@volter/supercode-orchestrator 0.4.160 → 0.5.2

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/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
- init --backing sqlite|files · boards create <slug> --backing sqlite|files
51
- Where the board is kept: sqlite is Hermes's kanban.db (the default); files is one JSON file per card.
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
- if (!boardExists(root, slug)) return {};
165
- const b = openBoard(root, slug);
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
- return Object.fromEntries(b.db.prepare('SELECT status, COUNT(*) AS n FROM tasks GROUP BY status').all().map((r) => [r.status, Number(r.n)]));
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
- const slug = boardOf(root, args);
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
- if (sid) setActor(`sc:${localMachine()}:${sid === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code'}:${sid}`);
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
- // a verb with no session behind it is recorded as the OS user's; it holds no role on any card
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'] ?? callerName(),
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'] ?? callerName(), priority: Number(args.priority) || 0, idempotencyKey: args['idempotency-key'] ?? null }), true);
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 ? ` (${t.status} in kanban.db)` : ''}`, ` assignee: ${t.assignee || '-'}`];
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': 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;
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
- const meta = writeMeta(root, slug, { name: args.name, description: args.description, icon: args.icon, color: args.color, default_workdir: args['default-workdir'] });
922
- openBoard(root, slug, { create: true, document: args.document ?? null, backing: args.backing ?? null }).close();
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}\n DB path: ${meta.db_path}${args.switch ? `\n Switched to '${slug}'.` : ''}`, meta);
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 caller with no session behind it has no role (the
1121
- * manager acts from its own session, named in params.managers); the session of the card's current run has that run's role; the card's working session between runs
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 === actorOf()) roles.push('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', 'set', 'claim', 'attach-rm', 'migrate'].includes(verb)) refuse(`does not ${verb}; the fleet's manager does`);
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) === actorOf()) continue;
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
  }