trantor 0.18.77 → 0.18.78
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/crew-runner.mjs +83 -13
- package/bin/summarize.mjs +5 -15
- package/hooks/heartbeat.mjs +4 -2
- package/hooks/prompt-focus.mjs +30 -12
- package/lib/classify-failure.mjs +7 -0
- package/lib/turn-policy.mjs +23 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.78",
|
|
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/crew-runner.mjs
CHANGED
|
@@ -16,13 +16,13 @@ import { applyWorktreeDeclaration, ensureSeatWorktree, provisioningLines, readWo
|
|
|
16
16
|
import {
|
|
17
17
|
AUTH_MARKER_RE, classifyFailure, looksLikeAuthDeath,
|
|
18
18
|
verdictFor, substantiveOutput, stallVerdict, cutSignalFor,
|
|
19
|
-
BILLING_RE, looksLikeBillingDeath, usageSaysNoWork, zeroUsageVerdict,
|
|
19
|
+
BILLING_RE, looksLikeBillingDeath, usageSaysNoWork, zeroUsageVerdict, noDeliveryVerdict,
|
|
20
20
|
permissionRejection, looksLikePermissionDeath,
|
|
21
21
|
readPromptText, stripPromptEcho,
|
|
22
22
|
} from "../lib/classify-failure.mjs";
|
|
23
23
|
import { capWake, capBcast, pickLessons, composePrompt, contractBase, baseLine } from "./crew-payload.mjs";
|
|
24
24
|
import {
|
|
25
|
-
cardRefs,
|
|
25
|
+
cardRefs, servedContractCard, carriesWork, parseTurnTokens, parseResetAt, reasonWithBalances, quotaResetAt, PARKING_REASONS,
|
|
26
26
|
senderProjectOf, isLinkedProject, stateSkipReason, isMessageCardTitle, OPEN_CARD_STATUSES,
|
|
27
27
|
CUT_CHAIN_PARK_MIN, cutChainEvidence, isBoundedPark,
|
|
28
28
|
} from "../lib/turn-policy.mjs";
|
|
@@ -279,6 +279,11 @@ let lastEmptyOutput = false;
|
|
|
279
279
|
// the CLI printed its banner and quit. The both-streams-silent rule above stays; this is the
|
|
280
280
|
// second, harder-to-see empty shape.
|
|
281
281
|
let lastEmptyTurn = false;
|
|
282
|
+
// #10197: exit 0 on a turn BOUND to a card contract with neither a new commit nor a card move to
|
|
283
|
+
// testing/done — the ibkr shape (48s, prose, card still todo, announced done). The card's status
|
|
284
|
+
// at turn end rides along so the assigner's notice can name it.
|
|
285
|
+
let lastNoDelivery = false;
|
|
286
|
+
let lastNoDeliveryStatus = "";
|
|
282
287
|
const ERRF = join(homedir(), ".agent-bus", `err-${AGENT}-${PROJ}.txt`);
|
|
283
288
|
const DUTY_NUDGES = process.env.RUNNER_DUTY_NUDGES === "1";
|
|
284
289
|
const DUTY_NUDGE_STATE = process.env.RUNNER_DUTY_NUDGE_STATE
|
|
@@ -742,6 +747,16 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
|
|
|
742
747
|
// #7759: the worktree snapshot the turn is judged against at its end — porcelain covers edits
|
|
743
748
|
// AND new untracked files, which a HEAD-only comparison misses.
|
|
744
749
|
const statusBefore = gitOut(["status", "--porcelain"], TURN_DIR);
|
|
750
|
+
// #10197: the card status snapshot the turn is judged against at its end — a move to
|
|
751
|
+
// testing/done under the turn is delivery even without a commit. null = the hub read failed:
|
|
752
|
+
// the delivery check stands down rather than claim no-delivery on a transport error.
|
|
753
|
+
let cardStatusBefore = null;
|
|
754
|
+
if (!opts.state && sessionCard > 0) {
|
|
755
|
+
try {
|
|
756
|
+
const { task } = await api(`/card?project=${encodeURIComponent(PROJ)}&id=${sessionCard}`);
|
|
757
|
+
cardStatusBefore = task ? String(task.status || "") : null;
|
|
758
|
+
} catch { cardStatusBefore = null; }
|
|
759
|
+
}
|
|
745
760
|
// #9778: the docker snapshot the turn is judged against — containers the CLI spawns are recorded
|
|
746
761
|
// against the seat's card at turn end, then stopped when the card closes or the seat parks.
|
|
747
762
|
const dockerBefore = listContainers();
|
|
@@ -1011,16 +1026,38 @@ exit $turn_exit`;
|
|
|
1011
1026
|
effExit = 1;
|
|
1012
1027
|
log("\x1b[31mexit 0 but the session usage record resolved to all zeros — no model work happened; treating as FAILED\x1b[0m");
|
|
1013
1028
|
}
|
|
1029
|
+
// #10197: a turn BOUND to a card contract reads done only on DELIVERY — a NEW commit since the
|
|
1030
|
+
// turn started, or the card moved to testing/done under it. The ibkr shape (exit 0, output, no
|
|
1031
|
+
// commit, card still todo) is NO-DELIVERY: the ledger names it and the contract stays owed.
|
|
1032
|
+
// Judged on hub reads of the card status; a failed read keeps today's label, never no-delivery.
|
|
1033
|
+
let cardStatusAtEnd = "", noDelivery = false;
|
|
1034
|
+
if (!cut && !opts.state && realExit === 0 && effExit === 0 && sessionCard > 0 && cardStatusBefore !== null) {
|
|
1035
|
+
let endStatus = null;
|
|
1036
|
+
try {
|
|
1037
|
+
const { task } = await api(`/card?project=${encodeURIComponent(PROJ)}&id=${sessionCard}`);
|
|
1038
|
+
endStatus = task ? String(task.status || "") : null;
|
|
1039
|
+
} catch { endStatus = null; }
|
|
1040
|
+
if (endStatus !== null) {
|
|
1041
|
+
cardStatusAtEnd = endStatus;
|
|
1042
|
+
const moved = ["testing", "done"].includes(endStatus) && !["testing", "done"].includes(cardStatusBefore);
|
|
1043
|
+
noDelivery = !newCommit && !moved;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
lastNoDelivery = noDelivery;
|
|
1047
|
+
lastNoDeliveryStatus = cardStatusAtEnd;
|
|
1048
|
+
if (noDelivery) log(`\x1b[33mexit 0 but the turn DELIVERED NOTHING on #${sessionCard} — no new commit, card still ${cardStatusAtEnd || "unknown"}\x1b[0m`);
|
|
1014
1049
|
// #5868: the verdict rides the telemetry row so a classification survives the pane scrolling
|
|
1015
1050
|
// away — the same "classified X because Y" shape the runner logs. A silent cut names its own
|
|
1016
1051
|
// verdict (#7752): the exit under it is the sweep's SIGPIPE. A box cut passes `cut` so its 137
|
|
1017
1052
|
// reads as the sweep's SIGKILL (#7099); a zero-usage turn names its own verdict too (#9724).
|
|
1018
|
-
const verdict = stallCut ? stallVerdict() : zeroUsage ? zeroUsageVerdict()
|
|
1053
|
+
const verdict = stallCut ? stallVerdict() : zeroUsage ? zeroUsageVerdict()
|
|
1054
|
+
: noDelivery ? noDeliveryVerdict(sessionCard, cardStatusAtEnd || "unknown")
|
|
1055
|
+
: verdictFor(realExit, effExit, lastEmptyOutput, ownOut, lastEmptyTurn, cut);
|
|
1019
1056
|
// #6289: every ledger row names in ONE field what happened to the turn — cut, stalled (#7752),
|
|
1020
1057
|
// api-error, completed — and what it cost (0 means "not reported", never "free"). #7762: `card`
|
|
1021
1058
|
// binds the row to the card the turn worked (0 = kickoff/pulse) so the seat record attributes
|
|
1022
1059
|
// empty/stalled turns to the card that produced nothing. `cut` stays too: the drills read it.
|
|
1023
|
-
const outcome = cut ? (stallCut ? "stalled" : "cut") : (effExit !== 0 ? "api-error" : lastEmptyTurn ? "empty" : "completed");
|
|
1060
|
+
const outcome = cut ? (stallCut ? "stalled" : "cut") : (effExit !== 0 ? "api-error" : noDelivery ? "no-delivery" : lastEmptyTurn ? "empty" : "completed");
|
|
1024
1061
|
// #7756: a clean turn that ASKED its assigner is demoted-but-owed, not "completed". The judge
|
|
1025
1062
|
// (deliverWake's /contracts read) renames the ledger row, so "asked" is what the log keeps.
|
|
1026
1063
|
let finalOutcome = outcome;
|
|
@@ -1251,7 +1288,9 @@ async function newestOwnCard(session, why) {
|
|
|
1251
1288
|
} catch { return 0; } // board unreadable: no binding beats a guessed one
|
|
1252
1289
|
}
|
|
1253
1290
|
async function resolveWakeCard(messages, { session }) {
|
|
1254
|
-
|
|
1291
|
+
// #10824: the contract message BEING SERVED names the card — the newest direct message's own
|
|
1292
|
+
// binding — never a contract still queued behind it and never the session's previous card.
|
|
1293
|
+
const cited = servedContractCard(messages, { session });
|
|
1255
1294
|
if (!(cited > 0)) return 0;
|
|
1256
1295
|
try {
|
|
1257
1296
|
const { task } = await api(`/card?project=${encodeURIComponent(PROJ)}&id=${cited}`);
|
|
@@ -1312,6 +1351,7 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1312
1351
|
}
|
|
1313
1352
|
let retryAt = 0; // 0 = deliver at the next opportunity
|
|
1314
1353
|
let deliveryFails = 0; // consecutive failed attempts at the SAME pending batch
|
|
1354
|
+
let noDeliveryRetry = false;
|
|
1315
1355
|
if (pendingWake.length) log(`\x1b[33m${pendingWake.length} message(s) survived from a previous run — redelivering\x1b[0m`);
|
|
1316
1356
|
|
|
1317
1357
|
// #7060: the turn the boot line was read as a promise about. It is a transcript turn by
|
|
@@ -1455,6 +1495,12 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1455
1495
|
// a bounded park instead of queueing behind the window; a mention does not, because the room
|
|
1456
1496
|
// talking is not a request. An exhausted or auth park is a wall and stands anyway (#6134).
|
|
1457
1497
|
const directWake = wake.filter(m => m.to === SESSION);
|
|
1498
|
+
// #10197: no-delivery owes the old contract, but its retry delay must not batch away
|
|
1499
|
+
// fresh contracts. Only actual CLI failures impose backoff on a new wake.
|
|
1500
|
+
if (noDeliveryRetry) {
|
|
1501
|
+
unpark("new wake after no-delivery");
|
|
1502
|
+
retryAt = 0; deliveryFails = 0; noDeliveryRetry = false;
|
|
1503
|
+
}
|
|
1458
1504
|
if (parkState && directWake.length) {
|
|
1459
1505
|
if (isBoundedPark(parkState.reason)) {
|
|
1460
1506
|
unpark(`direct message from ${directWake[0].from || "?"}`);
|
|
@@ -1491,6 +1537,7 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1491
1537
|
} catch { return 0; }
|
|
1492
1538
|
}
|
|
1493
1539
|
async function deliverWake() {
|
|
1540
|
+
noDeliveryRetry = false;
|
|
1494
1541
|
// #7756: captured and cleared HERE so an early return below never leaks the flag into a
|
|
1495
1542
|
// later turn — only the turn the answer released reads it.
|
|
1496
1543
|
const resumedAfterAsk = askReleased;
|
|
@@ -1560,12 +1607,13 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1560
1607
|
// #6134: ONE SESSION PER CARD; a different card starts a fresh CLI session and the seat is told.
|
|
1561
1608
|
// #7061: bound by SHAPE, not position, so an order opening with what shipped binds the right card.
|
|
1562
1609
|
// #7763: a message-card (or phantom) citation never binds — the seat's own newest card does.
|
|
1563
|
-
|
|
1610
|
+
// #10824: the binding is the SERVED contract's; #7756's answer-released turn keeps the held card.
|
|
1611
|
+
const card = resumedAfterAsk ? sessionCard : await resolveWakeCard(wakeForTurn, { session: SESSION });
|
|
1564
1612
|
// #7754: a `base:` sha this worktree cannot resolve is never worked from origin/main — the card
|
|
1565
1613
|
// goes to blocked, the assigner is told, and no model turn is spent. The check keeps the id the
|
|
1566
|
-
//
|
|
1614
|
+
// served contract cites: #7763's rebinding covers confirmed message-card ids only, never a
|
|
1567
1615
|
// card cited in the text, so the unresolvable-base branch blocks the cited card.
|
|
1568
|
-
const baseCard = card ||
|
|
1616
|
+
const baseCard = card || servedContractCard(wakeForTurn, { session: SESSION });
|
|
1569
1617
|
const base = contractBase(wakeForTurn) || localMainHead();
|
|
1570
1618
|
if (base && baseCard && !resolvesHere(base)) {
|
|
1571
1619
|
const text = `cannot resolve base ${base} in ${TURN_DIR} (git cat-file -e failed) — #${baseCard} moved to blocked; push or fetch that sha, or name one this worktree can reach`;
|
|
@@ -1579,13 +1627,13 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1579
1627
|
return;
|
|
1580
1628
|
}
|
|
1581
1629
|
const baseText = base ? `\n${baseLine(base)}\n` : "";
|
|
1582
|
-
// #6289: the
|
|
1583
|
-
//
|
|
1584
|
-
//
|
|
1585
|
-
//
|
|
1630
|
+
// #6289: the session SHAPE (fresh vs resume) reads the served contract's raw citation — a wake
|
|
1631
|
+
// whose card the board lacks still opens fresh, never a bare resume of the kickoff's CLI.
|
|
1632
|
+
// #10824: the LABEL is the binding, 0 included — a wake that binds nothing labels its turn 0
|
|
1633
|
+
// and never inherits the previous contract's card (#10197's judge then reads the right card).
|
|
1586
1634
|
const sessionCardForTurn = card || baseCard;
|
|
1587
1635
|
const fresh = !resumedAfterAsk && sessionCardForTurn > 0 && sessionCardForTurn !== sessionCard;
|
|
1588
|
-
|
|
1636
|
+
sessionCard = card;
|
|
1589
1637
|
const cited = [...new Set(wakeForTurn.flatMap(m => cardRefs(m.text)))];
|
|
1590
1638
|
const freshText = fresh
|
|
1591
1639
|
? `\n(FRESH SESSION for card #${card} — you are not the session that worked earlier cards and you remember none of them. Read your card first: relay_board with card:${card}.)\n`
|
|
@@ -1602,6 +1650,7 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1602
1650
|
const stopDutyNudgeWatcher = startDutyNudgeWatcher(dutyPlan, tStart);
|
|
1603
1651
|
let ec;
|
|
1604
1652
|
turnAsk = null; // #7756: the judge reads THIS turn's ending, never a previous turn's ask
|
|
1653
|
+
lastNoDelivery = false; lastNoDeliveryStatus = ""; // #10197: runTurn judges THIS turn only
|
|
1605
1654
|
const stateStep = !stateSkip("wake", card);
|
|
1606
1655
|
try {
|
|
1607
1656
|
ec = stateStep
|
|
@@ -1723,6 +1772,27 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1723
1772
|
savePending(pendingWake, pendingBcast);
|
|
1724
1773
|
await reportHealthy();
|
|
1725
1774
|
log(`turn asked its assigner — holding the contract until ask #${awaitingAsk.id} is answered`);
|
|
1775
|
+
} else if (lastNoDelivery) {
|
|
1776
|
+
// #10197: the turn exited 0 but DELIVERED NOTHING on its card — no new commit, the card
|
|
1777
|
+
// still short of testing/done. "⚠ no commit" is not a receipt, so the sender's ledger keeps
|
|
1778
|
+
// the contract owed; the queue is kept and the ladder retries — a second no-delivery
|
|
1779
|
+
// attempt parks, exactly like the empty-turn ladder it sits beside.
|
|
1780
|
+
deliveryFails++;
|
|
1781
|
+
noDeliveryRetry = true;
|
|
1782
|
+
savePending(pendingWake, pendingBcast);
|
|
1783
|
+
const still = lastNoDeliveryStatus || "unresolved";
|
|
1784
|
+
if (deliveryFails >= 2) {
|
|
1785
|
+
retryAt = await parkSeat("no-delivery", pendingWake.length);
|
|
1786
|
+
await notifyAssigners(assigners,
|
|
1787
|
+
`⛔ your contract is PARKED on ${SESSION} (no-delivery: two exit-0 turns with no commit and card #${sessionCard} unmoved) · asked: "${asked}"`);
|
|
1788
|
+
lastTurnAt = Date.now();
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
const wait = RETRY_MS[Math.min(deliveryFails - 1, RETRY_MS.length - 1)];
|
|
1792
|
+
retryAt = Date.now() + wait;
|
|
1793
|
+
log(`\x1b[33mno commit, card #${sessionCard} still ${still} — wake not consumed, ${pendingWake.length} message(s) stay owed; retrying in ${Math.round(wait / 1000)}s\x1b[0m`);
|
|
1794
|
+
await notifyAssigners(assigners,
|
|
1795
|
+
`⚠ no commit, card #${sessionCard} still ${still} on ${SESSION} (exit 0, ${secs}s) · asked: "${asked}"`);
|
|
1726
1796
|
} else if (lastEmptyTurn) {
|
|
1727
1797
|
// #7759: the turn exited 0 but was HOLLOW — a banner is not work. The wake is NOT
|
|
1728
1798
|
// consumed: the queue is kept and the ladder retries, and the assigner hears EMPTY,
|
package/bin/summarize.mjs
CHANGED
|
@@ -1,18 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// trantor summarize
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// Title-cleaning makes machine-generated cards readable; it cannot make them MEAN anything. This
|
|
7
|
-
// gives each card a one-line narrative ("assigned — did") written by a CHEAP model from the card's
|
|
8
|
-
// own thread (its events + the messages that cite it). The board then reads as a story a human can
|
|
9
|
-
// follow, which was the ask: "what the agent was assigned, what the agent did, what the flow was."
|
|
10
|
-
//
|
|
11
|
-
// Economics by design (the Scrooge doctrine): candidates are MACHINE-TITLED cards without a
|
|
12
|
-
// summary (--all widens to every unsummarized card), batched into ONE cheap-model call per hub,
|
|
13
|
-
// difficulty easy, capped per run. The summary lands via /task/update and rides the tasks.extra
|
|
14
|
-
// column — permanent, never recomputed. Runs ambiently from the heartbeat (hourly, detached) and
|
|
15
|
-
// on demand as `trantor summarize`.
|
|
2
|
+
// trantor summarize [--project <p>] [--limit N] [--all] [--dry] [--quiet] — gives each machine-titled
|
|
3
|
+
// card a one-line "assigned — did" narrative written by ONE cheap-model call per hub from the card's
|
|
4
|
+
// own thread; lands via /task/update on tasks.extra (permanent, never recomputed). Runs ambiently
|
|
5
|
+
// from the heartbeat (hourly, detached) and on demand as `trantor summarize`.
|
|
16
6
|
import { execSync, spawnSync } from "node:child_process";
|
|
17
7
|
import { readFileSync, existsSync } from "node:fs";
|
|
18
8
|
import { join } from "node:path";
|
|
@@ -40,7 +30,7 @@ const scroogeBin = () => process.env.SCROOGE_BIN
|
|
|
40
30
|
// A title no human wrote: protocol frames, dumped prompts, id soup. These are the cards whose
|
|
41
31
|
// board presence is noise until a narrative replaces them.
|
|
42
32
|
const machineTitled = (t) =>
|
|
43
|
-
/^\s*[<{[]/.test(t) || /<task-notification>|<system-reminder>|toolu_[A-Za-z0-9]/.test(t) ||
|
|
33
|
+
/^\s*[<{[]/.test(t) || /<task-notification>|<system-reminder>|<trantor-succession>|<trantor-takeover>|toolu_[A-Za-z0-9]/.test(t) ||
|
|
44
34
|
/^\s*(subagent|general-purpose|Explore|Task|Plan):/i.test(t) || t.length > 130;
|
|
45
35
|
|
|
46
36
|
const hubs = new Set([config.url || "http://127.0.0.1:4477", ...Object.values(config.hubs || {})]);
|
package/hooks/heartbeat.mjs
CHANGED
|
@@ -119,10 +119,12 @@ async function maybeEarlyWarn(stdinRaw, session) {
|
|
|
119
119
|
// It is marked where the baton fires (here on the backstop, in Stop on the normal path).
|
|
120
120
|
armBaton(sessionId, { projectDir, transcript, reason: "context-warn", windowId, tty, tokens: usage.tokens });
|
|
121
121
|
// Tell the RUNNING agent (once, at arm time): wrap up and author the boundary handoff yourself.
|
|
122
|
-
|
|
122
|
+
// NOT <system-reminder>: CC 2.1.292 escapes that tag in hook output, so the notice would
|
|
123
|
+
// arrive as literal text (#11110). Our own <trantor-…> tags pass through like the other hooks'.
|
|
124
|
+
ARM_CTX = `<trantor-succession>TRANTOR SUCCESSION ARMED — this session is at ${Math.round(usage.frac * 100)}% of its context window, and the baton is armed: your NEXT STOP fires the handoff. You are now responsible for your own succession:\n`
|
|
123
125
|
+ `1. Reach a real task boundary — finish, pause, or checkpoint your in-flight work NOW; do not start new work.\n`
|
|
124
126
|
+ `2. Author (or refresh) the rich handoff from your CURRENT state — via the handoff skill or the relay_handoff MCP tool — so the successor gets the freshest picture, not a digest of a session 36 seconds stale. A fresh model-authored handoff supersedes the auto-digest.\n`
|
|
125
|
-
+ `3. Then end your turn. The Stop hook fires the baton at that boundary.</
|
|
127
|
+
+ `3. Then end your turn. The Stop hook fires the baton at that boundary.</trantor-succession>`;
|
|
126
128
|
} catch {}
|
|
127
129
|
}
|
|
128
130
|
|
package/hooks/prompt-focus.mjs
CHANGED
|
@@ -18,6 +18,25 @@ function readStdin() {
|
|
|
18
18
|
}
|
|
19
19
|
// A prompt that is JUST an acknowledgement/continuation (anchored to end) — not a new focus.
|
|
20
20
|
const ACK = /^(y|yes|yep|yeah|ok|okay|sure|go|go ahead|continue|proceed|do it|please|thanks|thank you|ty|next|k|cool|nice|great|perfect|sounds good|👍)[\s.!]*$/i;
|
|
21
|
+
// A paste arrives wrapped in `<pasted_content id="…">…</pasted_content>`: the wrapper is transport,
|
|
22
|
+
// not content — the operator typed what is inside (#10027's chat.rs rule, same channel). Peel it so
|
|
23
|
+
// the marker check and the focus title see the words, never the tag. Unwrapped text passes through.
|
|
24
|
+
function unwrapPasted(t) {
|
|
25
|
+
const s = String(t || "").trim();
|
|
26
|
+
if (!s.startsWith("<pasted_content")) return s;
|
|
27
|
+
const gt = s.indexOf(">");
|
|
28
|
+
if (gt < 0) return s;
|
|
29
|
+
const close = s.lastIndexOf("</pasted_content>");
|
|
30
|
+
return (close > gt ? s.slice(gt + 1, close) : s.slice(gt + 1)).trim();
|
|
31
|
+
}
|
|
32
|
+
// HARNESS-INJECTED prompts are not a human's focus. A CLOSED list of known prefixes, not "starts
|
|
33
|
+
// with <": a paste merely happens to start with a tag, and a human quoting JSON is still a human.
|
|
34
|
+
// (The old ^\s*[<{[] heuristic silently dropped every pasted operator prompt, #11110.)
|
|
35
|
+
const HARNESS_MARKERS = [
|
|
36
|
+
"<task-notification", "<system-reminder", "<teammate-message",
|
|
37
|
+
"<command-name", "<environment_context", "[SYSTEM NOTIFICATION",
|
|
38
|
+
];
|
|
39
|
+
const isHarnessInjection = (t) => HARNESS_MARKERS.some(m => t.startsWith(m));
|
|
21
40
|
function titleFrom(prompt) {
|
|
22
41
|
let s = String(prompt || "").replace(/\s+/g, " ").trim();
|
|
23
42
|
// strip a leading politeness/imperative wrapper so the card reads as the WORK, not "can you please…"
|
|
@@ -42,7 +61,7 @@ function loadRecapCtx(sessionId) {
|
|
|
42
61
|
const mandate = rec.mode === "unattended"
|
|
43
62
|
? " Then RESUME the handoff's OPEN THREADS immediately — they are your work order; this succession is unattended, do NOT wait for the user."
|
|
44
63
|
: " Then WAIT for the user.";
|
|
45
|
-
return `<
|
|
64
|
+
return `<trantor-takeover>You took over via handoff ${rec.handoffId}. If you have not yet recapped it, your reply MUST begin with the ≤3-sentence recap (task, state, next step) before anything else — including before answering this message.${mandate}</trantor-takeover>`;
|
|
46
65
|
} catch { return ""; }
|
|
47
66
|
}
|
|
48
67
|
function emitAndExit() {
|
|
@@ -61,17 +80,16 @@ try {
|
|
|
61
80
|
// don't card home-dir sessions (matches sessionstart's phantom-project guard)
|
|
62
81
|
if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && cwd === homedir()) { emitAndExit(); }
|
|
63
82
|
const trimmed = prompt.replace(/\s+/g, " ").trim();
|
|
83
|
+
const text = unwrapPasted(trimmed); // a paste's wrapper is transport; judge the words inside
|
|
64
84
|
// skip empties, tiny continuations, and pure acks — they're not a new focus
|
|
65
|
-
if (!
|
|
85
|
+
if (!text || text.length < 12 || ACK.test(text)) { emitAndExit(); }
|
|
66
86
|
// The Accounts ask drill launches a real Claude session, so its scripted prompt traverses this
|
|
67
87
|
// hook just like operator work. It is harness traffic, though, and must never become a focus card.
|
|
68
|
-
if (/\bTRANTOR ASK DRILL\b/.test(
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
if (
|
|
73
|
-
emitAndExit();
|
|
74
|
-
}
|
|
88
|
+
if (/\bTRANTOR ASK DRILL\b/.test(text)) { emitAndExit(); }
|
|
89
|
+
// Task notifications, hook reminders and protocol frames arrive through the same UserPromptSubmit
|
|
90
|
+
// channel, and carding one titled a board card "<task-notification> <task-id>bavlqfmzq</task-id>…"
|
|
91
|
+
// — pure noise a human cannot read.
|
|
92
|
+
if (isHarnessInjection(text)) { emitAndExit(); }
|
|
75
93
|
const project = resolveProject(cwd);
|
|
76
94
|
const session = process.env.RELAY_SESSION
|
|
77
95
|
|| (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${project}`);
|
|
@@ -79,18 +97,18 @@ try {
|
|
|
79
97
|
// two Claude sessions in one project share (and fight over) a single focus card, and sub-agent
|
|
80
98
|
// cards, whose `parent` is exactly this UUID, have nothing to nest under.
|
|
81
99
|
const cc = String(input.session_id || "").slice(0, 120);
|
|
82
|
-
const r = await signedPost("/focus", { session, project, title: titleFrom(
|
|
100
|
+
const r = await signedPost("/focus", { session, project, title: titleFrom(text), by: session, cc }, { session });
|
|
83
101
|
|
|
84
102
|
// Only pay a model when the heuristic actually mangles the prompt. A short, already-clear ask
|
|
85
103
|
// ("fix the login redirect") reads fine as-is and buying a rewrite for it is exactly the kind of
|
|
86
104
|
// reflexive spend the economics doctrine exists to stop.
|
|
87
105
|
const id = r?.json?.id;
|
|
88
|
-
if (id &&
|
|
106
|
+
if (id && text.length > 90 && process.env.TRANTOR_NO_SCROOGE_TITLES !== "1") {
|
|
89
107
|
try {
|
|
90
108
|
const busDir = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
|
|
91
109
|
mkdirSync(busDir, { recursive: true });
|
|
92
110
|
const pf = join(busDir, `focus-prompt-${String(cc || session).replace(/[^A-Za-z0-9_.-]/g, "_")}.txt`);
|
|
93
|
-
writeFileSync(pf,
|
|
111
|
+
writeFileSync(pf, text);
|
|
94
112
|
const worker = join(dirname(dirname(fileURLToPath(import.meta.url))), "bin", "focus-title.mjs");
|
|
95
113
|
// Detached + unref'd + stdio ignored: the hook returns NOW. Nothing downstream waits on this,
|
|
96
114
|
// and a worker that dies takes the heuristic title with it, which is a fine outcome.
|
package/lib/classify-failure.mjs
CHANGED
|
@@ -200,6 +200,13 @@ export function zeroUsageVerdict() {
|
|
|
200
200
|
return "classified zero-usage because the session usage record resolved to all zeros — no model work happened";
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
/** #10197: the verdict line for a card-bound turn that DELIVERED NOTHING — exit 0 with output but
|
|
204
|
+
* no new commit since turn start and the card unmoved (the ibkr specimen: 48s, prose, card still
|
|
205
|
+
* todo, announced done). Named here so the drill pins the string. */
|
|
206
|
+
export function noDeliveryVerdict(card, status) {
|
|
207
|
+
return `classified no-delivery because exit 0 with no new commit and card #${card} still ${status}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
203
210
|
/** Classify one failed turn. Returns { reason, matched } — matched is the evidence excerpt the
|
|
204
211
|
* runner logs, so the next misclassification is diagnosable from the seat log alone. `cut` is the
|
|
205
212
|
* runner's box/stall marker: 137 reads as the sweep's SIGKILL only under it — without the marker
|
package/lib/turn-policy.mjs
CHANGED
|
@@ -53,6 +53,29 @@ export function assignedCardRef(text) {
|
|
|
53
53
|
return m ? Number(m.slice(1).find((g) => g != null)) : 0;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/// #10824: the CONTRACT MESSAGE BEING SERVED decides the card — the newest direct message's own
|
|
57
|
+
/// binding: a hub-stamped `card` field, else the id its own text ASSIGNS (#7061 shape), else its
|
|
58
|
+
/// first citation — never a contract queued behind it or the previous turn's card. Deference: a
|
|
59
|
+
/// newest message that re-cites an older assignment is commentary on it, and #7061's reading stands.
|
|
60
|
+
export function servedContractCard(messages, { session = "" } = {}) {
|
|
61
|
+
const all = (Array.isArray(messages) ? messages : []).filter(Boolean);
|
|
62
|
+
if (!all.length) return 0;
|
|
63
|
+
const direct = session ? all.filter((m) => m.to === session) : [];
|
|
64
|
+
if (!direct.length) return wakeCard(all, { session });
|
|
65
|
+
const sorted = newestLast(direct);
|
|
66
|
+
const served = sorted[sorted.length - 1];
|
|
67
|
+
const stamped = Number(served?.card);
|
|
68
|
+
if (Number.isFinite(stamped) && stamped > 0) return stamped;
|
|
69
|
+
const own = assignedCardRef(served?.text);
|
|
70
|
+
if (own) return own;
|
|
71
|
+
const named = cardRefs(served?.text);
|
|
72
|
+
if (named.length) {
|
|
73
|
+
const olderAssigned = new Set(sorted.slice(0, -1).map((m) => assignedCardRef(m?.text)).filter(Boolean));
|
|
74
|
+
if (!named.some((c) => olderAssigned.has(c))) return named[0];
|
|
75
|
+
}
|
|
76
|
+
return wakeCard(all, { session });
|
|
77
|
+
}
|
|
78
|
+
|
|
56
79
|
/// #7061: the card a wake batch binds to. Direct messages outrank @mentions, the NEWEST assignment wins,
|
|
57
80
|
/// and only an assignment-free batch falls back to the newest message's first citation.
|
|
58
81
|
export function wakeCard(messages, { session = "" } = {}) {
|