trantor 0.18.50 → 0.18.52
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/cli.mjs +2 -0
- package/bin/crew-runner.mjs +285 -9
- package/bin/doctor.mjs +61 -1
- package/bin/drill-surface.mjs +47 -0
- package/bin/duty.mjs +68 -1
- package/bin/state-bench.mjs +880 -0
- package/bin/state.mjs +295 -0
- package/bin/write-handoff.mjs +7 -1
- package/hooks/lib/api.mjs +23 -3
- package/hooks/lib/handoff.mjs +115 -1
- package/hooks/sessionstart.mjs +10 -0
- package/hub/duty.mjs +43 -2
- package/hub/routes/admin.mjs +5 -0
- package/lib/duty-nudges.mjs +36 -2
- package/lib/state/assemble.mjs +96 -0
- package/lib/state/cost.mjs +88 -0
- package/lib/state/derive.mjs +229 -0
- package/lib/state/driver.mjs +535 -0
- package/lib/state/gate.mjs +355 -0
- package/lib/state/promote.mjs +122 -0
- package/lib/state/store.mjs +442 -0
- package/lib/state/validate.mjs +19 -4
- package/mcp.mjs +5 -1
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.52",
|
|
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/cli.mjs
CHANGED
|
@@ -90,6 +90,7 @@ switch (cmd) {
|
|
|
90
90
|
case "proposals": case "proposal": run("bin/proposals.mjs"); break;
|
|
91
91
|
case "inbox": run("bin/inbox.mjs"); break;
|
|
92
92
|
case "duty": run("bin/duty.mjs"); break;
|
|
93
|
+
case "state": run("bin/state.mjs"); break;
|
|
93
94
|
case "seats": case "seat": run("bin/seats.mjs"); break;
|
|
94
95
|
case "seat-why": case "why": run("bin/seat-why.mjs"); break;
|
|
95
96
|
case "orchestrate": run("bin/orchestrate.mjs"); break;
|
|
@@ -227,6 +228,7 @@ switch (cmd) {
|
|
|
227
228
|
trantor hub run the hub in the foreground (setup installs it as a service instead)
|
|
228
229
|
…or manage per-project hub pins: hub list · hub set <project> <url> · hub unset <project>
|
|
229
230
|
seats: which project lives in which directory — seats · seats add · seats up · seats login install
|
|
231
|
+
trantor state a seat's working memory: show <seat> <card> [--json] · validate · reset --force · gc [--apply]
|
|
230
232
|
trantor seat-why WHY a seat is down (err file, logs, pids): seat-why <agent> [--json] — quota, auth, crash, or just no pane
|
|
231
233
|
trantor watch live bus feed in the terminal
|
|
232
234
|
trantor inbox THIS session's unread bus messages, signed (works under enforce) — [--all] [--consume] [--json]
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -31,6 +31,11 @@ import {
|
|
|
31
31
|
auditDutyNudges, claimDutyNudges, claudeTranscriptDir, dutyEscalations, dutyNudgeDirective,
|
|
32
32
|
observedDutyNudgeIds,
|
|
33
33
|
} from "../lib/duty-nudges.mjs";
|
|
34
|
+
import {
|
|
35
|
+
BREAKER_WINDOW, STATE_ENV, TURN_RESULT_SCHEMA,
|
|
36
|
+
breakerVerdict, describeTurn, hasJsonSchemaFlag, parseEnvelope, renderCardTail, runStep,
|
|
37
|
+
} from "../lib/state/driver.mjs";
|
|
38
|
+
import { costLine } from "../lib/state/cost.mjs";
|
|
34
39
|
|
|
35
40
|
const AGENT = process.argv[2];
|
|
36
41
|
const DIR = process.argv[3] || process.cwd();
|
|
@@ -289,7 +294,19 @@ const CLI = {
|
|
|
289
294
|
openrouter: { first: `opencode run --dir {DIR}{M} "$(cat {P})"`,
|
|
290
295
|
next: `opencode run --dir {DIR} -s {SID}{M} "$(cat {P})"`, mflag: " -m ", pinned: true, env: join(homedir(), ".token-scrooge", ".env") },
|
|
291
296
|
claude: { first: `claude{M} -p "$(cat {P})" --dangerously-skip-permissions`,
|
|
292
|
-
next: `claude -c{M} -p "$(cat {P})" --dangerously-skip-permissions`,
|
|
297
|
+
next: `claude -c{M} -p "$(cat {P})" --dangerously-skip-permissions`,
|
|
298
|
+
// TRANTOR_STATE_ASSEMBLE=1 — Trantor State Phase 2a (TDD §4.6). Note what is GONE:
|
|
299
|
+
// `-c`. The resumed transcript is the thing this path exists to stop re-sending, so a
|
|
300
|
+
// state step is a fresh `claude -p` carrying the assembled prefix instead, and
|
|
301
|
+
// `--json-schema` holds the seat to the TurnResult grammar. Used for the first step
|
|
302
|
+
// of a card too: with no `-c` it IS the `first` shape, and a first step that returned
|
|
303
|
+
// no TurnResult would leave the run recorder a hole on turn 1.
|
|
304
|
+
//
|
|
305
|
+
// The flag is off by default and nothing above changes, so the transcript path stays
|
|
306
|
+
// byte-identical — test/state/test-runner-state.mjs asserts that against these very
|
|
307
|
+
// strings rather than against a reading of this comment.
|
|
308
|
+
stateNext: `claude{M} -p "$(cat {P})" --dangerously-skip-permissions --output-format json --json-schema "$(cat {S})"`,
|
|
309
|
+
mflag: " --model " },
|
|
293
310
|
// DeepSeek Harness. Every turn is a FRESH session — headless has no resume yet — so the seat
|
|
294
311
|
// relies on the wake prompt + the board (via the relay tools its profile mounts) rather than
|
|
295
312
|
// conversation memory. `trantor connect` builds the ~/.dsh/profiles/trantor composition: their
|
|
@@ -469,10 +486,40 @@ async function parkSeat(reason, undelivered, resetHint = 0) {
|
|
|
469
486
|
if (orch !== SESSION) await api("/send", { from: SESSION, to: orch, text, project: PROJ, kind: "alert" }).catch(() => {});
|
|
470
487
|
}
|
|
471
488
|
log(`\x1b[31mparked (${reason})${when ? ` — retrying after ${when}` : " — no reset time in the output; waiting for a restart"}\x1b[0m`);
|
|
489
|
+
// The two /send calls above are the whole escalation, and on 2026-09-09 that was not enough:
|
|
490
|
+
// the DUTY seat parked on a quota read, held 48 messages for 21.9 hours, and announced it over
|
|
491
|
+
// the very bus that had stopped moving, to an orchestrator that was idle and therefore could not
|
|
492
|
+
// receive it. The alarm for "the bus is stuck" cannot itself be a bus message. So park also
|
|
493
|
+
// rings a bell the operator can actually hear, out of band, once per park.
|
|
494
|
+
notifyOperator(`Trantor: ${SESSION} PARKED (${reason})`,
|
|
495
|
+
`${undelivered} message(s) held${when ? ` — retrying after ${when}` : ` — needs \`trantor up ${AGENT}\``}`);
|
|
472
496
|
// No reset time means no timer can clear it: hold until the operator restarts the seat.
|
|
473
497
|
return resetAt || Number.MAX_SAFE_INTEGER;
|
|
474
498
|
}
|
|
475
499
|
|
|
500
|
+
/**
|
|
501
|
+
* Reach the operator on a channel that does not depend on the bus, the hub, or a live session.
|
|
502
|
+
* Best-effort and strictly non-fatal: a seat must never die because a notifier is missing.
|
|
503
|
+
* Silence-able with TRANTOR_NO_DESKTOP_NOTIFY=1 for headless boxes and test runs.
|
|
504
|
+
*/
|
|
505
|
+
function notifyOperator(title, body) {
|
|
506
|
+
if (process.env.TRANTOR_NO_DESKTOP_NOTIFY === "1") return;
|
|
507
|
+
try {
|
|
508
|
+
// Always leave a durable trace first: a notification can be missed or suppressed, a file cannot.
|
|
509
|
+
// This is what `trantor doctor` reads, so the escalation survives a machine nobody was sitting at.
|
|
510
|
+
const alertsPath = join(homedir(), ".agent-bus", "alerts.jsonl");
|
|
511
|
+
appendFileSync(alertsPath, `${JSON.stringify({ ts: Date.now(), session: SESSION, title, body })}\n`);
|
|
512
|
+
} catch {}
|
|
513
|
+
try {
|
|
514
|
+
if (process.platform === "darwin") {
|
|
515
|
+
// osascript is present on every mac; no dependency to install and nothing to keep running.
|
|
516
|
+
const esc = (s) => String(s).replace(/["\\]/g, "\\$&");
|
|
517
|
+
spawnSync("osascript", ["-e", `display notification "${esc(body)}" with title "${esc(title)}"`],
|
|
518
|
+
{ timeout: 5000, stdio: "ignore" });
|
|
519
|
+
}
|
|
520
|
+
} catch {}
|
|
521
|
+
}
|
|
522
|
+
|
|
476
523
|
// The seat's own balance rows, for the #6131 read: a stalled turn that printed nothing on a seat
|
|
477
524
|
// whose plan is spent is exhaustion, not a crash. Bounded and best-effort — a slow provider API
|
|
478
525
|
// must never hold up the failure path, and an unreachable one just leaves the reason as it was.
|
|
@@ -546,6 +593,66 @@ async function reportHealthy() {
|
|
|
546
593
|
cmuxStatus("ok", "#14b8a6", "check"); herdrAgent("idle");
|
|
547
594
|
}
|
|
548
595
|
|
|
596
|
+
// ---- Trantor State Phase 2a — the flagged path (TDD §4.1, §4.6, §7.3) -----------------------
|
|
597
|
+
//
|
|
598
|
+
// OFF BY DEFAULT, and off means the transcript path runs unchanged. Three things have to be true
|
|
599
|
+
// before a single byte of this is reachable: the operator set TRANTOR_STATE_ASSEMBLE=1, the seat is
|
|
600
|
+
// `claude` (§7.3 — it is the only row whose CLI can enforce the grammar), and the installed CLI
|
|
601
|
+
// actually carries `--json-schema` (§6 — the minimum version is unconfirmed, so this PROBES rather
|
|
602
|
+
// than assuming; no flag, no state mode, and the runner says so once).
|
|
603
|
+
const STATE_FLAG_ON = process.env[STATE_ENV] === "1";
|
|
604
|
+
const STATE_SCHEMA_FILE = join(homedir(), ".agent-bus", `state-schema-${AGENT}-${PROJ}.json`);
|
|
605
|
+
const STATE_MODE = (() => {
|
|
606
|
+
if (!STATE_FLAG_ON) return false;
|
|
607
|
+
if (AGENT !== "claude") { log(`${STATE_ENV}=1 but this seat is '${AGENT}' — state mode is claude-only (TDD §7.3); staying on the transcript path`); return false; }
|
|
608
|
+
const probe = hasJsonSchemaFlag((bin, args) => spawnSync(bin, args, { encoding: "utf8", timeout: 20000 }));
|
|
609
|
+
if (!probe) { log(`\x1b[33m${STATE_ENV}=1 but this claude CLI has no --json-schema — state mode stays OFF (TDD §6)\x1b[0m`); return false; }
|
|
610
|
+
try {
|
|
611
|
+
mkdirSync(join(homedir(), ".agent-bus"), { recursive: true, mode: 0o700 });
|
|
612
|
+
writeFileSync(STATE_SCHEMA_FILE, JSON.stringify(TURN_RESULT_SCHEMA), { mode: 0o600 });
|
|
613
|
+
} catch (e) { log(`\x1b[33mstate mode OFF — could not write ${STATE_SCHEMA_FILE}: ${e.message}\x1b[0m`); return false; }
|
|
614
|
+
log(`\x1b[36mTrantor State: ASSEMBLE mode ON for this seat (schema ${STATE_SCHEMA_FILE})\x1b[0m`);
|
|
615
|
+
return true;
|
|
616
|
+
})();
|
|
617
|
+
|
|
618
|
+
// The PREAMBLE, and it is the whole cost claim in one constant: the bytes before STATE_DELIM must
|
|
619
|
+
// be identical on every step or provider prefix caching never engages and the curve stays O(T).
|
|
620
|
+
// So it is computed ONCE, from things that do not vary per turn — no clock, no turn number, no
|
|
621
|
+
// wake text. Everything that changes rides in the state block, the card log, or the observation.
|
|
622
|
+
const STATE_PREAMBLE = `You are running on Trantor State. Your working memory for this card is the STATE block below — it is carried for you, so you do not have to re-read the conversation or the worktree to know where you are.
|
|
623
|
+
|
|
624
|
+
Answer with ONE JSON object matching the schema you were given: { "patch": Op[], "action": Action }.
|
|
625
|
+
|
|
626
|
+
{ "set": { "field": "task"|"notes"|"ext.<key>", "value": ... } }
|
|
627
|
+
{ "add": { "list": "done"|"in_flight"|"next"|"blockers", "item": { "id", "text", "paths"? } } }
|
|
628
|
+
{ "remove": { "list": ..., "id": ... } }
|
|
629
|
+
{ "move": { "id": ..., "from": ..., "to": ... } }
|
|
630
|
+
|
|
631
|
+
action is exactly one of { "done": true } (the card is finished), { "ask": "<question>" }, or { "continue": true } (you did real work this step and are not finished).
|
|
632
|
+
|
|
633
|
+
Rules the harness enforces, so that you do not have to guess at them:
|
|
634
|
+
· verify, files, cursor, rev, card and the counters are HARNESS-WRITTEN. A patch touching them is rejected.
|
|
635
|
+
· An item may only reach "done" with evidence. Cite the files it rests on inline — "wire the promoter @lib/x.mjs,test/test-x.mjs" — and the harness runs the gate for you. A red gate hands you the failing assertion as your next observation; it does not mark your work done and it does not mark it failed.
|
|
636
|
+
· Do the actual work with your own tools during this step. The patch describes what you did; it is not a plan.
|
|
637
|
+
|
|
638
|
+
${RULES}`;
|
|
639
|
+
|
|
640
|
+
// The card log the state block is read against (§4.1's `tail`). One board read per step, the same
|
|
641
|
+
// call hooks/lib/handoff.mjs already makes.
|
|
642
|
+
async function cardTail(card) {
|
|
643
|
+
try {
|
|
644
|
+
const r = await api(`/tasks?project=${encodeURIComponent(PROJ)}`);
|
|
645
|
+
return renderCardTail(Array.isArray(r?.tasks) ? r.tasks : r, card);
|
|
646
|
+
} catch { return ""; }
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// The patch-outcome ledger the breaker reads back. Kept in memory for this runner AND appended to
|
|
650
|
+
// disk by the driver, because the breaker is a per-seat rolling window and a runner restart should
|
|
651
|
+
// not hand a misbehaving seat a clean slate it did not earn.
|
|
652
|
+
let patchLedger = [];
|
|
653
|
+
let breakerTripped = false;
|
|
654
|
+
let statePromotedHash;
|
|
655
|
+
|
|
549
656
|
// ---- the time box (#6134) --------------------------------------------------------------------
|
|
550
657
|
// A turn with no ceiling is how a seat spends an afternoon on one card: the 09-02 baseline was 151
|
|
551
658
|
// turns and ~16 agentic hours across the fleet. TRANTOR_TURN_MAX_MS ends the CLI's process group
|
|
@@ -572,8 +679,12 @@ let WD_CHILD = null;
|
|
|
572
679
|
function killWatchdog() { if (WD_CHILD) { try { WD_CHILD.kill("SIGTERM"); } catch {} WD_CHILD = null; } }
|
|
573
680
|
process.on("exit", killWatchdog);
|
|
574
681
|
|
|
575
|
-
|
|
682
|
+
// #6969: `opts.state` is the ONLY way this function behaves differently, and it is set from one
|
|
683
|
+
// place (stateTurn). With it unset every line below is the path that shipped before Phase 2a.
|
|
684
|
+
let lastEnvelope = "";
|
|
685
|
+
async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
|
|
576
686
|
TURN++; banner(trigger);
|
|
687
|
+
lastEnvelope = "";
|
|
577
688
|
const t0 = Date.now();
|
|
578
689
|
// A fresh session must not resume the old one's id: `first` is chosen by isFirst OR a missing
|
|
579
690
|
// sid, so a stale sid would quietly resume the session this turn exists to leave behind.
|
|
@@ -592,8 +703,12 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
592
703
|
// #6154: a pinned seat with no sid yet resumes as FRESH — the guard below fails open, because
|
|
593
704
|
// a resume without an id must fall back to a new session, never to `next`'s bare resume shape.
|
|
594
705
|
let cmd = (isFirst || ((cli.sid || cli.pinned) && !sid)) ? cli.first : cli.next;
|
|
706
|
+
// The flagged row (TDD §4.6). `{S}` exists only in `stateNext`, so the replaceAll below is a
|
|
707
|
+
// no-op on every other path — which is what "flag off = byte-identical" has to mean.
|
|
708
|
+
if (opts.state && cli.stateNext) cmd = cli.stateNext;
|
|
595
709
|
const mfrag = MODEL && cli.mflag ? `${cli.mflag}${MODEL}` : "";
|
|
596
|
-
cmd = cmd.replaceAll("{M}", mfrag).replaceAll("{P}", pf).replaceAll("{SID}", sid).replaceAll("{DIR}", TURN_DIR)
|
|
710
|
+
cmd = cmd.replaceAll("{M}", mfrag).replaceAll("{P}", pf).replaceAll("{SID}", sid).replaceAll("{DIR}", TURN_DIR)
|
|
711
|
+
.replaceAll("{S}", STATE_SCHEMA_FILE);
|
|
597
712
|
// PRECEDENCE, and it is easy to get backwards — this is the second time.
|
|
598
713
|
// Each file is PREPENDED, so the one prepended LAST runs FIRST, and in shell the file that runs
|
|
599
714
|
// LAST wins. To make ~/.agent-bus/.env (the CREW layer) win it must be prepended FIRST, i.e.
|
|
@@ -623,7 +738,15 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
623
738
|
// topology is load-bearing (#5481): stdout+stderr must still BOTH land in ERRF, and the sid
|
|
624
739
|
// path still folds stdout in via /dev/stderr → the --tee2 hop below.
|
|
625
740
|
const SCRUB = `node ${join(import.meta.dirname, "..", "lib", "redact.mjs")}`;
|
|
626
|
-
|
|
741
|
+
// §4.6 names a real cost of `--output-format json`: the seat's window would print a JSON blob
|
|
742
|
+
// instead of prose, and the operator watches that window. So on a state step stdout goes to a
|
|
743
|
+
// file and the runner prints the result line and the cost line itself. stderr still streams to
|
|
744
|
+
// the window through the process substitution below, unchanged.
|
|
745
|
+
const ENVF = join(homedir(), ".agent-bus", `envelope-${AGENT}-${PROJ}.json`);
|
|
746
|
+
if (opts.state) { try { unlinkSync(ENVF); } catch {} }
|
|
747
|
+
const inner = opts.state
|
|
748
|
+
? `${cmd} > ${ENVF}`
|
|
749
|
+
: (cli.sid ? `${cmd} | tee /dev/stderr` : `${cmd} | ${SCRUB} --tee ${ERRF}`);
|
|
627
750
|
// #5684: runTurn is spawnSync, so the runner cannot watch its own turn — a DETACHED watchdog
|
|
628
751
|
// does. Armed by a stamp file, disarmed when the turn ends (stamp removed below); a turn past
|
|
629
752
|
// the window with no activity (transcript, worktree, or stderr — #6206: stdout silence alone
|
|
@@ -754,6 +877,12 @@ exit $turn_exit`;
|
|
|
754
877
|
let ownOut = "";
|
|
755
878
|
try { ownOut = stripPromptEcho(readFileSync(ERRF, "utf8"), readPromptText(pf)); } catch { ownOut = ""; }
|
|
756
879
|
lastErrText = ownOut.slice(-4000);
|
|
880
|
+
if (opts.state) {
|
|
881
|
+
try { lastEnvelope = redactKeys(readFileSync(ENVF, "utf8")); } catch { lastEnvelope = ""; }
|
|
882
|
+
const env = parseEnvelope(lastEnvelope);
|
|
883
|
+
if (env.turn) log(`state step: ${describeTurn(env.turn)} · ${costLine(env.cost)}`);
|
|
884
|
+
else log(`\x1b[33mstate step: no TurnResult — ${env.error}\x1b[0m`);
|
|
885
|
+
}
|
|
757
886
|
if (cli.sid && r.stdout) { const m = r.stdout.match(cli.sid); if (m) sid = m[1]; }
|
|
758
887
|
// #6154: the opencode family prints no sid on stdout — the id comes from opencode's own DB,
|
|
759
888
|
// keyed by the worktree the session was created in. Fail-open: nothing found leaves sid empty,
|
|
@@ -787,7 +916,10 @@ exit $turn_exit`;
|
|
|
787
916
|
// never fired — the tee topology is the load-bearing fact; keep this comment with it.)
|
|
788
917
|
// The judgment now runs on the ECHO-STRIPPED text (#5868): a CLI that replays the prompt but
|
|
789
918
|
// does no work has still produced nothing of its own.
|
|
790
|
-
|
|
919
|
+
// #6969: on a state step the CLI's whole answer is the envelope, so ERRF holds only stderr and
|
|
920
|
+
// silence there is the NORMAL shape of a healthy turn. Judging it "empty-output" would park a
|
|
921
|
+
// working seat on its first clean state step.
|
|
922
|
+
if (realExit === 0 && effExit === 0 && !lastErrText.trim() && !lastEnvelope.trim()) {
|
|
791
923
|
effExit = 1;
|
|
792
924
|
lastEmptyOutput = true;
|
|
793
925
|
log("\x1b[31mexit 0 but the turn produced NO output — treating as FAILED (empty-output)\x1b[0m");
|
|
@@ -797,7 +929,13 @@ exit $turn_exit`;
|
|
|
797
929
|
const verdict = verdictFor(realExit, effExit, lastEmptyOutput, ownOut);
|
|
798
930
|
// #6134: what the turn COST, from the CLI's own usage line. Zero means this CLI printed none —
|
|
799
931
|
// never that the turn was free. `trantor seat-why` totals these into today's spend per seat.
|
|
800
|
-
|
|
932
|
+
let tokens = parseTurnTokens(ownOut);
|
|
933
|
+
if (opts.state && lastEnvelope) {
|
|
934
|
+
const c = parseEnvelope(lastEnvelope).cost;
|
|
935
|
+
// 0 means "this CLI printed no usage", never "free" — so only overwrite when the envelope
|
|
936
|
+
// actually carried counts.
|
|
937
|
+
if (c) tokens = c.input + c.output + c.cache_read + c.cache_creation;
|
|
938
|
+
}
|
|
801
939
|
// #6289: every ledger row names in ONE field what happened to the turn — cut (the box ended it),
|
|
802
940
|
// api-error (the CLI failed), completed — and what it cost in tokens, even when this CLI printed
|
|
803
941
|
// no usage line (0 means "not reported", never "free"). `cut` stays too: the drills read it.
|
|
@@ -813,7 +951,12 @@ exit $turn_exit`;
|
|
|
813
951
|
// The follow-up rides the SAME session, so the model still has the turn it was cut out of and
|
|
814
952
|
// only has to land it. Exactly one — a follow-up that runs long is itself boxed, and boxing a
|
|
815
953
|
// boxed turn forever is the loop this card exists to end.
|
|
816
|
-
|
|
954
|
+
// A state step gets no prose follow-up: TIME_BOX_PROMPT is not a TurnResult prompt, and feeding
|
|
955
|
+
// it would break the byte-identical prefix the cost claim rests on. It is also unnecessary —
|
|
956
|
+
// §4.4 is explicit that a cut turn has never partially applied a patch, so what was lost is the
|
|
957
|
+
// dead turn's observations, and store.recover() rebuilds those from git on the next readState.
|
|
958
|
+
// The step is recorded with `cut: true` so §8.7 can still count it.
|
|
959
|
+
if (cut && !inFollowUp && !opts.state) {
|
|
817
960
|
inFollowUp = true;
|
|
818
961
|
try { return await runTurn(TIME_BOX_PROMPT, false, "time-box follow-up"); }
|
|
819
962
|
finally { inFollowUp = false; }
|
|
@@ -821,6 +964,70 @@ exit $turn_exit`;
|
|
|
821
964
|
return effExit;
|
|
822
965
|
}
|
|
823
966
|
|
|
967
|
+
// What the NEXT state step opens with when the last one was rejected (§4.8: the seat's next step
|
|
968
|
+
// begins with the actual failing assertion). Cleared on every accepted patch.
|
|
969
|
+
let stateObservation = "";
|
|
970
|
+
|
|
971
|
+
// ---- Phase 2a: one state step, driven by lib/state/driver.mjs -------------------------------
|
|
972
|
+
//
|
|
973
|
+
// The runner's whole job here is transport and side effects: it hands the driver a way to run the
|
|
974
|
+
// CLI and a way to act on the returned action, and the driver holds the §4.1 order. Returns an
|
|
975
|
+
// exit code so deliverWake's success/failure ladder is untouched.
|
|
976
|
+
async function stateTurn({ card, observation, trigger, assigners = [] }) {
|
|
977
|
+
const tail = await cardTail(card);
|
|
978
|
+
const r = await runStep({
|
|
979
|
+
seat: SESSION, card, project: PROJ, cwd: TURN_DIR,
|
|
980
|
+
preamble: STATE_PREAMBLE, tail, observation,
|
|
981
|
+
promoted: statePromotedHash,
|
|
982
|
+
now: Date.now(),
|
|
983
|
+
deps: {
|
|
984
|
+
callCli: async (prompt) => {
|
|
985
|
+
// isFirst=true every step ON PURPOSE: a state step carries no `-c`, so there is no session
|
|
986
|
+
// to resume and a stale sid must never be handed to one.
|
|
987
|
+
const exit = await runTurn(prompt, true, trigger, { state: true });
|
|
988
|
+
return { exit, stdout: lastEnvelope, cut: lastTurnCut };
|
|
989
|
+
},
|
|
990
|
+
executeAction: async (action) => {
|
|
991
|
+
// `ask` is the one action with an outside effect. State does NOT move cards (§4.7) — the
|
|
992
|
+
// seat calls relay_task_move itself — so `done` and `continue` are recorded and nothing else.
|
|
993
|
+
const ask = String(action?.ask ?? "").trim();
|
|
994
|
+
if (ask) await notifyAssigners(assigners, `❓ ${SESSION} asks on #${card}: ${ask}`);
|
|
995
|
+
},
|
|
996
|
+
},
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
statePromotedHash = r.promoted;
|
|
1000
|
+
if (r.patchRecord) patchLedger.push(r.patchRecord);
|
|
1001
|
+
|
|
1002
|
+
// §7.3's breaker: a seat whose rolling MALFORMED rate is over budget goes back to the transcript
|
|
1003
|
+
// path, and the room is told ONCE. Never repeated — a warning repeated every turn is the thing
|
|
1004
|
+
// the monitoring doctrine calls noise, and the condition is a state, not an event.
|
|
1005
|
+
if (!breakerTripped) {
|
|
1006
|
+
const v = breakerVerdict(patchLedger, SESSION, { window: BREAKER_WINDOW });
|
|
1007
|
+
if (v.tripped) {
|
|
1008
|
+
breakerTripped = true;
|
|
1009
|
+
log(`\x1b[31mstate-mode CIRCUIT BREAKER tripped — ${v.message}; falling back to the transcript path\x1b[0m`);
|
|
1010
|
+
await api("/send", {
|
|
1011
|
+
from: SESSION, to: "all", project: PROJ, kind: "status",
|
|
1012
|
+
text: `⚠️ ${SESSION} left Trantor State: ${v.message} (TDD §7.3 breaker) — back on the transcript path`,
|
|
1013
|
+
}).catch(() => {});
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
if (!r.ok) {
|
|
1018
|
+
// A rejection is NOT a failed turn. The patch was refused, the state is untouched, and the
|
|
1019
|
+
// rejection is the next step's observation — which is the loop working, not the seat dying.
|
|
1020
|
+
// Only the transport's own exit decides the runner's ladder, and a rejected patch on an exit-0
|
|
1021
|
+
// CLI is an exit-0 turn.
|
|
1022
|
+
log(`state step rejected (${r.code}): ${String(r.message).slice(0, 200)}`);
|
|
1023
|
+
stateObservation = r.observation;
|
|
1024
|
+
return r.step && r.step.exit ? r.step.exit : 0;
|
|
1025
|
+
}
|
|
1026
|
+
stateObservation = "";
|
|
1027
|
+
return 0;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
|
|
824
1031
|
// ---- main loop ----
|
|
825
1032
|
const KICKOFF = process.env.CREW_KICKOFF ||
|
|
826
1033
|
`You just joined (your arrival was already announced on the bus). 1) relay_inbox — if a contract for you is already waiting, do it now per the Rules. 2) End your turn.\n\n${RULES}`;
|
|
@@ -887,7 +1094,24 @@ function isRunnerSession(session) {
|
|
|
887
1094
|
return /^[a-z0-9_.-]+$/.test(label) && !label.startsWith("hub:");
|
|
888
1095
|
}
|
|
889
1096
|
|
|
1097
|
+
// A hub staleness alert describes a condition that was true for a moment: "#16909 has been
|
|
1098
|
+
// UNDELIVERED for 2m — go nudge someone". Acting on it 22 hours later is meaningless, and the queue
|
|
1099
|
+
// had no expiry, so on 2026-09-09 the duty seat's backlog became SELF-POISONING: the hub kept
|
|
1100
|
+
// noticing undelivered mail and sending more alerts, duty could not work them off, and a restart
|
|
1101
|
+
// faithfully redelivered 49 dead nudges and re-wedged the seat. 46 of those 49 were hub alerts, the
|
|
1102
|
+
// oldest 22.1 hours old, every one describing a two-minute condition.
|
|
1103
|
+
//
|
|
1104
|
+
// So these EXPIRE. Deliberately narrow: only messages the HUB generated about staleness, never a
|
|
1105
|
+
// message from a peer. A real contract is never dropped for being old — a seat that misses a
|
|
1106
|
+
// teammate's request is the failure this bus exists to prevent, and no backlog is worth causing it.
|
|
1107
|
+
const HUB_ALERT_TTL_MS = Number(process.env.TRANTOR_HUB_ALERT_TTL_MS || 30 * 60_000);
|
|
1108
|
+
const isExpiredHubAlert = (m) =>
|
|
1109
|
+
m?.from === "hub:duty" &&
|
|
1110
|
+
Number.isFinite(m?.ts) &&
|
|
1111
|
+
Date.now() - m.ts > HUB_ALERT_TTL_MS;
|
|
1112
|
+
|
|
890
1113
|
function shouldWake(message) {
|
|
1114
|
+
if (isExpiredHubAlert(message)) return false;
|
|
891
1115
|
if (isReceipt(message) || isStatusBroadcast(message)) return false;
|
|
892
1116
|
// #6134: the SENDER decides. `wake:false` says "this is context, not a contract" — it batches
|
|
893
1117
|
// into the next turn's prompt like a broadcast and never buys a CLI session of its own.
|
|
@@ -957,8 +1181,22 @@ function askedExcerpt(message) {
|
|
|
957
1181
|
// broadcasts batched behind them. Restored from disk first: a runner that was killed mid-turn
|
|
958
1182
|
// (or a machine that rebooted) still owes those messages, and the hub will never send them again.
|
|
959
1183
|
const restored = loadPending();
|
|
1184
|
+
// Say what the restore SHED, not just what it kept. A queue that quietly halves itself on restart
|
|
1185
|
+
// is indistinguishable from one that lost real work, and this is the moment the expiry above
|
|
1186
|
+
// actually bites — a wedged seat comes back carrying only what still means something.
|
|
1187
|
+
const shed = restored.wake.filter(isExpiredHubAlert).length +
|
|
1188
|
+
restored.bcast.filter(isExpiredHubAlert).length;
|
|
960
1189
|
let pendingWake = restored.wake.filter(shouldWake);
|
|
961
|
-
let pendingBcast = restored.bcast.filter(m => !isReceipt(m) && !isStatusBroadcast(m));
|
|
1190
|
+
let pendingBcast = restored.bcast.filter(m => !isExpiredHubAlert(m) && !isReceipt(m) && !isStatusBroadcast(m));
|
|
1191
|
+
if (shed) {
|
|
1192
|
+
log(`\x1b[33mdropped ${shed} expired hub staleness alert(s) older than ${Math.round(HUB_ALERT_TTL_MS / 60000)}m — they describe conditions that have long since changed\x1b[0m`);
|
|
1193
|
+
// Write the shed queue back NOW rather than waiting for the next failed delivery to persist it.
|
|
1194
|
+
// Caught live on 2026-09-09: after a restart shed 3 of 4, `trantor duty status` still reported
|
|
1195
|
+
// 4 held, because status reads the FILE and the file was still the pre-shed one. Disk and memory
|
|
1196
|
+
// disagreeing is the whole class of bug this day was about — a health check cannot be honest if
|
|
1197
|
+
// the state it reads is stale.
|
|
1198
|
+
savePending(pendingWake, pendingBcast);
|
|
1199
|
+
}
|
|
962
1200
|
let retryAt = 0; // 0 = deliver at the next opportunity
|
|
963
1201
|
let deliveryFails = 0; // consecutive failed attempts at the SAME pending batch
|
|
964
1202
|
if (pendingWake.length) log(`\x1b[33m${pendingWake.length} message(s) survived from a previous run — redelivering\x1b[0m`);
|
|
@@ -1073,6 +1311,17 @@ function askedExcerpt(message) {
|
|
|
1073
1311
|
messages: wake,
|
|
1074
1312
|
statePath: DUTY_NUDGE_STATE,
|
|
1075
1313
|
owner: `${RUNNER_ID}:${TURN + 1}`,
|
|
1314
|
+
// Has the recipient already read it? /peer — SINGULAR — is the only endpoint that serialises
|
|
1315
|
+
// deliveredUpTo (/peers does not, a gap that already cost one wrong diagnosis today). The
|
|
1316
|
+
// cursor is monotonic, so `>= id` means the message was handed over and there is nothing to
|
|
1317
|
+
// nudge about. Best-effort by design: any failure here leaves the nudge standing, because a
|
|
1318
|
+
// missed nudge is worse than a redundant one.
|
|
1319
|
+
isDelivered: async ({ id, recipient }) => {
|
|
1320
|
+
if (!recipient || !/^\d+$/.test(String(id))) return false;
|
|
1321
|
+
const r = await api(`/peer?session=${encodeURIComponent(recipient)}`).catch(() => null);
|
|
1322
|
+
const upTo = Number(r?.deliveredUpTo || 0);
|
|
1323
|
+
return upTo > 0 && upTo >= Number(id);
|
|
1324
|
+
},
|
|
1076
1325
|
})
|
|
1077
1326
|
: { items: [], targets: [], owner: "" };
|
|
1078
1327
|
const claimedIds = new Set(dutyPlan.items.map(item => item.id));
|
|
@@ -1126,7 +1375,19 @@ function askedExcerpt(message) {
|
|
|
1126
1375
|
});
|
|
1127
1376
|
const stopDutyNudgeWatcher = startDutyNudgeWatcher(dutyPlan, tStart);
|
|
1128
1377
|
let ec;
|
|
1129
|
-
|
|
1378
|
+
const stateStep = STATE_MODE && !breakerTripped && card > 0;
|
|
1379
|
+
try {
|
|
1380
|
+
ec = stateStep
|
|
1381
|
+
? await stateTurn({
|
|
1382
|
+
card, trigger: deliveryFails ? `${trigger} (redelivery)` : trigger, assigners,
|
|
1383
|
+
// The observation is everything that CHANGED since the last step — the wake, the
|
|
1384
|
+
// broadcasts, the redelivery note, and any rejection the last step earned. None of it
|
|
1385
|
+
// may reach the preamble, or the prefix stops being byte-identical and the cache claim
|
|
1386
|
+
// dies quietly (§4.6).
|
|
1387
|
+
observation: [stateObservation, wakeText, ctxText, againText + freshText].filter(Boolean).join("\n"),
|
|
1388
|
+
})
|
|
1389
|
+
: await runTurn(prompt, fresh, deliveryFails ? `${trigger} (redelivery)` : trigger);
|
|
1390
|
+
}
|
|
1130
1391
|
finally { stopDutyNudgeWatcher(); }
|
|
1131
1392
|
const secs = Math.round((Date.now() - tStart) / 1000);
|
|
1132
1393
|
let skippedNudges = [];
|
|
@@ -1183,6 +1444,21 @@ function askedExcerpt(message) {
|
|
|
1183
1444
|
const parkReason = PARKING_REASONS.has(reason) ? reason : (lastTurnCut ? "time-box" : "api-error");
|
|
1184
1445
|
if (PARKING_REASONS.has(reason) || deliveryFails >= 2) {
|
|
1185
1446
|
retryAt = await parkSeat(parkReason, pendingWake.length, quotaReset);
|
|
1447
|
+
// A supervised seat does not have to sit parked until someone notices. RUNNER_PARK_MAX_MS
|
|
1448
|
+
// is set only by `trantor duty up`, which runs the seat under a launchd keepalive: past the
|
|
1449
|
+
// ceiling, exit and let the supervisor restart it clean — a fresh process re-reads auth and
|
|
1450
|
+
// redelivers the queue from disk, which is exactly what un-wedged the 2026-09-09 incident
|
|
1451
|
+
// when the operator finally ran `trantor duty up` by hand 21.9 hours late.
|
|
1452
|
+
// Unsupervised seats keep the old behaviour: exiting would just kill them for good.
|
|
1453
|
+
const parkMax = Number(process.env.RUNNER_PARK_MAX_MS || 0);
|
|
1454
|
+
if (parkMax > 0) {
|
|
1455
|
+
const wakeIn = Math.max(0, Math.min(retryAt - Date.now(), parkMax));
|
|
1456
|
+
log(`\x1b[33msupervised seat: exiting in ${Math.round(wakeIn / 1000)}s so the keepalive restarts it clean\x1b[0m`);
|
|
1457
|
+
setTimeout(() => {
|
|
1458
|
+
log("parked past the ceiling — exiting for the keepalive to relaunch");
|
|
1459
|
+
process.exit(0); // 0, not 1: this is a deliberate hand-off, not a crash
|
|
1460
|
+
}, wakeIn).unref?.();
|
|
1461
|
+
}
|
|
1186
1462
|
await notifyAssigners(assigners,
|
|
1187
1463
|
`⛔ your contract is PARKED on ${SESSION} (${parkReason}) — not retrying · asked: "${asked}"`);
|
|
1188
1464
|
lastTurnAt = Date.now();
|
package/bin/doctor.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Checks: runtime, hub, plugin, each CLI (installed? wired? AUTHENTICATED?), API keys,
|
|
4
4
|
// quota profile, optional Scrooge brain. Prints a checklist with copy-paste fixes.
|
|
5
5
|
// node bin/doctor.mjs
|
|
6
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
6
|
+
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
7
7
|
import { join, dirname } from "node:path";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { execSync } from "node:child_process";
|
|
@@ -304,6 +304,66 @@ prof?.providers && Object.keys(prof.providers).length
|
|
|
304
304
|
? ok(`quota profile set (${Object.entries(prof.providers).map(([k, v]) => `${k}=${v.plan}`).join(", ")})`)
|
|
305
305
|
: warn("quota profile not set — the Advisor will assume API billing everywhere", `node ${join(ROOT, "bin", "profile.mjs")} set claude=max codex=plus deepseek=api … (use YOUR real plans)`);
|
|
306
306
|
|
|
307
|
+
// fleet — is the crew actually OPERATING, not just installed?
|
|
308
|
+
//
|
|
309
|
+
// Added 2026-09-09, because on that morning this command reported nine issues, every one about
|
|
310
|
+
// provider keys and billing attribution, while the duty seat had been holding 48 undelivered
|
|
311
|
+
// messages for 21.9 hours and the orchestrator had slept through a night of finished crew work.
|
|
312
|
+
// Doctor checked whether credentials EXIST. Nothing checked whether the fleet was MOVING. These
|
|
313
|
+
// two signals are both already on disk, written by the runner itself — nobody was reading them.
|
|
314
|
+
section("the fleet (is it actually running?)");
|
|
315
|
+
{
|
|
316
|
+
const busDir = join(H, ".agent-bus");
|
|
317
|
+
// 1. Undelivered queues. crew-runner persists pending-<agent>-<project>.json on every failed
|
|
318
|
+
// delivery and unlinks it when the queue drains, so a file with an old head means mail is
|
|
319
|
+
// stuck for that seat — whatever its process table says.
|
|
320
|
+
let stuck = 0;
|
|
321
|
+
const abandonedSeats = [];
|
|
322
|
+
try {
|
|
323
|
+
for (const f of readdirSync(busDir).filter(n => n.startsWith("pending-") && n.endsWith(".json"))) {
|
|
324
|
+
const j = read(join(busDir, f));
|
|
325
|
+
const held = [...(j?.wake || []), ...(j?.bcast || [])];
|
|
326
|
+
if (!held.length) continue;
|
|
327
|
+
const stamps = held.map(m => m?.ts).filter(Number.isFinite);
|
|
328
|
+
const oldest = stamps.length ? Math.min(...stamps) : j?.ts;
|
|
329
|
+
const hours = (Date.now() - oldest) / 3.6e6;
|
|
330
|
+
const seat = f.replace(/^pending-|\.json$/g, "");
|
|
331
|
+
// Three bands, because one flat warning per stuck queue is its own failure: this machine has
|
|
332
|
+
// leftovers from projects that ended weeks ago, and a doctor that cries about ten of them
|
|
333
|
+
// every run teaches you to skim past the one that matters. Under an hour is the retry ladder
|
|
334
|
+
// doing its job. Over a week is an abandoned seat, worth tidying, not worth alarming about.
|
|
335
|
+
// The band between is the live stall — the shape of the 2026-09-09 incident.
|
|
336
|
+
const abandoned = hours >= 24 * 7;
|
|
337
|
+
if (hours >= 1 && !abandoned) {
|
|
338
|
+
stuck++;
|
|
339
|
+
warn(`${seat}: ${held.length} message(s) undelivered, oldest ${hours.toFixed(1)}h old — mail is not moving`,
|
|
340
|
+
`the runner parks on quota/api failure and only a restart un-parks it: trantor up ${seat.split("-")[0]} (duty: trantor duty up)`);
|
|
341
|
+
} else if (abandoned) {
|
|
342
|
+
abandonedSeats.push(`${seat} (${(hours / 24).toFixed(0)}d)`);
|
|
343
|
+
} else {
|
|
344
|
+
note(`${seat}: ${held.length} queued, oldest ${hours.toFixed(1)}h — within the retry ladder`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (abandonedSeats.length) {
|
|
348
|
+
note(`${abandonedSeats.length} abandoned queue(s) older than a week: ${abandonedSeats.join(", ")} — leftovers from finished work, safe to delete`);
|
|
349
|
+
}
|
|
350
|
+
if (!stuck) ok("no live seat is sitting on undelivered mail");
|
|
351
|
+
} catch { note(`no bus directory at ${busDir} yet — nothing has run`); }
|
|
352
|
+
|
|
353
|
+
// 2. Park alerts. notifyOperator appends one line per park, so a park that happened while nobody
|
|
354
|
+
// was at the machine is still visible here afterwards — the point of writing it to disk.
|
|
355
|
+
try {
|
|
356
|
+
const alerts = readFileSync(join(busDir, "alerts.jsonl"), "utf8").trim().split("\n").filter(Boolean);
|
|
357
|
+
const recent = alerts.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
358
|
+
.filter(a => a && Date.now() - a.ts < 24 * 3.6e6);
|
|
359
|
+
if (recent.length) {
|
|
360
|
+
const last = recent[recent.length - 1];
|
|
361
|
+
warn(`${recent.length} seat park alert(s) in the last 24h — most recent: ${last.title}`,
|
|
362
|
+
`read them: tail ~/.agent-bus/alerts.jsonl — then restart the seat named above`);
|
|
363
|
+
} else ok("no seat has parked in the last 24h");
|
|
364
|
+
} catch { ok("no seat has parked in the last 24h"); }
|
|
365
|
+
}
|
|
366
|
+
|
|
307
367
|
say(issues ? `\n${issues} issue(s) — fix the → lines above, then re-run the doctor.` : "\nAll clear — open a claude session in any project and say: \"fire up the crew\".");
|
|
308
368
|
// Must come BEFORE the exit — process.exit() here truncated the report entirely.
|
|
309
369
|
if (JSON_MODE) console.log(JSON.stringify({ ...REPORT, issueCount: issues }));
|
package/bin/drill-surface.mjs
CHANGED
|
@@ -35,6 +35,9 @@ export const CARD_STEPS = {
|
|
|
35
35
|
6668: { steps: ["S6-handoff"], autoClose: true },
|
|
36
36
|
6317: { steps: ["S6-key-post", "S6-key-throw"], autoClose: false, recipe: "covered by in-app Drill Mode: open a real Workspace with a live terminal pane and exercise key dispatch in the terminal and composer" },
|
|
37
37
|
6667: { steps: ["S4b", "S7"], autoClose: true },
|
|
38
|
+
// P9 (#6965): the Phase-1 handoff carries a WorkingState and it carries NO credit. Never
|
|
39
|
+
// auto-closes — with the flag off the step SKIPs, and a skip must not be mistaken for proof.
|
|
40
|
+
6965: { steps: ["S4c"], autoClose: false, recipe: "set TRANTOR_STATE_HANDOFF=1 on a dogfood card, run `trantor drill`, and read S4c: the carried state must show 0 credited paths" },
|
|
38
41
|
6587: { steps: ["S8"], autoClose: false, recipe: "needs live duty probe: operator removes the trantor/trantor-duty link, DMs the idle orchestrator, and checks for a socket nudge within 3 minutes" },
|
|
39
42
|
6481: { steps: ["S9"], autoClose: true },
|
|
40
43
|
6483: { steps: ["S10"], autoClose: false, recipe: "covered by in-app Drill Mode (#6800): operator checks Accounts with the CLI below the app minimum, then restores the CLI" },
|
|
@@ -346,6 +349,50 @@ step("S4 · handoff machine: warn → arm → fire → WRITTEN → successor cla
|
|
|
346
349
|
}, { timeoutMs: 120_000, everyMs: 2_000 });
|
|
347
350
|
if (recapped) PASS("first Stop recorded RECAPPED and cleared the net", recapped.states.map(s => s.state).join("→"));
|
|
348
351
|
else FAIL("first Stop recorded RECAPPED and cleared the net", stamp() ? "stamp still present" : "no recapped state");
|
|
352
|
+
|
|
353
|
+
// ---------- S4c · the handoff carries a WorkingState, and it carries NO CREDIT ----------
|
|
354
|
+
// Rides S4's machine deliberately: the record above is a REAL handoff, written by the real Stop
|
|
355
|
+
// path and claimed by a real successor. Everything else about Phase 1 is proven against
|
|
356
|
+
// fixtures, and today taught us what that gap costs — a defect that survived two review rounds,
|
|
357
|
+
// five reviewers and 264 assertions was found only when something finally ran the live path.
|
|
358
|
+
//
|
|
359
|
+
// The load-bearing assertion is the second one. A derived state carries NO credit, because no
|
|
360
|
+
// gate ran: every path must be verified:false, so the successor has to re-earn its evidence
|
|
361
|
+
// before anything moves to done. If that ever came back true, the handoff path would be a
|
|
362
|
+
// laundering route for unverified work — a hole in the verified-done rule shaped exactly like a
|
|
363
|
+
// session boundary. P4's unit tests hold the same property (flipping it kills 4 assertions);
|
|
364
|
+
// this is that property on a real record.
|
|
365
|
+
//
|
|
366
|
+
// Flag OFF is the norm today, and then the correct outcome is a SKIP: the state block must be
|
|
367
|
+
// absent and the prose path untouched. A step that only passes with the feature on would go red
|
|
368
|
+
// for everyone until it ships, and a red nobody can act on is noise.
|
|
369
|
+
const carried = (() => {
|
|
370
|
+
try { return JSON.parse(readFileSync(handoffFile, "utf8")).state ?? null; }
|
|
371
|
+
catch { return null; }
|
|
372
|
+
})();
|
|
373
|
+
if (!carried) {
|
|
374
|
+
SKIP("S4c · handoff carries WorkingState",
|
|
375
|
+
"TRANTOR_STATE_HANDOFF is off — no state block on the record, and the prose path is unchanged. Turn the flag on for a dogfood card to exercise this.");
|
|
376
|
+
} else {
|
|
377
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- SAFETY: `carried` is JSON.parse of a handoff record read off disk — an untrusted I/O boundary, and this is its decode. A record written by an older schema, or a truncated write, can put anything in `files`; Object.keys on a non-object would throw and turn a real drill run into a crash rather than a verdict.
|
|
378
|
+
const files = (carried.files && typeof carried.files === "object") ? carried.files : {};
|
|
379
|
+
const paths = Object.keys(files);
|
|
380
|
+
const credited = paths.filter(p => files[p]?.verified === true);
|
|
381
|
+
if (credited.length === 0) {
|
|
382
|
+
PASS("S4c · the carried state grants NO credit (every path verified:false)",
|
|
383
|
+
`${paths.length} path(s), 0 credited`);
|
|
384
|
+
} else {
|
|
385
|
+
FAIL("S4c · the carried state grants NO credit (every path verified:false)",
|
|
386
|
+
`CREDITED: ${credited.join(", ")} — the handoff is laundering unverified work`);
|
|
387
|
+
}
|
|
388
|
+
// verify is a harness field; a derived state must not arrive claiming a gate ran either.
|
|
389
|
+
const v = carried.verify || {};
|
|
390
|
+
if (v.tested === true) FAIL("S4c · the carried state claims no gate ran", `verify.tested=true cmd=${v.cmd || "?"}`);
|
|
391
|
+
else PASS("S4c · the carried state claims no gate ran", `verify.tested=${v.tested ?? "absent"}`);
|
|
392
|
+
|
|
393
|
+
if (carried.schema_version) PASS("S4c · the carried state is schema-stamped", `v${carried.schema_version}`);
|
|
394
|
+
else FAIL("S4c · the carried state is schema-stamped", "no schema_version on the record");
|
|
395
|
+
}
|
|
349
396
|
}
|
|
350
397
|
}
|
|
351
398
|
|