@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 +17 -1
- package/board/cli.mjs +17 -10
- package/board/dispatch.mjs +2 -2
- package/board/engine.mjs +16 -18
- package/board/store.mjs +1 -1
- package/board/tools.mjs +6 -5
- package/board/workflow.mjs +54 -6
- package/board/ztrack.mjs +90 -33
- package/mcp/hermes-kanban-tools.json +4 -0
- package/package.json +1 -1
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 =
|
|
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) =>
|
|
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)');
|
|
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}`);
|
package/board/dispatch.mjs
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/board/workflow.mjs
CHANGED
|
@@ -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
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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.
|
|
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);
|
|
503
|
-
if (!
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
552
|
-
const
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
}
|
|
584
|
-
|
|
585
|
-
|
|
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.
|
|
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": {
|