trantor 0.18.79 → 0.18.80
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 +18 -15
- package/hooks/lib/handoff-claims.mjs +121 -0
- package/hooks/sessionstart.mjs +3 -55
- package/hooks/stop-inbox.mjs +11 -40
- package/lib/turn-policy.mjs +12 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.80",
|
|
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
|
@@ -24,7 +24,7 @@ import { capWake, capBcast, pickLessons, composePrompt, contractBase, baseLine }
|
|
|
24
24
|
import {
|
|
25
25
|
cardRefs, servedContractCard, carriesWork, parseTurnTokens, parseResetAt, reasonWithBalances, quotaResetAt, PARKING_REASONS,
|
|
26
26
|
senderProjectOf, isLinkedProject, stateSkipReason, isMessageCardTitle, OPEN_CARD_STATUSES,
|
|
27
|
-
CUT_CHAIN_PARK_MIN, cutChainEvidence, isBoundedPark,
|
|
27
|
+
CUT_CHAIN_PARK_MIN, cutChainEvidence, isBoundedPark, askDuringTurn, askedVerdict,
|
|
28
28
|
} from "../lib/turn-policy.mjs";
|
|
29
29
|
import { ocTurnUsage, dshTurnUsage, usageTotal } from "../lib/turn-usage.mjs";
|
|
30
30
|
import { listContainers, newContainers, recordContainers, statePathFor as dockerStatePathFor, sweep as dockerSweep } from "../lib/docker-janitor.mjs";
|
|
@@ -356,17 +356,17 @@ let askReleased = false;
|
|
|
356
356
|
// session. deliverWake judges each clean wake turn against /contracts — an open ask renames the
|
|
357
357
|
// ledger row "asked" and HOLDS the wake (no failure count, no backoff, no park) until the answer.
|
|
358
358
|
let turnAsk = null;
|
|
359
|
-
async function askJudge() {
|
|
359
|
+
async function askJudge(startedAt) {
|
|
360
360
|
try {
|
|
361
361
|
const r = await api(`/contracts?session=${encodeURIComponent(SESSION)}&project=${encodeURIComponent(PROJ)}`);
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
return turnAsk ? "asked" : null;
|
|
362
|
+
const rows = [...(r?.contracts || []), ...(r?.abandonedContracts || []),
|
|
363
|
+
...(r?.supersededContracts || []), ...(r?.ackContracts || [])];
|
|
364
|
+
const ask = askDuringTurn(rows, startedAt);
|
|
365
|
+
turnAsk = ask && !ask.answered ? { id: ask.id, to: String(ask.to || "") } : null;
|
|
366
|
+
return ask ? redactKeys(askedVerdict(ask)) : null;
|
|
368
367
|
} catch { return null; }
|
|
369
368
|
}
|
|
369
|
+
|
|
370
370
|
function savePending(wake, bcast) {
|
|
371
371
|
try {
|
|
372
372
|
// the seen-set (#7778) and a held ask (#7756) keep the file alive even with both queues empty:
|
|
@@ -1031,6 +1031,12 @@ exit $turn_exit`;
|
|
|
1031
1031
|
effExit = 1;
|
|
1032
1032
|
log("\x1b[31mexit 0 but the session usage record resolved to all zeros — no model work happened; treating as FAILED\x1b[0m");
|
|
1033
1033
|
}
|
|
1034
|
+
// #11230: asking is delivery even without a commit or a completed card move.
|
|
1035
|
+
let askVerdict = null;
|
|
1036
|
+
if (!cut && realExit === 0 && effExit === 0 && opts.judgeOutcome) {
|
|
1037
|
+
askVerdict = await opts.judgeOutcome(t0);
|
|
1038
|
+
if (askVerdict) lastEmptyTurn = false;
|
|
1039
|
+
}
|
|
1034
1040
|
// #10197: a turn BOUND to a card contract reads done only on DELIVERY — a NEW commit since the
|
|
1035
1041
|
// turn started, or the card moved to testing/done under it. The ibkr shape (exit 0, output, no
|
|
1036
1042
|
// commit, card still todo) is NO-DELIVERY: the ledger names it and the contract stays owed.
|
|
@@ -1045,7 +1051,7 @@ exit $turn_exit`;
|
|
|
1045
1051
|
if (endStatus !== null) {
|
|
1046
1052
|
cardStatusAtEnd = endStatus;
|
|
1047
1053
|
const moved = ["testing", "done"].includes(endStatus) && !["testing", "done"].includes(cardStatusBefore);
|
|
1048
|
-
noDelivery = !newCommit && !moved;
|
|
1054
|
+
noDelivery = !newCommit && !moved && !askVerdict;
|
|
1049
1055
|
}
|
|
1050
1056
|
}
|
|
1051
1057
|
lastNoDelivery = noDelivery;
|
|
@@ -1056,6 +1062,7 @@ exit $turn_exit`;
|
|
|
1056
1062
|
// verdict (#7752): the exit under it is the sweep's SIGPIPE. A box cut passes `cut` so its 137
|
|
1057
1063
|
// reads as the sweep's SIGKILL (#7099); a zero-usage turn names its own verdict too (#9724).
|
|
1058
1064
|
const verdict = stallCut ? stallVerdict() : zeroUsage ? zeroUsageVerdict()
|
|
1065
|
+
: askVerdict ? askVerdict
|
|
1059
1066
|
: noDelivery ? noDeliveryVerdict(sessionCard, cardStatusAtEnd || "unknown")
|
|
1060
1067
|
: verdictFor(realExit, effExit, lastEmptyOutput, ownOut, lastEmptyTurn, cut);
|
|
1061
1068
|
// #6289: every ledger row names in ONE field what happened to the turn — cut, stalled (#7752),
|
|
@@ -1063,12 +1070,7 @@ exit $turn_exit`;
|
|
|
1063
1070
|
// binds the row to the card the turn worked (0 = kickoff/pulse) so the seat record attributes
|
|
1064
1071
|
// empty/stalled turns to the card that produced nothing. `cut` stays too: the drills read it.
|
|
1065
1072
|
const outcome = cut ? (stallCut ? "stalled" : "cut") : (effExit !== 0 ? "api-error" : noDelivery ? "no-delivery" : lastEmptyTurn ? "empty" : "completed");
|
|
1066
|
-
|
|
1067
|
-
// (deliverWake's /contracts read) renames the ledger row, so "asked" is what the log keeps.
|
|
1068
|
-
let finalOutcome = outcome;
|
|
1069
|
-
if (outcome === "completed" && opts.judgeOutcome) {
|
|
1070
|
-
try { finalOutcome = (await opts.judgeOutcome()) || outcome; } catch {}
|
|
1071
|
-
}
|
|
1073
|
+
const finalOutcome = askVerdict ? "asked" : outcome;
|
|
1072
1074
|
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 };
|
|
1073
1075
|
if (usage) telemetryRow.usage = usage;
|
|
1074
1076
|
if (dockerNew.length) telemetryRow.containers = dockerNew.map(c => c.name || c.id);
|
|
@@ -1775,6 +1777,7 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1775
1777
|
// the ask's id, so a restart keeps holding — and the failure ladder never sees it. The
|
|
1776
1778
|
// assigner is NOT notified here: the kind:ask message itself is already that notification.
|
|
1777
1779
|
awaitingAsk = turnAsk; turnAsk = null;
|
|
1780
|
+
deliveryFails = 0; retryAt = 0; noDeliveryRetry = false;
|
|
1778
1781
|
savePending(pendingWake, pendingBcast);
|
|
1779
1782
|
await reportHealthy();
|
|
1780
1783
|
log(`turn asked its assigner — holding the contract until ask #${awaitingAsk.id} is answered`);
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { asString, asRecord } from "../../lib/decode.mjs";
|
|
2
|
+
import { readFileSync, writeFileSync, renameSync, symlinkSync, readlinkSync, unlinkSync, statSync, readdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { handoffDir } from "../../lib/project.mjs";
|
|
6
|
+
import { readFirstPaths, pathsReadIn } from "./handoff.mjs";
|
|
7
|
+
|
|
8
|
+
export const CLAIM_TTL_SECONDS = 600;
|
|
9
|
+
|
|
10
|
+
export function claimHandoff(record, session, now, transcriptBytes = 0, token = "") {
|
|
11
|
+
if (record.consumed || !session?.session_id) return null;
|
|
12
|
+
if (record.claim && record.claim.expiresAt > now) return null;
|
|
13
|
+
const claim = { ...session, at: now, expiresAt: now + CLAIM_TTL_SECONDS, transcriptBytes, token };
|
|
14
|
+
return { ...record, consumed: false, claim,
|
|
15
|
+
states: [...(record.states || []), { state: "claimed", ts: now, by: session.session_id }] };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function recapHandoff(record, { sessionId, token, replied, missed }, now) {
|
|
19
|
+
if (record.consumed || record.claim?.session_id !== sessionId || record.claim?.token !== token) return null;
|
|
20
|
+
if (!replied || missed.length) return null;
|
|
21
|
+
return { ...record, consumed: true, consumedAt: now, consumedBy: record.claim,
|
|
22
|
+
states: [...(record.states || []), { state: "recapped", ts: now, by: sessionId }] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function hasAssistantReply(body, claimedAt) {
|
|
26
|
+
return body.split("\n").some(line => {
|
|
27
|
+
let row;
|
|
28
|
+
try { row = asRecord(JSON.parse(line)); } catch { return false; }
|
|
29
|
+
if (row?.type !== "assistant" || row.isSidechain) return false;
|
|
30
|
+
const timestamp = asString(row.timestamp);
|
|
31
|
+
if (timestamp && Date.parse(timestamp) < claimedAt * 1000) return false;
|
|
32
|
+
const content = asRecord(row.message)?.content;
|
|
33
|
+
const text = asString(content);
|
|
34
|
+
if (text !== null) return text.trim().length > 0;
|
|
35
|
+
return Array.isArray(content) && content.some(block => {
|
|
36
|
+
const b = asRecord(block);
|
|
37
|
+
return b?.type === "text" && !!asString(b.text)?.trim();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Serialize claim and recap writes; a killed hook's PID lock can be reclaimed (#11223).
|
|
43
|
+
function updateRecord(path, update) {
|
|
44
|
+
const lock = `${path}.lock`;
|
|
45
|
+
try { symlinkSync(String(process.pid), lock); }
|
|
46
|
+
catch {
|
|
47
|
+
try {
|
|
48
|
+
const owner = readlinkSync(lock);
|
|
49
|
+
try { process.kill(Number(owner), 0); return null; }
|
|
50
|
+
catch (error) { if (error.code !== "ESRCH") return null; }
|
|
51
|
+
if (readlinkSync(lock) !== owner) return null;
|
|
52
|
+
unlinkSync(lock);
|
|
53
|
+
symlinkSync(String(process.pid), lock);
|
|
54
|
+
} catch { return null; }
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const next = update(JSON.parse(readFileSync(path, "utf8")));
|
|
58
|
+
if (!next) return null;
|
|
59
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
60
|
+
writeFileSync(tmp, JSON.stringify(next, null, 2));
|
|
61
|
+
renameSync(tmp, path);
|
|
62
|
+
return next;
|
|
63
|
+
} finally { unlinkSync(lock); }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function claimHandoffFile(path, session, now = Math.floor(Date.now() / 1000)) {
|
|
67
|
+
let bytes = 0;
|
|
68
|
+
try { bytes = statSync(session.transcript_path).size; } catch {}
|
|
69
|
+
return updateRecord(path, record => claimHandoff(record, session, now, bytes, randomUUID()));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function loadPendingHandoff(project, { claim = true, freshSession = null, dir = handoffDir(), now = Math.floor(Date.now() / 1000) } = {}) {
|
|
73
|
+
const prefix = `${project}-`;
|
|
74
|
+
let files;
|
|
75
|
+
try {
|
|
76
|
+
files = readdirSync(dir).filter(f => f.startsWith(prefix) && /^\d+\.json$/.test(f.slice(prefix.length)))
|
|
77
|
+
.sort((a, b) => Number(b.slice(prefix.length, -5)) - Number(a.slice(prefix.length, -5)));
|
|
78
|
+
} catch { return null; }
|
|
79
|
+
for (const file of files) {
|
|
80
|
+
try {
|
|
81
|
+
const path = join(dir, file);
|
|
82
|
+
const record = JSON.parse(readFileSync(path, "utf8"));
|
|
83
|
+
if (record.consumed) continue;
|
|
84
|
+
if (record.claim?.expiresAt > now) return null;
|
|
85
|
+
if (!claim) return record;
|
|
86
|
+
const next = claimHandoffFile(path, freshSession, now);
|
|
87
|
+
if (!next) return null;
|
|
88
|
+
const readFirst = readFirstPaths(next.summary || "");
|
|
89
|
+
writeFileSync(recapStampPath(dir, freshSession.session_id), JSON.stringify({
|
|
90
|
+
handoffId: next.id, file, token: next.claim.token, ts: now,
|
|
91
|
+
mode: next.mode === "unattended" ? "unattended" : "attended", readFirst,
|
|
92
|
+
}));
|
|
93
|
+
return next;
|
|
94
|
+
} catch { return null; }
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function recapStampPath(dir, sid) {
|
|
100
|
+
return join(dir, `recap-pending-${String(sid).replace(/[^A-Za-z0-9_.-]/g, "_")}.json`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function completeHandoffRecap({ sessionId, transcriptPath, dir = handoffDir(), now = Math.floor(Date.now() / 1000) }) {
|
|
104
|
+
const stampPath = recapStampPath(dir, sessionId);
|
|
105
|
+
try {
|
|
106
|
+
const stamp = JSON.parse(readFileSync(stampPath, "utf8"));
|
|
107
|
+
const file = stamp.file || `${stamp.handoffId}.json`;
|
|
108
|
+
if (file.includes("/") || file.includes("\\")) return null;
|
|
109
|
+
let missed = [];
|
|
110
|
+
const next = updateRecord(join(dir, file), record => {
|
|
111
|
+
if (record.claim?.session_id !== sessionId || record.claim?.token !== stamp.token) return null;
|
|
112
|
+
if (record.claim.transcript_path !== transcriptPath) return null;
|
|
113
|
+
const body = readFileSync(transcriptPath).subarray(record.claim.transcriptBytes).toString("utf8");
|
|
114
|
+
missed = pathsReadIn(transcriptPath, stamp.readFirst || []).missed;
|
|
115
|
+
return recapHandoff(record, { sessionId, token: stamp.token, missed,
|
|
116
|
+
replied: hasAssistantReply(body, record.claim.at) }, now);
|
|
117
|
+
});
|
|
118
|
+
if (next) unlinkSync(stampPath);
|
|
119
|
+
return { recapped: !!next, missed };
|
|
120
|
+
} catch { return null; }
|
|
121
|
+
}
|
package/hooks/sessionstart.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// trantor SessionStart hook — every session registers with the hub and gets a roster of OTHER live
|
|
3
3
|
// sessions injected, so independent sessions discover each other. Config: env RELAY_URL →
|
|
4
4
|
// ~/.agent-bus/config.json → http://127.0.0.1:4477. Identity: RELAY_SESSION → "<hostname>:<basename(cwd)>".
|
|
5
|
-
import { readFileSync,
|
|
5
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
6
6
|
import { join, basename, dirname } from "node:path";
|
|
7
7
|
import { homedir, hostname } from "node:os";
|
|
8
8
|
import { execSync, spawn } from "node:child_process";
|
|
@@ -10,7 +10,8 @@ import { fileURLToPath } from "node:url";
|
|
|
10
10
|
import { resolveProject, hostId, resolveHubInfo, knownProjects, nonSeatReason, nestedProjects, handoffDir, readOrchSession, writeOrchSession } from "../lib/project.mjs";
|
|
11
11
|
import { formatSubagentManifest } from "../lib/subagent-manifest.mjs";
|
|
12
12
|
import { updateAvailable, maybeNotifyDesktop, readConfig } from "./lib/update-check.mjs";
|
|
13
|
-
import { renderStateBlock,
|
|
13
|
+
import { renderStateBlock, capSummary } from "./lib/handoff.mjs";
|
|
14
|
+
import { loadPendingHandoff } from "./lib/handoff-claims.mjs";
|
|
14
15
|
import { maybeCheckBalances } from "./lib/balance-check.mjs";
|
|
15
16
|
import { getJSON, signedGet, signedPost, loadIdentity } from "./lib/api.mjs";
|
|
16
17
|
import { ledgerPaths, ensureStart, anchorCursor, writeCursor } from "./lib/inbox-ledger.mjs";
|
|
@@ -40,59 +41,6 @@ function buildDoctrineShortForm() {
|
|
|
40
41
|
`</trantor-build-doctrine>\n`;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
// Load the most recent UNCONSUMED handoff for this project; `claim` marks it consumed so exactly one
|
|
44
|
-
// session takes it. A compaction-triggered start may show it but must NOT claim it from the new window.
|
|
45
|
-
function loadPendingHandoff(projectName, { claim = true, freshSession = null } = {}) {
|
|
46
|
-
try {
|
|
47
|
-
const dir = handoffDir(); // NEVER join(homedir(), …) here: a drill pointed at a temp bus dir must not claim the real handoffs
|
|
48
|
-
if (!existsSync(dir)) return null;
|
|
49
|
-
// Match ONLY "<projectName>-<numeric stamp>.json" (a loose startsWith caught leaked fixtures), and
|
|
50
|
-
// sort by the numeric stamp, newest first, since a string sort could pick a stale handoff.
|
|
51
|
-
const re = new RegExp("^" + projectName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "-(\\d+)\\.json$");
|
|
52
|
-
const files = readdirSync(dir)
|
|
53
|
-
.map(f => { const m = re.exec(f); return m ? { f, stamp: Number(m[1]) } : null; })
|
|
54
|
-
.filter(Boolean)
|
|
55
|
-
.sort((a, b) => b.stamp - a.stamp)
|
|
56
|
-
.map(x => x.f);
|
|
57
|
-
for (const f of files) {
|
|
58
|
-
const p = join(dir, f);
|
|
59
|
-
const rec = JSON.parse(readFileSync(p, "utf8"));
|
|
60
|
-
if (!rec.consumed) {
|
|
61
|
-
if (claim) {
|
|
62
|
-
// `consumed` is set at hook time, BEFORE the model has read anything, so also record WHO is
|
|
63
|
-
// taking over: baton-close waits for the successor's first assistant turn before closing the original.
|
|
64
|
-
rec.consumed = true;
|
|
65
|
-
rec.consumedAt = nowSec();
|
|
66
|
-
if (freshSession && (freshSession.session_id || freshSession.transcript_path)) {
|
|
67
|
-
rec.consumedBy = {
|
|
68
|
-
session_id: freshSession.session_id || "",
|
|
69
|
-
transcript_path: freshSession.transcript_path || "",
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
// §5 CLAIMED on the machine's ledger (SYSTEM-CONTRACT) and the recap net armed: a recap-pending
|
|
73
|
-
// stamp names this successor, so a successor that never recaps is mechanically impossible.
|
|
74
|
-
if (!Array.isArray(rec.states)) rec.states = [];
|
|
75
|
-
rec.states.push({ state: "claimed", ts: nowSec(), by: freshSession?.session_id || "" });
|
|
76
|
-
writeFileSync(p, JSON.stringify(rec, null, 2));
|
|
77
|
-
if (freshSession?.session_id) {
|
|
78
|
-
try {
|
|
79
|
-
// #5645: the mandate rides the stamp too, so prompt-focus's recap reminder pins
|
|
80
|
-
// the SAME rec.mode the injection below announces (attended=WAIT / unattended=RESUME).
|
|
81
|
-
// #8162: the READ-FIRST list rides the stamp as DATA, so the Stop hook can check the
|
|
82
|
-
// successor opened it instead of accepting a reply as proof it understood anything.
|
|
83
|
-
const readFirst = readFirstPaths(rec.summary || "");
|
|
84
|
-
writeFileSync(join(dir, `recap-pending-${String(freshSession.session_id).replace(/[^A-Za-z0-9_.-]/g, "_")}.json`),
|
|
85
|
-
JSON.stringify({ handoffId: rec.id, ts: nowSec(), mode: rec.mode === "unattended" ? "unattended" : "attended", readFirst }));
|
|
86
|
-
} catch {}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
return rec;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
} catch {}
|
|
93
|
-
return null;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
44
|
function nowSec() { try { return Number(execSync("date +%s", { encoding: "utf8" }).trim()) || 0; } catch { return 0; } }
|
|
97
45
|
|
|
98
46
|
function readStdin() {
|
package/hooks/stop-inbox.mjs
CHANGED
|
@@ -8,10 +8,12 @@ import { join, dirname } from "node:path";
|
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
|
-
import { resolveProject, hostId,
|
|
11
|
+
import { resolveProject, hostId, busDir } from "../lib/project.mjs";
|
|
12
12
|
import { signedGet } from "./lib/api.mjs"; // signed: enforce hubs 401 unsigned reads — unsigned, T2 delivery is silently dead
|
|
13
13
|
import { ledgerPaths, ensureStart, anchorCursor, writeCursor } from "./lib/inbox-ledger.mjs";
|
|
14
|
-
import { readArm, clearArm, markHandedOff,
|
|
14
|
+
import { readArm, clearArm, markHandedOff, subagentsActive } from "./lib/handoff.mjs";
|
|
15
|
+
|
|
16
|
+
import { completeHandoffRecap } from "./lib/handoff-claims.mjs";
|
|
15
17
|
|
|
16
18
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
17
19
|
|
|
@@ -125,10 +127,14 @@ async function main() {
|
|
|
125
127
|
let input = {};
|
|
126
128
|
try { input = JSON.parse(raw || "{}"); } catch {}
|
|
127
129
|
|
|
128
|
-
// Loop guard FIRST, before any work: if we already blocked once this stop-cycle, the model has had
|
|
129
|
-
// its chance to deal with the inbox and is entitled to stop.
|
|
130
|
-
if (input.stop_hook_active) return allow();
|
|
131
130
|
if (process.env.RELAY_STOP_INBOX === "0") return allow();
|
|
131
|
+
const recap = completeHandoffRecap({ sessionId: String(input.session_id || ""), transcriptPath: input.transcript_path || "" });
|
|
132
|
+
if (recap?.missed.length && !input.stop_hook_active) {
|
|
133
|
+
process.stdout.write(JSON.stringify({ decision: "block", reason: `Read these handoff files before recapping: ${recap.missed.join(", ")}` }));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// A retry may satisfy the recap evidence, but must never loop the inbox block (#11223).
|
|
137
|
+
if (input.stop_hook_active) return allow();
|
|
132
138
|
|
|
133
139
|
const projectDir = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
134
140
|
|
|
@@ -151,41 +157,6 @@ async function main() {
|
|
|
151
157
|
process.stderr.write("[trantor] turn boundary reached — firing the armed baton\n");
|
|
152
158
|
}
|
|
153
159
|
} catch {}
|
|
154
|
-
// §5 RECAPPED (SYSTEM-CONTRACT): this session's FIRST turn boundary after claiming a handoff.
|
|
155
|
-
// By Stop time an assistant reply exists, and every prompt of that first turn carried the
|
|
156
|
-
// recap reminder (prompt-focus) — so the reply had the instruction in front of it. Record the
|
|
157
|
-
// transition on the handoff's own ledger and disarm the net.
|
|
158
|
-
try {
|
|
159
|
-
const sid = String(input.session_id || "");
|
|
160
|
-
if (sid) {
|
|
161
|
-
const stampPath = join(handoffDir(), `recap-pending-${sid.replace(/[^A-Za-z0-9_.-]/g, "_")}.json`);
|
|
162
|
-
if (existsSync(stampPath)) {
|
|
163
|
-
try {
|
|
164
|
-
const stamp = JSON.parse(readFileSync(stampPath, "utf8"));
|
|
165
|
-
// #8162: RECAPPED used to mean "a reply exists by Stop time", which a successor satisfies
|
|
166
|
-
// from the injected summary alone — so a handoff naming read-first files certified a
|
|
167
|
-
// takeover that opened none of them, three days running. Now the ledger asks the same
|
|
168
|
-
// question the state gate learned to ask: ground truth, not testimony. A Read/Grep tool
|
|
169
|
-
// call naming the path is evidence; the successor's own say-so is not.
|
|
170
|
-
const want = Array.isArray(stamp.readFirst) ? stamp.readFirst : [];
|
|
171
|
-
const { missed } = want.length
|
|
172
|
-
? pathsReadIn(input.transcript_path || "", want)
|
|
173
|
-
: { missed: [] };
|
|
174
|
-
if (missed.length) {
|
|
175
|
-
// The stamp STAYS. Not recapped, so the next boundary asks again — and the successor is
|
|
176
|
-
// told exactly what it skipped rather than left to discover it when the operator does.
|
|
177
|
-
process.stderr.write(
|
|
178
|
-
`[trantor] handoff ${stamp.handoffId}: NOT recapped — the handoff named ${want.length} file(s) to read first and ${missed.length} ${missed.length === 1 ? "was" : "were"} never opened: ${missed.join(", ")}. Read them before going further; this is the context the handoff exists to carry.\n`);
|
|
179
|
-
} else {
|
|
180
|
-
appendHandoffState(stamp.handoffId, "recapped", sid);
|
|
181
|
-
try { unlinkSync(stampPath); } catch {}
|
|
182
|
-
}
|
|
183
|
-
} catch {
|
|
184
|
-
try { unlinkSync(stampPath); } catch {}
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
} catch {}
|
|
189
160
|
// Mirror the other hooks: a home-directory session isn't project work and isn't on the bus.
|
|
190
161
|
if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && projectDir === homedir()) return allow();
|
|
191
162
|
|
package/lib/turn-policy.mjs
CHANGED
|
@@ -287,3 +287,15 @@ export function cutChainEvidence(rows) {
|
|
|
287
287
|
export function isBoundedPark(reason) {
|
|
288
288
|
return !PARKING_REASONS.has(reason);
|
|
289
289
|
}
|
|
290
|
+
|
|
291
|
+
// #11230: only an ask posted in this turn is delivery; an older unanswered ask cannot excuse it.
|
|
292
|
+
export function askDuringTurn(rows, startedAt) {
|
|
293
|
+
const asks = rows.filter(row => Number(row.ts) >= startedAt
|
|
294
|
+
&& (row.kind === "ask" || /^❓\s/.test(String(row.text || ""))));
|
|
295
|
+
return asks.sort((a, b) => Number(b.id) - Number(a.id))[0] || null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function askedVerdict(ask) {
|
|
299
|
+
const firstLine = String(ask.text || "").trim().split(/\r?\n/)[0];
|
|
300
|
+
return `asked: ${firstLine.replace(/^❓\s*(?:ask(?:s)?(?: on #[0-9]+)?\s*:\s*)?/, "")}`;
|
|
301
|
+
}
|