@volter/supercode-orchestrator 0.4.4

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/hermes-cli.mjs ADDED
@@ -0,0 +1,440 @@
1
+ // Hermes's own tools beside a worker (docs/plans/hermes-compat.md row 7). A home is a Hermes home, and the
2
+ // skills it carries call Hermes's CLI (`hermes kanban …`, `hermes cron notepad …`, `hermes cron doctor`)
3
+ // against it, so a worker of any harness runs with `HERMES_HOME` set to its profile's folder and the
4
+ // pinned `hermes` on its `PATH`; and what Hermes's scheduler records about a failed run (its incidents,
5
+ // in `cron/executions.db`) the orchestrator records through Hermes's own function.
6
+ import { spawn, spawnSync } from 'node:child_process';
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { dirname, join } from 'node:path';
9
+ import { parseDotenv } from './orchestration.mjs';
10
+ import { locateHermes } from './apply/doors.mjs';
11
+
12
+ /** Hermes's interpreter over `code`, `payload` on stdin, without blocking the daemon: `{ status, stdout, stderr }`. */
13
+ function python(install, code, payload, env, timeout) {
14
+ return new Promise((done) => {
15
+ const child = spawn(install.python, ['-c', code], { cwd: install.source, env, stdio: ['pipe', 'pipe', 'pipe'] });
16
+ let stdout = '', stderr = '';
17
+ const timer = setTimeout(() => child.kill('SIGKILL'), timeout);
18
+ child.stdout.setEncoding('utf8').on('data', (c) => { stdout += c; });
19
+ child.stderr.setEncoding('utf8').on('data', (c) => { stderr = (stderr + c).slice(-4000); });
20
+ child.on('error', (error) => { clearTimeout(timer); done({ status: null, stdout, stderr: error.message }); });
21
+ child.on('close', (status) => { clearTimeout(timer); done({ status, stdout, stderr }); });
22
+ child.stdin.end(JSON.stringify(payload));
23
+ });
24
+ }
25
+
26
+ let located;
27
+ /** The pinned Hermes on this daemon's `PATH`: `{ bin, python, source }`, or null when there is none. */
28
+ export function hermesInstall() {
29
+ if (located !== undefined) return located;
30
+ try {
31
+ const which = spawnSync('sh', ['-c', 'command -v hermes'], { encoding: 'utf8' }).stdout.trim();
32
+ located = which ? { bin: dirname(which), ...locateHermes() } : null;
33
+ } catch {
34
+ located = null;
35
+ }
36
+ return located;
37
+ }
38
+
39
+ /** The environment a non-Hermes worker adds for Hermes's CLI: its profile's home, and `hermes` on `PATH`. */
40
+ export function hermesToolEnv(profileDir) {
41
+ const install = hermesInstall();
42
+ const env = { HERMES_HOME: profileDir };
43
+ if (install?.bin) env.PATH = `${install.bin}:${process.env.PATH ?? ''}`;
44
+ return env;
45
+ }
46
+
47
+ /**
48
+ * Record a failed run as Hermes's scheduler does: `cron.incidents.upsert_incident`, one incident per job
49
+ * and error signature, in the profile's `cron/executions.db`. Answers the incident id, or null (and why)
50
+ * when Hermes is not installed or refused.
51
+ */
52
+ export function recordIncident(profileDir, { job_id, job_name = null, error }) {
53
+ const install = hermesInstall();
54
+ if (!install?.python) return { id: null, why: 'no Hermes install on PATH' };
55
+ const code = 'import json,sys\nfrom cron.incidents import upsert_incident\na=json.loads(sys.stdin.read())\nprint(json.dumps(upsert_incident(a["job_id"], a["error"], job_name=a.get("job_name"))))';
56
+ const run = spawnSync(install.python, ['-c', code], {
57
+ input: JSON.stringify({ job_id, job_name, error: String(error ?? 'failed') }),
58
+ encoding: 'utf8', timeout: 30_000, cwd: install.source,
59
+ env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '', HERMES_HOME: profileDir, PYTHONPATH: install.source },
60
+ });
61
+ if (run.status !== 0) return { id: null, why: (run.stderr || run.error?.message || '').trim().slice(-300) };
62
+ const [id] = JSON.parse(run.stdout.trim().split('\n').pop());
63
+ return { id };
64
+ }
65
+
66
+ // Access through Hermes's own pairing store and writers (docs/plans/hermes-compat.md row 8): a code is
67
+ // issued and approved by `gateway.pairing.PairingStore` (salted hashes, its expiry, rate limit and
68
+ // lockout), an approval mirrored into the platform's allowlist as Hermes mirrors it; an allowlist entry is
69
+ // `<PLATFORM>_ALLOWED_USERS` in the profile's `.env` (`hermes_cli.config.save_env_value`); an admin is the
70
+ // platform's `extra.allow_admin_from` in `config.yaml` (`save_config`).
71
+ const ACCESS_OPS = `
72
+ import json, os, sys
73
+ a = json.loads(sys.stdin.read()); op = a["op"]
74
+ from gateway.pairing import PairingStore, _read_allowlist_env, _sync_allowlist_remove, _allowlist_env_for_platform
75
+ store = PairingStore()
76
+ def ids(v): return [x.strip() for x in (v or "").split(",") if x.strip()]
77
+ out = None
78
+ if op == "issue":
79
+ # Hermes's gateway: a rate-limited sender is answered with nothing; a refused code with the busy line,
80
+ # its rate limit recorded so the next messages are silent
81
+ if store._is_rate_limited(a["platform"], a["user_id"]):
82
+ out = {"silent": True}
83
+ else:
84
+ code = store.generate_code(a["platform"], a["user_id"], a.get("user_name") or "")
85
+ if not code:
86
+ store._record_rate_limit(a["platform"], a["user_id"])
87
+ out = {"code": code}
88
+ elif op == "approve":
89
+ for p in a["platforms"]:
90
+ got = store.approve_code(p, a["code"])
91
+ if got:
92
+ out = {"platform": p, **got}
93
+ break
94
+ elif op == "pending":
95
+ out = store.list_pending()
96
+ elif op in ("allow", "revoke"):
97
+ var = _allowlist_env_for_platform(a["platform"]) or (a["platform"].upper() + "_ALLOWED_USERS")
98
+ current = ids(_read_allowlist_env(var))
99
+ if op == "allow":
100
+ if a["user_id"] not in current:
101
+ from hermes_cli.config import save_env_value
102
+ save_env_value(var, ",".join(current + [a["user_id"]]))
103
+ else:
104
+ store.revoke(a["platform"], a["user_id"])
105
+ _sync_allowlist_remove(a["platform"], a["user_id"])
106
+ out = True
107
+ elif op in ("admin_add", "admin_remove"):
108
+ from hermes_cli.config import load_config, save_config
109
+ cfg = load_config()
110
+ extra = cfg.setdefault("platforms", {}).setdefault(a["platform"], {}).setdefault("extra", {})
111
+ admins = [str(x) for x in (extra.get("allow_admin_from") or [])]
112
+ if op == "admin_add" and a["user_id"] not in admins: admins.append(a["user_id"])
113
+ if op == "admin_remove": admins = [x for x in admins if x != a["user_id"]]
114
+ extra["allow_admin_from"] = admins
115
+ save_config(cfg)
116
+ out = True
117
+ print("@@access@@" + json.dumps(out))
118
+ `;
119
+
120
+ /**
121
+ * One access operation through Hermes's own functions against a profile's home: `issue` (answers
122
+ * `{ code }`, `{ code: null }` when Hermes refuses one, or `{ silent: true }` for a rate-limited sender), `approve` (answers
123
+ * `{ platform, user_id, user_name }` or null), `pending`, `allow`, `revoke`, `admin_add`, `admin_remove`.
124
+ * Throws when Hermes is not installed: access lives in Hermes's stores.
125
+ */
126
+ export function hermesAccess(profileDir, op, args = {}) {
127
+ const install = hermesInstall();
128
+ if (!install?.python) throw new Error(`access ${op} needs Hermes's pairing store and writers, and no Hermes is installed on PATH`);
129
+ const envFile = join(profileDir, '.env');
130
+ const dotenv = existsSync(envFile) ? Object.fromEntries(parseDotenv(readFileSync(envFile, 'utf8'))) : {};
131
+ const run = spawnSync(install.python, ['-c', ACCESS_OPS], {
132
+ input: JSON.stringify({ op, ...args }), encoding: 'utf8', timeout: 30_000, cwd: install.source,
133
+ env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '', ...dotenv, HERMES_HOME: profileDir, PYTHONPATH: install.source },
134
+ });
135
+ const line = (run.stdout ?? '').split('\n').find((l) => l.startsWith('@@access@@'));
136
+ if (!line) throw new Error(`Hermes answered nothing for access ${op}: ${(run.stderr || run.error?.message || '').trim().slice(-300)}`);
137
+ return JSON.parse(line.slice('@@access@@'.length));
138
+ }
139
+
140
+ // A worker's transcript mirrored into the home's Hermes session store (docs/plans/hermes-compat.md row 11),
141
+ // through Hermes's own `SessionDB`: the session row (`ensure_session`, the entry's session id, its chat and
142
+ // origin, `model_config._supercode_mirror` naming the worker's own session and how many rows the mirror
143
+ // wrote) and its messages (`replace_messages`, one atomic rewrite under Hermes's turn lease), so
144
+ // `hermes sessions` lists and resumes the conversation. A row Hermes has written to since the last mirror
145
+ // (its message count past the mirror's) is Hermes continuing the conversation: the mirror never rewrites
146
+ // it, and the worker's next start takes Hermes's turns over (the loop's `continueFromHermes`).
147
+ const MIRROR = `
148
+ import json, sys
149
+ a = json.loads(sys.stdin.read()); op = a["op"]
150
+ from hermes_state import SessionDB
151
+ db = SessionDB()
152
+ def mark(row):
153
+ try: return (json.loads(row.get("model_config") or "{}") or {}).get("_supercode_mirror")
154
+ except Exception: return None
155
+ def config(row):
156
+ try: return json.loads(row.get("model_config") or "{}") or {}
157
+ except Exception: return {}
158
+ row = db.get_session(a["id"])
159
+ m = mark(row) if row is not None else None
160
+ ahead = row is not None and (m is None or (m.get("messages") is not None and (row.get("message_count") or 0) > m["messages"]))
161
+ out = None
162
+ if op == "state":
163
+ out = {"exists": row is not None, "ahead": ahead, "messages": row.get("message_count") if row else None}
164
+ elif op == "seal":
165
+ if row is not None:
166
+ cfg = config(row); cfg["_supercode_mirror"] = {**(m or {}), "messages": row.get("message_count") or 0, "continued_as": a["continued_as"]}
167
+ db.update_session_meta(a["id"], json.dumps(cfg))
168
+ out = True
169
+ elif ahead:
170
+ out = {"diverged": row.get("message_count")}
171
+ else:
172
+ db.ensure_session(a["id"], source=a["source"], model=a.get("model"), model_config=a["model_config"],
173
+ session_key=a.get("session_key"), chat_id=a.get("chat_id"), chat_type=a.get("chat_type"),
174
+ thread_id=a.get("thread_id"), user_id=a.get("user_id"), cwd=a.get("cwd"),
175
+ parent_session_id=a.get("parent"), origin_json=json.dumps(a.get("origin") or {}))
176
+ db.replace_messages(a["id"], a["messages"], reject_active_turn_lease=True)
177
+ row = db.get_session(a["id"])
178
+ cfg = config(row); cfg["_supercode_mirror"] = {**a["model_config"]["_supercode_mirror"], "messages": row.get("message_count") or 0}
179
+ db.update_session_meta(a["id"], json.dumps(cfg))
180
+ if a.get("end"): db.end_session(a["id"], a["end"])
181
+ out = {"mirrored": row.get("message_count") or 0}
182
+ print("@@mirror@@" + json.dumps(out))
183
+ `;
184
+
185
+ async function runMirror(profileDir, payload) {
186
+ const install = hermesInstall();
187
+ if (!install?.python) return { error: 'no Hermes install on PATH' };
188
+ const run = await python(install, MIRROR, payload, { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '', HERMES_HOME: profileDir, PYTHONPATH: install.source }, 60_000);
189
+ const line = run.stdout.split('\n').find((l) => l.startsWith('@@mirror@@'));
190
+ if (!line) return { error: run.stderr.trim().slice(-300) };
191
+ return { out: JSON.parse(line.slice('@@mirror@@'.length)) };
192
+ }
193
+
194
+ /**
195
+ * Whether Hermes has carried a mirrored conversation past its mirror: `{ exists, ahead, messages }`, or
196
+ * null when Hermes is not installed or unreadable.
197
+ */
198
+ export async function mirrorState(profileDir, id) {
199
+ return (await runMirror(profileDir, { op: 'state', id })).out ?? null;
200
+ }
201
+
202
+ /** A mirror Hermes continued, closed once its turns went on as `continuedAs`: listed no more, never rewritten. */
203
+ export async function sealMirror(profileDir, id, continuedAs) {
204
+ return (await runMirror(profileDir, { op: 'seal', id, continued_as: continuedAs })).out === true;
205
+ }
206
+
207
+ /** One Supercode-normalized message (OpenAI chat shape) as a Hermes `messages` row. */
208
+ function hermesMessage(m) {
209
+ const text = Array.isArray(m.content)
210
+ ? m.content.map((part) => (typeof part === 'string' ? part : part?.text ?? '')).filter(Boolean).join('\n')
211
+ : m.content ?? null;
212
+ const at = Date.parse(m.metadata?.timestamp ?? '');
213
+ return {
214
+ role: m.role, content: text,
215
+ ...(m.tool_calls?.length ? { tool_calls: m.tool_calls } : {}),
216
+ ...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
217
+ ...(m.name ? { tool_name: m.name } : {}),
218
+ ...(Number.isFinite(at) ? { timestamp: at / 1000 } : {}),
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Mirror a worker session into the profile's Hermes store under `id` (its routing entry's session id).
224
+ * `binding` supplies the chat; `session` is the worker's transcript as Supercode loaded it; `parent` the
225
+ * Hermes session it continues. Answers `{ mirrored }` (rows written), `{ diverged }` (Hermes's row is
226
+ * ahead: nothing written) or `{ mirrored: null, why }`.
227
+ */
228
+ export async function mirrorSession(profileDir, { id, sessionKey, binding, session, locator = null, parent = null, end = null }) {
229
+ const key = binding.key ?? {};
230
+ const got = await runMirror(profileDir, {
231
+ op: 'mirror', id, parent, end, source: key.platform ?? 'orchestrator', model: session.model ?? null, cwd: session.cwd ?? null,
232
+ model_config: { _supercode_mirror: { harness: binding.worker?.harness ?? session.source, session_id: session.session_id ?? null, locator, ...(binding.recurrence ? { recurrence: { job_id: binding.recurrence.job_id, kind: binding.recurrence.kind ?? 'cron' } } : {}) } },
233
+ session_key: sessionKey, chat_id: key.chat_id ?? null, chat_type: key.kind ?? null, thread_id: key.thread_id ?? null, user_id: key.participant_id ?? null,
234
+ origin: { platform: key.platform ?? null, chat_id: key.chat_id ?? null, chat_type: key.kind ?? null, thread_id: key.thread_id ?? null },
235
+ messages: (session.messages ?? []).filter((m) => m.role !== 'system').map(hermesMessage),
236
+ });
237
+ return got.out ?? { mirrored: null, why: got.error };
238
+ }
239
+
240
+ // Hermes's own tools against a profile's home (docs/plans/hermes-compat.md row 13): `session_search`,
241
+ // `memory`, `skills_list` and `skill_view` dispatched through Hermes's registry, their handlers given what
242
+ // Hermes's agent gives them (the memory store built as `agent_init` builds it, the current session); the
243
+ // memory snapshot Hermes's agent puts in its system prompt at session start
244
+ // (`format_for_system_prompt`, each enabled store); and Hermes's schedule parser (`cron.jobs.parse_schedule`).
245
+ const HERMES_TOOL = `
246
+ import json, sys
247
+ a = json.loads(sys.stdin.read()); op = a["op"]
248
+ from hermes_cli.config import load_config
249
+ cfg = load_config()
250
+ def memory_store():
251
+ from tools.memory_tool import MemoryStore, get_builtin_memory_config, get_builtin_memory_store_flags
252
+ mc = get_builtin_memory_config(cfg); me, ue = get_builtin_memory_store_flags(cfg)
253
+ if not (me or ue): return None, me, ue
254
+ store = MemoryStore(memory_char_limit=mc.get("memory_char_limit", 2200), user_char_limit=mc.get("user_char_limit", 1375), memory_enabled=me, user_profile_enabled=ue)
255
+ store.load_from_disk()
256
+ return store, me, ue
257
+ if op == "snapshot":
258
+ store, me, ue = memory_store()
259
+ blocks = []
260
+ if store and me: blocks.append(store.format_for_system_prompt("memory"))
261
+ if store and ue: blocks.append(store.format_for_system_prompt("user"))
262
+ out = "\\n\\n".join(b for b in blocks if b)
263
+ elif op == "approval":
264
+ from tools.approval import detect_dangerous_command, _get_cron_approval_mode, _get_single_query_approval_mode, _get_approval_mode
265
+ danger = detect_dangerous_command(a["command"]) if a.get("command") else (False, None, None)
266
+ if not danger[0]:
267
+ out = {"verdict": "allow", "why": None}
268
+ else:
269
+ ctx = a.get("context")
270
+ mode = _get_cron_approval_mode() if ctx == "cron" else _get_single_query_approval_mode() if ctx == "single_query" else ("approve" if _get_approval_mode() == "off" else "ask")
271
+ out = {"verdict": "allow" if mode == "approve" else ("ask" if mode == "ask" else "deny"), "why": danger[2]}
272
+ # Hermes's own words to the model for a denial, so it finds another way (tools/approval.py)
273
+ if out["verdict"] == "deny" and ctx == "cron":
274
+ out["message"] = (f"BLOCKED: Command flagged as dangerous ({danger[2]}) but cron jobs run without a user present to approve it. "
275
+ "Find an alternative approach that avoids this command. To allow dangerous commands in cron jobs, set approvals.cron_mode: approve in config.yaml.")
276
+ elif out["verdict"] == "deny":
277
+ out["message"] = (f"BLOCKED: Command flagged as dangerous ({danger[2]}) but single-query mode (-q) runs without a user present to approve it. "
278
+ "Find an alternative approach that avoids this command. To allow dangerous commands in single-query mode, set approvals.single_query_mode: approve in config.yaml.")
279
+ elif op == "kanban_prompt":
280
+ from agent.prompt_builder import KANBAN_GUIDANCE
281
+ from agent.skill_commands import build_preloaded_skills_prompt
282
+ skills = build_preloaded_skills_prompt(a.get("skills") or [], task_id=a.get("task"))[0] if a.get("skills") else ""
283
+ out = {"guidance": KANBAN_GUIDANCE, "skills": skills or ""}
284
+ elif op == "schedule":
285
+ from cron.jobs import parse_schedule
286
+ try: out = parse_schedule(a["text"])
287
+ except ValueError as error: out = {"error": str(error)}
288
+ else:
289
+ import tools.memory_tool, tools.session_search_tool, tools.skills_tool, tools.kanban_tools
290
+ from tools.registry import registry
291
+ kw = {}
292
+ if a["name"] == "memory": kw["store"] = memory_store()[0]
293
+ if a["name"] == "session_search": kw["current_session_id"] = a.get("session_id")
294
+ out = registry.dispatch(a["name"], a.get("args") or {}, **kw)
295
+ print("@@hermes@@" + json.dumps(out))
296
+ `;
297
+
298
+ const hermesEnv = (install, profileDir) => ({ PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '', HERMES_HOME: profileDir, PYTHONPATH: install.source });
299
+
300
+ function hermesAnswer(what, run) {
301
+ const line = run.stdout.split('\n').find((l) => l.startsWith('@@hermes@@'));
302
+ if (!line) throw new Error(`Hermes answered nothing for ${what}: ${run.stderr.trim().slice(-300)}`);
303
+ return JSON.parse(line.slice('@@hermes@@'.length));
304
+ }
305
+
306
+ async function runHermes(profileDir, payload, env = {}) {
307
+ const install = hermesInstall();
308
+ if (!install?.python) throw new Error(`${payload.name ?? payload.op} is Hermes's, and no Hermes is installed on PATH`);
309
+ return hermesAnswer(payload.name ?? payload.op, await python(install, HERMES_TOOL, payload, { ...hermesEnv(install, profileDir), ...env }, 60_000));
310
+ }
311
+
312
+ /**
313
+ * The environment Hermes's dispatcher gives the worker it spawns for a task (`kanban_db._default_spawn`):
314
+ * the task, its run and claim, the board's database, workspaces and slug, the author its comments carry.
315
+ */
316
+ export const KANBAN_ENV = Object.freeze([
317
+ 'HERMES_KANBAN_TASK', 'HERMES_KANBAN_WORKSPACE', 'HERMES_KANBAN_BRANCH', 'HERMES_KANBAN_RUN_ID',
318
+ 'HERMES_KANBAN_CLAIM_LOCK', 'HERMES_KANBAN_DB', 'HERMES_KANBAN_WORKSPACES_ROOT', 'HERMES_KANBAN_BOARD',
319
+ 'HERMES_KANBAN_GOAL_MODE', 'HERMES_KANBAN_GOAL_MAX_TURNS', 'HERMES_PROFILE', 'HERMES_TENANT', 'HERMES_SESSION_SOURCE',
320
+ ]);
321
+
322
+ /**
323
+ * One of Hermes's tools, called as Hermes's agent calls it; answers the handler's own result (Hermes's
324
+ * text). `env` is a dispatched worker's task environment, for the board's tools.
325
+ */
326
+ export function hermesTool(profileDir, name, args, { sessionId = null, env = null } = {}) {
327
+ return runHermes(profileDir, { op: 'tool', name, args, session_id: sessionId }, env ?? {});
328
+ }
329
+
330
+ /**
331
+ * Hermes's approval rule for a worker's request (tools/approval.py): a shell command Hermes's own
332
+ * `detect_dangerous_command` calls safe runs without asking, as it does under Hermes, and so does every
333
+ * other tool (Hermes's file tools are not gated); a dangerous one is asked in a conversation (unless
334
+ * `approvals.mode: off`), and answered by `approvals.cron_mode` in a fire and `approvals.single_query_mode`
335
+ * in a dispatched worker, each deny by default. Answers `{ verdict: allow|ask|deny, why }`, or null without
336
+ * Hermes (the worker's own prompt is then relayed as it stands).
337
+ */
338
+ export async function hermesApproval(profileDir, { command = null, context = 'conversation' } = {}) {
339
+ if (!hermesInstall()?.python) return null;
340
+ return runHermes(profileDir, { op: 'approval', command, context });
341
+ }
342
+
343
+ /**
344
+ * What Hermes's agent adds to a dispatched worker's system prompt: `KANBAN_GUIDANCE` (the task protocol)
345
+ * and the task's force-loaded skills (`build_preloaded_skills_prompt`, as `hermes --skills` loads them).
346
+ */
347
+ export function kanbanPrompt(profileDir, { skills = [], task = null, env = {} } = {}) {
348
+ return runHermes(profileDir, { op: 'kanban_prompt', skills, task }, env);
349
+ }
350
+
351
+ /** The memory Hermes's agent would carry into a session starting now, as its system prompt carries it; '' for none. */
352
+ export async function memorySnapshot(profileDir) {
353
+ if (!hermesInstall()?.python) return '';
354
+ return runHermes(profileDir, { op: 'snapshot' });
355
+ }
356
+
357
+ /**
358
+ * A schedule string as Hermes parses it (`{ kind: once|interval|cron, … }`), or null when no Hermes is
359
+ * installed; throws Hermes's own refusal. Synchronous: schedules are parsed where a job is built.
360
+ */
361
+ export function hermesSchedule(profileDir, text) {
362
+ const install = hermesInstall();
363
+ if (!install?.python) return null;
364
+ const run = spawnSync(install.python, ['-c', HERMES_TOOL], { input: JSON.stringify({ op: 'schedule', text }), encoding: 'utf8', timeout: 30_000, cwd: install.source, env: hermesEnv(install, profileDir) });
365
+ const out = hermesAnswer('schedule', { stdout: run.stdout ?? '', stderr: run.stderr || run.error?.message || '' });
366
+ if (out?.error) throw new Error(out.error);
367
+ return out;
368
+ }
369
+
370
+ // One tick of Hermes's board dispatcher, as a Hermes gateway runs it (gateway/kanban_watchers.py
371
+ // `_kanban_dispatcher_watcher`): the home's `kanban:` settings read as the gateway reads them, then
372
+ // `kanban_db.dispatch_once` on every board. Hermes claims, reclaims, promotes and spawns; its spawn runs
373
+ // `HERMES_BIN`, which the caller sets to the orchestrator's worker command.
374
+ const KANBAN_TICK = `
375
+ import json, sys
376
+ a = json.loads(sys.stdin.read())
377
+ from hermes_cli.config import load_config
378
+ from hermes_cli import kanban_db as kb
379
+ cfg = ((load_config() or {}).get("kanban") or {})
380
+ def pos_int(v):
381
+ try: v = int(v)
382
+ except (TypeError, ValueError): return None
383
+ return v if v >= 1 else None
384
+ max_in_progress = kb.resolve_max_in_progress(pos_int(cfg.get("max_in_progress")))
385
+ try: failure_limit = int(cfg.get("failure_limit", kb.DEFAULT_FAILURE_LIMIT))
386
+ except (TypeError, ValueError): failure_limit = kb.DEFAULT_FAILURE_LIMIT
387
+ if failure_limit < 1: failure_limit = kb.DEFAULT_FAILURE_LIMIT
388
+ try: stale = int(cfg.get("dispatch_stale_timeout_seconds", 0) or 0)
389
+ except (TypeError, ValueError): stale = 0
390
+ default_assignee = (cfg.get("default_assignee") or "").strip() or None
391
+ try: boards = kb.list_boards(include_archived=False)
392
+ except Exception: boards = [kb.read_board_metadata(kb.DEFAULT_BOARD)]
393
+ out = []
394
+ for b in boards:
395
+ slug = b.get("slug") or kb.DEFAULT_BOARD
396
+ conn = None
397
+ try:
398
+ conn = kb.connect(board=slug)
399
+ r = kb.dispatch_once(conn, board=slug, max_spawn=cfg.get("max_spawn"), max_in_progress=max_in_progress,
400
+ failure_limit=failure_limit, stale_timeout_seconds=stale, default_assignee=default_assignee,
401
+ max_in_progress_per_profile=pos_int(cfg.get("max_in_progress_per_profile")),
402
+ reconcile_orphans=bool(cfg.get("reconcile_orphans", True)))
403
+ count = lambda sql: conn.execute(sql).fetchone()[0]
404
+ out.append({"board": slug, "spawned": [list(x) for x in r.spawned], "reclaimed": r.reclaimed, "crashed": r.crashed,
405
+ "timed_out": r.timed_out, "skipped_locked": bool(getattr(r, "skipped_locked", False)),
406
+ "subscriptions": count("SELECT COUNT(*) FROM kanban_notify_subs"), "triage": count("SELECT COUNT(*) FROM tasks WHERE status = 'triage'")})
407
+ except Exception as error:
408
+ out.append({"board": slug, "error": str(error)})
409
+ finally:
410
+ if conn is not None: conn.close()
411
+ print("@@hermes@@" + json.dumps(out, default=str))
412
+ `;
413
+
414
+ /** One dispatcher tick over the board at `root` (Hermes's `kanban_home`), `env` adding `HERMES_BIN` and the like. */
415
+ export async function kanbanTick(root, env = {}) {
416
+ const install = hermesInstall();
417
+ if (!install?.python) throw new Error('the board is Hermes\'s, and no Hermes is installed on PATH');
418
+ return hermesAnswer('kanban dispatch', await python(install, KANBAN_TICK, {}, { ...hermesEnv(install, root), ...env }, 120_000));
419
+ }
420
+
421
+ /**
422
+ * Hermes's MCP trust gate for a relayed tool call (tools/mcp_tool.py `_trust_gate_check`): a tool of one of the
423
+ * profile's servers configured anything but `trust: full` (unset is full) is asked in a conversation and denied
424
+ * with nobody present. Answers a verdict, or null when the call is not such a server's.
425
+ */
426
+ export function untrustedServerVerdict(profile, tool, context, { mcp = false } = {}) {
427
+ const servers = profile?.residue?.config?.mcp_servers ?? {};
428
+ // the configured name the tool is prefixed with (a name may itself hold `__`: the longest match is its)
429
+ const server = Object.keys(servers).filter((name) => (tool ?? '').startsWith(`mcp__${name}__`)).sort((a, b) => b.length - a.length)[0]
430
+ ?? (/^mcp__(.+?)__/.exec(tool ?? '')?.[1] ?? null);
431
+ // an MCP approval that names no tool cannot be judged: it fails closed, as Hermes's unknown trust does
432
+ if (!server && mcp) {
433
+ const why = 'an MCP tool call that names no tool';
434
+ return context === 'conversation' ? { verdict: 'ask', why } : { verdict: 'deny', why, message: `BLOCKED: ${why} cannot be approved with nobody present. Find an alternative approach.` };
435
+ }
436
+ const trust = server ? servers[server]?.trust : undefined;
437
+ if (!server || trust === undefined || trust === null || String(trust).trim().toLowerCase() === 'full') return null;
438
+ const why = `server ${server} is configured 'trust: untrusted'`;
439
+ return context === 'conversation' ? { verdict: 'ask', why } : { verdict: 'deny', why, message: `BLOCKED: ${why}, and its tools need approval, but nobody is present to approve it. Find an alternative approach.` };
440
+ }