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/.claude-plugin/plugin.json +1 -1
- package/bin/bridge.mjs +1 -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/turn-watchdog.mjs +35 -23
- package/docs/BUILD-DOCTRINE.md +117 -0
- 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 +15 -10
- package/lib/seat-record.mjs +3 -14
- package/lib/seat-why.mjs +4 -3
- package/lib/seats.mjs +15 -11
- package/lib/state/driver.mjs +20 -6
- package/lib/state/flags.mjs +26 -0
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.63",
|
|
4
4
|
"description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
|
|
5
5
|
"mcpServers": {
|
|
6
6
|
"relay": {
|
package/bin/bridge.mjs
CHANGED
|
@@ -62,6 +62,7 @@ const saveMap = () => { try { mkdirSync(dirname(MAPFILE), { recursive: true });
|
|
|
62
62
|
|
|
63
63
|
const cardBody = (t) => ({ project: PROJECT, title: t.title, status: t.status, assignee: t.assignee || "",
|
|
64
64
|
difficulty: t.difficulty || undefined, model: t.model || undefined, phase: t.phase || undefined,
|
|
65
|
+
drill: t.drill || undefined, // #6452: a mirror keeps its source's drill line
|
|
65
66
|
by: t.by || "", source: "bridge" });
|
|
66
67
|
|
|
67
68
|
async function tick() {
|
package/bin/crew/core.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { accessSync, constants, existsSync, mkdirSync, readFileSync } from "node
|
|
|
2
2
|
import { basename, dirname, join } from "node:path";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { STATE_FLAGS } from "../../lib/state/flags.mjs";
|
|
5
6
|
|
|
6
7
|
export const ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
7
8
|
|
|
@@ -132,14 +133,11 @@ export function gridColumns(size) {
|
|
|
132
133
|
return columns;
|
|
133
134
|
}
|
|
134
135
|
|
|
135
|
-
// Operator flags the RUNNER itself reads, forwarded from whoever launched the seat.
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
// supported way to reach a seat at all, which is how Phase 2a came to be "enabled" with a schema
|
|
141
|
-
// file that was never written and a runner still on the transcript path.
|
|
142
|
-
const FORWARDED_ENV = ["TRANTOR_STATE", "TRANTOR_STATE_ASSEMBLE", "TRANTOR_STATE_HANDOFF", "TRANTOR_STATE_GATE"];
|
|
136
|
+
// Operator flags the RUNNER itself reads, forwarded from whoever launched the seat. The list lives
|
|
137
|
+
// in lib/state/flags.mjs beside the resolver: the launcher env stays the forwarder (the runner boots
|
|
138
|
+
// before any file is sourced), and #7159 lets the runner fall back to ~/.agent-bus/.env itself, so a
|
|
139
|
+
// flag documented there finally arms the seat that reads it.
|
|
140
|
+
const FORWARDED_ENV = STATE_FLAGS;
|
|
143
141
|
|
|
144
142
|
export function runnerCommand(ctx, agent, model = "", effort = null) {
|
|
145
143
|
const forwarded = FORWARDED_ENV
|
package/bin/crew-payload.mjs
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
export const PAYLOAD_CAPS = Object.freeze({
|
|
7
7
|
wakeCount: 10, // direct/@mention messages: keep the last ~10
|
|
8
8
|
wakeMsgChars: 2000, // per-message body cap (the hub caps card notes at 2000 too)
|
|
9
|
+
wakeHeadChars: 1200, // #7063: over the per-message cap, keep the head…
|
|
10
|
+
wakeTailChars: 700, // #7063: …AND the tail — a work order is asks-last, the instructions live there
|
|
9
11
|
bcastCount: 10, // FYI broadcast context: keep the last ~10
|
|
10
12
|
bcastMsgChars: 1000,
|
|
11
13
|
lessonsCount: 15, // top ~15 lessons, ranked by relevance to this turn's trigger
|
|
@@ -22,8 +24,17 @@ export function capWake(wake, caps = PAYLOAD_CAPS) {
|
|
|
22
24
|
const kept = list.slice(-caps.wakeCount);
|
|
23
25
|
const lines = kept.map(m => {
|
|
24
26
|
let body = String(m?.text ?? "");
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
// #7063: a work order is written context-first, asks-last, so a HEAD cut deleted exactly the
|
|
28
|
+
// instructions ("so, do this: 1) … 2) …") and kept the rationale — two orders lost their item 2
|
|
29
|
+
// in one turn. Over the per-message cap, keep the head AND the tail with ONE marker line
|
|
30
|
+
// between, so a card id, a base: line and the closing asks all survive. The cap is per
|
|
31
|
+
// message, never a batch budget (body > wakeMsgChars ⇒ elided > 0, the marker is always true).
|
|
32
|
+
if (body.length > caps.wakeMsgChars) {
|
|
33
|
+
const elided = body.length - caps.wakeHeadChars - caps.wakeTailChars;
|
|
34
|
+
body = body.slice(0, caps.wakeHeadChars)
|
|
35
|
+
+ `\n…[${n(elided)} chars of this message elided from the middle]\n`
|
|
36
|
+
+ body.slice(-caps.wakeTailChars);
|
|
37
|
+
}
|
|
27
38
|
return `[${m?.from}${m?.to === "all" ? " -> all (mentions you)" : ""}]: ${body}`;
|
|
28
39
|
});
|
|
29
40
|
return { text: lines.join("\n"), kept: kept.length, total: list.length };
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
breakerVerdict, describeTurn, hasJsonSchemaFlag, parseEnvelope, renderCardTail, runStep,
|
|
34
34
|
} from "../lib/state/driver.mjs";
|
|
35
35
|
import { costLine } from "../lib/state/cost.mjs";
|
|
36
|
+
import { resolveStateFlags } from "../lib/state/flags.mjs";
|
|
36
37
|
|
|
37
38
|
const AGENT = process.argv[2];
|
|
38
39
|
const DIR = process.argv[3] || process.cwd();
|
|
@@ -247,7 +248,7 @@ const EFFORT_FLAG = EFFORT ? cliEffortFlag(AGENT, EFFORT) : { flag: "", text: ""
|
|
|
247
248
|
|
|
248
249
|
// RUNNER_RULES / RUNNER_KICKOFF env overrides: the runner is also the substrate for non-crew
|
|
249
250
|
// always-on seats (the fleet DUTY agent, bin/duty.mjs) whose doctrine is not "work your card".
|
|
250
|
-
const RULES = process.env.RUNNER_RULES || `Rules: you are ${SESSION} on the trantor crew. Before starting a card, read YOUR card: relay_board with card:<id> (the card, its deps, its notes, and the last five done cards whose title shares a word); never the whole board. If your wake names no card id (or cites only a message-card), make relay_board with mine:true your FIRST call — it lists the cards assigned to you (doing/testing/todo, newest first, full card shape); work your newest one (#7763). Work your assigned file(s), report on the bus (relay_send, <280 chars), move your Kanban card as you go with a NOTE saying what you did (doing -> testing
|
|
251
|
+
const RULES = process.env.RUNNER_RULES || `Rules: you are ${SESSION} on the trantor crew. Before starting a card, read YOUR card: relay_board with card:<id> (the card, its deps, its notes, and the last five done cards whose title shares a word); never the whole board. If your wake names no card id (or cites only a message-card), make relay_board with mine:true your FIRST call — it lists the cards assigned to you (doing/testing/todo, newest first, full card shape); work your newest one (#7763). Work your assigned file(s), report on the bus (relay_send, <280 chars), move your Kanban card as you go with a NOTE saying what you did (doing -> testing, then STOP: you never close your own card to done — the orchestrator runs the card's drill on the built artifact and closes it, and the hub refuses a move to done on a card with no drill line anyway; in 'testing' run YOUR OWN test file — never the full npm test, suites collide across seats — plus \`node bin/slop-gate.mjs\` when the repo has one: it lints ONLY your changed files against the anti-slop rules, and a card must not reach testing with slop-gate failing; use 'failed' + a report if anything breaks). If a contract omits a fact you cannot proceed without, ASK — never invent the value: relay_ask(<card>, <question>) blocks the card with your question, keeps the turn owed (no park, no failure), and resumes you when the assigner's answer lands; an invented value that reads as reasoned is the worst outcome this crew ships (#7756). If you need something from another session, message THAT SESSION (relay_peers to find its id, relay_send to reach it) — never ask the human to pass it along; carrying messages between agents is the job this bus exists to remove. When your work for THIS message is finished, END YOUR TURN — do NOT park, do NOT loop relay_wait; the runner waits for you and will wake you with the next message. Path discipline: build/test from your worktree root ${TURN_DIR} with absolute paths or --manifest-path/--prefix instead of cd-ing into subdirs, and put anything that must land outside the repo under ${TURN_DIR}/.agent-bus-out/ (gitignored) — never ~/.agent-bus. Realigning your seat branch after the orchestrator harvested your commits is \`trantor sync\` run from your worktree: it reads the harvest receipts and refuses when an unharvested commit would be lost, so never reset or rebase onto main by hand. A contract's \`base: <sha>\` line is the integration head: start your seat branch at that sha (\`git reset --hard <sha>\` on the fresh branch), never at origin/main, which trails the orchestrator's unpushed integration commits; the object is already here as the ref \`main\`. If \`git cat-file -e <sha>\` fails, say \`cannot resolve base <sha>\` on the bus and move the card to blocked instead of reasoning from origin/main. Every testing/done note names the sha you verified against as \`verified at <sha>\`; a note without it is flagged HOLLOW. Cross-project action is a breach: never \`trantor up\` a crew, register a seat, or send a card/contract into a project other than ${PROJ} unless the operator ran \`trantor policy link ${PROJ} <other> --reason "<why>"\` first — the hub, the CLI and this runner all refuse it mechanically, so ask the operator to link the projects instead of routing around the refusal.`;
|
|
251
252
|
|
|
252
253
|
// ---- the pulse --------------------------------------------------------------
|
|
253
254
|
// RUNNER_PULSE_MS re-runs an orchestrator seat's mission note on a cadence when the bus is silent;
|
|
@@ -543,8 +544,13 @@ async function reportHealthy() {
|
|
|
543
544
|
}
|
|
544
545
|
|
|
545
546
|
// ---- Trantor State Phase 2a — the flagged path (TDD §4.1, §4.6, §7.3) -----------------------
|
|
546
|
-
// Off by default
|
|
547
|
-
//
|
|
547
|
+
// Off by default — off means the transcript path runs unchanged. #7159: the flags resolve through
|
|
548
|
+
// the one place (lib/state/flags.mjs), launcher env first and ~/.agent-bus/.env filling the rest;
|
|
549
|
+
// resolved values are applied fill-only to process.env so downstream reads (runGate's GATE) agree.
|
|
550
|
+
const stateFlags = resolveStateFlags();
|
|
551
|
+
for (const [name, flag] of Object.entries(stateFlags)) {
|
|
552
|
+
if (flag.value && process.env[name] === undefined) process.env[name] = flag.value;
|
|
553
|
+
}
|
|
548
554
|
const STATE_FLAG_ON = process.env[STATE_ENV] === "1";
|
|
549
555
|
const STATE_SCHEMA_FILE = join(homedir(), ".agent-bus", `state-schema-${AGENT}-${PROJ}.json`);
|
|
550
556
|
const STATE_MODE = (() => {
|
|
@@ -558,7 +564,7 @@ const STATE_MODE = (() => {
|
|
|
558
564
|
} catch (e) { log(`\x1b[33mstate mode OFF — could not write ${STATE_SCHEMA_FILE}: ${e.message}\x1b[0m`); return false; }
|
|
559
565
|
// #7060: the checks above prove CONFIGURATION, not the prompt, so say "armed" and name the one
|
|
560
566
|
// thing that engages it; each turn then reports which path it took.
|
|
561
|
-
log(`\x1b[36mTrantor State: ASSEMBLE armed for this seat (schema ${STATE_SCHEMA_FILE})\x1b[0m`);
|
|
567
|
+
log(`\x1b[36mTrantor State: ASSEMBLE armed for this seat (${STATE_ENV}=${stateFlags[STATE_ENV].value} via ${stateFlags[STATE_ENV].layer}; schema ${STATE_SCHEMA_FILE})\x1b[0m`);
|
|
562
568
|
log(`\x1b[36m a turn is assembled only when a wake ASSIGNS it a card — the kickoff and every pulse run the transcript path, and each turn says which one it took\x1b[0m`);
|
|
563
569
|
return true;
|
|
564
570
|
})();
|
|
@@ -615,6 +621,14 @@ let statePromotedHash;
|
|
|
615
621
|
// TRANTOR_TURN_MAX_MS ends the CLI's process group at the box and runs ONE follow-up turn in the
|
|
616
622
|
// same session ("commit what is done, move the card, report") so a cut turn lands its work.
|
|
617
623
|
const TURN_MAX_MS = Math.max(0, Number(process.env.TRANTOR_TURN_MAX_MS || 20 * 60 * 1000));
|
|
624
|
+
// #7761: the box counts LIVENESS. At the deadline a turn that moved within the stall window is
|
|
625
|
+
// extended one step (half the box: +10m at the default) up to TRANTOR_TURN_CEILING_MS, the one
|
|
626
|
+
// knob (default 60m). A ceiling at or under the box disables extension; a silent turn still ends
|
|
627
|
+
// at the #7752 stall window, extended or not. The watchdog owns the clock — see turn-watchdog.mjs.
|
|
628
|
+
const TURN_CEILING_MS = TURN_MAX_MS ? Math.max(0, Number(process.env.TRANTOR_TURN_CEILING_MS || 60 * 60 * 1000)) : 0;
|
|
629
|
+
const TURN_EXTEND_MS = Math.ceil(TURN_MAX_MS / 2);
|
|
630
|
+
const TURN_EXTENSIONS_MAX = TURN_EXTEND_MS > 0 && TURN_CEILING_MS > TURN_MAX_MS
|
|
631
|
+
? Math.floor((TURN_CEILING_MS - TURN_MAX_MS) / TURN_EXTEND_MS) : 0;
|
|
618
632
|
const TIME_BOX_PROMPT = "your previous turn was cut at the time box; commit what is done, move the card with a note, report in one line";
|
|
619
633
|
let inFollowUp = false;
|
|
620
634
|
// #6289: whether the turn that just ended was CUT at the time box. The follow-up recursion
|
|
@@ -715,12 +729,23 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
|
|
|
715
729
|
// instead of burning the box. Unlinked at turn start and again with the cut cleanup.
|
|
716
730
|
const STALLF = join(homedir(), ".agent-bus", `turnstall-${AGENT}-${PROJ}`);
|
|
717
731
|
try { unlinkSync(STALLF); } catch {}
|
|
732
|
+
// #7761: the watchdog writes the extended deadline (epoch seconds) here for the shell box to
|
|
733
|
+
// re-read, and one row per extension to EXTF for the ledger. Both unlinked at turn start.
|
|
734
|
+
const DEADLINEF = join(homedir(), ".agent-bus", `turndeadline-${AGENT}-${PROJ}`);
|
|
735
|
+
const EXTF = join(homedir(), ".agent-bus", `turnext-${AGENT}-${PROJ}`);
|
|
736
|
+
try { unlinkSync(DEADLINEF); } catch {}
|
|
737
|
+
try { unlinkSync(EXTF); } catch {}
|
|
738
|
+
const boxPlan = TURN_EXTENSIONS_MAX ? {
|
|
739
|
+
maxMs: TURN_MAX_MS, extendMs: TURN_EXTEND_MS, ceilingMs: TURN_CEILING_MS, extensionsMax: TURN_EXTENSIONS_MAX,
|
|
740
|
+
deadlineFile: DEADLINEF, extFile: EXTF, card: sessionCard || 0,
|
|
741
|
+
assigners: (opts.assigners || []).map(a => ({ from: a.from, id: a.id })),
|
|
742
|
+
} : undefined;
|
|
718
743
|
// Touched by the stderr scrubber as its LAST act (the shell below); node waits for it after
|
|
719
744
|
// spawnSync before reading ERRF — see the drain note at the spawnSync call.
|
|
720
745
|
const DRAINF = join(homedir(), ".agent-bus", `turndrain-${AGENT}-${PROJ}`);
|
|
721
746
|
try { unlinkSync(DRAINF); } catch {}
|
|
722
747
|
try {
|
|
723
|
-
writeFileSync(STAMPF, JSON.stringify({ turn: TURN, startedAt: Date.now(), runner: RUNNER_ID }));
|
|
748
|
+
writeFileSync(STAMPF, JSON.stringify({ turn: TURN, startedAt: Date.now(), runner: RUNNER_ID, box: boxPlan }));
|
|
724
749
|
const wd = spawn(process.execPath, [join(import.meta.dirname, "turn-watchdog.mjs"), STAMPF, ERRF, String(WD_MS), SESSION, PROJ, HUB, TRANSCRIPT_DIR, TURN_DIR,
|
|
725
750
|
// #7752: the stall marker only exists when a box does — with no box there is no sweep to
|
|
726
751
|
// end the turn, so the watchdog stays report-only (reporting is the whole job, boxless).
|
|
@@ -742,6 +767,7 @@ ${sweep}
|
|
|
742
767
|
deadline=$(( $(date +%s) + ${Math.ceil(TURN_MAX_MS / 1000)} ))
|
|
743
768
|
while :; do
|
|
744
769
|
[ -f "${STALLF}" ] && break
|
|
770
|
+
d=$(cat "${DEADLINEF}" 2>/dev/null); case "$d" in ""|*[!0-9]*) ;; *) deadline=$d;; esac
|
|
745
771
|
[ "$(date +%s)" -ge "$deadline" ] && break
|
|
746
772
|
sleep 1 & sleeppid=$!
|
|
747
773
|
wait "$sleeppid"
|
|
@@ -777,7 +803,7 @@ exit $turn_exit`;
|
|
|
777
803
|
// A BACKSTOP only, deliberately later than the shell's own box: if bash itself wedges, node
|
|
778
804
|
// still ends the turn. When the in-shell box works — the normal path — this never fires, which
|
|
779
805
|
// is the point: the shell kills while the tree is still walkable, node cannot.
|
|
780
|
-
if (TURN_MAX_MS) { spawnOpts.timeout = TURN_MAX_MS + 30000; spawnOpts.killSignal = "SIGKILL"; }
|
|
806
|
+
if (TURN_MAX_MS) { spawnOpts.timeout = (TURN_EXTENSIONS_MAX ? TURN_CEILING_MS : TURN_MAX_MS) + 30000; spawnOpts.killSignal = "SIGKILL"; }
|
|
781
807
|
const r = spawnSync("/bin/bash", ["-c", shell], spawnOpts);
|
|
782
808
|
// The shell's box leaves the marker; the backstop leaves an ETIMEDOUT. Either way the turn was
|
|
783
809
|
// cut, not merely failed. A stall marker (#7752) outranks the box marker: a turn silent for
|
|
@@ -785,6 +811,13 @@ exit $turn_exit`;
|
|
|
785
811
|
const boxed = existsSync(CUTF);
|
|
786
812
|
const stallCut = existsSync(STALLF);
|
|
787
813
|
const cut = !!TURN_MAX_MS && (boxed || stallCut || r.error?.code === "ETIMEDOUT");
|
|
814
|
+
// #7761: how far the watchdog moved the box, one EXTF row per extension. boxMs is the box the
|
|
815
|
+
// turn actually had, so a ceiling cut reads as the ceiling, never as the 20-minute default.
|
|
816
|
+
let extensions = 0;
|
|
817
|
+
try { extensions = readFileSync(EXTF, "utf8").split("\n").filter(Boolean).length; } catch {}
|
|
818
|
+
const boxMs = TURN_MAX_MS + extensions * TURN_EXTEND_MS;
|
|
819
|
+
try { unlinkSync(DEADLINEF); } catch {}
|
|
820
|
+
try { unlinkSync(EXTF); } catch {}
|
|
788
821
|
// DRAIN before classifying, never on a CUT turn (the sweep killed the scrubber, its marker never
|
|
789
822
|
// comes). bash 3.2 `wait` skips process substitutions, so wait for DRAINF, bounded.
|
|
790
823
|
if (!cut) {
|
|
@@ -800,6 +833,7 @@ exit $turn_exit`;
|
|
|
800
833
|
try { unlinkSync(CUTF); } catch {}
|
|
801
834
|
try { unlinkSync(STALLF); } catch {}
|
|
802
835
|
if (stallCut) log(`\x1b[33mturn STALLED — no bytes on either stream and no transcript advance for ${Math.round(WD_MS / 60000)}m; ended at the stall window, not the ${Math.round(TURN_MAX_MS / 1000)}s box\x1b[0m`);
|
|
836
|
+
else if (extensions) log(`\x1b[33mturn cut at the ${Math.round(boxMs / 1000)}s box after ${extensions} liveness extension(s) (ceiling ${Math.round(TURN_CEILING_MS / 1000)}s) — CLI and every descendant ended${boxed ? "" : " (node backstop: bash itself was wedged)"}\x1b[0m`);
|
|
803
837
|
else log(`\x1b[33mturn cut at the ${Math.round(TURN_MAX_MS / 1000)}s time box — CLI and every descendant ended${boxed ? "" : " (node backstop: bash itself was wedged)"}\x1b[0m`);
|
|
804
838
|
}
|
|
805
839
|
lastTurnCut = cut;
|
|
@@ -883,6 +917,7 @@ exit $turn_exit`;
|
|
|
883
917
|
const telemetryRow = { ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, card: sessionCard || 0, model: MODEL || "cli-default", duration_ms: Date.now() - t0, exit: realExit, effExit, authFailed: effExit !== realExit, emptyOutput: lastEmptyOutput, emptyTurn: lastEmptyTurn, verdict, outcome: finalOutcome, tokens };
|
|
884
918
|
if (cut) telemetryRow.cut = true;
|
|
885
919
|
if (stallCut) telemetryRow.stalled = true;
|
|
920
|
+
if (extensions) { telemetryRow.extensions = extensions; telemetryRow.boxMs = boxMs; }
|
|
886
921
|
// #7752/#7099: a cut turn's 141/137 is the sweep's own signal (SIGPIPE/SIGKILL), recorded as
|
|
887
922
|
// the cut signal — never read as a quota or crash pattern downstream.
|
|
888
923
|
if (cut) { const sig = cutSignalFor(realExit); if (sig) telemetryRow.cutSignal = sig; }
|
|
@@ -920,7 +955,7 @@ async function stateTurn({ card, observation, trigger, assigners = [] }) {
|
|
|
920
955
|
callCli: async (prompt) => {
|
|
921
956
|
// isFirst=true every step ON PURPOSE: a state step carries no `-c`, so there is no session
|
|
922
957
|
// to resume and a stale sid must never be handed to one.
|
|
923
|
-
const exit = await runTurn(prompt, true, trigger, { state: true });
|
|
958
|
+
const exit = await runTurn(prompt, true, trigger, { state: true, assigners });
|
|
924
959
|
return { exit, stdout: lastEnvelope, cut: lastTurnCut };
|
|
925
960
|
},
|
|
926
961
|
executeAction: async (action) => {
|
|
@@ -1367,9 +1402,17 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1367
1402
|
await loadLessons();
|
|
1368
1403
|
const lessons = pickLessons(LESSONS_RAW, wakeCapped.text + " " + bcastCapped.text);
|
|
1369
1404
|
const trigger = wakeForTurn.some(m => m.to === SESSION) ? "direct message" : "@mention";
|
|
1370
|
-
// Who is owed an answer, captured BEFORE the turn: pendingWake is cleared on success.
|
|
1405
|
+
// Who is owed an answer, captured BEFORE the turn: pendingWake is cleared on success. The id is
|
|
1406
|
+
// the NEWEST wake this runner holds for that assigner (#6987): append order means first-wins
|
|
1407
|
+
// threaded the receipt onto a PREVIOUS turn's id — the original contract, not the answer that
|
|
1408
|
+
// released this turn — which kept the contract reading WAITING while the stop hook chased.
|
|
1371
1409
|
const assigners = [];
|
|
1372
|
-
for (const m of wakeForTurn)
|
|
1410
|
+
for (const m of wakeForTurn) {
|
|
1411
|
+
if (!m.from) continue;
|
|
1412
|
+
const held = assigners.find(a => a.from === m.from);
|
|
1413
|
+
if (held) held.id = m.id;
|
|
1414
|
+
else assigners.push({ from: m.from, id: m.id });
|
|
1415
|
+
}
|
|
1373
1416
|
const asked = askedExcerpt(wakeForTurn[0]);
|
|
1374
1417
|
const tStart = Date.now();
|
|
1375
1418
|
// #6134: ONE SESSION PER CARD; a different card starts a fresh CLI session and the seat is told.
|
|
@@ -1428,7 +1471,7 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1428
1471
|
// dies quietly (§4.6).
|
|
1429
1472
|
observation: [stateObservation, wakeText, ctxText, againText + freshText + baseText].filter(Boolean).join("\n"),
|
|
1430
1473
|
})
|
|
1431
|
-
: await runTurn(prompt, fresh, deliveryFails ? `${trigger} (redelivery)` : trigger, { judgeOutcome: askJudge });
|
|
1474
|
+
: await runTurn(prompt, fresh, deliveryFails ? `${trigger} (redelivery)` : trigger, { judgeOutcome: askJudge, assigners });
|
|
1432
1475
|
}
|
|
1433
1476
|
finally { stopDutyNudgeWatcher(); }
|
|
1434
1477
|
const secs = Math.round((Date.now() - tStart) / 1000);
|
package/bin/doctor.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { loadOrCreate } from "../lib/identity.mjs";
|
|
|
13
13
|
import { sfetchJson } from "../lib/signed-fetch.mjs";
|
|
14
14
|
import { scan } from "../lib/splitbrain.mjs";
|
|
15
15
|
import { resolveSecrets, envFileSecrets, backendFor } from "../lib/secrets.mjs";
|
|
16
|
+
import { resolveStateFlags } from "../lib/state/flags.mjs";
|
|
16
17
|
|
|
17
18
|
const H = homedir();
|
|
18
19
|
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
@@ -324,6 +325,17 @@ if (straggling.live.length && backendFor(process.env, { create: true }) !== "non
|
|
|
324
325
|
ok(`secret store: ${storeNames.length} key(s) in the ${backendFor()} (${storeNames.join(", ")}), none left in .env`);
|
|
325
326
|
}
|
|
326
327
|
|
|
328
|
+
// ── Trantor State flags (#7159): the runners resolve these through ONE place — process env first,
|
|
329
|
+
// ~/.agent-bus/.env second — and each line names the layer that answered, so a flag that LOOKED
|
|
330
|
+
// applied can no longer pass for armed.
|
|
331
|
+
section("trantor state flags");
|
|
332
|
+
{
|
|
333
|
+
const resolved = resolveStateFlags();
|
|
334
|
+
const setFlags = Object.entries(resolved).filter(([, r]) => r.layer !== "unset");
|
|
335
|
+
for (const [name, r] of setFlags) ok(`${name}=${r.value} via ${r.layer}`);
|
|
336
|
+
if (!setFlags.length) note("no state flags set — Trantor State runs dark by default (TDD §11)");
|
|
337
|
+
}
|
|
338
|
+
|
|
327
339
|
// brain
|
|
328
340
|
section("the brain");
|
|
329
341
|
has("scrooge") || existsSync(join(H, ".local", "bin", "scrooge"))
|
package/bin/drill-report.mjs
CHANGED
|
@@ -68,7 +68,7 @@ export class DrillReport {
|
|
|
68
68
|
if (!this.map[id].autoClose || result.status !== "pass" || this.closures[id]) continue;
|
|
69
69
|
const response = await signedPost("/task/update", {
|
|
70
70
|
id: Number(id), project: this.project, by: this.session, status: "done",
|
|
71
|
-
note: `trantor drill PASS\n${result.evidence.join("\n")}`.slice(0, 2000),
|
|
71
|
+
note: `Drill: trantor drill PASS\n${result.evidence.join("\n")}`.slice(0, 2000), // "Drill:" prefix: the hub's done gate (#6452) reads it as the drill line
|
|
72
72
|
}, { project: this.project, session: this.session, timeoutMs: 15000 });
|
|
73
73
|
this.closures[id] = response.ok && response.json?.task?.status === "done"
|
|
74
74
|
&& response.json.task.id === Number(id) && response.json.task.project === this.project
|
|
@@ -51,6 +51,7 @@ test("signed closer on an enforce hub: complete evidence closes, partial/failure
|
|
|
51
51
|
const card = (await signedGet("/tasks?project=trantor")).json.tasks[0];
|
|
52
52
|
assert.equal(card.status, "done");
|
|
53
53
|
assert.equal(card.workedBy, session);
|
|
54
|
+
assert.match(card.log.at(-1).text, /^Drill: trantor drill PASS/); // #6452: the closer's note IS the drill line the hub gate requires
|
|
54
55
|
assert.match(card.log.at(-1).text, /real evidence A/);
|
|
55
56
|
assert.match(card.log.at(-1).text, /real evidence B/);
|
|
56
57
|
assert.equal(JSON.parse(readFileSync(path, "utf8"))[id].closure, "done");
|
package/bin/reconcile.mjs
CHANGED
|
@@ -45,8 +45,25 @@ async function tasks() {
|
|
|
45
45
|
const j = r.json;
|
|
46
46
|
return Array.isArray(j) ? j : (j?.tasks || j?.cards || []);
|
|
47
47
|
}
|
|
48
|
-
async function move(id, status) {
|
|
49
|
-
|
|
48
|
+
async function move(id, status, note) {
|
|
49
|
+
return signedPost("/task/update", { id, status, by: "reconcile", note }, { timeoutMs: 4000 })
|
|
50
|
+
.catch(e => ({ ok: false, status: 0, json: { error: e.message } }));
|
|
51
|
+
}
|
|
52
|
+
// #6452: the hub refuses done on a card with no drill line, and reconcile cannot claim a drill it
|
|
53
|
+
// never ran. A card judged shipped closes when it already carries a drill line; otherwise it parks
|
|
54
|
+
// at testing with the verdict on its log, and the orchestrator runs the drill and closes it.
|
|
55
|
+
async function close(x) {
|
|
56
|
+
const note = `reconcile: judged shipped${x.commit ? ` at ${x.commit}` : ""}${x.reason ? ` — ${x.reason}` : ""}`;
|
|
57
|
+
const r = await move(x.t.id, "done", note);
|
|
58
|
+
if (r.ok) return "closed";
|
|
59
|
+
if (r.status === 409 && /drill/i.test(r.json?.error || "")) {
|
|
60
|
+
const parked = await move(x.t.id, "testing", `${note}; no drill line on the card, so it waits at testing for the orchestrator's drill`);
|
|
61
|
+
if (parked.ok) return "parked";
|
|
62
|
+
console.error(` #${x.t.id}: hub ${parked.status} ${parked.json?.error || ""}`);
|
|
63
|
+
return "failed";
|
|
64
|
+
}
|
|
65
|
+
console.error(` #${x.t.id}: hub ${r.status} ${r.json?.error || ""}`);
|
|
66
|
+
return "failed";
|
|
50
67
|
}
|
|
51
68
|
// the memory record for THIS project (Claude Code stores it per encoded-cwd); optional context.
|
|
52
69
|
function memoryExcerpt() {
|
|
@@ -118,6 +135,7 @@ if (!doIt) {
|
|
|
118
135
|
console.log(`\n ${done.length} card(s) → done, ${stale.length} → stale. Re-run to apply:\n trantor reconcile${val("--older", "-o") ? ` --older ${val("--older", "-o")}` : ""} --yes\n`);
|
|
119
136
|
process.exit(0);
|
|
120
137
|
}
|
|
121
|
-
|
|
138
|
+
const outcome = { closed: 0, parked: 0, failed: 0 };
|
|
139
|
+
for (const x of done) outcome[await close(x)]++;
|
|
122
140
|
for (const x of stale) await move(x.t.id, "stale");
|
|
123
|
-
console.log(`\n ✓ reconciled: ${
|
|
141
|
+
console.log(`\n ✓ reconciled: ${outcome.closed} closed as done${outcome.parked ? `, ${outcome.parked} parked at testing (no drill line)` : ""}${outcome.failed ? `, ${outcome.failed} refused` : ""}, ${stale.length} moved to stale. ${active.length} left active.\n`);
|
package/bin/turn-watchdog.mjs
CHANGED
|
@@ -1,22 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Turn watchdog (#5684,
|
|
3
|
-
// turn —
|
|
4
|
-
//
|
|
5
|
-
// timer storm). With no stall file (report-only mode) the turn is never killed — reporting is
|
|
6
|
-
// the whole job. With one (#7752, kill mode) a turn silent on EVERY channel for the whole
|
|
7
|
-
// window is also ENDED at the window: the marker tells the runner's shell box to sweep early,
|
|
8
|
-
// so the kill itself still lives in exactly one place — the shell that owns $job.
|
|
9
|
-
//
|
|
10
|
-
// #6206: stdout silence is NOT a stall — `claude -p` prints nothing until the turn ends by
|
|
11
|
-
// design, so a seat editing five files was reported STALLED while its transcript advanced.
|
|
12
|
-
// Liveness is new activity in the seat's transcript (the CLI's session file), its worktree, or
|
|
13
|
-
// stderr growth; silence on ALL of them for a whole window is the only thing reported. And a
|
|
14
|
-
// watchdog never speaks for a runner it does not belong to: the stamp carries the runner's
|
|
15
|
-
// instance id, so a survivor of a replaced runner exits on mismatch or runner death instead of
|
|
16
|
-
// re-matching the NEW runner's turn number (the 09:47 false alarm was exactly that orphan).
|
|
17
|
-
//
|
|
2
|
+
// Turn watchdog (#5684, #6206, #7752, #7761): runTurn is spawnSync, so this DETACHED helper watches
|
|
3
|
+
// the turn — liveness = transcript, worktree or stderr moving; a whole silent window earns ONE stall
|
|
4
|
+
// report, and in kill mode (stall file given) also ends the turn via the shell box. Stamp-bound to one runner.
|
|
18
5
|
// node bin/turn-watchdog.mjs <stampFile> <errFile> <windowMs> <session> <project> <hubUrl> <transcriptDir> <workDir> [stallFile]
|
|
19
|
-
import { readFileSync, writeFileSync, statSync, readdirSync } from "node:fs";
|
|
6
|
+
import { readFileSync, writeFileSync, appendFileSync, statSync, readdirSync } from "node:fs";
|
|
20
7
|
import { join } from "node:path";
|
|
21
8
|
import { hostId } from "../lib/project.mjs";
|
|
22
9
|
import { signedPost } from "../hooks/lib/api.mjs";
|
|
@@ -74,14 +61,29 @@ let baseErr = errSize();
|
|
|
74
61
|
const armedAt = armed.startedAt || Date.now();
|
|
75
62
|
const SLACK = 2000; // timestamp granularity + scheduler drift under load
|
|
76
63
|
|
|
77
|
-
// #7752 kill mode: poll liveness
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
64
|
+
// #7752 kill mode: poll liveness; a whole window silent on EVERY channel writes the stall marker
|
|
65
|
+
// (the shell box sweeps on it), reports once, exits; stderr is measured ROLLING (bytes this window).
|
|
66
|
+
// #7761: `armed.box` (step, ceiling, assigners) exists only when a ceiling above the box does; a
|
|
67
|
+
// deadline one poll away on a turn that moved within the window is pushed out one step.
|
|
68
|
+
const box = stallFile && armed.box && Number(armed.box.extensionsMax) > 0 ? armed.box : null;
|
|
69
|
+
const mins = (ms) => `${Math.max(1, Math.round(ms / 60000))}m`;
|
|
70
|
+
const secsOrMins = (ms) => (ms >= 60000 ? mins(ms) : `${Math.round(ms / 1000)}s`);
|
|
71
|
+
async function tellExtension(text) {
|
|
72
|
+
const orch = `${hostId()}:${project}`;
|
|
73
|
+
const seen = new Set();
|
|
74
|
+
for (const a of [...(box.assigners || []), { from: orch }]) {
|
|
75
|
+
const to = String(a?.from || "");
|
|
76
|
+
if (!to || to === "all" || to === session || to.startsWith("hub:") || seen.has(to)) continue;
|
|
77
|
+
seen.add(to);
|
|
78
|
+
try { await signedPost(`${hub}/send`, { from: session, to, text, project, kind: "status", wake: false }, { session }); } catch {}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
82
|
if (stallFile) {
|
|
83
83
|
const poll = Math.max(250, Math.min(windowMs / 4, 10000));
|
|
84
84
|
let lastErrAt = armedAt;
|
|
85
|
+
let deadline = armedAt + (box ? Number(box.maxMs) : 0);
|
|
86
|
+
let extensions = 0;
|
|
85
87
|
for (;;) {
|
|
86
88
|
await sleep(poll);
|
|
87
89
|
const s = readStamp();
|
|
@@ -93,7 +95,17 @@ if (stallFile) {
|
|
|
93
95
|
const freshCut = Math.max(armedAt, now - windowMs - SLACK);
|
|
94
96
|
const tr = transcriptDir ? newestMtime(transcriptDir) : 0;
|
|
95
97
|
const wk = workDir ? newestMtime(workDir) : 0;
|
|
96
|
-
if (tr > freshCut || wk > freshCut || now - lastErrAt < windowMs)
|
|
98
|
+
if (tr > freshCut || wk > freshCut || now - lastErrAt < windowMs) { // producing work: alive
|
|
99
|
+
if (box && extensions < Number(box.extensionsMax) && now + poll + SLACK >= deadline) {
|
|
100
|
+
extensions++;
|
|
101
|
+
deadline += Number(box.extendMs);
|
|
102
|
+
try { writeFileSync(box.deadlineFile, String(Math.floor(deadline / 1000))); } catch {}
|
|
103
|
+
try { appendFileSync(box.extFile, JSON.stringify({ n: extensions, at: now, until: deadline }) + "\n"); } catch {}
|
|
104
|
+
const card = Number(box.card) > 0 ? ` on #${box.card}` : "";
|
|
105
|
+
await tellExtension(`⏳ ${session} turn extended +${secsOrMins(Number(box.extendMs))} (${extensions}/${box.extensionsMax})${card} — alive: ${describeLast({ tr, wk })}${now - lastErrAt < windowMs ? ", stderr moving" : ""}; box now ${secsOrMins(deadline - armedAt)} of a ${secsOrMins(Number(box.ceilingMs))} ceiling`);
|
|
106
|
+
}
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
97
109
|
try { writeFileSync(stallFile, ""); } catch {}
|
|
98
110
|
const mins = Math.round((now - armedAt) / 60000);
|
|
99
111
|
const orch = `${hostId()}:${project}`;
|
|
@@ -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/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);
|