trantor 0.18.62 → 0.18.63

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/hub/store.mjs CHANGED
@@ -44,6 +44,24 @@ function appendTaskNote(t, b, ts = Date.now()) {
44
44
  if (!b || typeof b.note !== "string") return false;
45
45
  return appendTaskLog(t, b.by || "", b.note, ts);
46
46
  }
47
+ // #6452: a card's drill line (build doctrine rule 1) — the `drill` field, a checklist item or a log
48
+ // note starting "Drill:", or the move's own note; that note also counts when it names the gate
49
+ // command it ran (the orchestrator closes with "verified at <sha>" + the gate). The command shapes
50
+ // mirror hooks/lib/hollow-move.mjs, minus bare pass counts: "12/12" names no gate.
51
+ const DRILL_MAX = 300;
52
+ const DRILL_LINE = /^\s*drill:/i;
53
+ const GATE_CMD = /(node\s+test\/|npm\s+(run\s+)?test|pnpm\s+test|yarn\s+test|vitest|pytest|go\s+test|cargo\s+test|make\s+test)/i;
54
+ function cleanDrill(v) {
55
+ const s = stripNulText(v).replace(/\s+/g, " ").trim();
56
+ return s ? s.slice(0, DRILL_MAX) : "";
57
+ }
58
+ function hasDrillLine(t, b = {}) {
59
+ if (cleanDrill(b.drill) || cleanDrill(t.drill)) return true;
60
+ if (Array.isArray(t.checklist) && t.checklist.some(c => DRILL_LINE.test(String(c?.text ?? "")))) return true;
61
+ if (Array.isArray(t.log) && t.log.some(e => DRILL_LINE.test(String(e?.text ?? "")))) return true;
62
+ return typeof b.note === "string" && (DRILL_LINE.test(b.note) || GATE_CMD.test(b.note));
63
+ }
64
+
47
65
  // Card checklists (#5624): acceptance items are the one honest denominator for a progress bar.
48
66
  // Accepts plain strings (fresh items) or {text,done} (round-trips); caps 20 items x 200 chars.
49
67
  // Returns null for a non-array so callers can distinguish "not sent" from "sent empty".
@@ -106,11 +124,9 @@ function normalizeState(loaded = {}) {
106
124
  // migrate old numeric form
107
125
  s.peers[session] = typeof v === "number"
108
126
  ? { lastSeen: v, status: "", project: "" }
109
- // #6170: `kind` must be carried across the load. This normalizer rebuilds every peer from an
110
- // explicit field list, so a field missing here is dropped no matter how faithfully the store
111
- // returned it — which is exactly what happened: the column was added, Postgres held the right
112
- // values, and the kinds still came back empty on the first live restart. llm/model stay
113
- // out on purpose: those ARE in-memory presence, re-supplied by the next heartbeat.
127
+ // #6170: `kind` rides the load. This normalizer rebuilds every peer from an explicit field
128
+ // list, so a field missing here is dropped however faithfully the store returned it (the kinds
129
+ // came back empty on the first live restart). llm/model stay out: they are in-memory presence.
114
130
  : { lastSeen: v.lastSeen || 0, status: v.status || "", project: v.project || "", pubkey: v.pubkey || "", identity: v.identity || null, authWarning: v.authWarning || "", hookVersion: v.hookVersion || "", kind: v.kind || "", deliveredUpTo: v.deliveredUpTo || v.delivered_up_to || 0, _on: v._on === true || v.online === true };
115
131
  }
116
132
  return s;
@@ -240,6 +256,6 @@ setInterval(persist, persistTickMs).unref?.();
240
256
 
241
257
  return {
242
258
  state, durableStore, persist, persistHealth, markDirty, reload, startChangeSubscription,
243
- HUB_SRC, appendTaskLog, appendTaskNote, cleanChecklist, stripNulText,
259
+ HUB_SRC, appendTaskLog, appendTaskNote, cleanChecklist, cleanDrill, hasDrillLine, stripNulText,
244
260
  };
245
261
  }
package/lib/autonomy.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
4
4
  import { join, dirname } from "node:path";
5
5
  import { homedir } from "node:os";
6
+ import { asRecord } from "./decode.mjs";
6
7
 
7
8
  export const AUTONOMY_PATH = () =>
8
9
  join(process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus"), "autonomy.json");
@@ -32,7 +33,7 @@ export function loadAutonomy() {
32
33
  return {
33
34
  version: 1,
34
35
  defaults: { ...DEFAULTS, ...(raw.defaults || {}) },
35
- projects: raw.projects && typeof raw.projects === "object" ? raw.projects : {},
36
+ projects: asRecord(raw.projects) ?? {},
36
37
  };
37
38
  } catch {
38
39
  // A corrupt file must not hand out permissions nobody granted. Fall back to the safe defaults.
package/lib/decode.mjs ADDED
@@ -0,0 +1,13 @@
1
+ /* oxlint-disable anti-slop/no-runtime-typeof -- SAFETY: this module IS the I/O boundary decoder for
2
+ lib/. Everything it is handed came off disk (config.json, autonomy.json, model-catalog.json), out
3
+ of a JSONL transcript written by whatever CLI version produced it, or off an HTTP request — the
4
+ type of any field is exactly what cannot be assumed, so these typeof checks are the parse that
5
+ establishes the contract, not a substitute for one (#7174). */
6
+ // Each helper returns the value at its known shape or null, so callers branch on a domain value.
7
+ // Deliberately permissive where the inline checks they replaced were: asRecord admits arrays and
8
+ // asNumber admits NaN, because `typeof x === "object"` and `typeof x === "number"` did (#7174).
9
+
10
+ export function asRecord(v) { return v && typeof v === "object" ? v : null; }
11
+ export function asString(v) { return typeof v === "string" ? v : null; }
12
+ export function asNumber(v) { return typeof v === "number" ? v : null; }
13
+ export function asFunction(v) { return typeof v === "function" ? v : null; }
package/lib/identity.mjs CHANGED
@@ -4,6 +4,7 @@ import { generateKeyPairSync, createPublicKey, createPrivateKey, sign as cryptoS
4
4
  import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, chmodSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { homedir } from "node:os";
7
+ import { asFunction, asString } from "./decode.mjs";
7
8
 
8
9
  export const SCHEME = "trantor-v1";
9
10
  export const INST_SCHEME = "trantor-inst-v1";
@@ -105,7 +106,7 @@ export function publicView(identity) {
105
106
  // --- canonical request -------------------------------------------------------------------------
106
107
  export function bodyHash(body) {
107
108
  if (body === undefined || body === null || body === "") return "";
108
- const buf = Buffer.isBuffer(body) ? body : Buffer.from(typeof body === "string" ? body : JSON.stringify(body), "utf8");
109
+ const buf = Buffer.isBuffer(body) ? body : Buffer.from(asString(body) ?? JSON.stringify(body), "utf8");
109
110
  return createHash("sha256").update(buf).digest("hex");
110
111
  }
111
112
 
@@ -199,7 +200,8 @@ export function verifyEndorsement({ durablePubkey, instancePubkey, instanceId, c
199
200
  // (is this pubkey known? may it touch this project?) belong to the hub, which owns that state.
200
201
  // Returns { ok, pubkey, ts, nonce, reason }.
201
202
  export function verifyRequest({ headers, method, path, body, now = Date.now() }) {
202
- const get = (k) => (typeof headers?.get === "function" ? headers.get(k) : headers?.[k] ?? headers?.[k.toLowerCase()]);
203
+ const lookup = asFunction(headers?.get);
204
+ const get = (k) => (lookup ? lookup.call(headers, k) : headers?.[k] ?? headers?.[k.toLowerCase()]);
203
205
  const pubkey = get(HDR.pubkey), sig = get(HDR.sig), ts = get(HDR.ts), nonce = get(HDR.nonce);
204
206
  if (!pubkey || !sig || !ts || !nonce) return { ok: false, reason: "unsigned" };
205
207
  if (!/^[0-9a-f]{64}$/i.test(pubkey)) return { ok: false, reason: "bad-pubkey" };
@@ -1,17 +1,9 @@
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.
1
+ // lib/model-catalog.mjs — the declarative model catalog (#7777): scores pick WHICH model, this
2
+ // says HOW to call it. Shape and rules: docs/CONTRACT-lib.md, Providers and balances.
12
3
  import { readFileSync } from "node:fs";
13
4
  import { dirname, join } from "node:path";
14
5
  import { fileURLToPath } from "node:url";
6
+ import { asRecord } from "./decode.mjs";
15
7
 
16
8
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
17
9
  export const CATALOG_PATH = process.env.TRANTOR_MODEL_CATALOG || join(ROOT, "configs", "model-catalog.json");
@@ -22,8 +14,8 @@ export function loadCatalog(path = CATALOG_PATH) {
22
14
  if (CACHE && path === CATALOG_PATH) return CACHE;
23
15
  let cat;
24
16
  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 = {};
17
+ if (!asRecord(cat)) cat = { version: 0, models: {} };
18
+ if (!asRecord(cat.models)) cat.models = {};
27
19
  if (path === CATALOG_PATH) CACHE = cat;
28
20
  return cat;
29
21
  }
@@ -87,14 +79,11 @@ export function resolveEffort(agent, modelId, difficulty, cat = loadCatalog()) {
87
79
  const kinds = Object.keys(level);
88
80
  params = kinds.length === 1 ? level[kinds[0]] : {};
89
81
  }
90
- return { found: true, agent, model: entry.id, difficulty, api, params: params && typeof params === "object" ? params : {} };
82
+ return { found: true, agent, model: entry.id, difficulty, api, params: asRecord(params) ?? {} };
91
83
  }
92
84
 
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.
85
+ // The per-CLI argument carrying the effort parameters, plus the one log line the runner prints.
86
+ // Only what a CLI can actually carry is applied; the rest stays at provider default and says so.
98
87
  export function cliEffortFlag(agent, effort) {
99
88
  if (!effort) return { flag: "", text: "" };
100
89
  const difficulty = effort.difficulty || "?";
package/lib/project.mjs CHANGED
@@ -5,6 +5,7 @@ import { execSync } from "node:child_process";
5
5
  import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, readdirSync, statSync, realpathSync } from "node:fs";
6
6
  import { basename, join, dirname, resolve, sep } from "node:path";
7
7
  import { homedir, hostname } from "node:os";
8
+ import { asRecord, asString } from "./decode.mjs";
8
9
 
9
10
  export function gitRoot(dir) {
10
11
  try {
@@ -192,7 +193,7 @@ export function writeOrchSession(project, sid, by = "unknown") {
192
193
  function configPath() { return join(busDir(), "config.json"); }
193
194
 
194
195
  export function readConfig() {
195
- try { const c = configPath(); if (existsSync(c)) { const j = JSON.parse(readFileSync(c, "utf8")); if (j && typeof j === "object") return j; } } catch {}
196
+ try { const c = configPath(); if (existsSync(c)) { const j = asRecord(JSON.parse(readFileSync(c, "utf8"))); if (j) return j; } } catch {}
196
197
  return {};
197
198
  }
198
199
 
@@ -203,9 +204,10 @@ export function resolveHubInfo(project, env = process.env) {
203
204
  if (env.RELAY_URL) return { url: env.RELAY_URL, via: "env" };
204
205
  const cfg = readConfig();
205
206
  const name = project || resolveProject();
206
- const u = cfg?.hubs?.[name];
207
- if (u && typeof u === "string") return { url: u, via: "pin" };
208
- if (cfg?.url && typeof cfg.url === "string") return { url: cfg.url, via: "global" };
207
+ const pinned = asString(cfg?.hubs?.[name]);
208
+ if (pinned) return { url: pinned, via: "pin" };
209
+ const globalUrl = asString(cfg?.url);
210
+ if (globalUrl) return { url: globalUrl, via: "global" };
209
211
  } catch {}
210
212
  return { url: DEFAULT_HUB_URL, via: "default" };
211
213
  }
@@ -217,7 +219,7 @@ export function resolveHub(project, env = process.env) {
217
219
  // Every project the operator has deliberately pinned — the "expected one of these" list a
218
220
  // misplaced session needs in order to fix itself.
219
221
  export function knownProjects() {
220
- try { const h = readConfig()?.hubs; return h && typeof h === "object" ? Object.keys(h).sort() : []; } catch { return []; }
222
+ try { return Object.keys(asRecord(readConfig()?.hubs) ?? {}).sort(); } catch { return []; }
221
223
  }
222
224
 
223
225
  // How many IMMEDIATE children of `dir` are git repos. Bounded (first 200 entries) and fail-safe:
@@ -272,10 +274,12 @@ function writeConfig(cfg) {
272
274
  // Pin a project to a hub. URL must be absolute http(s); trailing slash stripped so
273
275
  // `${hub}/path` concatenation never double-slashes.
274
276
  export function setProjectHub(project, url) {
275
- if (!project || typeof project !== "string") throw new Error("project required");
277
+ if (!asString(project)) throw new Error("project required");
276
278
  if (!/^https?:\/\//.test(String(url || ""))) throw new Error("url must start with http:// or https://");
277
279
  const cfg = readConfig();
278
- cfg.hubs = { ...(cfg.hubs && typeof cfg.hubs === "object" ? cfg.hubs : {}), [project]: String(url).replace(/\/+$/, "") };
280
+ const hubs = { ...(asRecord(cfg.hubs) ?? {}) };
281
+ hubs[project] = String(url).replace(/\/+$/, "");
282
+ cfg.hubs = hubs;
279
283
  writeConfig(cfg);
280
284
  }
281
285
 
@@ -283,9 +287,10 @@ export function setProjectHub(project, url) {
283
287
  // a mapping existed.
284
288
  export function unsetProjectHub(project) {
285
289
  const cfg = readConfig();
286
- if (!cfg.hubs || typeof cfg.hubs !== "object" || !(project in cfg.hubs)) return false;
287
- delete cfg.hubs[project];
288
- if (!Object.keys(cfg.hubs).length) delete cfg.hubs;
290
+ const hubs = asRecord(cfg.hubs);
291
+ if (!hubs || !(project in hubs)) return false;
292
+ delete hubs[project];
293
+ if (!Object.keys(hubs).length) delete cfg.hubs;
289
294
  writeConfig(cfg);
290
295
  return true;
291
296
  }
@@ -1,17 +1,6 @@
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.
1
+ // #7762: the seat record is DERIVED from card history and the runner's ledger, never a store, so
2
+ // a seat benched by relay_advise can always be un-benched. Rules: docs/CONTRACT-lib.md, Crew turn
3
+ // policy and failure classification.
15
4
  import { readFileSync, writeFileSync, renameSync, readdirSync, mkdirSync } from "node:fs";
16
5
  import { join, dirname } from "node:path";
17
6
  import { homedir } from "node:os";
package/lib/seat-why.mjs CHANGED
@@ -4,6 +4,7 @@ import { readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { homedir } from "node:os";
6
6
  import { execSync } from "node:child_process";
7
+ import { asNumber, asRecord } from "./decode.mjs";
7
8
 
8
9
  const busDir = () => process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
9
10
 
@@ -18,7 +19,7 @@ function readTelemetry(file) {
18
19
  const out = [];
19
20
  for (const line of readF(file).split("\n")) {
20
21
  if (!line.trim()) continue;
21
- try { const r = JSON.parse(line); if (r && typeof r === "object") out.push(r); } catch {}
22
+ try { const r = asRecord(JSON.parse(line)); if (r) out.push(r); } catch {}
22
23
  }
23
24
  return out;
24
25
  }
@@ -60,7 +61,7 @@ const rel = (ts) => {
60
61
  // print none contribute 0, which is why the count is reported alongside — "3 of 7 turns reported".
61
62
  export function todaySpend(telemetry, now = Date.now()) {
62
63
  const midnight = new Date(now); midnight.setHours(0, 0, 0, 0);
63
- const rows = telemetry.filter(r => typeof r.turn === "number" && r.ts >= midnight.getTime());
64
+ const rows = telemetry.filter(r => asNumber(r.turn) !== null && r.ts >= midnight.getTime());
64
65
  const withTokens = rows.filter(r => Number(r.tokens) > 0);
65
66
  return {
66
67
  turns: rows.length,
@@ -86,7 +87,7 @@ export function seatWhy(project, agent, opts = {}) {
86
87
  const telemetry = readTelemetry(join(dir, "logs", `${agent}-${project}.jsonl`));
87
88
  const pane = findPane(dir, project, agent);
88
89
  const pids = opts.pidCheck ? opts.pidCheck(project, agent) : scanPids(project, agent);
89
- const last = [...telemetry].reverse().find(r => typeof r.turn === "number") || null;
90
+ const last = [...telemetry].reverse().find(r => asNumber(r.turn) !== null) || null;
90
91
  const boots = telemetry.filter(r => r.boot).length;
91
92
  const errTail = errText.trim().split("\n").filter(Boolean).slice(-2).join(" | ").slice(0, 240);
92
93
 
package/lib/seats.mjs CHANGED
@@ -4,39 +4,43 @@ import { execFileSync, spawn } from "node:child_process";
4
4
  import { existsSync } from "node:fs";
5
5
  import { resolve } from "node:path";
6
6
  import { readConfig, writeConfigPublic, resolveProject, resolveHubInfo } from "./project.mjs";
7
+ import { asRecord, asString } from "./decode.mjs";
7
8
 
8
9
  // ── the registry ────────────────────────────────────────────────────────────────
9
10
  // config.json gains `seats: { <project>: { dir } }`. Same file as `hubs`, same shape of decision:
10
11
  // a deliberate operator statement, not an inference.
11
12
  export function readSeats() {
12
13
  try {
13
- const s = readConfig()?.seats;
14
- if (!s || typeof s !== "object") return {};
14
+ const s = asRecord(readConfig()?.seats);
15
+ if (!s) return {};
15
16
  const out = {};
16
17
  for (const [p, v] of Object.entries(s)) {
17
- const dir = typeof v === "string" ? v : v?.dir;
18
- const agent = (typeof v === "object" && typeof v?.agent === "string" && v.agent) ? v.agent : "claude";
19
- if (typeof dir === "string" && dir) out[p] = { dir, agent };
18
+ const dir = asString(v) ?? asRecord(v)?.dir;
19
+ const agent = asString(asRecord(v)?.agent) || "claude";
20
+ if (asString(dir)) out[p] = { dir, agent };
20
21
  }
21
22
  return out;
22
23
  } catch { return {}; }
23
24
  }
24
25
 
25
26
  export function declareSeat(project, dir, agent = "claude") {
26
- if (!project || typeof project !== "string") throw new Error("project required");
27
+ if (!asString(project)) throw new Error("project required");
27
28
  const abs = resolve(dir || process.cwd());
28
29
  if (!existsSync(abs)) throw new Error(`directory does not exist: ${abs}`);
29
30
  const cfg = readConfig();
30
- cfg.seats = { ...(cfg.seats && typeof cfg.seats === "object" ? cfg.seats : {}), [project]: { dir: abs, agent } };
31
+ const seats = { ...(asRecord(cfg.seats) ?? {}) };
32
+ seats[project] = { dir: abs, agent };
33
+ cfg.seats = seats;
31
34
  writeConfigPublic(cfg);
32
35
  return { project, dir: abs, agent };
33
36
  }
34
37
 
35
38
  export function undeclareSeat(project) {
36
39
  const cfg = readConfig();
37
- if (!cfg.seats || typeof cfg.seats !== "object" || !(project in cfg.seats)) return false;
38
- delete cfg.seats[project];
39
- if (!Object.keys(cfg.seats).length) delete cfg.seats;
40
+ const seats = asRecord(cfg.seats);
41
+ if (!seats || !(project in seats)) return false;
42
+ delete seats[project];
43
+ if (!Object.keys(seats).length) delete cfg.seats;
40
44
  writeConfigPublic(cfg);
41
45
  return true;
42
46
  }
@@ -123,7 +127,7 @@ export function launchSeat(seat, { command, dryRun = false } = {}) {
123
127
  // a guessed seat is exactly the kind of inference this file exists to replace.
124
128
  export function suggestSeats(workspace) {
125
129
  const cfg = readConfig();
126
- const pinned = cfg?.hubs && typeof cfg.hubs === "object" ? Object.keys(cfg.hubs) : [];
130
+ const pinned = Object.keys(asRecord(cfg?.hubs) ?? {});
127
131
  const declared = readSeats();
128
132
  const out = [];
129
133
  for (const project of pinned) {
@@ -15,8 +15,9 @@ import { fromEnvelope } from "./cost.mjs";
15
15
  // upward is the deliberate inversion, since no number gets a second home. Its dispatch is guarded.
16
16
  import { PATCH_BUDGET, appendJsonl, patchesPath, runPath, seatClass } from "../../bin/state-bench.mjs";
17
17
 
18
- /** The flag. Absent or not "1" = the transcript path, byte for byte (§4.6). */
19
- export const STATE_ENV = "TRANTOR_STATE_ASSEMBLE";
18
+ // The flag name lives in flags.mjs (#7159), beside the resolution list — re-exported so the
19
+ // runner's import surface is unchanged.
20
+ export { STATE_ENV } from "./flags.mjs";
20
21
 
21
22
  /** §7.3's window: the rolling invalid rate is measured over a seat's last 20 turns. */
22
23
  export const BREAKER_WINDOW = 20;
@@ -268,6 +269,7 @@ export async function runStep({
268
269
  let gateRun = null;
269
270
  // Whether the CLI was CUT at the time box. It is the transport that knows, so the transport says
270
271
  // so on the way back rather than the caller guessing before the turn has run (§8.7 reads it).
272
+ // Per attempt, not per step (#7226): a cut on the first attempt belongs to that attempt's row.
271
273
  let stepCut = cut === true;
272
274
 
273
275
  // §7.3's fallback ladder: a MALFORMED patch earns exactly one retry, with the rejection message
@@ -282,9 +284,11 @@ export async function runStep({
282
284
  trace.push("cli");
283
285
  const run = await D.callCli(prompt, { attempt });
284
286
  exit = run && Number.isInteger(run.exit) ? run.exit : 1;
285
- if (run && run.cut === true) stepCut = true;
287
+ stepCut = cut === true || (run && run.cut === true);
286
288
  const parsed = parseEnvelope(run ? run.stdout : "");
287
- if (parsed.cost) cost = parsed.cost;
289
+ // The price of THIS call. A retry that came back unpriced must not inherit the first attempt's
290
+ // cost into its own row: two rows, one price each, or the bench sums a call twice.
291
+ cost = parsed.cost || null;
288
292
 
289
293
  // ---- 4. TIER 1: git touch + credit expiry, EVERY turn, before the apply ----
290
294
  trace.push("tier1");
@@ -325,6 +329,14 @@ export async function runStep({
325
329
  if (attempt === 0) first = rejection ? rejection.code : null;
326
330
  if (!rejection) break;
327
331
  if (attempt >= 1 || !MALFORMED_CODES.includes(rejection.code)) break;
332
+ // #7226: the attempt about to be retried was a CLI call of its own — often a boxed one, since a
333
+ // cut turn is no output and no output is malformed — so it gets its own run row HERE, before the
334
+ // retry's call. Written now, a runner that dies during the retry cannot fold it into the step's
335
+ // row, and §8.7 counts every turn the box actually ran instead of one per step.
336
+ recordStep(D, project, card, {
337
+ turn: state.cursor.turn, rev: null, by: seat, ts: now, exit, cost, cut: stepCut, disturbed,
338
+ action: null, verify: null, verified_paths: [], rejected: rejection, retry: true,
339
+ });
328
340
  attempt++;
329
341
  obs = `${observation}\n\n${rejectionObservation(rejection)}`.trim();
330
342
  }
@@ -424,8 +436,9 @@ export async function runStep({
424
436
  };
425
437
  }
426
438
 
427
- /** One run-step row for `~/.agent-bus/state/runs/<project>-<card>.jsonl`. The FIELD NAMES ARE A CONTRACT
428
- * with bin/state-bench.mjs (docs/CONTRACT-state.md §run record); a missing cost stays null, never 0. */
439
+ /** One run row for `~/.agent-bus/state/runs/<project>-<card>.jsonl` — one per CLI call the step made,
440
+ * so a retried attempt is a row of its own (#7226). The FIELD NAMES ARE A CONTRACT with
441
+ * bin/state-bench.mjs (docs/CONTRACT-state.md §run record); a missing cost stays null, never 0. */
429
442
  export function recordStep(D, project, card, row) {
430
443
  const c = row.cost || {};
431
444
  const out = {
@@ -448,6 +461,7 @@ export function recordStep(D, project, card, row) {
448
461
  };
449
462
  if (row.cut) out.cut = true;
450
463
  if (row.disturbed) out.disturbed = true;
464
+ if (row.retry) out.retry = true; // #7226: the attempt runStep retried in-step; its outcome is the next row
451
465
  try { D.appendJsonl(runPath(project, card), out); } catch { /* a recorder that throws must not end a turn */ }
452
466
  return out;
453
467
  }
@@ -0,0 +1,26 @@
1
+ // Trantor State flags (#7159) — the ONE place the runner's TRANTOR_STATE* names resolve. The runner
2
+ // boots from its OWN process env, so a flag set in ~/.agent-bus/.env (the file the turn wrapper
3
+ // sources on the spawned CLI, one level too deep for the runner) never reached it. Here process env
4
+ // wins and the file fills every name the launcher did not set; `trantor doctor` reports the layers.
5
+ import { join } from "node:path";
6
+ import { homedir } from "node:os";
7
+ import { parseEnvFile } from "../provider-keys.mjs";
8
+
9
+ /** §4.6: absent or not "1" = the transcript path, byte for byte. The name lives here so the list
10
+ * below and the runner's armed check cannot drift; driver.mjs re-exports it for its importers. */
11
+ export const STATE_ENV = "TRANTOR_STATE_ASSEMBLE";
12
+
13
+ // The names bin/crew/core.mjs forwards from whoever ran `trantor up` — one list, so forwarding and
14
+ // resolution cannot drift apart.
15
+ export const STATE_FLAGS = ["TRANTOR_STATE", "TRANTOR_STATE_ASSEMBLE", "TRANTOR_STATE_HANDOFF", "TRANTOR_STATE_GATE"];
16
+
17
+ /** `{ [name]: { value, layer } }` per flag; layer is "process env", "~/.agent-bus/.env (crew)" or
18
+ * "unset". Pure — env in, no writes — so the doctor reports the same answer the runner got. */
19
+ export function resolveStateFlags(env = process.env, file = join(homedir(), ".agent-bus", ".env")) {
20
+ const fromFile = parseEnvFile(file); // a missing file parses to {}
21
+ return Object.fromEntries(STATE_FLAGS.map((name) => {
22
+ if (env[name] !== undefined) return [name, { value: env[name], layer: "process env" }];
23
+ if (fromFile[name] !== undefined) return [name, { value: fromFile[name], layer: "~/.agent-bus/.env (crew)" }];
24
+ return [name, { value: "", layer: "unset" }];
25
+ }));
26
+ }
@@ -3,6 +3,7 @@
3
3
  import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
4
4
  import { join, dirname, basename, relative } from "node:path";
5
5
  import { homedir } from "node:os";
6
+ import { asString } from "./decode.mjs";
6
7
 
7
8
  const EDIT_TOOLS = /^(Write|Edit|MultiEdit|NotebookEdit)$/;
8
9
 
@@ -80,7 +81,7 @@ function analyzeAgent(meta, completedIds, projectRoot) {
80
81
  const content = b.input.content ?? b.input.new_string ?? "";
81
82
  wrote.set(b.input.file_path, content.length); // last write to a path wins
82
83
  }
83
- if (b?.type === "text" && typeof b.text === "string" && b.text.trim()) result = b.text.trim();
84
+ if (b?.type === "text") { const text = asString(b.text)?.trim(); if (text) result = text; }
84
85
  }
85
86
  }
86
87
  const status = meta.toolUseId && completedIds.has(meta.toolUseId) ? "completed" : "in-flight";
Binary file
package/mcp.mjs CHANGED
@@ -197,11 +197,11 @@ server.tool("relay_contracts", "What you dispatched and are still owed. Lists ev
197
197
  });
198
198
 
199
199
  server.tool("relay_task_add", "Add a Kanban card to a project's board on the dashboard (what you're about to work on). Defaults: THIS project, assigned to you, status 'todo'. Pass `project` to target another board — e.g. when you orchestrate a crew that runs in a different directory than the one you launched Claude from. Keep the team's progress visible. Attach a `note` whenever context isn't obvious from the title — it lands on the card's permanent log ({ts,by,text}, kept: last 40).",
200
- { title: z.string().describe("short task title"), status: z.enum(["todo","doing","testing","failed","done","blocked"]).optional(), assignee: z.string().optional().describe("session id to assign (default: you)"), difficulty: z.enum(["easy","medium","hard"]).optional().describe("difficulty tag — drives model/agent routing (relay_advise) and shows on the board"), model: z.string().optional().describe("the model this card is routed to (from relay_advise routing, or the CLI default) — shown on the card"), deps: z.array(z.number()).optional().describe("card ids this card depends on — drawn as branch edges in the Flow view (e.g. integration depends on every crew card)"), phase: z.string().optional().describe("phase/milestone this card belongs to (e.g. 'P5', 'Auth', 'Launch') — groups it in the Flow view's phase flowchart. Optional; otherwise inferred from the title prefix + time."), note: z.string().max(2000).optional().describe("optional card-log entry (<=2000 chars): context, the plan, or a link — stored on the card as {ts,by,text}"), project: z.string().optional().describe("board to add to (default: this session's project). Set to the crew's project when you orchestrate from a different directory"), checklist: z.array(z.string().max(200)).max(20).optional().describe("acceptance items for the card — the honest denominator for its progress bar. Tick them off with relay_task_check as each is truly met") },
201
- async ({ title, status, assignee, difficulty, model, deps, phase, note, project, checklist }) => {
200
+ { title: z.string().describe("short task title"), status: z.enum(["todo","doing","testing","failed","done","blocked"]).optional(), assignee: z.string().optional().describe("session id to assign (default: you)"), difficulty: z.enum(["easy","medium","hard"]).optional().describe("difficulty tag — drives model/agent routing (relay_advise) and shows on the board"), model: z.string().optional().describe("the model this card is routed to (from relay_advise routing, or the CLI default) — shown on the card"), deps: z.array(z.number()).optional().describe("card ids this card depends on — drawn as branch edges in the Flow view (e.g. integration depends on every crew card)"), phase: z.string().optional().describe("phase/milestone this card belongs to (e.g. 'P5', 'Auth', 'Launch') — groups it in the Flow view's phase flowchart. Optional; otherwise inferred from the title prefix + time."), note: z.string().max(2000).optional().describe("optional card-log entry (<=2000 chars): context, the plan, or a link — stored on the card as {ts,by,text}"), project: z.string().optional().describe("board to add to (default: this session's project). Set to the crew's project when you orchestrate from a different directory"), checklist: z.array(z.string().max(200)).max(20).optional().describe("acceptance items for the card — the honest denominator for its progress bar. Tick them off with relay_task_check as each is truly met"), drill: z.string().max(300).optional().describe("the card's DRILL (build doctrine rule 1, <=300 chars): the exact thing a person does on the built artifact and what they must see — not a test command. The hub refuses to move a card to done without a drill line (this field, a checklist item or a note starting with 'Drill:'), so a card cut without one is not ready to be worked") },
201
+ async ({ title, status, assignee, difficulty, model, deps, phase, note, project, checklist, drill }) => {
202
202
  const proj = project || PROJECT;
203
- const { task } = await api("POST", "/task", { project: proj, title, status: status || "todo", assignee: assignee || SESSION, difficulty, model, deps, phase, note, checklist, by: SESSION });
204
- return { content: [{ type: "text", text: `card #${task.id} added to ${proj}: "${title}" [${task.status}]${phase?` · phase ${phase}`:""}${task.checklist?.length?` · ${task.checklist.length} acceptance item(s)`:""}` }] };
203
+ const { task } = await api("POST", "/task", { project: proj, title, status: status || "todo", assignee: assignee || SESSION, difficulty, model, deps, phase, note, checklist, drill, by: SESSION });
204
+ return { content: [{ type: "text", text: `card #${task.id} added to ${proj}: "${title}" [${task.status}]${phase?` · phase ${phase}`:""}${task.checklist?.length?` · ${task.checklist.length} acceptance item(s)`:""}${task.drill ? " · drill set" : " · NO DRILL — the hub will refuse done until one is set"}` }] };
205
205
  });
206
206
 
207
207
  server.tool("relay_task_check", "Tick (or untick) ONE acceptance item on a card's checklist — the card's progress bar reads checked/total, so tick an item only when it is genuinely met (tests run, behavior observed), never to make the bar move. Items are 0-indexed in the order relay_task_add listed them.",
@@ -220,9 +220,9 @@ server.tool("relay_phase_goal", "Set what a PHASE is for — its goal — shown
220
220
  return { content: [{ type: "text", text: `phase "${phase}" goal set for ${proj}` }] };
221
221
  });
222
222
 
223
- server.tool("relay_task_move", "Move a Kanban card as you progress: todo -> doing -> testing -> done. NEVER move straight to done: move to 'testing' when you finish, run the project's tests/typecheck, then 'done' only if green — or 'failed' (with a relay_send explaining what broke) if not. The orchestrator bounces failed cards back to doing. blocked = waiting on something external. A move to 'testing' or 'done' MUST carry a `note` (<=2000 chars): what you changed and the evidence (the test command + counts). The note lands on the card's permanent log — the board shows its ·N count, so a silent move reads as unverified work. A testing/done move with NO worktree diff, no new files, no ticked checklist items and no test command in the note is flagged HOLLOW: and reported to the card's assigner (#7750) — a legitimately no-code card (docs, investigation, refusal, answered on the bus) avoids the flag by declaring that outcome in the note.",
224
- { id: z.number(), status: z.enum(["todo","doing","testing","failed","done","blocked"]), note: z.string().max(2000).optional().describe("card-log entry (<=2000 chars) — REQUIRED on moves to testing/done: what changed + the evidence (command, pass counts)") },
225
- async ({ id, status, note }) => {
223
+ server.tool("relay_task_move", "Move a Kanban card as you progress: todo -> doing -> testing -> done. NEVER move straight to done: move to 'testing' when you finish, run the project's tests/typecheck, then 'done' only if green — or 'failed' (with a relay_send explaining what broke) if not. The orchestrator bounces failed cards back to doing. blocked = waiting on something external. A move to 'testing' or 'done' MUST carry a `note` (<=2000 chars): what you changed and the evidence (the test command + counts). The note lands on the card's permanent log — the board shows its ·N count, so a silent move reads as unverified work.",
224
+ { id: z.number(), status: z.enum(["todo","doing","testing","failed","done","blocked"]), note: z.string().max(2000).optional().describe("card-log entry (<=2000 chars) — REQUIRED on moves to testing/done: what changed + the evidence (command, pass counts)"), drill: z.string().max(300).optional().describe("set or fix the card's drill line (<=300 chars) in the same call — what a person does on the built artifact and must see. A move to done is refused by the hub when the card has no drill line") },
225
+ async ({ id, status, note, drill }) => {
226
226
  // #7750: seat-side hollow-move check. The hub cannot see this worktree, so the evidence is
227
227
  // gathered HERE at report time; a hollow move still lands (never blocks a no-code card), the
228
228
  // note is prefixed HOLLOW: and the card's assigner gets one bus line. Never throws into the move.
@@ -250,7 +250,14 @@ server.tool("relay_task_move", "Move a Kanban card as you progress: todo -> doin
250
250
  }
251
251
  }
252
252
  } catch { /* the flag must never break a move */ }
253
- await api("POST", "/task/update", { id, status, note: outNote, by: SESSION, ...(blast ? { blast } : {}) });
253
+ try {
254
+ await api("POST", "/task/update", { id, status, note: outNote, drill, by: SESSION, ...(blast ? { blast } : {}) });
255
+ } catch (error) {
256
+ // #6452: a refused move is the teaching moment — say why the hub said no (no drill line,
257
+ // frozen assignee), not "hub 409".
258
+ if (error?.status === 409) return { content: [{ type: "text", text: `REFUSED: card #${id} stays where it is — ${error.hubError || error.message}` }], isError: true };
259
+ throw error;
260
+ }
254
261
  return { content: [{ type: "text", text: `card #${id} -> ${status}` }] };
255
262
  });
256
263
 
@@ -374,7 +381,10 @@ function cardView(tasks, id, proj) {
374
381
  const card = tasks.find(t => t.id === id);
375
382
  if (!card) return `${proj}: no card #${id}`;
376
383
  const out = [`#${card.id} ${card.title}`,
377
- `status: ${card.status}${card.assignee ? ` · @${card.assignee}` : ""}${card.difficulty ? ` · ${card.difficulty}` : ""}${card.model ? ` · ${card.model}` : ""}`];
384
+ `status: ${card.status}${card.assignee ? ` · @${card.assignee}` : ""}${card.difficulty ? ` · ${card.difficulty}` : ""}${card.model ? ` · ${card.model}` : ""}`,
385
+ // #6452: the drill is what a person does on the built artifact to see this card's work; the
386
+ // hub refuses done without one, so a card view says so up front.
387
+ card.drill ? `drill: ${card.drill}` : `drill: (none on the card — the hub refuses a move to done until a drill line exists: relay_task_move with \`drill\`, a checklist item or a note starting "Drill:")`];
378
388
 
379
389
  const deps = (Array.isArray(card.deps) ? card.deps : []).map(d => {
380
390
  const t = tasks.find(x => x.id === d);
@@ -430,7 +440,7 @@ server.tool("relay_board", "Show a project's Kanban board (all cards + their sta
430
440
  if (!tasks.length) return { content: [{ type: "text", text: `${proj}: no cards yet` }] };
431
441
  if (card) return { content: [{ type: "text", text: cardView(tasks, card, proj) }] };
432
442
  const by = { todo: [], doing: [], testing: [], failed: [], done: [], blocked: [] };
433
- for (const t of tasks) (by[t.status] || by.todo).push(`#${t.id} ${t.title}${t.assignee ? ` (@${t.assignee})` : ""}${noteCount(t) ? ` ·${noteCount(t)}` : ""}`);
443
+ for (const t of tasks) (by[t.status] || by.todo).push(`#${t.id} ${t.title}${t.assignee ? ` (@${t.assignee})` : ""}${noteCount(t) ? ` ·${noteCount(t)}` : ""}${t.drill ? " ·drill" : ""}`);
434
444
  const cols = Object.entries(by).filter(([, v]) => v.length).map(([k, v]) => `${k.toUpperCase()}:\n ${v.join("\n ")}`);
435
445
  return { content: [{ type: "text", text: `${proj} board\n${cols.join("\n")}` }] };
436
446
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.62",
3
+ "version": "0.18.63",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
@@ -28,7 +28,8 @@
28
28
  ".claude-plugin/",
29
29
  "README.md",
30
30
  "LICENSE",
31
- "engine/"
31
+ "engine/",
32
+ "docs/BUILD-DOCTRINE.md"
32
33
  ],
33
34
  "repository": {
34
35
  "type": "git",
@@ -36,9 +36,14 @@ explicit EVENT/INTERFACE CONTRACT — cross-agent bugs come from contract drift.
36
36
  from the brief, hooks, hub brief + first card) — it never spawns a session; firing the crew
37
37
  is this phase's job.
38
38
  1. `relay_project_brief("<what + why + goal>")`
39
- 2. One card per package: `relay_task_add(title, assignee, difficulty, model)` — set `model`
40
- to the advisor-routed model (or the CLI's default name); difficulty + model show as badges
41
- on the card. Assignees: `codex:<project>` etc. Keep one for yourself.
39
+ 2. One card per package: `relay_task_add(title, assignee, difficulty, model, drill)` — set
40
+ `model` to the advisor-routed model (or the CLI's default name); difficulty + model show as
41
+ badges on the card. Assignees: `codex:<project>` etc. Keep one for yourself.
42
+ **Every card names its drill** (build doctrine rule 1): `drill` is the exact thing a person
43
+ does on the built artifact and what they must see ("open Settings, toggle X, the badge turns
44
+ green"), not a test command. A card without a drill line is not ready to be worked, and the
45
+ hub refuses to move it to done: `/task/update` answers 409 unless the card carries a `drill`,
46
+ a checklist item or a note starting with `Drill:`. Cutting a card without one is a defect.
42
47
  3. Open the dashboard: **`trantor ui`** — which opens the **desktop app**, not a browser.
43
48
  Do NOT open the hub URL in a browser. A remote hub runs `auth:enforce`, and a browser cannot
44
49
  sign its requests: the page loads but `/projects`, `/tasks` and `/peers` all return 401, so the
@@ -150,10 +155,16 @@ orchestrator runs the full suite at integration) plus `node bin/slop-gate.mjs` w
150
155
  one (the anti-slop lint over the seat's changed files — a card must not reach done failing it);
151
156
  `done` only green, and moves to testing/done carry a `note` with the evidence — the note is the
152
157
  card's permanent story; `failed` (+ bus report) pulses red on the board until you bounce it.
153
- Enforce the gate — bounce anything that skipped it (bounces are visible: "↩ bounced" on the
154
- card, history in its tooltip). When all report done: integrate, fix contract mismatches
155
- YOURSELF, move your card through testing → done, broadcast "🚀 <thing> is live", and when the
156
- user is finished: `trantor down`.
158
+ **The seat that wrote the code never closes its own card to done.** `testing` is the seat's
159
+ last move; `done` is yours, after you ran the card's drill on the built artifact (the installed
160
+ app, the live hub, the real CLI) and wrote the result on the card. Say so in every contract:
161
+ "move to testing with the evidence, then stop; the orchestrator runs the drill and closes". The
162
+ hub enforces the drill half: a move to done on a card with no drill line is refused (409), your
163
+ own moves included. Enforce the rest — bounce anything that skipped the gate (bounces are
164
+ visible: "↩ bounced" on the card, history in its tooltip). When all report testing: run each
165
+ drill, close the cards that pass, bounce the rest, integrate, fix contract mismatches
166
+ YOURSELF, move your own card through testing → done (its drill run and noted, same as any
167
+ other), broadcast "🚀 <thing> is live", and when the user is finished: `trantor down`.
157
168
 
158
169
  ## Rules
159
170
  - Coordinate ONLY over the bus; messages <280 chars; the dashboard lanes are the user's view.