@volter/supercode-orchestrator 0.4.144 → 0.4.146

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -299,25 +299,13 @@ platforms:
299
299
  host: 127.0.0.1
300
300
  port: 8090
301
301
  key: ${API_SERVER_KEY}
302
- rh2: # -> adapters/rh2.mjs: Runhuman Room conversations as a channel
303
- enabled: true
304
- token: ${RH2_SESSION_TOKEN} # the bot principal's automation session; a reference loads as a
305
- # credential only at the block's top level or under extra:
306
- extra:
307
- api_url: https://pilot.runhuman.com
308
- organization: peakhealth
309
- principal_id: prn_… # the bot's own principal (is_me, mentions)
310
302
  ```
311
303
 
312
- **The RH2 adapter** (`adapters/rh2.mjs`) holds an RH2 session, follows every active Room the bot is
313
- a member of (or `extra.rooms`), polls each conversation it may read (`GET …/messages?after=`), turns
314
- a person's line (`kind: human`, or a trigger's `automation`) into an `inbound` on the key
315
- `{platform: rh2, kind: channel, chat_id: <roomId>/<conversationId>}`, and posts the reply as an
316
- `agent`-kind message authored as the bot. Typing is RH2 conversation presence, tried once and
317
- forgotten if the deployment has no door for it.
318
-
319
- Both blocks show up in `supercode channels list --harness orchestrator` through the reader that was
320
- already there — `configured` by key *presence*, never by reading a value.
304
+ RH2 integration attaches through an admin-consented app installation. A hand-written
305
+ `platforms: rh2` block grants no integration authority and the runtime reports it as
306
+ unsupported. The reusable RH2 transport remains in `adapters/rh2.mjs`; the Room-chat
307
+ module supplies its app-owned settings and credentials when that module is installed.
308
+ Native Hermes channel configuration remains the worker's configuration.
321
309
 
322
310
  **The Chat SDK sidecar** mounts `chat@4.39.0` (npm package `chat`, repo `vercel/chat`; a
323
311
  devDependency of this package, installed alongside `@chat-adapter/state-memory@4.39.0`). The
package/adapters/rh2.mjs CHANGED
@@ -16,19 +16,9 @@
16
16
  // PATCH /rooms/:room/conversations/:c/messages/:id {body} — a streamed reply grows, a progress line updates, by edit
17
17
  // POST /rooms/:room/conversations/:c/presence {state: "typing"} — best effort, forgotten on 404
18
18
  //
19
- // The block:
20
- // platforms:
21
- // rh2:
22
- // enabled: true
23
- // token: ${RH2_SESSION_TOKEN} # the bot principal's session (loads as credentials.token;
24
- // # a nested `credentials:` block is not read as one)
25
- // extra:
26
- // api_url: https://pilot.runhuman.com # or the boundary's RH2 route
27
- // organization: peakhealth
28
- // headers: '{"x-twins-key":"…"}' # optional extra request headers (JSON)
29
- // rooms: room_a,room_b # optional; default every active Room the bot is in
30
- // principal_id: prn_… # the bot's own principal (is_me / mentions)
31
- // poll_seconds: 2
19
+ // This is reusable transport code. The daemon does not attach it from a
20
+ // platforms block; an installed integration module owns its settings and token.
21
+ // See docs/adr/0006-teams-installable-apps.md (C1) and the Room-chat module (M4).
32
22
  //
33
23
  // The surface key is `{platform: 'rh2', kind: 'channel', chat_id: '<roomId>'}`: a Room is one surface, so the agent it
34
24
  // seats is one session however many of the Room's conversations it takes part in. A line wakes it where RH2 says the bot
package/bin/runtime.mjs CHANGED
@@ -65,7 +65,7 @@ import { hermesAccess } from '../hermes-cli.mjs';
65
65
 
66
66
  import {
67
67
  load, save, refreshVault, step, importFrom, exportTo, closeWorld, Orchestrator, LoopbackAdapter,
68
- ChatSdkAdapter, ApiServerAdapter, Rh2Adapter,
68
+ ChatSdkAdapter, ApiServerAdapter,
69
69
  TICK_MS, accessPolicy, applyOperator, performRemoveProfileDir, OPERATOR_OPS,
70
70
  } from '../index.mjs';
71
71
  import * as events from '../events.mjs';
@@ -86,7 +86,6 @@ const ADAPTERS = {
86
86
  loopback: (platform, profile) => new LoopbackAdapter({ platform, profile }),
87
87
  'chat-sdk': (platform, profile, ctx) => new ChatSdkAdapter({ platform, profile, profileDir: ctx.state.profiles[profile]?.dir ?? null, channel: ctx.channel, vault: ctx.vault, log: ctx.log }),
88
88
  api_server: (platform, profile, ctx) => new ApiServerAdapter({ platform, profile, channel: ctx.channel, vault: ctx.vault, state: ctx.state, log: ctx.log }),
89
- rh2: (platform, profile, ctx) => new Rh2Adapter({ platform, profile, channel: ctx.channel, vault: ctx.vault, refreshVault: () => refreshVault(ctx.state, ctx.vault), log: ctx.log }),
90
89
  };
91
90
 
92
91
  /// The selector a `platforms:` block resolves through: its `extra.adapter`
@@ -341,6 +340,7 @@ function buildAdapters(state, vault = new Map(), log = () => {}) {
341
340
  continue;
342
341
  }
343
342
  const selector = adapterSelector(platform, channel);
343
+ if(selector==='rh2'){report.push({platform,profile:profileName,state:'unsupported (RH2 integration requires an installed app; a platforms block grants no integration authority)'});continue;}
344
344
  const make = ADAPTERS[selector];
345
345
  if (!make) {
346
346
  report.push({ platform, profile: profileName, state: `unsupported (no adapter \`${selector}\` for this platform yet)` });
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');
@@ -116,9 +117,10 @@ class ZtrackBoard {
116
117
  }
117
118
 
118
119
  transaction(fn) {
119
- if (this.cachedReader) this.invalidate();
120
120
  this.readOnly(()=>{ if(this.runtime.prepare('PRAGMA journal_mode').get().journal_mode!=='wal')this.runtime.exec('PRAGMA journal_mode=WAL'); });
121
121
  for (;;) {
122
+ const incoming = this.readOnly(() => this.prepareHermes());
123
+ if (this.cachedReader) this.invalidate();
122
124
  try { this.runtime.exec('BEGIN'); }
123
125
  catch (error) { if (!sqliteContention(error)) throw error; continue; }
124
126
  this.active = true;
@@ -130,8 +132,11 @@ class ZtrackBoard {
130
132
  // Parsing, rules and rendering run against a snapshot, without a runtime writer transaction.
131
133
  this.pendingValues = new Map(); this.computing = true;
132
134
  this.load({ observe: true });
135
+ if (incoming && (incoming.version !== version || incoming.open !== hash(this.text) || incoming.archived !== hash(this.archiveText))) {
136
+ this.invalidate(); continue;
137
+ }
133
138
  if (this.seed) this.seedIntoMemory();
134
- else this.lift();
139
+ else this.lift(incoming);
135
140
  const result = fn(this.db);
136
141
  const changes = this.rows.changes(this.mem, { all: !this.rows.normalized() });
137
142
  const canonical = (row, omitted) => row && Object.fromEntries(Object.entries(row).filter(([key])=>!omitted.has(key)));
@@ -379,7 +384,7 @@ class ZtrackBoard {
379
384
  status: task.status === 'passed' ? (task.metadata?.status === 'cancelled' ? 'cancelled' : 'done') : (['done','cancelled'].includes(task.metadata?.status) ? 'todo' : task.metadata?.status ?? 'todo'),
380
385
  metadata: task.metadata?.properties ?? {}, assignee: task.metadata?.assignee ?? card.assignee,
381
386
  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) })) };
387
+ 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
388
  if (task.waitingOn) taskCard.relations.push({ type: 'blocked-by', issueId: task.waitingOn });
384
389
  return [tid, taskCard];
385
390
  });
@@ -401,7 +406,7 @@ class ZtrackBoard {
401
406
  const value = (task, state, field) => field === 'status' ? state?.stage ?? task?.status ?? 'todo'
402
407
  : field === 'blocked_by' ? json(state?.blocked_by, []).map(b => [b.kind, b.id]).sort()
403
408
  : field === 'paused' ? json(state?.paused, null)
404
- : field === 'archived' ? Boolean(state?.archived) : task?.[field] ?? null;
409
+ : field === 'archived' ? Boolean(state?.archived) : field === 'review_only' ? Boolean(state?.review_only) : task?.[field] ?? null;
405
410
  for (const id of new Set([...tasks.keys(), ...Object.keys(this.rows.cachedTasks)])) {
406
411
  if (this.errors.has(id)) continue;
407
412
  const oldTask = this.rows.cachedTasks[id], oldState = this.rows.cachedCards[id];
@@ -430,7 +435,7 @@ class ZtrackBoard {
430
435
  const task = { ...meta, ...this.saved.runtime?.[id], id, title: card.title, body: stored.body,
431
436
  status: card.status, assignee: card.assignee ?? null, created_at: meta.created_at ?? now(), priority: card.priority ?? 0,
432
437
  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,
438
+ 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
439
  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
440
  paused: card.paused ? JSON.stringify(card.paused) : null, archived: meta.archived ? 1 : 0, every: card.every ?? null };
436
441
  return { task, state };
@@ -485,6 +490,7 @@ class ZtrackBoard {
485
490
  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
491
  const metadata = Object.fromEntries(Object.entries(row).filter(([k, v]) => !RUNTIME.has(k) && !CARD_MAIN.has(k) && v != null));
487
492
  if (c.archived) metadata.archived = true;
493
+ if (c.review_only) metadata.review_only = true;
488
494
  if (json(c.blocked_by, []).length) metadata.blocked_by = json(c.blocked_by, []);
489
495
  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
496
  branch: row.branch_name || undefined, machine: c.machine || undefined, paused: json(c.paused, null) ?? undefined, every: c.every || undefined,
@@ -499,20 +505,33 @@ class ZtrackBoard {
499
505
  }
500
506
  const renderedKeys = new Set();
501
507
  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}`);
508
+ const c = states.get(row.id);
509
+ if (!cards.has(c.subtask_of)) throw new Error(`subtask ${row.id} has no arc ${c.subtask_of}`);
504
510
  const taken = occupied.get(c.subtask_of) ?? new Set(); occupied.set(c.subtask_of, taken);
505
511
  const match = /^([a-z]+\d+)\s+(.+)$/.exec(row.title);
506
512
  let key = this.taskKeys.get(row.id)?.split(':').at(-1) ?? match?.[1];
507
513
  if (!key || renderedKeys.has(`${c.subtask_of}:${key}`)) { let n = 1; while (taken.has(`c${n}`)) n++; key = `c${n}`; }
508
514
  taken.add(key); renderedKeys.add(`${c.subtask_of}:${key}`); this.taskKeys.set(row.id, `${c.subtask_of}:${key}`);
515
+ }
516
+ for (const row of rows.filter((r) => states.get(r.id)?.subtask_of && !this.errors.has(r.id))) {
517
+ const c = states.get(row.id); const parent = cards.get(c.subtask_of);
518
+ const key = this.taskKeys.get(row.id).split(':').at(-1);
519
+ const match = /^([a-z]+\d+)\s+(.+)$/.exec(row.title);
509
520
  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
521
  const originalTask = this.originals.get(c.subtask_of)?.acceptanceCriteria.find((t) => t.id === key);
522
+ const blocks = json(c.blocked_by, []);
523
+ const messages = blocks.filter(block => block.kind === 'message');
511
524
  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
525
  metadata: { id: row.id, status: c.stage ?? row.status, body: parsed.body, assignee: row.assignee, machine: c.machine,
513
526
  properties: Object.fromEntries(Object.entries(row).filter(([k,v]) => !RUNTIME.has(k) && !CARD_MAIN.has(k) && v != null)),
514
527
  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 })) });
528
+ waitingOn: messages[0]?.id,
529
+ blockedBy: blocks.filter(block => block !== messages[0]).map(block => {
530
+ const target = this.taskKeys.get(block.id);
531
+ if (!target) return { issue: this.fileIds.get(block.id) ?? block.id };
532
+ const [arcId, taskKey] = target.split(':');
533
+ return { issue: this.fileIds.get(arcId) ?? arcId, ac: taskKey };
534
+ }) });
516
535
  }
517
536
  const open = [], archived = [];
518
537
  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);
@@ -542,27 +561,39 @@ class ZtrackBoard {
542
561
  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
562
  }
544
563
 
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);
564
+ prepareHermes() {
565
+ if (this.seed || !existsSync(this.compatPath)) return null;
566
+ let entry; try { entry = workflowFor(this.root); } catch { return null; }
567
+ const compat = new DatabaseSync(this.compatPath, { readOnly: true });
549
568
  try {
569
+ const prepared = { entry, evidence: new Map(), version: this.get('version'), open: hash(read(this.path)), archived: hash(read(this.archive)) };
550
570
  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 {
571
+ prepared.rows = compat.prepare('SELECT * FROM task_events WHERE id > ? ORDER BY id').all(cursor);
572
+ for (const row of prepared.rows) {
573
+ const payload = json(row.payload, {}) ?? {};
574
+ if (row.kind !== 'completed' || payload.__supercode_export || !this.db.prepare('SELECT 1 FROM tasks WHERE id=?').get(row.task_id)) continue;
575
+ prepared.evidence.set(row.id, inspectCompletionEvidence(this.board(), row.task_id, entry, payload));
576
+ }
577
+ return prepared;
578
+ } finally { compat.close(); }
579
+ }
580
+ lift(prepared) {
581
+ if (!prepared) return;
582
+ const { entry, rows, evidence } = prepared;
583
+ const before = actorOf();
584
+ try {
585
+ for (const row of rows) {
586
+ const payload = json(row.payload, {}) ?? {};
587
+ if (payload.__supercode_export) continue;
588
+ const task = this.mem.prepare('SELECT * FROM tasks WHERE id = ?').get(row.task_id);
589
+ if (!task) continue;
590
+ this.mem.exec('SAVEPOINT hermes_card');
591
+ try {
561
592
  const roles = ['manager', 'reviewer', 'platform', 'hermes'];
562
593
  setActor(payload.by ?? 'hermes');
563
594
  let name = null; let words = { ...payload, reason: payload.reason ?? 'unblocked in Hermes' };
564
595
  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; }
596
+ else if (row.kind === 'completed') { name = 'complete'; words.summary = payload.summary ?? task.result; words.ci = evidence.get(row.id); }
566
597
  else if (row.kind === 'blocked' || row.kind === 'dependency_wait') { name = 'block'; words.on = payload.on ?? payload.parent ?? null; words.hermes = true; }
567
598
  else if (row.kind === 'archived' && statusOf(this.mem, task) !== 'done') name = 'cancel';
568
599
  if (row.kind === 'unblocked' && entry.workflow.verbs?.hermes_unpause) {
@@ -570,19 +601,19 @@ class ZtrackBoard {
570
601
  for (const effect of resumed.effects) this.mem.prepare('INSERT INTO supercode_effects (effect) VALUES (?)').run(JSON.stringify(effect));
571
602
  }
572
603
  if (name) {
573
- const result = fire(this.mem, this.board(), entry, task.id, name, { roles, payload: words });
604
+ const apply = () => fire(this.mem, this.board(), entry, task.id, name, { roles, payload: words });
605
+ const result = name === 'complete' ? withCompletionEvidence(this.board(), task.id, words.ci, apply) : apply();
574
606
  for (const effect of result.effects) this.mem.prepare('INSERT INTO supercode_effects (effect) VALUES (?)').run(JSON.stringify(effect));
575
607
  }
576
608
  event(this.mem, task.id, 'hermes_lifted', { by: 'hermes', source_event: row.id, verb: name, kind: row.kind });
577
609
  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
- }
610
+ } catch(error) {
611
+ this.mem.exec('ROLLBACK TO hermes_card; RELEASE hermes_card');
612
+ event(this.mem, task.id, 'hermes_refused', {by:'hermes',source_event:row.id,kind:row.kind,reason:error.message});
582
613
  }
583
- } finally { setActor(before); }
584
- if (rows.length) this.put('hermes_cursor', rows.at(-1).id);
585
- } finally { compat.close(); }
614
+ }
615
+ } finally { setActor(before); }
616
+ if (rows.length) this.put('hermes_cursor', rows.at(-1).id);
586
617
  }
587
618
  flushExport() {
588
619
  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.144",
3
+ "version": "0.4.146",
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": {