trantor 0.18.91 → 0.18.93

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.91",
3
+ "version": "0.18.93",
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]
@@ -10,21 +10,26 @@ function routeModel(ctx, provider, candidates, task, difficulty) {
10
10
  ? [script, "route", "--candidates", candidates, "-t", task, "-d", difficulty, "--json"]
11
11
  : [script, "route", "--provider", provider, "-t", task, "-d", difficulty, "--json"];
12
12
  const result = call("python3", args, { env: ctx.env });
13
- if (!result.ok || !result.stdout) return "";
14
- try { return JSON.parse(result.stdout).qualified || ""; }
15
- catch { return ""; }
13
+ try {
14
+ const decision = JSON.parse(result.stdout);
15
+ if (decision.error) throw new Error(`[crew] router refused ${provider}: ${decision.error}`);
16
+ return result.ok ? decision.qualified || "" : "";
17
+ } catch (error) {
18
+ if (error instanceof SyntaxError) return "";
19
+ throw error;
20
+ }
16
21
  }
17
22
 
18
23
  export function resolveModel(ctx, agent, provider, task, difficulty) {
19
24
  const result = call("opencode", ["models", provider], { env: ctx.env });
20
25
  const candidates = result.ok ? result.stdout.split(/\s+/).filter(Boolean).join(" ") : "";
21
26
  let model = routeModel(ctx, provider, candidates, task, difficulty);
22
- if (!model && candidates) {
23
- const first = candidates.split(" ")[0];
24
- model = first.startsWith(`${provider}/`) ? first : `${provider}/${first}`;
25
- console.error(`[crew] router unavailable for ${agent}:${provider} — using the provider's own catalog head (${model})`);
27
+ // An unavailable router must not erase seats from a layout preview. Never launch an unresolved model.
28
+ if (!model && ctx.dry && candidates) {
29
+ model = `${provider}/<unresolved>`;
30
+ console.error(`[crew] dry run only for ${agent}:${provider} — router unavailable; model unresolved, live launch would be refused`);
26
31
  }
27
- if (!model) throw new Error(`[crew] live model selection failed for ${agent}:${provider} — no router and no catalog; refusing opencode global default`);
32
+ if (!model) throw new Error(`[crew] live model selection failed for ${agent}:${provider} — no priced model matching provider preference; refusing opencode global default`);
28
33
  if (model.split("/")[0] !== provider) throw new Error(`[crew] router selected ${model} outside ${provider} — refusing cross-provider fallback`);
29
34
  return model;
30
35
  }
@@ -50,7 +55,7 @@ export function resolveSpec(ctx, spec, task, difficulty, skipped) {
50
55
  const model = resolveModel(ctx, agent, field, task, difficulty);
51
56
  // #7777: the live model is known — attach the catalog's per-difficulty effort parameters here
52
57
  // so the runner applies them where the CLI accepts them (CREW_EFFORT, below).
53
- console.log(` → ${agent}: live model ${model} (${field} · ${task}/${difficulty})`);
58
+ console.log(` → ${agent}: ${model.endsWith("/<unresolved>") ? "preview" : "live"} model ${model} (${field} · ${task}/${difficulty})`);
54
59
  return { agent, model, effort: resolveEffort(agent, model, difficulty) };
55
60
  } catch (error) {
56
61
  console.error(error.message);
@@ -1111,7 +1111,13 @@ exit $turn_exit`;
1111
1111
  if (endStatus !== null) {
1112
1112
  cardStatusAtEnd = endStatus;
1113
1113
  const moved = ["testing", "done"].includes(endStatus) && !["testing", "done"].includes(cardStatusBefore);
1114
- noDelivery = !newCommit && !moved && !askVerdict && !blockedVerdict;
1114
+ // #11551: a card ALREADY at testing/done when the turn began is delivered work — the
1115
+ // codex-2 specimen's redelivered contract could neither commit anew nor move the card, and
1116
+ // the check parked the seat for work it had already shipped. It only stands while the card
1117
+ // still IS testing/done: a mid-turn bounce to doing (endStatus doing) reopens the contract.
1118
+ const alreadyDelivered = ["testing", "done"].includes(cardStatusBefore)
1119
+ && ["testing", "done"].includes(endStatus);
1120
+ noDelivery = !newCommit && !moved && !alreadyDelivered && !askVerdict && !blockedVerdict;
1115
1121
  }
1116
1122
  }
1117
1123
  lastNoDelivery = noDelivery;
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
+ }
package/bin/models.mjs CHANGED
@@ -1,10 +1,6 @@
1
1
  #!/usr/bin/env node
2
- // trantor models — browse the live models behind your crew seats (opencode is the adapter).
3
- //
4
- // trantor models # every opencode-driven seat + how many live models it serves
5
- // trantor models <provider> # list that provider's live models + what the router picks per
6
- // # difficulty (so you can see hard→strong, easy→cheap at a glance)
7
- import { execSync } from "node:child_process";
2
+ // Browse live crew models and preview the same routing used at launch.
3
+ import { execSync, spawnSync } from "node:child_process";
8
4
  import { dirname, join } from "node:path";
9
5
  import { homedir } from "node:os";
10
6
  import { existsSync, statSync } from "node:fs";
@@ -25,16 +21,11 @@ const liveModels = (providerOc) => {
25
21
  catch { return []; }
26
22
  };
27
23
 
28
- const FLASH_TIER = /(flash|turbo|lite|mini|highspeed|small)/i;
29
-
30
24
  function routePick(candList, diff) {
31
- try {
32
- const hard = diff === "hard";
33
- const strong = hard ? candList.filter(id => !FLASH_TIER.test(id)) : candList;
34
- const pool = strong.length > 0 ? strong : candList;
35
- const out = execSync(`python3 ${SCROOGE} route --candidates ${JSON.stringify(pool.join(" "))} -t code -d ${diff} --json 2>/dev/null`, { encoding: "utf8" });
36
- return JSON.parse(out).qualified || "?";
37
- } catch { return "?"; }
25
+ const result = spawnSync("python3", [SCROOGE, "route", "--candidates", candList.join(" "),
26
+ "-t", "code", "-d", diff, "--json"], { encoding: "utf8" });
27
+ try { return JSON.parse(result.stdout); }
28
+ catch { return { error: "router unavailable", unpriced: [] }; }
38
29
  }
39
30
 
40
31
  function listAll() {
@@ -57,12 +48,14 @@ function detail(name) {
57
48
  const models = liveModels(providerOc);
58
49
  if (!models.length) { console.log(`No live models for '${providerOc}' (opencode offline / no key / unknown provider).`); return; }
59
50
  console.log(`${C.gold}${providerOc}${C.off} — ${models.length} live models\n`);
60
- for (const m of models.slice(0, 60)) console.log(` ${m}`);
51
+ const picks = Object.fromEntries(["easy", "medium", "hard"].map(d => [d, routePick(models, d)]));
52
+ const unpriced = new Set(Object.values(picks).flatMap(p => p.unpriced || []));
53
+ for (const m of models.slice(0, 60)) console.log(` ${m}${unpriced.has(m.split("/").at(-1)) ? " (unpriced)" : ""}`);
61
54
  if (models.length > 60) console.log(` ${C.dim}… +${models.length - 60} more${C.off}`);
62
55
  if (existsSync(SCROOGE) || has("scrooge")) {
63
56
  console.log(`\n${C.gold}router picks (code task):${C.off}`);
64
- for (const d of ["easy", "medium", "hard"]) console.log(` ${d.padEnd(7)} → ${routePick(models, d)}`);
65
- const caps = join(H, ".token-scrooge", "capabilities.json");
57
+ for (const d of ["easy", "medium", "hard"]) console.log(` ${d.padEnd(7)} → ${picks[d].qualified || picks[d].error}`);
58
+ const caps = join(process.env.SCROOGE_HOME || join(H, ".token-scrooge"), "capabilities.json");
66
59
  // only nag when the scores are missing or older than the weekly refresh allows for
67
60
  const ageDays = existsSync(caps) ? (Date.now() - statSync(caps).mtimeMs) / 86400e3 : Infinity;
68
61
  if (ageDays > 9) console.log(`${C.dim}(scores ${ageDays === Infinity ? "missing" : `${Math.floor(ageDays)}d old`} — run scrooge-capabilities to (re)score the catalog for accurate difficulty routing)${C.off}`);
@@ -37,7 +37,7 @@ Config: $SCROOGE_HOME (default ~/.token-scrooge) holds registry.json + calls.js
37
37
  Keys: read from the environment; optionally also from $SCROOGE_ENV_FILE (KEY=VALUE)
38
38
  Exit: 0 ok, non-zero on error (message to stderr).
39
39
  """
40
- import sys, os, json, time, argparse, urllib.request, urllib.error, re, shutil, hashlib
40
+ import sys, os, json, time, argparse, urllib.request, urllib.error, re, shutil, hashlib, math
41
41
 
42
42
  HOME = os.path.expanduser("~")
43
43
  SCROOGE_DIR = os.environ.get("SCROOGE_HOME", os.path.join(HOME, ".token-scrooge"))
@@ -171,18 +171,26 @@ def model_quality(caps, mid, metric):
171
171
  v = c.get("intelligence") # fall back to the general index
172
172
  return float(v) if isinstance(v, (int, float)) else 0.0
173
173
 
174
- def blended_cost(reg, caps, mid):
174
+ def model_prices(reg, caps, mid):
175
175
  # The curated registry carries cost for its own models; for CATALOG models (OpenRouter's
176
176
  # hundreds, brought via --candidates) the per-model price rides on the capability entry
177
177
  # instead — so a brought model is ranked on its real price without bloating the registry.
178
- m = reg["models"].get(mid) or (caps.get(mid) if isinstance(caps, dict) else None) or {}
179
- return max(1e-6, 0.3 * m.get("cost_in", 0) + 0.7 * m.get("cost_out", 0))
178
+ for m in (reg["models"].get(mid) or {}, (caps or {}).get(mid) or {}):
179
+ prices = [m.get("cost_in"), m.get("cost_out")]
180
+ if all(type(v) in (int, float) and math.isfinite(v) and v >= 0 for v in prices):
181
+ return prices
182
+ return None
183
+
184
+ def blended_cost(reg, caps, mid):
185
+ prices = model_prices(reg, caps, mid)
186
+ return max(1e-6, 0.3 * prices[0] + 0.7 * prices[1]) if prices is not None else None
180
187
 
181
188
  def weigh_candidates(reg, caps, cand_ids, task, difficulty):
182
189
  """Apply the difficulty floor, then rank survivors by quality^qw / cost^cw.
183
190
  Returns [(model_id, score)] best-first."""
184
191
  metric = task_metric(task)
185
- quals = [(mid, model_quality(caps, mid, metric)) for mid in cand_ids]
192
+ quals = [(mid, model_quality(caps, mid, metric)) for mid in cand_ids
193
+ if blended_cost(reg, caps, mid) is not None]
186
194
  scored_q = sorted(q for _, q in quals if q > 0)
187
195
  pct = DIFF_PCTL.get(difficulty, 0.5)
188
196
  floor = 0.0
@@ -218,6 +226,9 @@ def route_task(reg, caps, task, difficulty, prompt, spread=0):
218
226
  live = [m for m in cands if provider_key(reg, reg["models"][m]["provider"])]
219
227
  if not live:
220
228
  raise SystemExit("No live API key for any model serving task '%s'." % task)
229
+ live = [m for m in live if blended_cost(reg, caps, m) is not None]
230
+ if not live:
231
+ raise SystemExit("No priced candidate models; enrich pricing before routing.")
221
232
  if not caps: # no quality data → legacy cheapest-first
222
233
  return live[0], reg["models"][live[0]]["provider"], {"difficulty": None, "weighed": False}
223
234
  diff = difficulty or infer_difficulty(task, prompt)
@@ -659,7 +670,19 @@ def cmd_route(reg, args):
659
670
  by_bare = {}
660
671
  for r in raw:
661
672
  by_bare.setdefault(r.split("/")[-1], r)
662
- bare_ids = list(by_bare.keys())
673
+ unpriced = [b for b in by_bare if blended_cost(reg, caps, b) is None]
674
+ bare_ids = [b for b in by_bare if b not in unpriced]
675
+ if not provider:
676
+ providers = {r.split("/")[0] for r in raw if "/" in r}
677
+ if len(providers) == 1:
678
+ provider = next(iter(providers))
679
+ if provider == "qwen":
680
+ preferred = "qwen3.8-max" if args.difficulty == "hard" else "qwen3.8-flash"
681
+ bare_ids = [b for b in bare_ids if b == preferred]
682
+ if not bare_ids:
683
+ print(json.dumps({"error": "no priced candidate models matching provider preference",
684
+ "unpriced": unpriced}))
685
+ raise SystemExit(2)
663
686
  task = args.task or "code"
664
687
  difficulty = args.difficulty or "medium"
665
688
  metric = task_metric(task)
@@ -681,11 +704,13 @@ def cmd_route(reg, args):
681
704
  original = by_bare[pick_bare]
682
705
  qualified = original if "/" in original else (("%s/%s" % (provider, pick_bare)) if provider else pick_bare)
683
706
  result = {"provider": provider, "model": pick_bare, "qualified": qualified,
684
- "weighed": weighed, "task": task, "difficulty": difficulty, "why": why, "candidates": cand_out}
707
+ "weighed": weighed, "task": task, "difficulty": difficulty, "why": why, "candidates": cand_out, "unpriced": unpriced}
685
708
  if getattr(args, "json", False):
686
709
  print(json.dumps(result))
687
710
  else:
688
711
  err(DIM("[scrooge route] %s → %s (%s)" % (provider or "?", qualified, "weighed" if weighed else "heuristic")))
712
+ if unpriced:
713
+ err("[scrooge route] unpriced (excluded): " + ", ".join(unpriced))
689
714
  print(qualified)
690
715
  return 0
691
716
 
@@ -704,7 +729,9 @@ def cmd_list(reg, args):
704
729
  q = caps.get(m) or {}
705
730
  fmt = lambda v: ("%2.0f" % v) if isinstance(v, (int, float)) else " -"
706
731
  cap = "%s/%s/%s " % (fmt(q.get("intelligence")), fmt(q.get("coding")), fmt(q.get("reasoning")))
707
- print(" %s %-24s %5.2f/%-5.2f %s%-9s %s" % (live, m, c.get("cost_in",0), c.get("cost_out",0),
732
+ prices = model_prices(reg, caps, m)
733
+ price = "unpriced" if prices is None else "%5.2f/%-5.2f" % tuple(prices)
734
+ print(" %s %-24s %s %s%-9s %s" % (live, m, price,
708
735
  cap, c.get("trust",""), ",".join(c.get("good_for", []))))
709
736
  print("\nTASKS (weighed by capability×cost, gated by difficulty):")
710
737
  for t, ms in reg.get("tasks", {}).items():
@@ -36,8 +36,16 @@ def ok(name, cond):
36
36
  # 1. catalog cost via caps fallback (model absent from registry)
37
37
  ok("blended_cost falls back to the capability entry for catalog models",
38
38
  abs(g["blended_cost"](reg, caps, "frontier") - (0.3 * 5.0 + 0.7 * 22.5)) < 1e-6)
39
- ok("blended_cost is 1e-6 for an entirely unknown model (no crash)",
40
- g["blended_cost"](reg, caps, "does-not-exist") == 1e-6)
39
+ ok("unknown model is unpriced", g["blended_cost"](reg, caps, "does-not-exist") is None)
40
+ for bad in ({}, {"cost_in": 1}, {"cost_in": None, "cost_out": 1},
41
+ {"cost_in": -1, "cost_out": 1}, {"cost_in": float("nan"), "cost_out": 1}):
42
+ ok("invalid or incomplete prices are unpriced", g["blended_cost"](reg, {"bad": bad}, "bad") is None)
43
+ ok("explicit zero prices are free", g["blended_cost"](reg, {"free": {"cost_in": 0, "cost_out": 0}}, "free") == 1e-6)
44
+ for diff in ("easy", "medium", "hard"):
45
+ mixed = {"priced": {"coding": 50, "cost_in": 2, "cost_out": 6}, "unpriced": {"coding": 99}}
46
+ ranked = g["weigh_candidates"](reg, mixed, list(mixed), "code", diff)
47
+ ok("priced beats stronger unpriced at " + diff, [m for m, _ in ranked] == ["priced"])
48
+ ok("all unpriced yields no pick", g["weigh_candidates"](reg, {}, ["unknown"], "code", "easy") == [])
41
49
 
42
50
  def winner(diff):
43
51
  return g["weigh_candidates"](reg, caps, cands, "code", diff)[0][0]
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). ---
@@ -0,0 +1,163 @@
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 { stringify } from "yaml";
5
+ import { checkPin, frontMatter, memoryDirectory, notePins, pinTarget, refreshPinGraph, symbolFingerprint } from "./memory-pins.mjs";
6
+
7
+ export function replaceNotePins(text, pins) {
8
+ const front = frontMatter(text);
9
+ const newline = text.includes("\r\n") ? "\r\n" : "\n";
10
+ const value = stringify(pins, { collectionStyle: "flow", lineWidth: 0 }).trimEnd();
11
+ if (!front) return `---${newline}pins: ${value}${newline}---${newline}${text}`;
12
+ const pair = front.document.contents?.items.find(item => item.key?.value === "pins");
13
+ if (!pair) {
14
+ if (front.document.contents?.flow) throw new Error("cannot add pins to flow-style front matter without changing other keys");
15
+ const at = front.start + front.yaml.length;
16
+ return `${text.slice(0, at)}pins: ${value}${newline}${text.slice(at)}`;
17
+ }
18
+ // Value ranges exclude neighboring keys and their comments; never reserialize the whole note.
19
+ const [start, end] = pair.value.range;
20
+ const old = front.yaml.slice(start, end);
21
+ const ending = old.match(/\r?\n$/)?.[0] || "";
22
+ const spacing = start === end ? " " : "";
23
+ return text.slice(0, front.start + start) + spacing + value + ending + text.slice(front.start + end);
24
+ }
25
+
26
+ export function extractMentions(text, project, nodes, siblingNotes = new Set()) {
27
+ const front = frontMatter(text);
28
+ const body = (front ? text.slice(front.end) : text)
29
+ .replace(/\[\[[\s\S]*?\]\]/g, " ").replace(/\b[a-z][a-z0-9+.-]*:\/\/\S+/gi, " ");
30
+ const paths = new Set([...nodes.values()].map(node => node.path));
31
+ const mentions = [];
32
+ const seen = new Set();
33
+ const pattern = /(?<![\w./\\-])((?:\.\/)?(?:[\w@.-]+\/)*[\w@.-]+)(?:#([\w$.-]+)|:(\d+))?/g;
34
+ for (const match of body.matchAll(pattern)) {
35
+ let file = match[1].replace(/^\.\//, "").replace(/[.,]+$/, "");
36
+ if (!pinTarget({ file }) || file === "Node.js") continue;
37
+ if (siblingNotes.has(file)) continue;
38
+ if (!file.includes("/") && /^v?\d+(?:\.[\da-z-]+)+$/i.test(file)
39
+ && !/\.(?:[cm]?jsx?|tsx?|py|rs|go|swift|c|h|cpp|hpp|java|kt|rb|sh|php|vue|svelte)$/i.test(file)) continue;
40
+ const prefix = `${basename(project)}/`;
41
+ if (!existingFile(project, file) && file.startsWith(prefix) && existingFile(project, file.slice(prefix.length))) {
42
+ file = file.slice(prefix.length);
43
+ }
44
+ 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;
45
+ const symbol = match[2]?.replace(/\.+$/, "");
46
+ const mention = symbol ? { file, symbol: `${file}#${symbol}` }
47
+ : match[3] ? { file, line: Number(match[3]) } : { file };
48
+ const key = JSON.stringify(mention);
49
+ if (!seen.has(key)) { seen.add(key); mentions.push(mention); }
50
+ }
51
+ return mentions;
52
+ }
53
+
54
+ function existingFile(project, file) {
55
+ try { return statSync(join(project, file)).isFile(); }
56
+ catch { return false; }
57
+ }
58
+
59
+ function resolveMention(mention, nodes, project) {
60
+ if (!existingFile(project, mention.file)) return null;
61
+ if (mention.symbol) return nodes.has(mention.symbol) ? { symbol: mention.symbol, fp: symbolFingerprint(project, nodes.get(mention.symbol)) } : null;
62
+ if (mention.line === undefined) return { file: mention.file };
63
+ const lines = readFileSync(join(project, mention.file), "utf8").split(/\r?\n/);
64
+ if (mention.line < 1 || mention.line > lines.length) return null;
65
+ const enclosing = [...nodes.values()].filter(node => node.path === mention.file && node.kind !== "file")
66
+ .map(node => ({ node, span: /^L(\d+)-L(\d+)$/.exec(node.span) }))
67
+ .filter(({ span }) => span && +span[1] <= mention.line && +span[2] >= mention.line)
68
+ .sort((a, b) => (+a.span[2] - +a.span[1]) - (+b.span[2] - +b.span[1]));
69
+ return enclosing.length ? { symbol: enclosing[0].node.id, fp: symbolFingerprint(project, enclosing[0].node) } : { file: mention.file };
70
+ }
71
+
72
+ function planNote(text, project, nodes, date, siblingNotes) {
73
+ const pins = notePins(text);
74
+ const diagnostics = [];
75
+ const keys = new Set(pins.map(pin => pinTarget(pin)).filter(Boolean).map(pin => pin.symbol || pin.file));
76
+ for (const mention of extractMentions(text, project, nodes, siblingNotes)) {
77
+ if (!existingFile(project, mention.file)) {
78
+ diagnostics.push(`mentions a path that doesn't exist: ${mention.file}`);
79
+ continue;
80
+ }
81
+ const pin = resolveMention(mention, nodes, project);
82
+ if (!pin) { diagnostics.push(`unresolved mention: ${mention.symbol || `${mention.file}:${mention.line}`}`); continue; }
83
+ const key = pin.symbol || pin.file;
84
+ if (!keys.has(key)) { pins.push({ ...pin, at: date }); keys.add(key); }
85
+ }
86
+ const refreshed = pins.map(pin => {
87
+ const checked = checkPin(pin, project, nodes);
88
+ if (checked.status === "broken" || checked.status === "invalid pin") {
89
+ diagnostics.push(`${checked.status}: ${checked.detail}`);
90
+ return pin;
91
+ }
92
+ const refreshed = { ...pin };
93
+ if (checked.actual) { refreshed.fp = checked.actual; refreshed.at = date; }
94
+ return refreshed;
95
+ });
96
+ return { pins: refreshed, diagnostics, text: refreshed.length ? replaceNotePins(text, refreshed) : text };
97
+ }
98
+
99
+ export function pinMemory({ project = process.cwd(), memoryDir = memoryDirectory(project), note, all = false, dryRun = false } = {}) {
100
+ project = resolve(project);
101
+ if (Boolean(note) === Boolean(all)) throw new Error("specify one note or --all");
102
+ const files = existsSync(memoryDir) ? readdirSync(memoryDir, { withFileTypes: true })
103
+ .filter(entry => entry.isFile() && entry.name.endsWith(".md") && entry.name !== "MEMORY.md").map(entry => entry.name).sort() : [];
104
+ let selected = files;
105
+ if (!all) {
106
+ const requested = note.endsWith(".md") ? note : `${note}.md`;
107
+ const name = basename(requested);
108
+ if (name === "MEMORY.md") throw new Error("MEMORY.md is an index and cannot be pinned");
109
+ if (resolve(memoryDir, requested) !== resolve(memoryDir, name) || !files.includes(name)) throw new Error(`note not found in project memory: ${note}`);
110
+ selected = [name];
111
+ }
112
+ const siblingNotes = new Set(files);
113
+ const plans = selected.map(name => {
114
+ const plan = { name, path: join(memoryDir, name), original: undefined, pins: [], diagnostics: [], targets: [] };
115
+ try {
116
+ plan.original = readFileSync(plan.path, "utf8");
117
+ plan.text = plan.original;
118
+ plan.pins = notePins(plan.original);
119
+ plan.targets = [...plan.pins, ...extractMentions(plan.original, project, new Map(), siblingNotes)
120
+ .map(mention => ({ file: mention.file }))];
121
+ } catch (error) { failNote(plan, error); }
122
+ return plan;
123
+ });
124
+ const nodes = refreshPinGraph(project, plans.flatMap(plan => plan.targets));
125
+ if (!nodes) return { project, status: "pins off", message: "no graft graph, pins off", notes: [] };
126
+ const date = new Date().toISOString().slice(0, 10);
127
+ for (const plan of plans) {
128
+ if (plan.failed) continue;
129
+ try {
130
+ Object.assign(plan, planNote(plan.original, project, nodes, date, siblingNotes));
131
+ if (!dryRun && plan.text !== plan.original) atomicNoteWrite(plan);
132
+ } catch (error) { failNote(plan, error); }
133
+ }
134
+ return { project, status: dryRun ? "dry run" : "pinned", notes: plans.map(plan => {
135
+ const note = { name: plan.name, pins: plan.pins, diagnostics: plan.diagnostics, changed: plan.text !== plan.original };
136
+ if (plan.failed) note.failed = true;
137
+ return note;
138
+ }) };
139
+ }
140
+
141
+ function failNote(plan, error) {
142
+ plan.failed = true;
143
+ plan.text = plan.original;
144
+ plan.pins = plan.original === undefined ? [] : notePins(plan.original);
145
+ plan.diagnostics.push(error.message);
146
+ }
147
+
148
+ function atomicNoteWrite(plan) {
149
+ const temp = join(dirname(plan.path), `.${basename(plan.path)}.${randomUUID()}.tmp`);
150
+ try {
151
+ writeFileSync(temp, plan.text, { flag: "wx", mode: statSync(plan.path).mode });
152
+ if (readFileSync(plan.path, "utf8") !== plan.original) throw new Error(`note changed during pinning: ${plan.name}; retry`);
153
+ renameSync(temp, plan.path);
154
+ } finally { if (existsSync(temp)) unlinkSync(temp); }
155
+ }
156
+
157
+ export function formatMemoryPin(result) {
158
+ if (result.message) return result.message;
159
+ return result.notes.map(note => {
160
+ const pins = note.pins.map(pin => pinTarget(pin)).map(pin => pin?.symbol || pin?.file || "invalid pin");
161
+ return `${note.name}: ${note.failed ? "failed" : result.status} — ${pins.join(", ") || "no pins"}${note.diagnostics.map(message => `\n ${message}`).join("")}`;
162
+ }).join("\n");
163
+ }
@@ -0,0 +1,151 @@
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 { isMap, parseDocument } from "yaml";
7
+ import { z } from "zod";
8
+
9
+ export function fingerprint(text) {
10
+ const normalized = text.replace(/\r\n/g, "\n").split("\n")
11
+ .map(line => line.trimEnd()).join("\n").replace(/^\n+|\n+$/g, "");
12
+ return `sha256:${createHash("sha256").update(normalized, "utf8").digest("hex").slice(0, 16)}`;
13
+ }
14
+
15
+ export function memoryDirectory(project, configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude")) {
16
+ return join(configDir, "projects", resolve(project).replace(/[^a-zA-Z0-9]/g, "-"), "memory");
17
+ }
18
+
19
+ export function frontMatter(text) {
20
+ const match = /^(---\r?\n)([\s\S]*?)(^---(?:\r?\n|$))/m.exec(text);
21
+ if (!match || match.index !== 0) {
22
+ if (/^---\r?\n/.test(text)) throw new Error("unterminated front matter");
23
+ return null;
24
+ }
25
+ const document = parseDocument(match[2]);
26
+ if (document.errors.length) throw new Error(`invalid front matter: ${document.errors[0].message.split("\n")[0]}`);
27
+ if (document.contents && !isMap(document.contents)) throw new Error("front matter must be a mapping");
28
+ return { document, yaml: match[2], start: match[1].length, end: match[0].length };
29
+ }
30
+
31
+ export function notePins(text) {
32
+ try {
33
+ const front = frontMatter(text);
34
+ const data = front?.document.toJS();
35
+ if (!data || data.pins == null) return [];
36
+ return Array.isArray(data.pins) ? data.pins : [data.pins];
37
+ } catch (error) {
38
+ return [{ invalid: error.message.split("\n")[0] }];
39
+ }
40
+ }
41
+
42
+ const fileSchema = z.string().min(1).refine(file => !isAbsolute(file)
43
+ && !file.split(/[\\/]/).some(part => part === ".." || part === "" || part === "."));
44
+ const symbolSchema = z.object({
45
+ symbol: z.string().refine(symbol => {
46
+ const split = symbol.lastIndexOf("#");
47
+ return split > 0 && split < symbol.length - 1 && fileSchema.safeParse(symbol.slice(0, split)).success;
48
+ }),
49
+ fp: z.string().regex(/^sha256:[0-9a-f]{16}$/),
50
+ }).passthrough().refine(pin => !Object.hasOwn(pin, "file"));
51
+ const pinSchema = z.union([
52
+ symbolSchema,
53
+ z.object({ file: fileSchema }).passthrough()
54
+ .refine(pin => !Object.hasOwn(pin, "symbol") && !Object.hasOwn(pin, "fp")),
55
+ ]);
56
+
57
+ export function pinTarget(pin) {
58
+ const parsed = pinSchema.safeParse(pin);
59
+ if (!parsed.success) return null;
60
+ const value = parsed.data;
61
+ return value.symbol ? { file: value.symbol.slice(0, value.symbol.lastIndexOf("#")), symbol: value.symbol }
62
+ : { file: value.file };
63
+ }
64
+
65
+ export function checkPin(pin, project, nodes) {
66
+ try { return checkTarget(pin, project, nodes); }
67
+ catch (error) { return { pin, status: "invalid pin", detail: error.message }; }
68
+ }
69
+
70
+ function checkTarget(pin, project, nodes) {
71
+ const target = pinTarget(pin);
72
+ if (!target) return { pin, status: "invalid pin", detail: pin?.invalid || "expected symbol + fp, or file without fp" };
73
+ const path = join(project, target.file);
74
+ if (!existsSync(path) || !statSync(path).isFile()) return { pin, status: "broken", detail: `file gone: ${target.file}` };
75
+ if (!target.symbol) return { pin, status: "ok" };
76
+ const node = nodes.get(target.symbol);
77
+ if (!node) return { pin, status: "broken", detail: `symbol gone: ${target.symbol}` };
78
+ const actual = symbolFingerprint(project, node);
79
+ return actual === pin.fp ? { pin, status: "ok" }
80
+ : { pin, status: "stale", actual, detail: `body changed: ${target.symbol} (${pin.fp} -> ${actual})` };
81
+ }
82
+
83
+ export function symbolFingerprint(project, node) {
84
+ const span = /^L(\d+)-L(\d+)$/.exec(node.span);
85
+ const lines = readFileSync(join(project, node.path), "utf8").replace(/\r\n/g, "\n").split("\n");
86
+ if (!span || +span[1] < 1 || +span[2] < +span[1] || +span[2] > lines.length) {
87
+ throw new Error(`invalid graft span for ${node.id}; run graft build`);
88
+ }
89
+ return fingerprint(lines.slice(+span[1] - 1, +span[2]).join("\n"));
90
+ }
91
+
92
+ export function pinFileNewer(project, pin, graphTime) {
93
+ const target = pinTarget(pin);
94
+ if (!target) return false;
95
+ try { return statSync(join(project, target.file)).mtimeMs > graphTime; }
96
+ catch { return false; }
97
+ }
98
+
99
+ export function refreshPinGraph(project, pins = []) {
100
+ const graphPath = join(project, "graft", ".graph", "wiring.json");
101
+ if (!existsSync(graphPath)) return null;
102
+ const needsBuild = () => pins.some(pin => pinFileNewer(project, pin, statSync(graphPath).mtimeMs));
103
+ if (needsBuild()) {
104
+ const lockPath = `${graphPath}.lock`;
105
+ const deadline = Date.now() + 125000;
106
+ let lock;
107
+ while (lock === undefined) {
108
+ try { lock = openSync(lockPath, "wx"); }
109
+ catch (error) {
110
+ if (error.code !== "EEXIST") throw error;
111
+ if (Date.now() >= deadline) throw new Error("timed out waiting for graft build lock");
112
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
113
+ }
114
+ }
115
+ try {
116
+ // Another note writer may have refreshed the graph while we waited.
117
+ if (needsBuild()) {
118
+ const build = spawnSync("graft", ["build", project, "--no-gitignore", "--no-ignore"], {
119
+ encoding: "utf8", timeout: 120000, maxBuffer: 16 * 1024 * 1024,
120
+ });
121
+ if (build.error || build.status !== 0) throw new Error(`graft build failed: ${build.error?.message || build.stderr || build.stdout}`);
122
+ }
123
+ } finally { closeSync(lock); unlinkSync(lockPath); }
124
+ }
125
+ const graph = JSON.parse(readFileSync(graphPath, "utf8"));
126
+ if (!Array.isArray(graph.nodes)) throw new Error("invalid graft wiring graph: missing nodes");
127
+ return new Map(graph.nodes.map(node => [node.id, node]));
128
+ }
129
+
130
+ export function checkMemory({ project = process.cwd(), memoryDir = memoryDirectory(project) } = {}) {
131
+ project = resolve(project);
132
+ const graphPath = join(project, "graft", ".graph", "wiring.json");
133
+ if (!existsSync(graphPath)) return { project, status: "pins off", message: "no graft graph, pins off", notes: [] };
134
+ const notes = existsSync(memoryDir) ? readdirSync(memoryDir, { withFileTypes: true })
135
+ .filter(entry => entry.isFile() && entry.name.endsWith(".md") && entry.name !== "MEMORY.md")
136
+ .map(entry => ({ name: entry.name, pins: notePins(readFileSync(join(memoryDir, entry.name), "utf8")) })) : [];
137
+ let nodes = new Map();
138
+ if (notes.some(note => note.pins.some(pin => pinTarget(pin)?.symbol))) {
139
+ try { nodes = refreshPinGraph(project, notes.flatMap(note => note.pins)); }
140
+ catch (error) { return { project, status: "pins off", message: `pins off: ${error.message}`, notes: [] }; }
141
+ }
142
+ return { project, status: "checked", notes: notes.map(note => ({
143
+ name: note.name, pins: note.pins.map(pin => checkPin(pin, project, nodes)),
144
+ })) };
145
+ }
146
+
147
+ export function formatMemoryCheck(result) {
148
+ if (result.message) return result.message;
149
+ return result.notes.flatMap(note => note.pins.filter(pin => pin.status !== "ok")
150
+ .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`;
151
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.91",
3
+ "version": "0.18.93",
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": {