@volter/supercode-orchestrator 0.4.145 → 0.4.147

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/ci.mjs CHANGED
@@ -21,8 +21,24 @@ export function completionEvidence(board, id) {
21
21
  }
22
22
 
23
23
  export function observeCompletionEvidence(board, id, entry, payload = {}) {
24
- const state = { required: true, ready: false, reason: null };
24
+ const state = inspectCompletionEvidence(board, id, entry, payload);
25
25
  observations.set(key(board, id), state);
26
+ return state;
27
+ }
28
+
29
+ export function withCompletionEvidence(board, id, state, fn) {
30
+ const identity = key(board, id);
31
+ const previous = observations.get(identity);
32
+ observations.set(identity, state);
33
+ try { return fn(); }
34
+ finally {
35
+ if (previous) observations.set(identity, previous);
36
+ else observations.delete(identity);
37
+ }
38
+ }
39
+
40
+ export function inspectCompletionEvidence(board, id, entry, payload = {}) {
41
+ const state = { required: true, ready: false, reason: null };
26
42
  if (!entry.params.completion_ci) { Object.assign(state, { required: false, ready: true }); return state; }
27
43
  try {
28
44
  const task = board.db.prepare('SELECT t.*,c.subtask_of,c.workspace_anchor FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(id);
package/board/cli.mjs CHANGED
@@ -15,7 +15,7 @@ import { openZtrackBoard } from './ztrack.mjs';
15
15
  import * as engine from './engine.mjs';
16
16
  import { adoptSession, boardAddress, boardLabel, boardSettings, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, supercodeBin, tick } from './dispatch.mjs';
17
17
  import { watchBoard } from './wake.mjs';
18
- import { completionOwnerOf, loadWorkflow, requestSessionClose, scopeOf, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
18
+ import { completionOwnerOf, isLatestReviewer, loadWorkflow, requestSessionClose, scopeOf, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
19
19
  import { evaluate } from './expr.mjs';
20
20
 
21
21
  const HELP = `supercode workflow <verb> [--root <home>] [--board <slug>] [--json]
@@ -47,7 +47,7 @@ Hermes Kanban's verbs, with their flags:
47
47
  supercode's own:
48
48
  init --backing sqlite|files · boards create <slug> --backing sqlite|files
49
49
  Where the board is kept: sqlite is Hermes's kanban.db (the default); files is one JSON file per card.
50
- create … --machine M --subtask-of ARC --notify ADDRESS|--no-notify --no-start
50
+ create … --machine M --subtask-of ARC --notify ADDRESS|--no-notify --no-start [--review-only]
51
51
  With a pane lane and room, a card starts at once and answers "started" with its session; else it answers why it
52
52
  waits, and its creator is told when it starts. A subtask is worked by its arc's session and never starts alone.
53
53
  move <id> <machine|none> adopt <id> <session> [--assignee P] [--role ROLE] close <id> serve (= daemon) list --machine M
@@ -58,6 +58,7 @@ supercode's own:
58
58
  create ... --adopt <session> hold dispatch until that live session is attached
59
59
  goto <id> <status> [reason] [--<field> value]: the event the workflow says takes the card to that status, sent.
60
60
 
61
+ specify <id> --review-only|--no-review-only: a manager declares a standalone independent review job.
61
62
  specify/decompose with no spec start a session on the triage card to write it. A card's own session may comment on,
62
63
  heartbeat and request review of its card, specify or decompose it in triage, and work its subtasks. The home's
63
64
  workflow declares who may block, pause or finish a card; request-state mails its manager without changing state.
@@ -65,7 +66,7 @@ supercode's own:
65
66
 
66
67
  const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm']);
67
68
  const FLAGS = new Set(['json', 'triage', 'no-notify', 'no-start', 'all', 'force', 'help', 'mine', 'archived', 'reclaim', 'dry-run',
68
- 'goal', 'no-goal', 'off', 'switch', 'delete', 'no-attachments', 'include-logs', 'verbose']);
69
+ 'goal', 'no-goal', 'review-only', 'no-review-only', 'off', 'switch', 'delete', 'no-attachments', 'include-logs', 'verbose']);
69
70
  // flags that take every following word up to the next flag (Hermes's nargs='+')
70
71
  const GREEDY = new Set(['ids', 'rm']);
71
72
 
@@ -198,7 +199,7 @@ const cardDict = (t) => ({
198
199
  created_by: t.created_by, created_at: t.created_at, started_at: t.started_at, completed_at: t.completed_at, result: t.result,
199
200
  skills: t.skills ?? [], max_retries: t.max_retries, model_override: t.model_override, provider_override: t.provider_override,
200
201
  session_id: t.session_id, workflow_template_id: t.workflow_template_id, current_step_key: t.current_step_key,
201
- machine: t.machine ?? null, subtask_of: t.subtask_of ?? null,
202
+ machine: t.machine ?? null, subtask_of: t.subtask_of ?? null, review_only: Boolean(t.review_only),
202
203
  });
203
204
 
204
205
  /** Hermes's durations: seconds, or 30s / 5m / 2h / 1d. */
@@ -230,6 +231,7 @@ const metadataOf = (v) => {
230
231
  export async function runWorkflow(argv) {
231
232
  const args = parse(argv);
232
233
  const [verb, ...rest] = args._;
234
+ if (args['review-only'] && args['no-review-only']) throw new Error('Choose review-only or no-review-only, not both.');
233
235
  if (!verb || verb === 'help' || args.help) { process.stdout.write(HELP); return 0; }
234
236
  const root = homeOf(args);
235
237
  const slug = boardOf(root, args);
@@ -251,7 +253,11 @@ export async function runWorkflow(argv) {
251
253
  }
252
254
  // the board's workflow (the IR's): what each verb does, and who may send it, is its
253
255
  // every verb reads the board through its workflow (a list settles cards too), so every verb loads it
254
- engine.setCaller((db, cardId) => rolesFor(db, cardId, sid, workflowFor(root).params));
256
+ engine.setCaller((db, cardId) => {
257
+ const roles = rolesFor(db, cardId, sid, workflowFor(root).params);
258
+ if (verb === 'comment' && isLatestReviewer(db, cardId, actorOf()) && !roles.includes('reviewer')) roles.push('reviewer');
259
+ return roles;
260
+ });
255
261
  // a verb with no session behind it (a person at the CLI, the board file's sync) is the manager's, as its roles are
256
262
  if (!sid && verb !== 'help') { try { setActor([].concat(workflowFor(root).params?.managers ?? [])[0] ?? null); } catch { /* no workflow */ } }
257
263
 
@@ -296,7 +302,7 @@ export async function runWorkflow(argv) {
296
302
  workspace, branch: args.branch, parents: args.parent, priority: args.priority, tenant: args.tenant,
297
303
  triage: args.triage, idempotencyKey: args['idempotency-key'], maxRuntime: duration(args['max-runtime']),
298
304
  maxRetries, skills: args.skill, model: args.model, provider: args.provider, createdBy: args['created-by'] ?? callerName(),
299
- noStart: Boolean(args['no-start']), adopt: args.adopt ?? null,
305
+ noStart: Boolean(args['no-start']), adopt: args.adopt ?? null, reviewOnly: Boolean(args['review-only']),
300
306
  every: args.every ?? null, sessionId: callerSession(), subtaskOf: args['subtask-of'] ?? null, initialStatus: args['initial-status'] ?? null,
301
307
  project: args.project ?? meta.project_id ?? null, goal: args.goal ? { maxTurns: args['goal-max-turns'] ? Number(args['goal-max-turns']) : null } : args['no-goal'] ? false : null,
302
308
  }), true);
@@ -477,7 +483,7 @@ export async function runWorkflow(argv) {
477
483
  for (const t of ids()) await withBoardAsync((b) => engine.setGoal(b, t, { on: !args.off, maxTurns }));
478
484
  say(args, ids().map((t) => args.off ? `${t} is no longer a goal card` : `${t} is a goal card${maxTurns ? ` (${maxTurns} turns)` : ''}`).join('\n'), { ok: true }); return 0;
479
485
  }
480
- case 'move': need(2, 'a card id and a machine (or none)'); withBoard((b) => engine.setMachine(b, id, rest[1] === 'none' ? null : rest[1])); say(args, `${id} runs on ${rest[1]}`, { ok: true }); return 0;
486
+ case 'move': need(2, 'a card id and a machine (or none)'); await withBoardAsync((b) => engine.setMachine(b, id, rest[1] === 'none' ? null : rest[1])); say(args, `${id} runs on ${rest[1]}`, { ok: true }); return 0;
481
487
 
482
488
  case 'claim': {
483
489
  need(1, 'a card id');
@@ -505,7 +511,7 @@ export async function runWorkflow(argv) {
505
511
  need(2, 'a card id and the text');
506
512
  let text = rest.slice(1).join(' ');
507
513
  if (args['max-len']) text = text.slice(0, Number(args['max-len']));
508
- const n = await withBoardAsync((b) => engine.comment(b, id, args.author ?? callerAddress() ?? callerName(), text));
514
+ const n = await withBoardAsync((b) => engine.comment(b, id, isLatestReviewer(b.db, id, actorOf()) ? actorOf() : args.author ?? callerAddress() ?? callerName(), text));
509
515
  say(args, `Comment added to ${id}`, { ok: true, comment: n }); return 0;
510
516
  }
511
517
  case 'attach': {
@@ -623,8 +629,8 @@ export async function runWorkflow(argv) {
623
629
  if (!targets[0]) throw new Error(`${verb} needs a card id, or --all`);
624
630
  const results = [];
625
631
  for (const t of targets) {
626
- if (verb === 'specify' && (args.title !== undefined || args.body !== undefined || args.workspace !== undefined)) {
627
- 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() }));
632
+ if (verb === 'specify' && (args.title !== undefined || args.body !== undefined || args.workspace !== undefined || args['review-only'] || args['no-review-only'])) {
633
+ 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, reviewOnly: args['review-only'] ? true : args['no-review-only'] ? false : null, author: args.author ?? callerAddress() ?? callerName() }));
628
634
  results.push({ id: t, specified: true, status: moved.to });
629
635
  } else if (verb === 'decompose' && args.child?.length) {
630
636
  const children = args.child.map((c) => { const [title, assignee] = c.split(/@(?=[^@]*$)/); return { title: title.trim(), assignee: assignee?.trim() || undefined }; });
@@ -1114,6 +1120,7 @@ function authorize(db, verb, rest, args, own) {
1114
1120
  if (!card) return; // the verb itself answers "no card"
1115
1121
  const creator = completionOwnerOf(db, card.id);
1116
1122
  if (creator && creator === actorOf()) return;
1123
+ if (verb === 'comment' && isLatestReviewer(db, card.id, actorOf())) return;
1117
1124
  // a card's session writes its own card and its subtasks; what it may do with them is the workflow's (`by`)
1118
1125
  if (card.id !== own.id && card.subtask_of !== own.id) refuse(`writes only its own card and its subtasks, not ${card.id}`);
1119
1126
  for (const other of args.ids ?? []) if (other !== own.id && engine.getTask(db, other)?.subtask_of !== own.id) refuse(`writes only its own card and its subtasks, not ${other}`);
@@ -1085,14 +1085,14 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
1085
1085
  };
1086
1086
  let session = null;
1087
1087
  let replaced = null;
1088
- if (prev?.surface === 'native' && !prev.pane) {
1088
+ if (prev?.surface === 'native' && !prev.pane && !prev.close_confirmed_at) {
1089
1089
  const reading = nativeReading(nativeSessions(prev.machine, prev.harness), prev);
1090
1090
  if (reading.alive == null) throw unavailable(`cannot establish native continuation liveness for ${addressOf(prev)}`);
1091
1091
  fleet.native ??= new Map(); fleet.native.set(addressOf(prev), reading);
1092
1092
  if (reading.alive && (prev.closed_at || prev.close_requested || prev.forgotten)) throw unavailable(`native session ${addressOf(prev)} is still live; its close must finish before replacement`);
1093
1093
  }
1094
1094
  if (prev?.forgotten) {
1095
- if (prev.pane && panePresent(prev.machine, prev.pane)) throw new Error(`forgotten session ${prev.session_id} still has live pane ${prev.pane}; close it before replacement`);
1095
+ if (!prev.close_confirmed_at && prev.pane && panePresent(prev.machine, prev.pane)) throw new Error(`forgotten session ${prev.session_id} still has live pane ${prev.pane}; close it before replacement`);
1096
1096
  replaced = { session_id: prev.session_id, why: 'the workflow let it go' };
1097
1097
  }
1098
1098
  else if (prev && (prev.harness ?? 'claude-code') !== harness) {
package/board/engine.mjs CHANGED
@@ -10,7 +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
+ import { checkWorkspace, assertWorkspaceOwner, declareWorkspace, isLocalWorkspace } from './workspace.mjs';
14
14
  import { DatabaseSync } from 'node:sqlite';
15
15
  import { view, actorOf, claimer, event, json, newTaskId, now, tx } from './store.mjs';
16
16
  import { observeCompletionEvidence } from './ci.mjs';
@@ -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, 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, 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 };
41
41
  }
42
42
 
43
43
  function must(db, id) {
@@ -153,6 +153,7 @@ function projectOf(root, idOrSlug) {
153
153
  }
154
154
 
155
155
  export function createCard(board, spec) {
156
+ if (spec.reviewOnly !== undefined && typeof spec.reviewOnly !== 'boolean') throw new Error('reviewOnly must be a boolean');
156
157
  const title = String(spec.title ?? '').trim();
157
158
  if (!title) throw new Error('a card has a title');
158
159
  const ws = parseWorkspace(spec.workspace ?? 'scratch');
@@ -182,6 +183,11 @@ export function createCard(board, spec) {
182
183
  const missing = parents.filter((p) => !one(db, 'SELECT 1 FROM tasks WHERE id = ?', p));
183
184
  if (missing.length) throw new Error(`unknown parent card(s): ${missing.join(', ')}`);
184
185
  const arc = spec.subtaskOf ? getTask(db, spec.subtaskOf) : null;
186
+ 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
+ }
185
191
  if (spec.subtaskOf && !arc) throw new Error(`no card ${spec.subtaskOf} to be a subtask of`);
186
192
  if (arc?.subtask_of) throw new Error(`${arc.id} is itself a subtask of ${arc.subtask_of}; a subtask belongs to an arc`);
187
193
  let id = newTaskId();
@@ -210,14 +216,13 @@ export function createCard(board, spec) {
210
216
  spec.skills?.length ? JSON.stringify(spec.skills) : null, spec.maxRetries ?? null, spec.model ?? null, spec.provider ?? null,
211
217
  spec.reasoning ?? null, spec.sessionId ?? null, spec.project ?? null, spec.goal ? 1 : 0, spec.goal?.maxTurns ?? null);
212
218
  const machine = spec.machine ?? arc?.machine ?? null;
213
- 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);
214
- const entry = workflowFor(board.root);
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);
215
220
  for (const p of parents) addDependency(db, entry, id, p);
216
221
  // where it starts is the workflow's `initial` (Hermes: triage, blocked, todo behind open parents, else ready)
217
222
  // the create's own words: whether it asked to be a goal card (null: the workflow decides), triage, its arc
218
223
  const status = startNew(db, board, entry, getTask(db, id), { triage: Boolean(spec.triage), initial_status: initial,
219
- every: spec.every ?? null, goal: spec.goal === undefined || spec.goal === null ? null : spec.goal !== false, subtask_of: arc?.id ?? null });
220
- event(db, id, 'created', { creator: actorOf(), assignee: spec.assignee || null, status, parents, tenant: spec.tenant ?? null, ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
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 } : {}) });
221
226
  if ((spec.noStart || spec.adopt) && !arc) event(db, id, 'dispatch_held', { session: spec.adopt ?? null, reason: spec.adopt ? 'awaiting adoption' : 'created with --no-start' });
222
227
  if (parents.length) inheritSubscriptions(db, id, parents);
223
228
  if (arc) {
@@ -251,16 +256,7 @@ function wouldCycle(db, parent, child) {
251
256
 
252
257
 
253
258
  /** The machine a card's session runs on. Moving it is a decision: a running card is reclaimed first. */
254
- export function setMachine(board, id, machine) {
255
- return tx(board, (db) => {
256
- const task = must(db, id);
257
- if (task.current_run_id) throw new Error(`${id} has an open run on ${task.machine}; end it before moving it`);
258
- 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');
259
- if (machine !== task.machine) run(db, 'UPDATE tasks SET workspace_path=NULL WHERE id=?', id);
260
- run(db, 'INSERT INTO supercode_cards (task_id, machine) VALUES (?, ?) ON CONFLICT(task_id) DO UPDATE SET machine = excluded.machine', id, machine || null);
261
- event(db, id, 'machine_set', { machine: machine || null, was: task.machine });
262
- });
263
- }
259
+ export const setMachine = (board, id, machine, { roles } = {}) => act(board, id, 'move', { roles, payload: { machine: machine || null } });
264
260
 
265
261
  // ------------------------------------------------------------------ lifecycle
266
262
 
@@ -472,7 +468,7 @@ export const schedule = (board, id, reason = null, { roles } = {}) => act(board,
472
468
  export const promote = (board, id, { actor = 'user', reason = null, force = false, roles } = {}) => act(board, id, 'promote', { roles, payload: { actor, reason, force } });
473
469
  export const reclaim = (board, id, reason = null, { roles } = {}) => act(board, id, 'reclaim', { roles, payload: { reason } });
474
470
  export const archive = (board, id, { roles } = {}) => act(board, id, 'archive', { roles });
475
- export function specify(board, id, { title = null, body = null, workspace = null, branch = null, author = 'user', roles } = {}) {
471
+ export function specify(board, id, { title = null, body = null, workspace = null, branch = null, reviewOnly = null, author = 'user', roles } = {}) {
476
472
  let declaration = null;
477
473
  if (workspace != null) {
478
474
  const task = getTask(board.db, id), ws = parseWorkspace(workspace);
@@ -480,7 +476,8 @@ export function specify(board, id, { title = null, body = null, workspace = null
480
476
  declaration = { kind: ws.kind, ...declareWorkspace(ws.kind, ws.path, task.machine, branch) };
481
477
  if (declaration.kind === 'worktree') declaration.branch ??= `wt/${id}`;
482
478
  }
483
- return act(board, id, 'specify', { roles, payload: { title, body, author, ...(declaration ? { workspace: declaration } : {}) } });
479
+ 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.');
480
+ return act(board, id, 'specify', { roles, payload: { title, body, author, ...(reviewOnly != null ? { review_only: reviewOnly } : {}), ...(declaration ? { workspace: declaration } : {}) } });
484
481
  }
485
482
  export const editResult = (board, id, { result, summary = null, metadata = undefined, roles } = {}) => act(board, id, 'edit', { roles, payload: { result, summary, metadata } });
486
483
  export const heartbeat = (board, id, note = null, { roles, until = null } = {}) => act(board, id, 'heartbeat', { roles, payload: { note, until } });
@@ -600,6 +597,7 @@ export function cardContext(db, id) {
600
597
  const task = must(db, id);
601
598
  const out = [`# Card ${task.id}: ${task.title}`, ''];
602
599
  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}.` : ''}`);
600
+ 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.');
603
601
  if (task.body) out.push('', cap(task.body, 8 * 1024));
604
602
  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);
605
603
  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', 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', 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' };
77
77
 
78
78
  // ------------------------------------------------------------------ backings
79
79
  //
package/board/tools.mjs CHANGED
@@ -7,7 +7,7 @@ import { mkdirSync, statSync, writeFileSync } from 'node:fs';
7
7
  import { basename, join } from 'node:path';
8
8
  import { DEFAULT_BOARD, actorOf, event, json, openBoard, setActor, tx } from './store.mjs';
9
9
  import * as engine from './engine.mjs';
10
- import { completionOwnerOf, loadWorkflow } from './workflow.mjs';
10
+ import { completionOwnerOf, isLatestReviewer, loadWorkflow } from './workflow.mjs';
11
11
 
12
12
  const ok = (fields = {}) => JSON.stringify({ ok: true, ...fields });
13
13
  const error = (message) => JSON.stringify({ error: message });
@@ -44,6 +44,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
44
44
  if (!own || !runId) return own ? [] : ['manager'];
45
45
  const meta = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(runId)?.metadata, {})?.supercode ?? {};
46
46
  const roles = cardId === own ? [meta.role ?? 'implementer'] : [];
47
+ if (name === 'kanban_comment' && isLatestReviewer(db, cardId, actorOf()) && !roles.includes('reviewer')) roles.push('reviewer');
47
48
  if (actorOf() && completionOwnerOf(db, cardId) === actorOf()) roles.push('creator');
48
49
  return roles;
49
50
  });
@@ -65,7 +66,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
65
66
  ? `run ${runId ?? '(missing)'} of ${own} is no longer its current run; nothing was changed` : null;
66
67
  };
67
68
  try {
68
- if (name !== 'kanban_show' && name !== 'kanban_list') { const stale = ownRun(); if (stale) return error(stale); }
69
+ if (name !== 'kanban_show' && name !== 'kanban_list' && !(name === 'kanban_comment' && isLatestReviewer(board.db, tid, actorOf()))) { const stale = ownRun(); if (stale) return error(stale); }
69
70
  switch (name) {
70
71
  case 'kanban_show': {
71
72
  const card = tx(board, (db) => { const c = engine.showCard(db, tid); c.context = engine.cardContext(db, tid); return c; });
@@ -73,7 +74,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
73
74
  return JSON.stringify({
74
75
  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,
75
76
  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,
76
- current_run_id: t.current_run_id, model_override: t.model_override, provider_override: t.provider_override },
77
+ current_run_id: t.current_run_id, model_override: t.model_override, provider_override: t.provider_override, review_only: Boolean(t.review_only) },
77
78
  parents: card.parents.map((p) => p.id), children: card.children,
78
79
  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 })),
79
80
  worker_context: card.context,
@@ -135,7 +136,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
135
136
  }
136
137
  case 'kanban_comment': {
137
138
  if (!String(args.body ?? '').trim()) return error('body is required');
138
- const n = await engine.comment(board, tid, author, redact(String(args.body)));
139
+ const n = await engine.comment(board, tid, isLatestReviewer(board.db, tid, actorOf()) ? actorOf() : author, redact(String(args.body)));
139
140
  return ok({ task_id: tid, comment_id: n });
140
141
  }
141
142
  case 'kanban_attach': case 'kanban_attach_url': {
@@ -169,7 +170,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
169
170
  title: args.title, body: args.body ? redact(String(args.body)) : null, assignee: args.assignee ?? null, parents: [args.parents ?? []].flat(), tenant: args.tenant ?? null,
170
171
  priority: args.priority ?? 0, workspace, project: args.project ?? null, triage: Boolean(args.triage), idempotencyKey: args.idempotency_key ?? null,
171
172
  maxRuntime: args.max_runtime_seconds ?? null, initialStatus: args.initial_status ?? null, skills: args.skills ?? null, model: args.model ?? null, provider: args.provider ?? null,
172
- goal: args.goal_mode ? { maxTurns: args.goal_max_turns ?? null } : null, createdBy: author,
173
+ goal: args.goal_mode ? { maxTurns: args.goal_max_turns ?? null } : null, createdBy: author, reviewOnly: args.review_only ?? false,
173
174
  });
174
175
  return ok({ task_id: made.id, existing: made.existing, status: tx(board, (db) => engine.getTask(db, made.id)).status });
175
176
  }
@@ -12,6 +12,7 @@ import { randomBytes } from 'node:crypto';
12
12
  import { actorOf, appendRunObservation, event as record, json, now } from './store.mjs';
13
13
  import { evaluate, holds, render } from './expr.mjs';
14
14
  import { completionEvidence } from './ci.mjs';
15
+ import { isWorkspacePath } from './workspace.mjs';
15
16
 
16
17
  const loaded = new Map();
17
18
 
@@ -70,6 +71,18 @@ export function completionOwnerOf(db, id) {
70
71
  return creatorOf(db, id);
71
72
  }
72
73
 
74
+ export function latestReview(db, id) {
75
+ const row = one(db, "SELECT id,started_at,metadata FROM task_runs WHERE task_id=? AND json_extract(metadata,'$.supercode.role')='reviewer' ORDER BY id DESC LIMIT 1", id);
76
+ if (!row) return null;
77
+ const session = json(row.metadata, {})?.supercode ?? {};
78
+ const address = session.address ?? (session.machine && session.harness && session.session_id ? `sc:${session.machine}:${session.harness}:${session.session_id}` : null);
79
+ return { id: row.id, started_at: row.started_at, address };
80
+ }
81
+
82
+ export function isLatestReviewer(db, id, address) {
83
+ return Boolean(address && latestReview(db, id)?.address === address);
84
+ }
85
+
73
86
  /** What kanban.db stores for `status`. */
74
87
  export function storedAs(workflow, status) {
75
88
  return workflow.statuses[status]?.stored_as ?? status;
@@ -236,6 +249,8 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {} } = {}
236
249
  ...task,
237
250
  creator_address: creatorOf(db, task.id),
238
251
  completion_owner_address: completionOwnerOf(db, task.id),
252
+ review_only: Boolean(state.review_only),
253
+ latest_review: latestReview(db, task.id) ?? {},
239
254
  ci: completionEvidence(board, task.id),
240
255
  dispatch_hold: one(db, "SELECT kind FROM task_events WHERE task_id=? AND kind IN ('dispatch_held','dispatch_released','session_adopted') ORDER BY id DESC LIMIT 1", task.id)?.kind === 'dispatch_held',
241
256
  status,
@@ -560,6 +575,7 @@ const FORMAT = {
560
575
  commented: ({ e }) => ({ author: String(e.author ?? 'user').trim(), len: String(e.body ?? '').length }),
561
576
  archived: () => null,
562
577
  status: ({ e, before, to }) => ({ status: to, reason: e.reason ?? 'reopened', previous_status: before.stored_as }),
578
+ machine_set: ({ e, before }) => ({ machine: e.machine ?? null, was: before.machine }),
563
579
  };
564
580
  const RETRY_KINDS = new Set(['crashed', 'timed_out', 'stale', 'rate_limited', 'session_lost', 'reclaimed', 'reconciled', 'spawn_failed']);
565
581
 
@@ -653,6 +669,14 @@ const ACTIONS = {
653
669
  } else record(ctx.db, on ?? ctx.id, kind, payload && Object.keys(payload).length ? payload : null, on ? null : ctx.runId);
654
670
  },
655
671
  assign(ctx, { field, value }) {
672
+ if (field === 'machine') {
673
+ const task = one(ctx.db, 'SELECT t.current_run_id,t.workspace_kind,c.workspace_anchor,c.machine FROM tasks t JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?', ctx.id);
674
+ if (task.current_run_id) throw new Error('End the current run before moving its card.');
675
+ 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');
676
+ if ((value ?? null) !== task.machine) run(ctx.db, 'UPDATE tasks SET workspace_path=NULL WHERE id=?', ctx.id);
677
+ cardField(ctx, 'machine', value ?? null);
678
+ return;
679
+ }
656
680
  if (!ASSIGNABLE.has(field)) throw new Error(`the workflow assigns ${field}, which is not a card field it may set`);
657
681
  run(ctx.db, `UPDATE tasks SET ${field} = ? WHERE id = ?`, typeof value === 'boolean' ? Number(value) : value ?? null, ctx.id);
658
682
  },
@@ -742,6 +766,12 @@ const ACTIONS = {
742
766
  run(ctx.db, 'DELETE FROM task_links WHERE parent_id = ? AND child_id = ?', parent, ctx.id);
743
767
  },
744
768
  specify(ctx, said) {
769
+ if (said?.review_only != null) {
770
+ const task = cardOf(ctx.db, ctx.id);
771
+ 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.');
772
+ cardField(ctx, 'review_only', said.review_only ? 1 : 0);
773
+ record(ctx.db, ctx.id, 'review_mode_changed', { review_only: said.review_only }, null);
774
+ }
745
775
  if (said?.workspace) {
746
776
  const task = cardOf(ctx.db, ctx.id), ws = said.workspace;
747
777
  if (task.current_run_id) throw new Error('end the current run before changing its workspace');
@@ -779,14 +809,32 @@ const ACTIONS = {
779
809
  // the slot's last session is let go: its next run starts a new session (a card's context is otherwise kept: a
780
810
  // session that cannot be resumed stops the card, it is never replaced on the board's own account)
781
811
  forget_session(ctx, slot) {
782
- const name = String(slot ?? 'worker');
812
+ const name = String(typeof slot === 'object' && slot ? slot.slot ?? 'worker' : slot ?? 'worker');
813
+ const harness = typeof slot === 'object' && slot ? ctx.entry.profiles[slot.assignee]?.worker?.harness : null;
814
+ if (typeof slot === 'object' && slot && !harness) return;
815
+ const released = new Set();
783
816
  for (const row of all(ctx.db, 'SELECT id, metadata FROM task_runs WHERE task_id = ? AND metadata IS NOT NULL ORDER BY id DESC', ctx.id)) {
784
817
  const whole = json(row.metadata, {}) ?? {};
785
- if (!whole.supercode?.session_id || (whole.supercode.slot ?? 'worker') !== name) continue;
786
- whole.supercode = { ...whole.supercode, forgotten: now() };
787
- run(ctx.db, 'UPDATE task_runs SET metadata = ? WHERE id = ?', JSON.stringify(whole), row.id);
788
- return;
818
+ const session = whole.supercode;
819
+ if ((!session?.session_id && !session?.pane && !session?.launch_key) || (name !== '*' && (session.slot ?? 'worker') !== name) || (harness && (session.harness ?? 'claude-code') === harness)) continue;
820
+ requestSessionClose(ctx.db, ctx.id, { runId: row.id });
821
+ const latest = json(one(ctx.db, 'SELECT metadata FROM task_runs WHERE id=?', row.id)?.metadata, {}) ?? {};
822
+ if (session.session_id) released.add(session.session_id);
823
+ latest.supercode = { ...latest.supercode, forgotten: now(), resumable: false };
824
+ run(ctx.db, "UPDATE task_runs SET metadata=?, ended_at=COALESCE(ended_at,?), status=CASE WHEN ended_at IS NULL THEN 'superseded' ELSE status END, outcome=CASE WHEN ended_at IS NULL THEN 'superseded' ELSE outcome END, claim_lock=NULL,claim_expires=NULL,worker_pid=NULL WHERE id=?", JSON.stringify(latest), now(), row.id);
789
825
  }
826
+ const gaveUp = one(ctx.db, "SELECT created_at,payload FROM task_events WHERE task_id=? AND kind='gave_up' ORDER BY id DESC LIMIT 1", ctx.id);
827
+ const failures = Number(json(gaveUp?.payload, {})?.failures ?? 0);
828
+ if (!failures || !released.size) return;
829
+ const recent = all(ctx.db, 'SELECT outcome,metadata FROM task_runs WHERE task_id=? AND ended_at<=? ORDER BY id DESC LIMIT ?', ctx.id, gaveUp.created_at, failures);
830
+ if (recent.length !== failures || !recent.every(row => {
831
+ const failure = json(row.metadata, {})?.last_events?.spawn_failed?.payload;
832
+ return row.outcome === 'spawn_failed' && failure?.resume_failed === true && released.has(failure.session_id);
833
+ })) return;
834
+ const blocks = json(one(ctx.db, 'SELECT blocked_by FROM supercode_cards WHERE task_id=?', ctx.id)?.blocked_by, []);
835
+ for (const block of blocks.filter(block => block.kind === 'message' && block.reason === 'failure limit reached' && block.since === gaveUp.created_at))
836
+ ACTIONS.relation(ctx, { remove: block.id, reason: 'The manager released the session responsible for these stale continuation failures.' });
837
+ run(ctx.db, 'UPDATE tasks SET consecutive_failures=0,last_failure_error=NULL WHERE id=?', ctx.id);
790
838
  },
791
839
  // an event sent to other cards (`to`: an expression naming their ids, e.g. a card's open subtasks), as `as` sends it;
792
840
  // a card it does not apply to now is passed over
@@ -845,7 +893,7 @@ export function requestSessionClose(db, id, { runId = null, slot = null, through
845
893
  if ((runId != null && row.id !== runId) || (through != null && row.id > through)) continue;
846
894
  const whole = json(row.metadata, {}) ?? {};
847
895
  const session = whole.supercode;
848
- if ((!session?.pane && !session?.launch_key) || session.close_transferred_to || session.close_confirmed_at || (slot != null && (session.slot ?? 'worker') !== slot)) continue;
896
+ if ((!session?.pane && !session?.launch_key && !(session?.surface === 'native' && session.session_id)) || session.close_transferred_to || session.close_confirmed_at || (slot != null && (session.slot ?? 'worker') !== slot)) continue;
849
897
  if (session.close_requested && !session.closed_at) continue;
850
898
  whole.supercode = { ...session, close_requested: session.close_requested ?? now() };
851
899
  delete whole.supercode.closed_at; // older versions recorded success without checking the receipt
package/board/ztrack.mjs CHANGED
@@ -8,6 +8,7 @@ import { RuntimeRows } from './rows.mjs';
8
8
  import { DatabaseSync } from 'node:sqlite';
9
9
  import { actorOf, event, json, now, setActor } from './store.mjs';
10
10
  import { fire, projectStatus, statusOf, workflowFor } from './workflow.mjs';
11
+ import { inspectCompletionEvidence, withCompletionEvidence } from './ci.mjs';
11
12
 
12
13
  const require = createRequire(import.meta.url);
13
14
  const hash = (text) => createHash('sha256').update(text).digest('hex');
@@ -26,6 +27,14 @@ const sqliteContention = error => [5, 6].includes(Number(error?.errcode) & 255)
26
27
  || /^database (?:table |schema )?is locked$/i.test(error?.message ?? '');
27
28
  const exportConflict = error => error?.message === 'Hermes wrote during board export; retained its events for the next read';
28
29
  const recoveryConflict = error => error?.message === 'document changed during recovery; retry';
30
+ // One-shot verbs open the same board several times. Share byte-keyed codec
31
+ // work across those instances and cap retention for long-running dispatchers.
32
+ const documentCache = new Map(), renderCache = new Map();
33
+ function remember(cache, path, value) {
34
+ cache.delete(path);
35
+ cache.set(path, value);
36
+ while (cache.size > 8) cache.delete(cache.keys().next().value);
37
+ }
29
38
 
30
39
  export function openZtrackBoard(marker, { create = false, document = null, seed = null, init, root, slug, compatPath }) {
31
40
  if (!existsSync(marker)) {
@@ -48,6 +57,9 @@ class ZtrackBoard {
48
57
  this.runtime = new DatabaseSync(this.runtimePath);
49
58
  this.readOnly(()=>{ if (!this.runtime.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='state'").get()) this.runtime.exec('CREATE TABLE IF NOT EXISTS state (key TEXT PRIMARY KEY, value TEXT NOT NULL)'); });
50
59
  this.errors = new Map(); this.preserve = new Map();
60
+ // Codec projections depend on document bytes, not runtime revisions. Keep one
61
+ // entry per document across optimistic retries; run/event churn must not
62
+ // repeatedly parse an unchanged board before reaching its version check.
51
63
  this.mem = null; this.active = false; this.rows = new RuntimeRows(this.runtime);
52
64
  mkdirSync(dirname(this.path), { recursive: true });
53
65
  const self = this;
@@ -116,9 +128,10 @@ class ZtrackBoard {
116
128
  }
117
129
 
118
130
  transaction(fn) {
119
- if (this.cachedReader) this.invalidate();
120
131
  this.readOnly(()=>{ if(this.runtime.prepare('PRAGMA journal_mode').get().journal_mode!=='wal')this.runtime.exec('PRAGMA journal_mode=WAL'); });
121
132
  for (;;) {
133
+ const incoming = this.readOnly(() => this.prepareHermes());
134
+ if (this.cachedReader) this.invalidate();
122
135
  try { this.runtime.exec('BEGIN'); }
123
136
  catch (error) { if (!sqliteContention(error)) throw error; continue; }
124
137
  this.active = true;
@@ -130,8 +143,11 @@ class ZtrackBoard {
130
143
  // Parsing, rules and rendering run against a snapshot, without a runtime writer transaction.
131
144
  this.pendingValues = new Map(); this.computing = true;
132
145
  this.load({ observe: true });
146
+ if (incoming && (incoming.version !== version || incoming.open !== hash(this.text) || incoming.archived !== hash(this.archiveText))) {
147
+ this.invalidate(); continue;
148
+ }
133
149
  if (this.seed) this.seedIntoMemory();
134
- else this.lift();
150
+ else this.lift(incoming);
135
151
  const result = fn(this.db);
136
152
  const changes = this.rows.changes(this.mem, { all: !this.rows.normalized() });
137
153
  const canonical = (row, omitted) => row && Object.fromEntries(Object.entries(row).filter(([key])=>!omitted.has(key)));
@@ -320,7 +336,7 @@ class ZtrackBoard {
320
336
  this.errors.clear(); this.preserve.clear();
321
337
  const records = []; this.documentRecords=new Map();
322
338
  for (const [text,path] of [[this.text,this.path],[this.archiveText,this.archive]]) {
323
- const result = this.codec.inspectBoardDocument(text,path);
339
+ const result = this.inspectDocument(text,path);
324
340
  const kept = new Set(result.errors.map(e=>e.id));
325
341
  for(const error of result.errors)this.errors.set(nativeId(error.id),error.reason);
326
342
  for(const record of result.records) {
@@ -379,7 +395,7 @@ class ZtrackBoard {
379
395
  status: task.status === 'passed' ? (task.metadata?.status === 'cancelled' ? 'cancelled' : 'done') : (['done','cancelled'].includes(task.metadata?.status) ? 'todo' : task.metadata?.status ?? 'todo'),
380
396
  metadata: task.metadata?.properties ?? {}, assignee: task.metadata?.assignee ?? card.assignee,
381
397
  machine: task.metadata?.machine ?? card.machine, acceptanceCriteria: [], acceptance: task.lines ?? [],
382
- relations: (task.blockedBy ?? []).map((b) => ({ type: 'blocked-by', issueId: b.ac ? `${nativeId(b.issue)}:${b.ac}` : nativeId(b.issue) })) };
398
+ relations: (task.blockedBy ?? []).map((b) => ({ type: 'blocked-by', issueId: b.ac && /^(?:[ma]-[0-9a-f]{6,}|t_[0-9a-f]+)$/.test(b.ac) ? b.ac : b.ac ? `${nativeId(b.issue)}:${b.ac}` : nativeId(b.issue) })) };
383
399
  if (task.waitingOn) taskCard.relations.push({ type: 'blocked-by', issueId: task.waitingOn });
384
400
  return [tid, taskCard];
385
401
  });
@@ -401,7 +417,7 @@ class ZtrackBoard {
401
417
  const value = (task, state, field) => field === 'status' ? state?.stage ?? task?.status ?? 'todo'
402
418
  : field === 'blocked_by' ? json(state?.blocked_by, []).map(b => [b.kind, b.id]).sort()
403
419
  : field === 'paused' ? json(state?.paused, null)
404
- : field === 'archived' ? Boolean(state?.archived) : task?.[field] ?? null;
420
+ : field === 'archived' ? Boolean(state?.archived) : field === 'review_only' ? Boolean(state?.review_only) : task?.[field] ?? null;
405
421
  for (const id of new Set([...tasks.keys(), ...Object.keys(this.rows.cachedTasks)])) {
406
422
  if (this.errors.has(id)) continue;
407
423
  const oldTask = this.rows.cachedTasks[id], oldState = this.rows.cachedCards[id];
@@ -430,7 +446,7 @@ class ZtrackBoard {
430
446
  const task = { ...meta, ...this.saved.runtime?.[id], id, title: card.title, body: stored.body,
431
447
  status: card.status, assignee: card.assignee ?? null, created_at: meta.created_at ?? now(), priority: card.priority ?? 0,
432
448
  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 };
433
- const state = { ...this.saved.clocks?.[id], task_id: id, workspace_anchor: workspace?.[2] ?? null, stage: card.status, machine: card.machine ?? null, subtask_of: parent,
449
+ const state = { ...this.saved.clocks?.[id], task_id: id, workspace_anchor: workspace?.[2] ?? null, review_only: meta.review_only === true ? 1 : 0, stage: card.status, machine: card.machine ?? null, subtask_of: parent,
434
450
  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' }))),
435
451
  paused: card.paused ? JSON.stringify(card.paused) : null, archived: meta.archived ? 1 : 0, every: card.every ?? null };
436
452
  return { task, state };
@@ -473,6 +489,21 @@ class ZtrackBoard {
473
489
  committer() {
474
490
  return 'document-editor';
475
491
  }
492
+ inspectDocument(text, path) {
493
+ const previous = documentCache.get(path);
494
+ if (previous?.text === text) return previous.result;
495
+ const result = this.codec.inspectBoardDocument(text, path);
496
+ remember(documentCache, path, { text, result });
497
+ return result;
498
+ }
499
+ renderDocument(text, records, path, preserve) {
500
+ const projection = JSON.stringify([records, preserve]);
501
+ const previous = renderCache.get(path);
502
+ if (previous?.text === text && previous.projection === projection) return previous.result;
503
+ const result = this.codec.renderBoardDocument(text, records, path, preserve);
504
+ remember(renderCache, path, { text, projection, result });
505
+ return result;
506
+ }
476
507
  documents(changes=null) {
477
508
  this.publishedFingerprints = {};
478
509
  const allRows = this.mem.prepare('SELECT * FROM tasks ORDER BY created_at, id').all();
@@ -485,6 +516,7 @@ class ZtrackBoard {
485
516
  const parsed = this.preset.KanbanRootSchema.parse(this.preset.parseKanban([{ id: this.fileIds.get(row.id) ?? row.id, title: row.title, status: c.stage ?? row.status, assignee: row.assignee || undefined, body: row.body ?? '' }])).issues[0];
486
517
  const metadata = Object.fromEntries(Object.entries(row).filter(([k, v]) => !RUNTIME.has(k) && !CARD_MAIN.has(k) && v != null));
487
518
  if (c.archived) metadata.archived = true;
519
+ if (c.review_only) metadata.review_only = true;
488
520
  if (json(c.blocked_by, []).length) metadata.blocked_by = json(c.blocked_by, []);
489
521
  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}` : ''),
490
522
  branch: row.branch_name || undefined, machine: c.machine || undefined, paused: json(c.paused, null) ?? undefined, every: c.every || undefined,
@@ -499,20 +531,33 @@ class ZtrackBoard {
499
531
  }
500
532
  const renderedKeys = new Set();
501
533
  for (const row of rows.filter((r) => states.get(r.id)?.subtask_of && !this.errors.has(r.id))) {
502
- const c = states.get(row.id); const parent = cards.get(c.subtask_of);
503
- if (!parent) throw new Error(`subtask ${row.id} has no arc ${c.subtask_of}`);
534
+ const c = states.get(row.id);
535
+ if (!cards.has(c.subtask_of)) throw new Error(`subtask ${row.id} has no arc ${c.subtask_of}`);
504
536
  const taken = occupied.get(c.subtask_of) ?? new Set(); occupied.set(c.subtask_of, taken);
505
537
  const match = /^([a-z]+\d+)\s+(.+)$/.exec(row.title);
506
538
  let key = this.taskKeys.get(row.id)?.split(':').at(-1) ?? match?.[1];
507
539
  if (!key || renderedKeys.has(`${c.subtask_of}:${key}`)) { let n = 1; while (taken.has(`c${n}`)) n++; key = `c${n}`; }
508
540
  taken.add(key); renderedKeys.add(`${c.subtask_of}:${key}`); this.taskKeys.set(row.id, `${c.subtask_of}:${key}`);
541
+ }
542
+ for (const row of rows.filter((r) => states.get(r.id)?.subtask_of && !this.errors.has(r.id))) {
543
+ const c = states.get(row.id); const parent = cards.get(c.subtask_of);
544
+ const key = this.taskKeys.get(row.id).split(':').at(-1);
545
+ const match = /^([a-z]+\d+)\s+(.+)$/.exec(row.title);
509
546
  const parsed = this.preset.KanbanRootSchema.parse(this.preset.parseKanban([{ id: row.id, title: row.title, status: c.stage ?? row.status, body: row.body ?? '' }])).issues[0];
510
547
  const originalTask = this.originals.get(c.subtask_of)?.acceptanceCriteria.find((t) => t.id === key);
548
+ const blocks = json(c.blocked_by, []);
549
+ const messages = blocks.filter(block => block.kind === 'message');
511
550
  parent.acceptanceCriteria.push({ sources: originalTask?.sources, id: key, status: ['done', 'cancelled'].includes(c.stage ?? row.status) ? 'passed' : 'pending', evidence: [], text: match?.[2] ?? row.title,
512
551
  metadata: { id: row.id, status: c.stage ?? row.status, body: parsed.body, assignee: row.assignee, machine: c.machine,
513
552
  properties: Object.fromEntries(Object.entries(row).filter(([k,v]) => !RUNTIME.has(k) && !CARD_MAIN.has(k) && v != null)),
514
553
  card: { paused: json(c.paused, null) ?? undefined, every: c.every || undefined } }, lines: parsed.acceptance,
515
- blockedBy: json(c.blocked_by, []).map((b) => ({ issue: b.id })) });
554
+ waitingOn: messages[0]?.id,
555
+ blockedBy: blocks.filter(block => block !== messages[0]).map(block => {
556
+ const target = this.taskKeys.get(block.id);
557
+ if (!target) return { issue: this.fileIds.get(block.id) ?? block.id };
558
+ const [arcId, taskKey] = target.split(':');
559
+ return { issue: this.fileIds.get(arcId) ?? arcId, ac: taskKey };
560
+ }) });
516
561
  }
517
562
  const open = [], archived = [];
518
563
  if(affected)for(const [id,{record,path}] of this.documentRecords??[])if(!affected.has(id)&&!this.errors.has(id))(path===this.path?open:archived).push(record);
@@ -532,7 +577,7 @@ class ZtrackBoard {
532
577
  }
533
578
  const original=this.documentRecords;
534
579
  const changedPath=path=>!affected||[...affected].some(id=>original?.get(id)?.path===path||cards.has(id)&&((states.get(id)?.archived||['done','cancelled'].includes(cards.get(id).status))?this.archive:this.path)===path);
535
- return { open: !changedPath(this.path)?this.text:this.codec.renderBoardDocument(this.text, open, this.path, [...(this.preserve.get(this.path)??[])]), archived: !changedPath(this.archive)?this.archiveText:this.codec.renderBoardDocument(this.archiveText, archived, this.archive, [...(this.preserve.get(this.archive)??[])]) };
580
+ return { open: !changedPath(this.path)?this.text:this.renderDocument(this.text, open, this.path, [...(this.preserve.get(this.path)??[])]), archived: !changedPath(this.archive)?this.archiveText:this.renderDocument(this.archiveText, archived, this.archive, [...(this.preserve.get(this.archive)??[])]) };
536
581
  }
537
582
  snapshot(documents) {
538
583
  const fingerprints = this.fingerprints();
@@ -542,27 +587,39 @@ class ZtrackBoard {
542
587
  return { bodyProjection:2, documentHashes:{open:hash(documents.open),archived:hash(documents.archived)}, errors:[...this.errors], fingerprints: { ...fingerprints, ...this.publishedFingerprints }, taskIds: Object.fromEntries([...this.taskKeys].map(([id, key]) => [key, id])) };
543
588
  }
544
589
 
545
- lift() {
546
- if (!existsSync(this.compatPath)) return;
547
- let entry; try { entry = workflowFor(this.root); } catch { return; }
548
- const compat = new DatabaseSync(this.compatPath);
590
+ prepareHermes() {
591
+ if (this.seed || !existsSync(this.compatPath)) return null;
592
+ let entry; try { entry = workflowFor(this.root); } catch { return null; }
593
+ const compat = new DatabaseSync(this.compatPath, { readOnly: true });
549
594
  try {
595
+ const prepared = { entry, evidence: new Map(), version: this.get('version'), open: hash(read(this.path)), archived: hash(read(this.archive)) };
550
596
  const cursor = this.get('hermes_cursor', 0);
551
- const rows = compat.prepare('SELECT * FROM task_events WHERE id > ? ORDER BY id').all(cursor);
552
- const before = actorOf();
553
- try {
554
- for (const row of rows) {
555
- const payload = json(row.payload, {}) ?? {};
556
- if (payload.__supercode_export) continue;
557
- const task = this.mem.prepare('SELECT * FROM tasks WHERE id = ?').get(row.task_id);
558
- if (!task) continue;
559
- this.mem.exec('SAVEPOINT hermes_card');
560
- try {
597
+ prepared.rows = compat.prepare('SELECT * FROM task_events WHERE id > ? ORDER BY id').all(cursor);
598
+ for (const row of prepared.rows) {
599
+ const payload = json(row.payload, {}) ?? {};
600
+ if (row.kind !== 'completed' || payload.__supercode_export || !this.db.prepare('SELECT 1 FROM tasks WHERE id=?').get(row.task_id)) continue;
601
+ prepared.evidence.set(row.id, inspectCompletionEvidence(this.board(), row.task_id, entry, payload));
602
+ }
603
+ return prepared;
604
+ } finally { compat.close(); }
605
+ }
606
+ lift(prepared) {
607
+ if (!prepared) return;
608
+ const { entry, rows, evidence } = prepared;
609
+ const before = actorOf();
610
+ try {
611
+ for (const row of rows) {
612
+ const payload = json(row.payload, {}) ?? {};
613
+ if (payload.__supercode_export) continue;
614
+ const task = this.mem.prepare('SELECT * FROM tasks WHERE id = ?').get(row.task_id);
615
+ if (!task) continue;
616
+ this.mem.exec('SAVEPOINT hermes_card');
617
+ try {
561
618
  const roles = ['manager', 'reviewer', 'platform', 'hermes'];
562
619
  setActor(payload.by ?? 'hermes');
563
620
  let name = null; let words = { ...payload, reason: payload.reason ?? 'unblocked in Hermes' };
564
621
  if (row.kind === 'unblocked') { name = 'unblock'; words = { ...words, reason: 'unblocked in Hermes', hermes: true }; }
565
- else if (row.kind === 'completed') { name = 'complete'; words.summary = payload.summary ?? task.result; }
622
+ else if (row.kind === 'completed') { name = 'complete'; words.summary = payload.summary ?? task.result; words.ci = evidence.get(row.id); }
566
623
  else if (row.kind === 'blocked' || row.kind === 'dependency_wait') { name = 'block'; words.on = payload.on ?? payload.parent ?? null; words.hermes = true; }
567
624
  else if (row.kind === 'archived' && statusOf(this.mem, task) !== 'done') name = 'cancel';
568
625
  if (row.kind === 'unblocked' && entry.workflow.verbs?.hermes_unpause) {
@@ -570,19 +627,19 @@ class ZtrackBoard {
570
627
  for (const effect of resumed.effects) this.mem.prepare('INSERT INTO supercode_effects (effect) VALUES (?)').run(JSON.stringify(effect));
571
628
  }
572
629
  if (name) {
573
- const result = fire(this.mem, this.board(), entry, task.id, name, { roles, payload: words });
630
+ const apply = () => fire(this.mem, this.board(), entry, task.id, name, { roles, payload: words });
631
+ const result = name === 'complete' ? withCompletionEvidence(this.board(), task.id, words.ci, apply) : apply();
574
632
  for (const effect of result.effects) this.mem.prepare('INSERT INTO supercode_effects (effect) VALUES (?)').run(JSON.stringify(effect));
575
633
  }
576
634
  event(this.mem, task.id, 'hermes_lifted', { by: 'hermes', source_event: row.id, verb: name, kind: row.kind });
577
635
  this.mem.exec('RELEASE hermes_card');
578
- } catch(error) {
579
- this.mem.exec('ROLLBACK TO hermes_card; RELEASE hermes_card');
580
- event(this.mem, task.id, 'hermes_refused', {by:'hermes',source_event:row.id,kind:row.kind,reason:error.message});
581
- }
636
+ } catch(error) {
637
+ this.mem.exec('ROLLBACK TO hermes_card; RELEASE hermes_card');
638
+ event(this.mem, task.id, 'hermes_refused', {by:'hermes',source_event:row.id,kind:row.kind,reason:error.message});
582
639
  }
583
- } finally { setActor(before); }
584
- if (rows.length) this.put('hermes_cursor', rows.at(-1).id);
585
- } finally { compat.close(); }
640
+ }
641
+ } finally { setActor(before); }
642
+ if (rows.length) this.put('hermes_cursor', rows.at(-1).id);
586
643
  }
587
644
  flushExport() {
588
645
  const changes = this.get('export_pending', []);
@@ -345,6 +345,10 @@
345
345
  "inputSchema": {
346
346
  "type": "object",
347
347
  "properties": {
348
+ "review_only": {
349
+ "type": "boolean",
350
+ "description": "Declare a standalone independent review job, where permitted by the home's manager policy. Its verdict is accepted without another review of the review; implementation arcs keep their independent review requirement."
351
+ },
348
352
  "title": {
349
353
  "type": "string",
350
354
  "description": "Short task title (required)."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.4.145",
3
+ "version": "0.4.147",
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": {