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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/bridge.mjs +1 -0
- package/bin/cli.mjs +2 -0
- package/bin/crew/core.mjs +6 -8
- package/bin/crew-payload.mjs +13 -2
- package/bin/crew-runner.mjs +53 -10
- package/bin/doctor.mjs +12 -0
- package/bin/drill-report.mjs +1 -1
- package/bin/drill-report.test.mjs +1 -0
- package/bin/reconcile.mjs +22 -4
- package/bin/retire.mjs +67 -0
- package/bin/state-bench.mjs +46 -6
- package/bin/turn-watchdog.mjs +35 -23
- package/docs/BUILD-DOCTRINE.md +117 -0
- package/engine/bin/scrooge +10 -2
- package/hooks/handoff-now.mjs +9 -2
- package/hooks/lib/handoff.mjs +46 -14
- package/hooks/sessionstart.mjs +25 -0
- package/hub/routes/cards.mjs +9 -1
- package/hub/store.mjs +22 -6
- package/lib/autonomy.mjs +2 -1
- package/lib/decode.mjs +13 -0
- package/lib/identity.mjs +4 -2
- package/lib/model-catalog.mjs +8 -19
- package/lib/project.mjs +35 -10
- package/lib/retire-panes.mjs +205 -0
- package/lib/seat-record.mjs +3 -14
- package/lib/seat-why.mjs +4 -3
- package/lib/seats.mjs +15 -11
- package/lib/state/derive.mjs +6 -0
- package/lib/state/driver.mjs +35 -11
- package/lib/state/flags.mjs +26 -0
- package/lib/state/store.mjs +20 -0
- package/lib/state/validate.mjs +40 -19
- package/lib/subagent-manifest.mjs +2 -1
- package/lib/subagent-scan.mjs +0 -0
- package/mcp.mjs +20 -10
- package/package.json +3 -2
- package/skills/crew/SKILL.md +18 -7
- package/skills/prd-review/SKILL.md +12 -3
- package/ui.html +6 -3
|
@@ -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.
|
package/engine/bin/scrooge
CHANGED
|
@@ -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
|
-
|
|
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")
|
package/hooks/handoff-now.mjs
CHANGED
|
@@ -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
|
-
|
|
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`);
|
package/hooks/lib/handoff.mjs
CHANGED
|
@@ -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 (
|
|
744
|
-
if (
|
|
745
|
-
// #
|
|
746
|
-
//
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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 (
|
|
753
|
-
|
|
754
|
-
|
|
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)) {
|
|
783
|
+
if (!existsSync(script)) { _log(`[trantor] handoff-prompt.sh missing\n`); return false; }
|
|
758
784
|
const timeout = String(conf.handoffPromptTimeout || 25);
|
|
759
|
-
|
|
760
|
-
|
|
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
|
}
|
package/hooks/sessionstart.mjs
CHANGED
|
@@ -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 {}
|
package/hub/routes/cards.mjs
CHANGED
|
@@ -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`
|
|
110
|
-
//
|
|
111
|
-
//
|
|
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
|
|
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(
|
|
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
|
|
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" };
|
package/lib/model-catalog.mjs
CHANGED
|
@@ -1,17 +1,9 @@
|
|
|
1
|
-
// lib/model-catalog.mjs — the declarative model catalog (
|
|
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
|
|
26
|
-
if (!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
|
|
82
|
+
return { found: true, agent, model: entry.id, difficulty, api, params: asRecord(params) ?? {} };
|
|
91
83
|
}
|
|
92
84
|
|
|
93
|
-
//
|
|
94
|
-
//
|
|
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
|
|
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
|
|
207
|
-
if (
|
|
208
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
}
|