@volter/supercode-orchestrator 0.4.159 → 0.5.1
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 +16 -5
- package/adapters/chat-sdk.mjs +4 -1
- package/adapters/rh2.mjs +18 -20
- package/agent-home.mjs +35 -0
- package/agent-profiles.mjs +53 -0
- package/agents.mjs +443 -0
- package/bin/agent-door.mjs +53 -0
- package/bin/runtime-door.mjs +23 -0
- package/bin/runtime.mjs +59 -4
- package/board/agent-declarations.mjs +29 -0
- package/board/ci.mjs +6 -1
- package/board/cli.mjs +158 -31
- package/board/dispatch.mjs +304 -124
- package/board/engine.mjs +30 -10
- package/board/event-fleet.mjs +59 -0
- package/board/event-relay.mjs +45 -0
- package/board/event-stream.mjs +104 -0
- package/board/incidents.mjs +57 -0
- package/board/published.mjs +86 -0
- package/board/runtime-door.mjs +12 -0
- package/board/store.mjs +39 -6
- package/board/tools.mjs +1 -0
- package/board/wake.mjs +17 -56
- package/board/workflow.mjs +28 -7
- package/board/workspace.mjs +18 -15
- package/commands.mjs +2 -2
- package/conversations.mjs +108 -0
- package/cron.mjs +30 -4
- package/gates.mjs +3 -2
- package/hermes-cli.mjs +19 -2
- package/index.d.ts +43 -2
- package/index.mjs +4 -0
- package/installed-apps.mjs +52 -0
- package/ir.mjs +3 -3
- package/kanban-worker.mjs +16 -6
- package/loop.mjs +211 -21
- package/mcp/bridge.mjs +13 -2
- package/mcp/tools.mjs +17 -0
- package/mcp/verbs.mjs +29 -7
- package/ontology.d.ts +246 -2
- package/operator.mjs +6 -1
- package/package.json +2 -2
- package/reducer.mjs +133 -28
- package/webhook.mjs +8 -4
package/bin/runtime.mjs
CHANGED
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
//
|
|
53
53
|
// Everything it prints is one JSON object per line on stdout.
|
|
54
54
|
|
|
55
|
+
import { SenderIdentityRecord } from '@volter/teams/sender-identity';
|
|
55
56
|
import { existsSync, mkdirSync, rmdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
56
57
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
57
58
|
import { spawn } from 'node:child_process';
|
|
@@ -65,10 +66,11 @@ import { hermesAccess } from '../hermes-cli.mjs';
|
|
|
65
66
|
|
|
66
67
|
import {
|
|
67
68
|
load, save, refreshVault, step, importFrom, exportTo, closeWorld, Orchestrator, LoopbackAdapter,
|
|
68
|
-
ChatSdkAdapter, ApiServerAdapter,
|
|
69
|
+
ChatSdkAdapter, ApiServerAdapter, Rh2Adapter,
|
|
69
70
|
TICK_MS, accessPolicy, applyOperator, performRemoveProfileDir, OPERATOR_OPS,
|
|
70
71
|
} from '../index.mjs';
|
|
71
72
|
import * as events from '../events.mjs';
|
|
73
|
+
import { appView, installApp, installedApps, removeApp } from '../installed-apps.mjs';
|
|
72
74
|
import { httpJobExecutor } from '../hosted.mjs';
|
|
73
75
|
import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
|
|
74
76
|
|
|
@@ -326,7 +328,7 @@ async function runOperator(op, argv) {
|
|
|
326
328
|
* block. A platform whose adapter does not exist yet is listed as
|
|
327
329
|
* `unsupported`: the daemon never pretends a channel is connected.
|
|
328
330
|
*/
|
|
329
|
-
function buildAdapters(state, vault = new Map(), log = () => {}) {
|
|
331
|
+
function buildAdapters(state, vault = new Map(), log = () => {}, root = null) {
|
|
330
332
|
const adapters = {};
|
|
331
333
|
const report = [];
|
|
332
334
|
for (const [profileName, profile] of Object.entries(state.profiles)) {
|
|
@@ -350,6 +352,15 @@ function buildAdapters(state, vault = new Map(), log = () => {}) {
|
|
|
350
352
|
report.push({ platform, profile: profileName, adapter: selector, state: 'connecting' });
|
|
351
353
|
}
|
|
352
354
|
}
|
|
355
|
+
// Installed apps (ADR 0017): each attaches its channel with the installation's own token, under the profile it was
|
|
356
|
+
// installed for. Uninstalling on the other side revokes the token and the channel stops.
|
|
357
|
+
for (const app of root ? installedApps(root) : []) {
|
|
358
|
+
if (app.app !== 'room-chat') continue;
|
|
359
|
+
if (!state.profiles[app.profile]) { report.push({ platform: 'rh2', profile: app.profile, app: app.name, state: 'unsupported (its profile does not exist)' }); continue; }
|
|
360
|
+
if (adapters.rh2) { report.push({ platform: 'rh2', profile: app.profile, app: app.name, state: 'already owned by another profile' }); continue; }
|
|
361
|
+
adapters.rh2 = new Rh2Adapter({ platform: 'rh2', profile: app.profile, log, vault, channel: { enabled: true, extra: { api_url: app.origin, organization: app.organizationId, principal_id: app.principalId, token: app.token } } });
|
|
362
|
+
report.push({ platform: 'rh2', profile: app.profile, app: app.name, adapter: 'rh2', state: 'connecting' });
|
|
363
|
+
}
|
|
353
364
|
return { adapters, report };
|
|
354
365
|
}
|
|
355
366
|
|
|
@@ -396,6 +407,7 @@ function parseAccessArgs(argv) {
|
|
|
396
407
|
else if (arg.startsWith('--root=')) args.root = arg.slice('--root='.length);
|
|
397
408
|
else if (arg === '--profile') args.profile = argv[(i += 1)];
|
|
398
409
|
else if (arg.startsWith('--profile=')) args.profile = arg.slice('--profile='.length);
|
|
410
|
+
else if (arg === '--principal') args.principal = argv[(i += 1)];
|
|
399
411
|
else if (arg.startsWith('--')) throw new Error(`unknown argument \`${arg}\``);
|
|
400
412
|
else args.rest.push(arg);
|
|
401
413
|
}
|
|
@@ -433,6 +445,30 @@ const ACCESS_OPS = {
|
|
|
433
445
|
* `access <verb> ...`: the COLD path. Access lives in Hermes's stores, so each verb is performed through
|
|
434
446
|
* Hermes's own pairing store and writers, then the home is loaded again to report what stands.
|
|
435
447
|
*/
|
|
448
|
+
/** `apps install NAME --origin URL --client-id ID --client-secret-file FILE --code CODE [--profile P]`, `apps list`, `apps remove NAME`. */
|
|
449
|
+
async function appsMain(argv) {
|
|
450
|
+
const [verb, ...rest] = argv;
|
|
451
|
+
const flags = {}, positional = [];
|
|
452
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
453
|
+
if (rest[i].startsWith('--')) { flags[rest[i].slice(2)] = rest[i + 1]; i += 1; } else positional.push(rest[i]);
|
|
454
|
+
}
|
|
455
|
+
const root = resolve(flags.root ?? defaultRoot());
|
|
456
|
+
try {
|
|
457
|
+
if (verb === 'list') { say('apps', { root, apps: installedApps(root).map(appView) }); return; }
|
|
458
|
+
if (verb === 'remove') { say('apps', { root, removed: removeApp(root, positional[0] ?? '') }); return; }
|
|
459
|
+
if (verb === 'install') {
|
|
460
|
+
for (const name of ['origin', 'client-id', 'client-secret-file', 'code']) if (!flags[name]) throw new Error(`apps install needs --${name}`);
|
|
461
|
+
const clientSecret = readFileSync(flags['client-secret-file'], 'utf8').trim();
|
|
462
|
+
say('apps', { root, installed: appView(await installApp({ root, name: positional[0] ?? '', origin: flags.origin, clientId: flags['client-id'], clientSecret, code: flags.code, profile: flags.profile ?? 'default' })) });
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
throw new Error('apps install|list|remove');
|
|
466
|
+
} catch (error) {
|
|
467
|
+
say('error', { root, message: String(error.message ?? error) });
|
|
468
|
+
process.exit(1);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
436
472
|
async function accessMain(argv) {
|
|
437
473
|
let parsed;
|
|
438
474
|
try {
|
|
@@ -443,6 +479,16 @@ async function accessMain(argv) {
|
|
|
443
479
|
}
|
|
444
480
|
const [verb, ...rest] = parsed.rest;
|
|
445
481
|
const root = resolve(parsed.root ?? defaultRoot());
|
|
482
|
+
// Hermes's gateway reads its pairing store on every message, so an approval admits the person at once. A live
|
|
483
|
+
// daemon holds the loaded access in memory: the approval goes over its socket (its grant reaches that allowlist).
|
|
484
|
+
const lease = verb === 'pair-approve' ? readLease(root) : null;
|
|
485
|
+
if (lease && leaseIsLive(lease)) {
|
|
486
|
+
const answer = await overSocket(socketPathFor(root), { op: 'access.pair_approve', args: { code: rest[0], principal: parsed.principal ?? null }, profile: parsed.profile });
|
|
487
|
+
const fresh = (await load(root)).state.profiles[parsed.profile];
|
|
488
|
+
say(answer.ok ? 'access' : 'error', answer.ok ? accessReport(root, parsed.profile, fresh, { verb, changed: true, door: 'live' }) : { root, profile: parsed.profile, verb, message: answer.error });
|
|
489
|
+
if (!answer.ok) process.exit(1);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
446
492
|
let state;
|
|
447
493
|
let vault;
|
|
448
494
|
try {
|
|
@@ -481,8 +527,13 @@ async function accessMain(argv) {
|
|
|
481
527
|
say('error', { root, profile: parsed.profile, verb, message: `pairing code ${args.code} is not pending in Hermes's pairing store (wrong, expired, or locked out)` });
|
|
482
528
|
process.exit(1);
|
|
483
529
|
}
|
|
530
|
+
let identity=null;
|
|
531
|
+
if(op==='approve') {
|
|
532
|
+
try {identity=await new SenderIdentityRecord().approvedPairing({...got,code:args.code,principal_id:parsed.principal??null,location:profile.dir});}
|
|
533
|
+
catch(error) {say('identity-warning',{root,profile:parsed.profile,message:error.message,pairing_approved:true});}
|
|
534
|
+
}
|
|
484
535
|
const fresh = (await load(root)).state.profiles[parsed.profile];
|
|
485
|
-
say('access', accessReport(root, parsed.profile, fresh, { verb, changed: true, ...(op === 'approve' ? { approved: got } : {}) }));
|
|
536
|
+
say('access', accessReport(root, parsed.profile, fresh, { verb, changed: true, identity, ...(op === 'approve' ? { approved: got } : {}) }));
|
|
486
537
|
}
|
|
487
538
|
|
|
488
539
|
/**
|
|
@@ -571,6 +622,10 @@ async function main() {
|
|
|
571
622
|
await accessMain(argv.slice(1));
|
|
572
623
|
return;
|
|
573
624
|
}
|
|
625
|
+
if (argv[0] === 'apps') {
|
|
626
|
+
await appsMain(argv.slice(1));
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
574
629
|
if (argv[0] === 'apply') {
|
|
575
630
|
try {
|
|
576
631
|
const code = await runApply(argv.slice(1));
|
|
@@ -669,7 +724,7 @@ async function main() {
|
|
|
669
724
|
}
|
|
670
725
|
}
|
|
671
726
|
|
|
672
|
-
const { adapters, report } = buildAdapters(state, vault, (level, message) => say('log', { level, message }));
|
|
727
|
+
const { adapters, report } = buildAdapters(state, vault, (level, message) => say('log', { level, message }), root);
|
|
673
728
|
// `SUPERCODE_BIN` is set by `supercode orchestrator start` to the very
|
|
674
729
|
// binary that spawned this daemon, so the worker doors it opens are that
|
|
675
730
|
// build's, not whatever `supercode` a PATH lookup happens to find.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Consume the card-creation intent after the board transaction; retries reuse the daemon-minted id.
|
|
2
|
+
import { agentScope, teamsUnavailable, ensureCardAgent, localCardAgent, finishAgentAssignment } from '../agents.mjs';
|
|
3
|
+
import { event, json, tx, view } from './store.mjs';
|
|
4
|
+
import { workflowFor } from './workflow.mjs';
|
|
5
|
+
|
|
6
|
+
export function declareCardAgentEffect(board,effect) {
|
|
7
|
+
const task=view(board,db=>db.prepare('SELECT t.*,c.subtask_of,c.archived,c.sponsor FROM tasks t JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(effect.id));
|
|
8
|
+
if(!task||task.subtask_of)return true;
|
|
9
|
+
const entry=workflowFor(board.root),profile=task.assignee??effect.profile??entry.params.default_assignee;
|
|
10
|
+
// The reasons already told since the card's last declaration: each is recorded once, so a retry that alternates
|
|
11
|
+
// between two refusals writes nothing (a write wakes the dispatcher, which would retry at once).
|
|
12
|
+
const told=view(board,db=>new Set(db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='agent_declaration_pending' AND id>COALESCE((SELECT max(id) FROM task_events WHERE task_id=? AND kind='agent_declared'),0)").all(effect.id,effect.id).map(row=>json(row.payload,{})?.reason)));
|
|
13
|
+
try {
|
|
14
|
+
if(!entry.profiles[profile])throw new Error('card agent declaration needs a named profile');
|
|
15
|
+
const context=effect.context??process.env.SUPERCODE_CONTEXT,scope=agentScope(context);
|
|
16
|
+
const options={root:board.root,org:scope.org,server:scope.server,profiles:entry.profiles,
|
|
17
|
+
profile,card:effect.id,role:effect.role,sponsor:task.sponsor??null,context,starterSession:effect.starter_session};
|
|
18
|
+
// Without a reachable Teams the role's identity is minted here; the board's reconcile declares it later.
|
|
19
|
+
let agent;
|
|
20
|
+
if(scope.offline)agent=localCardAgent(options);
|
|
21
|
+
else try{agent=ensureCardAgent(options);}catch(error){if(!teamsUnavailable(error))throw error;agent=localCardAgent(options);}
|
|
22
|
+
if(task.archived||['done','cancelled','archived'].includes(task.status))finishAgentAssignment(board.root,scope.org,agent.assignment,`card ${effect.id} ${task.status}`);
|
|
23
|
+
tx(board,db=>event(db,effect.id,'agent_declared',{agent:agent.identity.id,org:scope.org,role:effect.role,profile,assignment:agent.assignment,synced:agent.synced!==false}));
|
|
24
|
+
return true;
|
|
25
|
+
}catch(error) {
|
|
26
|
+
if(!told.has(error.message))tx(board,db=>event(db,effect.id,'agent_declaration_pending',{role:effect.role,profile,reason:error.message}));
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
package/board/ci.mjs
CHANGED
|
@@ -218,6 +218,8 @@ function knownRepositories() {
|
|
|
218
218
|
export function reviewDue(board, id, prs) {
|
|
219
219
|
const unjudged = prs.filter((p) => !judgedHead(board, id, p.head));
|
|
220
220
|
if (!unjudged.length) return false;
|
|
221
|
+
const voided = board.db.prepare("SELECT r.payload FROM task_events v JOIN task_events r ON r.id=json_extract(v.payload,'$.review_request') AND r.task_id=v.task_id WHERE v.task_id=? AND v.kind='review_voided' ORDER BY v.id DESC LIMIT 1").get(id);
|
|
222
|
+
if (candidatePullRequests(voided && JSON.parse(voided.payload)?.ci).some(j => unjudged.some(p => p.url === j.pr.url && p.head === j.commit))) return true;
|
|
221
223
|
const last = board.db.prepare(`SELECT r.payload, (SELECT MIN(v.created_at) FROM task_events v WHERE v.task_id = r.task_id
|
|
222
224
|
AND v.kind IN ('changes_requested', 'review_pass') AND v.id > r.id) AS verdict_at
|
|
223
225
|
FROM task_events r WHERE r.task_id = ? AND r.kind = 'review_requested'
|
|
@@ -248,6 +250,7 @@ export function judgedHead(board, id, head) {
|
|
|
248
250
|
return board.db.prepare(`SELECT v.id, v.kind FROM task_events r JOIN task_events v ON v.task_id = r.task_id
|
|
249
251
|
AND v.kind IN ('changes_requested', 'review_pass') AND v.id > r.id
|
|
250
252
|
AND v.id < COALESCE((SELECT MIN(n.id) FROM task_events n WHERE n.task_id = r.task_id AND n.kind = 'review_requested' AND n.id > r.id), 9223372036854775807)
|
|
253
|
+
AND NOT EXISTS (SELECT 1 FROM task_events x WHERE x.task_id=r.task_id AND x.kind='review_voided' AND x.id>v.id AND json_extract(x.payload,'$.review_request')=r.id)
|
|
251
254
|
WHERE r.task_id = ? AND r.kind = 'review_requested' AND (json_extract(r.payload, '$.ci.commit') = ?
|
|
252
255
|
OR EXISTS (SELECT 1 FROM json_each(r.payload, '$.ci.prs') p WHERE json_extract(p.value, '$.commit') = ?)) ORDER BY v.id LIMIT 1`).get(id, head, head) ?? null;
|
|
253
256
|
}
|
|
@@ -264,7 +267,9 @@ export function inspectCompletionEvidence(board, id, entry, payload = {}, stage
|
|
|
264
267
|
}
|
|
265
268
|
const candidate = stage === 'acceptance' ? reviewCandidate(board, id) : null;
|
|
266
269
|
if (candidate) {
|
|
267
|
-
const row = board.db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='review_pass' ORDER BY id DESC LIMIT 1").get(id);
|
|
270
|
+
const row = board.db.prepare("SELECT id,payload FROM task_events WHERE task_id=? AND kind='review_pass' ORDER BY id DESC LIMIT 1").get(id);
|
|
271
|
+
const voided = board.db.prepare("SELECT id FROM task_events WHERE task_id=? AND kind='review_voided' ORDER BY id DESC LIMIT 1").get(id);
|
|
272
|
+
if (voided && (!row || voided.id > row.id)) throw new Error('The manager voided this verdict; the board must obtain a new review before acceptance.');
|
|
268
273
|
const passed = candidatePullRequests(row && JSON.parse(row.payload)?.ci);
|
|
269
274
|
const wanted = candidatePullRequests(candidate);
|
|
270
275
|
if (wanted.length !== passed.length || wanted.some((w) => !passed.some((p) => p.pr.url === w.pr.url && p.commit === w.commit))) throw new Error('The current PR heads need their independent verdict before acceptance.');
|
package/board/cli.mjs
CHANGED
|
@@ -4,19 +4,22 @@
|
|
|
4
4
|
// `serve`, `--at`) are added beside them. The Rust CLI hands every verb but `load` to `bin/orchestrator.mjs workflow`,
|
|
5
5
|
// which lands here. The home is `--root`, else `SUPERCODE_ORCHESTRATOR_HOME`, else `HERMES_HOME`; the board is
|
|
6
6
|
// `--board <slug>`, else `HERMES_KANBAN_BOARD`, else the home's current board (`kanban/current`), else `default`.
|
|
7
|
+
import { taskStatusOf } from './published.mjs';
|
|
7
8
|
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
8
9
|
import { spawnSync } from 'node:child_process';
|
|
9
10
|
import { basename, join, resolve } from 'node:path';
|
|
10
11
|
import { tmpdir, userInfo } from 'node:os';
|
|
11
12
|
import { read } from '../orchestration.mjs';
|
|
12
|
-
import { actorOf, view, backingPaths, layOut, backingOf, boardExists, boardPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
|
|
13
|
+
import { actorOf, view, backingPaths, layOut, backingOf, boardExists, boardPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, setPrincipal, principalOf, isCaller, tx } from './store.mjs';
|
|
13
14
|
import { isLocalWorkspace } from './workspace.mjs';
|
|
14
15
|
import { openZtrackBoard } from './ztrack.mjs';
|
|
15
16
|
import * as engine from './engine.mjs';
|
|
16
17
|
import { requestAdoption, boardAddress, boardLabel, boardSettings, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, supercodeBin, tick } from './dispatch.mjs';
|
|
17
18
|
import { watchBoard } from './wake.mjs';
|
|
19
|
+
import { streamBoardPublications } from './event-stream.mjs';
|
|
18
20
|
import { completionOwnerOf, isLatestReviewer, loadWorkflow, requestSessionClose, scopeOf, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
|
|
19
21
|
import { evaluate } from './expr.mjs';
|
|
22
|
+
import { agentDoor,agentScope,activeAgentAssignments,reassignAgent,agentUsage,localAgentRecord,agentRunForSession } from '../agents.mjs';
|
|
20
23
|
|
|
21
24
|
const HELP = `supercode workflow <verb> [--root <home>] [--board <slug>] [--json]
|
|
22
25
|
|
|
@@ -30,6 +33,7 @@ Hermes Kanban's verbs, with their flags:
|
|
|
30
33
|
swarm <goal> --worker PROFILE:TITLE[:SKILL,SKILL]... --verifier P --synthesizer P
|
|
31
34
|
list [--mine] [--assignee] [--status] [--tenant] [--session] [--archived] [--sort] show <id> context <id> runs <id>
|
|
32
35
|
set <id> <assignee|machine|model|every> <value|none> [--provider] [--ids ...] assign <id> <profile|none> set-model <id> [model] [--provider] set-goal <id>... [--goal-max-turns N] [--off]
|
|
36
|
+
reassign <id> --agent AGENT [--role implementer] (moves a card role's assignment to another agent; sessions keep their identity)
|
|
33
37
|
claim <id> [--ttl] reclaim <id> [--reason] heartbeat <id> [--note] [--until <time|30m>] link|unlink <parent> <child>
|
|
34
38
|
comment <id> <text> [--author] [--max-len] attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
|
|
35
39
|
complete <id>... [--result] [--summary] [--metadata JSON] edit <id> --result [--summary] [--metadata]
|
|
@@ -47,8 +51,17 @@ Hermes Kanban's verbs, with their flags:
|
|
|
47
51
|
pause [reason] · resume (Hermes's emergency stop, the ESTOP file: new starts stop, running sessions go on)
|
|
48
52
|
|
|
49
53
|
supercode's own:
|
|
50
|
-
|
|
51
|
-
|
|
54
|
+
events [--after <cursor>] [--once] --json
|
|
55
|
+
Native retained card snapshots, then committed changes; a changed source requests resynchronization.
|
|
56
|
+
usage [--agent AGENT] [--sponsor PERSON] [--json]
|
|
57
|
+
Tokens each agent's ended card sessions recorded (the harness's own records), per model, summed on its sponsor.
|
|
58
|
+
init --backing sqlite|files · boards create <slug>
|
|
59
|
+
Where the Org's one task store is kept: sqlite is Hermes's kanban.db (the default); files is one JSON file per
|
|
60
|
+
card. A board is a saved view over that store: the cards carrying its slug as a tag (create --tag, tag/untag).
|
|
61
|
+
create … --tag T… --sponsor PERSON | --requested-by <platform>:<user> [--request <message ref>]
|
|
62
|
+
Tags are ordered (Hermes's tenant is the first). A proven requester sponsors the card with the request as
|
|
63
|
+
evidence; an unproven one is recorded as requested by (unverified) and the sponsor stays inherited.
|
|
64
|
+
tag <id> <tag>… untag <id> <tag>…
|
|
52
65
|
create … --machine M --subtask-of ARC --notify ADDRESS|--no-notify --no-start
|
|
53
66
|
With a pane lane and room, a card starts at once and answers "started" with its session; else it answers why it
|
|
54
67
|
waits, and its creator is told when it starts. A subtask is worked by its arc's session and never starts alone.
|
|
@@ -72,9 +85,9 @@ supercode's own:
|
|
|
72
85
|
workflow declares who may block, pause or finish a card; request-state mails its manager without changing state.
|
|
73
86
|
`;
|
|
74
87
|
|
|
75
|
-
const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm', 'pr', 'pr-head', 'reviewed-sha', 'finding']);
|
|
88
|
+
const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm', 'tag', 'pr', 'pr-head', 'reviewed-sha', 'finding']);
|
|
76
89
|
const FLAGS = new Set(['json', 'triage', 'no-notify', 'no-start', 'all', 'force', 'help', 'mine', 'archived', 'reclaim', 'dry-run',
|
|
77
|
-
'goal', 'no-goal', 'off', 'switch', 'delete', 'no-attachments', 'include-logs', 'verbose']);
|
|
90
|
+
'once', 'goal', 'no-goal', 'off', 'switch', 'delete', 'no-attachments', 'include-logs', 'verbose']);
|
|
78
91
|
// flags that take every following word up to the next flag (Hermes's nargs='+')
|
|
79
92
|
const GREEDY = new Set(['ids', 'rm']);
|
|
80
93
|
|
|
@@ -120,7 +133,7 @@ function boardOf(root, args) {
|
|
|
120
133
|
for (const want of [args.board, process.env.HERMES_KANBAN_BOARD, existsSync(currentFile(root)) ? readFileSync(currentFile(root), 'utf8').trim() : null]) {
|
|
121
134
|
if (!want) continue;
|
|
122
135
|
const slug = normalSlug(want);
|
|
123
|
-
if (slug === DEFAULT_BOARD || boardExists(root, slug)) return slug;
|
|
136
|
+
if (slug === DEFAULT_BOARD || boardExists(root, slug) || existsSync(metaPath(root, slug))) return slug;
|
|
124
137
|
if (want === args.board) return slug;
|
|
125
138
|
}
|
|
126
139
|
return DEFAULT_BOARD;
|
|
@@ -128,6 +141,8 @@ function boardOf(root, args) {
|
|
|
128
141
|
|
|
129
142
|
const boardDir = (root, slug) => join(root, 'kanban', 'boards', slug);
|
|
130
143
|
const metaPath = (root, slug) => join(boardDir(root, slug), 'board.json');
|
|
144
|
+
/** A saved view: a named board with metadata and no store of its own (C4). */
|
|
145
|
+
const isView = (root, slug) => slug !== DEFAULT_BOARD && !boardExists(root, slug) && existsSync(metaPath(root, slug));
|
|
131
146
|
|
|
132
147
|
function boardMeta(root, slug) {
|
|
133
148
|
const meta = { slug, name: slug === DEFAULT_BOARD ? 'Default' : slug, description: '', icon: '', color: '', default_workdir: null, project_id: null, created_at: null, archived: false };
|
|
@@ -161,10 +176,15 @@ function boardsOf(root, { includeArchived = true } = {}) {
|
|
|
161
176
|
}
|
|
162
177
|
|
|
163
178
|
function counts(root, slug) {
|
|
164
|
-
|
|
165
|
-
|
|
179
|
+
const view = isView(root, slug);
|
|
180
|
+
if (!view && !boardExists(root, slug)) return {};
|
|
181
|
+
if (view && !boardExists(root, DEFAULT_BOARD)) return {};
|
|
182
|
+
const b = openBoard(root, view ? DEFAULT_BOARD : slug);
|
|
166
183
|
try {
|
|
167
|
-
|
|
184
|
+
const rows = b.db.prepare('SELECT t.status, t.tenant, c.tags FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id').all();
|
|
185
|
+
const out = {};
|
|
186
|
+
for (const r of rows) if (!view || tagsOf(r).includes(slug)) out[r.status] = (out[r.status] ?? 0) + 1;
|
|
187
|
+
return out;
|
|
168
188
|
} finally { b.close(); }
|
|
169
189
|
}
|
|
170
190
|
|
|
@@ -181,14 +201,54 @@ async function laneState(root) {
|
|
|
181
201
|
|
|
182
202
|
/** The caller's own supercode address, when it is a session supercode can reach (for its card's notices). */
|
|
183
203
|
// The calling session's own address, as its actor is named: no fleet scan (a `message list` costs seconds).
|
|
204
|
+
// the address the board recorded for the calling session when it launched it (a verb mailed from another machine
|
|
205
|
+
// runs on the board's machine as its sender, whose address names the sender's machine)
|
|
206
|
+
let recordedCaller = null;
|
|
184
207
|
function callerAddress() {
|
|
185
208
|
const id = callerSession();
|
|
186
209
|
if (!id) return null;
|
|
187
|
-
return `sc:${localMachine()}:${id === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code'}:${id}`;
|
|
210
|
+
return recordedCaller ?? `sc:${localMachine()}:${id === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code'}:${id}`;
|
|
188
211
|
}
|
|
189
212
|
|
|
190
213
|
const callerName = () => process.env.HERMES_PROFILE || process.env.USER || 'user';
|
|
191
214
|
|
|
215
|
+
/**
|
|
216
|
+
* C4: who the caller is as a principal, as a card's `created_by` records it: its agent (a relaunch keeps it), the
|
|
217
|
+
* automation running it, its own plain session, or the person its Teams login names. Never the OS user; null when
|
|
218
|
+
* nothing names one.
|
|
219
|
+
*/
|
|
220
|
+
function callerPrincipal(root, sid, { teams = false } = {}) {
|
|
221
|
+
if (process.env.SUPERCODE_AGENT_ID) return `agent:${process.env.SUPERCODE_AGENT_ID}`;
|
|
222
|
+
if (process.env.SUPERCODE_AUTOMATION) return `automation:${process.env.SUPERCODE_AUTOMATION}`;
|
|
223
|
+
if (sid) {
|
|
224
|
+
const harness = sid === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code';
|
|
225
|
+
const owner = agentRunForSession(root, [`${harness}:${sid}`]);
|
|
226
|
+
if (owner) return `agent:${owner.agent_id}`;
|
|
227
|
+
// a session an agent declared elsewhere owns (a manager's main session) is that agent's, as Teams records it
|
|
228
|
+
let declared = null;
|
|
229
|
+
try { declared = agentDoor('session', [`${harness}:${sid}`], { context: process.env.SUPERCODE_CONTEXT }).data?.agent?.id ?? null; } catch { /* no Teams: the session */ }
|
|
230
|
+
return declared ? `agent:${declared}` : `session:${callerAddress()}`;
|
|
231
|
+
}
|
|
232
|
+
if (!teams) return null;
|
|
233
|
+
const scope = agentScope(process.env.SUPERCODE_CONTEXT);
|
|
234
|
+
return !scope.offline && scope.principal ? `person:${scope.principal}` : null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** C4: a requester named by an outside id; a proven link makes them the card's sponsor, anything less is recorded unverified. */
|
|
238
|
+
function requestedSponsor(args) {
|
|
239
|
+
if (args.sponsor) return { sponsor: args.sponsor, sponsorEvidence: args.request ? { request: args.request, by: principalOf() } : null };
|
|
240
|
+
if (!args['requested-by']) return {};
|
|
241
|
+
let resolved = null;
|
|
242
|
+
try { resolved = agentDoor('resolveIdentity', [args['requested-by']], { context: process.env.SUPERCODE_CONTEXT }).data; } catch { /* no Teams: unverified */ }
|
|
243
|
+
if (resolved?.classification === 'proven' && resolved.principal?.kind === 'user') {
|
|
244
|
+
const link = resolved.links?.find((l) => l.classification === 'proven');
|
|
245
|
+
return { sponsor: resolved.principal.id, sponsorEvidence: { observation: args['requested-by'], link: link?.id ?? null, proof: link?.proof?.kind ?? null, request: args.request ?? null, by: principalOf() } };
|
|
246
|
+
}
|
|
247
|
+
return { requestedByUnverified: { observation: args['requested-by'], label: resolved?.label ?? 'unresolved', request: args.request ?? null } };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const tagsOf = (card) => { const tags = Array.isArray(card.tags) ? card.tags : json(card.tags, null); return Array.isArray(tags) ? tags : card.tenant ? [card.tenant] : []; };
|
|
251
|
+
|
|
192
252
|
const say = (args, human, value) => { process.stdout.write(args.json ? `${JSON.stringify(value, null, 2)}\n` : `${human}\n`); };
|
|
193
253
|
|
|
194
254
|
// Hermes's own output lines, so a reader of `hermes kanban list` reads this one
|
|
@@ -241,7 +301,12 @@ export async function runWorkflow(argv) {
|
|
|
241
301
|
const [verb, ...rest] = args._;
|
|
242
302
|
if (!verb || verb === 'help' || args.help) { process.stdout.write(HELP); return 0; }
|
|
243
303
|
const root = homeOf(args);
|
|
244
|
-
|
|
304
|
+
if (verb === 'events') return streamBoardPublications({root,after:args.after,once:args.once});
|
|
305
|
+
// C4: one task store per Org. A named board without a store of its own is a saved view over it: its tag selects
|
|
306
|
+
// the cards it shows, and a card filed through it carries that tag.
|
|
307
|
+
const named = boardOf(root, args);
|
|
308
|
+
const viewTag = isView(root, named) ? named : null;
|
|
309
|
+
const slug = viewTag ? DEFAULT_BOARD : named;
|
|
245
310
|
const open = (create = false) => { const b = openBoard(root, slug, { create, document: args.document ?? null, backing: create ? args.backing ?? null : null }); b.label = boardLabel(root, slug); return b; };
|
|
246
311
|
const withBoard = (fn, create = false) => { const b = open(create); try { return fn(b); } finally { b.close(); } };
|
|
247
312
|
const withBoardAsync = async (fn) => { const b = open(); try { return await fn(b); } finally { b.close(); } };
|
|
@@ -254,7 +319,9 @@ export async function runWorkflow(argv) {
|
|
|
254
319
|
await loadWorkflow(root).catch((error) => { if (verb !== 'init' && verb !== 'boards') throw error; });
|
|
255
320
|
const sid = listing && !args.mine ? null : callerSession();
|
|
256
321
|
// what this command records is the calling session's act (its address, as its subscriptions name it)
|
|
257
|
-
|
|
322
|
+
recordedCaller = sid && boardExists(root, slug) ? withBoard((b) => b.db.prepare("SELECT json_extract(metadata, '$.supercode.address') AS address FROM task_runs WHERE json_extract(metadata, '$.supercode.session_id') = ? ORDER BY id DESC LIMIT 1").get(sid)?.address) ?? null : null;
|
|
323
|
+
if (sid) setActor(callerAddress());
|
|
324
|
+
setPrincipal(callerPrincipal(root, sid, { teams: !sid && !READS.has(verb) }));
|
|
258
325
|
if (sid && !READS.has(verb) && boardExists(root, slug)) {
|
|
259
326
|
withBoard((b) => { reconcileCallerLaunch(b, sid); const own = ownCard(b.db, sid); if (own) authorize(b.db, verb, rest, args, own); });
|
|
260
327
|
}
|
|
@@ -265,8 +332,8 @@ export async function runWorkflow(argv) {
|
|
|
265
332
|
if (verb === 'comment' && isLatestReviewer(db, cardId, actorOf()) && !roles.includes('reviewer')) roles.push('reviewer');
|
|
266
333
|
return roles;
|
|
267
334
|
});
|
|
268
|
-
//
|
|
269
|
-
if (!sid && verb !== 'help') setActor(`user:${userInfo().username}`);
|
|
335
|
+
// A plain terminal acts as its signed-in person; an offline OS label grants no creator authority.
|
|
336
|
+
if (!sid && verb !== 'help') setActor(principalOf() ?? `user:${userInfo().username}`);
|
|
270
337
|
|
|
271
338
|
switch (verb) {
|
|
272
339
|
case 'migrate': {
|
|
@@ -307,11 +374,15 @@ export async function runWorkflow(argv) {
|
|
|
307
374
|
title: rest.join(' '), body: args.body, assignee: args.assignee === 'none' ? null : args.assignee, machine: args.machine,
|
|
308
375
|
workspace, branch: args.branch, parents: args.parent, priority: args.priority, tenant: args.tenant,
|
|
309
376
|
triage: args.triage, idempotencyKey: args['idempotency-key'], maxRuntime: duration(args['max-runtime']),
|
|
310
|
-
maxRetries, skills: args.skill, model: args.model, provider: args.provider, createdBy: args['created-by'] ??
|
|
377
|
+
maxRetries, skills: args.skill, model: args.model, provider: args.provider, createdBy: args['created-by'] ?? null,
|
|
311
378
|
noStart: Boolean(args['no-start']), adopt: args.adopt ?? null,
|
|
312
379
|
every: args.every ?? null, sessionId: callerSession(), subtaskOf: args['subtask-of'] ?? null, initialStatus: args['initial-status'] ?? null,
|
|
313
380
|
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,
|
|
381
|
+
tags: [...(viewTag ? [viewTag] : []), ...(args.tag ?? [])],
|
|
382
|
+
// a person filing for themselves sponsors it; an agent filing a person's request names them (proven) or records them unverified
|
|
383
|
+
...(args.sponsor || args['requested-by'] ? requestedSponsor(args) : principalOf()?.startsWith('person:') ? { sponsor: principalOf().slice('person:'.length) } : {}),
|
|
314
384
|
}), true);
|
|
385
|
+
await withBoardAsync(b=>engine.runEffects(b,created.effects??[]));
|
|
315
386
|
let answer = { id: created.id, existing: created.existing };
|
|
316
387
|
if (args.adopt) {
|
|
317
388
|
withBoard(b => { if (!rolesFor(b.db, created.id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager adopts an existing session.'); });
|
|
@@ -339,7 +410,7 @@ export async function runWorkflow(argv) {
|
|
|
339
410
|
return { profile, title, skills: skills ? skills.split(',').map((x) => x.trim()).filter(Boolean) : [] };
|
|
340
411
|
});
|
|
341
412
|
if (!args.verifier || !args.synthesizer) throw new Error('a swarm names its --verifier and --synthesizer');
|
|
342
|
-
const made = withBoard((b) => engine.swarm(b, { goal: rest.join(' '), workers, verifier: args.verifier, synthesizer: args.synthesizer, tenant: args.tenant ?? null, createdBy: args['created-by'] ??
|
|
413
|
+
const made = withBoard((b) => engine.swarm(b, { goal: rest.join(' '), workers, verifier: args.verifier, synthesizer: args.synthesizer, tenant: args.tenant ?? null, createdBy: args['created-by'] ?? null, priority: Number(args.priority) || 0, idempotencyKey: args['idempotency-key'] ?? null }), true);
|
|
343
414
|
say(args, `Swarm root: ${made.root_id}\nWorkers: ${(made.worker_ids ?? []).join(', ')}\nVerifier: ${made.verifier_id}\nSynthesizer: ${made.synthesizer_id}`, made);
|
|
344
415
|
return 0;
|
|
345
416
|
}
|
|
@@ -349,6 +420,8 @@ export async function runWorkflow(argv) {
|
|
|
349
420
|
const cards = withBoard((b) => {
|
|
350
421
|
let rows = engine.listCards(b.db, { status: args.status, assignee, machine: args.machine, includeArchived: args.archived || args.all });
|
|
351
422
|
if (args.tenant) rows = rows.filter((t) => t.tenant === args.tenant);
|
|
423
|
+
// a saved view (C4) is the Org's cards that carry its tag
|
|
424
|
+
for (const tag of [...(viewTag ? [viewTag] : []), ...(args.tag ?? [])]) rows = rows.filter((t) => tagsOf(t).includes(tag));
|
|
352
425
|
if (args.session) rows = rows.filter((t) => t.session_id === args.session);
|
|
353
426
|
if (args['workflow-template-id']) rows = rows.filter((t) => t.workflow_template_id === args['workflow-template-id']);
|
|
354
427
|
if (args['step-key']) rows = rows.filter((t) => t.current_step_key === args['step-key']);
|
|
@@ -404,7 +477,7 @@ export async function runWorkflow(argv) {
|
|
|
404
477
|
say(args, '', { task: cardDict(t), latest_summary: latest, parents: card.parents.map((p) => p.id), children: card.children, subtasks: card.subtasks, comments: card.comments, events: card.events, runs: card.runs, time: card.time, subscriptions: card.subscriptions, diagnostics: card.diagnostics });
|
|
405
478
|
return 0;
|
|
406
479
|
}
|
|
407
|
-
const out = [`Task ${t.id}: ${t.title}`, ` status: ${card.stage}${card.stage !== t.status ?
|
|
480
|
+
const out = [`Task ${t.id}: ${t.title}`, ` status: ${taskStatusOf(card.stage, t.completed_at)} (lane ${card.stage}${card.stage !== t.status ? `, ${t.status} in kanban.db` : ''})`, ` assignee: ${t.assignee || '-'}`];
|
|
408
481
|
if (t.machine) out.push(` machine: ${t.machine}`);
|
|
409
482
|
// where the card's time went: each role's runs, and the rest waiting
|
|
410
483
|
const mins = (sec) => `${Math.round(sec / 60)}m`;
|
|
@@ -418,6 +491,10 @@ export async function runWorkflow(argv) {
|
|
|
418
491
|
if (t.model_override) out.push(` model: ${t.model_override}${t.provider_override ? ` (provider: ${t.provider_override})` : ''}`);
|
|
419
492
|
out.push(` max-retries: ${t.max_retries ?? settings.failureLimit} (${t.max_retries !== null ? 'task' : 'board'})`);
|
|
420
493
|
out.push(` created: ${ts(t.created_at)} by ${t.created_by || '-'}`);
|
|
494
|
+
const own = withBoard((b) => engine.getTask(b.db, t.id));
|
|
495
|
+
if (own?.tags?.length) out.push(` tags: ${own.tags.join(', ')}`);
|
|
496
|
+
if (own?.sponsor) out.push(` sponsor: ${own.sponsor}${own.sponsor_evidence?.observation ? ` (requested by ${own.sponsor_evidence.observation}, proven)` : ''}`);
|
|
497
|
+
if (own?.requested_by_unverified) out.push(` requested by (unverified): ${own.requested_by_unverified.label ?? own.requested_by_unverified.observation}`);
|
|
421
498
|
if (card.diagnostics.length) {
|
|
422
499
|
out.push('', ` Diagnostics (${card.diagnostics.length}):`);
|
|
423
500
|
for (const d of card.diagnostics) { out.push(` ${{ warning: '⚠', error: '!!', critical: '!!!' }[d.severity]} [${d.severity}] ${d.title}`); for (const a of d.actions.slice(0, 1)) out.push(` → ${a}`); }
|
|
@@ -456,13 +533,30 @@ export async function runWorkflow(argv) {
|
|
|
456
533
|
return 0;
|
|
457
534
|
}
|
|
458
535
|
|
|
536
|
+
// G3: usage from each run's own harness records, summed on agents and on their sponsors
|
|
537
|
+
case 'usage': {
|
|
538
|
+
const byAgent = agentUsage(root), context = process.env.SUPERCODE_CONTEXT;
|
|
539
|
+
const sponsorOf = (agent) => { try { return agentDoor('get', [agent], { context }).data.sponsor ?? null; } catch { for (const row of activeAgentAssignments(root).filter((a) => a.agent_id === agent)) { const local = localAgentRecord(root, row.org, agent); if (local) return local.identity.sponsor ?? null; } return null; } };
|
|
540
|
+
const sum = (records) => { const out = {}; for (const u of records) { const m = out[u.model] ??= { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, cost: 0 }; m.input_tokens += u.input_tokens ?? 0; m.output_tokens += u.output_tokens ?? 0; m.cache_read_tokens += u.cache_read_tokens ?? 0; m.cache_write_tokens += u.cache_write_tokens ?? 0; m.cost += u.cost?.amount ?? 0; if (u.cost?.amount != null) { m.cost_recorded = true; m.currency = u.cost.currency ?? m.currency ?? null; } else m.cost_unrecorded = true; } return out; };
|
|
541
|
+
// a harness that keeps no price records tokens only: its cost reads as unrecorded, never as zero
|
|
542
|
+
const line = (byModel) => Object.entries(byModel).map(([m, t]) => `${m} in ${t.input_tokens} out ${t.output_tokens} cache read ${t.cache_read_tokens} write ${t.cache_write_tokens} cost ${t.cost_recorded ? `${t.cost.toFixed(4)} ${t.currency ?? ''}${t.cost_unrecorded ? ' (some sessions unrecorded)' : ''}`.trim() : 'unrecorded'}`).join('; ');
|
|
543
|
+
const agents = [...byAgent.keys()].filter((a) => !args.agent || a === args.agent);
|
|
544
|
+
const rows = agents.map((agent) => ({ agent, sponsor: args.sponsor || !args.agent ? sponsorOf(agent) : undefined, sessions: new Set(byAgent.get(agent).map((u) => u.session)).size, by_model: sum(byAgent.get(agent)) }))
|
|
545
|
+
.filter((row) => !args.sponsor || row.sponsor === args.sponsor);
|
|
546
|
+
const total = sum(rows.flatMap((row) => byAgent.get(row.agent)));
|
|
547
|
+
const sponsors = [...new Set(rows.map((row) => row.sponsor).filter(Boolean))].map((sponsor) => ({ sponsor, agents: rows.filter((row) => row.sponsor === sponsor).length, by_model: sum(rows.filter((row) => row.sponsor === sponsor).flatMap((row) => byAgent.get(row.agent))) }));
|
|
548
|
+
say(args, rows.length ? [...rows.map((r) => `${r.agent}${r.sponsor ? ` (sponsor ${r.sponsor})` : ''}: ${r.sessions} session(s) ${line(r.by_model)}`),
|
|
549
|
+
...sponsors.map((s) => `sponsor ${s.sponsor}: ${s.agents} agent(s) ${line(s.by_model)}`), `total: ${line(total)}`].join('\n') : '(no recorded usage)', { agents: rows, sponsors, total, ...(args.sponsor ? { sponsor: args.sponsor } : {}) });
|
|
550
|
+
return 0;
|
|
551
|
+
}
|
|
552
|
+
|
|
459
553
|
case 'context': need(1, 'a card id'); process.stdout.write(`${withBoard((b) => view(b, (db) => engine.cardContext(db, id)))}\n`); return 0;
|
|
460
554
|
|
|
461
555
|
case 'runs': {
|
|
462
556
|
need(1, 'a card id');
|
|
463
557
|
const got = withBoard((b) => engine.runs(b.db, id, { stateType: args['state-type'] ?? null, stateName: args['state-name'] ?? null }));
|
|
464
558
|
if (args.json) { say(args, '', got); return 0; }
|
|
465
|
-
process.stdout.write(got.length ? `${got.map((r) => `#${r.id} ${(r.outcome || r.status).padEnd(14)} @${r.profile || '-'} ${ts(r.started_at)}${r.ended_at ? ` → ${ts(r.ended_at)}` : ' (active)'}${r.metadata?.supercode?.address ? ` ${r.metadata.supercode.address}` : ''}${r.summary ? `\n → ${r.summary.split('\n')[0].slice(0, 160)}` : ''}`).join('\n')}\n` : `(no runs for ${id})\n`);
|
|
559
|
+
process.stdout.write(got.length ? `${got.map((r) => `#${r.id} ${(r.outcome || r.status).padEnd(14)} @${r.profile || '-'} ${ts(r.started_at)}${r.ended_at ? ` → ${ts(r.ended_at)}` : ' (active)'}${r.metadata?.supercode?.address ? ` ${r.metadata.supercode.address}` : ''}${r.metadata?.supercode?.counted === false ? ` not counted: profile differs (${r.metadata.supercode.agent_profile_mismatch.differences.map((d) => d.field).join(', ')})` : ''}${r.summary ? `\n → ${r.summary.split('\n')[0].slice(0, 160)}` : ''}`).join('\n')}\n` : `(no runs for ${id})\n`);
|
|
466
560
|
return 0;
|
|
467
561
|
}
|
|
468
562
|
|
|
@@ -477,7 +571,20 @@ export async function runWorkflow(argv) {
|
|
|
477
571
|
say(args, cards.map((t) => `${t}: ${rest[1]} ${value ?? '(none)'}`).join('\n'), { ok: true }); return 0;
|
|
478
572
|
}
|
|
479
573
|
case 'set-model': need(1, 'a card id'); await withBoardAsync((b) => engine.set(b, id, 'model', rest[1] ?? null, { provider: args.provider ?? null })); say(args, rest[1] ? `${id} runs on model ${rest[1]}${args.provider ? ` (${args.provider})` : ''} from its next start` : `${id} uses its profile's model`, { ok: true }); return 0;
|
|
480
|
-
case 'assign': case 'reassign':
|
|
574
|
+
case 'assign': case 'reassign': if (args.agent) {
|
|
575
|
+
// C7: a card role's assignment moves to another agent; the sessions it ran keep their identity
|
|
576
|
+
need(1, 'a card id');
|
|
577
|
+
const context=process.env.SUPERCODE_CONTEXT,scope=agentDoor('scope',[],{context});
|
|
578
|
+
const task=withBoard(b=>engine.getTask(b.db,id));
|
|
579
|
+
if(!task)throw new Error(`no card ${id}`);
|
|
580
|
+
withBoard(b=>{if(!rolesFor(b.db,id,sid,workflowFor(root).params).includes('manager'))throw new Error('Only the manager moves a card assignment between agents.');});
|
|
581
|
+
const role=args.role??'implementer';
|
|
582
|
+
const assignment=activeAgentAssignments(root).find(item=>item.org===scope.org&&item.kind==='card'&&item.target===id&&item.role===role);
|
|
583
|
+
if(!assignment)throw new Error('declare the card role before reassigning its assignment');
|
|
584
|
+
const changed=reassignAgent(root,scope.org,assignment.id,args.agent,{context});
|
|
585
|
+
withBoard(b=>tx(b,db=>event(db,id,'agent_assignment_reassigned',{assignment:assignment.id,role,from:assignment.agent_id,to:changed.agent_id})));
|
|
586
|
+
say(args,`Reassigned ${id}/${role} to agent ${changed.agent_id}; sessions keep their identity`,{ok:true,assignment:changed});return 0;
|
|
587
|
+
} need(2, 'a card id and a profile (or none)'); await withBoardAsync((b) => engine.set(b, id, 'assignee', rest[1] === 'none' ? null : rest[1])); say(args, `Assigned ${id} to ${rest[1] === 'none' ? '(nobody)' : rest[1]}`, { ok: true }); return 0;
|
|
481
588
|
case 'set-goal': {
|
|
482
589
|
// a card into goal mode (or out of it with --off) from its next idle turn: the heartbeat keeps its session going
|
|
483
590
|
need(1, 'a card id');
|
|
@@ -509,6 +616,23 @@ export async function runWorkflow(argv) {
|
|
|
509
616
|
case 'link': need(2, 'a parent and a child'); await withBoardAsync((b) => engine.link(b, rest[0], rest[1])); say(args, `Linked ${rest[0]} -> ${rest[1]}`, { ok: true }); return 0;
|
|
510
617
|
case 'unlink': { need(2, 'a parent and a child'); const gone = await withBoardAsync((b) => engine.unlink(b, rest[0], rest[1])); say(args, gone ? `Unlinked ${rest[0]} -> ${rest[1]}` : `no link ${rest[0]} -> ${rest[1]}`, { ok: gone }); return gone ? 0 : 1; }
|
|
511
618
|
|
|
619
|
+
// C4: a card's ordered tags; Hermes's tenant follows the first, and a board view selects by tag
|
|
620
|
+
case 'tag': case 'untag': {
|
|
621
|
+
need(2, 'a card id and a tag');
|
|
622
|
+
const tags = withBoard((b) => tx(b, (db) => {
|
|
623
|
+
const card = engine.getTask(db, id);
|
|
624
|
+
if (!card) throw new Error(`no card ${id}`);
|
|
625
|
+
const given = rest.slice(1).map((t) => t.trim()).filter(Boolean);
|
|
626
|
+
const next = verb === 'tag' ? [...new Set([...tagsOf(card), ...given])] : tagsOf(card).filter((t) => !given.includes(t));
|
|
627
|
+
db.prepare('INSERT INTO supercode_cards (task_id, tags) VALUES (?, ?) ON CONFLICT(task_id) DO UPDATE SET tags = excluded.tags').run(id, JSON.stringify(next));
|
|
628
|
+
db.prepare('UPDATE tasks SET tenant = ? WHERE id = ?').run(next[0] ?? null, id);
|
|
629
|
+
event(db, id, verb === 'tag' ? 'tagged' : 'untagged', { tags: given, now: next });
|
|
630
|
+
return next;
|
|
631
|
+
}));
|
|
632
|
+
say(args, `${id} tags: ${tags.join(', ') || '(none)'}`, { id, tags });
|
|
633
|
+
return 0;
|
|
634
|
+
}
|
|
635
|
+
|
|
512
636
|
case 'comment': {
|
|
513
637
|
need(2, 'a card id and the text');
|
|
514
638
|
let text = rest.slice(1).join(' ');
|
|
@@ -596,7 +720,7 @@ export async function runWorkflow(argv) {
|
|
|
596
720
|
case 'reopen-review': {
|
|
597
721
|
need(1, 'a card id');
|
|
598
722
|
const results = [];
|
|
599
|
-
for (const t of ids()) results.push([t, (await withBoardAsync((b) => engine.reopenReview(b, t))).to]);
|
|
723
|
+
for (const t of ids()) results.push([t, (await withBoardAsync((b) => engine.reopenReview(b, t, { reason: args.reason ?? null }))).to]);
|
|
600
724
|
say(args, results.map(([t, s]) => `Reopened ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
|
|
601
725
|
}
|
|
602
726
|
case 'promote': {
|
|
@@ -656,7 +780,7 @@ export async function runWorkflow(argv) {
|
|
|
656
780
|
|
|
657
781
|
case 'diagnostics': case 'diag': {
|
|
658
782
|
const { settings } = await laneState(root).catch(() => ({ settings: boardSettings({}) }));
|
|
659
|
-
const fleet = observeFleet();
|
|
783
|
+
const fleet = observeFleet(root);
|
|
660
784
|
const order = ['warning', 'error', 'critical'];
|
|
661
785
|
const found = withBoard((b) => {
|
|
662
786
|
const cards = args.task ? [engine.getTask(b.db, args.task)].filter(Boolean) : engine.listCards(b.db, {});
|
|
@@ -917,11 +1041,13 @@ async function boardsVerb(root, rest, args) {
|
|
|
917
1041
|
return 0;
|
|
918
1042
|
}
|
|
919
1043
|
case 'create': case 'new': {
|
|
1044
|
+
// C4: a new board is a saved view over the Org's one store, selecting the cards tagged with its slug
|
|
920
1045
|
const slug = normalSlug(target);
|
|
921
|
-
|
|
922
|
-
|
|
1046
|
+
if (slug === DEFAULT_BOARD) throw new Error("'default' is the Org's store itself");
|
|
1047
|
+
if (args.backing || args.document) throw new Error('a board is a saved view over the Org\'s one task store; its backing is the store\'s (`init --backing`)');
|
|
1048
|
+
const meta = writeMeta(root, slug, { name: args.name, description: args.description, icon: args.icon, color: args.color, default_workdir: args['default-workdir'], view: { tags: [slug] } });
|
|
923
1049
|
if (args.switch) { mkdirSync(join(root, 'kanban'), { recursive: true }); writeFileSync(currentFile(root), `${slug}\n`); }
|
|
924
|
-
say(args, `Board '${slug}' ready.\n Display name: ${meta.name}
|
|
1050
|
+
say(args, `Board '${slug}' ready: a view of the Org's cards tagged ${slug}.\n Display name: ${meta.name}${args.switch ? `\n Switched to '${slug}'.` : ''}`, meta);
|
|
925
1051
|
return 0;
|
|
926
1052
|
}
|
|
927
1053
|
case 'rm': case 'remove': case 'delete': {
|
|
@@ -940,7 +1066,7 @@ async function boardsVerb(root, rest, args) {
|
|
|
940
1066
|
}
|
|
941
1067
|
case 'switch': case 'use': {
|
|
942
1068
|
const slug = normalSlug(target);
|
|
943
|
-
if (slug !== DEFAULT_BOARD && !boardExists(root, slug)) throw new Error(`board '${slug}' does not exist; \`supercode workflow boards create ${slug}\``);
|
|
1069
|
+
if (slug !== DEFAULT_BOARD && !boardExists(root, slug) && !isView(root, slug)) throw new Error(`board '${slug}' does not exist; \`supercode workflow boards create ${slug}\``);
|
|
944
1070
|
mkdirSync(join(root, 'kanban'), { recursive: true });
|
|
945
1071
|
writeFileSync(currentFile(root), `${slug}\n`);
|
|
946
1072
|
say(args, `Active board is now '${slug}'.`, { ok: true, board: slug });
|
|
@@ -1117,12 +1243,11 @@ function ownCard(db, sessionId) {
|
|
|
1117
1243
|
const READS = new Set(['list', 'ls', 'show', 'context', 'runs', 'log', 'tail', 'watch', 'stats', 'assignees', 'diagnostics', 'diag', 'attachments', 'notify-list', 'capacity', 'help', 'init']);
|
|
1118
1244
|
|
|
1119
1245
|
/**
|
|
1120
|
-
* Who a caller is on card `cardId`, in the workflow's roles: a
|
|
1121
|
-
* manager acts from its
|
|
1246
|
+
* Who a caller is on card `cardId`, in the workflow's roles: a signed-in terminal person retains their creator role;
|
|
1247
|
+
* the manager acts from its named session; the session of the card's current run has that run's role; the card's working session between runs
|
|
1122
1248
|
* is its `implementer`; the session working a subtask's arc is `arc`; its recorded creator also has the creator role; any other session is nobody on it.
|
|
1123
1249
|
*/
|
|
1124
1250
|
function rolesFor(db, cardId, sessionId, params = {}) {
|
|
1125
|
-
if (!sessionId) return [];
|
|
1126
1251
|
// a session the workflow names as the board's manager (params.managers: its addresses) is the manager on every card
|
|
1127
1252
|
const managers = Array.isArray(params.managers) ? params.managers.map(String) : [];
|
|
1128
1253
|
if (managers.some((m) => m === sessionId || m.endsWith(`:${sessionId}`))) return ['manager'];
|
|
@@ -1130,7 +1255,8 @@ function rolesFor(db, cardId, sessionId, params = {}) {
|
|
|
1130
1255
|
if (!task) return [];
|
|
1131
1256
|
const roles = [];
|
|
1132
1257
|
const creator = completionOwnerOf(db, cardId);
|
|
1133
|
-
if (creator && creator
|
|
1258
|
+
if (creator && isCaller(creator)) roles.push('creator');
|
|
1259
|
+
if (!sessionId) return roles;
|
|
1134
1260
|
const current = task.current_run_id ? json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(task.current_run_id)?.metadata, {})?.supercode : null;
|
|
1135
1261
|
if (current?.session_id === sessionId) roles.push(current.role ?? 'implementer');
|
|
1136
1262
|
else if (engine.lastSession(db, cardId)?.session_id === sessionId) roles.push('implementer');
|
|
@@ -1151,7 +1277,8 @@ function authorize(db, verb, rest, args, own) {
|
|
|
1151
1277
|
if (verb === 'boards') { if (!['list', 'ls', 'show', 'current', undefined].includes(rest[0])) refuse('does not change the home\'s boards'); return; }
|
|
1152
1278
|
// verbs that act on the board rather than on a card are the manager's and the board's, never a card's session's
|
|
1153
1279
|
// (what a session may do to a card is its workflow's: each transition's `by`)
|
|
1154
|
-
if ((['pause', 'resume'].includes(verb) && !rest[0]) || ['swarm', 'dispatch', 'daemon', 'serve', 'gc', 'repair', 'adopt', 'close', '
|
|
1280
|
+
if ((['pause', 'resume'].includes(verb) && !rest[0]) || ['swarm', 'dispatch', 'daemon', 'serve', 'gc', 'repair', 'adopt', 'close', 'claim', 'attach-rm', 'migrate'].includes(verb)) refuse(`does not ${verb}; the fleet's manager does`);
|
|
1281
|
+
if (verb === 'set' && !(rest[0] === own.id && rest[1] === 'every')) refuse("sets only Every on its own auditor card; other properties are the manager's");
|
|
1155
1282
|
if (args.all) refuse(`does not ${verb} every card; only its own`);
|
|
1156
1283
|
if (['notify-subscribe', 'notify-unsubscribe'].includes(verb) && args['for-card']) {
|
|
1157
1284
|
if (args['for-card'] !== own.id) refuse(`subscribes only its own arc (--for-card ${own.id})`);
|
|
@@ -1161,7 +1288,7 @@ function authorize(db, verb, rest, args, own) {
|
|
|
1161
1288
|
for (const target of targets) {
|
|
1162
1289
|
const card = engine.getTask(db, target);
|
|
1163
1290
|
if (!card) continue;
|
|
1164
|
-
if (completionOwnerOf(db, card.id)
|
|
1291
|
+
if (isCaller(completionOwnerOf(db, card.id))) continue;
|
|
1165
1292
|
if (verb === 'comment' && isLatestReviewer(db, card.id, actorOf())) continue;
|
|
1166
1293
|
if (card.id !== own.id && card.subtask_of !== own.id) refuse(`writes only its own card and its subtasks, not ${card.id}`);
|
|
1167
1294
|
}
|