trantor 0.18.64 → 0.18.65

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.64",
3
+ "version": "0.18.65",
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": {
@@ -33,7 +33,7 @@ const log = (s) => { try { appendFileSync(logFile, `${new Date().toISOString()}
33
33
 
34
34
  // Same text as the app's kickoff (lib.rs KICKOFF_PROMPT) — one boot prompt so the successor
35
35
  // recaps unprompted instead of sitting idle until a human types (the 15-minute silence, #5649).
36
- const KICKOFF_PROMPT = "You have just taken over via handoff. Recap now per your instructions.";
36
+ const KICKOFF_PROMPT = "You have just taken over via handoff. FIRST open every file it names as read-first — memory, PRD, TDD — before you answer or touch anything; the summary points at them, it does not replace them (#8162). THEN recap per your instructions.";
37
37
 
38
38
  // The pane, resolved exactly like the app does (orch_pane_from_rows): last orch row wins.
39
39
  export function orchPane(rows, project) {
@@ -794,9 +794,74 @@ export function maybeSpawn(projectDir, conf = readConfig(), handoffFile = "", de
794
794
  } catch (e) { process.stderr.write(`[trantor] maybeSpawn error: ${e?.message}\n`); return false; }
795
795
  }
796
796
 
797
+ /** The files a handoff tells its successor to read before doing anything (#8162).
798
+ *
799
+ * A handoff has always been able to SAY "read these first" — crebral-health's named three memory
800
+ * files on 2026-09-19 and the successor opened none of them. Saying it was the whole mechanism.
801
+ * This pulls the list out as data so something downstream can check it.
802
+ *
803
+ * Recognised: a `READ FIRST` / `READ-FIRST` / `Read first:` heading or line, and every path-looking
804
+ * token on it and the lines beneath it until the next blank line or heading. Deliberately narrow —
805
+ * a handoff that mentions a file in passing is not asking anyone to read it, and a gate that fires
806
+ * on every path in a 4k summary would be noise the successor learns to ignore.
807
+ * @returns {string[]} project-relative or absolute paths, de-duplicated, capped
808
+ */
809
+ export function readFirstPaths(summary, { max = 12 } = {}) {
810
+ const text = String(summary || "");
811
+ const out = [];
812
+ const lines = text.split(/\r?\n/);
813
+ let collecting = false;
814
+ for (const raw of lines) {
815
+ const line = raw.trim();
816
+ const opens = /read[\s-]?first/i.test(line);
817
+ if (!collecting && !opens) continue;
818
+ if (collecting && (line === "" || /^#{1,6}\s/.test(line))) break;
819
+ collecting = true;
820
+ // Paths: a token with a slash or a known doc/memory extension, stripped of markdown furniture.
821
+ for (const m of line.matchAll(/[`"'(\[]?([~./\w-]*[\w-]+\.(?:md|mjs|js|ts|tsx|json|sql|rs|py|sh|txt))[`"')\]]?/g)) {
822
+ const p = m[1].replace(/^[`"'(\[]+|[`"')\]]+$/g, "");
823
+ if (p && !out.includes(p)) out.push(p);
824
+ if (out.length >= max) return out;
825
+ }
826
+ }
827
+ return out;
828
+ }
829
+
830
+ /** Which of `paths` this session actually OPENED, read off its own transcript (#8162).
831
+ *
832
+ * Ground truth rather than testimony, the same rule the state gate learned the hard way: a
833
+ * successor saying "I have read the handoff" is not evidence that it did. A Read/Grep/Glob tool
834
+ * call naming the path is. Matches on basename as well as full path, because a handoff written by
835
+ * a model may name `revenue-integrity-build.md` where the Read call carries the absolute path.
836
+ * @returns {{read: string[], missed: string[]}}
837
+ */
838
+ export function pathsReadIn(transcriptPath, paths) {
839
+ const want = (paths || []).filter(Boolean);
840
+ if (!want.length) return { read: [], missed: [] };
841
+ let body = "";
842
+ try { body = readFileSync(transcriptPath, "utf8"); } catch { return { read: [], missed: want }; }
843
+ // Only the tool CALLS count. A path quoted in the injected handoff summary, or in the model's own
844
+ // prose, must never read as "opened" — that is precisely the proxy this card exists to kill.
845
+ const opened = new Set();
846
+ for (const m of body.matchAll(/"name"\s*:\s*"(Read|Grep|Glob|NotebookRead)"\s*,\s*"input"\s*:\s*\{([^}]*)\}/g)) {
847
+ for (const f of m[2].matchAll(/"(?:file_path|path|pattern)"\s*:\s*"([^"]+)"/g)) opened.add(f[1]);
848
+ }
849
+ const hit = (p) => {
850
+ const base = p.split("/").pop();
851
+ for (const o of opened) if (o === p || o.endsWith("/" + p) || (base && o.endsWith("/" + base)) || o === base) return true;
852
+ return false;
853
+ };
854
+ const read = want.filter(hit);
855
+ return { read, missed: want.filter(p => !read.includes(p)) };
856
+ }
857
+
797
858
  // The self-announcing fresh session command (single-quoted so it survives osascript→shell un-escaped).
798
- // Brevity is part of the prompt: a takeover that answers with 5k-character status dumps loses the operator.
799
- export const RECAP_CMD = "claude 'Recap the handoff you just took over — task, state, next step in at most 3 sentences. Then wait for me. Keep all replies short by default: no status tables, no headers, no walls of text unless I explicitly ask for detail.'";
859
+ // Brevity is part of the prompt: a takeover that answers with 5k-character status dumps loses the
860
+ // operator. But brevity is about what you SAY, and #8162 found it had quietly become permission not
861
+ // to READ: the summary is injected at SessionStart, so a 3-sentence recap is producible without
862
+ // opening a file, and a successor doing exactly as asked never opened one. Three days of takeovers
863
+ // went straight to code off a summary. So the order is now read-then-recap, and the recap stays short.
864
+ export const RECAP_CMD = "claude 'You have just taken over via handoff. FIRST open every file the handoff names as read-first — its memory files, its PRD and TDD — and do not answer until you have. They are the context the handoff exists to carry, and the summary is a pointer to them, not a substitute. THEN recap in at most 3 sentences: task, state, next step. Then wait for me. Keep all replies short by default: no status tables, no headers, no walls of text unless I explicitly ask for detail.'";
800
865
 
801
866
  // ONE suppression check for every path that can open a terminal window: two names for it once let a
802
867
  // drill set the wrong one and open eight live sessions in deleted temp directories, so both are honoured.
@@ -10,7 +10,7 @@ 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 } from "./lib/handoff.mjs";
13
+ import { renderStateBlock, readFirstPaths} from "./lib/handoff.mjs";
14
14
  import { maybeCheckBalances } from "./lib/balance-check.mjs";
15
15
  import { getJSON, signedGet, signedPost, loadIdentity } from "./lib/api.mjs";
16
16
  import { ledgerPaths, ensureStart, anchorCursor, writeCursor } from "./lib/inbox-ledger.mjs";
@@ -78,8 +78,11 @@ function loadPendingHandoff(projectName, { claim = true, freshSession = null } =
78
78
  try {
79
79
  // #5645: the mandate rides the stamp too, so prompt-focus's recap reminder pins
80
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 || "");
81
84
  writeFileSync(join(dir, `recap-pending-${String(freshSession.session_id).replace(/[^A-Za-z0-9_.-]/g, "_")}.json`),
82
- JSON.stringify({ handoffId: rec.id, ts: nowSec(), mode: rec.mode === "unattended" ? "unattended" : "attended" }));
85
+ JSON.stringify({ handoffId: rec.id, ts: nowSec(), mode: rec.mode === "unattended" ? "unattended" : "attended", readFirst }));
83
86
  } catch {}
84
87
  }
85
88
  }
@@ -499,9 +502,9 @@ try {
499
502
  // attended (default) = recap-then-WAIT; unattended = recap-then-RESUME — the handoff's OPEN
500
503
  // THREADS are the work order ("handoffs must never be a break").
501
504
  if (handoff.mode === "unattended") {
502
- additionalContext += `🔄 **You are taking over from a prior session that hit its context limit, in UNATTENDED (long-run) mode.** This is a fresh full window. Resume the work below — the prior session's summary, git state, and a pointer to its full transcript (searchable; Foundation/Gaia has it ingested) follow. Continue from "OPEN THREADS & NEXT STEPS"; do not restart from scratch. Recap the task, state, and next step in at most 3 sentences, then RESUME the open threads immediately — they are your work order. Do NOT wait for the user; keep building. Keep replies short: no status tables, no headers, no walls of text.\n\n`;
505
+ additionalContext += `🔄 **You are taking over from a prior session that hit its context limit, in UNATTENDED (long-run) mode.** This is a fresh full window. Resume the work below — the prior session's summary, git state, and a pointer to its full transcript (searchable; Foundation/Gaia has it ingested) follow. Continue from "OPEN THREADS & NEXT STEPS"; do not restart from scratch.\n\n**READ BEFORE YOU BUILD (#8162).** This summary is a POINTER to the project's context, not a replacement for it. Open every file this handoff names as read-first — its memory files, its PRD and its TDD — BEFORE you touch code. Reading is not waiting, and it is not optional: three days running, successors recapped from this summary alone and went straight to code, burning a fifth of a context window rediscovering what was already written down. The ledger now checks which files you opened, not whether you replied.\n\nThen recap the task, state, and next step in at most 3 sentences, then RESUME the open threads — they are your work order. Do NOT wait for the user; keep building. Keep replies short: no status tables, no headers, no walls of text.\n\n`;
503
506
  } else {
504
- additionalContext += `🔄 **You are taking over from a prior session that hit its context limit.** This is a fresh full window. Resume the work below — the prior session's summary, git state, and a pointer to its full transcript (searchable; Foundation/Gaia has it ingested) follow. Continue from "OPEN THREADS & NEXT STEPS"; do not restart from scratch. Recap the task, state, and next step in at most 3 sentences, then wait. Keep replies short: no status tables, no headers, no walls of text unless the user explicitly asks for detail.\n\n`;
507
+ additionalContext += `🔄 **You are taking over from a prior session that hit its context limit.** This is a fresh full window. Resume the work below — the prior session's summary, git state, and a pointer to its full transcript (searchable; Foundation/Gaia has it ingested) follow. Continue from "OPEN THREADS & NEXT STEPS"; do not restart from scratch.\n\n**READ BEFORE YOU ANSWER (#8162).** This summary is a POINTER to the project's context, not a replacement for it. Open every file this handoff names as read-first — its memory files, its PRD and its TDD — before you recap. A 3-sentence recap is producible from this summary alone, which is exactly how three days of takeovers went straight to code without opening anything. The ledger now checks which files you opened, not whether you replied.\n\nThen recap the task, state, and next step in at most 3 sentences, then wait. Keep replies short: no status tables, no headers, no walls of text unless the user explicitly asks for detail.\n\n`;
505
508
  }
506
509
  // Verification gates FIRST — these are structured "must verify before shipping" claims the prior
507
510
  // session couldn't independently prove. They go above the summary on purpose: a safety-critical
@@ -11,7 +11,7 @@ import { homedir } from "node:os";
11
11
  import { resolveProject, hostId, handoffDir, 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, appendHandoffState, subagentsActive } from "./lib/handoff.mjs";
14
+ import { readArm, clearArm, markHandedOff, appendHandoffState, subagentsActive, pathsReadIn} from "./lib/handoff.mjs";
15
15
 
16
16
  const HERE = dirname(fileURLToPath(import.meta.url));
17
17
 
@@ -162,9 +162,27 @@ async function main() {
162
162
  if (existsSync(stampPath)) {
163
163
  try {
164
164
  const stamp = JSON.parse(readFileSync(stampPath, "utf8"));
165
- appendHandoffState(stamp.handoffId, "recapped", sid);
166
- } catch {}
167
- try { unlinkSync(stampPath); } catch {}
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
+ }
168
186
  }
169
187
  }
170
188
  } catch {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.64",
3
+ "version": "0.18.65",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"