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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +6 -1
- package/bin/cli.mjs +3 -0
- package/bin/crew/models.mjs +14 -9
- package/bin/crew-runner.mjs +7 -1
- package/bin/memory.mjs +34 -0
- package/bin/models.mjs +11 -18
- package/engine/bin/scrooge +35 -8
- package/engine/test-routing.py +10 -2
- 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/models.mjs
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
model =
|
|
25
|
-
console.error(`[crew]
|
|
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
|
|
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);
|
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/bin/models.mjs
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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)} → ${
|
|
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}`);
|
package/engine/bin/scrooge
CHANGED
|
@@ -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
|
|
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
|
|
179
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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():
|
package/engine/test-routing.py
CHANGED
|
@@ -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("
|
|
40
|
-
|
|
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}`); }
|
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": {
|