trantor 0.18.60 → 0.18.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.60",
3
+ "version": "0.18.61",
4
4
  "description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
5
5
  "mcpServers": {
6
6
  "relay": {
package/bin/advise.mjs CHANGED
@@ -15,7 +15,9 @@ import { join } from "node:path";
15
15
  import { homedir } from "node:os";
16
16
  import { execSync } from "node:child_process";
17
17
  import { pathToFileURL } from "node:url";
18
- import { busDir, readConfig } from "../lib/project.mjs";
18
+ import { busDir, readConfig, resolveProject } from "../lib/project.mjs";
19
+ import { loadCatalog, lookup as catalogLookup, effortParams, UNCATALOGUED_STATUS } from "../lib/model-catalog.mjs";
20
+ import { benchedAt, loadSeatRecord } from "../lib/seat-record.mjs";
19
21
 
20
22
  const H = homedir();
21
23
  const read = (p, fb) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return fb; } };
@@ -119,7 +121,7 @@ const FORECAST = { easy: 0.3e6, medium: 1.5e6, hard: 6e6 }; // tokens
119
121
  const CREW_PREF = { hard: ["codex", "glm", "kimi", "deepseek", "openrouter"], medium: ["kimi", "glm", "codex", "deepseek", "openrouter"], easy: ["deepseek", "kimi", "glm", "codex", "openrouter"] };
120
122
 
121
123
  export function advise(input, world = loadWorld()) {
122
- const { profile, registry, caps, agents, scrooge, roster = BUILTIN_ROSTER } = world;
124
+ const { profile, registry, caps, agents, scrooge, roster = BUILTIN_ROSTER, record } = world;
123
125
  // brought (discovered) opencode providers extend the preference list — appended LAST in every
124
126
  // tier (unknown strength a priori, like openrouter), so they fill once the curated seats are
125
127
  // taken, and are the only option for a user who brought nothing but a custom provider.
@@ -148,17 +150,39 @@ export function advise(input, world = loadWorld()) {
148
150
 
149
151
  // ---- routing per package ----
150
152
  const used = {};
153
+ // #7762: seats benched by the record ride along so the recommendation says WHO was benched,
154
+ // at which difficulty, and what their producing nothing already cost (tokens the crew burned).
155
+ const seatFeedback = [];
151
156
  const routing = pkgs.map(p => {
152
157
  if ((mode === "hybrid" || mode === "scrooge") && p.difficulty === "easy" && scrooge) {
153
158
  const m = scroogeModelFor(registry, caps, p.kind, p.difficulty);
154
159
  const tok = FORECAST.easy;
155
160
  const cost = m ? +(tok * 0.6 * m.cost_in / 1e6 + tok * 0.4 * m.cost_out / 1e6).toFixed(3) : null;
156
- return { ...p, executor: "scrooge", model: m?.model, pool: "api", est_cost_usd: cost,
161
+ // #7777: scores pick WHICH model; the catalog says HOW to call it — attach the per-difficulty
162
+ // request parameters so the executor does not have to look them up again.
163
+ const catEntry = catalogLookup(m?.model);
164
+ const catParams = catEntry.found ? effortParams(m.model, p.difficulty, "openai-chat") : null;
165
+ const effort = catEntry.found
166
+ ? { found: true, difficulty: p.difficulty, api: "openai-chat", params: catParams || {} }
167
+ : { found: false, difficulty: p.difficulty, params: null, status: UNCATALOGUED_STATUS };
168
+ return { ...p, executor: "scrooge", model: m?.model, pool: "api", est_cost_usd: cost, effort,
157
169
  reason: `easy + stateless → cheapest capable model (${m?.model}); not worth a crew seat` };
158
170
  }
159
171
  if (p.owner === "self") return { ...p, executor: "orchestrator", pool: tierOf(profile, "claude"), reason: "architect-owned (foundation/integration doctrine) — the orchestrator keeps the shared contract in its own hands" };
160
172
  if (mode === "solo") return { ...p, executor: "orchestrator", pool: tierOf(profile, "claude"), reason: "small enough to do inline" };
161
- const pref = [...CREW_PREF[p.difficulty], ...broughtPref].filter(a => agents.includes(a));
173
+ const prefAll = [...CREW_PREF[p.difficulty], ...broughtPref].filter(a => agents.includes(a));
174
+ // #7762 feedback loop: a seat whose last 3 cards AT THIS DIFFICULTY in THIS project were
175
+ // empty/bounced is benched at that difficulty — the redone work counts against it, so it
176
+ // stops being "the cheap option". A bench never empties the pool (better a struck seat
177
+ // than no seat), and the record is per project — never global.
178
+ const struck = prefAll.map(a => [a, benchedAt(record, a, p.difficulty)]).filter(([, b]) => b);
179
+ const eligible = prefAll.filter(a => !benchedAt(record, a, p.difficulty));
180
+ const pref = eligible.length ? eligible : prefAll;
181
+ for (const [s, b] of struck) {
182
+ const wasted = b.wastedTokens ? ` ≈${(b.wastedTokens / 1e6).toFixed(1)}M tok burned` : "";
183
+ seatFeedback.push({ seat: s, difficulty: p.difficulty, streak: b.streak, cardIds: b.cardIds, wastedTokens: b.wastedTokens,
184
+ note: `${s} benched at ${p.difficulty}: last ${b.streak.length} cards ${b.streak.join("/")} (#${b.cardIds.join(" #")})${wasted}` });
185
+ }
162
186
  const agent = pref.sort((a, b) => (used[a] || 0) - (used[b] || 0))[0] || agents[0] || "deepseek";
163
187
  used[agent] = (used[agent] || 0) + 1;
164
188
  const pool = tierOf(profile, roster[agent]?.provider || agent);
@@ -176,6 +200,7 @@ export function advise(input, world = loadWorld()) {
176
200
  // has scored it (AA scores + price proxy + per-difficulty cost weighting → hard escalates to a
177
201
  // strong model, easy stays cheap). If it hasn't been run, routing falls back to cost-only.
178
202
  if (agent === "openrouter" && p.difficulty === "hard") why_r += ` — OpenRouter ranks capability×cost; run \`scrooge-capabilities\` to keep the catalog scored (or pin openrouter:openrouter/<vendor>/<model>)`;
203
+ if (struck.length && !struck.some(([s]) => s === agent)) why_r += ` — ${struck.map(([s]) => s).join("/")} benched at ${p.difficulty} here (last 3 cards empty/bounced; seat_feedback)`;
179
204
  return { ...p, executor: agent, pool, est_cost_usd: est, reason: why_r };
180
205
  });
181
206
  // crew-size rationale: seats are EMERGENT from the work, and we say so
@@ -188,11 +213,14 @@ export function advise(input, world = loadWorld()) {
188
213
 
189
214
  const apiCost = +(routing.reduce((s, r) => s + (r.est_cost_usd || 0), 0)).toFixed(2);
190
215
  const pools = [...new Set(routing.map(r => `${r.executor}:${r.pool}`))];
216
+ // One bench line per seat+difficulty, however many packages tripped it.
217
+ const feedback = [...new Map(seatFeedback.map(f => [`${f.seat}@${f.difficulty}`, f])).values()];
191
218
  const summary =
192
219
  `Recommendation: ${mode.toUpperCase()}. ${why.join("; ")}. ` +
193
220
  (mode === "crew" || mode === "hybrid"
194
221
  ? `Routing: ${routing.map(r => `${r.title}→${r.executor}${r.model ? `(${r.model})` : ""}`).join(", ")}. ` +
195
- `Estimated real-money cost ≈ $${apiCost} (everything on a subscription pool is $0 marginal — quota pooling across ${pools.length} pools).`
222
+ `Estimated real-money cost ≈ $${apiCost} (everything on a subscription pool is $0 marginal — quota pooling across ${pools.length} pools).` +
223
+ (feedback.length ? ` Seat record (this project): ${feedback.map(f => f.note).join("; ")}.` : "")
196
224
  : "");
197
225
  const table = ["| package | diff | executor (model) | pool | est $ | reason |", "|---|---|---|---|---|---|",
198
226
  ...routing.map(r => `| ${r.title} | ${r.difficulty} | ${r.executor}${r.model ? ` (${r.model})` : ""} | ${r.pool} | ${r.est_cost_usd ?? "—"} | ${r.reason} |`)].join("\n");
@@ -221,7 +249,12 @@ export function advise(input, world = loadWorld()) {
221
249
  ? routing.map((x, j) => j + 1).filter(j => j !== i + 1)
222
250
  : (r.executor !== "orchestrator" ? foundationIdx.filter(f => f !== i + 1) : []) };
223
251
  });
224
- return { mode, why, crew, routing, routing_table_md: table, card_args: cards, est_api_cost_usd: apiCost, quota_pools: pools, summary, orchestrator_tier: orchTier, agents_available: agents };
252
+ // #7777: crew-bound packages get their catalog effort attached at SPAWN, when the live model is
253
+ // known (bin/crew/models.mjs resolveSpec → CREW_EFFORT); the advisor only records that it is deferred.
254
+ const catalogMeta = (() => { const c = loadCatalog(); return { version: c.version, models: Object.keys(c.models).length, source: "configs/model-catalog.json" }; })();
255
+ const recommendation = { mode, why, crew, routing, routing_table_md: table, card_args: cards, est_api_cost_usd: apiCost, quota_pools: pools, summary, orchestrator_tier: orchTier, agents_available: agents, catalog: catalogMeta };
256
+ if (feedback.length) recommendation.seat_feedback = feedback;
257
+ return recommendation;
225
258
  }
226
259
 
227
260
  // ---- CLI ----
@@ -238,6 +271,10 @@ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
238
271
  const stdin = readFileSync(0, "utf8").trim();
239
272
  input = stdin ? JSON.parse(stdin) : { packages: [] };
240
273
  }
241
- const out = advise(input);
274
+ const world = loadWorld();
275
+ // #7762: fold this project's seat record into the routing (fail-open — no hub, no record,
276
+ // and the advice is exactly what it was before the feedback loop existed).
277
+ try { world.record = await loadSeatRecord({ project: resolveProject(process.cwd()) }); } catch {}
278
+ const out = advise(input, world);
242
279
  console.log(JSON.stringify(out, null, 2));
243
280
  }
package/bin/cli.mjs CHANGED
@@ -99,6 +99,7 @@ switch (cmd) {
99
99
  case "state": run("bin/state.mjs"); break;
100
100
  case "seats": case "seat": run("bin/seats.mjs"); break;
101
101
  case "seat-why": case "why": run("bin/seat-why.mjs"); break;
102
+ case "seat-record": run("bin/seat-record.mjs"); break;
102
103
  case "orchestrate": run("bin/orchestrate.mjs"); break;
103
104
  case "app": run("bin/app.mjs"); break;
104
105
  case "patrol": run("bin/patrol.mjs"); break;
@@ -231,6 +232,7 @@ switch (cmd) {
231
232
  seats: which project lives in which directory — seats · seats add · seats up · seats login install
232
233
  trantor state a seat's working memory: show <seat> <card> [--json] · validate · reset --force · gc [--apply]
233
234
  trantor seat-why WHY a seat is down (err file, logs, pids): seat-why <agent> [--json] — quota, auth, crash, or just no pane
235
+ trantor seat-record the per-project seat record the advisor benches from (✓/∅/↩ per difficulty) — [--project p] [--reset <seat>] [--json]
234
236
  trantor watch live bus feed in the terminal
235
237
  trantor inbox THIS session's unread bus messages, signed (works under enforce) — [--all] [--consume] [--json]
236
238
  trantor policy the autonomy ladder: show | set <project> <1-4> | link <a> <b> --reason "<why>"
package/bin/connect.mjs CHANGED
@@ -110,15 +110,28 @@ if (has("gemini")) {
110
110
  }), p);
111
111
  }
112
112
 
113
- // ---- Kimi CLI ---- (same refresh: the stale entry that caused #7893 was {RELAY_AGENT: kimi} only)
114
- if (has("kimi")) {
115
- const p = join(homedir(), ".kimi", "mcp.json");
116
- report("kimi", patchJson(p, d => {
117
- d.mcpServers ||= {};
118
- d.mcpServers.relay ||= { command: "node", args: [MCP], env: {} };
119
- d.mcpServers.relay.env = { ...d.mcpServers.relay.env, ...relayEnv("kimi") };
120
- if (HAS_GRAFT) d.mcpServers.graft ||= { command: GRAFT, args: ["mcp"] };
121
- }), p);
113
+ // ---- Kimi CLI + kimi-code ---- (same refresh: the stale entry that caused #7893 was {RELAY_AGENT: kimi} only)
114
+ // kimi-code is a separate install reading ~/.kimi-code/mcp.json — wiring only the old file left the
115
+ // running kimi seat on a hard-coded stale hub (#7938). Same shape and stamp; the dir is detected by
116
+ // config.toml presence — existsSync only, the config itself is never read.
117
+ const kimiRelay = (cli, p) => report(cli, patchJson(p, d => {
118
+ d.mcpServers ||= {};
119
+ d.mcpServers.relay ||= { command: "node", args: [MCP], env: {} };
120
+ d.mcpServers.relay.env = { ...d.mcpServers.relay.env, ...relayEnv("kimi") };
121
+ if (HAS_GRAFT) d.mcpServers.graft ||= { command: GRAFT, args: ["mcp"] };
122
+ }), p);
123
+ const kimiPaths = [join(homedir(), ".kimi", "mcp.json"), join(homedir(), ".kimi-code", "mcp.json")];
124
+ const kimiWritten = new Set();
125
+ if (has("kimi")) { kimiRelay("kimi", kimiPaths[0]); kimiWritten.add(kimiPaths[0]); }
126
+ if (existsSync(join(homedir(), ".kimi-code", "config.toml"))) { kimiRelay("kimi-code", kimiPaths[1]); kimiWritten.add(kimiPaths[1]); }
127
+ // A kimi-family config this run did NOT write still names a hub of its own; if it disagrees with
128
+ // the pin, say so — one CLI of the pair would keep registering on a different bus (#7938).
129
+ for (const p of kimiPaths) {
130
+ if (kimiWritten.has(p) || !existsSync(p)) continue;
131
+ try {
132
+ const u = JSON.parse(readFileSync(p, "utf8"))?.mcpServers?.relay?.env?.RELAY_URL;
133
+ if (u && u !== URL_) report("kimi", `WARN: ${p} still points at ${u} — re-run connect or refresh it by hand`);
134
+ } catch {}
122
135
  }
123
136
 
124
137
  // ---- OpenCode ----
package/bin/crew/cmux.mjs CHANGED
@@ -109,7 +109,7 @@ export function spawnCmux(ctx, specs, resolve, prune) {
109
109
  for (let index = 0; index < specs.length; index += 1) {
110
110
  const seat = resolve(specs[index]);
111
111
  if (!seat) continue;
112
- const launcher = seatLauncher(ctx, seat.agent, runnerCommand(ctx, seat.agent, seat.model));
112
+ const launcher = seatLauncher(ctx, seat.agent, runnerCommand(ctx, seat.agent, seat.model, seat.effort));
113
113
  const old = reuse ? readRows(ctx).filter(row => row.project === ctx.project && row.kind === "cmux" && row.agent === seat.agent).at(-1)?.handle || "" : "";
114
114
  let pane = "";
115
115
  if (reuse) {
@@ -149,7 +149,7 @@ function spawnAppleScript(ctx, specs, resolve) {
149
149
  for (let index = 0; index < specs.length; index += 1) {
150
150
  const seat = resolve(specs[index]);
151
151
  if (!seat) continue;
152
- const launcher = seatLauncher(ctx, seat.agent, runnerCommand(ctx, seat.agent, seat.model));
152
+ const launcher = seatLauncher(ctx, seat.agent, runnerCommand(ctx, seat.agent, seat.model, seat.effort));
153
153
  const old = reuse ? readRows(ctx).filter(row => row.project === ctx.project && row.kind === "cmux" && row.agent === seat.agent).at(-1)?.handle || "" : "";
154
154
  let pane;
155
155
  if (!tab && index === 0) {
package/bin/crew/core.mjs CHANGED
@@ -141,12 +141,15 @@ export function gridColumns(size) {
141
141
  // file that was never written and a runner still on the transcript path.
142
142
  const FORWARDED_ENV = ["TRANTOR_STATE", "TRANTOR_STATE_ASSEMBLE", "TRANTOR_STATE_HANDOFF", "TRANTOR_STATE_GATE"];
143
143
 
144
- export function runnerCommand(ctx, agent, model = "") {
144
+ export function runnerCommand(ctx, agent, model = "", effort = null) {
145
145
  const forwarded = FORWARDED_ENV
146
146
  .filter((name) => process.env[name])
147
147
  .map((name) => `${name}=${shellQuote(process.env[name])} `)
148
148
  .join("");
149
- return `cd ${shellQuote(ctx.dir)} && ${forwarded}CREW_MODEL=${shellQuote(model)} RELAY_PROJECT=${shellQuote(ctx.project)} RELAY_URL=${shellQuote(ctx.hub)} node ${shellQuote(join(ROOT, "bin/crew-runner.mjs"))} ${shellQuote(agent)} ${shellQuote(ctx.dir)}`;
149
+ // #7777: the launcher resolved the catalog's per-difficulty effort for this seat; the runner
150
+ // reads CREW_EFFORT, applies what the CLI accepts and logs the one-line summary.
151
+ const effortEnv = effort ? ` CREW_EFFORT=${shellQuote(JSON.stringify(effort))}` : "";
152
+ return `cd ${shellQuote(ctx.dir)} && ${forwarded}CREW_MODEL=${shellQuote(model)}${effortEnv} RELAY_PROJECT=${shellQuote(ctx.project)} RELAY_URL=${shellQuote(ctx.hub)} node ${shellQuote(join(ROOT, "bin/crew-runner.mjs"))} ${shellQuote(agent)} ${shellQuote(ctx.dir)}`;
150
153
  }
151
154
 
152
155
  export function listPids(pattern) {
@@ -165,7 +165,7 @@ function replacementPane(ctx, workspace, spec, hostPane, resolve) {
165
165
  pane = `%DRYT${spec.index}`;
166
166
  } else {
167
167
  pane = splitPane(ctx, hostPane, "right", ctx.dir);
168
- runSeat(ctx, pane, seat.agent, runnerCommand(ctx, seat.agent, seat.model));
168
+ runSeat(ctx, pane, seat.agent, runnerCommand(ctx, seat.agent, seat.model, seat.effort));
169
169
  }
170
170
  if (old) {
171
171
  closePane(ctx, old);
@@ -202,7 +202,7 @@ export function spawnHerdr(ctx, specs, resolve, prune) {
202
202
  function freshPane(ctx, workspace, spec, panes, columns, resolve) {
203
203
  const seat = resolve(spec.value);
204
204
  if (!seat) return null;
205
- const command = runnerCommand(ctx, seat.agent, seat.model);
205
+ const command = runnerCommand(ctx, seat.agent, seat.model, seat.effort);
206
206
  let pane = "";
207
207
  if (spec.index === 0) {
208
208
  if (ctx.dry) {
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { call } from "./core.mjs";
4
4
  import { reapSeat } from "./state.mjs";
5
+ import { resolveEffort } from "../../lib/model-catalog.mjs";
5
6
 
6
7
  function routeModel(ctx, provider, candidates, task, difficulty) {
7
8
  const bundled = join(ctx.root, "engine/bin/scrooge");
@@ -36,16 +37,22 @@ export function resolveSpec(ctx, spec, task, difficulty, skipped) {
36
37
  if (agent === "glm") field = "zai-coding-plan";
37
38
  else if (!["codex", "kimi", "claude", "gemini", "dsh", "opencode"].includes(agent)) field = agent;
38
39
  }
39
- if (!field) return { agent, model: "" };
40
- if (field.includes("/")) return { agent, model: field };
40
+ if (!field) {
41
+ // #7777: CLI-default seat (kimi/codex/claude/dsh) the catalog finds the entry through the
42
+ // agent alias so the runner still gets a CREW_EFFORT record (or the uncatalogued line).
43
+ return { agent, model: "", effort: resolveEffort(agent, "", difficulty) };
44
+ }
45
+ if (field.includes("/")) return { agent, model: field, effort: resolveEffort(agent, field, difficulty) };
41
46
  if (["claude", "codex", "kimi", "gemini"].includes(agent)) {
42
47
  console.log(` → ${agent}: model ${field} (native pin)`);
43
- return { agent, model: field };
48
+ return { agent, model: field, effort: resolveEffort(agent, field, difficulty) };
44
49
  }
45
50
  try {
46
51
  const model = resolveModel(ctx, agent, field, task, difficulty);
52
+ // #7777: the live model is known — attach the catalog's per-difficulty effort parameters here
53
+ // so the runner applies them where the CLI accepts them (CREW_EFFORT, below).
47
54
  console.log(` → ${agent}: live model ${model} (${field} · ${task}/${difficulty})`);
48
- return { agent, model };
55
+ return { agent, model, effort: resolveEffort(agent, model, difficulty) };
49
56
  } catch (error) {
50
57
  console.error(error.message);
51
58
  console.error(`[crew] ✗ skipping seat '${agent}' — model resolution failed for ${field} (${task}/${difficulty}); remaining seats still launch`);
package/bin/crew/tmux.mjs CHANGED
@@ -7,7 +7,7 @@ export function spawnTmux(ctx, specs, resolve) {
7
7
  for (const spec of specs) {
8
8
  const seat = resolve(spec);
9
9
  if (!seat) continue;
10
- const command = runnerCommand(ctx, seat.agent, seat.model);
10
+ const command = runnerCommand(ctx, seat.agent, seat.model, seat.effort);
11
11
  let pane = ctx.dry ? "%DRY" : "";
12
12
  if (first) {
13
13
  run(ctx, "tmux", ["new-session", "-d", "-s", session, "-n", "crew", "-x", "260", "-y", "60"], { rendered: `tmux new-session -d -s '${session}' -n crew -x 260 -y 60` });
@@ -52,7 +52,7 @@ export function spawnTerminal(ctx, specs, resolve) {
52
52
  recordState(ctx, ctx.project, "win", seat.agent, `%DRYWIN${index}`);
53
53
  continue;
54
54
  }
55
- const command = `clear && ${runnerCommand(ctx, seat.agent, seat.model)}`;
55
+ const command = `clear && ${runnerCommand(ctx, seat.agent, seat.model, seat.effort)}`;
56
56
  const title = `${ctx.project} · ${seat.agent.toUpperCase()}`;
57
57
  const result = appleScript(`tell application "Terminal"\nset w to do script "${appleScriptString(command)}"\nset custom title of w to "${appleScriptString(title)}"\nset theWin to first window whose tabs contains w\nset bounds of theWin to {${x}, ${y}, ${x + width}, ${y + height}}\nreturn id of theWin\nend tell\n`);
58
58
  if (result.stdout) {
@@ -26,6 +26,7 @@ import {
26
26
  observedDutyNudgeIds, requeueMissingWakeMessages, shedExpiredHubAlerts,
27
27
  } from "../lib/duty-nudges.mjs";
28
28
  import { dutyRecipientResolver } from "../lib/duty-recipient.mjs";
29
+ import { cliEffortFlag } from "../lib/model-catalog.mjs";
29
30
  import {
30
31
  BREAKER_WINDOW, STATE_ENV, TURN_RESULT_SCHEMA,
31
32
  breakerVerdict, describeTurn, hasJsonSchemaFlag, parseEnvelope, renderCardTail, runStep,
@@ -236,6 +237,13 @@ const cli = CLI[AGENT] || (NATIVE.has(AGENT) ? null : CLI.opencode);
236
237
  if (!cli) { console.error(`unknown agent '${AGENT}' (native: ${[...NATIVE].join(", ")}; any other name = an opencode provider seat)`); process.exit(1); }
237
238
  if (!CLI[AGENT]) log(`'${AGENT}' is not a built-in seat — running it as an opencode provider (BYOM)`);
238
239
 
240
+ // #7777: the launcher resolved the model catalog's per-difficulty effort for this seat and sent
241
+ // it as CREW_EFFORT (JSON from bin/crew/core.mjs runnerCommand). The runner applies the
242
+ // parameters the CLI can carry (codex -c model_reasoning_effort / claude --effort / opencode
243
+ // --variant) and logs ONE line saying what it set — or that the model is uncatalogued.
244
+ const EFFORT = (() => { try { return JSON.parse(process.env.CREW_EFFORT || "null"); } catch { return null; } })();
245
+ const EFFORT_FLAG = EFFORT ? cliEffortFlag(AGENT, EFFORT) : { flag: "", text: "" };
246
+
239
247
  // RUNNER_RULES / RUNNER_KICKOFF env overrides: the runner is also the substrate for non-crew
240
248
  // always-on seats (the fleet DUTY agent, bin/duty.mjs) whose doctrine is not "work your card".
241
249
  const RULES = process.env.RUNNER_RULES || `Rules: you are ${SESSION} on the trantor crew. Before starting a card, read YOUR card: relay_board with card:<id> (the card, its deps, its notes, and the last five done cards whose title shares a word); never the whole board. Work your assigned file(s), report on the bus (relay_send, <280 chars), move your Kanban card as you go with a NOTE saying what you did (doing -> testing -> done; in 'testing' run YOUR OWN test file — never the full npm test, suites collide across seats — plus \`node bin/slop-gate.mjs\` when the repo has one: it lints ONLY your changed files against the anti-slop rules, and a card must not reach done with slop-gate failing; use 'failed' + a report if anything breaks). If a contract omits a fact you cannot proceed without, ASK — never invent the value: relay_ask(<card>, <question>) blocks the card with your question, keeps the turn owed (no park, no failure), and resumes you when the assigner's answer lands; an invented value that reads as reasoned is the worst outcome this crew ships (#7756). If you need something from another session, message THAT SESSION (relay_peers to find its id, relay_send to reach it) — never ask the human to pass it along; carrying messages between agents is the job this bus exists to remove. When your work for THIS message is finished, END YOUR TURN — do NOT park, do NOT loop relay_wait; the runner waits for you and will wake you with the next message. Path discipline: build/test from your worktree root ${TURN_DIR} with absolute paths or --manifest-path/--prefix instead of cd-ing into subdirs, and put anything that must land outside the repo under ${TURN_DIR}/.agent-bus-out/ (gitignored) — never ~/.agent-bus. Realigning your seat branch after the orchestrator harvested your commits is \`trantor sync\` run from your worktree: it reads the harvest receipts and refuses when an unharvested commit would be lost, so never reset or rebase onto main by hand. A contract's \`base: <sha>\` line is the integration head: start your seat branch at that sha (\`git reset --hard <sha>\` on the fresh branch), never at origin/main, which trails the orchestrator's unpushed integration commits; the object is already here as the ref \`main\`. If \`git cat-file -e <sha>\` fails, say \`cannot resolve base <sha>\` on the bus and move the card to blocked instead of reasoning from origin/main. Every testing/done note names the sha you verified against as \`verified at <sha>\`; a note without it is flagged HOLLOW. Cross-project action is a breach: never \`trantor up\` a crew, register a seat, or send a card/contract into a project other than ${PROJ} unless the operator ran \`trantor policy link ${PROJ} <other> --reason "<why>"\` first — the hub, the CLI and this runner all refuse it mechanically, so ask the operator to link the projects instead of routing around the refusal.`;
@@ -657,7 +665,7 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
657
665
  // The flagged row (TDD §4.6). `{S}` exists only in `stateNext`, so the replaceAll below is a
658
666
  // no-op on every other path — which is what "flag off = byte-identical" has to mean.
659
667
  if (opts.state && cli.stateNext) cmd = cli.stateNext;
660
- const mfrag = MODEL && cli.mflag ? `${cli.mflag}${MODEL}` : "";
668
+ const mfrag = (MODEL && cli.mflag ? `${cli.mflag}${MODEL}` : "") + EFFORT_FLAG.flag;
661
669
  cmd = cmd.replaceAll("{M}", mfrag).replaceAll("{P}", pf).replaceAll("{SID}", sid).replaceAll("{DIR}", TURN_DIR)
662
670
  .replaceAll("{S}", STATE_SCHEMA_FILE);
663
671
  // PRECEDENCE: each file is PREPENDED, so the list is iterated in written order, highest priority
@@ -665,6 +673,8 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
665
673
  const envs = [join(homedir(), ".agent-bus", ".env"), cli.env].filter(f => f && existsSync(f));
666
674
  cmd = withEnvFiles(cmd, envs);
667
675
  log(`turn starting (${isFirst ? "fresh session" : "resume"})${MODEL ? ` · model=${MODEL}` : ""}`);
676
+ // #7777: the one effort line — which effort parameters were set, or that the model is uncatalogued.
677
+ if (EFFORT && EFFORT_FLAG.text) log(EFFORT_FLAG.text);
668
678
  cmuxStatus("building", "#4a90d9", "hammer", { priority: 50 }); herdrAgent("working");
669
679
  // inherit stdio so the window shows the agent working live; also capture for sid-parsing.
670
680
  // Tee stderr to ERRF (still shown live in the window) so a failed turn can be classified.
@@ -852,7 +862,9 @@ exit $turn_exit`;
852
862
  // #6289: every ledger row names in ONE field what happened to the turn — cut (the box ended
853
863
  // it), stalled (the watchdog window ended it, #7752), api-error (the CLI failed), completed —
854
864
  // and what it cost in tokens, even when this CLI printed no usage line (0 means "not
855
- // reported", never "free"). `cut` stays too: the drills read it.
865
+ // reported", never "free"). `cut` stays too: the drills read it. #7762: `card` binds the row
866
+ // to the card the turn was working (0 = kickoff/pulse, no card) so the seat record can
867
+ // attribute empty/stalled turns to the card that produced nothing.
856
868
  const outcome = cut ? (stallCut ? "stalled" : "cut") : (effExit !== 0 ? "api-error" : lastEmptyTurn ? "empty" : "completed");
857
869
  // #7756: a clean turn that ASKED its assigner is demoted-but-owed, not "completed". The judge
858
870
  // (deliverWake's /contracts read) renames the ledger row, so "asked" is what the log keeps.
@@ -860,7 +872,7 @@ exit $turn_exit`;
860
872
  if (outcome === "completed" && opts.judgeOutcome) {
861
873
  try { finalOutcome = (await opts.judgeOutcome()) || outcome; } catch {}
862
874
  }
863
- const telemetryRow = { ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, model: MODEL || "cli-default", duration_ms: Date.now() - t0, exit: realExit, effExit, authFailed: effExit !== realExit, emptyOutput: lastEmptyOutput, emptyTurn: lastEmptyTurn, verdict, outcome: finalOutcome, tokens };
875
+ const telemetryRow = { ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, card: sessionCard || 0, model: MODEL || "cli-default", duration_ms: Date.now() - t0, exit: realExit, effExit, authFailed: effExit !== realExit, emptyOutput: lastEmptyOutput, emptyTurn: lastEmptyTurn, verdict, outcome: finalOutcome, tokens };
864
876
  if (cut) telemetryRow.cut = true;
865
877
  if (stallCut) telemetryRow.stalled = true;
866
878
  // #7752: a cut turn's 141 is the sweep's SIGPIPE, recorded as the cut signal — never read as
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ // trantor seat-record [--project p] [--reset <seat>] [--json]
3
+ // #7762: the per-project seat record the advisor benches from — derived from board cards +
4
+ // card-move events + runner ledgers, never a store of its own. --reset is the manual
5
+ // forgiveness path: evidence at or before the stamp stops counting (new completed cards are
6
+ // the organic one — they age the bad ones out of the 3-card window).
7
+ import { resolveProject } from "../lib/project.mjs";
8
+ import { loadSeatRecord, resetSeat, benchedAt, STRIKE, RECORD_LIMIT } from "../lib/seat-record.mjs";
9
+
10
+ const args = process.argv.slice(2);
11
+ const opt = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
12
+ const project = opt("--project") || resolveProject(process.cwd());
13
+ const reset = opt("--reset");
14
+
15
+ if (reset) {
16
+ const okReset = resetSeat({ project, seat: reset });
17
+ console.log(okReset
18
+ ? `${reset} reset on ${project} — its earlier cards no longer count toward a bench; new completed cards still age in`
19
+ : `reset FAILED — could not write the resets file`);
20
+ process.exit(okReset ? 0 : 1);
21
+ }
22
+
23
+ const record = await loadSeatRecord({ project });
24
+ if (args.includes("--json")) { console.log(JSON.stringify({ project, ...record }, null, 2)); process.exit(0); }
25
+
26
+ const seats = Object.entries(record.seats);
27
+ if (!seats.length) {
28
+ console.log(`no seat record for ${project} yet — it derives from board cards + runner ledgers once seats work cards`);
29
+ process.exit(0);
30
+ }
31
+ console.log(`seat record for ${project} — last ${RECORD_LIMIT} cards/seat (✓ completed · ∅ empty · ↩ bounced); a seat is benched at a difficulty after ${STRIKE} straight ∅/↩ there:`);
32
+ const MARK = { completed: "✓", empty: "∅", bounced: "↩" };
33
+ for (const [seat, s] of seats.sort(([a], [b]) => a.localeCompare(b))) {
34
+ const byDiff = {};
35
+ for (const c of s.cards) (byDiff[c.difficulty || "?"] ||= []).push(c);
36
+ const parts = Object.entries(byDiff).sort().map(([d, cs]) => `${d}:${cs.map(c => MARK[c.outcome] || c.outcome).join("")}`);
37
+ const bench = ["easy", "medium", "hard"].filter(d => benchedAt(record, seat, d));
38
+ const wasted = s.wastedTokens ? ` · wasted ≈${(s.wastedTokens / 1e6).toFixed(1)}M tok on ∅/↩` : "";
39
+ const hint = bench.length ? ` · BENCHED at ${bench.join(",")} — the advisor routes those elsewhere (forgive: trantor seat-record --project ${project} --reset ${seat})` : "";
40
+ console.log(` ${seat.padEnd(12)} ${parts.join(" ")}${wasted}${hint}`);
41
+ }
@@ -0,0 +1,119 @@
1
+ {
2
+ "version": 1,
3
+ "source": "card #7777 — model ids come from `trantor models` (opencode adapters) and the seat CLIs' defaults; limits and effort levels come from each provider's published docs, cited per entry. modalities = accepted inputs; every entry outputs text.",
4
+ "models": {
5
+ "zai-coding-plan/glm-5.3-flash": {
6
+ "api": ["openai-chat", "openai-responses", "anthropic-messages"],
7
+ "context": 1000000,
8
+ "maxOutput": 128000,
9
+ "modalities": ["text", "image", "video", "file"],
10
+ "url": "https://docs.z.ai/guides/vlm/glm-5.3-flash",
11
+ "notes": "docs: 1M context / 128K max output; thinking.type only supports enabled; reasoning_effort low|high|max (recommended max). GLM Coding Plan serves the OpenAI Chat Completion protocol only, so effort is keyed on openai-chat. The glm seat's easy/medium router pick (`trantor models glm`).",
12
+ "effort": {
13
+ "easy": { "openai-chat": { "reasoning_effort": "low" } },
14
+ "medium": { "openai-chat": { "reasoning_effort": "high" } },
15
+ "hard": { "openai-chat": { "reasoning_effort": "max" } }
16
+ }
17
+ },
18
+ "zai-coding-plan/glm-5.3": {
19
+ "api": ["openai-chat", "openai-responses", "anthropic-messages"],
20
+ "context": 1000000,
21
+ "maxOutput": 128000,
22
+ "modalities": ["text"],
23
+ "url": "https://docs.z.ai/guides/llm/glm-5.3",
24
+ "notes": "docs: 1M context / 128K max output; text-only input; reasoning always enabled (disabling not supported); reasoning_effort low|high|max, default max. The glm seat's hard router pick. Coding plan serves openai-chat only.",
25
+ "effort": {
26
+ "easy": { "openai-chat": { "reasoning_effort": "low" } },
27
+ "medium": { "openai-chat": { "reasoning_effort": "high" } },
28
+ "hard": { "openai-chat": { "reasoning_effort": "max" } }
29
+ }
30
+ },
31
+ "deepseek/deepseek-v4-flash": {
32
+ "api": ["openai-chat", "anthropic-messages", "openai-responses"],
33
+ "context": 1000000,
34
+ "maxOutput": 384000,
35
+ "modalities": ["text", "image"],
36
+ "url": "https://api-docs.deepseek.com/quick_start/pricing",
37
+ "notes": "Legacy id served by DeepSeek-V4.1-Flash at flash price (docs: 1M context, max output 384K, vision supported). Thinking mode is enabled by default with default effort high; legacy id still accepted by the API. The deepseek seat's easy/medium router pick.",
38
+ "effort": {
39
+ "easy": { "openai-chat": { "thinking": { "type": "enabled" }, "reasoning_effort": "low" },
40
+ "anthropic-messages": { "reasoning": { "effort": "low" } } },
41
+ "medium": { "openai-chat": { "thinking": { "type": "enabled" }, "reasoning_effort": "high" },
42
+ "anthropic-messages": { "reasoning": { "effort": "high" } } },
43
+ "hard": { "openai-chat": { "thinking": { "type": "enabled" }, "reasoning_effort": "max" },
44
+ "anthropic-messages": { "reasoning": { "effort": "max" } } }
45
+ }
46
+ },
47
+ "deepseek/deepseek-v4-pro": {
48
+ "api": ["openai-chat", "anthropic-messages", "openai-responses"],
49
+ "context": 1000000,
50
+ "maxOutput": 384000,
51
+ "modalities": ["text"],
52
+ "url": "https://api-docs.deepseek.com/quick_start/pricing",
53
+ "notes": "DeepSeek-V4-Pro-0813 (docs: 1M context, max output 384K, no vision; API service continues past 2026-09-14). Thinking mode enabled by default, default effort high. The deepseek seat's hard router pick.",
54
+ "effort": {
55
+ "easy": { "openai-chat": { "thinking": { "type": "enabled" }, "reasoning_effort": "low" },
56
+ "anthropic-messages": { "reasoning": { "effort": "low" } } },
57
+ "medium": { "openai-chat": { "thinking": { "type": "enabled" }, "reasoning_effort": "high" },
58
+ "anthropic-messages": { "reasoning": { "effort": "high" } } },
59
+ "hard": { "openai-chat": { "thinking": { "type": "enabled" }, "reasoning_effort": "max" },
60
+ "anthropic-messages": { "reasoning": { "effort": "max" } } }
61
+ }
62
+ },
63
+ "qwen/deepseek-v4-pro": {
64
+ "api": ["openai-chat"],
65
+ "context": 1000000,
66
+ "maxOutput": 384000,
67
+ "modalities": ["text"],
68
+ "url": "https://api-docs.deepseek.com/quick_start/pricing",
69
+ "notes": "DeepSeek V4-Pro served through the qwen opencode provider (Alibaba Model Studio openai-compatible endpoint, per the provider adapter). Model limits and effort levels per DeepSeek's own V4-Pro docs. The qwen seat's router pick at every difficulty.",
70
+ "effort": {
71
+ "easy": { "openai-chat": { "thinking": { "type": "enabled" }, "reasoning_effort": "low" } },
72
+ "medium": { "openai-chat": { "thinking": { "type": "enabled" }, "reasoning_effort": "high" } },
73
+ "hard": { "openai-chat": { "thinking": { "type": "enabled" }, "reasoning_effort": "max" } }
74
+ }
75
+ },
76
+ "kimi-code/default": {
77
+ "aliases": ["kimi"],
78
+ "api": ["openai-chat", "anthropic-messages"],
79
+ "context": 256000,
80
+ "maxOutput": 32768,
81
+ "modalities": ["text", "image", "video"],
82
+ "url": "https://platform.kimi.ai/docs/guide/kimi-k2-7-code-quickstart",
83
+ "notes": "kimi-code default — the kimi CLI's managed coding model (Kimi K2.7 Code family). docs: 256K context; max_tokens defaults to 32768 (no higher maximum published); thinking is always on and cannot be disabled; reasoning_effort is NOT supported on K2.7 Code — so no effort parameters are declarable and every level maps to provider default. Kimi API is OpenAI- and Anthropic-compatible.",
84
+ "effort": {
85
+ "easy": { "openai-chat": {} },
86
+ "medium": { "openai-chat": {} },
87
+ "hard": { "openai-chat": {} }
88
+ }
89
+ },
90
+ "codex/gpt-5.6-sol": {
91
+ "aliases": ["codex"],
92
+ "api": ["openai-responses", "openai-chat"],
93
+ "context": 1050000,
94
+ "maxOutput": 128000,
95
+ "modalities": ["text", "image"],
96
+ "url": "https://developers.openai.com/api/docs/models/gpt-5.6-sol",
97
+ "notes": "Codex CLI default model (codex docs name gpt-5.6-sol the replacement/default with default effort medium — https://developers.openai.com/codex/models). API docs: 1,050,000 context window / 128,000 max output; reasoning.effort supports none|low|medium(default)|high|xhigh|max. Codex applies effort via its model_reasoning_effort config key (codex exec -c model_reasoning_effort=...).",
98
+ "effort": {
99
+ "easy": { "openai-responses": { "reasoning_effort": "low" } },
100
+ "medium": { "openai-responses": { "reasoning_effort": "medium" } },
101
+ "hard": { "openai-responses": { "reasoning_effort": "high" } }
102
+ }
103
+ },
104
+ "anthropic/claude-sonnet-5": {
105
+ "aliases": ["claude", "sonnet"],
106
+ "api": ["anthropic-messages"],
107
+ "context": 1000000,
108
+ "maxOutput": 128000,
109
+ "modalities": ["text", "image"],
110
+ "url": "https://platform.claude.com/docs/en/models/overview",
111
+ "notes": "Claude Sonnet 5 (claude-sonnet-5) — the speed/intelligence default of the current lineup and the Claude Code default family. docs: 1M context, 128K max output, adaptive thinking with the effort parameter (default high; levels low|medium|high|xhigh|max). The claude CLI carries it per request via --effort.",
112
+ "effort": {
113
+ "easy": { "anthropic-messages": { "effort": "low" } },
114
+ "medium": { "anthropic-messages": { "effort": "medium" } },
115
+ "hard": { "anthropic-messages": { "effort": "high" } }
116
+ }
117
+ }
118
+ }
119
+ }
@@ -45,6 +45,32 @@ function sameOpen(left, right) {
45
45
  JSON.stringify(left.questions) === JSON.stringify(right.questions);
46
46
  }
47
47
 
48
+ // The declaration the Chat chips render from (#7776): the first question, its options as offered,
49
+ // and whether several may be picked. Chips never invent an option that is not in this list.
50
+ function declaredAsk(questions) {
51
+ const first = questions[0] ?? {};
52
+ const question = String(first.question ?? "");
53
+ if (!question) return null;
54
+ const options = (Array.isArray(first.options) ? first.options : [])
55
+ .map(o => ({ label: String(o?.label ?? ""), description: String(o?.description ?? "") }))
56
+ .filter(o => o.label)
57
+ .map(o => o.description ? o : { label: o.label });
58
+ return { question, options, multi: first.multiSelect === true };
59
+ }
60
+
61
+ function writeAtomic(sessionId, path, payload) {
62
+ const dir = join(busDir(), "asks");
63
+ mkdirSync(dir, { recursive: true });
64
+ const tmp = join(dir, `.${String(sessionId)}.${process.pid}.${Date.now()}.tmp`);
65
+ try {
66
+ writeFileSync(tmp, JSON.stringify(payload), { mode: 0o600 });
67
+ renameSync(tmp, path);
68
+ } catch (error) {
69
+ try { unlinkSync(tmp); } catch {}
70
+ throw error;
71
+ }
72
+ }
73
+
48
74
  function writeOpen(input, path) {
49
75
  if (String(input.tool_name ?? "") !== "AskUserQuestion") return;
50
76
  const questions = input.tool_input?.questions;
@@ -61,22 +87,39 @@ function writeOpen(input, path) {
61
87
  project: ctx.project,
62
88
  cwd,
63
89
  tool_use_id: incomingId ?? stored?.tool_use_id ?? null,
90
+ kind: "AskUserQuestion",
91
+ ask: declaredAsk(questions),
64
92
  questions,
65
93
  event: visibleTs === null ? "PreToolUse" : "PermissionRequest",
66
94
  visible_ts: visibleTs,
67
95
  ts: stored?.ts ?? now,
68
96
  };
69
97
  if (stored && sameOpen(stored, payload)) return;
70
- const dir = join(busDir(), "asks");
71
- mkdirSync(dir, { recursive: true });
72
- const tmp = join(dir, `.${String(input.session_id)}.${process.pid}.${Date.now()}.tmp`);
73
- try {
74
- writeFileSync(tmp, JSON.stringify(payload), { mode: 0o600 });
75
- renameSync(tmp, path);
76
- } catch (error) {
77
- try { unlinkSync(tmp); } catch {}
78
- throw error;
79
- }
98
+ writeAtomic(input.session_id, path, payload);
99
+ }
100
+
101
+ // A relay_ask is the turn-ending ask (#7756): the question is out on the bus and the session
102
+ // idles at its prompt until the answer lands, so the sidecar stays open past Stop and closes on
103
+ // the next prompt. It offers no options: the card shows the question, chips show nothing.
104
+ const isRelayAsk = name => /(^|__)relay_ask$/.test(String(name ?? ""));
105
+
106
+ function writeRelayAsk(input, path) {
107
+ const question = String(input.tool_input?.question ?? "").trim();
108
+ if (!question) return;
109
+ const cwd = String(input.cwd ?? "");
110
+ const now = Date.now();
111
+ writeAtomic(input.session_id, path, {
112
+ session_id: String(input.session_id),
113
+ project: sessionContext(cwd).project,
114
+ cwd,
115
+ tool_use_id: toolUseId(input),
116
+ kind: "relay_ask",
117
+ ask: { question, options: [], multi: false },
118
+ questions: [{ question, header: "ask", multiSelect: false, options: [] }],
119
+ event: "PreToolUse",
120
+ visible_ts: now,
121
+ ts: now,
122
+ });
80
123
  }
81
124
 
82
125
  function closeTool(input, path) {
@@ -85,15 +128,24 @@ function closeTool(input, path) {
85
128
  if (storedId === null || storedId === toolUseId(input)) unlinkSync(path);
86
129
  }
87
130
 
131
+ function closeTurn(path) {
132
+ let stored = null;
133
+ try { stored = JSON.parse(readFileSync(path, "utf8")); } catch {}
134
+ if (stored?.kind === "relay_ask") return;
135
+ try { unlinkSync(path); } catch {}
136
+ }
137
+
88
138
  try {
89
139
  const raw = await readStdin();
90
140
  const input = JSON.parse(raw || "{}");
91
141
  const path = sidecarPath(input?.session_id);
92
142
  if (path) {
93
143
  const event = String(input.hook_event_name ?? "");
94
- if (event === "PreToolUse" || event === "PermissionRequest") writeOpen(input, path);
144
+ if (event === "PreToolUse" && isRelayAsk(input.tool_name)) writeRelayAsk(input, path);
145
+ else if (event === "PreToolUse" || event === "PermissionRequest") writeOpen(input, path);
95
146
  else if (event === "PostToolUse" || event === "PostToolUseFailure") closeTool(input, path);
96
- else if (event === "Stop") {
147
+ else if (event === "Stop") closeTurn(path);
148
+ else if (event === "UserPromptSubmit") {
97
149
  try { unlinkSync(path); } catch {}
98
150
  }
99
151
  }
package/hooks/hooks.json CHANGED
@@ -25,6 +25,15 @@
25
25
  "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/prompt-focus.mjs"
26
26
  }
27
27
  ]
28
+ },
29
+ {
30
+ "matcher": "",
31
+ "hooks": [
32
+ {
33
+ "type": "command",
34
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/ask-sidecar.mjs"
35
+ }
36
+ ]
28
37
  }
29
38
  ],
30
39
  "PreToolUse": [
@@ -54,6 +63,15 @@
54
63
  "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/ask-sidecar.mjs"
55
64
  }
56
65
  ]
66
+ },
67
+ {
68
+ "matcher": "mcp__.*relay_ask",
69
+ "hooks": [
70
+ {
71
+ "type": "command",
72
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/ask-sidecar.mjs"
73
+ }
74
+ ]
57
75
  }
58
76
  ],
59
77
  "PermissionRequest": [
@@ -0,0 +1,123 @@
1
+ // lib/model-catalog.mjs — the declarative model catalog (card #7777).
2
+ //
3
+ // configs/model-catalog.json records, per "<provider>/<model-id>": which API kinds it speaks,
4
+ // context window, max output, input modalities, and — the part capabilities.json does not have —
5
+ // an `effort` block mapping each crew difficulty (easy/medium/hard) to CONCRETE request
6
+ // parameters PER API KIND (reasoning_effort, thinking, effort). Scores pick WHICH model; this
7
+ // catalog says HOW to call it. A model missing from the catalog still works at its provider
8
+ // default: lookup() returns an entry whose status says "not in catalog, provider default".
9
+ //
10
+ // Model ids come from `trantor models` / the provider adapters and CLI defaults — never typed
11
+ // from memory — and every entry cites its limits with a url.
12
+ import { readFileSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
17
+ export const CATALOG_PATH = process.env.TRANTOR_MODEL_CATALOG || join(ROOT, "configs", "model-catalog.json");
18
+ export const UNCATALOGUED_STATUS = "not in catalog, provider default";
19
+
20
+ let CACHE;
21
+ export function loadCatalog(path = CATALOG_PATH) {
22
+ if (CACHE && path === CATALOG_PATH) return CACHE;
23
+ let cat;
24
+ try { cat = JSON.parse(readFileSync(path, "utf8")); } catch { cat = null; }
25
+ if (!cat || typeof cat !== "object") cat = { version: 0, models: {} };
26
+ if (!cat.models || typeof cat.models !== "object") cat.models = {};
27
+ if (path === CATALOG_PATH) CACHE = cat;
28
+ return cat;
29
+ }
30
+
31
+ const uncatalogued = (id) => ({ found: false, id, status: UNCATALOGUED_STATUS, api: [], effort: {} });
32
+
33
+ // lookup(modelId) → the catalog entry (with found: true) or the uncatalogued default whose
34
+ // status says "not in catalog, provider default". Matching order: exact key, then a seat/CLI
35
+ // alias (kimi/codex/claude default entries carry `aliases`), then a bare id ("deepseek-v4-pro"
36
+ // matches "deepseek/deepseek-v4-pro" — scrooge routes bare ids, the runner qualified ones).
37
+ export function lookup(modelId, cat = loadCatalog()) {
38
+ const id = String(modelId || "").trim();
39
+ if (!id) return uncatalogued(id);
40
+ if (cat.models[id]) return { found: true, id, ...cat.models[id] };
41
+ for (const [key, entry] of Object.entries(cat.models)) {
42
+ if ((entry.aliases || []).includes(id)) return { found: true, id: key, ...entry };
43
+ }
44
+ const lower = id.toLowerCase();
45
+ for (const [key, entry] of Object.entries(cat.models)) {
46
+ if (key.toLowerCase().endsWith(`/${lower}`)) return { found: true, id: key, ...entry };
47
+ }
48
+ return uncatalogued(id);
49
+ }
50
+
51
+ // The request parameters one difficulty level maps to for one API kind. Returns null when the
52
+ // model is uncatalogued (or the level is missing); an EMPTY object means catalogued but the
53
+ // model takes no effort parameters (provider default — e.g. K2.7 Code thinks always).
54
+ export function effortParams(modelId, difficulty, apiKind, cat = loadCatalog()) {
55
+ const entry = lookup(modelId, cat);
56
+ if (!entry.found) return null;
57
+ const level = entry.effort?.[difficulty];
58
+ if (!level) return null;
59
+ if (apiKind && level[apiKind] !== undefined) return level[apiKind];
60
+ const kinds = Object.keys(level);
61
+ return kinds.length === 1 ? level[kinds[0]] : null;
62
+ }
63
+
64
+ // Which wire API kind each delivery path speaks: codex talks the OpenAI Responses API, the
65
+ // claude CLI talks anthropic-messages, every opencode-driven seat rides an openai-compatible
66
+ // chat endpoint (the glm coding plan is openai-chat only, per its provider docs).
67
+ const AGENT_API = { codex: "openai-responses", claude: "anthropic-messages", sonnet: "anthropic-messages" };
68
+ const apiOfAgent = (agent) => AGENT_API[agent] || "openai-chat";
69
+
70
+ // resolveEffort(agent, modelId, difficulty) → the launcher-side effort record that rides to the
71
+ // runner as CREW_EFFORT. modelId may be empty for a CLI-default seat (kimi/codex/claude) — the
72
+ // entry is then found through the agent alias. Always returns a record, so the runner can log
73
+ // exactly one effort line per turn.
74
+ export function resolveEffort(agent, modelId, difficulty, cat = loadCatalog()) {
75
+ const api = apiOfAgent(agent);
76
+ let entry = modelId ? lookup(modelId, cat) : uncatalogued("");
77
+ if (!entry.found && agent) {
78
+ const byAgent = lookup(agent, cat);
79
+ if (byAgent.found) entry = byAgent;
80
+ }
81
+ if (!entry.found) {
82
+ return { found: false, agent, model: modelId || agent || "", difficulty, api, status: UNCATALOGUED_STATUS };
83
+ }
84
+ const level = entry.effort?.[difficulty] || {};
85
+ let params = level[api];
86
+ if (params === undefined) {
87
+ const kinds = Object.keys(level);
88
+ params = kinds.length === 1 ? level[kinds[0]] : {};
89
+ }
90
+ return { found: true, agent, model: entry.id, difficulty, api, params: params && typeof params === "object" ? params : {} };
91
+ }
92
+
93
+ // cliEffortFlag(agent, effort) → { flag, text }: the per-CLI argument that carries the effort
94
+ // parameters, plus the ONE log line the runner prints about it. Only the parameters a CLI can
95
+ // actually carry are applied (codex: -c model_reasoning_effort; claude: --effort; opencode
96
+ // seats: --variant, opencode's provider-specific reasoning effort); anything else stays at
97
+ // provider default and the line says so.
98
+ export function cliEffortFlag(agent, effort) {
99
+ if (!effort) return { flag: "", text: "" };
100
+ const difficulty = effort.difficulty || "?";
101
+ const model = effort.model || agent || "";
102
+ if (!effort.found) return { flag: "", text: `effort: ${model} ${UNCATALOGUED_STATUS}` };
103
+ const params = effort.params || {};
104
+ if (agent === "codex" && params.reasoning_effort) {
105
+ return {
106
+ flag: ` -c model_reasoning_effort="${params.reasoning_effort}"`,
107
+ text: `effort(${difficulty}): model_reasoning_effort=${params.reasoning_effort} set for ${model} (codex -c, catalog ${effort.api})`,
108
+ };
109
+ }
110
+ if ((agent === "claude" || agent === "sonnet") && params.effort) {
111
+ return {
112
+ flag: ` --effort ${params.effort}`,
113
+ text: `effort(${difficulty}): --effort ${params.effort} set for ${model} (catalog ${effort.api})`,
114
+ };
115
+ }
116
+ if (params.reasoning_effort) {
117
+ return {
118
+ flag: ` --variant ${params.reasoning_effort}`,
119
+ text: `effort(${difficulty}): --variant ${params.reasoning_effort} set for ${model} (opencode ${effort.api} reasoning_effort)`,
120
+ };
121
+ }
122
+ return { flag: "", text: `effort(${difficulty}): ${model} in catalog — no per-request effort parameters (provider default)` };
123
+ }
@@ -0,0 +1,150 @@
1
+ // #7762: the seat record is DERIVED, never a store. The hub already logs every card move
2
+ // (/tasks cards carry history + the note log; /events keeps moves for aged-out cards) and the
3
+ // runner's ledger rows (~/.agent-bus/logs/<seat>-<project>.jsonl) carry per-turn outcome +
4
+ // tokens + card id. From those three this computes, PER PROJECT and PER SEAT, the last
5
+ // RECORD_LIMIT cards as completed / empty / bounced:
6
+ // bounced — a testing→doing move by someone other than the seat (the assigner sent it back),
7
+ // or a HOLLOW: note on the card (#7750)
8
+ // completed — the card reached done with no bounce on its trail
9
+ // empty — the card never reached done and none of the seat's ledger turns on it completed
10
+ // (outcome empty/stalled/cut — the turn produced nothing)
11
+ // relay_advise (bin/advise.mjs) benches a seat at a difficulty when its last STRIKE cards there
12
+ // are all empty/bounced. The ONLY forgiveness paths: `trantor seat-record --reset <seat>`
13
+ // (drops evidence older than the reset stamp) and new completed cards aging the bad ones out
14
+ // of the STRIKE window — so no seat is blacklisted forever.
15
+ import { readFileSync, writeFileSync, renameSync, readdirSync, mkdirSync } from "node:fs";
16
+ import { join, dirname } from "node:path";
17
+ import { homedir } from "node:os";
18
+ import { busDir } from "./project.mjs";
19
+
20
+ export const RECORD_LIMIT = 20;
21
+ export const STRIKE = 3;
22
+
23
+ // The roster token an assignee string routes by: "kimi:trantor" → "kimi".
24
+ export const seatLabel = (assignee) => String(assignee || "").split(":")[0];
25
+
26
+ const BAD_OUTCOMES = new Set(["empty", "bounced"]);
27
+ // Turn outcomes that count as the seat having produced something. "asked" (#7756) is a real
28
+ // answer-shaped turn, not a silent one; cut/stalled/empty produced nothing by definition.
29
+ const PRODUCTIVE_OUTCOMES = new Set(["completed", "asked"]);
30
+
31
+ export function resetsPathFor() {
32
+ return join(busDir(), "seat-record-resets.json");
33
+ }
34
+
35
+ // Pure. cards = full /tasks rows (history + log included — never fields=slim), events = /events
36
+ // type=moved rows (bounce evidence for cards that aged off the board), ledger = runner jsonl
37
+ // rows from every <seat>-<project>.jsonl, resets = { <seatLabel>: ts } for THIS project.
38
+ export function computeSeatRecord({ cards = [], events = [], ledger = [], resets = {}, limit = RECORD_LIMIT } = {}) {
39
+ const byCard = new Map();
40
+ const ensure = (id) => {
41
+ let c = byCard.get(id);
42
+ if (!c) { c = { id, title: "", difficulty: "", seat: "", status: "", ts: 0, history: [], log: [] }; byCard.set(id, c); }
43
+ return c;
44
+ };
45
+ for (const t of cards) {
46
+ const c = ensure(t.id);
47
+ c.title = t.title || c.title;
48
+ c.difficulty = t.difficulty || c.difficulty;
49
+ c.seat = seatLabel(t.assignee) || c.seat;
50
+ c.status = t.status || c.status;
51
+ c.ts = Math.max(c.ts, Number(t.updated || t.ts) || 0);
52
+ if (Array.isArray(t.history)) c.history.push(...t.history);
53
+ if (Array.isArray(t.log)) c.log.push(...t.log);
54
+ }
55
+ // Moved events re-state what a live card's history already says (idempotent — classification
56
+ // is boolean) and are the only trail left for a card that aged out of the board's task cap.
57
+ for (const e of events) {
58
+ if (e?.type !== "moved" || !Number.isInteger(e.taskId)) continue;
59
+ const c = ensure(e.taskId);
60
+ c.title = c.title || e.title || "";
61
+ c.difficulty = c.difficulty || e.difficulty || "";
62
+ c.seat = c.seat || seatLabel(e.assignee);
63
+ c.ts = Math.max(c.ts, Number(e.ts) || 0);
64
+ c.history.push({ from: e.from, to: e.to, by: e.by, ts: e.ts });
65
+ if (e.to === "done") c.status = "done";
66
+ }
67
+ const rows = ledger.filter(r => r && Number.isInteger(r.card) && r.card > 0);
68
+ const seats = {};
69
+ for (const c of byCard.values()) {
70
+ if (!c.seat) continue; // unassigned cards say nothing about a seat
71
+ const resetTs = Number(resets[c.seat]) || 0;
72
+ const seatRows = rows.filter(r => seatLabel(r.agent) === c.seat && r.card === c.id && (Number(r.ts) || 0) > resetTs);
73
+ const bounced =
74
+ c.history.some(h => h.from === "testing" && h.to === "doing" && h.by && seatLabel(h.by) !== c.seat) ||
75
+ c.log.some(l => /^HOLLOW:/.test(String(l?.text || ""))) ||
76
+ c.history.some(h => /^HOLLOW:/.test(String(h?.note || "")));
77
+ let outcome = null;
78
+ if (bounced) outcome = "bounced";
79
+ else if (c.status === "done") outcome = "completed";
80
+ else if (seatRows.length && seatRows.every(r => !PRODUCTIVE_OUTCOMES.has(r.outcome))) outcome = "empty";
81
+ if (!outcome) continue; // in flight or no evidence — not a record entry
82
+ const ts = Math.max(c.ts, ...c.history.map(h => Number(h.ts) || 0), ...c.log.map(l => Number(l.ts) || 0), ...seatRows.map(r => Number(r.ts) || 0), 0);
83
+ if (ts <= resetTs) continue; // a reset wipes everything it postdates
84
+ const s = (seats[c.seat] ||= { cards: [], wastedTokens: 0 });
85
+ s.cards.push({ id: c.id, title: c.title, difficulty: c.difficulty, outcome, ts });
86
+ if (BAD_OUTCOMES.has(outcome)) s.wastedTokens += seatRows.reduce((sum, r) => sum + (Number(r.tokens) || 0), 0);
87
+ }
88
+ for (const s of Object.values(seats)) {
89
+ s.cards.sort((a, b) => a.ts - b.ts);
90
+ if (s.cards.length > limit) s.cards.splice(0, s.cards.length - limit);
91
+ }
92
+ return { seats };
93
+ }
94
+
95
+ // The bench rule: the seat's last STRIKE cards AT THIS DIFFICULTY were all empty/bounced.
96
+ // Fewer than STRIKE cards is insufficient evidence — a new seat is never benched on one bad card.
97
+ export function benchedAt(record, seat, difficulty) {
98
+ const atDiff = (record?.seats?.[seat]?.cards || []).filter(c => c.difficulty === difficulty).slice(-STRIKE);
99
+ if (atDiff.length < STRIKE || !atDiff.every(c => BAD_OUTCOMES.has(c.outcome))) return null;
100
+ return { streak: atDiff.map(c => c.outcome), cardIds: atDiff.map(c => c.id), wastedTokens: record.seats[seat].wastedTokens };
101
+ }
102
+
103
+ const readJson = (p, fb) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return fb; } };
104
+
105
+ // Reads the three live sources for `project` through hooks/lib/api.mjs signedGet (fail-open: any
106
+ // unreachable piece yields an empty record — the advisor then routes exactly as before #7762).
107
+ // `get` is injectable so the drill never touches a hub.
108
+ export async function loadSeatRecord({ project, get, logDir, resetsPath } = {}) {
109
+ if (!project) return { seats: {} };
110
+ if (!get) {
111
+ try {
112
+ const { signedGet } = await import("../hooks/lib/api.mjs");
113
+ get = (path) => signedGet(path, { project });
114
+ } catch { return { seats: {} }; }
115
+ }
116
+ const [tasksR, eventsR] = await Promise.all([
117
+ get(`/tasks?project=${encodeURIComponent(project)}`),
118
+ get(`/events?project=${encodeURIComponent(project)}&type=moved&limit=2000`),
119
+ ]);
120
+ const cards = tasksR?.ok && Array.isArray(tasksR.json?.tasks) ? tasksR.json.tasks : [];
121
+ const events = eventsR?.ok && Array.isArray(eventsR.json?.events) ? eventsR.json.events : [];
122
+ const dir = logDir || join(homedir(), ".agent-bus", "logs");
123
+ const ledger = [];
124
+ try {
125
+ for (const f of readdirSync(dir)) {
126
+ if (!f.endsWith(`-${project}.jsonl`)) continue;
127
+ for (const line of readFileSync(join(dir, f), "utf8").split("\n")) {
128
+ if (!line.trim()) continue;
129
+ try { ledger.push(JSON.parse(line)); } catch {}
130
+ }
131
+ }
132
+ } catch {}
133
+ const resets = readJson(resetsPath || resetsPathFor(), {})?.[project] || {};
134
+ return computeSeatRecord({ cards, events, ledger, resets });
135
+ }
136
+
137
+ // The manual forgiveness path. Stamps now() for <seat> in <project>; evidence at or before the
138
+ // stamp stops counting. Atomic write — a crash mid-reset must not corrupt every other project's.
139
+ export function resetSeat({ project, seat, resetsPath, now = Date.now() } = {}) {
140
+ const p = resetsPath || resetsPathFor();
141
+ const all = readJson(p, {});
142
+ (all[project] ||= {})[seat] = now;
143
+ try {
144
+ mkdirSync(dirname(p), { recursive: true });
145
+ const tmp = `${p}.${process.pid}.tmp`;
146
+ writeFileSync(tmp, JSON.stringify(all, null, 2), { mode: 0o600 });
147
+ renameSync(tmp, p);
148
+ return true;
149
+ } catch { return false; }
150
+ }
package/mcp.mjs CHANGED
@@ -10,7 +10,8 @@ import { execSync, spawnSync } from "node:child_process";
10
10
  import { randomUUID } from "node:crypto";
11
11
  import { createRequire } from "node:module";
12
12
  import { fileURLToPath, pathToFileURL } from "node:url";
13
- import { advise } from "./bin/advise.mjs";
13
+ import { advise, loadWorld } from "./bin/advise.mjs";
14
+ import { loadSeatRecord } from "./lib/seat-record.mjs";
14
15
  import { resolveProject, hostId, resolveHubInfo, nonSeatReason, handoffDir, orchWriterSid } from "./lib/project.mjs";
15
16
  import { signedPost, signedGet } from "./hooks/lib/api.mjs";
16
17
  import { anchorCursor } from "./hooks/lib/inbox-ledger.mjs";
@@ -258,7 +259,11 @@ server.tool("relay_advise", "THE ADVISOR — ask the brain how to execute a body
258
259
  packages: z.array(z.object({ title: z.string(), difficulty: z.enum(["easy","medium","hard"]).optional(), kind: z.string().optional() })).describe("the work packages you'd cut as cards"),
259
260
  horizon: z.enum(["short","medium","long"]).optional().describe("how long this build will run (default inferred from package count)") },
260
261
  async ({ task, packages, horizon }) => {
261
- const out = advise({ task, packages, horizon });
262
+ // #7762: the advisor reads THIS project's seat record (derived, fail-open) so a seat whose
263
+ // recent cards produced nothing is benched at that difficulty instead of re-picked as cheap.
264
+ const world = loadWorld();
265
+ try { world.record = await loadSeatRecord({ project: PROJECT }); } catch {}
266
+ const out = advise({ task, packages, horizon }, world);
262
267
  return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }] };
263
268
  });
264
269
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.60",
3
+ "version": "0.18.61",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"