@volter/supercode-orchestrator 0.4.111 → 0.4.113

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/cli.mjs CHANGED
@@ -10,6 +10,7 @@ import { basename, join, resolve } from 'node:path';
10
10
  import { tmpdir } from 'node:os';
11
11
  import { read } from '../orchestration.mjs';
12
12
  import { view, backingPaths, layOut, backingOf, boardExists, boardPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
13
+ import { isLocalWorkspace } from './workspace.mjs';
13
14
  import { openZtrackBoard } from './ztrack.mjs';
14
15
  import * as engine from './engine.mjs';
15
16
  import { adoptSession, boardAddress, boardLabel, boardSettings, callerSession, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, tick } from './dispatch.mjs';
@@ -22,7 +23,7 @@ Hermes Kanban's verbs, with their flags:
22
23
  init [--backing sqlite|files|ztrack] [--document <arcs.md>]
23
24
  migrate --backing ztrack --document <arcs.md> --status-map <json>
24
25
  boards list|create|rm|switch|show|rename|set-default-workdir|export|import
25
- create <title> [--body] [--assignee] [--parent]... [--workspace scratch|worktree[:<path>]|dir:<path>] [--branch] [--project]
26
+ create <title> [--body] [--assignee] [--parent]... [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch] [--project]
26
27
  [--tenant] [--priority] [--triage] [--idempotency-key] [--max-runtime 30m] [--created-by] [--skill]... [--max-retries]
27
28
  [--model] [--provider] [--goal|--no-goal] [--goal-max-turns] [--initial-status blocked|running]
28
29
  swarm <goal> --worker PROFILE:TITLE[:SKILL,SKILL]... --verifier P --synthesizer P
@@ -189,7 +190,7 @@ const ts = (t) => {
189
190
  const line = (t) => `${ICONS[t.status] ?? '?'} ${t.id} ${(t.stage ?? t.status).padEnd(8)} ${(t.assignee || '(unassigned)').padEnd(20)}${t.tenant ? ` [${t.tenant}]` : ''} ${t.title}${t.machine ? ` @${t.machine}` : ''}`;
190
191
  const cardDict = (t) => ({
191
192
  id: t.id, title: t.title, body: t.body, assignee: t.assignee, status: t.status, priority: t.priority, tenant: t.tenant,
192
- workspace_kind: t.workspace_kind, workspace_path: t.workspace_path, branch_name: t.branch_name, project_id: t.project_id,
193
+ workspace_kind: t.workspace_kind, repository: t.workspace_anchor ?? null, workspace_path: t.workspace_path, branch_name: t.branch_name, project_id: t.project_id,
193
194
  created_by: t.created_by, created_at: t.created_at, started_at: t.started_at, completed_at: t.completed_at, result: t.result,
194
195
  skills: t.skills ?? [], max_retries: t.max_retries, model_override: t.model_override, provider_override: t.provider_override,
195
196
  session_id: t.session_id, workflow_template_id: t.workflow_template_id, current_step_key: t.current_step_key,
@@ -392,7 +393,8 @@ export async function runWorkflow(argv) {
392
393
  out.push(` time: ${Object.entries(card.time).filter(([, v]) => v > 0).map(([k, v]) => `${k} ${mins(v)}`).join(', ') || '-'}`);
393
394
  if (t.subtask_of) out.push(` subtask of: ${t.subtask_of}`);
394
395
  if (t.tenant) out.push(` tenant: ${t.tenant}`);
395
- out.push(` workspace: ${t.workspace_kind}${t.workspace_path ? ` @ ${t.workspace_path}` : ''}`);
396
+ out.push(` workspace: ${t.workspace_kind}${t.workspace_anchor ? `:${t.workspace_anchor}` : ''}`);
397
+ if (t.workspace_path) out.push(` resolved: ${t.workspace_path}`);
396
398
  if (t.branch_name) out.push(` branch: ${t.branch_name}`);
397
399
  if (t.skills?.length) out.push(` skills: ${t.skills.join(', ')}`);
398
400
  if (t.model_override) out.push(` model: ${t.model_override}${t.provider_override ? ` (provider: ${t.provider_override})` : ''}`);
@@ -611,8 +613,8 @@ export async function runWorkflow(argv) {
611
613
  if (!targets[0]) throw new Error(`${verb} needs a card id, or --all`);
612
614
  const results = [];
613
615
  for (const t of targets) {
614
- if (verb === 'specify' && (args.title !== undefined || args.body !== undefined)) {
615
- const moved = await withBoardAsync((b) => engine.specify(b, t, { title: args.title ?? null, body: args.body ?? null, author: args.author ?? callerAddress() ?? callerName() }));
616
+ if (verb === 'specify' && (args.title !== undefined || args.body !== undefined || args.workspace !== undefined)) {
617
+ const moved = await withBoardAsync((b) => engine.specify(b, t, { title: args.title ?? null, body: args.body ?? null, workspace: args.workspace ?? null, branch: args.branch ?? null, author: args.author ?? callerAddress() ?? callerName() }));
616
618
  results.push({ id: t, specified: true, status: moved.to });
617
619
  } else if (verb === 'decompose' && args.child?.length) {
618
620
  const children = args.child.map((c) => { const [title, assignee] = c.split(/@(?=[^@]*$)/); return { title: title.trim(), assignee: assignee?.trim() || undefined }; });
@@ -739,8 +741,9 @@ export async function runWorkflow(argv) {
739
741
  const logDays = Number(args['log-retention-days'] ?? 30);
740
742
  let removedWs = 0;
741
743
  const scratchRoot = slug === DEFAULT_BOARD ? join(root, 'kanban', 'workspaces') : join(boardDir(root, slug), 'workspaces');
742
- const archived = withBoard((b) => b.db.prepare("SELECT id, workspace_kind, workspace_path FROM tasks WHERE status = 'archived'").all());
744
+ const archived = withBoard((b) => b.db.prepare("SELECT t.id,t.workspace_kind,t.workspace_path,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.status = 'archived'").all());
743
745
  for (const t of archived) {
746
+ if (!isLocalWorkspace(t)) continue;
744
747
  if (t.workspace_kind === 'worktree' && t.workspace_path && existsSync(t.workspace_path)) {
745
748
  // an archived card's worktree goes, as Hermes's gc removes it (the branch stays)
746
749
  const r = spawnSync('git', ['-C', t.workspace_path, 'worktree', 'remove', '--force', t.workspace_path], { encoding: 'utf8' });
@@ -24,6 +24,7 @@ import { randomUUID } from 'node:crypto';
24
24
  import { fileURLToPath } from 'node:url';
25
25
  import { view, boardPath, claimer, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
26
26
  import { doors, fire, loadWorkflow, resumeOf, runSpec, requestSessionClose, retireSupersededRuns, refreshRelationAnswers, transitionsFor, scopeOf, statusOf, templateValue, workflowFor } from './workflow.mjs';
27
+ import { checkWorkspace, assertWorkspaceOwner, resolveWorkspace } from './workspace.mjs';
27
28
  import { evaluate, holds, render } from './expr.mjs';
28
29
  import {
29
30
  cardContext, claimTtl, currentRun, DEFAULT_FAILURE_LIMIT, extendClaim, getTask, lastSession,
@@ -58,7 +59,7 @@ export const supercodeBin = () => process.env.SUPERCODE_BIN || 'supercode';
58
59
  // Installed board workers use their sibling Teams package, not a CLI build's retained checkout.
59
60
  // An explicit operator override remains authoritative for source development.
60
61
  let teamsEntry;
61
- function commandEnv(as = null) {
62
+ export function commandEnv(as = null) {
62
63
  if (teamsEntry === undefined) {
63
64
  try { teamsEntry = createRequire(import.meta.url).resolve('@volter/supercode-teams/bin'); }
64
65
  catch { teamsEntry = null; }
@@ -439,9 +440,12 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
439
440
  for (const l of limits) if (holds(l.counts, scope)) { const g = JSON.stringify(evaluate(l.group, scope)); l.tally.set(g, (l.tally.get(g) ?? 0) + 1); }
440
441
  continue;
441
442
  }
442
- let moved;
443
+ let moved, checked;
443
444
  try {
445
+ checked = checkWorkspace(board, task);
444
446
  moved = tx(board, (db) => {
447
+ assertWorkspaceOwner(db, task, checked);
448
+ if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.mainRoot, task.id);
445
449
  if (!task.assignee && plan.profile) {
446
450
  db.prepare('UPDATE tasks SET assignee = ? WHERE id = ?').run(plan.profile, task.id);
447
451
  event(db, task.id, 'assigned', { assignee: plan.profile, source: 'workflow role' });
@@ -473,7 +477,12 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
473
477
 
474
478
  /** Launch what the card's status runs: its role's lane, in the run's session slot, told the run's prompt and doors. */
475
479
  export function launchRun(board, entry, id, lane, fleet, settings) {
476
- const task = getTaskNow(board, id);
480
+ let task = getTaskNow(board, id);
481
+ const checked = checkWorkspace(board, task);
482
+ if (checked) tx(board, (db) => {
483
+ assertWorkspaceOwner(db, task, checked);
484
+ db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.mainRoot, id);
485
+ });
477
486
  const status = view(board, (db) => statusOf(db, task));
478
487
  const spec = runSpec(entry, status, task);
479
488
  if (!spec) return null;
@@ -502,7 +511,12 @@ export function launchRun(board, entry, id, lane, fleet, settings) {
502
511
  export async function startCard(board, id, { roles = ['manager'], lanes, settings } = {}) {
503
512
  const entry = await loadWorkflow(board.root);
504
513
  const fleet = observeFleet();
505
- const moved = tx(board, (db) => fire(db, board, entry, id, entry.workflow.dispatch.start, { roles, payload: { lock: claimer() } }));
514
+ const before = getTaskNow(board, id), checked = checkWorkspace(board, before);
515
+ const moved = tx(board, (db) => {
516
+ assertWorkspaceOwner(db, before, checked);
517
+ if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.mainRoot, id);
518
+ return fire(db, board, entry, id, entry.workflow.dispatch.start, { roles, payload: { lock: claimer() } });
519
+ });
506
520
  const task = getTaskNow(board, id);
507
521
  const spec = runSpec(entry, moved.to, task);
508
522
  const profile = spec ? view(board, (db) => evaluate(entry.workflow.roles?.[spec.role] ?? 'card.assignee', scopeOf(db, board, task, { params: entry.params }))) : null;
@@ -696,7 +710,7 @@ function launcherCheck(db, task, lane, fleet) {
696
710
  if (!fleet.online.has(machine)) return `machine ${machine} is offline; the card starts when it is back`;
697
711
  // a card on this machine gets its workspace made here (scratch, dir or worktree, as Hermes makes it); another
698
712
  // machine's card names its directory there
699
- if (machine !== localMachine() && (task.workspace_kind !== 'dir' || !task.workspace_path)) return `a pane session on ${machine} starts in a directory there: the card's workspace is dir:<absolute path>, not ${task.workspace_kind}`;
713
+ if (machine !== localMachine() && task.workspace_kind === 'scratch') return `a remote card names its repository with worktree:<repository> or dir:<repository>`;
700
714
  if (!HARNESSES[lane.harness]) return `the board opens pane sessions of ${Object.keys(HARNESSES).join(' and ')}; the lane's worker is ${lane.harness}`;
701
715
  return null;
702
716
  }
@@ -828,9 +842,11 @@ function reconcileLaunches(board, fleet, report, entry, effects, settings) {
828
842
 
829
843
  function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'worker', meta = {} } = {}) {
830
844
  const machine = normalMachine(task.machine);
831
- if (machine === localMachine() && !(task.workspace_kind === 'dir' && task.workspace_path && existsSync(task.workspace_path))) {
845
+ {
832
846
  const made = resolveLocalWorkspace(board, task);
833
- tx(board, (db) => db.prepare('UPDATE tasks SET workspace_path = ? WHERE id = ?').run(made, task.id));
847
+ tx(board, (db) => {
848
+ db.prepare('UPDATE tasks SET workspace_path=?, branch_name=COALESCE(branch_name,?) WHERE id=?').run(made, task.workspace_kind === 'worktree' ? `wt/${task.id}` : null, task.id);
849
+ });
834
850
  task = { ...task, workspace_path: made };
835
851
  }
836
852
  const model = task.model_override ?? lane.model;
@@ -994,6 +1010,7 @@ function paneSurvives(machine, pane) {
994
1010
  /** Hermes's own worker argv and task environment, run on this machine; watched by its pid. */
995
1011
  function launchHeadless(board, task, runId, lane, settings, { prompt = null, skills: runSkills = [], meta = {} } = {}) {
996
1012
  const workspace = resolveLocalWorkspace(board, task);
1013
+ tx(board, (db) => db.prepare('UPDATE tasks SET workspace_path=?,branch_name=COALESCE(branch_name,?) WHERE id=?').run(workspace, task.workspace_kind === 'worktree' ? `wt/${task.id}` : null, task.id));
997
1014
  // the card's own skills and the ones its status's run names (a reviewer's, for one)
998
1015
  const skills = [...new Set([...task.skills, ...runSkills])];
999
1016
  const args = ['-p', lane.name, '--cli', '--accept-hooks', ...skills.flatMap((s) => ['--skills', s]),
@@ -1041,20 +1058,7 @@ function rotateLog(file, max, backups) {
1041
1058
  /** A headless card's directory on this machine, made as Hermes makes it (scratch, dir, or a git worktree). */
1042
1059
  function resolveLocalWorkspace(board, task) {
1043
1060
  const root = board.root;
1044
- if (task.workspace_kind === 'dir') { mkdirSync(task.workspace_path, { recursive: true }); return task.workspace_path; }
1045
- if (task.workspace_kind === 'worktree') {
1046
- const anchor = task.workspace_path;
1047
- if (!anchor) throw new Error(`card ${task.id} is a worktree card with no repository (worktree:<path>)`);
1048
- const top = spawnSync('git', ['-C', anchor, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });
1049
- const repo = top.status === 0 ? top.stdout.trim() : null;
1050
- const target = repo && repo === anchor ? join(repo, '.worktrees', task.id) : anchor;
1051
- if (!existsSync(target)) {
1052
- const branch = task.branch_name || `wt/${task.id}`;
1053
- const made = spawnSync('git', ['-C', repo ?? dirname(anchor), 'worktree', 'add', '-B', branch, target], { encoding: 'utf8' });
1054
- if (made.status !== 0) throw new Error(`git worktree add ${target}: ${made.stderr.trim().slice(-300)}`);
1055
- }
1056
- return target;
1057
- }
1061
+ if (task.workspace_kind !== 'scratch') return resolveWorkspace(task, true).path;
1058
1062
  const dir = task.workspace_path || (board.slug === 'default' ? join(root, 'kanban', 'workspaces', task.id) : join(root, 'kanban', 'boards', board.slug, 'workspaces', task.id));
1059
1063
  mkdirSync(dir, { recursive: true });
1060
1064
  return dir;
@@ -1102,7 +1106,7 @@ export function adoptSession(board, id, who, { workspace = null, ttl, assignee =
1102
1106
  if (!task.machine) db.prepare('INSERT INTO supercode_cards (task_id, machine) VALUES (?, ?) ON CONFLICT(task_id) DO UPDATE SET machine = excluded.machine').run(id, host);
1103
1107
  // the lane that resumes it if it is lost, set in the same transaction (assigning first would let a tick start another)
1104
1108
  if (assignee && assignee !== task.assignee) { db.prepare('UPDATE tasks SET assignee = ? WHERE id = ?').run(assignee, id); event(db, id, 'assigned', { assignee }); }
1105
- if (dir && !(task.workspace_kind === 'dir' && task.workspace_path)) {
1109
+ if (dir && !task.workspace_anchor && !(task.workspace_kind === 'dir' && task.workspace_path)) {
1106
1110
  db.prepare("UPDATE tasks SET workspace_kind = 'dir', workspace_path = ? WHERE id = ?").run(dir.replace(/^dir:/, ''), id);
1107
1111
  }
1108
1112
  // the workflow's adopt event: the card is claimed for its implementer, whose session this now is
@@ -1124,7 +1128,7 @@ export function adoptSession(board, id, who, { workspace = null, ttl, assignee =
1124
1128
 
1125
1129
  // a notice sent within a transaction's caller that cannot await (the launcher): the same door, synchronously
1126
1130
  function deliverNow(board, to, body, key) {
1127
- const request = { op: 'send', from: boardAddress(board.label), from_name: `${board.label} board`, to, queue: false, id: key,
1131
+ const request = { op: 'send', from: boardAddress(board.label), from_name: `${board.label} board`, to, queue: false, id: key, subject: String(body).split('\n')[0].slice(0, 160),
1128
1132
  body: `[Board notice from the ${board.label} board] ${body}\n\n(An automated notice from the board's dispatcher; replies to this address go nowhere.)` };
1129
1133
  const r = sc(['message', 'remote-deliver'], { input: JSON.stringify(request), timeout: 120_000 });
1130
1134
  const answer = lastJson(r.stdout);
@@ -1133,7 +1137,7 @@ function deliverNow(board, to, body, key) {
1133
1137
 
1134
1138
  async function send(from, label, to, body, key, { queue = false, ask = false } = {}) {
1135
1139
  const request = {
1136
- op: 'send', from, from_name: `${label} board`, to, queue, id: key,
1140
+ op: 'send', from, from_name: `${label} board`, to, queue, id: key, subject: String(body).split('\n')[0].slice(0, 160),
1137
1141
  // a question the board waits on is answered by a reply; a notice is not
1138
1142
  body: ask ? `[A question from the ${label} board] ${body}` : `[Board notice from the ${label} board] ${body}\n\n(An automated notice from the board's dispatcher; replies to this address go nowhere.)`,
1139
1143
  };
package/board/engine.mjs CHANGED
@@ -10,6 +10,7 @@
10
10
  import { spawn, spawnSync } from 'node:child_process';
11
11
  import { existsSync, rmSync } from 'node:fs';
12
12
  import { join, resolve } from 'node:path';
13
+ import { checkWorkspace, assertWorkspaceOwner, declareWorkspace, isLocalWorkspace, isWorkspacePath } from './workspace.mjs';
13
14
  import { DatabaseSync } from 'node:sqlite';
14
15
  import { view, claimer, event, json, newTaskId, now, tx } from './store.mjs';
15
16
  import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, eventTo, fire, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
@@ -35,7 +36,7 @@ export function getTask(db, id) {
35
36
  const row = one(db, 'SELECT * FROM tasks WHERE id = ?', id);
36
37
  if (!row) return null;
37
38
  const ours = one(db, 'SELECT * FROM supercode_cards WHERE task_id = ?', id);
38
- return { ...row, 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 };
39
+ 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 };
39
40
  }
40
41
 
41
42
  function must(db, id) {
@@ -48,13 +49,12 @@ const parentsOf = (db, id) => all(db, 'SELECT t.id, t.status FROM tasks t JOIN t
48
49
  const childrenOf = (db, id) => all(db, 'SELECT child_id AS id FROM task_links WHERE parent_id = ?', id).map((r) => r.id);
49
50
  const parentsDone = (db, id) => parentsOf(db, id).every((p) => p.status === 'done' || p.status === 'archived');
50
51
 
51
- /** The workspace word Hermes's CLI takes: `scratch`, `worktree`, `worktree:<path>`, `dir:<path>`. */
52
+ /** The workspace word Hermes's CLI takes: `scratch`, `worktree:<repository>`, `dir:<repository>`. */
52
53
  export function parseWorkspace(word = 'scratch') {
53
54
  const [kind, ...rest] = String(word).split(':');
54
55
  const path = rest.join(':') || null;
55
- if (!WORKSPACE_KINDS.includes(kind)) throw new Error(`workspace is scratch | worktree | worktree:<path> | dir:<path>, not ${word}`);
56
- if (kind === 'dir' && !path) throw new Error('a dir workspace names its path (dir:<absolute path>)');
57
- if (path && !/^(\/|~|[A-Za-z]:[\\/])/.test(path)) throw new Error(`workspace path ${path} is not absolute`);
56
+ if (!WORKSPACE_KINDS.includes(kind)) throw new Error(`workspace is scratch | worktree:<repository> | dir:<repository>, not ${word}`);
57
+ if (kind === 'dir' && !path) throw new Error('a dir workspace names its repository (dir:volter-ai/editor)');
58
58
  return { kind, path };
59
59
  }
60
60
 
@@ -155,8 +155,20 @@ export function createCard(board, spec) {
155
155
  const title = String(spec.title ?? '').trim();
156
156
  if (!title) throw new Error('a card has a title');
157
157
  const ws = parseWorkspace(spec.workspace ?? 'scratch');
158
- if (spec.branch && ws.kind !== 'worktree') throw new Error('a branch is only for a worktree workspace');
158
+ if (spec.branch && ws.kind === 'scratch') throw new Error('a scratch workspace has no repository branch');
159
159
  const parents = [...new Set((spec.parents ?? []).filter(Boolean))];
160
+ const project = spec.project ? projectOf(board.root, spec.project) : null;
161
+ if (project) {
162
+ if (ws.kind === 'scratch') ws.kind = 'worktree';
163
+ if (ws.kind === 'worktree' && !ws.path) ws.path = project.primary_path;
164
+ }
165
+ const machineHint = spec.machine ?? (spec.subtaskOf ? getTask(board.db, spec.subtaskOf)?.machine : null);
166
+ const declared = declareWorkspace(ws.kind, ws.path, machineHint, spec.branch);
167
+ const workspaceTask = { workspace_kind: ws.kind, workspace_path: declared.path, workspace_anchor: declared.repository, branch_name: declared.branch, machine: machineHint };
168
+ // Logical repositories may be filed before a machine has registered them; launch refuses missing mappings.
169
+ const checked = ws.kind === 'dir' && declared.path ? checkWorkspace(board, workspaceTask) : null;
170
+ ws.path = declared.path;
171
+ spec = { ...spec, branch: declared.branch ?? spec.branch };
160
172
  const initial = spec.initialStatus ?? null;
161
173
  if (initial && !['blocked', 'running'].includes(initial)) throw new Error('initial status is blocked or running');
162
174
  if (spec.provider && !spec.model) throw new Error('a provider needs a model');
@@ -172,17 +184,16 @@ export function createCard(board, spec) {
172
184
  if (arc?.subtask_of) throw new Error(`${arc.id} is itself a subtask of ${arc.subtask_of}; a subtask belongs to an arc`);
173
185
  let id = newTaskId();
174
186
  while (one(db, 'SELECT 1 FROM tasks WHERE id = ?', id)) id = newTaskId();
175
- // a project (Hermes's projects registry, `projects.db` in the home) anchors the card's worktree in its repository
176
- const project = spec.project ? projectOf(board.root, spec.project) : null;
187
+ assertWorkspaceOwner(db, workspaceTask, checked);
188
+ // The repository declaration stays separate from the resolved worktree path.
177
189
  if (project) {
178
190
  if (ws.kind === 'scratch') ws.kind = 'worktree';
179
- if (ws.kind === 'worktree' && !ws.path && project.primary_path) ws.path = join(project.primary_path, '.worktrees', id);
180
191
  if (ws.kind === 'worktree' && !spec.branch) {
181
192
  const t = title.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/, '');
182
193
  spec = { ...spec, branch: `${project.slug}/${id}${t ? `-${t}` : ''}` };
183
194
  }
184
195
  }
185
- spec = { ...spec, project: project?.id ?? null };
196
+ spec = { ...spec, project: project?.id ?? null, branch: spec.branch ?? (ws.kind === 'worktree' ? `wt/${id}` : null) };
186
197
  const t = now();
187
198
  run(db, `INSERT INTO tasks (id, title, body, assignee, status, priority, created_by, created_at, workspace_kind, workspace_path, branch_name,
188
199
  tenant, idempotency_key, max_runtime_seconds, skills, max_retries, model_override, provider_override, reasoning_effort, session_id,
@@ -193,7 +204,7 @@ export function createCard(board, spec) {
193
204
  spec.skills?.length ? JSON.stringify(spec.skills) : null, spec.maxRetries ?? null, spec.model ?? null, spec.provider ?? null,
194
205
  spec.reasoning ?? null, spec.sessionId ?? null, spec.project ?? null, spec.goal ? 1 : 0, spec.goal?.maxTurns ?? null);
195
206
  const machine = spec.machine ?? arc?.machine ?? null;
196
- if (machine || arc) run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id, machine, subtask_of) VALUES (?, ?, ?)', id, machine, arc?.id ?? null);
207
+ 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);
197
208
  const entry = workflowFor(board.root);
198
209
  for (const p of parents) addDependency(db, entry, id, p);
199
210
  // where it starts is the workflow's `initial` (Hermes: triage, blocked, todo behind open parents, else ready)
@@ -236,7 +247,9 @@ function wouldCycle(db, parent, child) {
236
247
  export function setMachine(board, id, machine) {
237
248
  return tx(board, (db) => {
238
249
  const task = must(db, id);
239
- if (task.status === 'running' && task.claim_lock) throw new Error(`${id} is running on ${task.machine}; reclaim it before moving it`);
250
+ if (task.current_run_id) throw new Error(`${id} has an open run on ${task.machine}; end it before moving it`);
251
+ if (task.workspace_kind !== 'scratch' && (!task.workspace_anchor || isWorkspacePath(task.workspace_anchor))) throw new Error('declare the repository and branch with workflow specify --workspace before moving a legacy path card');
252
+ if (machine !== task.machine) run(db, 'UPDATE tasks SET workspace_path=NULL WHERE id=?', id);
240
253
  run(db, 'INSERT INTO supercode_cards (task_id, machine) VALUES (?, ?) ON CONFLICT(task_id) DO UPDATE SET machine = excluded.machine', id, machine || null);
241
254
  event(db, id, 'machine_set', { machine: machine || null, was: task.machine });
242
255
  });
@@ -292,7 +305,7 @@ export function extendClaim(db, id, { ttl, reason, extra = {} }) {
292
305
  */
293
306
  function cleanupWorkspace(board, id) {
294
307
  const task = view(board, (db) => getTask(db, id));
295
- if (!task?.workspace_path || !['scratch', 'worktree'].includes(task.workspace_kind)) return;
308
+ if (!task?.workspace_path || !isLocalWorkspace(task) || !['scratch', 'worktree'].includes(task.workspace_kind)) return;
296
309
  const waiting = view(board, (db) => one(db, "SELECT 1 FROM task_links l JOIN tasks t ON t.id = l.child_id WHERE l.parent_id = ? AND t.status NOT IN ('done', 'archived') LIMIT 1", id));
297
310
  if (waiting || !existsSync(task.workspace_path)) return;
298
311
  if (task.workspace_kind === 'worktree') { spawnSync('git', ['-C', task.workspace_path, 'worktree', 'remove', '--force', task.workspace_path], { encoding: 'utf8' }); return; }
@@ -446,7 +459,16 @@ export const schedule = (board, id, reason = null, { roles } = {}) => act(board,
446
459
  export const promote = (board, id, { actor = 'user', reason = null, force = false, roles } = {}) => act(board, id, 'promote', { roles, payload: { actor, reason, force } });
447
460
  export const reclaim = (board, id, reason = null, { roles } = {}) => act(board, id, 'reclaim', { roles, payload: { reason } });
448
461
  export const archive = (board, id, { roles } = {}) => act(board, id, 'archive', { roles });
449
- export const specify = (board, id, { title = null, body = null, author = 'user', roles } = {}) => act(board, id, 'specify', { roles, payload: { title, body, author } });
462
+ export function specify(board, id, { title = null, body = null, workspace = null, branch = null, author = 'user', roles } = {}) {
463
+ let declaration = null;
464
+ if (workspace != null) {
465
+ const task = getTask(board.db, id), ws = parseWorkspace(workspace);
466
+ if (task.current_run_id) throw new Error('end the current run before changing its workspace');
467
+ declaration = { kind: ws.kind, ...declareWorkspace(ws.kind, ws.path, task.machine, branch) };
468
+ if (declaration.kind === 'worktree') declaration.branch ??= `wt/${id}`;
469
+ }
470
+ return act(board, id, 'specify', { roles, payload: { title, body, author, ...(declaration ? { workspace: declaration } : {}) } });
471
+ }
450
472
  export const editResult = (board, id, { result, summary = null, metadata = undefined, roles } = {}) => act(board, id, 'edit', { roles, payload: { result, summary, metadata } });
451
473
  export const heartbeat = (board, id, note = null, { roles, until = null } = {}) => act(board, id, 'heartbeat', { roles, payload: { note, until } });
452
474
  export const setModel = (board, id, model = null, provider = null, { roles } = {}) => {
@@ -462,7 +484,7 @@ export async function decompose(board, id, children, { author = 'user', roles }
462
484
  const task = view(board, (db) => must(db, id));
463
485
  const ids = children.map((c) => createCard(board, {
464
486
  title: c.title, body: c.body ?? null, assignee: c.assignee ?? task.assignee, machine: task.machine,
465
- workspace: task.workspace_path ? `${task.workspace_kind}:${task.workspace_path}` : task.workspace_kind, tenant: task.tenant, createdBy: author,
487
+ workspace: task.workspace_anchor ? `${task.workspace_kind}:${task.workspace_anchor}` : task.workspace_path ? `${task.workspace_kind}:${task.workspace_path}` : task.workspace_kind, tenant: task.tenant, createdBy: author,
466
488
  }).id);
467
489
  await act(board, id, 'decompose', { roles, payload: { children: ids, author } });
468
490
  return ids;
@@ -564,7 +586,7 @@ const cap = (text, n) => (text && text.length > n ? `${text.slice(0, n)}… (${t
564
586
  export function cardContext(db, id) {
565
587
  const task = must(db, id);
566
588
  const out = [`# Card ${task.id}: ${task.title}`, ''];
567
- out.push(`Status: ${task.status}. Machine: ${task.machine ?? 'unset'}. Workspace: ${task.workspace_kind}${task.workspace_path ? ` ${task.workspace_path}` : ''}.${task.subtask_of ? ` A subtask of ${task.subtask_of}.` : ''}`);
589
+ 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}.` : ''}`);
568
590
  if (task.body) out.push('', cap(task.body, 8 * 1024));
569
591
  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);
570
592
  if (parents.length) {
package/board/rows.mjs CHANGED
@@ -13,7 +13,7 @@ export class RuntimeRows {
13
13
  const columns=mem.prepare(`PRAGMA table_info(${quote(name)})`).all();
14
14
  const keys=columns.filter(c=>c.pk).sort((a,b)=>a.pk-b.pk).map(c=>c.name);
15
15
  if (!keys.length) keys.push('rowid');
16
- this.tables.set(name,{name,sql,keys});
16
+ this.tables.set(name,{name,sql,keys,columns});
17
17
  const key=prefix=>`json_array(${keys.map(k=>`${prefix}.${quote(k)}`).join(',')})`;
18
18
  for(const [suffix,verb,prefix] of [['i','INSERT','NEW'],['u','UPDATE','NEW'],['d','DELETE','OLD']])
19
19
  mem.exec(`CREATE TEMP TRIGGER IF NOT EXISTS ${quote(`_runtime_${name}_${suffix}`)} AFTER ${verb} ON main.${quote(name)} BEGIN INSERT OR IGNORE INTO _runtime_dirty VALUES ('${name}',${key(prefix)}); END`);
@@ -53,7 +53,7 @@ export class RuntimeRows {
53
53
  const table=this.tables.get(name);
54
54
  const row=mem.prepare(`SELECT * FROM ${quote(name)} WHERE ${table.keys.map(k=>`${quote(k)} IS ?`).join(' AND ')}`).get(...JSON.parse(key))??null;
55
55
  if(name==='tasks')return {name:'@runtime',key:JSON.parse(key)[0],value:row&&Object.fromEntries(Object.entries(row).filter(([k])=>RUNTIME.has(k))),source:{name,key,row}};
56
- if(name==='supercode_cards')return {name:'@clock',key:JSON.parse(key)[0],value:row&&{since:row.since,after_fired:row.after_fired,next_run_at:row.next_run_at},source:{name,key,row}};
56
+ if(name==='supercode_cards')return {name:'@clock',key:JSON.parse(key)[0],value:row&&{workspace_root:row.workspace_root,since:row.since,after_fired:row.after_fired,next_run_at:row.next_run_at},source:{name,key,row}};
57
57
  return {name,key,value:row,source:{name,key,row}};
58
58
  }).filter(change=>{
59
59
  if(all)return true;
@@ -78,6 +78,9 @@ export class RuntimeRows {
78
78
  for(const name of ['tasks','supercode_cards']) {
79
79
  const table=this.tables.get(name); if(!table)return;
80
80
  this.db.exec(table.sql.replace(/^CREATE TABLE (?:IF NOT EXISTS )?(?:"[^"]+"|\w+)/,`CREATE TABLE IF NOT EXISTS ${quote('read_'+name)}`));
81
+ const columns=this.db.prepare(`PRAGMA table_info(${quote('read_'+name)})`).all();
82
+ for(const c of table.columns) if(!columns.some(old=>old.name===c.name))this.db.exec(`ALTER TABLE ${quote('read_'+name)} ADD COLUMN ${quote(c.name)} ${c.type}${c.dflt_value==null?'':` DEFAULT ${c.dflt_value}`}`);
83
+
81
84
  }
82
85
  const rows=previous===revision-1?changes.filter(c=>['tasks','supercode_cards'].includes(c.source.name)).map(c=>c.source):this.db.prepare("SELECT name,key,value FROM runtime_rows WHERE name IN ('@task_cache','@card_cache')").all().map(r=>({name:r.name==='@task_cache'?'tasks':'supercode_cards',key:JSON.stringify([r.key]),row:json(r.value,null)}));
83
86
  for(const {name,key,row} of rows) {
package/board/store.mjs CHANGED
@@ -72,7 +72,7 @@ const OURS = `CREATE TABLE IF NOT EXISTS supercode_cards (task_id TEXT PRIMARY K
72
72
  CREATE TABLE IF NOT EXISTS supercode_effects (id INTEGER PRIMARY KEY AUTOINCREMENT, effect TEXT NOT NULL);`;
73
73
  // `stage`: the card's workflow status where kanban.db stores another (a `reviewing` card is `running` to Hermes)
74
74
  // `since`: when the card entered its workflow status; `after_fired`: which of the status's `after` moves have run since
75
- const OURS_COLUMNS = { 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' };
75
+ 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' };
76
76
 
77
77
  // ------------------------------------------------------------------ backings
78
78
  //
@@ -344,7 +344,7 @@ export function fire(db, board, entry, id, name, { roles = [], payload = {}, ext
344
344
  // `sender`: the roles the event was sent with (a validator can ask who is asking)
345
345
  const scope = scopeOf(db, board, task, { params, event: payload, extra: { lanes: lanesIn(entry), ...extra, sender: roles } });
346
346
  const allowed = candidates.filter((t) => !t.by?.length || t.by.some((r) => roles.includes(r)));
347
- if (!allowed.length) throw new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} there is for ${[...new Set(candidates.flatMap((t) => t.by ?? []))].join(' or ')}`);
347
+ if (!allowed.length) throw new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} there is for ${[...new Set(candidates.flatMap((t) => t.by ?? []))].join(' or ')}. ${candidates.map((t) => t.description).filter(Boolean).join(' ')}`);
348
348
  const taken = allowed.find((t) => holds(t.guard ?? null, scope));
349
349
  if (!taken) { const none = new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} does not apply to it now`); none.noTransition = true; throw none; }
350
350
  for (const v of taken.validate ?? []) {
@@ -557,7 +557,8 @@ const ACTIONS = {
557
557
  if (!['paused', 'archived'].includes(name)) throw new Error(`unknown flag ${name}`);
558
558
  if (name === 'paused') {
559
559
  const was = json(one(ctx.db, 'SELECT paused FROM supercode_cards WHERE task_id = ?', ctx.id)?.paused, null);
560
- if (!value && was && was.by !== actorOf() && !ctx.scope.sender?.includes('owner') && !(ctx.scope.sender?.includes('hermes') && ctx.scope.event?.hermes)) throw new Error('only the pauser or owner may resume this card');
560
+ const clearBy = ctx.entry.workflow.flags?.paused?.clear_by ?? ['pauser', 'owner'];
561
+ if (!value && was && !clearBy.some((role) => role === 'pauser' ? was.by === actorOf() : ctx.scope.sender?.includes(role))) throw new Error(`only ${clearBy.join(' or ')} may resume this card; ask the manager with your reason`);
561
562
  if (value && !String(reason ?? '').trim()) throw new Error('pause needs a reason');
562
563
  cardField(ctx, name, value ? JSON.stringify({ by: actorOf(), reason }) : null);
563
564
  } else cardField(ctx, name, Number(Boolean(value)));
@@ -682,6 +683,13 @@ const ACTIONS = {
682
683
  run(ctx.db, 'DELETE FROM task_links WHERE parent_id = ? AND child_id = ?', parent, ctx.id);
683
684
  },
684
685
  specify(ctx, said) {
686
+ if (said?.workspace) {
687
+ const task = cardOf(ctx.db, ctx.id), ws = said.workspace;
688
+ if (task.current_run_id) throw new Error('end the current run before changing its workspace');
689
+ run(ctx.db, 'UPDATE tasks SET workspace_kind=?,workspace_path=?,branch_name=? WHERE id=?', ws.kind, ws.path ?? null, ws.branch ?? null, ctx.id);
690
+ cardField(ctx, 'workspace_anchor', ws.repository ?? null);
691
+ cardField(ctx, 'workspace_root', null);
692
+ }
685
693
  if (said?.title && String(said.title).trim()) run(ctx.db, 'UPDATE tasks SET title = ? WHERE id = ?', String(said.title).trim(), ctx.id);
686
694
  if (said?.body !== undefined && said?.body !== null) run(ctx.db, 'UPDATE tasks SET body = ? WHERE id = ?', said.body, ctx.id);
687
695
  },
@@ -0,0 +1,62 @@
1
+ // Filesystem/RPC work happens before a board transaction; the ownership check is pure.
2
+ import { spawnSync } from 'node:child_process';
3
+ import { Workspaces, inspectCheckout, repositoryId, teamsHome } from '@volter/supercode-teams/workspaces';
4
+ import { localMachine, normalMachine, supercodeBin, commandEnv } from './dispatch.mjs';
5
+ export const isWorkspacePath = value => /^(\/|~|[A-Za-z]:[\\/])/.test(value ?? '');
6
+ function workspaceDoor(machine, verb, value, options = {}) {
7
+ if (!machine || normalMachine(machine) === localMachine()) {
8
+ const map = new Workspaces(teamsHome());
9
+ if (verb === 'inspect') return inspectCheckout(value);
10
+ if (verb === 'register') return map.register(value);
11
+ return map.resolve(value, options.branch, options);
12
+ }
13
+ const args = ['teams', 'workspace', verb, value, '--machine', machine];
14
+ for (const key of ['branch', 'card']) if (options[key]) args.push(`--${key}`, options[key]);
15
+ for (const key of ['solo', 'create']) if (options[key]) args.push(`--${key}`);
16
+ const r = spawnSync(supercodeBin(), args, { env: commandEnv(), encoding: 'utf8', timeout: 60_000 });
17
+ if (r.status !== 0) throw new Error(`workspace on ${machine}: ${(r.stderr || r.stdout || r.error?.message).trim()}`);
18
+ return JSON.parse(r.stdout);
19
+ }
20
+ export function declareWorkspace(kind, value, machine, branch = null) {
21
+ if (kind === 'scratch') return { repository: null, branch: null, path: null };
22
+ if (!value) throw new Error('workspace names a repository, for example worktree:volter-ai/editor');
23
+ if (!isWorkspacePath(value)) return { repository: repositoryId(value), branch, path: null };
24
+ // A legacy path supplied explicitly is checked and registered on its machine, never copied into the card.
25
+ const info = workspaceDoor(machine, 'inspect', value);
26
+ if (kind === 'worktree' && info.linked) throw new Error('worktree workspace already names a linked worktree; name its repository for a solo worktree');
27
+ const row = workspaceDoor(machine, 'register', value);
28
+ return { repository: row.repository, branch: branch ?? (kind === 'dir' ? row.branch : null), path: kind === 'dir' ? row.path : null };
29
+ }
30
+ export function resolveWorkspace(task, create = false) {
31
+ const declared = task.workspace_anchor ?? task.workspace_path;
32
+ if (isWorkspacePath(declared)) {
33
+ const info = workspaceDoor(task.machine, 'inspect', declared);
34
+ if (task.workspace_kind === 'dir') return info;
35
+ if (info.linked) throw new Error('worktree declaration is already a linked worktree; name its repository instead');
36
+ throw new Error('legacy worktree path needs registration and a repository declaration before launch');
37
+ }
38
+ return workspaceDoor(task.machine, 'resolve', declared, { branch: task.branch_name, solo: task.workspace_kind === 'worktree', card: task.id, create });
39
+ }
40
+ export function checkWorkspace(board, task) {
41
+ if (task.workspace_kind === 'scratch') return null;
42
+ const facts = resolveWorkspace(task), peers = [];
43
+ if (task.workspace_kind === 'dir' && !facts.linked) {
44
+ 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()) {
45
+ if (row.id === task.id || normalMachine(row.machine ?? localMachine()) !== normalMachine(task.machine ?? localMachine())) continue;
46
+ let other;
47
+ try { other = resolveWorkspace({ ...row, workspace_kind: 'dir' }); } catch { continue; } // That card's own launch reports its invalid workspace.
48
+ if (!other.linked && other.root === facts.root) peers.push(row.id);
49
+ }
50
+ }
51
+ const checked = { ...facts, mainRoot: task.workspace_kind === 'dir' && !facts.linked ? facts.root : null, peers };
52
+ assertWorkspaceOwner(board.db, task, checked);
53
+ return checked;
54
+ }
55
+ export function assertWorkspaceOwner(db, task, checked) {
56
+ if (!checked?.mainRoot) return;
57
+ const conflict = db.prepare(`SELECT t.id,c.machine,c.workspace_root 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()
58
+ .find(row => row.id !== task.id && normalMachine(row.machine ?? localMachine()) === normalMachine(task.machine ?? localMachine()) && (row.workspace_root === checked.mainRoot || checked.peers?.includes(row.id)));
59
+ if (conflict) throw new Error(`main checkout is already used by running card ${conflict.id}; declare worktree:${checked.repository} for a solo worktree`);
60
+ }
61
+
62
+ export const isLocalWorkspace = task => !task.machine || normalMachine(task.machine) === localMachine();
package/board/ztrack.mjs CHANGED
@@ -130,10 +130,10 @@ class ZtrackBoard {
130
130
  const documentChanges = changes.filter(({source})=>{
131
131
  if(source.name==='tasks') {
132
132
  const before=this.rows.cachedTasks[JSON.parse(source.key)[0]];
133
- if(source.row?.workspace_kind!=='scratch' && source.row?.workspace_path!==before?.workspace_path)return true;
133
+ if(source.row?.workspace_kind==='dir' && !this.rows.cachedCards[JSON.parse(source.key)[0]]?.workspace_anchor && source.row?.workspace_path!==before?.workspace_path)return true;
134
134
  return JSON.stringify(canonical(source.row,new Set([...RUNTIME,'status'])))!==JSON.stringify(canonical(before,new Set([...RUNTIME,'status'])));
135
135
  }
136
- if(source.name==='supercode_cards')return JSON.stringify(canonical(source.row,new Set(['since','after_fired','next_run_at'])))!==JSON.stringify(canonical(this.rows.cachedCards[JSON.parse(source.key)[0]],new Set(['since','after_fired','next_run_at'])));
136
+ if(source.name==='supercode_cards')return JSON.stringify(canonical(source.row,new Set(['since','after_fired','next_run_at','workspace_root'])))!==JSON.stringify(canonical(this.rows.cachedCards[JSON.parse(source.key)[0]],new Set(['since','after_fired','next_run_at','workspace_root'])));
137
137
  return false;
138
138
  });
139
139
  this.publishedFingerprints = {};
@@ -242,7 +242,11 @@ class ZtrackBoard {
242
242
  this.mem.exec(`CREATE TEMP VIEW tasks AS SELECT ${fields.join(',')} FROM source.read_tasks t LEFT JOIN source.read_supercode_cards c ON c.task_id=t.id`);
243
243
  continue;
244
244
  }
245
- if(native && name==='supercode_cards') {this.mem.exec('CREATE TEMP VIEW supercode_cards AS SELECT * FROM source.read_supercode_cards');continue;}
245
+ if(native && name==='supercode_cards') {
246
+ const have=new Set(this.mem.prepare('PRAGMA source.table_info(read_supercode_cards)').all().map(c=>c.name));
247
+ const fields=this.mem.prepare('PRAGMA main.table_info(supercode_cards)').all().map(c=>have.has(c.name)?quote(c.name):`${c.dflt_value??'NULL'} AS ${quote(c.name)}`);
248
+ this.mem.exec(`CREATE TEMP VIEW supercode_cards AS SELECT ${fields.join(',')} FROM source.read_supercode_cards`);continue;
249
+ }
246
250
  if(native && name==='task_links') {this.mem.exec("CREATE TEMP VIEW task_links AS SELECT json_extract(b.value,'$.id') parent_id,r.task_id child_id FROM source.read_supercode_cards r,json_each(r.blocked_by) b WHERE json_extract(b.value,'$.kind')='card'");continue;}
247
251
  if(name==='task_links') {
248
252
  this.mem.exec(`CREATE TEMP VIEW task_links AS SELECT json_extract(b.value,'$.id') parent_id,r.key child_id FROM source.runtime_rows r,json_each(json_extract(r.value,'$.blocked_by')) b WHERE r.name='@card_cache' AND json_extract(b.value,'$.kind')='card'`);
@@ -298,7 +302,7 @@ class ZtrackBoard {
298
302
  const keys = Object.keys(row);
299
303
  if (keys.length) this.mem.prepare(`UPDATE tasks SET ${keys.map(k => `${quote(k)}=?`).join(',')} WHERE id=?`).run(...keys.map(k => row[k]), id);
300
304
  }
301
- for (const [id, row] of Object.entries(this.saved.clocks ?? {})) this.mem.prepare('UPDATE supercode_cards SET since=?,after_fired=?,next_run_at=? WHERE task_id=?').run(row.since??null,row.after_fired??null,row.next_run_at??null,id);
305
+ for (const [id, row] of Object.entries(this.saved.clocks ?? {})) this.mem.prepare('UPDATE supercode_cards SET since=?,after_fired=?,next_run_at=?,workspace_root=? WHERE task_id=?').run(row.since??null,row.after_fired??null,row.next_run_at??null,row.workspace_root??null,id);
302
306
  this.rows.watch(this.mem);
303
307
  if (observe) this.documentEvents();
304
308
  return;
@@ -381,8 +385,8 @@ class ZtrackBoard {
381
385
  const workspace = /^(scratch|dir|worktree)(?::(.*))?$/.exec(card.workspace ?? 'scratch');
382
386
  const task = { ...meta, ...this.saved.runtime?.[id], id, title: card.title, body: stored.body,
383
387
  status: card.status, assignee: card.assignee ?? null, created_at: meta.created_at ?? now(), priority: card.priority ?? 0,
384
- workspace_kind: workspace?.[1] ?? 'scratch', workspace_path: workspace?.[1] === 'scratch' ? (this.saved.runtime?.[id]?.workspace_path ?? meta.workspace_path ?? null) : workspace?.[2] ?? null, branch_name: card.branch ?? null };
385
- const state = { ...this.saved.clocks?.[id], task_id: id, stage: card.status, machine: card.machine ?? null, subtask_of: parent,
388
+ workspace_kind: workspace?.[1] ?? 'scratch', workspace_path: this.saved.runtime?.[id]?.workspace_path ?? (/^(\/|~|[A-Za-z]:[\\/])/.test(workspace?.[2] ?? '') ? workspace[2] : null), branch_name: card.branch ?? null };
389
+ const state = { ...this.saved.clocks?.[id], task_id: id, workspace_anchor: workspace?.[2] ?? null, stage: card.status, machine: card.machine ?? null, subtask_of: parent,
386
390
  blocked_by: JSON.stringify((card.relations ?? []).map((r) => ({ ...(meta.blocked_by ?? []).find((b) => b.id === nativeId(r.issueId)), id: nativeId(r.issueId), kind: /^[ma]-/.test(r.issueId) ? 'message' : 'card' }))),
387
391
  paused: card.paused ? JSON.stringify(card.paused) : null, archived: meta.archived ? 1 : 0, every: card.every ?? null };
388
392
  return { task, state };
@@ -438,7 +442,7 @@ class ZtrackBoard {
438
442
  const metadata = Object.fromEntries(Object.entries(row).filter(([k, v]) => !RUNTIME.has(k) && !CARD_MAIN.has(k) && v != null));
439
443
  if (c.archived) metadata.archived = true;
440
444
  if (json(c.blocked_by, []).length) metadata.blocked_by = json(c.blocked_by, []);
441
- cards.set(row.id, { ...parsed, metadata, priority: row.priority ?? 0, workspace: row.workspace_kind + (row.workspace_kind !== 'scratch' && row.workspace_path ? `:${row.workspace_path}` : ''),
445
+ cards.set(row.id, { ...parsed, metadata, priority: row.priority ?? 0, workspace: row.workspace_kind + (row.workspace_kind !== 'scratch' && (c.workspace_anchor ?? row.workspace_path) ? `:${c.workspace_anchor ?? row.workspace_path}` : ''),
442
446
  branch: row.branch_name || undefined, machine: c.machine || undefined, paused: json(c.paused, null) ?? undefined, every: c.every || undefined,
443
447
  relations: json(c.blocked_by, []).map((b) => ({ type: 'blocked-by', issueId: this.fileIds.get(b.id) ?? b.id })), acceptanceCriteria: [] });
444
448
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.4.111",
3
+ "version": "0.4.113",
4
4
  "type": "module",
5
5
  "description": "The orchestrator runtime over the Volter Harness ontology: one typed operational model whose folder is its serialization, read and written through the harness orchestration doors (docs/ORCHESTRATOR-IR.md)",
6
6
  "exports": {
@@ -26,7 +26,8 @@
26
26
  "dependencies": {
27
27
  "@volter/supercode-harness-sdk": "^0.3.41",
28
28
  "@volter/ztrack": "^2.4.16",
29
- "yaml": "^2.9.0"
29
+ "yaml": "^2.9.0",
30
+ "@volter/supercode-teams": "^0.2.120"
30
31
  },
31
32
  "devDependencies": {
32
33
  "@chat-adapter/state-memory": "4.39.0",