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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/advise.mjs +44 -7
- package/bin/cli.mjs +2 -0
- package/bin/connect.mjs +22 -9
- package/bin/crew/cmux.mjs +2 -2
- package/bin/crew/core.mjs +5 -2
- package/bin/crew/herdr.mjs +2 -2
- package/bin/crew/models.mjs +11 -4
- package/bin/crew/tmux.mjs +2 -2
- package/bin/crew-runner.mjs +15 -3
- package/bin/seat-record.mjs +41 -0
- package/configs/model-catalog.json +119 -0
- package/hooks/ask-sidecar.mjs +64 -12
- package/hooks/hooks.json +18 -0
- package/lib/model-catalog.mjs +123 -0
- package/lib/seat-record.mjs +150 -0
- package/mcp.mjs +7 -2
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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) {
|
package/bin/crew/herdr.mjs
CHANGED
|
@@ -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) {
|
package/bin/crew/models.mjs
CHANGED
|
@@ -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)
|
|
40
|
-
|
|
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) {
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -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
|
+
}
|
package/hooks/ask-sidecar.mjs
CHANGED
|
@@ -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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
|