trantor 0.18.92 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +6 -1
- package/bin/cli.mjs +3 -0
- package/bin/crew-runner.mjs +7 -1
- package/bin/memory.mjs +34 -0
- package/hooks/hooks.json +11 -0
- package/hooks/lib/memory-start.mjs +63 -0
- package/hooks/memory-pin.mjs +30 -0
- package/hooks/sessionstart.mjs +3 -0
- package/hub/routes/cards.mjs +17 -1
- package/lib/memory-pin-write.mjs +163 -0
- package/lib/memory-pins.mjs +151 -0
- package/package.json +2 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
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]
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -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
|
-
|
|
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/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}`); }
|
package/hooks/sessionstart.mjs
CHANGED
|
@@ -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
|
package/hub/routes/cards.mjs
CHANGED
|
@@ -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.
|
|
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": {
|