openvibers/openvibe.codes Commits

engine/: the agent engine, ported from the portal (C1) (#159)

OP OpenVibers committed 14 days ago · 91f34f9f5a · 19 files, +2617 −0

The pieces of the private agent portal that every Codes surface shares, learned there in production since June 2026:

- engine/router.js: the tier classifier (basic, research, standard, hard, design; "tier: X" forces it) and the per-tier
  ladder, cheapest suitable first, skipping runners that are off, unhealthy, full or mostly failing.
- engine/chain.js: the attempts of one task, why they stopped and what happens next. Pure: the caller passes the data
  in, the engine never reads the portal's log folder.
- engine/budget.js: steps, minutes and dollars; one wrap-up turn after a steps or minutes stop, dollars stay a hard stop.
- engine/transcript.js: one event shape for Claude stream-json, Codex, Command Code, OpenCode, dsh and MiniMax, with
  the shape published as engine/transcript.schema.json.
- engine/effort.js: the reasoning level a harness accepts, and the models a provider refuses.
- engine/scope.js: one systemd scope per job, so nothing is left behind; nothing anywhere but Linux.
- engine/runerror.js: what a failed run really said.

CommonJS, Node 22, no new dependencies. The portal's own tests are ported (chain ends, failure reasons, run errors,
router, effort fit, transcript) and the suite runs in the repo's runner: 49/49 test files passed.

Changes

ddiff --git a/engine/README.md b/engine/README.md
nnew file mode 100644
iindex 0000000..fe511c6
---- /dev/null
++++ b/engine/README.md
@@@ -0,0 +1,176 @@
++# engine/
++
++The agent engine: the pieces of the private agent portal (learned in production, on this workstation, since June 2026)
++that every Codes surface shares — the CLI on a Node, the hosted runner and, until it retires, the portal itself.
++
++CommonJS, Node 22, no dependencies.
++
++```js
++const engine = require('./engine');
++
++engine.classify('Root cause of the race condition in the webhook'); // { tier: 'hard', why: 'a hard problem' }
++engine.outcome(attempts); // { state, reason, step, … }
++```
++
++## The modules
++
++### `router.js` — what kind of task is this, and which runner may take it
++
++A brief is classified into one of five tiers (`basic`, `research`, `standard`, `hard`, `design`); a line `tier: hard`
++in the brief forces it. Each tier has a ladder, cheapest suitable first: free models for chores, flat-price plans for
++everyday code, the strongest models for hard problems and design. `pick()` walks that ladder and skips a runner that is
++off, unhealthy, at its slot cap, or under 40% real success over seven days.
++
++Public: `classify(prompt, opts)`, `classifyText(text, opts)`, `pick(prompt, ctx)`, `track(runs)`, `briefFor(prompt, JOBS, name)`,
++`forcedTier(text)`, `LADDER`, `TIERS`.
++
++```js
++const { classify, pick } = require('./engine/router');
++
++classify('tier: hard\nFind the race condition'); // { tier: 'hard', why: 'the brief says so' }
++
++pick('Fix the parser', { // → { runner: 'mm', model: null, effort: 'high', … }
++ cfg: { subagents: { mm: { enabled: true, max: 2 } } },
++ health: { mm: { state: 'ok' } },
++ counts: {}, models: {}, runs: [],
++});
++```
++
++The ladder is the portal's, so the portal's jobs keep their routing. Codes supplies its own through
++`pick(prompt, { ladder })`.
++
++### `chain.js` — the attempts of one task, why they stopped, what happens next
++
++Pure: it takes the attempts and returns the outcome and the next step. It never reads a log folder, a marker file or a
++process table — the caller passes the data in. The failure reasons are the portal's: `limit`, `auth`, `capacity`,
++`refused`, `error`, `budget`, `stopped`, `crash`, `timeout`, `incomplete`, `context`, `hung`.
++
++An attempt is `{ key, runner, name, runnerName, status, updated, error, next, prev, parent, resolvedNote, stopped, budget, wrapUp, facts }`.
++
++Public: `outcome(attempts, opts)`, `nextStep(attempts, opts)`, `annotate(attempts)`, `settle(attempts, start)`,
++`nextRunner(run, ctx)`, `detect(attempt)`, `facts(attempt, events)`, `continuationNote(run, logFile)`,
++`reason(text)`, `base(name)`, `chainRoot(name)`, `sameChain(a, b)`, `REASON_TEXT`, `SWITCHABLE`.
++
++```js
++const { outcome } = require('./engine/chain');
++
++outcome([
++ { key: 'cc:a', runner: 'cc', name: 'a', runnerName: 'Command Code', status: 'failed', updated: 1, error: 'usage limit reached' },
++ { key: 'mm:a-r2', runner: 'mm', name: 'a-r2', runnerName: 'MiniMax Code', status: 'done', updated: 2 },
++], { cfg, health, counts, models, runners: ['cc', 'mm'] });
++// { state: 'done', reason: 'limit', attempt: 1,
++// step: { action: 'done', why: 'the task finished' }, … }
++```
++
++`step.action` is one of `wait`, `done`, `switch`, `continue`, `restart`, `wrap-up`, `stop`, `failed`, `none`.
++
++### `budget.js` — how far one task may go
++
++Three limits: steps (tool calls), minutes (wall clock) and dollars. A limit that is not set is no limit — an unknown
++price is never a zero budget. The first limit reached wins. Steps and minutes buy **one** wrap-up turn, so the work
++already done lands; dollars stay a hard stop.
++
++Public: `limitsFor(cfg, runner, override)`, `evaluate(used, limits)`, `state(status)`, `wrapUp(hit, opts)`,
++`wrapUpPrompt(hit)`, `STATES`.
++
++```js
++const { evaluate, wrapUp } = require('./engine/budget');
++
++evaluate({ steps: 120, minutes: 30, usd: 1.2 }, { steps: 100, minutes: 60, usd: 5 });
++// { hit: 'steps', limits: { steps: 100, minutes: 60, usd: 5 }, used: { steps: 120, minutes: 30, usd: 1.2 } }
++
++wrapUp('steps'); // { action: 'wrap-up', prompt: 'You reached your steps limit…', why: … }
++wrapUp('usd'); // { action: 'stop', prompt: null, why: 'a dollar limit is a hard stop: no wrap-up turn' }
++```
++
++### `transcript.js` — one event shape for every harness
++
++Claude Code stream-json, Codex, Command Code, OpenCode, `dsh --json` and MiniMax Code stream-json all become
++`{ t, text, name, input, output, at, … }`. The whole shape is described in [`transcript.schema.json`](transcript.schema.json).
++Parsing a file is incremental: only the bytes added since the last read are parsed, so polling a running task every
++second stays cheap.
++
++Public: `parse(file)`, `parseText(text)`, `view(state, from, limit)`, `stats(file)`, `statsOf(state)`,
++`persistStats(file)`, `summary(name, input)`, `kindOf(name)`, `clip(value, max)`, `textOf(content)`, `atOf(ts)`.
++
++```js
++const { parseText } = require('./engine/transcript');
++
++const state = parseText([
++ JSON.stringify({ type: 'assistant', message: { model: 'claude-sonnet-5-5', content: [{ type: 'text', text: 'On it.' }] } }),
++ JSON.stringify({ type: 'assistant', message: { content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'npm test' } }] } }),
++].join('\n'));
++
++state.events.map((e) => e.t); // ['text', 'tool']
++state.events[1].name; // 'Bash'
++state.events[1].input.text; // '{ "command": "npm test" }' (the input as text)
++```
++
++### `effort.js` — the reasoning level a harness accepts, and the models a provider refuses
++
++Each harness accepts a different set of levels, so `fitEffort` rounds a wanted level to the nearest one the target
++harness takes (a tie goes up) — otherwise a retry hands a runner a level it refuses and it dies before its first step.
++The blocked-model list is per harness: a refused model is replaced by one the harness still lists, and a launch with
++nothing left is an error, never a silent run with a refused model.
++
++Public: `fitEffort(runner, effort)`, `stepUp(effort, list)`, `EFFORTS`, `ALIASES`, `ORDER`, `harnessOf(id)`,
++`blockedList(blockedModels, runner)`, `isBlocked(blockedModels, runner, model)`, `pickModel({ runner, model, models, blockedModels })`,
++`blockModel(list, model, error)`, `unblockModel(list, model)`.
++
++```js
++const { fitEffort, pickModel } = require('./engine/effort');
++
++fitEffort('cc', 'medium'); // 'high' — Command Code does not take 'medium'
++fitEffort('oc', 'medium'); // 'medium' — OpenCode has no effort setting
++
++pickModel({ runner: 'cx', model: 'gpt-6.1-sol', models: { cx: ['gpt-6.1-sol', 'gpt-6-sol'] },
++ blockedModels: { cx: ['gpt-6.1-sol'] } });
++// 'gpt-6-sol'
++```
++
++Codes' harness names (`claude-code`, `codex`, `command-code`, `opencode`, `deepseek`) are accepted anywhere a portal
++runner id is.
++
++### `scope.js` — one systemd scope per job
++
++An agent that starts a server in the background leaves it behind when its shell exits: the server is reparented to the
++user's systemd, out of the job's process group, and nothing ever sees it again. Fifteen such servers and headless
++browsers ran this workstation out of memory on 2026-10-30 01:38 UTC — the kernel killed the IDE, the portal and every
++running job. A scope is a cgroup: everything the job starts stays in it, however it detaches, and `reap()` stops the
++scope when the job itself is gone. `ovjobs.slice` caps all jobs together (15G/18G) and each scope caps one job
++(6G/8G, `OOMPolicy=continue`).
++
++Linux with a systemd user manager only: everywhere else `wrap()` returns the command unchanged and `reap()` does
++nothing.
++
++Public: `available()`, `wrap(cmd, args, label)`, `remember(unit, pid, label)`, `reap({ log })`, `stopFor(pid)`,
++`SLICE`, `MAP`.
++
++```js
++const scope = require('./engine/scope');
++
++const { cmd, args, unit } = scope.wrap('node', ['server.js'], 'cc-my-job');
++// on Linux with systemd: cmd = 'systemd-run', unit = 'ovjob-cc-my-job-….scope'
++// elsewhere: cmd = 'node', unit = null
++
++await scope.reap({ log: (line) => console.log(line) });
++```
++
++### `runerror.js` — what a failed run really said
++
++A harness that ends without an answer often leaves the wrapper's own no-result message behind; that message lists
++possible causes, not an observed failure, so it is never reported as one. Only a real diagnostic in the feed or on
++stderr is, and never a fragment of the launch command (the brief is echoed after a killed child).
++
++Public: `failureError(feed, stderr, summary)`, `message(value)`, `NO_RESULT`.
++
++```js
++const { failureError } = require('./engine/runerror');
++
++failureError('{"type":"result","is_error":true,"result":"API Error: usage limit reached"}', '');
++// 'API Error: usage limit reached'
++```
++
++## Tests
++
++`test/engine-*.test.js` in the Codes repo, run by the repo's own runner (`npm test`).
ddiff --git a/engine/budget.js b/engine/budget.js
nnew file mode 100644
iindex 0000000..d394b97
---- /dev/null
++++ b/engine/budget.js
@@@ -0,0 +1,102 @@
++'use strict';
++
++/**
++ * The per-task budget: how far one task may go before it is ended cleanly.
++ *
++ * Three limits, all optional: steps (tool calls), minutes (wall clock since the task started) and dollars (what the
++ * task's own feed reported). A limit that is not set is no limit: an unknown price means no USD limit, never a zero
++ * budget. The defaults come from the config, the runner may have its own, and a single task may override them.
++ *
++ * When a limit is reached the task stops politely and says so ("budget-stopped"), it is not a failure. Steps and
++ * minutes buy one wrap-up turn, so the work already done lands (a commit, a push, a report) instead of sitting in a
++ * worktree. Dollars stay a hard stop: the owner's money does not get a farewell turn.
++ *
++ * Everything here is pure: the caller passes the numbers it already has.
++ */
++
++/** The states a task goes through. */
++const STATES = ['queued', 'running', 'done', 'failed', 'budget-stopped'];
++
++/** The limits a task runs under: the config's defaults, then the runner's own, then the task's override. */
++function limitsFor(cfg, runner, override) {
++ const b = (cfg && cfg.budget) || {};
++ const per = (b.perRunner && b.perRunner[runner]) || {};
++ const num = (v) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : null);
++ const out = {
++ steps: num(per.steps != null ? per.steps : b.steps),
++ minutes: num(per.minutes != null ? per.minutes : b.minutes),
++ usd: num(per.usd != null ? per.usd : b.usd),
++ };
++ if (override && typeof override === 'object') {
++ for (const k of ['steps', 'minutes', 'usd']) if (override[k] != null) out[k] = num(override[k]);
++ }
++ return out;
++}
++
++/**
++ * Compare what a task spent with its limits. The first limit reached wins (steps, then minutes, then dollars).
++ *
++ * @param {object} used what it spent: { steps, minutes, usd }
++ * @param {object} limits what it may spend: { steps, minutes, usd }
++ * @returns {{hit: string|null, limits: object, used: object}} which limit was reached, if any
++ */
++function evaluate(used = {}, limits = {}) {
++ const spent = {
++ steps: Number(used.steps || 0),
++ minutes: used.minutes == null ? null : Number(used.minutes),
++ usd: used.usd == null ? null : Number(used.usd),
++ };
++ const cap = {
++ steps: limits.steps == null ? null : Number(limits.steps),
++ minutes: limits.minutes == null ? null : Number(limits.minutes),
++ usd: limits.usd == null ? null : Number(limits.usd),
++ };
++ let hit = null;
++ if (cap.steps != null && spent.steps >= cap.steps) hit = 'steps';
++ else if (cap.minutes != null && spent.minutes != null && spent.minutes >= cap.minutes) hit = 'minutes';
++ else if (cap.usd != null && spent.usd != null && spent.usd >= cap.usd) hit = 'usd';
++ return {
++ hit,
++ limits: cap,
++ used: { steps: spent.steps, minutes: spent.minutes == null ? null : Math.round(spent.minutes * 10) / 10, usd: spent.usd },
++ };
++}
++
++/** The state of a task from its status: a task that stopped on a limit is 'budget-stopped', never a vague 'failed'. */
++function state(status) {
++ if (status === 'running' || status === 'queued') return status;
++ if (status === 'budget-stopped') return 'budget-stopped';
++ if (status === 'failed' || status === 'interrupted') return 'failed';
++ return 'done';
++}
++
++/**
++ * The one turn a task gets after its steps or minutes ran out: land the work, do not start anything new.
++ *
++ * @param {string} hit which limit was reached
++ * @returns {string} the prompt of the wrap-up turn
++ */
++function wrapUpPrompt(hit) {
++ return `You reached your ${hit} limit, so this is one short last turn to land the work. Do not start anything new.
++1. Run the tests your change touches. Commit what passes and push it. Open the PR if there is none.
++2. If something is half done, leave it out of the commit and say exactly what is left.
++3. Stop any local server or browser you started.
++4. End with a report: what landed (PRs), what is left, and anything the orchestrator must decide.`;
++}
++
++/**
++ * The wrap-up rule: one turn after a steps or minutes stop, never after a dollar stop.
++ *
++ * @param {string} hit which limit was reached ('steps', 'minutes' or 'usd')
++ * @param {object} [opts]
++ * @param {boolean} [opts.done] the wrap-up turn already ran
++ * @returns {{action: 'wrap-up'|'stop', prompt: string|null, why: string}} what to do now
++ */
++function wrapUp(hit, opts = {}) {
++ if (hit === 'usd') return { action: 'stop', prompt: null, why: 'a dollar limit is a hard stop: no wrap-up turn' };
++ if (opts.done) return { action: 'stop', prompt: null, why: 'the wrap-up turn already ran' };
++ if (!hit) return { action: 'stop', prompt: null, why: 'no limit was reached' };
++ return { action: 'wrap-up', prompt: wrapUpPrompt(hit), why: `one last turn to land the work after the ${hit} limit` };
++}
++
++module.exports = { STATES, limitsFor, evaluate, state, wrapUp, wrapUpPrompt };
ddiff --git a/engine/chain.js b/engine/chain.js
nnew file mode 100644
iindex 0000000..dc95d8a
---- /dev/null
++++ b/engine/chain.js
@@@ -0,0 +1,484 @@
++'use strict';
++
++/**
++ * The chain of one task: what each attempt did, why it stopped, and what happens next.
++ *
++ * Everything here is pure. It takes the attempts (already read by the caller) and returns the outcome and the next
++ * step; it never reads a log folder, a marker file or a process table. The portal's own folder stays its business, the
++ * hosted runner can use the same code with its own store, and a test needs no disk at all.
++ *
++ * An attempt is:
++ *
++ * key its own id, unique ("cc:a", "mm:a-r2")
++ * runner the harness that ran it ("cc", "mm", ...)
++ * name its name in the chain ("a", "a-r2", "↻ a-r2")
++ * runnerName the harness' own name, for the words a person reads
++ * status queued | running | done | failed | stopped | interrupted | budget-stopped
++ * updated when it last changed (ms)
++ * error what it said when it failed
++ * next the key of the attempt this one spawned (a retry)
++ * prev the key of the attempt before it
++ * parent the key of the attempt that carried this one on
++ * resolvedNote the owner's note ("marked as handled")
++ * stopped true when it was stopped on purpose
++ * budget { hit } — which limit it reached
++ * wrapUp true when its wrap-up turn already ran
++ * facts { exit, finalText, hasFinal, apiError, stopKind, session }
++ *
++ * The failure reasons are the portal's: limit (the plan ran out), auth (signed out), capacity (the model was busy),
++ * refused (turned off), error (the work itself went wrong), budget (a limit of the owner's), stopped (someone ended
++ * it on purpose) and crash (the process died without an answer).
++ */
++
++const { NO_RESULT } = require('./runerror');
++const { stepUp, EFFORTS } = require('./effort');
++const budget = require('./budget');
++
++/** The runners the portal's chains are made of. */
++const RUNNERS = /^(dsh|cx|cc|oc|pg|mm|cm)$/;
++
++/** The reasons the runner switch owns: a limit, a lost sign-in, a busy model, a runner turned off. */
++const SWITCHABLE = new Set(['limit', 'auth', 'capacity', 'refused']);
++
++/** The reasons a job stopped, in words. */
++const REASON_TEXT = {
++ stopped: 'was cancelled before it finished',
++ limit: 'hit its usage limit',
++ auth: 'lost its sign-in',
++ capacity: 'model was at capacity',
++ refused: 'was turned off in the portal',
++ error: 'stopped with an error',
++ budget: 'stopped at its limit',
++ unknown: 'stopped',
++ crash: 'the process died without an answer',
++ timeout: 'it ran out of time',
++ incomplete: 'it stopped before the work was done',
++ context: 'the context grew too big',
++ hung: 'it went quiet for too long',
++};
++
++// A job that stopped for a reason the engine can fix by itself: the context is too big, the clock ran out, the agent
++// said it was not done, the process died without an answer, or the log went quiet.
++const CONTEXT_RE = /prompt is too long|context length|maximum context|context window|too many tokens|input too long|context_length_exceeded|exceeds the context|prompt exceeds/i;
++const INCOMPLETE_RE = /i'?ll continue|ran out of|max(?:imum)? turns?|not finished|remaining steps|still (?:need|needs|to do|to finish)|more (?:work )?(?:to do|remains)|to be continued|incomplete/i;
++
++/**
++ * Why a run failed, from what it said.
++ *
++ * @param {string} text the error
++ * @returns {string} limit | auth | capacity | refused | error | unknown
++ */
++function reason(text) {
++ // Remove only the wrapper's speculative causes; retain any real diagnostic appended to it.
++ const s = String(text || '').replace(NO_RESULT, 'Command Code ended without a result');
++ if (!s) return 'unknown';
++ if (/turned off in the agent portal|portal limit/i.test(s)) return 'refused';
++ if (/usage limit|reached your [\w -]*limit|limit (exceeded|reached)|rate.?limit|quota|insufficient[ _]balance|insufficient_quota|plan_allowance_exhausted|out of credits|\b402\b|(?:HTTP(?: status)?|status(?: code)?|Request rejected)\s*[:=(]?\s*429\b/i.test(s)) return 'limit';
++ if (/authenticat|log ?in again|run .?cmd login|\b401\b|api key (has )?expired|invalid api key|unauthori[sz]ed|not signed in/i.test(s)) return 'auth';
++ if (/at capacity|overloaded|\b529\b|\b503\b|temporarily unavailable|server is busy/i.test(s)) return 'capacity';
++ if (/mcode exec failed:.*Internal server error.*\(50[024]\)/i.test(s) || /(?:HTTP(?: status)?|status(?: code)?)\s*[:=]?\s*50[024]\b/i.test(s)) return 'capacity';
++ return 'error';
++}
++
++/**
++ * The job a name belongs to: "bench-7-finish", "bench7", "bench7-r2" and "bench7-c1" are one job.
++ *
++ * @param {string} name the attempt's name
++ * @returns {string} the job's name
++ */
++function base(name) {
++ let n = String(name || '').toLowerCase();
++ for (let i = 0; i < 3; i++) n = n.replace(/[-_.](r\d+|c\d+|retry\d*|resume\d*|again|continued?|finish(ed)?|fix(es)?|followup|take\d+)$/, '');
++ n = n.replace(/(\d)f$/, '$1');
++ return n.replace(/[^a-z0-9]/g, '');
++}
++
++/**
++ * The chain a job belongs to: the name without its attempt number ("x-r2", "x-c1" → "x"). The fence of a chain covers
++ * every attempt of it.
++ *
++ * @param {string} name the attempt's name
++ * @returns {string} the chain's root
++ */
++function chainRoot(name) {
++ let n = String(name || '');
++ for (let i = 0; i < 4; i++) {
++ const m = n.match(/-(r\d+|c\d+)$/i);
++ if (!m) break;
++ n = n.slice(0, -m[0].length);
++ }
++ return n;
++}
++
++/**
++ * Two names are one chain when one is the other plus a suffix: "x" and "x-b" (a renamed successor) are one job,
++ * "seo-batch-a" and "seo-batch-b" are not.
++ *
++ * @param {string} a one name
++ * @param {string} b the other
++ * @returns {boolean} true when they are one chain
++ */
++function sameChain(a, b) {
++ const x = chainRoot(a).toLowerCase(), y = chainRoot(b).toLowerCase();
++ if (!x || !y) return false;
++ return x === y || x.startsWith(y + '-') || y.startsWith(x + '-');
++}
++
++/** The attempt that carried each attempt on (the latest one wins). */
++function carriedMap(runs, byKey) {
++ const carried = new Map();
++ for (const r of runs) if (r.parent && byKey.has(r.parent) && (!carried.has(r.parent) || carried.get(r.parent).updated < r.updated)) carried.set(r.parent, r);
++ return carried;
++}
++
++/** The end of a chain: the run that ended it, and the runs it passed through. */
++function settleChain(runs, byKey, carried, start) {
++ let end = start, hops = 0;
++ const trail = [start];
++ while (hops++ < 12) {
++ const c = carried.get(end.key);
++ const n = end.next;
++ const by = end.resolved && end.resolved.by;
++ const step = c && c !== end ? c : n && byKey.has(n) && n !== end.key ? byKey.get(n) : by && byKey.has(by) && by !== end.key && by !== start.key ? byKey.get(by) : null;
++ if (!step || trail.includes(step)) break;
++ end = step;
++ trail.push(end);
++ }
++ return { end, trail };
++}
++
++/**
++ * The attempt that ended a chain, and the attempts it passed through.
++ *
++ * @param {object[]} attempts the attempts of the chain
++ * @param {string|object} [start] the attempt to start from (the latest by default)
++ * @returns {object|null} the attempt that ended the chain
++ */
++function settle(attempts, start) {
++ const runs = annotate(attempts);
++ if (!runs.length) return null;
++ const from = (start && (start.key || start)) ? runs.find((r) => r.key === (start.key || start)) : null;
++ const latest = runs.reduce((a, b) => (b.updated > a.updated ? b : a));
++ const byKey = new Map(runs.map((r) => [r.key, r]));
++ return settleChain(runs, byKey, carriedMap(runs, byKey), from || latest).end;
++}
++
++/**
++ * The attempts of a chain, with the reason of each failure, what resolved it and the attempt number.
++ *
++ * @param {object[]} attempts the attempts
++ * @returns {object[]} new attempts (the input is never changed)
++ */
++function annotate(attempts) {
++ const runs = (attempts || []).map((a) => ({ ...a }));
++ const byKey = new Map(runs.map((r) => [r.key, r]));
++ const jobs = runs.filter((r) => RUNNERS.test(r.runner));
++ const groups = new Map();
++ for (const r of jobs) {
++ const b = base(r.name);
++ if (!groups.has(b)) groups.set(b, []);
++ groups.get(b).push(r);
++ }
++ // "crawl-candidates2" belongs with "crawl-candidates" when that job exists.
++ for (const [b, list] of [...groups]) {
++ const m = b.match(/^(.*[a-z])\d$/);
++ if (m && groups.has(m[1])) { groups.get(m[1]).push(...list); groups.delete(b); }
++ }
++ // The chain of every job: the attempts of it ("x-r2", "x-c1") and its renamed successors ("x-b"). A renamed
++ // successor belongs to the chain of the job it continues, even when the links between them are gone.
++ const chains = new Map();
++ for (const r of jobs) {
++ const k = chainRoot(r.name).toLowerCase();
++ if (!chains.has(k)) chains.set(k, []);
++ chains.get(k).push(r);
++ }
++ const kinOf = (r) => {
++ const k = chainRoot(r.name).toLowerCase(), out = [];
++ for (const [root, list] of chains) if (root === k || root.startsWith(k + '-') || k.startsWith(root + '-')) out.push(...list);
++ return out;
++ };
++ for (const r of jobs) {
++ if (r.status === 'failed' || r.status === 'stopped' || r.status === 'interrupted') r.reason = r.status === 'failed' ? reason(r.error) : r.status === 'interrupted' ? 'crash' : 'stopped';
++ if (r.status !== 'failed' && r.status !== 'stopped' && r.status !== 'interrupted') continue;
++ // The owner stopped it on purpose (the portal, the budget or a human): it is not a failure to attend to.
++ if (r.stopped) { r.resolved = { how: 'stopped', note: 'stopped on purpose' }; continue; }
++ if (r.resolvedNote) { r.resolved = { how: 'note', note: r.resolvedNote }; continue; }
++ // Follow explicit retry links to the end of the chain.
++ let next = r.next, hops = 0, end = null;
++ while (next && byKey.has(next) && hops++ < 8) { end = byKey.get(next); next = end.next; }
++ if (!end) {
++ const group = groups.get(base(r.name)) || [];
++ const seen = new Set(group.map((s) => s.key));
++ const kin = kinOf(r).filter((s) => !seen.has(s.key));
++ const later = [...group, ...kin].filter((s) => s.key !== r.key && s.updated >= r.updated - 60000).sort((a, b) => b.updated - a.updated);
++ end = later.find((s) => s.status === 'running' || s.status === 'queued') || later.find((s) => s.status === 'done') || later[0] || null;
++ }
++ if (end) {
++ const how = end.status === 'done' ? 'done' : end.status === 'running' || end.status === 'queued' ? 'running' : 'failed';
++ r.resolved = { how, by: end.key, byRunner: end.runnerName, note: how === 'done' ? `superseded by ${end.key}` : how === 'running' ? `taken over by ${end.key}` : `failed again as ${end.key}` };
++ // The list says "finished by <name>" when there is one; the note keeps the key of the successor.
++ if (how === 'done') r.resolved.byName = end.name;
++ }
++ }
++ // Where each chain really ends (2026-10-29, the owner: a "✓ failed again as …" line, a run a later session
++ // finished still called failed, a step-limit stop called a failure, and a page that said something else). A
++ // successor is followed through its own retry link, through a session that carried it on (a portal "↻" continuation
++ // names it as its parent) and through what resolved it, to the run that ended the chain. A step-limit stop with
++ // nothing after it is unfinished work: it needs attention, like a failure.
++ const carried = carriedMap(runs, byKey);
++ // How a run in the path ended, in the words a person reads.
++ const said = (x) => ({ done: 'finished', running: 'running', queued: 'queued', 'budget-stopped': 'stopped at its step limit', failed: 'failed', stopped: 'stopped', interrupted: 'crashed' })[x.status] || x.status;
++ const trails = new WeakMap(); // the runs a chain passed through, kept off the runs (the list is sent as JSON)
++ const settleFrom = (start) => {
++ const t = settleChain(runs, byKey, carried, start);
++ trails.set(t.end, t.trail);
++ return t.end;
++ };
++ const ended = (r, end) => {
++ const name = end.name;
++ if (end.status === 'done') return { how: 'done', by: end.key, byName: name, byRunner: end.runnerName, note: `superseded by ${end.key}` };
++ if (end.status === 'running' || end.status === 'queued') return { how: 'running', by: end.key, byName: name, byRunner: end.runnerName, note: `taken over by ${end.key}` };
++ if (end.resolved && (end.resolved.how === 'note' || end.resolved.how === 'stopped')) return { how: 'note', by: end.key, byName: name, byRunner: end.runnerName, note: `carried on as ${name}: ${end.resolved.note}` };
++ if (end.status === 'budget-stopped') return { how: 'budget', by: end.key, byName: name, byRunner: end.runnerName, note: `carried on as ${name}, which stopped at its step limit before it finished` };
++ return { how: 'failed', by: end.key, byName: name, byRunner: end.runnerName, note: `tried again as ${name}, which failed too` };
++ };
++ const ending = (r, end) => ({ ...ended(r, end), path: [r, ...(trails.get(end) || [end]).filter((x) => x !== r)].map((x) => ({ key: x.key, name: x.name, state: said(x) })) });
++ // The step-limit stops first (a handled note, then what carried them on), so a chain that ends in one reads it.
++ const stops = runs.filter((r) => r.status === 'budget-stopped');
++ for (const r of stops) {
++ r.reason = 'budget';
++ if (r.resolvedNote) r.resolved = { how: 'note', note: r.resolvedNote };
++ }
++ for (const r of stops) {
++ if (r.resolved) continue;
++ const end = settleFrom(r);
++ if (end !== r) r.resolved = ending(r, end);
++ }
++ for (const r of runs) {
++ if (r.status === 'budget-stopped' || !r.resolved || !r.resolved.by || !byKey.has(r.resolved.by)) continue;
++ r.resolved = ending(r, settleFrom(byKey.get(r.resolved.by)));
++ }
++ // A handled note that names the job the work went to ("being integrated by mm:x") is followed to where that job
++ // ended, so the line says whether the work was done, not only that someone wrote a note.
++ const named = (note) => (String(note || '').match(/\b(?:dsh|cx|cc|oc|pg|mm|cm|cd):[A-Za-z0-9._-]*[A-Za-z0-9]/g) || []).find((k) => byKey.has(k));
++ for (const r of runs) {
++ const res = r.resolved;
++ if (!res || res.how !== 'note') continue;
++ const k = res.by ? named(byKey.get(res.by) && byKey.get(res.by).resolved && byKey.get(res.by).resolved.note) || named(res.note) : named(res.note);
++ if (!k || k === r.key) continue;
++ const end = settleFrom(byKey.get(k));
++ if (end.status !== 'done' && end.status !== 'running' && end.status !== 'queued') continue;
++ r.resolved = { ...ending(r, end), said: res.note };
++ // The run whose note named it stays in the path ("a → a-r2 (stopped at its step limit) → integrate …").
++ const via = res.by && byKey.get(res.by);
++ if (via && !r.resolved.path.some((h) => h.key === via.key)) r.resolved.path.splice(1, 0, { key: via.key, name: via.name, state: said(via) });
++ }
++ // Attempt numbers along each chain (for "attempt 3").
++ for (const r of jobs) {
++ let n = 1, p = r.prev, hops = 0;
++ while (p && byKey.has(p) && hops++ < 8) { n++; p = byKey.get(p).prev; }
++ if (n > 1) r.attempt = n;
++ }
++ return runs;
++}
++
++/**
++ * The next runner (and model) for a failed job, or null when nothing fits right now.
++ *
++ * @param {object} run the failed attempt
++ * @param {object} ctx
++ * @param {object} ctx.cfg the config (subagents per runner, fallback.order)
++ * @param {object} ctx.health the health of each runner
++ * @param {object} ctx.counts how many jobs each runner runs now
++ * @param {object} ctx.models the catalogs of each runner
++ * @param {string[]} ctx.runners every runner
++ * @param {string} [ctx.tier] the tier of the job
++ * @returns {{runner: string, model: string|null, why: string}|null} the next runner
++ */
++function nextRunner(run, { cfg, health, counts, models, runners, tier } = {}) {
++ const why = run.reason;
++ // A stop is not a failure: someone ended that run on purpose (a kill, a duplicate taken over), so a second runner
++ // would only redo the work (2026-10-29: a stopped cc pin job was queued again on cm, and the mm duplicate with it).
++ if (why === 'stopped') return null;
++ // Phoenix Grove's Taster allowance is tiny: it takes only light jobs.
++ const usable = (k) => (k !== 'pg' || tier === 'basic') && runners.includes(k) && cfg.subagents[k] && cfg.subagents[k].enabled && (counts[k] || 0) < cfg.subagents[k].max
++ && health[k] && (health[k].state === 'ok');
++ // A real error: a second opinion — the next runner in the order (not the same one), with the error in the brief.
++ if (why === 'error') {
++ const order = (cfg.fallback.order || []).filter((k) => runners.includes(k) && k !== run.runner);
++ const k = order.find((x) => usable(x));
++ return k ? { runner: k, model: cfg.subagents[k].model || null, why: `${run.runnerName} stopped with an error; a second try on another runner` } : null;
++ }
++ if (why === 'capacity' && models[run.runner] && cfg.subagents[run.runner] && cfg.subagents[run.runner].enabled) {
++ const list = models[run.runner];
++ const cur = run.model && list.indexOf(run.model);
++ const other = list.find((m, i) => i > (cur >= 0 ? cur : 0) && m !== run.model);
++ if (other) return { runner: run.runner, model: other, why: `${run.model || 'its model'} was at capacity` };
++ }
++ const order = (cfg.fallback.order || []).filter((k) => runners.includes(k));
++ const start = order.indexOf(run.runner);
++ const rotated = start >= 0 ? order.slice(start + 1).concat(order.slice(0, start)) : order;
++ for (const k of rotated) {
++ if (k === run.runner) continue;
++ if (usable(k)) return { runner: k, model: cfg.subagents[k].model || null, why: `${run.runnerName} ${REASON_TEXT[why] || 'stopped'}` };
++ }
++ return null;
++}
++
++/**
++ * The note a continued attempt gets: what the earlier attempt did and where it stopped.
++ *
++ * @param {object} run the earlier attempt
++ * @param {string} logFile where its log is
++ * @returns {string} the note
++ */
++function continuationNote(run, logFile) {
++ return `\n\n---\n## Continuation (written by the agent portal)\n\nAn earlier attempt at this job ran on **${run.runnerName}** and ${REASON_TEXT[run.reason] || 'stopped'}${run.error ? `: "${String(run.error).slice(0, 200)}"` : ''}. Its log is ${logFile}.\nBefore anything else, look at the folder's current state (\`git status\`, \`git diff\`, \`git log -3\`) and the end of that log: keep the work it finished, do not redo it, and continue from where it stopped. If the job is already complete, check it and report that.\n`;
++}
++
++/**
++ * The facts of an attempt, from what its transcript holds.
++ *
++ * @param {object} attempt the attempt
++ * @param {object[]} [events] the transcript's events
++ * @returns {object} { exit, finalText, hasFinal, apiError, stopKind, session }
++ */
++function facts(attempt = {}, events = null) {
++ const f = { ...(attempt.facts || {}) };
++ const list = events || f.events || [];
++ let finalText = f.finalText || '', hasFinal = f.hasFinal || false, apiError = f.apiError || '';
++ for (const e of list) {
++ if (e.t === 'final') { hasFinal = true; if (e.text) finalText = e.text; if (e.isError && e.text && !apiError) apiError = e.text; }
++ else if (e.t === 'error' && !apiError) apiError = String(e.text || '');
++ }
++ if (!apiError && attempt.error) apiError = String(attempt.error);
++ return {
++ exit: f.exit == null ? null : f.exit,
++ finalText,
++ hasFinal,
++ apiError,
++ stopKind: f.stopKind || '',
++ session: f.session || null,
++ usage: f.usage || null,
++ };
++}
++
++/**
++ * Why an attempt stopped, or null when nothing is to be done about it.
++ *
++ * The order matters: a context error explains the stop even when there is an API error on top of it, and a limit, a
++ * lost sign-in or a busy model belong to the runner switch, not to a continue on the same runner.
++ *
++ * @param {object} attempt the attempt, with its facts
++ * @returns {{reason: string, facts: object}|null} why it stopped
++ */
++function detect(attempt = {}) {
++ // An attempt that finished is never continued.
++ if (attempt.status === 'done') return null;
++ const f = facts(attempt);
++ if (CONTEXT_RE.test(f.apiError || '') || CONTEXT_RE.test(f.finalText || '')) return { reason: 'context', facts: f };
++ // An attempt that was interrupted (killed, or a service restart) is a crash: its session can be resumed.
++ if (attempt.status === 'interrupted') return { reason: 'crash', facts: f };
++ const why = f.apiError ? reason(f.apiError) : 'error';
++ if (SWITCHABLE.has(why)) return null;
++ if (f.exit === 124) return { reason: 'timeout', facts: f };
++ if (f.exit === 8) return { reason: 'incomplete', facts: f };
++ if (/max_?tokens|length/i.test(f.stopKind || '')) return { reason: 'incomplete', facts: f };
++ if (f.hasFinal && INCOMPLETE_RE.test(f.finalText)) return { reason: 'incomplete', facts: f };
++ if (!f.hasFinal && !f.apiError) return { reason: 'crash', facts: f };
++ return null;
++}
++
++/**
++ * The next step of a chain: what should happen to the task now.
++ *
++ * @param {object[]} attempts the attempts (annotated or not)
++ * @param {object} [opts] what the caller knows: { cfg, health, counts, models, runners, tier }
++ * @returns {object} { action, why, … } — action is one of wait, done, switch, continue, restart, wrap-up, stop, failed
++ */
++function nextStep(attempts, opts = {}) {
++ const runs = annotate(attempts);
++ if (!runs.length) return { action: 'none', why: 'there are no attempts' };
++ const end = chainEnd(runs);
++ return step(runs, end, opts);
++}
++
++/** The end of a chain: its latest attempt, followed to whatever ended it. */
++function chainEnd(runs) {
++ if (!runs.length) return null;
++ const latest = runs.reduce((a, b) => (b.updated > a.updated ? b : a));
++ const byKey = new Map(runs.map((r) => [r.key, r]));
++ return settleChain(runs, byKey, carriedMap(runs, byKey), latest).end;
++}
++
++/** The next step, given the chain's end. */
++function step(runs, end, opts = {}) {
++ if (!end) return { action: 'none', why: 'there are no attempts' };
++ if (end.status === 'running' || end.status === 'queued') return { action: 'wait', why: 'the task is still running' };
++ if (end.status === 'done') return { action: 'done', why: 'the task finished' };
++ const why = end.reason || reason(end.error);
++ if (why === 'stopped') return { action: 'stop', why: 'the task was stopped on purpose' };
++ if (why === 'budget') {
++ const hit = (end.budget && end.budget.hit) || 'steps';
++ const wrap = budget.wrapUp(hit, { done: !!end.wrapUp });
++ return { action: wrap.action, hit, prompt: wrap.prompt, why: wrap.why };
++ }
++ // The same session continues the task when it stopped early (crash, timeout, incomplete, hung); a context that
++ // grew too big starts fresh with a compacted brief instead. This comes before the runner switch: a context that
++ // overflowed is not a worker's fault, and another runner would hit the same wall.
++ const hit = end.hung ? { reason: 'hung', facts: facts(end) } : detect(end);
++ if (hit) {
++ if (hit.reason === 'context') return { action: 'restart', why: 'the context grew too big: a fresh session with a compacted brief' };
++ const session = hit.facts.session;
++ if (!session) return { action: 'failed', why: `the task stopped (${hit.reason}) and there is no session to continue` };
++ return { action: 'continue', session, why: `the task stopped (${hit.reason}): continue it in the same session` };
++ }
++ // The runner switch: a limit, a lost sign-in, a busy model, a runner turned off, or a real error (a second
++ // opinion). A job retried three times is left to the owner.
++ if (SWITCHABLE.has(why) || why === 'error') {
++ const tries = end.attempt || 1;
++ const max = why === 'error' ? 2 : 4;
++ if (tries >= max) return { action: 'failed', why: `the task was already retried ${tries} times; the owner must decide` };
++ const choice = nextRunner(end, opts);
++ if (!choice) return { action: 'failed', why: `no other runner can take it now (${why})` };
++ const out = { action: 'switch', runner: choice.runner, model: choice.model, why: choice.why };
++ // A real error is retried one reasoning level up: the same attempt again rarely helps.
++ if (why === 'error') {
++ const up = stepUp(end.effort, EFFORTS[choice.runner]);
++ if (up) out.effort = up;
++ }
++ return out;
++ }
++ return { action: 'failed', why: 'the task failed' };
++}
++
++/**
++ * The outcome of a task and the next step: the whole chain, in one call.
++ *
++ * @param {object[]} attempts the attempts of the task
++ * @param {object} [opts] what the caller knows: { cfg, health, counts, models, runners, tier }
++ * @returns {{state: string, reason: string|null, resolved: object|null, attempt: number, path: object[], end: object, step: object, attempts: object[]}}
++ */
++function outcome(attempts, opts = {}) {
++ const runs = annotate(attempts);
++ const end = chainEnd(runs);
++ const state = !end ? 'none' : end.status === 'done' ? 'done' : end.status === 'budget-stopped' ? 'budget-stopped'
++ : end.status === 'failed' || end.status === 'interrupted' ? 'failed' : end.status;
++ return {
++ state,
++ reason: end ? (end.reason || null) : null,
++ resolved: end ? (end.resolved || null) : null,
++ attempt: end ? (end.attempt || 1) : 0,
++ path: (end && end.resolved && end.resolved.path) || [],
++ end: end ? { key: end.key, runner: end.runner, name: end.name, status: end.status } : null,
++ step: step(runs, end, opts),
++ attempts: runs,
++ };
++}
++
++module.exports = {
++ RUNNERS, SWITCHABLE, REASON_TEXT,
++ reason, base, chainRoot, sameChain,
++ annotate, settle, nextRunner, continuationNote,
++ facts, detect, nextStep, outcome,
++};
ddiff --git a/engine/effort.js b/engine/effort.js
nnew file mode 100644
iindex 0000000..e134860
---- /dev/null
++++ b/engine/effort.js
@@@ -0,0 +1,148 @@
++'use strict';
++
++/**
++ * Reasoning effort and blocked models: the two places where a runner is handed something it will refuse.
++ *
++ * Effort: every harness accepts a different set of levels. The 2026-10-29 incident: a job retried on another runner
++ * copied Codex's "medium" to Command Code, which died before its first step ("Unknown effort \"medium\". Supported: off,
++ * low, high, max."). `fitEffort` rounds a wanted level to the nearest one the target harness accepts (a tie goes up), so
++ * no caller can hand a runner a level it refuses. The levels are uneven ("off" and "ultra" exist on one harness only),
++ * so the ordering must know all of them or a cross-runner value passes through untouched.
++ *
++ * Blocked models: a provider can refuse a model on one account ("not supported with a ChatGPT account"). The list is
++ * per harness; a blocked model is replaced by another one the harness still lists, and a launch with no unblocked model
++ * left is an error, never a silent run with a refused model.
++ *
++ * The keys are the portal's short runner ids, so the portal's own ladders and catalogs keep working. Codes calls the
++ * same harnesses by their full names; `harnessOf` maps those onto the short ids.
++ */
++
++/** The reasoning levels each harness accepts (empty: the harness has no setting). */
++const EFFORTS = {
++ cx: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'],
++ cc: ['off', 'low', 'high', 'max'],
++ mm: ['low', 'medium', 'high', 'xhigh', 'max'],
++ cm: ['low', 'medium', 'high', 'xhigh', 'max'],
++ pg: ['low', 'medium', 'high', 'max'],
++ cd: ['low', 'high', 'max'],
++ claude: ['low', 'medium', 'high', 'xhigh', 'max'],
++ dsh: ['none', 'low', 'high', 'max'],
++ oc: [],
++};
++
++/** Codes' harness ids and the portal's short runner ids for the same harness. */
++const ALIASES = {
++ 'claude-code': 'claude',
++ codex: 'cx',
++ 'command-code': 'cc',
++ opencode: 'oc',
++ deepseek: 'dsh',
++ minimax: 'mm',
++ grove: 'pg',
++};
++
++/** Every level, from the weakest to the strongest. */
++const ORDER = ['off', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'];
++
++/** The short runner id of a harness, whoever named it. */
++function harnessOf(id) {
++ const key = String(id || '').toLowerCase();
++ return ALIASES[key] || key;
++}
++
++/**
++ * The nearest level the harness accepts for the wanted one (a tie goes up).
++ *
++ * @param {string} runner the harness
++ * @param {string|null} effort the wanted level
++ * @returns {string|null} a level the harness accepts, or the wanted one when the harness has no effort setting
++ */
++function fitEffort(runner, effort) {
++ const list = EFFORTS[harnessOf(runner)] || [];
++ if (!effort || !list.length || list.includes(effort)) return effort;
++ const at = ORDER.indexOf(effort);
++ if (at < 0) return effort;
++ for (let d = 0; d < ORDER.length; d++) for (const i of [at + d, at - d]) if (list.includes(ORDER[i])) return ORDER[i];
++ return effort;
++}
++
++/**
++ * One reasoning level up, for a retry of a real failure: the same attempt again rarely helps.
++ *
++ * @param {string|null} effort the level this attempt used
++ * @param {string[]|null} list the levels the runner accepts (null: any)
++ * @returns {string|null} the next level
++ */
++function stepUp(effort, list) {
++ const order = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
++ const i = order.indexOf(effort || 'medium');
++ for (let j = i + 1; j < order.length; j++) if (!list || list.includes(order[j])) return order[j];
++ return effort || null;
++}
++
++/** The blocked models of one harness. */
++function blockedList(blockedModels, runner) {
++ const list = (blockedModels || {})[harnessOf(runner)];
++ return Array.isArray(list) ? list : [];
++}
++
++/** Is this model refused by the provider on this harness's account? */
++function isBlocked(blockedModels, runner, model) {
++ if (!model) return false;
++ return blockedList(blockedModels, runner).includes(model);
++}
++
++/** The catalog of one harness, by either of its names. */
++function catalogFor(models, runner) {
++ const all = models || {};
++ return all[runner] || all[harnessOf(runner)] || [];
++}
++
++/**
++ * The model a launch may use: the wanted one, unless the provider refused it, then the first the harness still lists.
++ *
++ * @param {object} what
++ * @param {string} what.runner the harness
++ * @param {string|null} what.model the wanted model
++ * @param {object} what.models the catalogs, per harness
++ * @param {object} what.blockedModels the blocked lists, per harness
++ * @returns {string|null} the model to launch with
++ * @throws {Error} when every model the harness lists is blocked
++ */
++function pickModel({ runner, model, models, blockedModels } = {}) {
++ const blocked = blockedList(blockedModels, runner);
++ if (!model || !blocked.includes(model)) return model;
++ const replacement = catalogFor(models, runner).find((m) => !blocked.includes(m));
++ if (!replacement) throw new Error(`No unblocked model available for ${runner}`);
++ return replacement;
++}
++
++/** The provider messages that mean "this model is not available to you". */
++const REFUSAL_RE = /not supported when using|model_not_found|not available on your plan|model_not_in_plan/i;
++
++/**
++ * The blocked list after a refusal: the model is added when the provider said it does not have it, and left alone
++ * otherwise (a crash is not a missing model).
++ *
++ * @param {string[]} list the current blocked list
++ * @param {string} model the model that was refused
++ * @param {string} error the provider's message
++ * @returns {string[]} the new list
++ */
++function blockModel(list, model, error) {
++ if (!model || !REFUSAL_RE.test(String(error || ''))) return Array.isArray(list) ? list : [];
++ const current = Array.isArray(list) ? list : [];
++ if (current.includes(model)) return current;
++ return [...current, model];
++}
++
++/** The blocked list without one model. */
++function unblockModel(list, model) {
++ return (Array.isArray(list) ? list : []).filter((m) => m !== model);
++}
++
++module.exports = {
++ EFFORTS, ALIASES, ORDER, REFUSAL_RE,
++ harnessOf, fitEffort, stepUp,
++ blockedList, isBlocked, catalogFor, pickModel, blockModel, unblockModel,
++};
ddiff --git a/engine/index.js b/engine/index.js
nnew file mode 100644
iindex 0000000..63aec53
---- /dev/null
++++ b/engine/index.js
@@@ -0,0 +1,44 @@
++'use strict';
++
++/**
++ * The agent engine: the pieces of the private agent portal that Codes, the CLI and the hosted runner all share.
++ *
++ * engine.router what kind of task is this, and which runner may take it
++ * engine.chain what each attempt did, why it stopped, and what happens next (pure)
++ * engine.budget how far one task may go, and the wrap-up turn that lands the work
++ * engine.transcript one event shape for every harness's log
++ * engine.effort the reasoning level a harness accepts, and the models a provider refuses
++ * engine.scope one systemd scope per job, so nothing is left behind
++ * engine.runerror what a failed run really said
++ *
++ * The modules do not depend on each other in a cycle, and none of them reads the portal's log folder.
++ */
++
++const router = require('./router');
++const chain = require('./chain');
++const budget = require('./budget');
++const transcript = require('./transcript');
++const effort = require('./effort');
++const scope = require('./scope');
++const runerror = require('./runerror');
++
++module.exports = {
++ router,
++ chain,
++ budget,
++ transcript,
++ effort,
++ scope,
++ runerror,
++ // The names a caller usually wants, in one place.
++ classify: router.classify,
++ pick: router.pick,
++ outcome: chain.outcome,
++ nextStep: chain.nextStep,
++ evaluate: budget.evaluate,
++ limitsFor: budget.limitsFor,
++ parse: transcript.parse,
++ parseText: transcript.parseText,
++ fitEffort: effort.fitEffort,
++ pickModel: effort.pickModel,
++};
ddiff --git a/engine/router.js b/engine/router.js
nnew file mode 100644
iindex 0000000..6b18958
---- /dev/null
++++ b/engine/router.js
@@@ -0,0 +1,171 @@
++'use strict';
++
++/**
++ * The tier router: what kind of job is this, and which runner may take it.
++ *
++ * A brief is classified into one of five tiers — basic, research, standard, hard, design — and each tier has its own
++ * ladder, cheapest suitable first: free models for basic chores, flat-price plans for everyday code, the strongest
++ * models only for hard problems and design. A brief can force its tier with a line "tier: basic|research|standard|hard|
++ * design".
++ *
++ * Only runners that are on, healthy and have a free slot are chosen, and a runner whose recent jobs mostly failed for
++ * real is skipped until it does better (the record comes from the caller's run list, never from a file here). Pay-per-
++ * token DeepSeek comes after the plans that are already paid for.
++ *
++ * The ladder below is the portal's, so the portal's jobs keep their routing. Codes supplies its own ladder (its
++ * harness ids differ) through `pick(prompt, { ladder })`.
++ */
++
++const fs = require('fs');
++const path = require('path');
++const { fitEffort, stepUp, EFFORTS } = require('./effort');
++
++const LADDER = {
++ // Chores: renames, docs, formatting, counting, simple checks — free and plan Flash models first.
++ basic: [['oc', 'opencode-go/beam-free', null], ['cc', 'reflection/beam:free', 'low'], ['pg', 'deepseek-v4.1-flash-1017', 'low'], ['mm', null, 'low'], ['cx', 'gpt-6-luna', 'low'], ['dsh', 'deepseek-flash', 'none']],
++ // Reading, comparing, web research, audits: models with search, middle reasoning.
++ research: [['cx', 'gpt-6-sol', 'medium'], ['mm', null, 'medium'], ['dsh', 'deepseek-flash', 'high'], ['cc', 'deepseek/deepseek-v4.1-flash', 'high'], ['oc', 'opencode-go/kimi-k3', null]],
++ // Everyday features and fixes: flat-price plans first.
++ standard: [['mm', null, 'high'], ['cx', 'gpt-6-sol', 'medium'], ['cc', 'deepseek/deepseek-v4.1-flash', 'high'], ['oc', 'opencode-go/kimi-k2.7-code', null], ['dsh', 'deepseek-flash', 'high']],
++ // Root causes, architecture, security, migrations: the strongest models.
++ // DeepSeek V4 Pro only as the very last resort (owner, 10-29: it costs a lot more and rarely earns it).
++ hard: [['cx', 'gpt-6-astra', 'high'], ['mm', null, 'max'], ['cc', 'deepseek/deepseek-v4.1-flash', 'high'], ['oc', 'opencode-go/kimi-k3.1-pro', null], ['dsh', 'deepseek-flash', 'max'], ['dsh', 'deepseek-v4-pro', 'max']],
++ // Layout, styling, UI: strong models with taste (Opus reviews later through OPUS-REVIEW-QUEUE.md).
++ // Astra only in the hard tier (owner, 10-29: "does it really need Astra for this?").
++ design: [['cx', 'gpt-6-sol', 'high'], ['mm', null, 'high'], ['cc', 'deepseek/deepseek-v4.1-flash', 'high'], ['dsh', 'deepseek-flash', 'high']],
++};
++
++/** The tiers a brief can fall into. */
++const TIERS = ['basic', 'research', 'standard', 'hard', 'design'];
++
++// The brief a launch prompt points at: "read /…/agents/jobs/x.brief.md and follow it" is a pointer, not a task, so
++// the job is judged by the brief it names (2026-10-29: a pin job was routed as "a short question" to a free model
++// because its prompt was one sentence long). The job's own brief counts too (jobs/<name>.brief.md, jobs/<name>.task.md).
++const BRIEF_RE = /(?:^|[\s'"`(])([^\s'"`)]*\/jobs\/[\w.-]+\.(?:md|txt))/g;
++
++/**
++ * The brief a prompt names, read from the jobs folder.
++ *
++ * @param {string} prompt the launch prompt
++ * @param {string} JOBS the jobs folder
++ * @param {string} [name] the job's own name
++ * @returns {{text: string, file: string}|null} the brief, or null when there is none
++ */
++function briefFor(prompt, JOBS, name) {
++ const text = String(prompt || '');
++ const cands = [];
++ for (const m of text.matchAll(BRIEF_RE)) {
++ const raw = m[1];
++ cands.push(path.isAbsolute(raw) ? raw : path.join(JOBS, path.basename(raw)));
++ }
++ if (name) cands.push(path.join(JOBS, `${name}.brief.md`), path.join(JOBS, `${name}.task.md`));
++ for (const p of cands) {
++ if (!p) continue;
++ let t = null;
++ try { t = fs.readFileSync(p, 'utf8'); } catch { continue; }
++ if (!t || !t.trim() || t.trim() === text.trim()) continue; // the prompt itself is no brief
++ return { text: t, file: p };
++ }
++ return null;
++}
++
++/** The tier a line forces, if it forces one. */
++function forcedTier(text) {
++ const m = String(text || '').match(/^\s*tier:\s*(basic|research|standard|hard|design)\b/im);
++ return m ? m[1].toLowerCase() : null;
++}
++
++/**
++ * The tier of a brief, from the brief itself.
++ *
++ * @param {string} text the brief (or the prompt)
++ * @param {object} [opts]
++ * @param {string} [opts.brief] the file the brief came from
++ * @param {boolean} [opts.whole] also look for the tier line past the first 4000 characters (a brief may be long)
++ * @returns {{tier: string, why: string, brief?: string}} the tier and the words that say so
++ */
++function classifyText(text, opts = {}) {
++ const p = String(text || ''), head = p.slice(0, 4000);
++ const said = opts.brief ? { brief: opts.brief } : {};
++ const forced = forcedTier(head) || (opts.whole ? forcedTier(p) : null);
++ if (forced) return { tier: forced, why: 'the brief says so', ...said };
++ if (/\b(race condition|root cause|architecture|security (review|fix|hole)|migration|data loss|deadlock|concurrency bug|memory leak|performance regression|hard bug)\b/i.test(head)) return { tier: 'hard', why: 'a hard problem', ...said };
++ if (/\b(design pass|redesign|layout|styling|visual|polish the|ui\b|ux\b|css|look and feel|screenshots? at 390)\b/i.test(head)) return { tier: 'design', why: 'design work', ...said };
++ if (/\b(research|investigate|survey|compare|audit|competitor|do not edit any repository|report only|read-only)\b/i.test(head)) return { tier: 'research', why: 'research or audit', ...said };
++ // A chore only when it reads like one and asks for nothing to be built (a short brief can still be a real feature).
++ const builds = /\b(add|build|implement|create|endpoint|api|feature|refactor|migrat|tests?|component|page|service|integrat)\b/i.test(head);
++ if (p.length < 1500 && !builds && /\b(rename|typo|reformat|format|bump|changelog|readme|docs? (update|fix)|add (a )?comment|count|list (all|the)|summari[sz]e|translate|check (that|whether|if)|lint|spell)\b/i.test(head)) return { tier: 'basic', why: 'a small chore', ...said };
++ if (p.length < 200 && !builds) return { tier: 'basic', why: 'a short question', ...said };
++ return { tier: 'standard', why: 'everyday code', ...said };
++}
++
++/**
++ * The tier of a job: from the brief its prompt names, or from the prompt itself.
++ *
++ * @param {string} prompt the launch prompt
++ * @param {object} [opts]
++ * @param {string} [opts.JOBS] the jobs folder, where briefs live
++ * @param {string} [opts.name] the job's own name
++ * @param {string} [opts.briefText] the brief's text, when the caller already has it
++ * @returns {{tier: string, why: string, brief?: string}} the tier and the words that say so
++ */
++function classify(prompt, opts = {}) {
++ const brief = opts.briefText ? { text: opts.briefText, file: opts.briefFile || null }
++ : (opts.JOBS ? briefFor(prompt, opts.JOBS, opts.name) : null);
++ if (!brief) return classifyText(String(prompt || ''));
++ // The tier line of the brief wins wherever it stands in it.
++ return classifyText(brief.text, { brief: brief.file || undefined, whole: true });
++}
++
++/**
++ * The record of what each runner really achieved: a runner whose recent jobs (7 days, at least 4) mostly failed for
++ * real (not limits or sign-in, which the runner switch handles) is skipped until it does better.
++ *
++ * @param {object[]} runs the runs, newest first
++ * @returns {object} per runner: { done, failed }
++ */
++function track(runs) {
++ const since = Date.now() - 7 * 86400e3, rec = {};
++ for (const r of runs || []) {
++ if (r.session || r.runner === 'ship' || r.updated < since || !['done', 'failed', 'interrupted'].includes(r.status)) continue;
++ if (r.status !== 'done' && ['limit', 'auth', 'capacity', 'refused'].includes(r.reason)) continue;
++ const t = rec[r.runner] || (rec[r.runner] = { done: 0, failed: 0 });
++ // Resolution belongs to the job chain, not this runner's attempt. A rescue
++ // by another run (or an owner note) must not turn its failure into success.
++ if (r.status === 'done') t.done++; else t.failed++;
++ }
++ return rec;
++}
++
++/**
++ * The runner, model and reasoning for a job, or null when nothing on the ladder can take it now.
++ *
++ * @param {string} prompt the brief or the launch prompt
++ * @param {object} ctx
++ * @param {object} ctx.cfg the config (subagents: per runner { enabled, max, model })
++ * @param {object} ctx.health the health of each runner ({ state })
++ * @param {object} ctx.counts how many jobs each runner runs now
++ * @param {object} ctx.models the catalogs of each runner
++ * @param {object[]} [ctx.runs] the recent runs, for the reliability gate
++ * @param {string} [ctx.JOBS] the jobs folder
++ * @param {string} [ctx.name] the job's own name
++ * @param {object} [ctx.ladder] the ladder to use (the portal's by default)
++ * @returns {{runner: string, model: string|null, effort: string|null, tier: string, brief: string|null, why: string}|null}
++ */
++function pick(prompt, { cfg, health, counts, models, runs, JOBS, name, ladder } = {}) {
++ const c = classify(prompt, { JOBS, name });
++ const rec = track(runs);
++ const reliable = (r) => { const t = rec[r]; return !t || t.done + t.failed < 4 || t.done / (t.done + t.failed) >= 0.4; };
++ const usable = (r) => cfg.subagents[r] && cfg.subagents[r].enabled && (counts[r] || 0) < cfg.subagents[r].max && health[r] && health[r].state === 'ok' && reliable(r);
++ for (const [runner, model, wanted] of (ladder || LADDER)[c.tier]) {
++ if (!usable(runner)) continue;
++ // A model the runner no longer lists is skipped for that runner's default (lists refresh from each provider).
++ const m = model && models[runner] && !models[runner].includes(model) ? null : model;
++ const effort = fitEffort(runner, wanted);
++ const judged = c.brief ? `${c.why} (the brief it names)` : c.why;
++ return { runner, model: m, effort, tier: c.tier, brief: c.brief || null, why: `${judged} → ${runner}${m ? ' · ' + m : ''}${effort ? ' · ' + effort : ''}` };
++ }
++ return null;
++}
++
++module.exports = { LADDER, TIERS, classify, classifyText, pick, track, briefFor, forcedTier, fitEffort, stepUp, EFFORTS };
ddiff --git a/engine/runerror.js b/engine/runerror.js
nnew file mode 100644
iindex 0000000..c72625b
---- /dev/null
++++ b/engine/runerror.js
@@@ -0,0 +1,53 @@
++'use strict';
++
++/**
++ * What a failed run really said.
++ *
++ * A harness that ends without an answer often leaves the wrapper's own no-result message behind
++ * ("Command Code ended without a result (credits, rate limit or a crash: check its exit code)"). That message lists
++ * possible causes, not an observed failure, so it is never reported as one: only a real diagnostic in the feed or on
++ * stderr is. The result is the work's own error, never a fragment of the launch command (the brief is echoed after a
++ * killed child).
++ */
++
++/** The wrapper's no-result message: possible causes, not an observed quota failure. */
++const NO_RESULT = /Command Code ended without a result\s*\(credits, rate limit or a crash: check its exit code\)/i;
++
++const MAX = 200;
++
++/** A message of any shape as one line of text. */
++function message(value) {
++ if (typeof value === 'string') return value;
++ if (Array.isArray(value)) return value.map(message).filter(Boolean).join('; ');
++ return value && typeof value === 'object' ? message(value.message || value.error) : '';
++}
++
++/**
++ * The error of a failed run, from its feed, its stderr and a summary.
++ *
++ * @param {string} feed the run's jsonl feed
++ * @param {string} stderr what the launcher wrote
++ * @param {string} summary a fallback message
++ * @returns {string} the error, at most 200 characters ('' when there was none)
++ */
++function failureError(feed, stderr, summary = '') {
++ let result = '';
++ // Parse complete JSON records: escaped quotes and whitespace are legal, and tool output is not a run failure.
++ for (const line of String(feed || '').split('\n')) {
++ let e; try { e = JSON.parse(line); } catch { continue; }
++ if (e.type === 'result' && e.is_error) result = message(e.result) || message(e.errors);
++ }
++ if (result && !NO_RESULT.test(result)) return result.trim().slice(0, MAX);
++ const lines = String(stderr || '').split('\n');
++ let diagnostic = '';
++ for (const line of lines) {
++ // Bash prints the entire launch command (including a multiline brief) after a killed child.
++ // Keep only its signal diagnosis, never a fragment of the echoed command or brief.
++ const signal = line.match(/:\s*line \d+:\s*\d+\s+(Killed|Terminated|Segmentation fault|Aborted)\b/);
++ if (signal) return signal[1];
++ if (/^(?:API Error:|Error:|Refused:|mcode exec (?:failed|cancelled):|\[claude-code:[\w-]+\]|(?:Killed|Terminated)\.?$)/.test(line.trim())) diagnostic = line.trim();
++ }
++ return (diagnostic || result || message(summary)).trim().slice(0, MAX);
++}
++
++module.exports = { failureError, message, NO_RESULT };
ddiff --git a/engine/scope.js b/engine/scope.js
nnew file mode 100644
iindex 0000000..d637843
---- /dev/null
++++ b/engine/scope.js
@@@ -0,0 +1,117 @@
++'use strict';
++// Every job and chat turn runs in its own systemd scope inside ovjobs.slice (2026-10-30).
++//
++// Why: an agent that starts a server in the background (`node server/index.js &`, setsid, nohup) leaves it behind when
++// its shell exits. The server is reparented to the user's systemd, so it is no longer in the job's process group or
++// tree and procgroup.stopTree never sees it. Fifteen such servers and headless browsers (8-16 h old, ~7 GB) ran the
++// workstation out of memory on 2026-10-30 01:38 UTC: the kernel killed the IDE, the portal and every running job.
++//
++// A scope is a cgroup: everything the job starts stays in it, however it detaches. When the job's own process is gone,
++// reap() stops the scope, which ends whatever is left. ovjobs.slice caps all jobs together (MemoryHigh 15G, MemoryMax
++// 18G) and each scope caps one job (6G/8G, OOMPolicy=continue: an out-of-memory kill takes one process, like a test
++// runner, not the whole job), so no job can take the machine down again.
++//
++// `systemd-run --scope` execs the command in place: the pid, process group, cwd, env and stdio are the same as a
++// plain spawn, so the rest of the runner (pid files, stopTree) is unchanged. Without systemd-run (or a user manager)
++// wrap() returns the command as it was, and this module does nothing anywhere but Linux.
++
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { execFile, execFileSync } = require('child_process');
++
++const SLICE = 'ovjobs.slice';
++const PER_JOB = ['-p', 'MemoryHigh=6G', '-p', 'MemoryMax=8G', '-p', 'OOMPolicy=continue', '-p', 'TimeoutStopSec=15'];
++
++// Where the map of scopes lives: which process leads which scope, so reap() knows when a job itself is over.
++const MAP = process.env.OV_JOB_SCOPE_MAP || path.join(os.homedir(), '.openvibe-codes', 'job-scopes.json');
++
++let usable = null;
++
++/** Are scopes available here? (Linux with a systemd user manager: false everywhere else.) */
++function available() {
++ if (usable !== null) return usable;
++ if (process.platform !== 'linux') return (usable = false);
++ if (process.env.OV_NO_JOB_SCOPES) return (usable = false);
++ try {
++ execFileSync('systemd-run', ['--user', '--scope', '--quiet', '--collect', `--slice=${SLICE}`, '--', 'true'], { timeout: 8000, stdio: 'ignore' });
++ usable = true;
++ } catch { usable = false; }
++ return usable;
++}
++
++const unitName = (label) => `ovjob-${String(label || 'job').replace(/[^A-Za-z0-9_-]+/g, '_').slice(0, 90)}-${Date.now().toString(36)}.scope`;
++
++/**
++ * The command to spawn instead of (cmd, args), and the scope it will run in (null when scopes are not available).
++ *
++ * @param {string} cmd the command
++ * @param {string[]} args its arguments
++ * @param {string} label a name for the scope
++ * @returns {{cmd: string, args: string[], unit: string|null}} what to spawn
++ */
++function wrap(cmd, args, label) {
++ if (!available()) return { cmd, args, unit: null };
++ const unit = unitName(label);
++ return {
++ cmd: 'systemd-run',
++ args: ['--user', '--scope', '--quiet', '--collect', `--unit=${unit.replace(/\.scope$/, '')}`, `--slice=${SLICE}`, `--description=ovjob ${label}`, ...PER_JOB, '--', cmd, ...args],
++ unit,
++ };
++}
++
++function load() { try { return JSON.parse(fs.readFileSync(MAP, 'utf8')); } catch { return {}; } }
++function save(map) {
++ try {
++ fs.mkdirSync(path.dirname(MAP), { recursive: true });
++ fs.writeFileSync(`${MAP}.tmp`, JSON.stringify(map));
++ fs.renameSync(`${MAP}.tmp`, MAP);
++ } catch { /* best effort */ }
++}
++
++/** Record which process leads a scope, so reap() knows when the job itself is over. */
++function remember(unit, pid, label) {
++ if (!unit || !(pid > 1)) return;
++ const map = load();
++ map[unit] = { pid, label: label || '', at: Date.now() };
++ save(map);
++}
++
++const run = (args) => new Promise((resolve) => execFile('systemctl', ['--user', ...args], { timeout: 20000 }, (e, out) => resolve(e ? null : String(out || ''))));
++
++// The scope's leader is still the job: alive and still in that cgroup (a reused pid is somebody else).
++function leads(pid, unit) {
++ try { return fs.readFileSync(`/proc/${pid}/cgroup`, 'utf8').includes(`/${unit}`); } catch { return false; }
++}
++
++/**
++ * Stop every scope whose job has ended, ending what it left behind.
++ *
++ * @param {object} [opts]
++ * @param {Function} [opts.log] where the notice goes
++ * @returns {Promise<string[]>} the scopes stopped
++ */
++async function reap({ log = () => {} } = {}) {
++ if (!available()) return [];
++ const out = await run(['list-units', '--type=scope', '--plain', '--no-legend', '--all', 'ovjob-*']);
++ if (out === null) return [];
++ const live = new Set(out.split('\n').map((l) => l.trim().split(/\s+/)[0]).filter((u) => /^ovjob-.*\.scope$/.test(u)));
++ const map = load();
++ const stopped = [];
++ for (const [unit, rec] of Object.entries(map)) {
++ if (!live.has(unit)) { delete map[unit]; continue; } // gone already (nothing was left behind)
++ if (leads(rec.pid, unit)) continue; // the job is still running
++ if (await run(['stop', unit]) !== null) { stopped.push(unit); delete map[unit]; log(`jobscope: ${rec.label || unit} ended; stopped its scope (left-behind processes)`); }
++ }
++ save(map);
++ return stopped;
++}
++
++/** Stop one job's scope now (the owner pressed Stop, or the budget ended it). */
++async function stopFor(pid) {
++ const map = load();
++ for (const [unit, rec] of Object.entries(map)) if (rec.pid === Number(pid)) { await run(['stop', unit]); delete map[unit]; save(map); return unit; }
++ return null;
++}
++
++module.exports = { available, wrap, remember, reap, stopFor, SLICE, MAP };
ddiff --git a/engine/transcript.js b/engine/transcript.js
nnew file mode 100644
iindex 0000000..80414eb
---- /dev/null
++++ b/engine/transcript.js
@@@ -0,0 +1,434 @@
++'use strict';
++
++/**
++ * One conversation shape for every harness's log, so a task reads the same whoever worked on it.
++ *
++ * The harnesses speak different languages: Claude Code stream-json (Claude, Claude on DeepSeek), the runners'
++ * converted feeds (Codex, Command Code, OpenCode Go), DeepSeek's `dsh --json` and MiniMax Code's
++ * `mcode exec --output-format stream-json`. This module turns all of them into one event shape:
++ *
++ * { t, text, name, input, output, at, … }
++ *
++ * `t` is the kind of event ('user', 'text', 'thinking', 'tool', 'error', 'final', 'meta'), `at` is when it happened
++ * (ISO 8601), and a tool event carries its `name`, its `input` and, once it arrives, its `output`. The whole shape is
++ * described in transcript.schema.json.
++ *
++ * Parsing is incremental per file: only the bytes added since the last read are parsed, so polling a running task
++ * every second or two stays cheap. `parseText` does the same for a stream that is already in memory.
++ */
++
++const fs = require('fs');
++
++const OUT_CAP = 8000;
++const CACHE_BYTES = 32 * 1024 * 1024;
++const cache = new Map();
++let cacheBytes = 0;
++
++/** Forget a file's parsed state (its log was truncated or removed). */
++function forget(file) {
++ const entry = cache.get(file);
++ if (entry) cacheBytes -= entry.bytes;
++ cache.delete(file);
++}
++
++/** Remember a file's parsed state, within the cache's size. */
++function remember(file, state) {
++ forget(file);
++ const bytes = state.events.reduce((total, event) => total + JSON.stringify(event).length * 2 + 512, 1024)
++ + state.byId.size * 128;
++ if (bytes > CACHE_BYTES) return;
++ while (cache.size >= 60 || cacheBytes + bytes > CACHE_BYTES) forget(cache.keys().next().value);
++ cache.set(file, { state, bytes });
++ cacheBytes += bytes;
++}
++
++/** A value as text, clipped, with the ANSI colours stripped. */
++function clip(v, n = OUT_CAP) {
++ let s = typeof v === 'string' ? v : v == null ? '' : JSON.stringify(v, null, 2);
++ s = s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
++ return s.length > n ? { text: s.slice(0, n), more: s.length - n } : { text: s, more: 0 };
++}
++
++/** The kind of a tool, from its name. */
++function kindOf(name) {
++ const n = String(name || '').toLowerCase();
++ if (/bash|shell|exec|command|terminal|run_?cmd/.test(n)) return 'shell';
++ if (/^(read|view|cat|read_?file|open)/.test(n)) return 'read';
++ if (/edit|write|patch|create_?file|replace|multiedit|str_replace/.test(n)) return 'edit';
++ if (/grep|glob|search_?files|find|list|ls|rg/.test(n) && !/web/.test(n)) return 'search';
++ if (/web|fetch|browse|url|http/.test(n)) return 'web';
++ if (/todo|task|agent|plan/.test(n)) return 'plan';
++ return 'tool';
++}
++
++/** One line about what a tool was asked to do. */
++function summary(name, input) {
++ if (input == null) return '';
++ if (typeof input !== 'object') return String(input).replace(/\s+/g, ' ').slice(0, 220);
++ let v = input.command || input.cmd || input.file_path || input.filePath || input.path || input.pattern || input.query || input.url
++ || input.description || input.prompt || Object.values(input).find((x) => typeof x === 'string');
++ if (Array.isArray(v)) v = v.join(' ');
++ v = String(v || '');
++ // Codex wraps every command as /bin/bash -lc "…".
++ const m = v.match(/^\/bin\/(?:ba)?sh -lc (['"])([\s\S]*)\1$/);
++ if (m) v = m[2];
++ // "cd <job folder> && …" says nothing new: show the command itself.
++ v = v.replace(/^\s*cd\s+("[^"]+"|'[^']+'|\S+)\s*(&&|;)\s*/, '');
++ return v.replace(/\s+/g, ' ').slice(0, 220);
++}
++
++/** The text of a content block, of any shape. */
++function textOf(c) {
++ if (c == null) return '';
++ if (typeof c === 'string') return c;
++ if (Array.isArray(c)) return c.map((b) => (typeof b === 'string' ? b : b && (b.text || b.content || (b.type === 'image' ? '[image]' : '')) || '')).join('\n');
++ if (typeof c === 'object') return c.text || c.content || c.output || JSON.stringify(c, null, 2);
++ return String(c);
++}
++
++/** A tool input as data, when it is a JSON string. */
++function inputOf(raw) {
++ if (typeof raw === 'string') { try { return JSON.parse(raw); } catch { return raw; } }
++ return raw;
++}
++
++/** An empty parse state. */
++function freshState() {
++ return { offset: 0, line: 0, events: [], byId: new Map(), session: null, model: null, cwd: null, usage: { input: 0, output: 0, cached: 0 }, cost: null };
++}
++
++/** When an event happened, as ISO 8601. */
++function atOf(ts) {
++ if (ts == null || !Number.isFinite(Number(ts))) return null;
++ try { return new Date(Number(ts)).toISOString(); } catch { return null; }
++}
++
++/** Add an event to the state, merging the ones that belong together. */
++function push(st, ev) {
++ if (ev.at === undefined) ev.at = atOf(ev.ts);
++ const last = st.events[st.events.length - 1];
++ // The same error reported twice in a row (a system error, then the result) shows once.
++ if (ev.t === 'error' && st.events.slice(-3).some((e) => e.t === 'error' && e.text.trim() === String(ev.text).trim())) return st.events[st.events.length - 1];
++ // Consecutive thinking (dsh emits one per step) and streamed message deltas merge into one block.
++ if (last && ev.t === last.t && !ev.mid && !last.mid && (ev.t === 'thinking' || (ev.t === 'text' && ev.delta && last.delta))) {
++ last.text = (last.text + (ev.t === 'thinking' && !ev.delta ? '\n\n' : '') + ev.text).slice(-20000);
++ return last;
++ }
++ ev.i = st.events.length;
++ st.events.push(ev);
++ return ev;
++}
++
++/** Add a tool call. */
++function addTool(st, id, name, input, line, ts) {
++ const inp = inputOf(input);
++ const ev = push(st, { t: 'tool', id: id || `t${st.events.length}`, name: name || 'tool', kind: kindOf(name), summary: summary(name, inp), input: clip(inp, 6000), pending: true, line, ts });
++ st.byId.set(ev.id, ev.i);
++ return ev;
++}
++
++/** Add the result of a tool call. */
++function setResult(st, id, output, isError) {
++ const i = st.byId.get(id);
++ if (i == null) return false;
++ const ev = st.events[i];
++ const out = clip(textOf(output));
++ ev.output = out;
++ ev.pending = false;
++ ev.isError = !!isError || /\[exit code: [1-9]\d*\]|^Exit code [1-9]|\bexit_code["']?:\s*[1-9]/.test(out.text.slice(-200));
++ const m = out.text.match(/\[exit code: (\d+)\]|Exit code (\d+)/);
++ if (m) ev.exit = Number(m[1] || m[2]);
++ ev.v = (ev.v || 0) + 1;
++ return true;
++}
++
++// A streamed piece of a message (Claude Code with --include-partial-messages): it grows in one place and is
++// replaced by the whole message when that arrives, so the same words are never shown twice.
++function pushDelta(st, ev) {
++ for (let i = st.events.length - 1; i >= 0; i--) {
++ const e = st.events[i];
++ if (e.t !== ev.t) continue;
++ if (e.partial) { e.text = (e.text + ev.text).slice(-20000); e.v = (e.v || 0) + 1; return e; }
++ break;
++ }
++ // mid keeps the delta apart from the block before it (push merges two thinking blocks otherwise).
++ const p = { ...ev, partial: true, mid: true };
++ ev.i = st.events.length;
++ st.events.push(p);
++ return p;
++}
++
++const lastPartial = (st, t) => {
++ for (let i = st.events.length - 1; i >= 0; i--) if (st.events[i].t === t && st.events[i].partial) return st.events[i];
++ return null;
++};
++
++function finalEv(st, text, extra, line, ts) {
++ let t = String(text || '').trim();
++ if (t && st.events.slice(-4).some((e) => e.t === 'error' && e.text.trim() === t)) t = '';
++ const last = [...st.events].reverse().find((e) => e.t === 'text');
++ // Claude's result repeats its last message: mark that message as the answer instead of printing it twice.
++ if (last && t && last.text.trim() === t) { last.answer = true; last.v = (last.v || 0) + 1; return push(st, { t: 'final', text: '', ...extra, line, ts }); }
++ return push(st, { t: 'final', text: t, ...extra, line, ts });
++}
++
++// MiniMax repeats a turn's usage in exec.completed, including partial usage when
++// turn.failed has none. Account for each turn once, adding only newly reported tokens.
++function minimaxUsage(st, e, payload = e) {
++ const u = payload.usage;
++ if (!u) return;
++ const key = `${e.runId || payload.runId || ''}:${e.turnId || payload.turnId || ''}`;
++ if (!st.minimaxUsage) st.minimaxUsage = new Map();
++ const previous = st.minimaxUsage.get(key) || { input: 0, output: 0, cached: 0 };
++ const cached = Number(u.cacheReadTokens || u.cachedInputTokens || u.cache_read_input_tokens || 0);
++ const current = { input: Number(u.inputTokens || u.input_tokens || 0) + cached,
++ output: Number(u.outputTokens || u.output_tokens || 0), cached };
++ for (const field of ['input', 'output', 'cached']) {
++ current[field] = Math.max(previous[field], current[field]);
++ st.usage[field] += current[field] - previous[field];
++ }
++ st.minimaxUsage.set(key, current);
++}
++
++/** Read one event of a harness' feed. */
++function handle(st, e, line) {
++ const ts = e._ts || e.timestampMs || (e.timestamp ? Date.parse(e.timestamp) : null) || null;
++ const type = e.type;
++ // MiniMax Code (schemaVersion 1 events).
++ if (e.schemaVersion && typeof type === 'string' && /\./.test(type)) {
++ if (e.sessionId) st.session = e.sessionId;
++ // A tool call arrives as item.started / item.updated / item.completed with its input and output inside toolCall
++ // (status 2 = done); tools run inside Programmatic Tool Calling's run_code carry parentToolCallId.
++ if (/^item\./.test(type) && e.item && e.item.type === 'tool_call' && e.item.toolCall) {
++ const tc = e.item.toolCall, id = tc.id || e.item.id;
++ let i = st.byId.get(id);
++ if (i == null) { const ev = addTool(st, id, tc.name || tc.toolName, tc.input || tc.arguments, line, ts); if (tc.parentToolCallId) ev.nested = true; i = ev.i; }
++ const ev = st.events[i];
++ if (tc.input != null && !ev.summary) { const inp = inputOf(tc.input); ev.input = clip(inp, 6000); ev.summary = summary(ev.name, inp); ev.v = (ev.v || 0) + 1; }
++ if (tc.output != null || type === 'item.completed') setResult(st, id, tc.output != null ? (tc.output.content || tc.output) : '', tc.isError || tc.status === 3 || /fail|error/i.test(String(tc.status)));
++ return;
++ }
++ // A message or thought takes its place when it starts (so it precedes the tools it announces) and its text when complete.
++ if (type === 'item.started' && e.item && (e.item.type === 'agent_message' || e.item.type === 'reasoning')) {
++ const ev = push(st, { t: e.item.type === 'agent_message' ? 'text' : 'thinking', text: '', mid: e.item.id, line, ts });
++ st.byId.set('m:' + e.item.id, ev.i);
++ return;
++ }
++ if (type === 'item.completed' && e.item && st.byId.has('m:' + e.item.id)) {
++ const ev = st.events[st.byId.get('m:' + e.item.id)];
++ ev.text = textOf(e.item.content || e.item.contentDelta) || ev.text; ev.v = (ev.v || 0) + 1;
++ return;
++ }
++ if (type === 'item.completed' && e.item) {
++ const it = e.item;
++ if (it.type === 'agent_message') push(st, { t: 'text', text: textOf(it.content || it.contentDelta), line, ts });
++ else if (it.type === 'reasoning') push(st, { t: 'thinking', text: textOf(it.content || it.contentDelta), line, ts });
++ else if (/tool_result|tool_output/.test(it.type)) setResult(st, it.toolCallId || it.callId || it.id, it.output || it.content || it.result, it.isError || it.status === 'failed');
++ } else if (type === 'turn.completed') {
++ if (e.model && e.model.modelId) st.model = e.model.modelId;
++ minimaxUsage(st, e);
++ } else if (type === 'turn.failed') push(st, { t: 'error', text: textOf(e.error && (e.error.message || e.error)) || `turn ${e.status || 'failed'}`, line, ts });
++ else if (type === 'exec.completed' && e.result) {
++ if (e.result.model && e.result.model.modelId) st.model = e.result.model.modelId;
++ minimaxUsage(st, e, e.result);
++ finalEv(st, e.result.answer || e.result.output || '', { isError: e.result.status && e.result.status !== 'succeeded', ms: e.result.durationMs }, line, ts);
++ }
++ return;
++ }
++ // DeepSeek's official harness (dsh --json).
++ if (type === 'session' && e.sessionId) { st.session = e.sessionId; st.cwd = e.cwd || st.cwd; push(st, { t: 'meta', kind: 'start', session: e.sessionId, cwd: e.cwd, line, ts }); return; }
++ if (type === 'tool_call') { addTool(st, e.callId, e.tool || e.name, e.input || e.arguments, line, ts); return; }
++ if (type === 'tool_result' && e.callId) { setResult(st, e.callId, e.result != null ? e.result : e.output, e.status && e.status !== 'completed'); return; }
++ if (type === 'thinking') { push(st, { t: 'thinking', text: String(e.text || ''), line, ts }); return; }
++ if (type === 'text' && typeof e.text === 'string') { push(st, { t: 'text', text: e.text, line, ts }); return; }
++ if (type === 'final') { finalEv(st, e.text, {}, line, ts); return; }
++ if (type === 'status') { if (e.usage && e.phase === 'step_end') { const c = Number(e.usage.cacheReadTokens || 0); st.usage.input += Number(e.usage.inputTokens || e.usage.promptTokens || 0) + c; st.usage.output += Number(e.usage.outputTokens || e.usage.completionTokens || 0); st.usage.cached += c; } return; }
++ if (type === 'error') { push(st, { t: 'error', text: textOf(e.message || e.text || e.error), line, ts }); return; }
++ // Claude Code stream-json and the runners' feeds (the same shape).
++ if (type === 'system') {
++ if (e.subtype === 'init') {
++ st.session = e.session_id || st.session; st.model = e.model || st.model; st.cwd = e.cwd || st.cwd;
++ push(st, { t: 'meta', kind: 'start', session: e.session_id, model: e.model, backend: e.backend, cwd: e.cwd, permission: e.permissionMode, line, ts });
++ } else if (e.subtype === 'error') push(st, { t: 'error', text: String(e.text || e.message || 'error'), line, ts });
++ else if (e.subtype === 'usage' && e.usage) { st.usage.input += Number(e.usage.input || 0); st.usage.output += Number(e.usage.output || 0); st.usage.cached += Number(e.usage.cached || 0); }
++ else if (e.subtype === 'compact_boundary') push(st, { t: 'meta', kind: 'compact', line, ts });
++ return;
++ }
++ // Claude Code's partial messages: the text and the thinking arrive as deltas while the turn runs.
++ if (type === 'stream_event') {
++ const ev = e.event || {};
++ if (ev.type === 'content_block_delta') {
++ const d = ev.delta || {};
++ if (d.type === 'text_delta' && d.text) pushDelta(st, { t: 'text', text: d.text, line, ts });
++ else if (d.type === 'thinking_delta' && (d.thinking || d.text)) pushDelta(st, { t: 'thinking', text: d.thinking || d.text, line, ts });
++ }
++ return;
++ }
++ if (type === 'assistant' && e.message) {
++ if (e.message.model && e.message.model !== '<synthetic>') st.model = e.message.model; // Claude Code's own error messages say <synthetic>
++ const u = e.message.usage;
++ // Command Code's feed says includes_cache: input_tokens already holds the cached tokens, so adding them again
++ // doubled its prompt volume and halved its shown cache rate (session-analysis 2026-10-29 §1.1).
++ if (u) { const inc = u.includes_cache === true; st.usage.input += Number(u.input_tokens || 0) + (inc ? 0 : Number(u.cache_read_input_tokens || 0) + Number(u.cache_creation_input_tokens || 0)); st.usage.output += Number(u.output_tokens || 0); st.usage.cached += Number(u.cache_read_input_tokens || 0); }
++ const content = Array.isArray(e.message.content) ? e.message.content : [{ type: 'text', text: textOf(e.message.content) }];
++ for (const b of content) {
++ if (!b) continue;
++ if (b.type === 'text' && b.text && b.text.trim()) {
++ // The whole message replaces what was streamed, so the words appear once.
++ const p = lastPartial(st, 'text');
++ if (p) { p.text = b.text; p.partial = false; p.v = (p.v || 0) + 1; }
++ else push(st, { t: 'text', text: b.text, line, ts });
++ }
++ else if ((b.type === 'thinking' || b.type === 'reasoning') && (b.thinking || b.text)) {
++ const p = lastPartial(st, 'thinking');
++ if (p) { p.text = b.thinking || b.text; p.partial = false; p.v = (p.v || 0) + 1; }
++ else push(st, { t: 'thinking', text: b.thinking || b.text, line, ts });
++ }
++ else if (b.type === 'tool_use') addTool(st, b.id, b.name, b.input, line, ts);
++ }
++ return;
++ }
++ if (type === 'user' && e.message) {
++ const c = e.message.content;
++ if (typeof c === 'string') { if (c.trim()) push(st, { t: 'user', text: c, line, ts }); return; }
++ for (const b of c || []) {
++ if (b && b.type === 'tool_result') setResult(st, b.tool_use_id, b.content, b.is_error);
++ else if (b && b.type === 'text' && b.text && !/^<|^\[Request interrupted/.test(b.text.trim())) push(st, { t: 'user', text: b.text, line, ts });
++ }
++ return;
++ }
++ if (type === 'result') {
++ if (e.session_id) st.session = e.session_id;
++ if (e.total_cost_usd != null) st.cost = (st.cost || 0) + Number(e.total_cost_usd);
++ const u = e.usage || {};
++ const tokens = Number(u.input_tokens || u.inputTokens || 0) + Number(u.output_tokens || u.outputTokens || 0);
++ finalEv(st, e.result, { isError: !!e.is_error, ms: e.duration_ms, turns: e.num_turns, cost: e.total_cost_usd, tokens: tokens || null }, line, ts);
++ }
++}
++
++/**
++ * Parse a stream that is already in memory.
++ *
++ * @param {string} text the jsonl feed
++ * @returns {object} the state: { events, session, model, cwd, usage, cost, line }
++ */
++function parseText(text) {
++ const st = freshState();
++ for (const l of String(text || '').split('\n')) {
++ st.line++;
++ if (!l || l[0] !== '{') continue;
++ let e; try { e = JSON.parse(l); } catch { continue; }
++ try { handle(st, e, st.line); } catch { /* one odd event never breaks the view */ }
++ }
++ return st;
++}
++
++/**
++ * Parse what was added to the file since the last call; returns the cached state.
++ *
++ * @param {string} file the jsonl feed
++ * @returns {object} the state
++ */
++function parse(file) {
++ const entry = cache.get(file);
++ let st = entry && entry.state;
++ let size = 0;
++ try { size = fs.statSync(file).size; } catch { forget(file); return freshState(); }
++ if (!st || size < st.offset) { forget(file); st = freshState(); }
++ if (size === st.offset) {
++ if (entry && entry.state === st) { cache.delete(file); cache.set(file, entry); }
++ return st;
++ }
++ const fd = fs.openSync(file, 'r');
++ try {
++ // Read in chunks; stop at the last complete line.
++ while (st.offset < size) {
++ const len = Math.min(size - st.offset, 4 * 1024 * 1024);
++ const buf = Buffer.alloc(len);
++ fs.readSync(fd, buf, 0, len, st.offset);
++ const end = buf.lastIndexOf(10);
++ if (end < 0) {
++ if (len === size - st.offset) break; // the last line is still being written
++ st.offset += len; // a single line over 4 MB: skip it rather than stall
++ continue;
++ }
++ for (const l of buf.subarray(0, end).toString('utf8').split('\n')) {
++ st.line++;
++ if (!l || l[0] !== '{') continue;
++ let e; try { e = JSON.parse(l); } catch { continue; }
++ try { handle(st, e, st.line); } catch { /* one odd event never breaks the view */ }
++ }
++ st.offset += end + 1;
++ }
++ } finally { fs.closeSync(fd); }
++ remember(file, st);
++ return st;
++}
++
++/**
++ * The events from index `from`, plus where the client should ask from next time (the first tool still waiting).
++ *
++ * @param {object} st the state
++ * @param {number} [from] the first event wanted
++ * @param {number} [limit] how many events at most
++ * @returns {object} { events, from, total, skipped, next, info }
++ */
++function view(st, from = 0, limit = 600) {
++ const total = st.events.length;
++ const open = st.events.findIndex((e) => e.t === 'tool' && e.pending);
++ let start = Math.max(0, Math.min(from, total));
++ let skipped = 0;
++ if (start === 0 && total > limit) { skipped = total - limit; start = skipped; }
++ return {
++ events: st.events.slice(start),
++ from: start, total, skipped,
++ next: open >= 0 ? Math.min(open, total) : total,
++ info: { session: st.session, model: st.model, cwd: st.cwd, usage: st.usage, cost: st.cost },
++ };
++}
++
++// The few numbers a list of jobs needs from a log, memoised by the file's size and mtime: an unchanged log is never
++// parsed again (the full parse cache above holds 60 files, and a busy day has hundreds). persistStats(file) keeps the
++// memo across restarts.
++const STATS = new Map();
++let statsFile = null, statsDirty = false;
++
++/** The numbers of a parsed stream: tools, edits, errors, tokens, cost. */
++function statsOf(st) {
++ const u = st.usage || { input: 0, output: 0, cached: 0 };
++ let tools = 0, edits = 0, errs = 0, lastTool = '', lastErr = '';
++ for (const e of st.events) {
++ if (e.t === 'tool') { tools++; if (e.kind === 'edit') edits++; if (e.isError) errs++; lastTool = `${e.name} ${e.summary}`; }
++ if (e.t === 'error' || (e.t === 'final' && e.isError && e.text)) lastErr = String(e.text).slice(0, 200);
++ }
++ return {
++ tools, edits, errs, cost: Number.isFinite(st.cost) ? Number(st.cost) : null,
++ input: u.input || 0, output: u.output || 0, cached: u.cached || 0,
++ model: st.model || null, lastTool: lastTool.slice(0, 200), lastErr,
++ };
++}
++
++/** The numbers of a log, memoised by the file's size and mtime. */
++function stats(file) {
++ let stt; try { stt = fs.statSync(file); } catch { return null; }
++ const hit = STATS.get(file);
++ if (hit && hit.size === stt.size && hit.mtime === stt.mtimeMs) return hit.s;
++ const s = statsOf(parse(file));
++ STATS.set(file, { size: stt.size, mtime: stt.mtimeMs, s });
++ statsDirty = true;
++ return s;
++}
++
++/** Keep the memo across restarts, in a file of the caller's choosing. */
++function persistStats(file) {
++ statsFile = file;
++ try { for (const [k, v] of Object.entries(JSON.parse(fs.readFileSync(file, 'utf8')))) STATS.set(k, v); } catch { /* first run */ }
++ setInterval(() => {
++ if (!statsDirty || !statsFile) return;
++ statsDirty = false;
++ for (const k of STATS.keys()) if (!fs.existsSync(k)) STATS.delete(k);
++ try { fs.writeFileSync(`${statsFile}.tmp`, JSON.stringify(Object.fromEntries(STATS))); fs.renameSync(`${statsFile}.tmp`, statsFile); } catch { statsDirty = true; }
++ }, 30000).unref();
++}
++
++module.exports = { parse, parseText, view, summary, kindOf, textOf, clip, stats, statsOf, persistStats, atOf };
ddiff --git a/engine/transcript.schema.json b/engine/transcript.schema.json
nnew file mode 100644
iindex 0000000..a92797e
---- /dev/null
++++ b/engine/transcript.schema.json
@@@ -0,0 +1,159 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.codes/schemas/engine/transcript@1",
++ "title": "OpenVibe.Codes transcript",
++ "description": "One event shape for every harness's log: Claude Code stream-json, Codex, Command Code, OpenCode, dsh --json and MiniMax Code stream-json. The normaliser lives in engine/transcript.js.",
++ "type": "object",
++ "required": ["events"],
++ "properties": {
++ "events": {
++ "type": "array",
++ "description": "The conversation, in order.",
++ "items": { "$ref": "#/$defs/event" }
++ },
++ "session": {
++ "type": ["string", "null"],
++ "description": "The harness's session id, when it reported one (a continue needs it)."
++ },
++ "model": {
++ "type": ["string", "null"],
++ "description": "The model that worked on it, when the feed said."
++ },
++ "cwd": {
++ "type": ["string", "null"],
++ "description": "The folder it worked in."
++ },
++ "usage": {
++ "type": "object",
++ "description": "The tokens it used.",
++ "properties": {
++ "input": { "type": "number", "minimum": 0 },
++ "output": { "type": "number", "minimum": 0 },
++ "cached": { "type": "number", "minimum": 0 }
++ },
++ "additionalProperties": false
++ },
++ "cost": {
++ "type": ["number", "null"],
++ "description": "The dollars the feed reported, when it reported any."
++ },
++ "line": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "The number of feed lines that were read."
++ }
++ },
++ "additionalProperties": true,
++ "$defs": {
++ "event": {
++ "type": "object",
++ "required": ["t"],
++ "description": "One event of the conversation.",
++ "properties": {
++ "t": {
++ "type": "string",
++ "enum": ["user", "text", "thinking", "tool", "error", "final", "meta"],
++ "description": "The kind of event."
++ },
++ "text": {
++ "type": "string",
++ "description": "The words: what was said, thought, or went wrong."
++ },
++ "name": {
++ "type": "string",
++ "description": "The tool's name (on a tool event)."
++ },
++ "kind": {
++ "type": "string",
++ "enum": ["shell", "read", "edit", "search", "web", "plan", "tool"],
++ "description": "What kind of tool it was (on a tool event)."
++ },
++ "summary": {
++ "type": "string",
++ "description": "One line about what the tool was asked to do."
++ },
++ "input": {
++ "type": "object",
++ "description": "What the tool was given.",
++ "properties": {
++ "text": { "type": "string" },
++ "more": { "type": "number", "minimum": 0 }
++ },
++ "required": ["text"],
++ "additionalProperties": true
++ },
++ "output": {
++ "type": ["object", "null"],
++ "description": "What the tool answered, once it did.",
++ "properties": {
++ "text": { "type": "string" },
++ "more": { "type": "number", "minimum": 0 }
++ },
++ "required": ["text"],
++ "additionalProperties": true
++ },
++ "at": {
++ "type": ["string", "null"],
++ "format": "date-time",
++ "description": "When it happened (ISO 8601), when the feed said."
++ },
++ "ts": {
++ "type": ["number", "null"],
++ "description": "When it happened, in milliseconds since the epoch."
++ },
++ "line": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "The feed line it came from."
++ },
++ "i": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "Its index in the event list."
++ },
++ "id": {
++ "type": "string",
++ "description": "The tool call's id (on a tool event)."
++ },
++ "pending": {
++ "type": "boolean",
++ "description": "The tool has not answered yet."
++ },
++ "isError": {
++ "type": "boolean",
++ "description": "It failed."
++ },
++ "exit": {
++ "type": "number",
++ "description": "The exit code of the command (on a tool event)."
++ },
++ "v": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "How often this event changed."
++ },
++ "partial": {
++ "type": "boolean",
++ "description": "The words are still streaming in."
++ },
++ "answer": {
++ "type": "boolean",
++ "description": "This message is the answer of the turn."
++ },
++ "session": {
++ "type": "string",
++ "description": "The session id (on a meta event)."
++ },
++ "model": {
++ "type": "string",
++ "description": "The model (on a meta event)."
++ },
++ "cwd": {
++ "type": "string",
++ "description": "The folder (on a meta event)."
++ }
++ },
++ "additionalProperties": true
++ }
++ }
++}
ddiff --git a/test/engine-budget.test.js b/test/engine-budget.test.js
nnew file mode 100644
iindex 0000000..8b54d8d
---- /dev/null
++++ b/test/engine-budget.test.js
@@@ -0,0 +1,70 @@
++'use strict';
++// The per-task budget, ported from the portal's jobbudget.js: steps, minutes and dollars, and the wrap-up turn that
++// lands the work when the steps or the minutes run out. Dollars stay a hard stop.
++
++const assert = require('node:assert/strict');
++const { check, done } = require('./helpers/boot');
++const { limitsFor, evaluate, state, wrapUp, wrapUpPrompt, STATES } = require('../engine/budget');
++
++(async () => {
++ await check('the limits are the config\'s, then the runner\'s, then the task\'s', () => {
++ const cfg = { budget: { steps: 100, minutes: 60, usd: 5, perRunner: { cc: { steps: 50 } } } };
++ assert.deepEqual(limitsFor(cfg, 'mm'), { steps: 100, minutes: 60, usd: 5 });
++ assert.deepEqual(limitsFor(cfg, 'cc'), { steps: 50, minutes: 60, usd: 5 });
++ assert.deepEqual(limitsFor(cfg, 'mm', { steps: 10, usd: 1 }), { steps: 10, minutes: 60, usd: 1 });
++ assert.deepEqual(limitsFor({}, 'mm'), { steps: null, minutes: null, usd: null });
++ });
++
++ await check('a limit that is not set is no limit, never a zero budget', () => {
++ assert.deepEqual(limitsFor({ budget: { steps: 0, minutes: -1, usd: 'many' } }, 'mm'), { steps: null, minutes: null, usd: null });
++ assert.equal(evaluate({ steps: 100000 }, limitsFor({}, 'mm')).hit, null);
++ });
++
++ await check('the first limit reached wins', () => {
++ const limits = { steps: 100, minutes: 60, usd: 5 };
++ assert.equal(evaluate({ steps: 120, minutes: 90, usd: 9 }, limits).hit, 'steps');
++ assert.equal(evaluate({ steps: 10, minutes: 90, usd: 9 }, limits).hit, 'minutes');
++ assert.equal(evaluate({ steps: 10, minutes: 20, usd: 9 }, limits).hit, 'usd');
++ assert.equal(evaluate({ steps: 10, minutes: 20, usd: 1 }, limits).hit, null);
++ });
++
++ await check('an unknown spend is not a reason to stop', () => {
++ assert.equal(evaluate({ steps: 1, minutes: null, usd: null }, { steps: 100, minutes: 60, usd: 5 }).hit, null);
++ const out = evaluate({ steps: 1, minutes: null, usd: null }, { steps: 100, minutes: 60, usd: 5 });
++ assert.equal(out.used.minutes, null);
++ assert.equal(out.used.usd, null);
++ });
++
++ await check('a task that stopped on a limit is budget-stopped, never a vague failure', () => {
++ assert.equal(state('budget-stopped'), 'budget-stopped');
++ assert.equal(state('failed'), 'failed');
++ assert.equal(state('interrupted'), 'failed');
++ assert.equal(state('done'), 'done');
++ assert.equal(state('running'), 'running');
++ assert.ok(STATES.includes('budget-stopped'));
++ });
++
++ await check('steps and minutes buy one wrap-up turn', () => {
++ const steps = wrapUp('steps');
++ assert.equal(steps.action, 'wrap-up');
++ assert.match(steps.prompt, /one short last turn/);
++ assert.match(steps.prompt, /Do not start anything new/);
++ assert.equal(wrapUp('minutes').action, 'wrap-up');
++ });
++
++ await check('a dollar limit is a hard stop', () => {
++ const dollars = wrapUp('usd');
++ assert.equal(dollars.action, 'stop');
++ assert.equal(dollars.prompt, null);
++ });
++
++ await check('only one wrap-up turn', () => {
++ assert.equal(wrapUp('steps', { done: true }).action, 'stop');
++ });
++
++ await check('the wrap-up prompt says which limit was reached', () => {
++ assert.match(wrapUpPrompt('minutes'), /your minutes limit/);
++ });
++
++ done();
++})();
ddiff --git a/test/engine-chain.test.js b/test/engine-chain.test.js
nnew file mode 100644
iindex 0000000..ba4f686
---- /dev/null
++++ b/test/engine-chain.test.js
@@@ -0,0 +1,162 @@
++'use strict';
++// The chain of one task, ported from the portal's test-chain-end.js: a failed job's line followed one hop, and a
++// step-limit stop with nothing after it was never attention. The chain is followed to the run that ended it: through
++// retry links, through a session that carried a run on (its parent), and through what resolved the next hop.
++//
++// The portal read those links from marker files in its log folder; the engine is pure, so the links come in with the
++// attempts.
++
++const assert = require('node:assert/strict');
++const { check, done } = require('./helpers/boot');
++const chain = require('../engine/chain');
++
++const now = Date.now();
++const job = (runner, name, status, at, extra = {}) => ({
++ key: `${runner}:${name}`, runner, name, runnerName: runner.toUpperCase(),
++ status, updated: at, error: status === 'done' ? '' : 'it broke', ...extra,
++});
++const carried = (parent, name, at) => ({
++ key: `cd:${name}`, runner: 'mm', harness: 'mm', parent, runnerName: 'MiniMax Code',
++ name: `↻ ${name}`, status: 'done', updated: at, error: '', session: true,
++});
++
++(async () => {
++ await check('a chain is followed through its retries and the sessions that carried it on', () => {
++ const attempts = [
++ // a: crashed, its retry stopped at the step limit, and a session carried that retry on to the end.
++ job('cc', 'a', 'failed', now - 9000, { next: 'mm:a-r2' }),
++ job('mm', 'a-r2', 'budget-stopped', now - 5000),
++ carried('mm:a-r2', 'a-r2', now - 1000),
++ // b: crashed, its retry stopped at the step limit, and nothing carried it on.
++ job('cx', 'b', 'failed', now - 9000, { next: 'mm:b-r2' }),
++ job('mm', 'b-r2', 'budget-stopped', now - 5000),
++ // c: the same as b, but the owner marked the stop handled.
++ job('cc', 'c', 'failed', now - 9000, { next: 'mm:c-r2' }),
++ job('mm', 'c-r2', 'budget-stopped', now - 5000, { resolvedNote: 'finished by hand in PR 12' }),
++ // d: crashed, its retry crashed too, and the second retry finished.
++ job('cc', 'd', 'failed', now - 9000, { next: 'mm:d-r2' }),
++ job('mm', 'd-r2', 'failed', now - 5000, { next: 'cm:d-r3' }),
++ job('cm', 'd-r3', 'done', now - 1000),
++ ];
++ const runs = chain.annotate(attempts);
++ const by = Object.fromEntries(runs.map((r) => [r.key, r]));
++
++ assert.equal(by['cc:a'].resolved.how, 'done', 'a session that carried the retry on finished the chain');
++ assert.equal(by['cc:a'].resolved.byName, '↻ a-r2');
++ assert.equal(by['mm:a-r2'].resolved.how, 'done', 'the step-limit stop it carried on is resolved too');
++
++ assert.equal(by['cx:b'].resolved.how, 'budget', 'the chain ended in a step-limit stop');
++ assert.match(by['cx:b'].resolved.note, /b-r2, which stopped at its step limit before it finished/);
++ assert.doesNotMatch(by['cx:b'].resolved.note, /failed again/, 'a step-limit stop is not called a failure');
++ assert.equal(by['mm:b-r2'].resolved, undefined, 'the unfinished stop needs attention');
++ assert.equal(by['mm:b-r2'].reason, 'budget');
++
++ assert.equal(by['mm:c-r2'].resolved.how, 'note', 'a stop the owner marked handled is handled');
++ assert.equal(by['cc:c'].resolved.how, 'note', 'and so is the job it carried on');
++ assert.match(by['cc:c'].resolved.note, /finished by hand in PR 12/);
++
++ assert.equal(by['cc:d'].resolved.how, 'done', 'two hops down, the chain finished');
++ assert.equal(by['cc:d'].resolved.byName, 'd-r3');
++ assert.equal(by['mm:d-r2'].resolved.how, 'done');
++ });
++
++ await check('a handled note that names where the work went is followed to how that job ended', () => {
++ const attempts = [
++ job('cc', 'e', 'failed', now - 9000, { next: 'mm:e-r2' }),
++ job('mm', 'e-r2', 'budget-stopped', now - 5000, { resolvedNote: 'Superseded: the commits are being integrated by mm:integrate-x.' }),
++ job('mm', 'integrate-x', 'budget-stopped', now - 3000),
++ carried('mm:integrate-x', 'integrate-x', now - 1000),
++ ];
++ const m = Object.fromEntries(chain.annotate(attempts).map((r) => [r.key, r]));
++ assert.equal(m['mm:e-r2'].resolved.how, 'done', 'the note named the job that finished the work');
++ assert.equal(m['mm:e-r2'].resolved.byName, '↻ integrate-x');
++ assert.match(m['mm:e-r2'].resolved.said, /being integrated by mm:integrate-x/, 'the note is kept');
++ assert.equal(m['cc:e'].resolved.how, 'done');
++ });
++
++ await check('the attempts are not changed by annotating them', () => {
++ const attempts = [job('cc', 'a', 'failed', now - 9000, { next: 'mm:a-r2' }), job('mm', 'a-r2', 'budget-stopped', now - 5000)];
++ const before = JSON.stringify(attempts);
++ chain.annotate(attempts);
++ assert.equal(JSON.stringify(attempts), before, 'the input is never changed');
++ });
++
++ await check('the outcome of a task is its state, its reason and its next step', () => {
++ const attempts = [
++ job('cc', 'a', 'failed', now - 9000, { error: 'usage limit reached' }),
++ job('mm', 'a-r2', 'done', now - 5000),
++ ];
++ const out = chain.outcome(attempts);
++ assert.equal(out.state, 'done');
++ assert.equal(out.end.key, 'mm:a-r2');
++ assert.equal(out.step.action, 'done');
++ assert.equal(out.attempts.length, 2);
++ });
++
++ await check('a limit hands the task to another runner', () => {
++ const cfg = { subagents: { cc: { enabled: true, max: 2 }, mm: { enabled: true, max: 2 } }, fallback: { order: ['cc', 'mm'] } };
++ const ctx = { cfg, health: { cc: { state: 'ok' }, mm: { state: 'ok' } }, counts: {}, models: {}, runners: ['cc', 'mm'] };
++ const attempts = [job('cc', 'a', 'failed', now, { error: 'usage limit reached' })];
++ const step = chain.nextStep(attempts, ctx);
++ assert.equal(step.action, 'switch');
++ assert.equal(step.runner, 'mm');
++ assert.match(step.why, /hit its usage limit/);
++ });
++
++ await check('a real error is retried on another runner, one reasoning level up', () => {
++ const cfg = { subagents: { cc: { enabled: true, max: 2 }, mm: { enabled: true, max: 2 } }, fallback: { order: ['cc', 'mm'] } };
++ const ctx = { cfg, health: { cc: { state: 'ok' }, mm: { state: 'ok' } }, counts: {}, models: {}, runners: ['cc', 'mm'] };
++ const attempts = [job('cc', 'a', 'failed', now, { error: 'the tests did not pass', effort: 'medium' })];
++ const step = chain.nextStep(attempts, ctx);
++ assert.equal(step.action, 'switch');
++ assert.equal(step.runner, 'mm');
++ assert.equal(step.effort, 'high', 'one level up: the same attempt again rarely helps');
++ });
++
++ await check('a crash is continued in the same session', () => {
++ const attempts = [{ ...job('cc', 'a', 'interrupted', now), facts: { session: 'sess-1', exit: 137 } }];
++ const step = chain.nextStep(attempts);
++ assert.equal(step.action, 'continue');
++ assert.equal(step.session, 'sess-1');
++ assert.match(step.why, /crash/);
++ });
++
++ await check('a context that grew too big starts fresh instead of continuing', () => {
++ const attempts = [{ ...job('cc', 'a', 'failed', now), facts: { apiError: 'prompt is too long: 250000 tokens' } }];
++ const step = chain.nextStep(attempts);
++ assert.equal(step.action, 'restart');
++ assert.match(step.why, /context grew too big/);
++ });
++
++ await check('a stop is not a failure and is never handed to another runner', () => {
++ const attempts = [job('cc', 'a', 'stopped', now)];
++ const step = chain.nextStep(attempts, { cfg: { subagents: {}, fallback: { order: [] } } });
++ assert.equal(step.action, 'stop');
++ });
++
++ await check('a step limit buys one wrap-up turn, a dollar limit does not', () => {
++ const steps = [job('cc', 'a', 'budget-stopped', now, { budget: { hit: 'steps' } })];
++ assert.equal(chain.nextStep(steps).action, 'wrap-up');
++ assert.match(chain.nextStep(steps).prompt, /one short last turn/);
++ const dollars = [job('cc', 'a', 'budget-stopped', now, { budget: { hit: 'usd' } })];
++ assert.equal(chain.nextStep(dollars).action, 'stop');
++ const done = [job('cc', 'a', 'budget-stopped', now, { budget: { hit: 'steps' }, wrapUp: true })];
++ assert.equal(chain.nextStep(done).action, 'stop', 'only one wrap-up turn');
++ });
++
++ await check('a running task is waited for, not decided', () => {
++ assert.equal(chain.nextStep([job('cc', 'a', 'running', now)]).action, 'wait');
++ assert.equal(chain.nextStep([job('cc', 'a', 'done', now)]).action, 'done');
++ });
++
++ await check('the names of one job, its chain and its base are told apart', () => {
++ assert.equal(chain.base('bench-7-finish'), 'bench7');
++ assert.equal(chain.base('bench7-r2'), 'bench7');
++ assert.equal(chain.chainRoot('x-r2'), 'x');
++ assert.equal(chain.chainRoot('x-c1'), 'x');
++ assert.equal(chain.sameChain('x', 'x-b'), true);
++ assert.equal(chain.sameChain('seo-batch-a', 'seo-batch-b'), false);
++ });
++
++ done();
++})();
ddiff --git a/test/engine-effort.test.js b/test/engine-effort.test.js
nnew file mode 100644
iindex 0000000..572b45c
---- /dev/null
++++ b/test/engine-effort.test.js
@@@ -0,0 +1,86 @@
++'use strict';
++// The reasoning level a harness accepts, ported from the portal's test-effort-fit.js. The 2026-10-29 incident: a job
++// retried on another runner copied cx's "medium" to Command Code, which died before its first step ("Unknown effort
++// \"medium\". Supported: off, low, high, max."). Levels are also uneven (off and ultra exist on one harness only), so
++// the ordering must know them all or a cross-runner value passes through untouched.
++//
++// The blocked-model list, from the portal's test-blocked-models.js: a model the provider refuses is replaced by one it
++// still lists, and a launch with nothing left is an error, never a silent run with a refused model.
++
++const assert = require('node:assert/strict');
++const { check, done } = require('./helpers/boot');
++const { fitEffort, stepUp, EFFORTS, pickModel, blockModel, unblockModel, isBlocked, harnessOf } = require('../engine/effort');
++
++const ALL = ['off', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'];
++
++(async () => {
++ await check('every level a harness offers is a level the ordering knows', () => {
++ for (const [harness, list] of Object.entries(EFFORTS)) {
++ for (const level of list) assert.ok(ALL.includes(level), `${harness} offers ${level}, which fitEffort does not order`);
++ }
++ });
++
++ await check('whatever comes in, the result is accepted by the harness', () => {
++ for (const [harness, list] of Object.entries(EFFORTS)) {
++ for (const level of ALL) {
++ const got = fitEffort(harness, level);
++ if (list.length) assert.ok(list.includes(got), `${harness} would be handed ${got} for ${level}`);
++ else assert.equal(got, level, `${harness} has no effort setting: ${level} passes through`);
++ }
++ }
++ });
++
++ await check('the reported case and the neighbours it must not break', () => {
++ assert.equal(fitEffort('cc', 'medium'), 'high');
++ assert.equal(fitEffort('cc', 'xhigh'), 'max');
++ assert.equal(fitEffort('cc', 'ultra'), 'max');
++ assert.equal(fitEffort('cc', 'off'), 'off');
++ assert.equal(fitEffort('cx', 'off'), 'low');
++ assert.equal(fitEffort('dsh', 'medium'), 'high');
++ assert.equal(fitEffort('cc', 'high'), 'high', 'an accepted level is left alone');
++ assert.equal(fitEffort('cc', null), null);
++ });
++
++ await check('Codes\' harness names are the same harnesses', () => {
++ assert.equal(harnessOf('command-code'), 'cc');
++ assert.equal(harnessOf('codex'), 'cx');
++ assert.equal(harnessOf('opencode'), 'oc');
++ assert.equal(harnessOf('claude-code'), 'claude');
++ assert.equal(harnessOf('deepseek'), 'dsh');
++ assert.equal(fitEffort('command-code', 'medium'), 'high');
++ assert.equal(fitEffort('claude-code', 'high'), 'high');
++ });
++
++ await check('a retry goes one level up', () => {
++ assert.equal(stepUp('medium', EFFORTS.cc), 'high');
++ assert.equal(stepUp('max', EFFORTS.cc), 'max');
++ assert.equal(stepUp('medium', EFFORTS.cx), 'high');
++ });
++
++ await check('a blocked model is replaced by one the harness still lists', () => {
++ const blockedModels = { cx: ['gpt-6.1-sol', 'gpt-6.1-terra'] };
++ const models = { cx: ['gpt-6.1-sol', 'gpt-6-sol'] };
++ assert.equal(pickModel({ runner: 'cx', model: 'gpt-6.1-sol', models, blockedModels }), 'gpt-6-sol');
++ assert.equal(pickModel({ runner: 'cx', model: 'gpt-6.1-terra', models, blockedModels }), 'gpt-6-sol');
++ assert.equal(pickModel({ runner: 'cx', model: 'gpt-6-sol', models, blockedModels }), 'gpt-6-sol', 'an unblocked model is left alone');
++ assert.equal(pickModel({ runner: 'cx', model: null, models, blockedModels }), null, 'no model asked for, none forced');
++ });
++
++ await check('a launch with nothing unblocked left is an error', () => {
++ const blockedModels = { cx: ['gpt-6.1-sol', 'gpt-6-sol'] };
++ const models = { cx: ['gpt-6.1-sol', 'gpt-6-sol'] };
++ assert.throws(() => pickModel({ runner: 'cx', model: 'gpt-6.1-sol', models, blockedModels }), /No unblocked model/);
++ });
++
++ await check('a model is blocked only when the provider says it does not have it', () => {
++ assert.deepEqual(blockModel([], 'gpt-6.1-sol', 'model_not_found'), ['gpt-6.1-sol']);
++ assert.deepEqual(blockModel([], 'gpt-6.1-sol', 'not available on your plan'), ['gpt-6.1-sol']);
++ assert.deepEqual(blockModel([], 'gpt-6.1-sol', 'the connection was reset'), [], 'a crash is not a missing model');
++ assert.deepEqual(blockModel(['gpt-6.1-sol'], 'gpt-6.1-sol', 'model_not_found'), ['gpt-6.1-sol'], 'no twice');
++ assert.deepEqual(unblockModel(['a', 'b'], 'a'), ['b']);
++ assert.equal(isBlocked({ cx: ['x'] }, 'cx', 'x'), true);
++ assert.equal(isBlocked({ cx: ['x'] }, 'cx', 'y'), false);
++ });
++
++ done();
++})();
ddiff --git a/test/engine-failure-reason.test.js b/test/engine-failure-reason.test.js
nnew file mode 100644
iindex 0000000..429c6a1
---- /dev/null
++++ b/test/engine-failure-reason.test.js
@@@ -0,0 +1,43 @@
++'use strict';
++// The failure reasons, ported from the portal's test-failure-reason.js: why a run failed, and what that means for the
++// routing. A limit, a lost sign-in, a busy model and a runner turned off are the runner switch's business; a real
++// error is not (and must not be mistaken for one).
++
++const assert = require('node:assert/strict');
++const { check, done } = require('./helpers/boot');
++const { reason, SWITCHABLE } = require('../engine/chain');
++const { track } = require('../engine/router');
++
++const generic = 'API Error: Command Code ended without a result (credits, rate limit or a crash: check its exit code)';
++const CASES = [
++ [generic, 'error'],
++ [generic + '\nAPI Error: usage limit reached', 'limit'],
++ ['mcode exec failed: The run failed: Internal server error. Please retry your request. (500). Retry after the connection recovers.', 'capacity'],
++ ['HTTP 502 Bad Gateway', 'capacity'], ['status code: 504', 'capacity'],
++ ['Request rejected (429)', 'limit'], ['HTTP 429', 'limit'],
++ ['API key has expired', 'auth'], ['model at capacity', 'capacity'],
++ ['turned off in the agent portal', 'refused'],
++ ['AssertionError: expected 500 items, got 429', 'error'],
++ ['test failed: expected HTTP 200, got 404', 'error'],
++];
++
++(async () => {
++ await check('every error is classified', () => {
++ for (const [text, expected] of CASES) assert.equal(reason(text), expected, text);
++ assert.equal(reason(''), 'unknown');
++ });
++
++ await check('a provider outage is the runner switch\'s business', () => {
++ assert.equal(SWITCHABLE.has(reason(CASES[2][0])), true, 'provider outages use existing fallback');
++ });
++
++ await check('an unknown failure is not excluded as a quota failure', () => {
++ assert.deepEqual(track([{ runner: 'cc', status: 'failed', updated: Date.now(), reason: reason(generic) }]), { cc: { done: 0, failed: 1 } }, 'unknown failures must not be excluded as quota failures');
++ });
++
++ await check('a provider outage does not penalise a runner\'s reliability', () => {
++ assert.deepEqual(track([{ runner: 'mm', status: 'failed', updated: Date.now(), reason: reason(CASES[2][0]) }]), {}, 'provider outages do not penalize coding reliability');
++ });
++
++ done();
++})();
ddiff --git a/test/engine-index.test.js b/test/engine-index.test.js
nnew file mode 100644
iindex 0000000..094a5d7
---- /dev/null
++++ b/test/engine-index.test.js
@@@ -0,0 +1,45 @@
++'use strict';
++// The engine as a package: what it exports, and what it may depend on.
++
++const assert = require('node:assert/strict');
++const fs = require('node:fs');
++const path = require('node:path');
++const { check, done } = require('./helpers/boot');
++const engine = require('../engine');
++
++const DIR = path.join(__dirname, '..', 'engine');
++
++(async () => {
++ await check('the engine exports every module and the names a caller wants', () => {
++ for (const name of ['router', 'chain', 'budget', 'transcript', 'effort', 'scope', 'runerror']) {
++ assert.ok(engine[name], `${name} is exported`);
++ }
++ for (const name of ['classify', 'pick', 'outcome', 'nextStep', 'evaluate', 'limitsFor', 'parse', 'parseText', 'fitEffort', 'pickModel']) {
++ assert.equal(typeof engine[name], 'function', `${name} is a function`);
++ }
++ });
++
++ await check('the engine needs nothing but node itself', () => {
++ const builtins = new Set(require('node:module').builtinModules);
++ for (const file of fs.readdirSync(DIR)) {
++ if (!file.endsWith('.js')) continue;
++ const source = fs.readFileSync(path.join(DIR, file), 'utf8');
++ for (const m of source.matchAll(/require\('([^']+)'\)/g)) {
++ const name = m[1];
++ if (name.startsWith('.')) continue;
++ assert.ok(builtins.has(name.replace(/^node:/, '')), `${file} requires ${name}, which is not a node builtin`);
++ }
++ }
++ });
++
++ await check('every module of the engine has a file and a test', () => {
++ const files = fs.readdirSync(DIR).filter((f) => f.endsWith('.js')).map((f) => f.replace(/\.js$/, ''));
++ const tests = fs.readdirSync(__dirname).filter((f) => f.startsWith('engine-') && f.endsWith('.test.js'))
++ .map((f) => f.replace(/^engine-/, '').replace(/\.test\.js$/, ''));
++ for (const name of files) {
++ assert.ok(tests.includes(name.replace(/-/g, '-')), `${name} has a test`);
++ }
++ });
++
++ done();
++})();
ddiff --git a/test/engine-router.test.js b/test/engine-router.test.js
nnew file mode 100644
iindex 0000000..48ada8a
---- /dev/null
++++ b/test/engine-router.test.js
@@@ -0,0 +1,113 @@
++'use strict';
++// The tier router, ported from the portal's test-router-brief.js and test-router-outcomes.js.
++//
++// Bug 3: the router judges a job by its launch prompt. A prompt that only says "read /…/x.brief.md and follow it" was
++// routed as "a short question" to a free model, so a hard job ran on a cheap one. The router now classifies on the
++// brief the prompt names (and on the job's own brief), and honours the tier line of that brief.
++
++const assert = require('node:assert/strict');
++const fs = require('node:fs');
++const os = require('node:os');
++const path = require('node:path');
++const { check, done } = require('./helpers/boot');
++const router = require('../engine/router');
++
++const folder = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-router-'));
++const JOBS = path.join(folder, 'jobs');
++fs.mkdirSync(JOBS);
++
++(async () => {
++ try {
++ const hard = 'Root cause of the race condition in the payment webhook.\n\n'.repeat(40) + '\ntier: hard\n';
++ fs.writeFileSync(path.join(JOBS, 'pin-round.brief.md'), hard);
++ const pointer = `Read ${path.join(JOBS, 'pin-round.brief.md')} first and follow it exactly.`;
++
++ await check('the prompt alone is judged as it reads', () => {
++ assert.equal(router.classify(pointer).tier, 'basic');
++ assert.equal(router.classify(pointer).why, 'a short question');
++ });
++
++ await check('the brief a prompt names decides the tier', () => {
++ const judged = router.classify(pointer, { JOBS, name: 'pin-round' });
++ assert.equal(judged.tier, 'hard', 'the brief decides the tier');
++ assert.equal(judged.brief, path.join(JOBS, 'pin-round.brief.md'));
++ });
++
++ await check('the tier line of the brief is honoured wherever it stands in it', () => {
++ fs.writeFileSync(path.join(JOBS, 'chore.brief.md'), 'Rename a variable in one file.\n\ntier: basic\n');
++ assert.equal(router.classify(`Follow ${path.join(JOBS, 'chore.brief.md')}`, { JOBS, name: 'chore' }).tier, 'basic');
++ });
++
++ await check("the job's own brief counts too, when the prompt does not name a file", () => {
++ assert.equal(router.classify('Do it.', { JOBS, name: 'pin-round' }).tier, 'hard');
++ assert.equal(router.classify('Do it.', { JOBS, name: 'unknown-job' }).tier, 'basic', 'a job without a brief is judged by its prompt');
++ });
++
++ await check('a prompt that names a file that is not there falls back to the prompt', () => {
++ assert.equal(router.classify('Read /nowhere/jobs/missing.brief.md and follow it.', { JOBS, name: 'nothing' }).tier, 'basic');
++ });
++
++ await check('the pick follows the brief: a hard job does not go to the free runner', () => {
++ const cfg = { subagents: { mm: { enabled: true, max: 2 }, cc: { enabled: false, max: 0 } } };
++ const ctx = { cfg, health: { mm: { state: 'ok' } }, counts: {}, models: {}, runs: [] };
++ assert.equal(router.pick(pointer, { ...ctx, JOBS, name: 'pin-round' }).runner, 'mm');
++ assert.equal(router.pick(pointer, { ...ctx, JOBS, name: 'pin-round' }).tier, 'hard');
++ assert.ok(router.pick(pointer, { ...ctx, JOBS, name: 'pin-round' }).why.includes('the brief it names'));
++ });
++
++ await check('the tiers are the five the product knows', () => {
++ assert.deepEqual(router.TIERS, ['basic', 'research', 'standard', 'hard', 'design']);
++ for (const tier of router.TIERS) assert.ok(router.LADDER[tier].length, `${tier} has a ladder`);
++ });
++
++ await check('a rescued failure does not turn into a success', () => {
++ const now = Date.now();
++ const run = (runner, status, extra = {}) => ({ runner, status, updated: now, ...extra });
++ const runs = [
++ run('mm', 'failed', { reason: 'error', resolved: { how: 'done', by: 'dsh:rescue' } }),
++ run('mm', 'failed', { reason: 'error', resolved: { how: 'running', by: 'mm:retry' } }),
++ run('mm', 'interrupted', { resolved: { how: 'note', note: 'Superseded' } }),
++ run('mm', 'done'),
++ run('dsh', 'done'),
++ ];
++ assert.deepEqual(router.track(runs), { mm: { done: 1, failed: 3 }, dsh: { done: 1, failed: 0 } });
++ for (const reason of ['limit', 'auth', 'capacity', 'refused']) {
++ assert.deepEqual(router.track([run('mm', 'failed', { reason })]), {}, reason);
++ }
++ assert.deepEqual(router.track([
++ run('mm', 'running'), run('mm', 'queued'), run('mm', 'stopped'),
++ run('mm', 'done', { updated: now - 8 * 86400e3 }),
++ run('mm', 'done', { session: true }), run('ship', 'done'),
++ ]), {});
++ });
++
++ await check('a runner that mostly fails is skipped, until it does better', () => {
++ const now = Date.now();
++ const run = (runner, status, extra = {}) => ({ runner, status, updated: now, ...extra });
++ const runs = [
++ run('mm', 'failed', { reason: 'error' }), run('mm', 'failed', { reason: 'error' }),
++ run('mm', 'failed', { reason: 'error' }), run('mm', 'done'),
++ run('dsh', 'done'),
++ ];
++ const context = {
++ cfg: { subagents: { mm: { enabled: true, max: 2 }, dsh: { enabled: true, max: 2 } } },
++ health: { mm: { state: 'ok' }, dsh: { state: 'ok' } }, counts: {}, models: {}, runs,
++ };
++ assert.equal(router.pick('tier: standard\nFix the parser', context).runner, 'dsh', 'rescued failures must not bypass reliability gate');
++ assert.equal(router.pick('tier: standard\nFix the parser', { ...context, runs: runs.slice(0, 3) }).runner, 'mm', 'fewer than four attempts retain the existing sampling rule');
++ assert.equal(router.pick('tier: standard\nFix the parser', { ...context, runs: [...runs, run('mm', 'done')] }).runner, 'mm', 'two successes in five attempts meet the existing threshold');
++ });
++
++ await check('a runner that is off, unhealthy or full is not chosen', () => {
++ const cfg = { subagents: { mm: { enabled: true, max: 2 }, cx: { enabled: true, max: 2 } } };
++ const off = { cfg, health: { mm: { state: 'ok' }, cx: { state: 'ok' } }, counts: {}, models: {}, runs: [] };
++ assert.equal(router.pick('tier: standard\nFix the parser', { ...off, cfg: { subagents: { mm: { enabled: false, max: 2 }, cx: { enabled: true, max: 2 } } } }).runner, 'cx');
++ assert.equal(router.pick('tier: standard\nFix the parser', { ...off, health: { mm: { state: 'login' }, cx: { state: 'ok' } } }).runner, 'cx');
++ assert.equal(router.pick('tier: standard\nFix the parser', { ...off, counts: { cx: 2 } }).runner, 'mm');
++ assert.equal(router.pick('tier: standard\nFix the parser', { ...off, cfg: { subagents: { mm: { enabled: true, max: 2 }, cx: { enabled: true, max: 2 } } }, health: { mm: { state: 'login' }, cx: { state: 'login' } } }), null);
++ });
++ } finally {
++ fs.rmSync(folder, { recursive: true, force: true });
++ }
++ done();
++})();
ddiff --git a/test/engine-runerror.test.js b/test/engine-runerror.test.js
nnew file mode 100644
iindex 0000000..c568872
---- /dev/null
++++ b/test/engine-runerror.test.js
@@@ -0,0 +1,33 @@
++'use strict';
++// What a failed run really said, ported from the portal's test-runerror.js: the wrapper's own no-result message is
++// possible causes, not an observed failure; the launch command echoed after a killed child is never the error.
++
++const assert = require('node:assert/strict');
++const { check, done } = require('./helpers/boot');
++const { failureError } = require('../engine/runerror');
++
++const generic = 'API Error: Command Code ended without a result (credits, rate limit or a crash: check its exit code)';
++const event = (result) => JSON.stringify({ type: 'result', is_error: true, result });
++
++(async () => {
++ await check('the error of a failed run is told from the feed, the stderr and the summary', () => {
++ assert.equal(failureError(event('API Error: model "x" is unavailable\nRetry later.'), 'launcher noise'), 'API Error: model "x" is unavailable\nRetry later.');
++ assert.equal(failureError(event(generic), '/r/cc-run.sh: line 61: 998 Killed cmd -p "# brief\n- report PRs --yolo -m model\n"'), 'Killed');
++ assert.equal(failureError(event(generic), '/r/cc-run.sh: line 61: 998 Killed cmd -p "# brief\nAPI Error: quota in a quoted example\n"'), 'Killed');
++ assert.equal(failureError(event(generic), 'Terminated.\n- report PRs --yolo'), 'Terminated.');
++ assert.equal(failureError(event(generic), '- report PRs --yolo'), generic);
++ assert.equal(failureError(event(generic), 'API Error: usage limit reached'), 'API Error: usage limit reached');
++ assert.equal(failureError('partial JSON\n' + event('Refused: off'), ''), 'Refused: off');
++ assert.equal(failureError(JSON.stringify({ type: 'tool_result', text: 'Error: test fixture' }), '', 'actual provider failure'), 'actual provider failure');
++ assert.equal(failureError(JSON.stringify({ type: 'result', is_error: true, errors: ['one', { message: 'two' }] }), ''), 'one; two');
++ assert.equal(failureError('', '[claude-code:unrecognized_model] {"model":"example"}'), '[claude-code:unrecognized_model] {"model":"example"}');
++ assert.equal(failureError('', 'mcode exec failed: Internal server error. (500).'), 'mcode exec failed: Internal server error. (500).');
++ });
++
++ await check('the error is short and never empty by accident', () => {
++ assert.equal(failureError(event('x'.repeat(500)), '').length, 200);
++ assert.equal(failureError('', ''), '');
++ });
++
++ done();
++})();
ddiff --git a/test/engine-scope.test.js b/test/engine-scope.test.js
nnew file mode 100644
iindex 0000000..3b670ad
---- /dev/null
++++ b/test/engine-scope.test.js
@@@ -0,0 +1,31 @@
++'use strict';
++// The job scopes, ported from the portal's jobscope.js. On a machine without systemd (or with the scopes turned off)
++// the module must do nothing at all: the command is spawned as it was, and the reaper has nothing to reap.
++
++const assert = require('node:assert/strict');
++const { check, done } = require('./helpers/boot');
++
++process.env.OV_NO_JOB_SCOPES = '1';
++const scope = require('../engine/scope');
++
++(async () => {
++ await check('without systemd the command is spawned as it was', () => {
++ assert.equal(scope.available(), false);
++ const wrapped = scope.wrap('node', ['server.js'], 'cc-my-job');
++ assert.equal(wrapped.cmd, 'node');
++ assert.deepEqual(wrapped.args, ['server.js']);
++ assert.equal(wrapped.unit, null);
++ });
++
++ await check('without systemd the reaper has nothing to reap', async () => {
++ assert.deepEqual(await scope.reap(), []);
++ assert.deepEqual(await scope.reap({ log: () => { throw new Error('the reaper must stay quiet'); } }), []);
++ assert.equal(await scope.stopFor(1234), null);
++ });
++
++ await check('the slice and the per-job caps are the portal\'s', () => {
++ assert.equal(scope.SLICE, 'ovjobs.slice');
++ });
++
++ done();
++})();
ddiff --git a/test/engine-transcript.test.js b/test/engine-transcript.test.js
nnew file mode 100644
iindex 0000000..5d4a2d6
---- /dev/null
++++ b/test/engine-transcript.test.js
@@@ -0,0 +1,146 @@
++'use strict';
++// The transcript normaliser, ported from the portal's test-transcript.js (the cache: retention, eviction, replay,
++// append, truncation and deletion) and extended with the event shape every harness ends up in.
++
++const assert = require('node:assert/strict');
++const fs = require('node:fs');
++const os = require('node:os');
++const path = require('node:path');
++const vm = require('node:vm');
++const { check, done } = require('./helpers/boot');
++const transcript = require('../engine/transcript');
++
++const folder = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-transcript-'));
++const source = fs.readFileSync(path.join(__dirname, '..', 'engine', 'transcript.js'), 'utf8');
++
++/** The module again, with a smaller cache, so the eviction can be seen. */
++function parser(budget) {
++ const context = { require, module: { exports: {} }, Buffer };
++ vm.runInNewContext(source.replace('32 * 1024 * 1024', String(budget)), context);
++ return context.module.exports;
++}
++
++function log(name, text) {
++ const file = path.join(folder, name);
++ fs.writeFileSync(file, JSON.stringify({ type: 'result', result: text }) + '\n');
++ return file;
++}
++
++(async () => {
++ try {
++ await check('the cache keeps, evicts, replays, appends, truncates and forgets', () => {
++ const small = parser(4096);
++ const first = log('first', 'first answer');
++ const firstState = small.parse(first);
++ assert.equal(small.parse(first), firstState);
++ const second = log('second', 'second answer');
++ const secondState = small.parse(second);
++ assert.equal(small.parse(first), firstState);
++ small.parse(log('third', 'third answer'));
++ assert.equal(small.parse(first), firstState);
++ const reloaded = small.parse(second);
++ assert.notEqual(reloaded, secondState);
++ assert.equal(reloaded.events[0].text, 'second answer');
++ const oversized = log('large', 'large answer '.repeat(1000));
++ const largeState = small.parse(oversized);
++ assert.notEqual(small.parse(oversized), largeState);
++ assert.equal(small.parse(oversized).events[0].text, largeState.events[0].text);
++ fs.appendFileSync(first, JSON.stringify({ type: 'result', result: 'appended' }) + '\n');
++ assert.equal(small.parse(first).events.at(-1).text, 'appended');
++ fs.writeFileSync(first, JSON.stringify({ type: 'result', result: 'new' }) + '\n');
++ assert.equal(small.parse(first).events.length, 1);
++ assert.equal(small.parse(first).events[0].text, 'new');
++ fs.unlinkSync(first);
++ assert.equal(small.parse(first).events.length, 0);
++ const many = parser(1024 * 1024);
++ const oldest = log('oldest', 'oldest');
++ const original = many.parse(oldest);
++ for (let index = 0; index < 60; index++) many.parse(log(`entry-${index}`, String(index)));
++ assert.notEqual(many.parse(oldest), original);
++ });
++
++ await check('Claude Code stream-json becomes the one shape', () => {
++ const state = transcript.parseText([
++ JSON.stringify({ type: 'system', subtype: 'init', session_id: 'sess-1', model: 'claude-sonnet-5-5', cwd: '/repo' }),
++ JSON.stringify({ type: 'assistant', message: { model: 'claude-sonnet-5-5', content: [{ type: 'text', text: 'On it.' }] } }),
++ JSON.stringify({ type: 'assistant', message: { content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'npm test' } }] } }),
++ JSON.stringify({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 't1', content: 'ok' }] } }),
++ JSON.stringify({ type: 'result', result: 'Done.', session_id: 'sess-1', total_cost_usd: 0.12 }),
++ ].join('\n'));
++ assert.equal(state.session, 'sess-1');
++ assert.equal(state.model, 'claude-sonnet-5-5');
++ assert.equal(state.cost, 0.12);
++ const kinds = state.events.map((e) => e.t);
++ assert.deepEqual(kinds, ['meta', 'text', 'tool', 'final']);
++ const tool = state.events.find((e) => e.t === 'tool');
++ assert.equal(tool.name, 'Bash');
++ assert.equal(tool.kind, 'shell');
++ assert.match(tool.input.text, /npm test/, 'the input is kept as text');
++ assert.equal(tool.pending, false, 'the result arrived');
++ assert.equal(tool.output.text, 'ok');
++ assert.equal(state.events.at(-1).text, 'Done.');
++ });
++
++ await check('a Codex and Command Code feed (the same shape) becomes the one shape', () => {
++ const state = transcript.parseText([
++ JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: 'Working.' }] } }),
++ JSON.stringify({ type: 'assistant', message: { content: [{ type: 'tool_use', id: 'c1', name: 'shell', input: { command: '/bin/bash -lc "ls -la"' } }] } }),
++ ].join('\n'));
++ const tool = state.events.find((e) => e.t === 'tool');
++ assert.equal(tool.name, 'shell');
++ assert.equal(tool.summary, 'ls -la', 'the wrapped command is unwrapped');
++ });
++
++ await check('DeepSeek\'s dsh --json becomes the one shape', () => {
++ const state = transcript.parseText([
++ JSON.stringify({ type: 'session', sessionId: 'dsh-1', cwd: '/repo' }),
++ JSON.stringify({ type: 'thinking', text: 'hmm' }),
++ JSON.stringify({ type: 'tool_call', callId: 'd1', tool: 'read_file', input: { path: '/repo/a.js' } }),
++ JSON.stringify({ type: 'tool_result', callId: 'd1', result: 'contents', status: 'completed' }),
++ JSON.stringify({ type: 'text', text: 'Read it.' }),
++ ].join('\n'));
++ assert.equal(state.session, 'dsh-1');
++ assert.deepEqual(state.events.map((e) => e.t), ['meta', 'thinking', 'tool', 'text']);
++ const tool = state.events.find((e) => e.t === 'tool');
++ assert.equal(tool.name, 'read_file');
++ assert.equal(tool.kind, 'read');
++ assert.equal(tool.output.text, 'contents');
++ });
++
++ await check('MiniMax Code\'s stream-json becomes the one shape', () => {
++ const state = transcript.parseText([
++ JSON.stringify({ schemaVersion: 1, type: 'item.started', sessionId: 'mm-1', item: { id: 'm1', type: 'agent_message' } }),
++ JSON.stringify({ schemaVersion: 1, type: 'item.completed', item: { id: 'm1', type: 'agent_message', content: 'Hello.' } }),
++ JSON.stringify({ schemaVersion: 1, type: 'item.started', item: { id: 't1', type: 'tool_call', toolCall: { id: 'tc1', name: 'Bash', input: { command: 'npm test' } } } }),
++ JSON.stringify({ schemaVersion: 1, type: 'item.completed', item: { id: 't1', type: 'tool_call', toolCall: { id: 'tc1', name: 'Bash', output: 'ok' } } }),
++ JSON.stringify({ schemaVersion: 1, type: 'exec.completed', result: { answer: 'All good.', status: 'succeeded' } }),
++ ].join('\n'));
++ assert.equal(state.session, 'mm-1');
++ assert.deepEqual(state.events.map((e) => e.t), ['text', 'tool', 'final']);
++ const tool = state.events.find((e) => e.t === 'tool');
++ assert.equal(tool.name, 'Bash');
++ assert.equal(tool.output.text, 'ok');
++ assert.equal(state.events.at(-1).text, 'All good.');
++ });
++
++ await check('every event says when it happened', () => {
++ const state = transcript.parseText(JSON.stringify({ type: 'result', result: 'Done.', timestampMs: 1712345678900 }));
++ assert.equal(state.events[0].at, '2024-04-05T19:34:38.900Z');
++ assert.equal(transcript.atOf(null), null);
++ assert.equal(transcript.atOf(1712345678900), '2024-04-05T19:34:38.900Z');
++ });
++
++ await check('the schema of the shape is published and describes an event', () => {
++ const schema = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'engine', 'transcript.schema.json'), 'utf8'));
++ assert.equal(schema.$id, 'https://openvibe.codes/schemas/engine/transcript@1');
++ const event = schema.$defs.event;
++ for (const field of ['t', 'text', 'name', 'input', 'output', 'at']) {
++ assert.ok(event.properties[field], `the schema describes ${field}`);
++ }
++ assert.deepEqual(event.properties.t.enum, ['user', 'text', 'thinking', 'tool', 'error', 'final', 'meta']);
++ });
++ } finally {
++ fs.rmSync(folder, { recursive: true, force: true });
++ }
++ done();
++})();