trantor 0.18.62 → 0.18.64

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.
@@ -0,0 +1,117 @@
1
+ # Build doctrine
2
+
3
+ How Trantor is built, and how every project run through Trantor is built. These are rules with
4
+ a gate each, not advice. An orchestrator that cannot show the gate has not followed the rule.
5
+ Operator ruling 2026-09-04, after a week in which the code was heavily tested and still broke on
6
+ the real path four times.
7
+
8
+ ## 1. The real path is the gate
9
+
10
+ - Every card names its drill: the exact thing a person does on the built artifact and what they
11
+ must see. A card without a drill line is not ready to be worked.
12
+ - Nothing merges until the orchestrator has run that drill on the built artifact (the installed
13
+ app, the live hub, the real CLI on this machine) and written the result on the card.
14
+ - Unit tests are necessary and never sufficient. "All tests green" is not evidence that the
15
+ operator will see the feature work.
16
+ - The seat that wrote the code never closes its own card to done. Testing is the seat's last
17
+ move; done is the orchestrator's, after the drill.
18
+
19
+ ## 2. Red blocks merge
20
+
21
+ - The whole suite runs on every push in CI. A red suite blocks merge, no exceptions.
22
+ - A suite red for more than one day is an incident with a card, not debt to be gated around.
23
+ - A flaky test is fixed or deleted within a week. Timing drills live in a quarantine lane that
24
+ cannot hide a real failure.
25
+ - Tests never depend on the runner's own environment (identity badge, pane id, project). A test
26
+ that inherits the badge and passes is lying.
27
+
28
+ ## 3. One owner per subsystem
29
+
30
+ - Every subsystem (runner, hub, hooks, crew launcher, desktop shell, chat, code, board) has one
31
+ named owner per wave. The owner reviews every change to it and may say no.
32
+ - A cross-seat edit to a subsystem goes through its owner over the bus before it lands.
33
+ - The orchestrator owns the gates and the merges. No seat merges to main.
34
+
35
+ ## 4. Causes, not symptoms
36
+
37
+ - A fix names its cause in the commit message and adds the drill that fails without the fix.
38
+ "Made X not happen" without a why is bounced.
39
+ - A second fix in the same seam within a week stops the line: the seam gets a contract document
40
+ (docs/CONTRACT-*.md) reviewed by the orchestrator before more code lands there.
41
+ - Trace first. A bug report becomes a card only after the orchestrator has the trace, log, or
42
+ crash report line that shows the mechanism. Guessing is not a plan.
43
+
44
+ ## 5. Fewer parts
45
+
46
+ - Prefer deleting to patching. A subsystem that has needed three fixes in a month is a candidate
47
+ for removal, not a fourth fix.
48
+ - No hand-rolled protocol, transport, sync, or auth layer when a maintained library does the job.
49
+ If the library is installed, use it the way it is meant to be used; do not bypass it.
50
+ - Look at the reference product first (the Orca rule). Adopt its shape, then adapt. Inventing is
51
+ the last option and needs a stated reason.
52
+
53
+ ## 6. Shape limits
54
+
55
+ - A source file is at most 800 lines (Rust 1,000). A function is at most 80 lines. Past the
56
+ limit, split by feature before adding to it.
57
+ - One language per layer. A shell script that parses JSON becomes a Node program.
58
+ - A module has one reason to change. A file named after a card, a date, or a person is wrong.
59
+
60
+ ## 7. Comments and records
61
+
62
+ - A code comment is one line of why. The incident story lives on the card or in docs, and the
63
+ comment links to it by number.
64
+ - Contracts live in docs/CONTRACT-*.md and are the source of truth for a seam. Code that
65
+ contradicts a contract is the bug, whichever came second.
66
+ - Memory records decisions and traps, not code structure. The repo records code.
67
+
68
+ ## 8. Rust and native boundaries
69
+
70
+ - No `unwrap` or `expect` outside tests. Clippy denies `unwrap_used` and `expect_used`.
71
+ - Every callback boundary into native code (extern "C", event handlers, window callbacks)
72
+ catches panics and logs them. A panic there aborts the app, and the crash report will not say
73
+ why.
74
+ - A patched or vendored dependency carries the upstream issue link and an expiry date in its
75
+ directory. Past the expiry, it is re-evaluated, not kept by default.
76
+
77
+ ## 9. Dependencies and releases
78
+
79
+ - Dependencies are pinned. An upgrade is a card with a drill, never a side effect.
80
+ - A release is: version bump, build, install on the operator's machine, drill, card note, memory
81
+ line. Published is not shipped; installed and drilled is shipped.
82
+ - Every substantive change reports with the four evidence blocks: code written, builds (command
83
+ and exit code), tested (command and counts), observed (the drill result). A report missing one
84
+ says which and why.
85
+
86
+ ## 10. Turn economy
87
+
88
+ - A seat's turn ends with a commit. Work that is not committed does not exist.
89
+ - A contract names the card, the files, the gate, and the drill. It never says "look into".
90
+ - No acks over the bus. A message either carries a contract, a bounce, a delivery, or a question
91
+ that blocks work. Everything else is batched or not sent.
92
+ - A seat that exits non-zero twice on one contract parks. The orchestrator reads why before
93
+ anything is redelivered.
94
+
95
+ ## 11. Anything that warns
96
+
97
+ The monitoring doctrine applies: state, not event; episodes, not timers; never warn about what
98
+ the operator declared; report duration, not repetition; quiet is not dead; every wake costs a
99
+ turn.
100
+
101
+ ## 12. Seats never touch the operator's live surfaces
102
+
103
+ - A seat never launches, quits, installs or replaces the installed app; never drives the running
104
+ app's UI; never logs the operator into or out of any provider; never runs `trantor up`, `down`
105
+ or `open` for any seat, its own included. These are the operator's surfaces and the
106
+ orchestrator's hands.
107
+ - A seat's real-path evidence ends at: tests green, a build that exits 0 from its own worktree,
108
+ and a note naming the drill. The orchestrator runs the drill, with the operator when it touches
109
+ a live account.
110
+ - A seat dispatches no sessions of its own outside its worktree. Work that leaves the worktree
111
+ does not exist.
112
+
113
+ ## 13. Audit
114
+
115
+ A project is audited against this document before its next wave: file shape, test tree and CI,
116
+ owners, real-path drills, dependency pins, comment policy. The audit produces a scorecard and a
117
+ consolidation phase, and the wave waits for the phase where the scorecard says so.
@@ -553,14 +553,16 @@ def cmd_call(reg, args):
553
553
  try:
554
554
  resp = http_post(url, headers, payload)
555
555
  except urllib.error.HTTPError as e:
556
- body = e.read().decode(errors="replace")[:500]
556
+ # ONE stderr line naming the status and the model (#6893): a provider body with
557
+ # newlines must not smear the ✗ across the caller's log — collapse, then clamp.
558
+ body = " ".join(e.read().decode(errors="replace").split())[:500]
557
559
  err(RED("🪙 scrooge ✗ %s/%s HTTP %s: %s" % (provider, model, e.code, body)))
558
560
  append_ledger({"ts": int(t0), "provider": provider, "model": model, "task": args.task,
559
561
  "project": proj, "cwd": cwd,
560
562
  "ok": False, "error": "HTTP %s" % e.code, "duration_ms": int((time.time()-t0)*1000)})
561
563
  raise SystemExit(2)
562
564
  except Exception as e:
563
- err(RED("🪙 scrooge ✗ %s/%s: %s" % (provider, model, e)))
565
+ err(RED("🪙 scrooge ✗ %s/%s: %s" % (provider, model, " ".join(str(e).split()))))
564
566
  append_ledger({"ts": int(t0), "provider": provider, "model": model, "task": args.task,
565
567
  "project": proj, "cwd": cwd,
566
568
  "ok": False, "error": str(e), "duration_ms": int((time.time()-t0)*1000)})
@@ -586,6 +588,12 @@ def cmd_call(reg, args):
586
588
  "prompt_preview": preview})
587
589
  err(ORANGE("🪙 scrooge ✓ %s/%s · %d→%d tok · ~$%.5f · %.1fs%s" %
588
590
  (provider, model, tin, tout, c, dt, (" · ledger#%d" % line_no) if line_no else "")))
591
+ # A genuinely empty model answer is a SUCCESS the caller must be able to tell from a
592
+ # failed call (#6893): exit 0, stdout stays EMPTY (not even a newline), and stderr
593
+ # carries the "empty answer" note — the ✗/exit-2 path is the only other possibility.
594
+ if not text.strip():
595
+ err(DIM("🪙 scrooge · empty answer"))
596
+ return
589
597
  sys.stdout.write(text)
590
598
  if not text.endswith("\n"):
591
599
  sys.stdout.write("\n")
@@ -14,11 +14,18 @@ try {
14
14
  const { file, record } = result;
15
15
  process.stderr.write(`[trantor] baton handoff written: ${file}\n`);
16
16
  await pingBus(basename(projectDir), record.id, conf);
17
- if (maybeSpawn(projectDir, conf)) { // open the fresh session that takes over
17
+ // The handoff file goes in: a pane baton cannot be driven without it (#8089).
18
+ if (maybeSpawn(projectDir, conf, file)) { // open the fresh session that takes over
18
19
  // AUTO baton: close the original ONLY when config.autoCloseOriginal is true; an auto-close must
19
- // never kill an in-flight session, so the default leaves the original alive.
20
+ // never kill an in-flight session, so the default leaves the original alive. A pane baton
21
+ // replaces its own pane and resolves no window, so there is nothing here to arm.
20
22
  const armed = windowId ? armBatonClose(file, windowId, tty, conf, { auto: true }) : false;
21
23
  process.stderr.write(`[trantor] fresh session spawned${armed ? ` · baton-close armed for window ${windowId}` : " · original window left alive (auto-close off by default)"}\n`);
24
+ } else {
25
+ // #8089's second half: this used to be an `if` with no `else`, so a declined spawn printed
26
+ // NOTHING. The record was written, no successor came, and the only evidence was a handoff stuck
27
+ // at `written`. A path that decides not to act still has to say so.
28
+ process.stderr.write(`[trantor] NO successor session opened for ${basename(projectDir)} — handoff ${basename(file)} is written but unclaimed; open one to take over\n`);
22
29
  }
23
30
  } catch (e) {
24
31
  process.stderr.write(`[trantor] handoff-now error: ${e?.message || e}\n`);
@@ -738,26 +738,58 @@ export async function pingBus(projectName, id, conf = readConfig()) {
738
738
  // Spawn a fresh same-agent session (macOS) that takes over via the handoff.
739
739
  // Default = ON (prompt with a timeout, default button "Open fresh session").
740
740
  // Disable with config.autoHandoffPrompt:false or env TRANTOR_NO_HANDOFF_SPAWN=1.
741
- export function maybeSpawn(projectDir, conf = readConfig()) {
741
+ export function maybeSpawn(projectDir, conf = readConfig(), handoffFile = "", deps = {}) {
742
+ // Injection points, for the same reason spawnBaton has them: "A DRILL MUST BE ABLE TO SAY NO —
743
+ // a path that spawns windows needs an off switch or it cannot be tested honestly." maybeSpawn had
744
+ // none, so its pane branch was never drilled, and that is exactly where #8089 lived for weeks.
745
+ const _pane = deps.paneSurfaceEnv || paneSurfaceEnv;
746
+ const _spawnPane = deps.spawnPaneBaton || spawnPaneBaton;
747
+ const _hasPane = deps.hasOrchPane || hasOrchPane;
748
+ const _platform = deps.platform || process.platform;
749
+ const _env = deps.env || process.env;
750
+ const _log = deps.log || ((s) => process.stderr.write(s));
742
751
  try {
743
- if (process.platform !== "darwin") return false;
744
- if (process.env.TRANTOR_NO_HANDOFF_SPAWN === "1") return false;
745
- // #6074: a session in a hosted pane never gets a Terminal window — the pane is the successor
746
- // surface, and the pane claims the handoff (trantor open) on its own.
747
- if (paneSurfaceEnv()) {
748
- process.stderr.write(`[trantor] session lives in herdr pane ${paneSurfaceEnv()} — no Terminal window; the pane claims the handoff\n`);
749
- return false;
752
+ if (_platform !== "darwin") return false;
753
+ if (_env.TRANTOR_NO_HANDOFF_SPAWN === "1") return false;
754
+ // #8089: a pane session gets NO Terminal window — but it does get a successor. This used to
755
+ // return false on the theory that "the pane claims the handoff (trantor open) on its own",
756
+ // and for an ARMED baton nothing was driving that: /trantor:handoff always runs inside a turn,
757
+ // so it always arms, so this is always the path taken — and spawnPaneBaton, which the direct
758
+ // path (spawnBaton) calls right here, was never reached. The record was written and the session
759
+ // sat there. Witnessed on crebral-health 2026-09-19: written 00:18:58, unclaimed, original alive.
760
+ const paneId = _pane(_env);
761
+ if (paneId) {
762
+ if (!handoffFile) {
763
+ _log(`[trantor] pane ${paneId} needs the handoff file to pass the baton and none was given — no successor opened\n`);
764
+ return false;
765
+ }
766
+ const ok = _spawnPane(projectDir, handoffFile, paneId);
767
+ _log(`[trantor] herdr pane ${paneId}: ${ok ? "baton driver spawned — it replaces this pane in place" : "baton driver FAILED to spawn — no successor"}\n`);
768
+ return ok;
750
769
  }
751
770
  if (conf.autoHandoffPrompt === false) return false;
752
- if (hasOrchPane(basename(projectDir))) {
753
- process.stderr.write(`[trantor] orch pane hosts ${basename(projectDir)} — no Terminal window; the pane claims the handoff on its next open\n`);
754
- return false;
771
+ if (_hasPane(basename(projectDir))) {
772
+ // Same correction as above for the cwd-keyed pane: drive the replacement, do not assume
773
+ // something else will. Without HERDR_PANE_ID the driver resolves the pane from crew-windows.
774
+ if (!handoffFile) {
775
+ _log(`[trantor] orch pane hosts ${basename(projectDir)} but no handoff file was given — no successor opened\n`);
776
+ return false;
777
+ }
778
+ const ok = _spawnPane(projectDir, handoffFile);
779
+ _log(`[trantor] orch pane hosts ${basename(projectDir)}: ${ok ? "baton driver spawned — it replaces the pane in place" : "baton driver FAILED to spawn — no successor"}\n`);
780
+ return ok;
755
781
  }
756
782
  const script = join(HERE, "..", "..", "bin", "handoff-prompt.sh");
757
- if (!existsSync(script)) { process.stderr.write(`[trantor] handoff-prompt.sh missing\n`); return false; }
783
+ if (!existsSync(script)) { _log(`[trantor] handoff-prompt.sh missing\n`); return false; }
758
784
  const timeout = String(conf.handoffPromptTimeout || 25);
759
- const child = spawn("/bin/bash", [script, projectDir, timeout], { detached: true, stdio: "ignore" });
760
- child.unref();
785
+ // Injectable for the same reason the pane legs are: this line opens a REAL Terminal window, and
786
+ // a drill that reaches it opens one per run. That is not hypothetical — test-pane-baton-spawn's
787
+ // "no pane" case fell through to here and opened a window on every `npm test`, with a comment
788
+ // above it claiming the drill did not exercise this leg. Four of them were sitting on the
789
+ // operator's desktop before anyone noticed, and only a non-existent fixture path stopped each
790
+ // one from starting a live billable session.
791
+ const child = (deps.spawnPrompt || spawn)("/bin/bash", [script, projectDir, timeout], { detached: true, stdio: "ignore" });
792
+ if (child && typeof child.unref === "function") child.unref();
761
793
  return true;
762
794
  } catch (e) { process.stderr.write(`[trantor] maybeSpawn error: ${e?.message}\n`); return false; }
763
795
  }
@@ -16,6 +16,30 @@ import { getJSON, signedGet, signedPost, loadIdentity } from "./lib/api.mjs";
16
16
  import { ledgerPaths, ensureStart, anchorCursor, writeCursor } from "./lib/inbox-ledger.mjs";
17
17
  import { ensureEnrolled } from "../lib/enroll.mjs";
18
18
 
19
+ // The build doctrine (docs/BUILD-DOCTRINE.md) in short form, one line per rule, injected for every
20
+ // project's ORCHESTRATOR at boot (#6452): orchestrators that never opened the doc missed the ruling.
21
+ // test/hooks/test.mjs checks every heading of the doc has a line here, so the two cannot drift.
22
+ function buildDoctrineShortForm() {
23
+ const doc = join(dirname(dirname(fileURLToPath(import.meta.url))), "docs", "BUILD-DOCTRINE.md");
24
+ return `<trantor-build-doctrine>\n` +
25
+ `📐 **Build doctrine** (operator ruling 2026-09-04; rules with a gate each, not advice). Full text: ${doc}\n` +
26
+ `1. The real path is the gate — every card names its DRILL (what a person does on the built artifact and must see); nothing merges until you ran it and wrote the result on the card; unit tests are never sufficient; the seat that wrote the code never closes its own card to done (testing is the seat's last move, done is yours after the drill).\n` +
27
+ `2. Red blocks merge — a red suite blocks merge; red for a day is an incident with a card; a flaky test is fixed or deleted within a week; no test inherits the runner's identity.\n` +
28
+ `3. One owner per subsystem — one named owner per wave who reviews every change; cross-seat edits go through the owner over the bus; you own the gates and merges, no seat merges to main.\n` +
29
+ `4. Causes, not symptoms — a fix names its cause in the commit and adds the drill that fails without it; a second fix in one seam within a week stops the line for a docs/CONTRACT-*.md; trace first, a bug becomes a card only with the mechanism in hand.\n` +
30
+ `5. Fewer parts — prefer deleting to patching (three fixes in a month = removal candidate); no hand-rolled protocol/transport/sync/auth when a maintained library does it; look at the reference product first (the Orca rule).\n` +
31
+ `6. Shape limits — a file is at most 800 lines (Rust 1,000), a function at most 80; one language per layer; a module has one reason to change and is never named after a card, date or person.\n` +
32
+ `7. Comments and records — a code comment is one line of why linking the card; contracts live in docs/CONTRACT-*.md and code that contradicts one is the bug; memory records decisions and traps, the repo records code.\n` +
33
+ `8. Rust and native boundaries — no unwrap/expect outside tests (clippy denies them); every callback into native code catches and logs panics; a patched/vendored dependency carries its upstream link and an expiry date.\n` +
34
+ `9. Dependencies and releases — dependencies are pinned and an upgrade is a card with a drill; a release is bump, build, install on the operator's machine, drill, card note, memory line (published is not shipped); every report carries the four evidence blocks: code, build, tests, observed.\n` +
35
+ `10. Turn economy — a seat's turn ends with a commit (uncommitted work does not exist); a contract names card, files, gate and drill, never "look into"; no acks over the bus; a seat that exits non-zero twice on one contract parks.\n` +
36
+ `11. Anything that warns — the monitoring doctrine: state not event, episodes not timers, never warn about what the operator declared, report duration not repetition, quiet is not dead, every wake costs a turn.\n` +
37
+ `12. Seats never touch the operator's live surfaces — a seat never launches/quits/installs the app, drives its UI, logs into providers, or runs trantor up/down/open; its evidence ends at tests green, a build from its worktree and a note naming the drill; you run the drill.\n` +
38
+ `13. Audit — a project is audited against the doctrine before its next wave (file shape, tests and CI, owners, drills, pins, comments); the wave waits for the consolidation phase the scorecard demands.\n` +
39
+ `Mechanics: relay_task_add has a \`drill\` field; the hub refuses any move to done, yours included, when the card carries no drill line.\n` +
40
+ `</trantor-build-doctrine>\n`;
41
+ }
42
+
19
43
  // Load the most recent UNCONSUMED handoff for this project; `claim` marks it consumed so exactly one
20
44
  // session takes it. A compaction-triggered start may show it but must NOT claim it from the new window.
21
45
  function loadPendingHandoff(projectName, { claim = true, freshSession = null } = {}) {
@@ -366,6 +390,7 @@ try {
366
390
  `- Check relay_inbox and the board before asking the operator anything a peer may already have answered.\n` +
367
391
  `- Dispatch rule: the target project is confirmed from the session's badge and cwd before any relay_send, relay_task_add or \`trantor up\` — an ambiguous instruction is not a project name; and a session asking the operator a question never triggers a wake (its messages batch until the answer).\n` +
368
392
  `</trantor-orchestrator-role>\n`;
393
+ additionalContext += buildDoctrineShortForm();
369
394
  process.stderr.write(`[trantor] injected orchestrator-role doctrine for ${project}\n`);
370
395
  }
371
396
  } catch {}
@@ -26,7 +26,7 @@ export async function routeCards({ req, res, q, P, auth, ctx }) {
26
26
  const {
27
27
  state, body, json, crossProjectGuard, touch, canon, filterReadable,
28
28
  appendEvent, appendCardEvent, now, markDirty, stripNulText,
29
- appendTaskLog, appendTaskNote, cleanChecklist, linkCommitToFocus,
29
+ appendTaskLog, appendTaskNote, cleanChecklist, cleanDrill, hasDrillLine, linkCommitToFocus,
30
30
  derivePhases, PROPOSAL_CAP, propFp, healthOf, REAP_GRACE_MS, subFp,
31
31
  prunePeers, canRead, HUB_VERSION, cmpSemver, isCardEvent, fmtAge, hubSend,
32
32
  ONLINE_MS,
@@ -177,6 +177,7 @@ export async function routeCards({ req, res, q, P, auth, ctx }) {
177
177
  by: b.by || "", ts: ts0, updated: ts0,
178
178
  history: [{ to: st0, by: b.by || "", ts: ts0 }] };
179
179
  { const cl = cleanChecklist(b.checklist); if (cl?.length) t.checklist = cl; } // #5624 — rides `extra`, survives restarts
180
+ { const dr = cleanDrill(b.drill); if (dr) t.drill = dr; } // #6452 — the card's drill line, rides `extra`
180
181
  if (b.source === "cc-subagent") { t._fp = subFp(b.title); if (b.agentType) t._atype = String(b.agentType).slice(0, 40); if (b.agentId) t._aid = String(b.agentId).slice(0, 80); if (b.parent) t.parent = String(b.parent).slice(0, 120); t.count = 1; if (t.status === "doing") { t._everStarted = true; t._inflight = 1; } }
181
182
  appendTaskNote(t, b, ts0);
182
183
  state.tasks.push(t); if (state.tasks.length > 2000) state.tasks.splice(0, 500);
@@ -210,6 +211,12 @@ export async function routeCards({ req, res, q, P, auth, ctx }) {
210
211
  }
211
212
  }
212
213
  }
214
+ // The done gate (#6452, build doctrine rule 1): done only with a drill line (store.hasDrillLine),
215
+ // whoever moves it, the orchestrator included. Runs BEFORE any mutation so a refused close leaves
216
+ // the card as it was; a bridge mirror replicates a status its source hub already gated.
217
+ if (b.status === "done" && t.status !== "done" && t.source !== "bridge" && !hasDrillLine(t, b)) {
218
+ return json(res, 409, { error: `no drill line on card #${t.id}: a card names what a person does on the built artifact and must see before it can be done — set \`drill\`, add a checklist item or a note starting with "Drill:", or close with a note naming the gate command you ran (build doctrine rule 1)`, id: t.id, status: t.status });
219
+ }
213
220
  let eventType = "updated", eventFrom = null, eventTo = null;
214
221
  if (b.status && ["todo","doing","testing","failed","done","blocked","stale"].includes(b.status) && b.status !== t.status) {
215
222
  eventType = "moved"; eventFrom = t.status; eventTo = b.status;
@@ -238,6 +245,7 @@ export async function routeCards({ req, res, q, P, auth, ctx }) {
238
245
  // the narrative line a human reads on the board ("assigned — did"), written by the cheap
239
246
  // summarizer; rides the tasks.extra column, so it survives restarts everywhere
240
247
  if (b.summary !== undefined) t.summary = String(b.summary).slice(0, 220);
248
+ if (b.drill !== undefined) { const dr = cleanDrill(b.drill); if (dr) t.drill = dr; else delete t.drill; } // #6452: set or clear the drill line
241
249
  // #5624: full checklist replace (null clears). Item-level toggles ride /task/checklist-toggle.
242
250
  if (b.checklist !== undefined) {
243
251
  const cl = cleanChecklist(b.checklist);
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 {
@@ -189,10 +190,30 @@ export function writeOrchSession(project, sid, by = "unknown") {
189
190
  return true;
190
191
  } catch { return false; }
191
192
  }
193
+ // Drop a project's row from the map. A session the map does not name cannot be resolved as a wake
194
+ // recipient (bin/wake-nudge.mjs), which is how a retired pane stops buying turns (#8017).
195
+ export function clearOrchSession(project, by = "unknown") {
196
+ try {
197
+ if (!project) return false;
198
+ const p = orchSessionsPath();
199
+ if (!existsSync(p)) return false;
200
+ const rows = readFileSync(p, "utf8").split("\n").filter(Boolean);
201
+ const prev = rows.find(r => r.split("\t")[0] === project)?.split("\t")[1] || "";
202
+ if (!prev) return false;
203
+ const kept = rows.filter(r => r.split("\t")[0] !== project);
204
+ writeFileSync(p, kept.length ? kept.join("\n") + "\n" : "");
205
+ try {
206
+ appendFileSync(join(busDir(), "orch-sessions.log"),
207
+ `${new Date().toISOString()}\t${project}\t${prev}\t-\t${by}\n`);
208
+ } catch {}
209
+ return true;
210
+ } catch { return false; }
211
+ }
212
+
192
213
  function configPath() { return join(busDir(), "config.json"); }
193
214
 
194
215
  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 {}
216
+ try { const c = configPath(); if (existsSync(c)) { const j = asRecord(JSON.parse(readFileSync(c, "utf8"))); if (j) return j; } } catch {}
196
217
  return {};
197
218
  }
198
219
 
@@ -203,9 +224,10 @@ export function resolveHubInfo(project, env = process.env) {
203
224
  if (env.RELAY_URL) return { url: env.RELAY_URL, via: "env" };
204
225
  const cfg = readConfig();
205
226
  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" };
227
+ const pinned = asString(cfg?.hubs?.[name]);
228
+ if (pinned) return { url: pinned, via: "pin" };
229
+ const globalUrl = asString(cfg?.url);
230
+ if (globalUrl) return { url: globalUrl, via: "global" };
209
231
  } catch {}
210
232
  return { url: DEFAULT_HUB_URL, via: "default" };
211
233
  }
@@ -217,7 +239,7 @@ export function resolveHub(project, env = process.env) {
217
239
  // Every project the operator has deliberately pinned — the "expected one of these" list a
218
240
  // misplaced session needs in order to fix itself.
219
241
  export function knownProjects() {
220
- try { const h = readConfig()?.hubs; return h && typeof h === "object" ? Object.keys(h).sort() : []; } catch { return []; }
242
+ try { return Object.keys(asRecord(readConfig()?.hubs) ?? {}).sort(); } catch { return []; }
221
243
  }
222
244
 
223
245
  // How many IMMEDIATE children of `dir` are git repos. Bounded (first 200 entries) and fail-safe:
@@ -272,10 +294,12 @@ function writeConfig(cfg) {
272
294
  // Pin a project to a hub. URL must be absolute http(s); trailing slash stripped so
273
295
  // `${hub}/path` concatenation never double-slashes.
274
296
  export function setProjectHub(project, url) {
275
- if (!project || typeof project !== "string") throw new Error("project required");
297
+ if (!asString(project)) throw new Error("project required");
276
298
  if (!/^https?:\/\//.test(String(url || ""))) throw new Error("url must start with http:// or https://");
277
299
  const cfg = readConfig();
278
- cfg.hubs = { ...(cfg.hubs && typeof cfg.hubs === "object" ? cfg.hubs : {}), [project]: String(url).replace(/\/+$/, "") };
300
+ const hubs = { ...(asRecord(cfg.hubs) ?? {}) };
301
+ hubs[project] = String(url).replace(/\/+$/, "");
302
+ cfg.hubs = hubs;
279
303
  writeConfig(cfg);
280
304
  }
281
305
 
@@ -283,9 +307,10 @@ export function setProjectHub(project, url) {
283
307
  // a mapping existed.
284
308
  export function unsetProjectHub(project) {
285
309
  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;
310
+ const hubs = asRecord(cfg.hubs);
311
+ if (!hubs || !(project in hubs)) return false;
312
+ delete hubs[project];
313
+ if (!Object.keys(hubs).length) delete cfg.hubs;
289
314
  writeConfig(cfg);
290
315
  return true;
291
316
  }