trantor 0.18.92 → 0.18.94

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.92",
3
+ "version": "0.18.94",
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/README.md CHANGED
@@ -436,7 +436,12 @@ trantor setup | doctor | connect | profile | provider | models
436
436
  `add <name> --key … [--plan api] [--base-url <url> --models a,b]` to bring any provider (custom
437
437
  endpoints are wired into OpenCode for you) · `remove <name>`.
438
438
  - **`trantor models [<provider>]`** — browse the live models behind each seat + the router's pick
439
- per difficulty.
439
+ per difficulty. A model with no known price is listed as unpriced and never picked.
440
+ - **`trantor memory`** — pins a project's Claude memory notes to the code they describe
441
+ (graft symbols and files) and flags a note **stale** when a pinned symbol's body changes, or
442
+ **broken** when it disappears. `check [--strict]` reports; `pin <note> | --all [--dry-run]`
443
+ pins or re-pins. Notes are pinned automatically when saved, and a session's first context line
444
+ names any stale or broken note. Terms: [GLOSSARY.md](GLOSSARY.md).
440
445
  - **`trantor up`** — `agent:model` pins a model (`deepseek:deepseek-v4-pro`); `agent:provider
441
446
  --task <k> --difficulty <d>` picks the **best live model** for the work at spawn
442
447
  (`glm:zai-coding-plan --difficulty hard`); spawns are verified on the bus with one retry;
package/bin/cli.mjs CHANGED
@@ -26,6 +26,7 @@ switch (cmd) {
26
26
  case "secrets": run("bin/secrets.mjs"); break;
27
27
  case "keys": run("bin/keys.mjs"); break;
28
28
  case "models": run("bin/models.mjs"); break;
29
+ case "memory": run("bin/memory.mjs"); break;
29
30
  case "advise": run("bin/advise.mjs"); break;
30
31
  case "verify": run("bin/crew-verify.mjs"); break;
31
32
  case "up": runCrew(); break;
@@ -271,6 +272,8 @@ switch (cmd) {
271
272
  trantor hub run the hub in the foreground (setup installs it as a service instead)
272
273
  …or manage per-project hub pins: hub list · hub set <project> <url> · hub unset <project>
273
274
  seats: which project lives in which directory — seats · seats add · seats up · seats login install
275
+ trantor memory check [--project <dir>] [--json] check note pins for stale or broken code
276
+ trantor memory pin <note> | --all [--dry-run] pin or re-pin notes from code mentions
274
277
  trantor state a seat's working memory: show <seat> <card> [--json] · validate · reset --force · gc [--apply]
275
278
  trantor seat-why WHY a seat is down (err file, logs, pids): seat-why <agent> [--json] — quota, auth, crash, or just no pane
276
279
  trantor seat-record the per-project seat record the advisor benches from (✓/∅/↩ per difficulty) — [--project p] [--reset <seat>] [--json]
@@ -29,6 +29,7 @@ import {
29
29
  } from "../lib/turn-policy.mjs";
30
30
  import { snapshotBeforeCut } from "./cut-snapshot.mjs";
31
31
  import { ocTurnUsage, dshTurnUsage, usageTotal } from "../lib/turn-usage.mjs";
32
+ import { ocCompactionContent } from "../lib/seat-compaction.mjs";
32
33
  import { listContainers, newContainers, recordContainers, statePathFor as dockerStatePathFor, sweep as dockerSweep } from "../lib/docker-janitor.mjs";
33
34
  import {
34
35
  auditDutyNudges, claimDutyNudges, claudeTranscriptDir, dutyEscalations, dutyNudgeDirective,
@@ -746,6 +747,21 @@ const PARK_WINDOW_MS = Math.max(0, Number(process.env.TRANTOR_PARK_WINDOW_MS ||
746
747
  // The card the CURRENT CLI session belongs to (#6134). 0 = the kickoff session, which belongs to
747
748
  // no card, so the first contract that names one starts a session of its own.
748
749
  let sessionCard = releaseState?.sessionCard || 0;
750
+ // #11559: per-card token accounting from the runner's own ledger (stdout usage lines, or the
751
+ // provider's session DB when the CLI prints none). A card that crosses its budget pauses — no
752
+ // further turn is bought for THAT card — until the card changes or the seat restarts, both of
753
+ // which reset the counter. The budget itself is an env knob, not a constant in the wall.
754
+ const CARD_TOKEN_BUDGET = Math.max(0, Number(process.env.TRANTOR_CARD_TOKEN_BUDGET || 1_000_000));
755
+ const BUDGET_RETRY_MS = 10 * 60 * 1000; // a paused card's OWN redelivery pace, never other cards'
756
+ let cardTokens = releaseState?.cardTokens || 0;
757
+ let cardStartedAt = releaseState?.cardStartedAt || 0;
758
+ let budgetStop = releaseState?.budgetStop || null; // { card, tokens } once the card crossed
759
+ let budgetRetryAt = releaseState?.budgetRetryAt || 0; // paces the paused card's OWN redeliveries
760
+
761
+ // #11559: the compaction block injected to opencode-family seats via OPENCODE_CONFIG_CONTENT.
762
+ // "" injects nothing (the operator set TRANTOR_OC_COMPACTION=off, or its JSON is invalid — the
763
+ // seat then behaves exactly as before, never with a half-armed config).
764
+ const OC_COMPACTION = ocCompactionContent(process.env);
749
765
 
750
766
  let sid = releaseState?.sid || "";
751
767
  // #6206: the watchdog is DETACHED, so a runner that dies without ending it leaves an orphan
@@ -930,18 +946,23 @@ exit $turn_exit`;
930
946
  // spawned. stdin is /dev/null: a background group that reads the terminal stops on SIGTTIN.
931
947
  detached: true,
932
948
  cwd: TURN_DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : ["ignore", "inherit", "inherit"],
933
- env: { ...process.env, ...shadowEnv(secrets), RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_SESSION: SESSION, RELAY_PROJECT: PROJ,
934
- // #6228: marks this env as belonging to PROJ, unlike a one-off RELAY_PROJECT override; crew.mjs's
935
- // `up` guard refuses to bring up another project's crew from a shell carrying this badge.
936
- TRANTOR_SEAT: PROJ,
937
- // A runner-managed seat must never hand itself a baton: the runner is its lifecycle manager,
938
- // so a handoff spawn would only leak an unmanaged interactive window.
939
- TRANTOR_NO_HANDOFF_SPAWN: "1", TRANTOR_NO_BATON_SPAWN: "1",
940
- // #6206: the seat's transcript dir — a real CLI ignores it, a drill's fake CLI writes
941
- // its transcript lines there so the watchdog sees the liveness a real claude shows.
942
- TRANTOR_TRANSCRIPT_DIR: TRANSCRIPT_DIR },
943
- maxBuffer: 16 * 1024 * 1024,
944
949
  };
950
+ const spawnEnv = { ...process.env, ...shadowEnv(secrets), RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_SESSION: SESSION, RELAY_PROJECT: PROJ,
951
+ // #6228: marks this env as belonging to PROJ, unlike a one-off RELAY_PROJECT override; crew.mjs's
952
+ // `up` guard refuses to bring up another project's crew from a shell carrying this badge.
953
+ TRANTOR_SEAT: PROJ,
954
+ // A runner-managed seat must never hand itself a baton: the runner is its lifecycle manager,
955
+ // so a handoff spawn would only leak an unmanaged interactive window.
956
+ TRANTOR_NO_HANDOFF_SPAWN: "1", TRANTOR_NO_BATON_SPAWN: "1",
957
+ // #6206: the seat's transcript dir — a real CLI ignores it, a drill's fake CLI writes
958
+ // its transcript lines there so the watchdog sees the liveness a real claude shows.
959
+ TRANTOR_TRANSCRIPT_DIR: TRANSCRIPT_DIR };
960
+ // #11559: opencode only compacts when the context is FULL, which on big-limit models is far
961
+ // past the point a seat card should reach. The merged-content injection arms compaction per
962
+ // seat (schema: opencode.ai/config.json) without touching the operator's global config.
963
+ if (cli.pinned && OC_COMPACTION) spawnEnv.OPENCODE_CONFIG_CONTENT = OC_COMPACTION;
964
+ spawnOpts.env = spawnEnv;
965
+ spawnOpts.maxBuffer = 16 * 1024 * 1024;
945
966
  // The timeout signals the shell to snapshot before sweeping, just like the normal box.
946
967
  // Its 30s margin keeps it out of the normal cut path.
947
968
  if (TURN_MAX_MS) { spawnOpts.timeout = (TURN_EXTENSIONS_MAX ? TURN_CEILING_MS : TURN_MAX_MS) + 30000; spawnOpts.killSignal = "SIGTERM"; }
@@ -1069,14 +1090,16 @@ exit $turn_exit`;
1069
1090
  // 0 means "this CLI printed no usage", never "free" — so only overwrite when the envelope
1070
1091
  // actually carried counts.
1071
1092
  if (c) tokens = c.input + c.output + c.cache_read + c.cache_creation;
1072
- }
1073
- // #8234: the CLIs that print no usage on stdout keep the counts in their own session records —
1093
+ } // #8234: the CLIs that print no usage on stdout keep the counts in their own session records —
1074
1094
  // opencode's db, the session resolved for this turn exactly like the `-s` resume id. Sum this
1075
1095
  // turn's window there; null (unknown) leaves the stdout paths above standing — never 0 ("free").
1076
1096
  const usage = cli.pinned && sid ? ocTurnUsage(OC_DB, sid, t0, Date.now())
1077
1097
  : AGENT === "dsh" ? dshTurnUsage(join(homedir(), ".dsh", "sessions"), TURN_DIR, t0, Date.now())
1078
1098
  : null;
1079
1099
  if (usage) tokens = usageTotal(usage);
1100
+ // #11559: every token this turn spent rides the card it served. A card-0 turn (kickoff, pulse,
1101
+ // duty) belongs to no card and adds nothing; the counter resets when the served card changes.
1102
+ if (sessionCard > 0 && Number.isFinite(tokens) && tokens > 0) cardTokens += tokens;
1080
1103
  // #9724: usage RESOLVED to all zeros means no model work happened — never success; null stays
1081
1104
  // unknown, never failure, and a shipped commit is real work (stale record, not the turn).
1082
1105
  const zeroUsage = realExit === 0 && effExit === 0 && !newCommit && usageSaysNoWork(usage);
@@ -1111,7 +1134,13 @@ exit $turn_exit`;
1111
1134
  if (endStatus !== null) {
1112
1135
  cardStatusAtEnd = endStatus;
1113
1136
  const moved = ["testing", "done"].includes(endStatus) && !["testing", "done"].includes(cardStatusBefore);
1114
- noDelivery = !newCommit && !moved && !askVerdict && !blockedVerdict;
1137
+ // #11551: a card ALREADY at testing/done when the turn began is delivered work — the
1138
+ // codex-2 specimen's redelivered contract could neither commit anew nor move the card, and
1139
+ // the check parked the seat for work it had already shipped. It only stands while the card
1140
+ // still IS testing/done: a mid-turn bounce to doing (endStatus doing) reopens the contract.
1141
+ const alreadyDelivered = ["testing", "done"].includes(cardStatusBefore)
1142
+ && ["testing", "done"].includes(endStatus);
1143
+ noDelivery = !newCommit && !moved && !alreadyDelivered && !askVerdict && !blockedVerdict;
1115
1144
  }
1116
1145
  }
1117
1146
  lastNoDelivery = noDelivery;
@@ -1457,7 +1486,8 @@ async function resolveWakeCard(messages, { session }) {
1457
1486
  writeFileSync(PENDF, JSON.stringify({ persona: PERSONA, agent: AGENT, project: PROJ, ts: Date.now(),
1458
1487
  wake: pendingWake, bcast: pendingBcast, seen: seenLedger, ask: awaitingAsk?.id || 0, askTo: awaitingAsk?.to || "" }));
1459
1488
  const state = { cursor, turn: TURN, sid, sessionCard, retryAt, deliveryFails,
1460
- noDeliveryRetry, askReleased, lastTurnAt, attempted: [...attemptedVersions] };
1489
+ noDeliveryRetry, askReleased, lastTurnAt, attempted: [...attemptedVersions],
1490
+ cardTokens, cardStartedAt, budgetStop, budgetRetryAt };
1461
1491
  process.execve(process.execPath, [process.execPath, ...process.execArgv, ...process.argv.slice(1)],
1462
1492
  { ...process.env, CREW_RELEASE_STATE: JSON.stringify(state) });
1463
1493
  } catch (error) { log(`runner restart failed: ${error.message}`); }
@@ -1479,14 +1509,15 @@ async function resolveWakeCard(messages, { session }) {
1479
1509
  log("parked — waiting for the next message or pulse");
1480
1510
  continue;
1481
1511
  }
1482
- // A due REDELIVERY runs before we go back to waiting — during an outage the bus is silent by
1483
- // definition, so the retry timer is the only thing that will ever move these messages.
1484
- // #7756: a held ask is owed but NEVER redelivered — only the assigner's answer releases it.
1485
- if (pendingWake.length && !awaitingAsk && Date.now() >= retryAt) { await deliverWake(); continue; }
1512
+ // A due REDELIVERY runs before we go back to waiting: during an outage the bus is silent, so
1513
+ // the retryAt timer is the only thing that will ever move these messages. #7756: a held ask
1514
+ // is owed but NEVER redelivered. #11559: a budget-paused card paces its own redeliveries on
1515
+ // budgetRetryAt - a clock that never delays a poll-delivered contract for a different card.
1516
+ if (pendingWake.length && !awaitingAsk && Date.now() >= retryAt && Date.now() >= budgetRetryAt) { await deliverWake(); continue; }
1486
1517
  // cap the long-poll hold so neither a due pulse nor a due redelivery waits out a silent 280s window
1487
1518
  const due = [];
1488
1519
  if (PULSE_MS) due.push(PULSE_MS - (Date.now() - lastTurnAt));
1489
- if (pendingWake.length && !awaitingAsk) due.push(retryAt - Date.now());
1520
+ if (pendingWake.length && !awaitingAsk) due.push(Math.min(retryAt, budgetRetryAt || retryAt) - Date.now());
1490
1521
  const holdS = due.length
1491
1522
  ? Math.max(5, Math.min(280, Math.ceil(Math.min(...due) / 1000)))
1492
1523
  : 280;
@@ -1749,6 +1780,20 @@ async function resolveWakeCard(messages, { session }) {
1749
1780
  // and never inherits the previous contract's card (#10197's judge then reads the right card).
1750
1781
  const sessionCardForTurn = card || baseCard;
1751
1782
  const fresh = !resumedAfterAsk && sessionCardForTurn > 0 && sessionCardForTurn !== sessionCard;
1783
+ // #11559: a pause rides the card, not the seat — a contract for a DIFFERENT card lifts it
1784
+ // (and resets the counter with the fresh session below); the paused card itself never buys
1785
+ // another CLI session. The wake stays owed and retries on a long clock, so the orchestrator
1786
+ // sees the pause, not a silent seat.
1787
+ if (budgetStop && sessionCardForTurn > 0 && sessionCardForTurn === budgetStop.card) {
1788
+ log(`\x1b[33mqueued — #${budgetStop.card} is over its token budget (${budgetStop.tokens} ≥ ${CARD_TOKEN_BUDGET}); paused until the card moves or \`trantor up ${AGENT}\` resets the budget\x1b[0m`);
1789
+ // Pace only the SELF-redelivery path (a non-empty queue with retryAt=0 would otherwise
1790
+ // hot-loop this branch and starve the poll): budgetRetryAt gates that line alone. A new
1791
+ // contract delivered by the poll never waits on it — a different card lifts the pause.
1792
+ budgetRetryAt = Date.now() + BUDGET_RETRY_MS;
1793
+ return;
1794
+ }
1795
+ if (budgetStop && fresh) { budgetStop = null; budgetRetryAt = 0; }
1796
+ if (fresh) { cardTokens = 0; cardStartedAt = Date.now(); }
1752
1797
  sessionCard = card;
1753
1798
  const cited = [...new Set(wakeForTurn.flatMap(m => cardRefs(m.text)))];
1754
1799
  const freshText = fresh
@@ -1951,6 +1996,19 @@ async function resolveWakeCard(messages, { session }) {
1951
1996
  await notifyAssigners(assigners,
1952
1997
  `✅ done on ${SESSION} (exit 0, ${secs}s) · asked: "${asked}" · check the board card and the files for what changed`);
1953
1998
  }
1999
+ // #11559: the pause is judged AFTER the classification so it never masks it — a failed turn
2000
+ // (ec !== 0) stays in its own ladder, and only a clean-exit turn hands its card to the stop.
2001
+ // The stop blocks FUTURE turns for THIS card only: a different card starts clean, and the
2002
+ // alert names the two ways out (move the card, or restart the seat, resetting the counter).
2003
+ if (!budgetStop && CARD_TOKEN_BUDGET > 0 && sessionCard > 0 && cardTokens >= CARD_TOKEN_BUDGET && ec === 0) {
2004
+ budgetStop = { card: sessionCard, tokens: cardTokens };
2005
+ const text = `⛔ #${sessionCard} hit its token budget on ${SESSION}: ${cardTokens} ≥ ${CARD_TOKEN_BUDGET} tokens this card.`
2006
+ + ` Further turns for THIS card are paused until it moves (new contract, or testing/done) or \`trantor up ${AGENT}\` resets the budget.`;
2007
+ log(`\x1b[33m${text}\x1b[0m`);
2008
+ await notifyAssigners(assigners, text);
2009
+ const orch = `${hostId()}:${PROJ}`;
2010
+ if (!assigners.some(a => a.from === orch)) await api("/send", { from: SESSION, to: orch, text, project: PROJ, kind: "alert" }).catch(() => {});
2011
+ }
1954
2012
  lastTurnAt = Date.now();
1955
2013
  }
1956
2014
  })();
package/bin/memory.mjs ADDED
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { checkMemory, formatMemoryCheck } from "../lib/memory-pins.mjs";
4
+ import { pinMemory, formatMemoryPin } from "../lib/memory-pin-write.mjs";
5
+
6
+ const usage = "Usage: trantor memory check [--project <dir>] [--json] [--strict]\n trantor memory pin <note> | --all [--dry-run] [--project <dir>] [--json]";
7
+ try {
8
+ const { values, positionals } = parseArgs({ allowPositionals: true, options: {
9
+ project: { type: "string" }, json: { type: "boolean" }, help: { type: "boolean" },
10
+ strict: { type: "boolean" }, all: { type: "boolean" }, "dry-run": { type: "boolean" },
11
+ } });
12
+ if (values.help) {
13
+ console.log(usage);
14
+ } else {
15
+ const [command, note] = positionals;
16
+ let result;
17
+ let format;
18
+ if (command === "check" && positionals.length === 1 && !values.all && !values["dry-run"]) {
19
+ result = checkMemory({ project: values.project });
20
+ format = formatMemoryCheck;
21
+ if (values.strict && result.notes.some(note => note.pins.some(pin => ["stale", "broken"].includes(pin.status)))) process.exitCode = 1;
22
+ } else if (command === "pin" && !values.strict && positionals.length <= 2 && Boolean(note) !== Boolean(values.all)) {
23
+ result = pinMemory({ project: values.project, note, all: values.all, dryRun: values["dry-run"] });
24
+ format = formatMemoryPin;
25
+ } else {
26
+ throw new Error(usage);
27
+ }
28
+ const output = values.json ? JSON.stringify(result, null, 2) : format(result);
29
+ if (output) console.log(output);
30
+ }
31
+ } catch (error) {
32
+ console.error(`memory: ${error.message}`);
33
+ process.exitCode = 1;
34
+ }
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ // trantor seat-tokens (#11559) — per-provider token totals for the seat fleet from opencode's
3
+ // session store, the 30-day shape compared week over week: sessions, assistant steps, tokens,
4
+ // the single biggest step, and cost. Read-only; a missing or locked DB prints "no data".
5
+ // node bin/seat-tokens.mjs [--days 30] [--db <path>] [--json]
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { seatTokensReport } from "../lib/seat-tokens.mjs";
9
+
10
+ const argv = process.argv.slice(2);
11
+ const val = (k, d) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; };
12
+ const DAYS = Math.max(1, Number(val("--days", 30)) || 30);
13
+ const DB = val("--db", join(process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"), "opencode", "opencode.db"));
14
+ const since = Date.now() - DAYS * 24 * 60 * 60 * 1000;
15
+ const rows = seatTokensReport({ dbPath: DB, sinceMs: since });
16
+ const fmt = (n) => Number(n).toLocaleString("en-US");
17
+
18
+ if (val("--json", "")) { console.log(JSON.stringify({ db: DB, days: DAYS, providers: rows }, null, 1)); process.exit(0); }
19
+ if (!rows.length) { console.log(`no session data in ${DB} for the last ${DAYS} day(s)`); process.exit(0); }
20
+ console.log(`opencode seat usage, last ${DAYS} day(s) — ${DB}\n`);
21
+ console.log("provider sessions steps input output cache-read cache-write peak-step cost");
22
+ for (const r of rows) {
23
+ console.log(
24
+ String(r.provider).padEnd(18),
25
+ String(r.sessions).padStart(8),
26
+ String(r.steps).padStart(8),
27
+ fmt(r.input).padStart(12),
28
+ fmt(r.output).padStart(10),
29
+ fmt(r.cacheRead).padStart(14),
30
+ fmt(r.cacheWrite).padStart(14),
31
+ fmt(r.peakInput).padStart(12),
32
+ fmt(Math.round(r.cost)).padStart(6),
33
+ );
34
+ }
package/hooks/hooks.json CHANGED
@@ -150,6 +150,17 @@
150
150
  "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/ask-sidecar.mjs"
151
151
  }
152
152
  ]
153
+ },
154
+ {
155
+ "matcher": "Write|Edit",
156
+ "hooks": [
157
+ {
158
+ "type": "command",
159
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/memory-pin.mjs",
160
+ "async": true,
161
+ "timeout": 130
162
+ }
163
+ ]
153
164
  }
154
165
  ],
155
166
  "PostToolUseFailure": [
@@ -0,0 +1,63 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { isMainThread, parentPort, Worker, workerData } from "node:worker_threads";
4
+ import { gitCheckoutRoot } from "./git-checkout.mjs";
5
+
6
+ const SLOW = "pin check skipped (slow), run trantor memory check";
7
+
8
+ export function memoryStartLine(project) {
9
+ return new Promise(resolveLine => {
10
+ let worker;
11
+ let settled = false;
12
+ const finish = line => {
13
+ if (settled) return;
14
+ settled = true;
15
+ clearTimeout(timer);
16
+ resolveLine(line);
17
+ if (worker) void worker.terminate().catch(() => {});
18
+ };
19
+ // Keep synchronous graph parsing and file reads off the hook's deadline timer.
20
+ const timer = setTimeout(() => finish(SLOW), 500);
21
+ try {
22
+ worker = new Worker(new URL(import.meta.url), { workerData: { project } });
23
+ worker.once("message", finish);
24
+ worker.once("error", () => finish(""));
25
+ worker.once("exit", () => finish(""));
26
+ } catch { finish(""); }
27
+ });
28
+ }
29
+
30
+ async function cachedMemoryLine(directory) {
31
+ const project = gitCheckoutRoot(directory) || resolve(directory);
32
+ const graphPath = join(project, "graft/.graph/wiring.json");
33
+ if (!existsSync(graphPath)) return "";
34
+ const { checkPin, memoryDirectory, notePins, pinFileNewer } = await import("../../lib/memory-pins.mjs");
35
+ const memoryDir = memoryDirectory(project);
36
+ if (!existsSync(memoryDir)) return "";
37
+ const graph = JSON.parse(readFileSync(graphPath, "utf8"));
38
+ const nodes = new Map(graph.nodes.map(node => [node.id, node]));
39
+ const graphTime = statSync(graphPath).mtimeMs;
40
+ let unchecked = 0;
41
+ const stale = new Set(), broken = new Set();
42
+ const notes = readdirSync(memoryDir, { withFileTypes: true })
43
+ .filter(entry => entry.isFile() && entry.name.endsWith(".md") && entry.name !== "MEMORY.md");
44
+ for (const note of notes) {
45
+ try {
46
+ const pins = notePins(readFileSync(join(memoryDir, note.name), "utf8"));
47
+ for (const pin of pins) {
48
+ if (pinFileNewer(project, pin, graphTime)) { unchecked++; continue; }
49
+ const { status } = checkPin(pin, project, nodes);
50
+ if (status === "stale") stale.add(note.name);
51
+ if (status === "broken") broken.add(note.name);
52
+ }
53
+ } catch { /* A concurrent edit or invalid span must not suppress other notes. */ }
54
+ }
55
+ const names = [...new Set([...stale, ...broken])].sort().map(name => name.replace(/[\s\x00-\x1f\x7f]+/g, " "));
56
+ const suffix = unchecked ? ` (${unchecked} unchecked: graph older than code, run trantor memory check)` : "";
57
+ return names.length || unchecked ? `${stale.size} stale, ${broken.size} broken notes: ${names.join(", ")}; run trantor memory check${suffix}` : "";
58
+ }
59
+
60
+ if (!isMainThread) {
61
+ try { parentPort.postMessage(await cachedMemoryLine(workerData.project)); }
62
+ catch { parentPort.postMessage(""); }
63
+ }
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from "node:fs";
3
+ import { basename, dirname, resolve } from "node:path";
4
+ import { pinMemory } from "../lib/memory-pin-write.mjs";
5
+ import { memoryDirectory } from "../lib/memory-pins.mjs";
6
+ import { gitCheckoutRoot } from "./lib/git-checkout.mjs";
7
+
8
+ async function pinSavedNote() {
9
+ let raw = "";
10
+ for await (const chunk of process.stdin) raw += chunk;
11
+ const input = JSON.parse(raw);
12
+ if (!["Write", "Edit"].includes(input.tool_name) || !input.cwd || !input.tool_input?.file_path) return;
13
+ const path = resolve(input.cwd, input.tool_input.file_path);
14
+ if (!path.endsWith(".md") || basename(path) === "MEMORY.md") return;
15
+ const memoryRoot = dirname(dirname(memoryDirectory(input.cwd)));
16
+ if (!path.startsWith(`${memoryRoot}/`) || basename(dirname(path)) !== "memory") return;
17
+ const project = gitCheckoutRoot(input.cwd) || resolve(input.cwd);
18
+ const memoryDir = memoryDirectory(project);
19
+ if (dirname(path) !== memoryDir) return;
20
+ // A symlink must not turn a note-save hook into a write outside project memory.
21
+ if (dirname(realpathSync(path)) !== realpathSync(memoryDir)) return;
22
+ const result = pinMemory({ project, memoryDir, note: basename(path) });
23
+ if (result.message) console.error(`memory-pin: ${result.message}`);
24
+ for (const note of result.notes) {
25
+ for (const diagnostic of note.diagnostics) console.error(`memory-pin: ${note.name}: ${diagnostic}`);
26
+ }
27
+ }
28
+
29
+ try { await pinSavedNote(); }
30
+ catch (error) { console.error(`memory-pin: ${error.message}`); }
@@ -16,6 +16,7 @@ import { maybeCheckBalances } from "./lib/balance-check.mjs";
16
16
  import { getJSON, signedGet, signedPost, loadIdentity } from "./lib/api.mjs";
17
17
  import { ledgerPaths, ensureStart, anchorCursor, writeCursor } from "./lib/inbox-ledger.mjs";
18
18
  import { ensureEnrolled } from "../lib/enroll.mjs";
19
+ import { memoryStartLine } from "./lib/memory-start.mjs";
19
20
 
20
21
  // The build doctrine (docs/BUILD-DOCTRINE.md) in short form, one line per rule, injected for every
21
22
  // project's ORCHESTRATOR at boot (#6452): orchestrators that never opened the doc missed the ruling.
@@ -156,6 +157,8 @@ try {
156
157
  process.stdout.write(emit(ctx, banner, ""));
157
158
  process.exit(0);
158
159
  }
160
+ const memoryLine = await memoryStartLine(projectDir);
161
+ if (memoryLine) additionalContext += `${memoryLine}\n`;
159
162
  const project = resolveProject(projectDir);
160
163
  sessionTitle = project; // baseline — enriched with the current work item after the catch-up fetch below
161
164
  const session = process.env.RELAY_SESSION
@@ -38,7 +38,7 @@ export async function routeCards({ req, res, q, P, auth, ctx }) {
38
38
  appendTaskLog, appendTaskNote, cleanChecklist, cleanDrill, hasDrillLine, linkCommitToFocus,
39
39
  derivePhases, PROPOSAL_CAP, propFp, healthOf, REAP_GRACE_MS, subFp,
40
40
  prunePeers, canRead, HUB_VERSION, cmpSemver, isCardEvent, fmtAge, hubSend,
41
- ONLINE_MS, PEER_TTL_MS,
41
+ ONLINE_MS, PEER_TTL_MS, AUTH_MODE,
42
42
  } = ctx;
43
43
  if (req.method === "POST" && P === "/task") { // create a card
44
44
  const b = await body(req);
@@ -596,6 +596,22 @@ export async function routeCards({ req, res, q, P, auth, ctx }) {
596
596
  appendEvent("lesson", lProj, b.by || "", { text, scope });
597
597
  return json(res, 200, { ok: true, count: state.lessons.length });
598
598
  }
599
+ // A lesson can outlive the world it was learned in; only a global owner rewrites one, keeping its id and old text.
600
+ if (req.method === "POST" && P === "/lesson/update") {
601
+ if (AUTH_MODE !== "off" && !auth?.identity?.scopes?.some(s => s.project === "*" && s.role === "owner")) {
602
+ return json(res, 403, { error: "global owner permission required" });
603
+ }
604
+ const b = await body(req);
605
+ const lesson = state.lessons.find(l => l.id === Number(b.id));
606
+ if (!lesson) return json(res, 404, { error: "no such lesson" });
607
+ const text = stripNulText(b.text).trim().slice(0, 400);
608
+ if (!text) return json(res, 400, { error: "text required" });
609
+ lesson.revisions = [...(lesson.revisions || []), { text: lesson.text, by: lesson.by, ts: lesson.ts }].slice(-5);
610
+ Object.assign(lesson, { text, by: b.by || "", ts: now() });
611
+ markDirty();
612
+ appendEvent("lesson.revised", "", b.by || "", { id: lesson.id, text, scope: lesson.scope });
613
+ return json(res, 200, { ok: true, lesson });
614
+ }
599
615
  // --- verification gates: structured "must verify before shipping" claims that travel with
600
616
  // handoffs and surface PROMINENTLY to whoever takes over (so a safety-critical check can't be
601
617
  // skimmed past in narrative prose — the "verify Gail coefficients" intent that got lost). ---
package/lib/dep.mjs ADDED
@@ -0,0 +1,37 @@
1
+ // Load an npm dependency from code that may run inside the Claude plugin snapshot, which ships
2
+ // no node_modules: plain import first, then borrow from the globally installed trantor (as mcp.mjs does).
3
+ import { execSync } from "node:child_process";
4
+ import { existsSync } from "node:fs";
5
+ import { createRequire } from "node:module";
6
+ import { dirname, join } from "node:path";
7
+ import { fileURLToPath, pathToFileURL } from "node:url";
8
+
9
+ const HERE = dirname(fileURLToPath(import.meta.url));
10
+ const req = createRequire(import.meta.url);
11
+ let fallbackRoots = null;
12
+
13
+ function borrowRoots() {
14
+ if (fallbackRoots) return fallbackRoots;
15
+ const roots = [];
16
+ const add = p => { if (p && !roots.includes(p)) roots.push(p); };
17
+ if (process.env.npm_config_prefix) add(join(process.env.npm_config_prefix, "lib", "node_modules"));
18
+ add(join(dirname(process.execPath), "..", "lib", "node_modules"));
19
+ for (const prefix of ["/opt/homebrew", "/usr/local"]) add(join(prefix, "lib", "node_modules"));
20
+ if (!roots.some(r => existsSync(join(r, "trantor")))) {
21
+ try { add(execSync("npm root -g", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim()); } catch {}
22
+ }
23
+ fallbackRoots = roots.flatMap(r => [join(r, "trantor"), r]);
24
+ return fallbackRoots;
25
+ }
26
+
27
+ export async function dep(spec) {
28
+ try {
29
+ return await import(spec);
30
+ } catch (error) {
31
+ if (error?.code !== "ERR_MODULE_NOT_FOUND" && error?.code !== "ERR_PACKAGE_PATH_NOT_EXPORTED") throw error;
32
+ }
33
+ for (const path of [HERE, ...borrowRoots()]) {
34
+ try { return await import(pathToFileURL(req.resolve(spec, { paths: [path] })).href); } catch {}
35
+ }
36
+ throw new Error(`cannot resolve '${spec}': this plugin snapshot has no node_modules and no global trantor install to borrow from (npm i -g trantor)`);
37
+ }
@@ -0,0 +1,165 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, renameSync, unlinkSync } from "node:fs";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { dep } from "./dep.mjs";
5
+ import { checkPin, frontMatter, memoryDirectory, notePins, pinTarget, refreshPinGraph, symbolFingerprint } from "./memory-pins.mjs";
6
+
7
+ const { stringify } = await dep("yaml");
8
+
9
+ export function replaceNotePins(text, pins) {
10
+ const front = frontMatter(text);
11
+ const newline = text.includes("\r\n") ? "\r\n" : "\n";
12
+ const value = stringify(pins, { collectionStyle: "flow", lineWidth: 0 }).trimEnd();
13
+ if (!front) return `---${newline}pins: ${value}${newline}---${newline}${text}`;
14
+ const pair = front.document.contents?.items.find(item => item.key?.value === "pins");
15
+ if (!pair) {
16
+ if (front.document.contents?.flow) throw new Error("cannot add pins to flow-style front matter without changing other keys");
17
+ const at = front.start + front.yaml.length;
18
+ return `${text.slice(0, at)}pins: ${value}${newline}${text.slice(at)}`;
19
+ }
20
+ // Value ranges exclude neighboring keys and their comments; never reserialize the whole note.
21
+ const [start, end] = pair.value.range;
22
+ const old = front.yaml.slice(start, end);
23
+ const ending = old.match(/\r?\n$/)?.[0] || "";
24
+ const spacing = start === end ? " " : "";
25
+ return text.slice(0, front.start + start) + spacing + value + ending + text.slice(front.start + end);
26
+ }
27
+
28
+ export function extractMentions(text, project, nodes, siblingNotes = new Set()) {
29
+ const front = frontMatter(text);
30
+ const body = (front ? text.slice(front.end) : text)
31
+ .replace(/\[\[[\s\S]*?\]\]/g, " ").replace(/\b[a-z][a-z0-9+.-]*:\/\/\S+/gi, " ");
32
+ const paths = new Set([...nodes.values()].map(node => node.path));
33
+ const mentions = [];
34
+ const seen = new Set();
35
+ const pattern = /(?<![\w./\\-])((?:\.\/)?(?:[\w@.-]+\/)*[\w@.-]+)(?:#([\w$.-]+)|:(\d+))?/g;
36
+ for (const match of body.matchAll(pattern)) {
37
+ let file = match[1].replace(/^\.\//, "").replace(/[.,]+$/, "");
38
+ if (!pinTarget({ file }) || file === "Node.js") continue;
39
+ if (siblingNotes.has(file)) continue;
40
+ if (!file.includes("/") && /^v?\d+(?:\.[\da-z-]+)+$/i.test(file)
41
+ && !/\.(?:[cm]?jsx?|tsx?|py|rs|go|swift|c|h|cpp|hpp|java|kt|rb|sh|php|vue|svelte)$/i.test(file)) continue;
42
+ const prefix = `${basename(project)}/`;
43
+ if (!existingFile(project, file) && file.startsWith(prefix) && existingFile(project, file.slice(prefix.length))) {
44
+ file = file.slice(prefix.length);
45
+ }
46
+ if (!paths.has(file) && !existingFile(project, file) && !file.includes("/") && !/\.(?:[cm]?jsx?|tsx?|py|rs|go|swift|c|h|cpp|hpp|java|kt|rb|sh|php|vue|svelte|md|mdx|txt|json|ya?ml|toml|sql|css|html)$/i.test(file)) continue;
47
+ const symbol = match[2]?.replace(/\.+$/, "");
48
+ const mention = symbol ? { file, symbol: `${file}#${symbol}` }
49
+ : match[3] ? { file, line: Number(match[3]) } : { file };
50
+ const key = JSON.stringify(mention);
51
+ if (!seen.has(key)) { seen.add(key); mentions.push(mention); }
52
+ }
53
+ return mentions;
54
+ }
55
+
56
+ function existingFile(project, file) {
57
+ try { return statSync(join(project, file)).isFile(); }
58
+ catch { return false; }
59
+ }
60
+
61
+ function resolveMention(mention, nodes, project) {
62
+ if (!existingFile(project, mention.file)) return null;
63
+ if (mention.symbol) return nodes.has(mention.symbol) ? { symbol: mention.symbol, fp: symbolFingerprint(project, nodes.get(mention.symbol)) } : null;
64
+ if (mention.line === undefined) return { file: mention.file };
65
+ const lines = readFileSync(join(project, mention.file), "utf8").split(/\r?\n/);
66
+ if (mention.line < 1 || mention.line > lines.length) return null;
67
+ const enclosing = [...nodes.values()].filter(node => node.path === mention.file && node.kind !== "file")
68
+ .map(node => ({ node, span: /^L(\d+)-L(\d+)$/.exec(node.span) }))
69
+ .filter(({ span }) => span && +span[1] <= mention.line && +span[2] >= mention.line)
70
+ .sort((a, b) => (+a.span[2] - +a.span[1]) - (+b.span[2] - +b.span[1]));
71
+ return enclosing.length ? { symbol: enclosing[0].node.id, fp: symbolFingerprint(project, enclosing[0].node) } : { file: mention.file };
72
+ }
73
+
74
+ function planNote(text, project, nodes, date, siblingNotes) {
75
+ const pins = notePins(text);
76
+ const diagnostics = [];
77
+ const keys = new Set(pins.map(pin => pinTarget(pin)).filter(Boolean).map(pin => pin.symbol || pin.file));
78
+ for (const mention of extractMentions(text, project, nodes, siblingNotes)) {
79
+ if (!existingFile(project, mention.file)) {
80
+ diagnostics.push(`mentions a path that doesn't exist: ${mention.file}`);
81
+ continue;
82
+ }
83
+ const pin = resolveMention(mention, nodes, project);
84
+ if (!pin) { diagnostics.push(`unresolved mention: ${mention.symbol || `${mention.file}:${mention.line}`}`); continue; }
85
+ const key = pin.symbol || pin.file;
86
+ if (!keys.has(key)) { pins.push({ ...pin, at: date }); keys.add(key); }
87
+ }
88
+ const refreshed = pins.map(pin => {
89
+ const checked = checkPin(pin, project, nodes);
90
+ if (checked.status === "broken" || checked.status === "invalid pin") {
91
+ diagnostics.push(`${checked.status}: ${checked.detail}`);
92
+ return pin;
93
+ }
94
+ const refreshed = { ...pin };
95
+ if (checked.actual) { refreshed.fp = checked.actual; refreshed.at = date; }
96
+ return refreshed;
97
+ });
98
+ return { pins: refreshed, diagnostics, text: refreshed.length ? replaceNotePins(text, refreshed) : text };
99
+ }
100
+
101
+ export function pinMemory({ project = process.cwd(), memoryDir = memoryDirectory(project), note, all = false, dryRun = false } = {}) {
102
+ project = resolve(project);
103
+ if (Boolean(note) === Boolean(all)) throw new Error("specify one note or --all");
104
+ const files = existsSync(memoryDir) ? readdirSync(memoryDir, { withFileTypes: true })
105
+ .filter(entry => entry.isFile() && entry.name.endsWith(".md") && entry.name !== "MEMORY.md").map(entry => entry.name).sort() : [];
106
+ let selected = files;
107
+ if (!all) {
108
+ const requested = note.endsWith(".md") ? note : `${note}.md`;
109
+ const name = basename(requested);
110
+ if (name === "MEMORY.md") throw new Error("MEMORY.md is an index and cannot be pinned");
111
+ if (resolve(memoryDir, requested) !== resolve(memoryDir, name) || !files.includes(name)) throw new Error(`note not found in project memory: ${note}`);
112
+ selected = [name];
113
+ }
114
+ const siblingNotes = new Set(files);
115
+ const plans = selected.map(name => {
116
+ const plan = { name, path: join(memoryDir, name), original: undefined, pins: [], diagnostics: [], targets: [] };
117
+ try {
118
+ plan.original = readFileSync(plan.path, "utf8");
119
+ plan.text = plan.original;
120
+ plan.pins = notePins(plan.original);
121
+ plan.targets = [...plan.pins, ...extractMentions(plan.original, project, new Map(), siblingNotes)
122
+ .map(mention => ({ file: mention.file }))];
123
+ } catch (error) { failNote(plan, error); }
124
+ return plan;
125
+ });
126
+ const nodes = refreshPinGraph(project, plans.flatMap(plan => plan.targets));
127
+ if (!nodes) return { project, status: "pins off", message: "no graft graph, pins off", notes: [] };
128
+ const date = new Date().toISOString().slice(0, 10);
129
+ for (const plan of plans) {
130
+ if (plan.failed) continue;
131
+ try {
132
+ Object.assign(plan, planNote(plan.original, project, nodes, date, siblingNotes));
133
+ if (!dryRun && plan.text !== plan.original) atomicNoteWrite(plan);
134
+ } catch (error) { failNote(plan, error); }
135
+ }
136
+ return { project, status: dryRun ? "dry run" : "pinned", notes: plans.map(plan => {
137
+ const note = { name: plan.name, pins: plan.pins, diagnostics: plan.diagnostics, changed: plan.text !== plan.original };
138
+ if (plan.failed) note.failed = true;
139
+ return note;
140
+ }) };
141
+ }
142
+
143
+ function failNote(plan, error) {
144
+ plan.failed = true;
145
+ plan.text = plan.original;
146
+ plan.pins = plan.original === undefined ? [] : notePins(plan.original);
147
+ plan.diagnostics.push(error.message);
148
+ }
149
+
150
+ function atomicNoteWrite(plan) {
151
+ const temp = join(dirname(plan.path), `.${basename(plan.path)}.${randomUUID()}.tmp`);
152
+ try {
153
+ writeFileSync(temp, plan.text, { flag: "wx", mode: statSync(plan.path).mode });
154
+ if (readFileSync(plan.path, "utf8") !== plan.original) throw new Error(`note changed during pinning: ${plan.name}; retry`);
155
+ renameSync(temp, plan.path);
156
+ } finally { if (existsSync(temp)) unlinkSync(temp); }
157
+ }
158
+
159
+ export function formatMemoryPin(result) {
160
+ if (result.message) return result.message;
161
+ return result.notes.map(note => {
162
+ const pins = note.pins.map(pin => pinTarget(pin)).map(pin => pin?.symbol || pin?.file || "invalid pin");
163
+ return `${note.name}: ${note.failed ? "failed" : result.status} — ${pins.join(", ") || "no pins"}${note.diagnostics.map(message => `\n ${message}`).join("")}`;
164
+ }).join("\n");
165
+ }
@@ -0,0 +1,153 @@
1
+ import { createHash } from "node:crypto";
2
+ import { closeSync, existsSync, openSync, readFileSync, readdirSync, statSync, unlinkSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { isAbsolute, join, resolve } from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+ import { dep } from "./dep.mjs";
7
+
8
+ const { isMap, parseDocument } = await dep("yaml");
9
+ const { z } = await dep("zod");
10
+
11
+ export function fingerprint(text) {
12
+ const normalized = text.replace(/\r\n/g, "\n").split("\n")
13
+ .map(line => line.trimEnd()).join("\n").replace(/^\n+|\n+$/g, "");
14
+ return `sha256:${createHash("sha256").update(normalized, "utf8").digest("hex").slice(0, 16)}`;
15
+ }
16
+
17
+ export function memoryDirectory(project, configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude")) {
18
+ return join(configDir, "projects", resolve(project).replace(/[^a-zA-Z0-9]/g, "-"), "memory");
19
+ }
20
+
21
+ export function frontMatter(text) {
22
+ const match = /^(---\r?\n)([\s\S]*?)(^---(?:\r?\n|$))/m.exec(text);
23
+ if (!match || match.index !== 0) {
24
+ if (/^---\r?\n/.test(text)) throw new Error("unterminated front matter");
25
+ return null;
26
+ }
27
+ const document = parseDocument(match[2]);
28
+ if (document.errors.length) throw new Error(`invalid front matter: ${document.errors[0].message.split("\n")[0]}`);
29
+ if (document.contents && !isMap(document.contents)) throw new Error("front matter must be a mapping");
30
+ return { document, yaml: match[2], start: match[1].length, end: match[0].length };
31
+ }
32
+
33
+ export function notePins(text) {
34
+ try {
35
+ const front = frontMatter(text);
36
+ const data = front?.document.toJS();
37
+ if (!data || data.pins == null) return [];
38
+ return Array.isArray(data.pins) ? data.pins : [data.pins];
39
+ } catch (error) {
40
+ return [{ invalid: error.message.split("\n")[0] }];
41
+ }
42
+ }
43
+
44
+ const fileSchema = z.string().min(1).refine(file => !isAbsolute(file)
45
+ && !file.split(/[\\/]/).some(part => part === ".." || part === "" || part === "."));
46
+ const symbolSchema = z.object({
47
+ symbol: z.string().refine(symbol => {
48
+ const split = symbol.lastIndexOf("#");
49
+ return split > 0 && split < symbol.length - 1 && fileSchema.safeParse(symbol.slice(0, split)).success;
50
+ }),
51
+ fp: z.string().regex(/^sha256:[0-9a-f]{16}$/),
52
+ }).passthrough().refine(pin => !Object.hasOwn(pin, "file"));
53
+ const pinSchema = z.union([
54
+ symbolSchema,
55
+ z.object({ file: fileSchema }).passthrough()
56
+ .refine(pin => !Object.hasOwn(pin, "symbol") && !Object.hasOwn(pin, "fp")),
57
+ ]);
58
+
59
+ export function pinTarget(pin) {
60
+ const parsed = pinSchema.safeParse(pin);
61
+ if (!parsed.success) return null;
62
+ const value = parsed.data;
63
+ return value.symbol ? { file: value.symbol.slice(0, value.symbol.lastIndexOf("#")), symbol: value.symbol }
64
+ : { file: value.file };
65
+ }
66
+
67
+ export function checkPin(pin, project, nodes) {
68
+ try { return checkTarget(pin, project, nodes); }
69
+ catch (error) { return { pin, status: "invalid pin", detail: error.message }; }
70
+ }
71
+
72
+ function checkTarget(pin, project, nodes) {
73
+ const target = pinTarget(pin);
74
+ if (!target) return { pin, status: "invalid pin", detail: pin?.invalid || "expected symbol + fp, or file without fp" };
75
+ const path = join(project, target.file);
76
+ if (!existsSync(path) || !statSync(path).isFile()) return { pin, status: "broken", detail: `file gone: ${target.file}` };
77
+ if (!target.symbol) return { pin, status: "ok" };
78
+ const node = nodes.get(target.symbol);
79
+ if (!node) return { pin, status: "broken", detail: `symbol gone: ${target.symbol}` };
80
+ const actual = symbolFingerprint(project, node);
81
+ return actual === pin.fp ? { pin, status: "ok" }
82
+ : { pin, status: "stale", actual, detail: `body changed: ${target.symbol} (${pin.fp} -> ${actual})` };
83
+ }
84
+
85
+ export function symbolFingerprint(project, node) {
86
+ const span = /^L(\d+)-L(\d+)$/.exec(node.span);
87
+ const lines = readFileSync(join(project, node.path), "utf8").replace(/\r\n/g, "\n").split("\n");
88
+ if (!span || +span[1] < 1 || +span[2] < +span[1] || +span[2] > lines.length) {
89
+ throw new Error(`invalid graft span for ${node.id}; run graft build`);
90
+ }
91
+ return fingerprint(lines.slice(+span[1] - 1, +span[2]).join("\n"));
92
+ }
93
+
94
+ export function pinFileNewer(project, pin, graphTime) {
95
+ const target = pinTarget(pin);
96
+ if (!target) return false;
97
+ try { return statSync(join(project, target.file)).mtimeMs > graphTime; }
98
+ catch { return false; }
99
+ }
100
+
101
+ export function refreshPinGraph(project, pins = []) {
102
+ const graphPath = join(project, "graft", ".graph", "wiring.json");
103
+ if (!existsSync(graphPath)) return null;
104
+ const needsBuild = () => pins.some(pin => pinFileNewer(project, pin, statSync(graphPath).mtimeMs));
105
+ if (needsBuild()) {
106
+ const lockPath = `${graphPath}.lock`;
107
+ const deadline = Date.now() + 125000;
108
+ let lock;
109
+ while (lock === undefined) {
110
+ try { lock = openSync(lockPath, "wx"); }
111
+ catch (error) {
112
+ if (error.code !== "EEXIST") throw error;
113
+ if (Date.now() >= deadline) throw new Error("timed out waiting for graft build lock");
114
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
115
+ }
116
+ }
117
+ try {
118
+ // Another note writer may have refreshed the graph while we waited.
119
+ if (needsBuild()) {
120
+ const build = spawnSync("graft", ["build", project, "--no-gitignore", "--no-ignore"], {
121
+ encoding: "utf8", timeout: 120000, maxBuffer: 16 * 1024 * 1024,
122
+ });
123
+ if (build.error || build.status !== 0) throw new Error(`graft build failed: ${build.error?.message || build.stderr || build.stdout}`);
124
+ }
125
+ } finally { closeSync(lock); unlinkSync(lockPath); }
126
+ }
127
+ const graph = JSON.parse(readFileSync(graphPath, "utf8"));
128
+ if (!Array.isArray(graph.nodes)) throw new Error("invalid graft wiring graph: missing nodes");
129
+ return new Map(graph.nodes.map(node => [node.id, node]));
130
+ }
131
+
132
+ export function checkMemory({ project = process.cwd(), memoryDir = memoryDirectory(project) } = {}) {
133
+ project = resolve(project);
134
+ const graphPath = join(project, "graft", ".graph", "wiring.json");
135
+ if (!existsSync(graphPath)) return { project, status: "pins off", message: "no graft graph, pins off", notes: [] };
136
+ const notes = existsSync(memoryDir) ? readdirSync(memoryDir, { withFileTypes: true })
137
+ .filter(entry => entry.isFile() && entry.name.endsWith(".md") && entry.name !== "MEMORY.md")
138
+ .map(entry => ({ name: entry.name, pins: notePins(readFileSync(join(memoryDir, entry.name), "utf8")) })) : [];
139
+ let nodes = new Map();
140
+ if (notes.some(note => note.pins.some(pin => pinTarget(pin)?.symbol))) {
141
+ try { nodes = refreshPinGraph(project, notes.flatMap(note => note.pins)); }
142
+ catch (error) { return { project, status: "pins off", message: `pins off: ${error.message}`, notes: [] }; }
143
+ }
144
+ return { project, status: "checked", notes: notes.map(note => ({
145
+ name: note.name, pins: note.pins.map(pin => checkPin(pin, project, nodes)),
146
+ })) };
147
+ }
148
+
149
+ export function formatMemoryCheck(result) {
150
+ if (result.message) return result.message;
151
+ return result.notes.flatMap(note => note.pins.filter(pin => pin.status !== "ok")
152
+ .map(pin => `${note.name}: ${pin.status} — ${pin.detail}`)).join("\n") || `ok: ${result.notes.length} notes, ${result.notes.reduce((sum, note) => sum + note.pins.length, 0)} pins`;
153
+ }
@@ -0,0 +1,34 @@
1
+ // #11559: seats run long cards in ONE opencode session (#6134's design), and opencode only
2
+ // auto-compacts when the context is FULL - far past 100K on big-limit models. The knob is the
3
+ // top-level `compaction` block (schema: opencode.ai/config.json), injected per seat via
4
+ // OPENCODE_CONFIG_CONTENT (override: TRANTOR_OC_COMPACTION), which MERGES with the operator's global config.
5
+
6
+ export const OC_COMPACTION_ENV = "TRANTOR_OC_COMPACTION";
7
+
8
+ // The seat default: compaction on, old tool outputs pruned, a small recent window preserved.
9
+ // `reserved` is the buffer subtracted from the model's window before opencode calls the context
10
+ // full, so a larger value fires compaction earlier. Override the whole block with
11
+ // TRANTOR_OC_COMPACTION=<json>, or set it to "off" to ship no config content at all.
12
+ export const OC_COMPACTION_DEFAULT = {
13
+ auto: true,
14
+ prune: true,
15
+ tail_turns: 4,
16
+ preserve_recent_tokens: 24_000,
17
+ reserved: 64_000,
18
+ };
19
+
20
+ /// A JSON string for OPENCODE_CONFIG_CONTENT, or "" when nothing should be injected: "off" opts
21
+ /// out, invalid JSON opts out loudly at the caller (never arms a half-parsed block), custom JSON
22
+ /// is wrapped as { compaction: <the value> } so the env stays one knob.
23
+ export function ocCompactionContent(env = process.env) {
24
+ const raw = String(env[OC_COMPACTION_ENV] || "").trim();
25
+ if (raw === "off") return "";
26
+ if (!raw) return JSON.stringify({ compaction: OC_COMPACTION_DEFAULT });
27
+ let parsed;
28
+ try { parsed = JSON.parse(raw); } catch { return ""; }
29
+ // SAFETY: the operator's raw JSON arrives here untyped — a bare number, string or null must
30
+ // opt OUT (no config), never arm a half-shaped compaction block at every opencode seat.
31
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof
32
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return "";
33
+ return JSON.stringify({ compaction: parsed });
34
+ }
@@ -0,0 +1,72 @@
1
+ // #11559: the seat fleet's token burn was invisible — mined by hand from opencode's DB. This
2
+ // reader makes it a command: per-provider 30-day session/step/token totals, read-only, so a
3
+ // week-over-week comparison is one line. No DB at all is "no data", never zero. Read path
4
+ // mirrors lib/turn-usage.mjs (sqlite3 CLI, -readonly, fail-open).
5
+ import { spawnSync } from "node:child_process";
6
+
7
+ const num = (v) => { const n = Number(v); return Number.isInteger(n) && n >= 0 ? n : 0; };
8
+
9
+ /// "provider/model" → "provider"; a bare model reads "?" so the row still groups. The 1.16.x DB
10
+ /// also stores whole JSON blobs ({"id":…,"providerID":…,"variant":…}) in session.model — the
11
+ /// provider then lives at $.providerID. The SQL mirrors this in CASE/WHEN form.
12
+ export function providerOf(model) {
13
+ const s = String(model || "").trim();
14
+ if (s.startsWith("{")) {
15
+ try { return String(JSON.parse(s).providerID || "?"); } catch { return "?"; }
16
+ }
17
+ const at = s.indexOf("/");
18
+ return at > 0 ? s.slice(0, at) : "?";
19
+ }
20
+
21
+ const PROVIDER_SQL = (col) => `CASE WHEN substr(${col},1,1)='{' THEN COALESCE(json_extract(${col},'$.providerID'),'?')`
22
+ + ` WHEN instr(${col},'/')>1 THEN substr(${col},1,instr(${col},'/')-1) ELSE '?' END`;
23
+
24
+ export const SESSIONS_SQL = (sinceMs) => `SELECT ${SESSION_COLS} FROM session`
25
+ + ` WHERE time_updated>=${Math.floor(sinceMs)} GROUP BY p ORDER BY total DESC;`;
26
+ const SESSION_COLS = `${PROVIDER_SQL("model")} AS p, COUNT(*) AS sessions,`
27
+ + ` COALESCE(SUM(tokens_input),0), COALESCE(SUM(tokens_output),0), COALESCE(SUM(tokens_cache_read),0),`
28
+ + ` COALESCE(SUM(tokens_cache_write),0), COALESCE(SUM(cost),0), COALESCE(MIN(time_created),0),`
29
+ + ` COALESCE(MAX(time_updated),0), COALESCE(SUM(tokens_input+tokens_output+tokens_cache_read+tokens_cache_write),0) AS total`;
30
+
31
+ export const STEPS_SQL = (sinceMs) => `SELECT ${PROVIDER_SQL("s.model")} AS p,`
32
+ + ` COUNT(m.id), COALESCE(MAX(json_extract(m.data,'$.tokens.input')),0)`
33
+ + ` FROM message m JOIN session s ON s.id=m.session_id`
34
+ + ` WHERE json_extract(m.data,'$.role')='assistant' AND m.time_created>=${Math.floor(sinceMs)} GROUP BY p;`;
35
+
36
+ const pipeRows = (text, cols) => String(text || "").split("\n").filter((l) => l.trim() !== "")
37
+ .map((line) => {
38
+ const parts = line.split("|");
39
+ return parts.length === cols ? parts : null;
40
+ })
41
+ .filter(Boolean);
42
+
43
+ /// { provider, sessions, input, output, cacheRead, cacheWrite, cost, firstAt, lastAt, tokens,
44
+ /// steps, peakInput } per provider, tokens descending. [] when both queries return nothing —
45
+ /// an empty fleet and an unreadable DB print the same "no data" line, never zeros that lie.
46
+ export function seatTokensReport({ dbPath, sinceMs, run = spawnSync } = {}) {
47
+ if (!dbPath || !Number.isFinite(sinceMs)) return [];
48
+ const q = (sql) => {
49
+ let r;
50
+ try { r = run("sqlite3", ["-readonly", dbPath, sql], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15000 }); }
51
+ catch { return null; }
52
+ return !r || r.error || r.status !== 0 ? null : r.stdout;
53
+ };
54
+ const sessions = pipeRows(q(SESSIONS_SQL(sinceMs)), 10);
55
+ const steps = pipeRows(q(STEPS_SQL(sinceMs)), 3);
56
+ const byProvider = new Map(sessions.map((c) => [c[0], {
57
+ provider: c[0],
58
+ sessions: num(c[1]),
59
+ input: num(c[2]), output: num(c[3]), cacheRead: num(c[4]), cacheWrite: num(c[5]),
60
+ cost: Number(c[6]) || 0,
61
+ firstAt: num(c[7]), lastAt: num(c[8]),
62
+ tokens: num(c[9]),
63
+ steps: 0, peakInput: 0,
64
+ }]));
65
+ for (const [p, count, peak] of steps) {
66
+ const row = byProvider.get(p);
67
+ if (!row) continue;
68
+ row.steps = num(count);
69
+ row.peakInput = num(peak);
70
+ }
71
+ return [...byProvider.values()].sort((a, b) => b.tokens - a.tokens);
72
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.92",
3
+ "version": "0.18.94",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
@@ -8,6 +8,7 @@
8
8
  "dependencies": {
9
9
  "@modelcontextprotocol/sdk": "^1.29.0",
10
10
  "pg": "^8.22.0",
11
+ "yaml": "^2.9.1",
11
12
  "zod": "^4.4.3"
12
13
  },
13
14
  "scripts": {