From 393df6b804bcf80087e916a758aa77274c1f4d00 Mon Sep 17 00:00:00 2001 From: anirudhkumar-nanonets Date: Wed, 7 Oct 2026 11:45:33 +0530 Subject: [PATCH 1/3] feat: ship trail as the command name, keep graft as its fallback --- .gitignore | 3 + TELEMETRY.md | 3 +- package.json | 2 +- scripts/pack-trail.mjs | 53 ++++ scripts/postinstall.mjs | 19 +- src/ask/ask.ts | 5 +- src/bin/graft.ts | 8 + src/bin/trail.ts | 6 + src/brand.ts | 143 ++++++++++ src/claude/hooks.ts | 7 +- src/claude/session-metrics.ts | 12 +- src/cli-meta.ts | 40 ++- src/cli.ts | 474 +++++++++++++++++++++++++------- src/context/check.ts | 11 +- src/context/savings.ts | 9 +- src/graph/check.ts | 9 +- src/graph/refresh.ts | 5 +- src/telemetry/contract.ts | 1 + src/telemetry/track.ts | 5 + src/upkeep.ts | 5 +- test/telemetry-contract.test.ts | 2 +- test/trail-name.test.ts | 227 +++++++++++++++ 22 files changed, 911 insertions(+), 138 deletions(-) create mode 100644 scripts/pack-trail.mjs create mode 100644 src/bin/graft.ts create mode 100644 src/bin/trail.ts create mode 100644 src/brand.ts create mode 100644 test/trail-name.test.ts diff --git a/.gitignore b/.gitignore index b6bb7d032..f9701b686 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ transcripts/ # graft's local repository settings — not committed. /.graft/ + +# The @trailhq/trail package, laid out by scripts/pack-trail.mjs. +/build/ diff --git a/TELEMETRY.md b/TELEMETRY.md index 0b535b5a3..bb24aeb3a 100644 --- a/TELEMETRY.md +++ b/TELEMETRY.md @@ -34,6 +34,7 @@ Every event carries only these common properties: | `node_major` | `20` | Major version only | | `ci` | `false` | Always false — CI never sends (see below) | | `agent_host` | `claude-code` / `cursor` / `mcp` / `cli` | Which surface graft ran under | +| `cli_name` | `trail` / `graft` | Which of the two names started the process (graft is trail's old name) | | `repo_id` | a random UUID | See "How it stays anonymous" | The events: @@ -72,7 +73,7 @@ An example event, in full: "timestamp": "2026-08-21T09:14:22.417Z", "properties": { "app_version": "0.12.0", "os": "darwin", "arch": "arm64", - "node_major": "20", "ci": "false", "agent_host": "claude-code", + "node_major": "20", "ci": "false", "agent_host": "claude-code", "cli_name": "trail", "repo_id": "6f2c1e90-...", "distinct_id": "b1f3a9c2-...", "files_bucket": "200-999", "langs": "go,ts", "mode": "deep", "duration_bucket": "30s-2m", "incremental": "true", diff --git a/package.json b/package.json index e77247697..fa58b0d9d 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "./package.json": "./package.json" }, "bin": { - "graft": "dist/cli.js" + "graft": "dist/bin/graft.js" }, "files": [ "dist", diff --git a/scripts/pack-trail.mjs b/scripts/pack-trail.mjs new file mode 100644 index 000000000..c6656bc8a --- /dev/null +++ b/scripts/pack-trail.mjs @@ -0,0 +1,53 @@ +/** + * Lays out the `@trailhq/trail` package from this repo's build, in build/trail/. + * + * trail and graft are one program under two names, published as two packages + * from the same `dist/`: + * + * @trailhq/trail bin: trail → dist/bin/trail.js + * @nanonets/graft bin: graft → dist/bin/graft.js (this repo's package.json) + * + * Each package ships exactly one command, so installing both never clashes on + * a bin name, and `graft upgrade` installs both. @nanonets/graft stays a full + * package rather than a pointer: the hook shims committed in thousands of repos + * resolve `@nanonets/graft/dist/claude/*` by path, and 78% of active installs + * are on versions older than the one that knows about trail. + * + * Usage, after `npm run build` (and the telemetry key stamp, when publishing): + * node scripts/pack-trail.mjs + * npm publish build/trail --access public + */ +import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const out = join(root, 'build', 'trail'); + +if (!existsSync(join(root, 'dist', 'bin', 'trail.js'))) { + console.error('✗ dist/bin/trail.js not found — run `npm run build` first'); + process.exit(1); +} + +const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); +const trail = { + ...pkg, + name: '@trailhq/trail', + description: + "Your team's shared memory for coding agents: a code map on your machine, and the notes, decisions and skills your whole team shares. Formerly graft.", + keywords: [...new Set(['trail', ...(pkg.keywords ?? [])])], + bin: { trail: 'dist/bin/trail.js' }, + // Built already; nothing to compile in the published tree. + scripts: { postinstall: pkg.scripts.postinstall }, +}; +delete trail.devDependencies; + +rmSync(out, { recursive: true, force: true }); +mkdirSync(out, { recursive: true }); +for (const f of pkg.files) { + const from = join(root, f); + if (existsSync(from)) cpSync(from, join(out, f), { recursive: true }); +} +writeFileSync(join(out, 'package.json'), JSON.stringify(trail, null, 2) + '\n'); +console.log(`✓ ${trail.name}@${trail.version} laid out in ${out}`); +console.log(' publish it with: npm publish build/trail --access public'); diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs index 8d4422d67..ae92f5e3a 100644 --- a/scripts/postinstall.mjs +++ b/scripts/postinstall.mjs @@ -1,12 +1,22 @@ // Prints a one-line nudge after install, and records the anonymous `install` // event. Never fails the install. -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { spawn } from 'node:child_process'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; const root = dirname(dirname(fileURLToPath(import.meta.url))); +// The same code ships as @trailhq/trail (the `trail` command) and as +// @nanonets/graft (`graft`, its old name). The install event and the nudge say +// which one this is. +let pkgName = '@nanonets/graft'; +try { + pkgName = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).name ?? pkgName; +} catch { /* the graft defaults */ } +const isTrail = pkgName === '@trailhq/trail'; +if (isTrail) process.env.TRAIL_INVOKED_AS = 'trail'; + /** * Record the install, then hand the queue straight to a detached child. * @@ -44,7 +54,12 @@ try { // not be able to silence it. try { await recordInstall(); } catch { /* telemetry is never worth an error */ } if (existsSync(join(dir, '.claude', 'helpers', 'graft-statusline.cjs'))) process.exit(0); - console.log('\n Graft installed. Run `npx graft init` to enable the Claude Code integration (statusline + hooks + auto-sync).\n'); + if (existsSync(join(dir, '.claude', 'helpers', 'trail-statusline.cjs'))) process.exit(0); + console.log( + isTrail + ? '\n Trail installed. Run `trail init` in a repo to wire it into your agents.\n' + : '\n Graft installed. Run `npx graft init` to enable the Claude Code integration (statusline + hooks + auto-sync).\n', + ); } catch { /* never fail an install */ } diff --git a/src/ask/ask.ts b/src/ask/ask.ts index a8469747f..755d2a92d 100644 --- a/src/ask/ask.ts +++ b/src/ask/ask.ts @@ -50,6 +50,7 @@ import { readSourceFile } from "../util/source.js"; import { counts, tokenize, type AskIndex, type AskIndexDoc } from "./index-file.js"; import { rulesForPointers, formatRules, type AppliedRule } from "../brain/attach.js"; import { readLink, readRulesCache } from "../brain/link.js"; +import { tag } from "../brand.js"; export interface AskHit { kind: "concept" | "symbol" | "caller" | "callee"; @@ -1543,7 +1544,7 @@ function escalationNudge(r: AskResult): string { if ((r.mode !== "lexical" && r.mode !== "empty") || r.hits.length > 3) return ""; const n = r.hits.length; return ( - `\n\n[graft] ${n === 0 ? "no hits" : `only ${n} hit${n === 1 ? "" : "s"}`} — don't re-ask with new wording; switch tool: ` + + `\n\n${tag()} ${n === 0 ? "no hits" : `only ${n} hit${n === 1 ? "" : "s"}`} — don't re-ask with new wording; switch tool: ` + "`graft grep \"\"` for every occurrence · `graft skeleton ` for a file's full API · `graft callers ` for who-uses." ); } @@ -1561,7 +1562,7 @@ function askSavingsLine(r: AskResult, body: string): string { const saved = base - pack; const pct = Math.round((saved / base) * 100); return ( - `[graft] tokens saved ≈ ${saved.toLocaleString()} (${pct}%) — this pack ≈ ` + + `${tag()} tokens saved ≈ ${saved.toLocaleString()} (${pct}%) — this pack ≈ ` + `${pack.toLocaleString()} tok vs reading the ${r.saved.files} source file(s) whole ≈ ` + `${base.toLocaleString()} tok. Estimate (baseline = those files read in full).` + savingsTurnNudge(saved) diff --git a/src/bin/graft.ts b/src/bin/graft.ts new file mode 100644 index 000000000..d792ac53d --- /dev/null +++ b/src/bin/graft.ts @@ -0,0 +1,8 @@ +#!/usr/bin/env node +// The `graft` command: trail under its old name, with every old command and +// meaning kept. Set explicitly, so a graft started from inside a trail process +// is still graft. +import { setBrand } from "../brand.js"; + +setBrand("graft"); +await import("../cli.js"); diff --git a/src/bin/trail.ts b/src/bin/trail.ts new file mode 100644 index 000000000..cb2323025 --- /dev/null +++ b/src/bin/trail.ts @@ -0,0 +1,6 @@ +#!/usr/bin/env node +// The `trail` command. Same code as `graft`, with trail's command layout. +import { setBrand } from "../brand.js"; + +setBrand("trail"); +await import("../cli.js"); diff --git a/src/brand.ts b/src/brand.ts new file mode 100644 index 000000000..46052ef47 --- /dev/null +++ b/src/brand.ts @@ -0,0 +1,143 @@ +/** + * Which name this process was started under: `trail`, or `graft`, the name it + * had before. + * + * Both names run the same code. `trail` gets the new command layout and says + * `[trail]`; `graft` keeps every old command, meaning and output byte for byte, + * because agents, hooks, CI scripts and statusline parsers already read it. + * Nothing about graft is ever removed — see `graftNotice` for the one line a + * person typing it sees. + * + * The name travels in an environment variable rather than a module variable so + * that every child this process spawns (the detached update check, a hook's + * background build) speaks the same name as its parent. Unset means graft: the + * only things that load `dist/cli.js` without going through `dist/bin/*` are + * shims and configs an older graft wrote. + * + * This file imports nothing heavier than node:fs, because the hooks load it. + */ +import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { delimiter, dirname, join } from "node:path"; + +export type Brand = "trail" | "graft"; + +/** Set by `dist/bin/trail.js` and `dist/bin/graft.js` before the CLI loads. */ +export const BRAND_ENV = "TRAIL_INVOKED_AS"; + +/** The npm package each name is published as. Same code, two names. */ +export const PACKAGE_FOR: Record = { + trail: "@trailhq/trail", + graft: "@nanonets/graft", +}; + +export function brand(env: NodeJS.ProcessEnv = process.env): Brand { + return env[BRAND_ENV] === "trail" ? "trail" : "graft"; +} + +export function setBrand(b: Brand, env: NodeJS.ProcessEnv = process.env): void { + env[BRAND_ENV] = b; +} + +/** `[trail]` or `[graft]`: the prefix agents and the statusline look for. */ +export function tag(env: NodeJS.ProcessEnv = process.env): string { + return `[${brand(env)}]`; +} + +/** + * The commands whose trail spelling is not just `graft` → `trail`, longest + * prefix first. `graft check` gave its name to the team check, `graft stats` + * and `graft trail status` became one `trail status`, and the `graft trail …` + * group moved to the top level. + */ +const RENAMED: ReadonlyArray = [ + ["graft trail disconnect", "trail logout"], + ["graft trail connect", "trail login"], + ["graft trail status", "trail status"], + ["graft trail", "trail"], + ["graft check", "trail build --check"], + ["graft stats", "trail status"], + ["graft", "trail"], +]; + +/** + * A command as the running name spells it. Takes the graft spelling, so call + * sites read the way they always have and graft's output does not change: + * `cmd("graft trail push")` is `graft trail push` under graft and `trail push` + * under trail. + */ +export function cmd(graftSpelling: string, b: Brand = brand()): string { + if (b === "graft") return graftSpelling; + for (const [from, to] of RENAMED) { + if (graftSpelling === from) return to; + if (graftSpelling.startsWith(`${from} `)) return `${to}${graftSpelling.slice(from.length)}`; + } + return graftSpelling; +} + +/* -------------------------------------------------------------------------- */ +/* the once-a-day line for people still typing graft */ +/* -------------------------------------------------------------------------- */ + +const DAY_MS = 24 * 60 * 60 * 1000; + +export function noticeStampPath(home: string = homedir()): string { + return join(home, ".graft", "rename-notice.json"); +} + +/** Whether a `trail` executable is on PATH. Only checked when the notice is due. */ +export function trailOnPath(env: NodeJS.ProcessEnv = process.env): boolean { + const names = process.platform === "win32" ? ["trail.cmd", "trail.exe", "trail"] : ["trail"]; + for (const dir of (env.PATH ?? "").split(delimiter)) { + if (!dir) continue; + for (const n of names) { + try { + if (statSync(join(dir, n)).isFile()) return true; + } catch { + /* not here */ + } + } + } + return false; +} + +export interface NoticeInput { + /** The graft spelling of what just ran, e.g. `graft ask` or `graft trail status`. */ + ran: string; + /** stderr is a terminal: a person is watching. */ + tty: boolean; + env?: NodeJS.ProcessEnv; + home?: string; + now?: number; +} + +/** + * The line a person typing `graft` sees, at most once a day, or null. + * + * Never shown to an agent (no terminal, or Claude Code's `CLAUDECODE` is set), + * in CI, or under trail itself: agents and scripts get graft's output exactly + * as it was. Writing the stamp is best-effort; a home we cannot write to just + * means the line may show again tomorrow. + */ +export function graftNotice(input: NoticeInput): string | null { + const env = input.env ?? process.env; + if (brand(env) !== "graft" || !input.tty) return null; + if (env.CLAUDECODE !== undefined || env.CI || env.GRAFT_NO_RENAME_NOTICE) return null; + const now = input.now ?? Date.now(); + const path = noticeStampPath(input.home); + try { + const last = (JSON.parse(readFileSync(path, "utf8")) as { shownAt?: number }).shownAt ?? 0; + if (now - last < DAY_MS) return null; + } catch { + /* never shown */ + } + try { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify({ shownAt: now }) + "\n"); + } catch { + /* shown again next time */ + } + const lines = [`· graft is now trail. this ran ${cmd(input.ran, "trail")}, and graft keeps working (shown once a day)`]; + if (!trailOnPath(env)) lines.push("· run graft upgrade to get the trail command too"); + return lines.join("\n"); +} diff --git a/src/claude/hooks.ts b/src/claude/hooks.ts index 21977973a..6bc61be10 100644 --- a/src/claude/hooks.ts +++ b/src/claude/hooks.ts @@ -17,6 +17,7 @@ import { readLink } from '../brain/link.js'; import { pickedAgents } from '../brain/push.js'; import { readTrailSnapshot, trailContextLine } from '../brain/watch-trail.js'; import { maybeAutopush, readTrailPushState, recordSeenSuggestions, type AutopushDeps } from '../brain/autopush.js'; +import { tag } from '../brand.js'; /** Prompts shorter than this never trigger retrieval — they are almost always * conversational ("yes go ahead", "thanks") and the coverage gate can't judge @@ -219,18 +220,18 @@ export function lastFileScopeHint(dir: string, lastFile: string | null | undefin (n) => n.kind === 'file' && (n.path === lastFile || n.path.endsWith(`/${lastFile}`)), ); if (matches.length === 0) { - console.error(`[graft] prompt hook: lastFile "${lastFile}" not found in the graph — skipping scope hint`); + console.error(`${tag()} prompt hook: lastFile "${lastFile}" not found in the graph — skipping scope hint`); return null; } const prefixes = new Set(matches.map((n) => scopeOf(n.path, scopes).prefix)); if (prefixes.size > 1) { - console.error(`[graft] prompt hook: lastFile "${lastFile}" matches more than one scope — skipping scope hint`); + console.error(`${tag()} prompt hook: lastFile "${lastFile}" matches more than one scope — skipping scope hint`); return null; } const [prefix] = prefixes; return prefix === '' ? null : prefix; // root scope: nothing to narrow } catch (e: any) { - console.error(`[graft] prompt hook: scope hint lookup failed (${e?.message ?? e}) — skipping`); + console.error(`${tag()} prompt hook: scope hint lookup failed (${e?.message ?? e}) — skipping`); return null; } } diff --git a/src/claude/session-metrics.ts b/src/claude/session-metrics.ts index 660b2c9fa..cf9b3b848 100644 --- a/src/claude/session-metrics.ts +++ b/src/claude/session-metrics.ts @@ -73,13 +73,15 @@ export function isGraftMcpTool(toolName: string): boolean { return GRAFT_MCP_TOOL_NAMES.has(bare); } -/** Whether a shell command line invokes the graft CLI (any of its install shapes). */ +/** Whether a shell command line invokes the CLI under either name (any of its install shapes). */ export function commandInvokesGraft(command: string): boolean { const c = command.trim(); - if (/dist[/\\]cli\.js/.test(c)) return true; // running from a source checkout - // `graft …`, `graft-dev …`, `npx [-y] [@nanonets/]graft …`, at the start of the - // line or of a &&/;/| segment — not "mygraft" or a path that merely contains it. - return /(^|[|&;]\s*)(npx\s+(-y\s+)?(@nanonets\/)?)?graft(-dev)?\b/i.test(c); + // running from a source checkout + if (/dist[/\\](cli|bin[/\\](trail|graft))\.js/.test(c)) return true; + // `graft …`, `graft-dev …`, `npx [-y] [@nanonets/]graft …`, and the same for + // `trail` / `@trailhq/trail`, at the start of the line or of a &&/;/| segment — + // not "mygraft" or a path that merely contains it. + return /(^|[|&;]\s*)(npx\s+(-y\s+)?(@nanonets\/|@trailhq\/)?)?(graft|trail)(-dev)?\b/i.test(c); } /** diff --git a/src/cli-meta.ts b/src/cli-meta.ts index f9cad44cf..add0ef0cf 100644 --- a/src/cli-meta.ts +++ b/src/cli-meta.ts @@ -9,8 +9,7 @@ import { execFileSync, spawnSync } from "node:child_process"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { toPosixPath } from "./util/paths.js"; - -const PKG_NAME = "@nanonets/graft"; +import { brand, PACKAGE_FOR } from "./brand.js"; /** Locates package.json relative to a module URL (works for both `dist/cli.js` * running one level under the published package root, and `src/cli.ts` running @@ -24,6 +23,22 @@ export function resolvePackageJsonPath(moduleUrl: string): string { return candidates[0]; } +/** + * The npm package this code was installed as: `@trailhq/trail`, or + * `@nanonets/graft` (the same code under its old name). Upgrades and update + * checks go to the package actually installed, not to the name typed. + */ +export function readPackageName(moduleUrl: string): string { + try { + const pkg = JSON.parse(readFileSync(resolvePackageJsonPath(moduleUrl), "utf8")) as { name?: string }; + return pkg.name ?? PACKAGE_FOR.graft; + } catch { + return PACKAGE_FOR.graft; + } +} + +const PKG_NAME = readPackageName(import.meta.url); + /** Reads the version of the graft package this module was loaded from. */ export function readCurrentVersion(moduleUrl: string): string { const raw = readFileSync(resolvePackageJsonPath(moduleUrl), "utf8"); @@ -67,13 +82,13 @@ export function getNpmViewVersion(pkgName: string = PKG_NAME, timeoutMs = 2000): /** Pure formatter for `graft version` — no I/O, easy to unit-test. */ export function formatVersionReport(current: string, latest: NpmViewResult): string { - const lines = [`graft ${current}`]; + const lines = [`${brand()} ${current}`]; if (!latest.ok || !latest.version) { lines.push("latest: unreachable (offline?)"); } else if (latest.version === current) { lines.push(`latest on npm: ${current} ✓ up to date`); } else { - lines.push(`latest on npm: ${latest.version} — run graft upgrade`); + lines.push(`latest on npm: ${latest.version} — run ${brand()} upgrade`); } return lines.join("\n"); } @@ -116,20 +131,23 @@ export interface UpgradeResult { errorMessage?: string; oldVersion?: string; newVersion?: string; + /** The `trail` package's version, when `graft upgrade` installed it alongside. */ + trailVersion?: string; } /** Pure formatter for a finished upgrade — no I/O, easy to unit-test. */ export function formatUpgradeReport(result: UpgradeResult): string { if (!result.ran) { return ( - "running via npx — npx already fetches the latest graft on every run.\n" + - "For a permanent install: npm install -g @nanonets/graft" + `running via npx — npx already fetches the latest ${brand()} on every run.\n` + + `For a permanent install: npm install -g ${PKG_NAME}` ); } if (!result.ok) { return `✗ npm install -g ${PKG_NAME}@latest failed${result.errorMessage ? `: ${result.errorMessage}` : ""}`; } - return `graft ${result.oldVersion ?? "?"} → ${result.newVersion ?? result.oldVersion ?? "?"}`; + const line = `${brand()} ${result.oldVersion ?? "?"} → ${result.newVersion ?? result.oldVersion ?? "?"}`; + return result.trailVersion ? `${line}\n✓ trail ${result.trailVersion} installed too — graft keeps working, and trail is its new name` : line; } /** Runs `npm install -g @nanonets/graft@latest` (inheriting stdio so the user @@ -140,10 +158,14 @@ export function runUpgrade(moduleUrl: string): UpgradeResult { if (isRunningViaNpx(moduleUrl)) { return { ran: false, ok: true, oldVersion }; } - const res = spawnSync("npm", ["install", "-g", `${PKG_NAME}@latest`], { stdio: "inherit" }); + // `graft upgrade` is the handover: it also installs the `trail` command, once + // that package is published. One npm call, so the two land at one version. + const alongside = PKG_NAME === PACKAGE_FOR.graft && getNpmViewVersion(PACKAGE_FOR.trail).ok ? [`${PACKAGE_FOR.trail}@latest`] : []; + const res = spawnSync("npm", ["install", "-g", `${PKG_NAME}@latest`, ...alongside], { stdio: "inherit" }); if (res.error || (res.status ?? 1) !== 0) { return { ran: true, ok: false, oldVersion, errorMessage: res.error?.message }; } const newVersion = readGlobalInstalledVersion(PKG_NAME) ?? getNpmViewVersion(PKG_NAME).version ?? oldVersion; - return { ran: true, ok: true, oldVersion, newVersion }; + const trailVersion = alongside.length ? (readGlobalInstalledVersion(PACKAGE_FOR.trail) ?? undefined) : undefined; + return { ran: true, ok: true, oldVersion, newVersion, trailVersion }; } diff --git a/src/cli.ts b/src/cli.ts index 54e97a9f2..a7614876b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,11 +1,16 @@ #!/usr/bin/env node /** - * `graft` CLI. Commands: build, ask, check, viz, mcp, callers, skeleton, grep, - * map, init. Git is the sync: commit graft/ and a clone has the graph. A - * workspace parent (≥2 git children) federates query commands across children. + * The `trail` CLI, also run as `graft`, its old name. Commands: build, ask, + * check, viz, mcp, callers, skeleton, grep, map, init. Git is the sync: commit + * graft/ and a clone has the graph. A workspace parent (≥2 git children) + * federates query commands across children. + * + * Both names run this file. Under `graft` every command keeps its old name and + * meaning; under `trail` the layout changes (see `TRAIL` below and brand.ts). */ import "dotenv/config"; import { Command } from "commander"; +import { existsSync, readdirSync, statSync } from "node:fs"; import { join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { Graft } from "./engine.js"; @@ -34,6 +39,7 @@ import { } from "./brain/push.js"; import { apiBaseUrl, clearPendingSignup, readLink, readPendingSignup, writeLink, writePendingSignup } from "./brain/link.js"; import { withLegacyNames } from "./legacy-args.js"; +import { brand, cmd, graftNotice, tag } from "./brand.js"; import { currentStage, DOING_LABEL, rulesSoFar, watchBuild, type RepoState } from "./brain/watch.js"; import { AGENT_WAIT_MS, @@ -83,6 +89,7 @@ import { formatUpgradeReport, formatVersionReport, getNpmViewVersion, readCurren import { patchBuildConfig, type BuildConfig } from "./util/state.js"; import { normalizePathPrefix } from "./util/paths.js"; import { latestSession, formatSessionStats, sessionInputRate } from "./claude/session-metrics.js"; +import { listSessionIds, readSession, sessionDir } from "./claude/state.js"; import { setInputRate } from "./context/savings.js"; import { formatUpdateNudge, maybeRefreshInBackground, readStamp, readUpdateCache, refreshUpdateCache, wiredHostIds, writeStamp } from "./upkeep.js"; import { @@ -105,6 +112,29 @@ import { const program = new Command(); const currentVersion = readCurrentVersion(import.meta.url); +/** + * Started as `trail` rather than `graft`. Decided once, before any command is + * registered, because it changes which commands exist: under trail the old + * `graft trail …` group moves to the top level, `check` hands its name to the + * team check (the freshness check is `build --check`), and `stats` and + * `trail status` become one `status`. Under graft nothing moves. + */ +const TRAIL = brand() === "trail"; + +/** Help-screen groups under trail; graft keeps its one flat list. */ +const GROUP = { + find: "Find code · your agent runs these:", + setup: "Setup:", + cloud: "Trail cloud:", + ci: "CI:", +} as const; + +/** Put a command in a help group, under trail only. */ +function grouped(c: Command, heading: string): Command { + if (TRAIL) c.helpGroup(heading); + return c; +} + /** * What the `query` telemetry event will say, filled in by the command as it runs * and emitted once from the `postAction` hook below. @@ -114,7 +144,7 @@ const currentVersion = readCurrentVersion(import.meta.url); * centrally — and because a command that calls `process.exit` should simply * report nothing, which falls out of never emitting until postAction. */ -let queryNote: { repo?: string; hit?: "yes" | "no" } = {}; +let queryNote: { repo?: string; hit?: "yes" | "no"; command?: string } = {}; /** Record the repo a query ran against, and pass it straight through so call * sites stay one line. */ @@ -133,8 +163,13 @@ function noteHit(found: boolean): void { } program - .name("graft") - .description("Build a repo's context graph as linked markdown, and keep it in sync with the code.") + .name(brand()) + .description( + TRAIL + ? "Your team's shared memory for coding agents: a code map on your machine, and\n" + + "the notes, decisions and skills your whole team shares." + : "Build a repo's context graph as linked markdown, and keep it in sync with the code.", + ) .version(currentVersion, "-v, --version") .option("--dir ", "context graph directory (default: /graft)") .option("--provider ", "LLM wire format: openai | anthropic | litellm | orcarouter (env GRAFT_PROVIDER)") @@ -192,7 +227,7 @@ const DIR_ARG = ["[dir]", "repository root (default: nearest ancestor with a gra function queryRoot(dir?: string): string { if (dir !== undefined) return resolve(dir); const { root, levels } = nearestGraftRoot(process.cwd(), program.opts().dir); - if (levels > 0) console.error(`[graft] no graft/ here — answering from ${root}/graft`); + if (levels > 0) console.error(`${tag()} no graft/ here — answering from ${root}/graft`); return root; } @@ -254,6 +289,10 @@ const UPKEEP_SKIP = new Set(["version", "upgrade", "_update-check", "_brain-refr */ program.hook("preAction", (_parent, action) => { if (UPKEEP_SKIP.has(action.name())) return; + // Someone typing graft by hand learns its new name, once a day. Agents, + // hooks, CI and pipes never see it (see graftNotice). + const renamed = graftNotice({ ran: spelling(action), tty: Boolean(process.stderr.isTTY) }); + if (renamed) console.error(renamed); maybeRefreshInBackground(); const nudge = formatUpdateNudge(currentVersion, readUpdateCache()?.latest); if (nudge) console.error(nudge); @@ -273,11 +312,19 @@ program.hook("preAction", (_parent, action) => { * is the right failure mode for a metric. */ program.hook("postAction", (_parent, action) => { - const name = action.name(); + // `trail build --check` is graft's `check`, and counts as it always has. + const name = queryNote.command ?? action.name(); if (!isTrackedCommand(name)) return; track("query", { command: name, surface: "cli", hit: queryNote.hit }, { repo: queryNote.repo }); }); +/** `graft ask`, `graft trail status`: a command as graft spells it, whatever ran it. */ +function spelling(action: Command): string { + const words: string[] = []; + for (let c: Command | null = action; c && c.parent; c = c.parent) words.unshift(c.name()); + return ["graft", ...words].join(" "); +} + // Hidden from --help: only ever spawned detached by maybeRefreshInBackground. program .command("_brain-refresh", { hidden: true }) @@ -310,8 +357,7 @@ program await runFlush(); }); -program - .command("telemetry") +grouped(program.command("telemetry"), GROUP.setup) .description("Show, inspect, or turn off the anonymous usage stats (see TELEMETRY.md)") .argument("[action]", "status (default) | enable | disable | debug", "status") .action((action: string) => { @@ -321,7 +367,7 @@ program return; case "enable": patchState({ enabled: true }); - console.log("telemetry: on — anonymous, aggregate-only. `graft telemetry status` for details."); + console.log(`telemetry: on — anonymous, aggregate-only. \`${cmd("graft telemetry status")}\` for details.`); return; case "disable": // Also stamp the notice as shown: someone who has just opted out should @@ -338,30 +384,33 @@ program } }); +// Hidden under trail, where -v says the same; listed under graft as it was. program - .command("version") + .command("version", { hidden: TRAIL }) .description("Print the installed version and the latest published on npm") .action(() => { const latest = getNpmViewVersion(); console.log(formatVersionReport(currentVersion, latest)); }); -program - .command("upgrade") - .description("Upgrade the globally installed graft to the latest version on npm") +grouped(program.command("upgrade"), GROUP.setup) + .description(`Upgrade the globally installed ${brand()} to the latest version on npm`) .action(() => { const result = runUpgrade(import.meta.url); console.log(formatUpgradeReport(result)); if (result.ran && !result.ok) process.exit(1); }); -program - .command("build") +grouped(program.command("build"), GROUP.setup) .description( - "Build graft/ from your code — wiring graph + per-file cards ($0, no key). " + - "Add --deep for the LLM concept map + per-symbol summaries/crux.", + TRAIL + ? "Rebuild the code map in graft/ ($0, no key). --check fails if it's stale; --deep adds LLM summaries." + : "Build graft/ from your code — wiring graph + per-file cards ($0, no key). " + + "Add --deep for the LLM concept map + per-symbol summaries/crux.", ) .argument("[dir]", "repository root", ".") + .option("--check", "build nothing: fail if graft/ is stale relative to the code (for CI)") + .option("--json", "with --check: output the drift as JSON") .option("--deep", "run the LLM pass: concept nodes (graft/*.md) + per-symbol summary/crux") .option("-e, --extensions ", 'code extensions to include (e.g. ".ts" ".py"); an extension with no parser is ignored with a warning that lists the supported set') .option("-j, --concurrency ", "files summarized in parallel during --deep (default 5)") @@ -405,6 +454,8 @@ program .action(async ( dir: string, opts: { + check?: boolean; + json?: boolean; deep?: boolean; extensions?: string[]; concurrency?: string; @@ -420,6 +471,13 @@ program }, command: Command, ) => { + if (opts.check) { + // The freshness check, which was `graft check`. Counted as `check` so the + // metric carries on across the rename; the dir defaults like a query's. + queryNote.command = "check"; + await runCheckCommand(command.args[0], { extensions: opts.extensions, json: opts.json }, cmd("graft check")); + return; + } const buildStartedAt = Date.now(); if (opts.gitignore === false) process.env.GRAFT_NO_GITIGNORE = "1"; if (opts.ignore === false) process.env.GRAFT_NO_IGNORE = "1"; @@ -494,7 +552,7 @@ program console.error( "⚠ no API key set — falling back to the structural build (no LLM summaries).\n" + " Set GRAFT_API_KEY (and GRAFT_PROVIDER / GRAFT_BASE_URL / GRAFT_MODEL for your\n" + - " provider) and re-run `graft build --deep` to add concept nodes and summaries.", + ` provider) and re-run \`${cmd("graft build --deep")}\` to add concept nodes and summaries.`, ); } if (deep && resolved.usedLegacyEnv) { @@ -589,7 +647,7 @@ program if (process.env.GRAFT_NO_GITIGNORE) { console.log(` ${rel}/ is a local cache — add it to your gitignore if you want it untracked.`); } else { - console.log(` ${rel}/ is git-ignored (added automatically) — a local cache; teammates run \`graft build\` to get their own.`); + console.log(` ${rel}/ is git-ignored (added automatically) — a local cache; teammates run \`${cmd("graft build")}\` to get their own.`); } // #127: a --deep run whose LLM calls failed used to print the same success @@ -616,7 +674,7 @@ program if (conceptErrors.length > 0) console.error(` ${conceptErrors.length} concept-pass error(s).`); console.error(` meaning coverage: ${ready}/${total} symbols (${pct}%).`); console.error( - " Nothing computed was lost: re-run `graft build --deep` to resume from what is cached.\n" + + ` Nothing computed was lost: re-run \`${cmd("graft build --deep")}\` to resume from what is cached.\n` + " Pass --allow-partial to accept a degraded meaning tier and exit 0.", ); if (!opts.allowPartial) process.exitCode = 1; @@ -624,8 +682,7 @@ program } }); -program - .command("ask") +grouped(program.command("ask"), GROUP.find) .description("Query the graft/ graph — returns ranked nodes + exact file:line, routed to prose or wiring ($0, no key)") .argument("", "what you want to understand, in plain words") .argument(...DIR_ARG) @@ -664,8 +721,7 @@ program } }); -program - .command("skeleton") +grouped(program.command("skeleton"), GROUP.find) .description("Signatures-only view of one file from the wiring graph — the cheapest way to see a file's API surface") .argument("", "repo-relative path (or unique basename) of the file") .argument(...DIR_ARG) @@ -681,13 +737,42 @@ program else process.stdout.write(formatSkeleton(r)); }); -program - .command("check") - .description("Fail if graft/ is stale relative to the code (for CI)") - .argument(...DIR_ARG) - .option("-e, --extensions ", "code extensions to include") - .option("--json", "output the drift as JSON") - .action(async (dirArg: string | undefined, opts: { extensions?: string[]; json?: boolean }) => { +if (!TRAIL) { + program + .command("check") + .description("Fail if graft/ is stale relative to the code (for CI)") + .argument(...DIR_ARG) + .option("-e, --extensions ", "code extensions to include") + .option("--json", "output the drift as JSON") + .action(async (dirArg: string | undefined, opts: { extensions?: string[]; json?: boolean }) => { + await runCheckCommand(dirArg, opts, "graft check"); + }); +} else { + // `trail check` is the team check: your diff against what the team has + // learned. Until that ships it only says so, and where the freshness check + // went. Non-zero, so a CI step renamed from `graft check` fails loudly + // instead of passing without checking anything. + program + .command("check", { hidden: true }) + .description("Check your diff against everything the team has learned") + .argument("[dir]") + .allowUnknownOption() + .action(() => { + console.error("· trail check will compare your diff against everything your team has learned. It isn't available yet."); + console.error("· looking for the code map freshness check? that's trail build --check"); + process.exitCode = 1; + }); +} + +/** + * The freshness check: `graft check`, and `trail build --check`. `label` is how + * the report names the command, so each name prints its own spelling. + */ +async function runCheckCommand( + dirArg: string | undefined, + opts: { extensions?: string[]; json?: boolean }, + label: string, +): Promise { warnUnsupportedExtensions(opts.extensions); const dir = noteQuery(queryRoot(dirArg)); const checkGlobalDir = program.opts().dir; @@ -708,24 +793,25 @@ program if (opts.json) { console.log(JSON.stringify({ context: r, graph: g.missing ? null : g }, null, 2)); } else if (bothMissing) { - console.log("graft check: NO GRAPH\n\nNo graft/ graph found. Run `graft build` first."); + console.log(`${label}: NO GRAPH\n\nNo graft/ graph found. Run \`${cmd("graft build")}\` first.`); } else { if (r.missing) { console.log( - "deep layer: not built (run `graft build --deep` for concept nodes) — wiring graph is the source of truth", + `deep layer: not built (run \`${cmd("graft build --deep")}\` for concept nodes) — wiring graph is the source of truth`, ); } else { - console.log(formatCheckReport(r)); + console.log(formatCheckReport(r, label)); } if (!g.missing) console.log("\n" + formatGraphCheckReport(g)); } if (bothMissing || markdownFail || wiringFail) process.exit(1); - }); +} +// Under trail this is part of `status`; the old name still runs, unlisted. program - .command("stats") - .description("Show this agent session's graft-vs-source usage mix and tokens saved") + .command("stats", { hidden: TRAIL }) + .description(`Show this agent session's ${brand()}-vs-source usage mix and tokens saved`) .argument(...DIR_ARG) .option("--json", "output the session stats as JSON") .action((dirArg: string | undefined, opts: { json?: boolean }) => { @@ -742,7 +828,7 @@ program }); program - .command("viz") + .command("viz", { hidden: TRAIL }) .description("Serve an interactive visualization of the context graph (and graph.json when present)") .argument(...DIR_ARG) .option("-p, --port ", "port to serve on", "4400") @@ -767,7 +853,7 @@ program const globalOpts = program.opts<{ dir?: string }>(); const contextDir = contextDirFor(root, globalOpts.dir); if (!existsSync(contextDir)) { - console.error(`✗ no context graph at ${contextDir} — run \`graft build --deep\` first`); + console.error(`✗ no context graph at ${contextDir} — run \`${cmd("graft build --deep")}\` first`); process.exit(1); } const viewerDir = fileURLToPath(new URL("./viewer/", import.meta.url)); // prebuilt @@ -784,7 +870,7 @@ program }); const kb = Math.round(out.bytes / 1024); console.log( - `graft viz → ${out.file} (${kb} kB, ${out.contextNodes} concept nodes, ${out.codeNodes} code nodes)`, + `${cmd("graft viz")} → ${out.file} (${kb} kB, ${out.contextNodes} concept nodes, ${out.codeNodes} code nodes)`, ); return; } @@ -795,7 +881,7 @@ program port: Number(opts.port), repoName: basename(root), }); - console.log(`graft viz → ${srv.url} (ctrl-c to stop)`); + console.log(`${cmd("graft viz")} → ${srv.url} (ctrl-c to stop)`); if (opts.open) { const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; spawn(opener, [srv.url], { stdio: "ignore", detached: true, shell: process.platform === "win32" }).unref(); @@ -803,7 +889,7 @@ program }); program - .command("mcp") + .command("mcp", { hidden: TRAIL }) .description("Serve the graph over MCP (stdio) — exposes graft_find_code, graft_trace_calls, graft_find_all, graft_file_api, graft_repo_map and graft_check_freshness as tools") .argument(...DIR_ARG) .action(async (dirArg: string | undefined) => { @@ -813,8 +899,7 @@ program startMcpServer(dir, globalOpts.dir, currentVersion); }); -program - .command("callers") +grouped(program.command("callers"), GROUP.find) .description( "Who calls/references a symbol ($0, no LLM). --direction out gives callees (what it calls); --depth N (or all) walks transitively for full blast radius", ) @@ -855,8 +940,7 @@ program }, ); -program - .command("blast") +grouped(program.command("blast"), GROUP.ci) .description( "Blast radius of a diff: what depends on the lines this change touched ($0, no LLM). " + "Built for CI — `--format markdown` is a PR comment with a Mermaid diagram.", @@ -888,8 +972,7 @@ program }); }); -program - .command("grep") +grouped(program.command("grep"), GROUP.find) .description("Regex search over indexed files, hits grouped by enclosing symbol and ranked by coupling ($0, no LLM)") .argument("", "regex pattern (or literal string with --fixed)") .argument(...DIR_ARG) @@ -924,8 +1007,7 @@ program }, ); -program - .command("map") +grouped(program.command("map"), GROUP.find) .description( "Token-budgeted repo orientation — directory clusters, per-directory hubs, and global hotspots from the wiring graph ($0, no LLM)", ) @@ -956,7 +1038,7 @@ program const contextDir = contextDirFor(root, globalOpts.dir); const graph = loadGraphCached(contextDir); if (!graph) { - console.error("✗ no graph — run graft build first"); + console.error(`✗ no graph — run ${cmd("graft build")} first`); process.exit(1); return; } @@ -968,9 +1050,12 @@ program process.stdout.write(formatRepoMap(map)); }); -program - .command("init") - .description("Wire Graft into the AI coding agents used with this repo (instruction files + MCP server; full hooks + statusline + MCP for Claude Code)") +grouped(program.command("init"), GROUP.setup) + .description( + TRAIL + ? "Wire trail into your agents: instruction files and MCP for each, plus hooks and a statusline for Claude Code" + : "Wire Graft into the AI coding agents used with this repo (instruction files + MCP server; full hooks + statusline + MCP for Claude Code)", + ) .argument("[dir]", "target repo directory", ".") .option("--no-build", "skip building the graph (wire files only)") .option("--agents ", `only these agents (${hostIds().join(", ")}, claude)`) @@ -1137,14 +1222,14 @@ async function runInitCommand( } else if (res.built) { graphLine = "✓ graph built"; } else if (res.failed) { - graphLine = "⚠ the graph build failed — run graft build to see why"; + graphLine = `⚠ the graph build failed — run ${cmd("graft build")} to see why`; } else if (existing) { graphNodes = existing.meta.nodeCount; graphLine = `✓ graph ready · ${fmt(existing.meta.nodeCount)} nodes, ${fmt(existing.meta.edgeCount)} edges`; } else if (hasGraftIndex(repo)) { graphLine = "✓ graph ready"; } else { - graphLine = "· skipped the graph build — run graft build"; + graphLine = `· skipped the graph build — run ${cmd("graft build")}`; } } @@ -1179,11 +1264,11 @@ async function runInitCommand( else console.error(`✓ brain: pulled ${res.ruleCount} rule(s) from ${brainLink.brainId}`); for (const w of res.writes) console.error(`✓ brain rules: ${w.path} (${w.action})`); if (res.ruleCount > 0 && res.writes.length === 0) - console.error("· no instruction file to write rules into — graft ask still carries them"); + console.error(`· no instruction file to write rules into — ${cmd("graft ask")} still carries them`); } else if (res.warning) { console.error(`⚠ trail: ${res.warning}`); } else if (res.ruleCount === 0) { - console.error("✓ trail connected · no rules yet — graft trail push reads this repo into it"); + console.error(`✓ trail connected · no rules yet — ${cmd("graft trail push")} reads this repo into it`); } else { const where = res.writes.map((w) => shown(repo, w.path)).join(", "); const n = `${res.ruleCount.toLocaleString("en-US")} rule${res.ruleCount === 1 ? "" : "s"}`; @@ -1200,7 +1285,7 @@ async function runInitCommand( const top = parts.length > 1 ? `${parts[0]}/` : parts[0]!; if (!tops.includes(top)) tops.push(top); } - console.error("· restart your agents so a new session picks up graft"); + console.error(`· restart your agents so a new session picks up ${brand()}`); if (tops.length) console.error(`· commit ${tops.slice(0, 4).join(" ")}${tops.length > 4 ? ` +${tops.length - 4} more` : ""} to share it — graft/ stays local and git-ignored`); } @@ -1288,7 +1373,7 @@ function wireTarget( say(`· mcp claude: ${res.mcp.path} (already registered)`); else say(`✓ mcp claude: ${res.mcp.path} (${res.mcp.action}) — restart Claude Code to load the graft MCP server`); - say(res.built ? "✓ built the graph (graft build)" : "· skipped graph build"); + say(res.built ? `✓ built the graph (${cmd("graft build")})` : "· skipped graph build"); if (!wantStatusline) say("· skipped Claude Code statusLine (--no-statusline)"); for (const w of res.warnings) say(`⚠ ${w}`); } @@ -1330,7 +1415,7 @@ function wireTarget( if (!quiet) say( buildGraphIfMissing(repo, { build: opts.build, cliPath }) - ? "✓ built the graph (graft build)" + ? `✓ built the graph (${cmd("graft build")})` : "· skipped graph build", ); } @@ -1371,9 +1456,8 @@ function formatRetractions(rs: Retraction[], apply: boolean): string { return lines.join("\n").replace(/^\n/, ""); } -program - .command("uninstall") - .description("Remove every file and config entry graft has written to this repo (the inverse of init)") +grouped(program.command("uninstall"), GROUP.setup) + .description(`Remove every file and config entry ${TRAIL ? "trail or graft" : "graft"} has written to this repo (the inverse of init)`) .argument("[dir]", "target repo directory", ".") .option("-y, --yes", "actually remove (without this, prints what it would remove and exits)") .option("--keep-cache", "keep graft/ and the .gitignore entries — wiring only") @@ -1396,14 +1480,10 @@ program console.error( bad.length ? `\n⚠ ${bad.length} file(s) could not be parsed and were left as-is — see above.` - : "\n✓ graft fully removed. `graft init` re-wires from scratch.", + : `\n✓ ${brand()} fully removed. \`${cmd("graft init")}\` re-wires from scratch.`, ); }); -// `graft trail …` still works: see legacy-args.ts. -const brain = program - .command("trail") - .description("The Trail attached to this repo: the rules mined from its own history"); /** * Get this repo a trail from the terminal, by sending the user through signup @@ -1449,7 +1529,7 @@ async function signUpForBrain(repo: string, slug: string): Promise", ":, or a bare brain id with GRAFT_BRAIN_TOKEN set") .argument("[dir]", "target repo directory", ".") .action(async (handoff: string, dir: string) => { + await runConnect(handoff, dir); + }); +} + +/** `graft trail connect`, and `trail login `: attach a trail from a Trail page's handoff. */ +async function runConnect(handoff: string, dir: string): Promise { const parsed = parseBrainArg(handoff); if ("error" in parsed) { console.error(`✗ ${parsed.error}`); @@ -1549,28 +1635,29 @@ brain // read the repo yet. Saying "0 rules" without saying why reads as a // failure, and the next step is the whole point. console.error("✓ attached this repo to the brain — it has no rules yet"); - console.error("· run `graft trail push` to read this repository into it"); + console.error(`· run \`${cmd("graft trail push")}\` to read this repository into it`); return; } console.error(`✓ pulled ${res.ruleCount} rule(s) from ${parsed.brainId}`); for (const w of res.writes) console.error(`✓ ${w.path} (${w.action})`); - }); +} -brain - .command("pull") +function pullCommand(name = "pull"): Command { + return new Command(name) .description("Refresh the trail's rules and write every change you accepted in Trail into this repo's context files") .argument("[dir]", "target repo directory", ".") .option("--dry-run", "show what would change without writing anything or telling Trail") .action(async (dir: string, opts: { dryRun?: boolean }) => { await runTrailPullCommand(dir, opts); }); +} /** `graft trail pull`, and `graft claude-md pull` which is now the same thing. */ async function runTrailPullCommand(dir: string, opts: { dryRun?: boolean }): Promise { const repo = resolve(dir); const link = readLink(repo); if (!link) { - console.error("✗ this repo has no trail yet — run graft trail push first"); + console.error(`✗ this repo has no trail yet — run ${cmd("graft trail push")} first`); process.exitCode = 1; return; } @@ -1586,8 +1673,8 @@ function positiveNumber(raw: string): number | null { return Number.isFinite(n) && n > 0 ? n : null; } -brain - .command("watch") +function watchCommand(name = "watch"): Command { + return new Command(name) .description("Wait until Trail has suggestions to review or accepted changes to pull, then say so once") .argument("[dir]", "target repo directory", ".") .option("--interval ", "how often to check Trail", String(WATCH_DEFAULTS.intervalMs / 1000)) @@ -1651,9 +1738,10 @@ brain finish(result); }, ); +} -brain - .command("push") +function pushCommand(name = "push"): Command { + return new Command(name) .description("Read THIS repo on your machine and build its trail — no GitHub App, works on private repos") .argument("[dir]", "target repo directory", ".") .option("--no-approve", "leave the mined rules as drafts for review") @@ -1681,7 +1769,7 @@ brain } if (!link) { if (!here) { - console.error("✗ this directory has no GitHub origin remote — graft can only push a GitHub repository today"); + console.error(`✗ this directory has no GitHub origin remote — ${brand()} can only push a GitHub repository today`); process.exitCode = 1; return; } @@ -1706,7 +1794,7 @@ brain } const ctx = pushContext(repo); if (!ctx) { - console.error("✗ this directory has no GitHub origin remote — graft can only push a GitHub repository today"); + console.error(`✗ this directory has no GitHub origin remote — ${brand()} can only push a GitHub repository today`); process.exitCode = 1; return; } @@ -1732,14 +1820,14 @@ brain // non-interactive push only says what to run. if (wiredHostIds(repo).length === 0) { if (process.stdin.isTTY && tty) { - console.error("· graft isn't set up here yet — pick the agents your team uses:"); + console.error(`· ${brand()} isn't set up here yet — pick the agents your team uses:`); await runInitCommand( repo, { build: true, mcp: true, hooks: true, statusline: true, global: true, verbose }, { epilogue: false, push: !verbose }, ); } else { - console.error("· graft isn't set up here — run graft init so your agents read these rules"); + console.error(`· ${brand()} isn't set up here — run ${cmd("graft init")} so your agents read these rules`); } } @@ -1747,7 +1835,7 @@ brain // here can later go stale on its own. Without one the ingest still works; // its rules simply govern the repo rather than a symbol in it. const graph = loadGraphCached(contextDirFor(repo, program.opts().dir)); - if (!graph) console.error("· no graph yet — run graft build so rules can be anchored to symbols"); + if (!graph) console.error(`· no graph yet — run ${cmd("graft build")} so rules can be anchored to symbols`); const reading = startSpinner(`reading ${ctx.owner}/${ctx.name} · commits, pull-request discussion and the docs in the tree`); const built = await buildLocalDigest(repo, graph, { autoApprove: opts.approve !== false, context: ctx }); if ("error" in built) { @@ -1862,6 +1950,7 @@ brain } console.error(` ${buildPage}`); }); +} // `graft claude-md pull` is `graft trail pull` now: the CLAUDE.md changes are // the first half of what that writes. Kept so the old name keeps working, but @@ -1876,12 +1965,12 @@ claudeMd .argument("[dir]", "target repo directory", ".") .option("--dry-run", "show what would change without writing anything or telling Trail") .action(async (dir: string, opts: { dryRun?: boolean }) => { - console.error("· graft claude-md pull is now part of graft trail pull — running that"); + console.error(`· ${brand()} claude-md pull is now part of ${cmd("graft trail pull")} — running that`); await runTrailPullCommand(dir, opts); }); -brain - .command("status") +function brainStatusCommand(name = "status"): Command { + return new Command(name) .description("Show the attached brain, how many rules are cached, and how many still match the code") .argument("[dir]", "target repo directory", ".") .option("--json", "machine-readable output") @@ -1903,13 +1992,13 @@ brain return; } if (!link) { - console.error("· no brain attached — run `graft trail connect :`"); + console.error(`· no brain attached — run \`${cmd("graft trail connect")} :\``); return; } console.error(`brain ${link.brainId}`); console.error(` ${rules.length} rule(s) cached${fetchedAt ? `, pulled ${new Date(fetchedAt).toISOString()}` : ""}`); if (!graph) { - console.error(" no graph yet — run `graft build` to see which rules still match the code"); + console.error(` no graph yet — run \`${cmd("graft build")}\` to see which rules still match the code`); return; } const stale = applied.filter((a) => a.stale); @@ -1921,9 +2010,10 @@ brain ); for (const a of stale.slice(0, 10)) console.error(` - ${a.rule}\n ${a.pointer}`); }); +} -brain - .command("disconnect") +function disconnectCommand(name = "disconnect"): Command { + return new Command(name) .description("Forget the attached brain (its rules stay in the instruction files until the next init)") .argument("[dir]", "target repo directory", ".") .action((dir: string) => { @@ -1931,6 +2021,196 @@ brain clearLink(resolve(dir)); console.error(`✓ detached the brain from ${repo}`); }); +} + +/** + * `trail login`: link this repo to Trail. With a handoff from a Trail page it + * attaches that trail, exactly as `graft trail connect` does. Without one it + * signs up in the browser, as `graft trail push` does for a repo with no trail, + * and stops there: nothing about the repository is read until `trail push`. + */ +function loginCommand(name = "login"): Command { + return new Command(name) + .description("Sign in and link this repo to Trail") + .argument("[handoff]", ": from a Trail page; leave it out to sign up in the browser") + .argument("[dir]", "target repo directory", ".") + .action(async (handoff: string | undefined, dir: string) => { + if (handoff) { + await runConnect(handoff, dir); + return; + } + const repo = resolve(dir); + const existing = readLink(repo); + if (existing) { + console.error(`✓ already signed in · this repo is linked to Trail (${existing.brainId})`); + return; + } + const here = repoSlugFromGit(repo); + if (!here) { + console.error(`✗ this directory has no GitHub origin remote — ${brand()} can only link a GitHub repository today`); + process.exitCode = 1; + return; + } + const link = await signUpForBrain(repo, `${here.owner}/${here.name}`); + if (!link) { + if (!process.exitCode) process.exitCode = 1; + return; + } + console.error(`· next: ${cmd("graft trail push")} reads this repo's history into it`); + }); +} + +/** Files ending in `ext` directly under `dir`, or 0 when it doesn't exist. */ +function countFiles(dir: string, ext: string): number { + try { + return readdirSync(dir).filter((f) => f.endsWith(ext)).length; + } catch { + return 0; + } +} + +/** Directories directly under `dir`, or 0 when it doesn't exist. */ +function countDirs(dir: string): number { + try { + return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).length; + } catch { + return 0; + } +} + +/** Tokens saved by this repo's agent sessions touched in the last 7 days. */ +function savedThisWeek(repo: string, now = Date.now()): number { + const since = now - 7 * 24 * 60 * 60 * 1000; + let total = 0; + for (const id of listSessionIds(repo)) { + try { + if (statSync(join(sessionDir(repo), `${id}.json`)).mtimeMs < since) continue; + } catch { + continue; + } + total += readSession(repo, id).savedTokens ?? 0; + } + return total; +} + +/** 212345 → `212k`, 1_250_000 → `1.3M`. */ +function shortCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${Math.round(n / 1_000)}k`; + return String(n); +} + +/** + * `trail status`: what was `graft stats` and `graft trail status`, on one + * screen. The code map and whether it matches the code, the team's notes and + * skills in .trail/, the Trail link and how many of its rules still match, and + * what the agents saved this week. + */ +function statusCommand(name = "status"): Command { + return new Command(name) + .description("Code map, notes, cloud link and tokens saved, on one screen") + .argument(...DIR_ARG) + .option("--json", "machine-readable output") + .action(async (dirArg: string | undefined, opts: { json?: boolean }) => { + const repo = queryRoot(dirArg); + const graph = loadGraphCached(contextDirFor(repo, program.opts().dir)); + const g = graph ? await engineFrom().checkGraph(repo) : null; + const fresh = g && !g.missing ? g.ok : null; + const trailDir = join(repo, ".trail"); + const hasTrailDir = existsSync(trailDir); + const notes = countFiles(join(trailDir, "notes"), ".md"); + const skills = countDirs(join(trailDir, "skills")); + const { link, rules } = brainStatus(repo); + const anchored = link && graph ? rulesForPointers(graph.nodes.map((n) => `${n.path}:${n.span}`), rules, graph) : []; + const matching = anchored.filter((a) => !a.stale).length; + const saved = savedThisWeek(repo); + + if (opts.json) { + console.log( + JSON.stringify( + { + codeMap: graph ? { nodes: graph.meta.nodeCount, inSync: fresh } : null, + trail: hasTrailDir ? { notes, skills } : null, + cloud: link ? { brainId: link.brainId, cached: rules.length, anchored: anchored.length, matching } : null, + week: { savedTokens: saved }, + }, + null, + 2, + ), + ); + return; + } + const fmt = (x: number) => x.toLocaleString("en-US"); + const row = (k: string, v: string) => console.log(`${k.padEnd(11)} ${v}`); + if (!graph) row("code map", `✗ not built · run ${cmd("graft build")}`); + else if (fresh === false) row("code map", `⚠ ${fmt(graph.meta.nodeCount)} nodes · behind the code, refreshed on the next query`); + else row("code map", `✓ ${fmt(graph.meta.nodeCount)} nodes · in sync with the code`); + if (hasTrailDir) row(".trail/", `${notes} note${notes === 1 ? "" : "s"} · ${skills} skill${skills === 1 ? "" : "s"}`); + if (!link) row("cloud", `not signed in · ${cmd("graft trail connect")}`); + else + row( + "cloud", + `linked · ${fmt(rules.length)} rule${rules.length === 1 ? "" : "s"} cached` + + (graph && anchored.length ? `, ${fmt(matching)} of ${fmt(anchored.length)} anchored still match the code` : ""), + ); + row("this week", saved > 0 ? `~${shortCount(saved)} tokens saved` : "nothing saved yet"); + }); +} + +if (TRAIL) { + // The old `graft trail …` group, at the top level: `trail push`, `trail + // login`. `connect` and `watch` still run, unlisted — connect so the + // handoff links Trail already sent keep working. + for (const c of [loginCommand(), pushCommand(), pullCommand()]) program.addCommand(c.helpGroup(GROUP.cloud)); + program.addCommand( + disconnectCommand("logout").description("Unlink this repo from Trail (its rules stay in the instruction files until the next init)").helpGroup(GROUP.cloud), + ); + program.addCommand(connectCommand(), { hidden: true }); + program.addCommand(watchCommand(), { hidden: true }); + program.addCommand(statusCommand().helpGroup(GROUP.setup)); + + // `trail trail push` and the rest, for fingers that learned graft. + const habit = program.command("trail", { hidden: true }).description("The old graft trail … commands"); + for (const c of [connectCommand(), pullCommand(), watchCommand(), pushCommand(), statusCommand(), disconnectCommand(), loginCommand()]) + habit.addCommand(c); + + // Help lists the groups in this order, and the commands within each. + const order = ["ask", "grep", "skeleton", "callers", "map", "init", "build", "status", "upgrade", "uninstall", "telemetry", "login", "push", "pull", "logout", "blast"]; + const rank = (c: Command) => { + const i = order.indexOf(c.name()); + return i === -1 ? order.length : i; + }; + (program.commands as Command[]).sort((a, b) => rank(a) - rank(b)); + + // One line each in the list; `trail --help` keeps the long text. + const summaries: Record = { + ask: "ranked answer, with the code inlined at each file:line", + grep: "every occurrence, grouped by enclosing symbol", + skeleton: "a file's whole API in ~200 tokens", + callers: "who calls it, or what it calls with --direction out", + map: "directory clusters, hubs and hotspots", + init: "wire trail into your agents", + build: "rebuild the code map · --check fails if it's stale", + status: "code map, notes, cloud link and tokens saved", + upgrade: "install the latest trail", + uninstall: "remove everything trail or graft wrote to this repo", + telemetry: "show or turn off the anonymous usage stats", + login: "sign in and link this repo to Trail", + push: "send this repo's history to Trail to build its rules", + pull: "write changes you accepted in Trail into this repo", + logout: "unlink this repo from Trail", + blast: "what depends on the lines this diff touched", + }; + for (const c of program.commands) if (summaries[c.name()]) c.summary(summaries[c.name()]!); + program.addHelpText("after", "\ntrail --help for flags · every graft command still works"); +} else { + // `graft trail …` still works: see legacy-args.ts. + const brain = program + .command("trail") + .description("The Trail attached to this repo: the rules mined from its own history"); + for (const c of [connectCommand(), pullCommand(), watchCommand(), pushCommand(), brainStatusCommand(), disconnectCommand()]) + brain.addCommand(c); +} program.parseAsync(withLegacyNames(process.argv)).catch((err) => { console.error(err instanceof Error ? err.message : err); diff --git a/src/context/check.ts b/src/context/check.ts index 0090a001a..1fd4686f5 100644 --- a/src/context/check.ts +++ b/src/context/check.ts @@ -19,6 +19,7 @@ import { relPosix } from "../util/paths.js"; import { readSourceFile } from "../util/source.js"; import { CODE_EXTENSIONS, listContextFiles } from "./build.js"; import { contextDirFor, readManifest, readNodes } from "./node-file.js"; +import { cmd } from "../brand.js"; export interface CheckResult { ok: boolean; @@ -152,13 +153,13 @@ export function staleBanner(f: FreshnessResult | null): string | null { } /** Render a check result as a human-readable report. */ -export function formatCheckReport(r: CheckResult): string { +export function formatCheckReport(r: CheckResult, label = "graft check"): string { if (r.missing) { - return "graft check: NO GRAPH\n\nNo graft/manifest.json found. Run `graft build --deep` first."; + return `${label}: NO GRAPH\n\nNo graft/manifest.json found. Run \`${cmd("graft build --deep")}\` first.`; } - if (r.ok) return "graft check: OK — the graph is in sync with the code."; + if (r.ok) return `${label}: OK — the graph is in sync with the code.`; - const lines: string[] = ["graft check: STALE", ""]; + const lines: string[] = [`${label}: STALE`, ""]; if (r.contentDrift.length) { lines.push(`changed (${r.contentDrift.length}):`); for (const c of r.contentDrift) lines.push(` ~ ${c.path} (${c.from} → ${c.to})`); @@ -175,7 +176,7 @@ export function formatCheckReport(r: CheckResult): string { lines.push(`index mismatch (${r.indexDrift.length}):`); for (const s of r.indexDrift) lines.push(` ! ${s}`); } - lines.push("", "Run `graft build --deep` to regenerate, then commit graft/."); + lines.push("", `Run \`${cmd("graft build --deep")}\` to regenerate, then commit graft/.`); return lines.join("\n"); } diff --git a/src/context/savings.ts b/src/context/savings.ts index bacc7c25c..fc237f0cd 100644 --- a/src/context/savings.ts +++ b/src/context/savings.ts @@ -14,6 +14,7 @@ */ import type { GraphV1 } from '../graph/types.js'; import { formatDollars } from './price.js'; +import { brand, tag } from "../brand.js"; export interface Savings { /** How many source files the baseline covers. */ @@ -100,8 +101,8 @@ export function savingsTurnNudge(savedTokens: number): string { return ( ` This call is worth ${formatDollars(usd)} at the rate this session is ` + 'actually paying for input tokens. At the end of your reply, tell the user ' + - `the total graft tokens saved this turn and what they were worth${sum}` + - '"🌱 graft saved ~N tokens (~$X) this turn".' + `the total ${brand()} tokens saved this turn and what they were worth${sum}` + + `"🌱 ${brand()} saved ~N tokens (~$X) this turn".` ); } @@ -118,7 +119,7 @@ export function savingsLine(body: string, saved: Savings | undefined): string { const delta = base - pack; const pct = Math.round((delta / base) * 100); return ( - `[graft] tokens saved ≈ ${delta.toLocaleString()} (${pct}%) — this output ≈ ` + + `${tag()} tokens saved ≈ ${delta.toLocaleString()} (${pct}%) — this output ≈ ` + `${pack.toLocaleString()} tok vs reading the ${saved.files} file(s) it covers whole ≈ ` + `${base.toLocaleString()} tok (estimate).` + savingsTurnNudge(delta) @@ -134,7 +135,7 @@ export function savingsLine(body: string, saved: Savings | undefined): string { */ export function sumSavingsFooters(text: string): number { let total = 0; - for (const m of text.matchAll(/\[graft\] tokens saved ≈ ([\d,]+)/g)) { + for (const m of text.matchAll(/\[(?:graft|trail)\] tokens saved ≈ ([\d,]+)/g)) { total += Number(m[1].replace(/,/g, '')) || 0; } return total; diff --git a/src/graph/check.ts b/src/graph/check.ts index d36f2bd27..77acdafea 100644 --- a/src/graph/check.ts +++ b/src/graph/check.ts @@ -27,6 +27,7 @@ import { listSourceFiles } from "./build.js"; import { readGraph, wiringPath } from "./write.js"; import { readFingerprint } from "./fingerprint.js"; import { readSourceFile } from "../util/source.js"; +import { cmd } from "../brand.js"; export interface GraphCheckResult { ok: boolean; @@ -166,7 +167,7 @@ export async function checkGraph( /** Render a graph-check result as a human-readable report. */ export function formatGraphCheckReport(r: GraphCheckResult): string { if (r.missing) { - return "graph check: NO GRAPH\n\nNo graft/.graph/wiring.json found. Run `graft build` first."; + return `graph check: NO GRAPH\n\nNo graft/.graph/wiring.json found. Run \`${cmd("graft build")}\` first.`; } if (r.ok) { // A share, not a bare count: "1203 not yet summarized" reads the same whether @@ -195,8 +196,8 @@ export function formatGraphCheckReport(r: GraphCheckResult): string { for (const id of r.stale) lines.push(` ! ${id}`); } lines.push(""); - if (structural) lines.push("Run `graft build` to rebuild the structure, then commit graft/."); - if (r.stale.length) lines.push("Run `graft build --deep` to refresh stale summaries."); + if (structural) lines.push(`Run \`${cmd("graft build")}\` to rebuild the structure, then commit graft/.`); + if (r.stale.length) lines.push(`Run \`${cmd("graft build --deep")}\` to refresh stale summaries.`); return lines.join("\n"); } @@ -214,7 +215,7 @@ function formatPendingNote(r: GraphCheckResult, pct: number): string { // meaning replies, so name the nodes and point at the last build's errors. return ( `meaning tier ${pct}% complete — ${r.pending} of ${r.nodes} node(s) pending${named}. ` + - `Run \`graft build --deep\` to summarize them; if a deep build already left these pending, ` + + `Run \`${cmd("graft build --deep")}\` to summarize them; if a deep build already left these pending, ` + `that meaning pass failed — see that build's errors (re-running alone will not clear them)` ); } diff --git a/src/graph/refresh.ts b/src/graph/refresh.ts index a749f7ab6..b34621ec7 100644 --- a/src/graph/refresh.ts +++ b/src/graph/refresh.ts @@ -41,6 +41,7 @@ import { driftCount, isClean, probeDrift, readFingerprint, type Drift } from "./ import { invalidateGraphCaches } from "./load.js"; import { seedGraph, type SeedResult } from "./seed.js"; import { wiringPath } from "./write.js"; +import { tag } from "../brand.js"; /** How long to wait for another process's in-flight rebuild before giving up and * answering from the current graph. Long enough to ride out a small repo's build, @@ -263,11 +264,11 @@ export async function ensureFreshChildren( /** The one-line note a CLI/MCP surface prints when a refresh actually happened. * Null when there's nothing to say (the overwhelmingly common case). */ export function refreshNote(r: RefreshResult): string | null { - if (!r.refreshed) return r.note ? `[graft] ${r.note}` : null; + if (!r.refreshed) return r.note ? `${tag()} ${r.note}` : null; const n = r.drift ? driftCount(r.drift) : 0; const built = `refreshed the graph (${n || "?"} file${n === 1 ? "" : "s"} changed) before answering`; // Both facts, when a worktree was seeded *and* the copy needed repairing: the // count is the interesting half (it's the branch diff), the provenance explains // where a graph came from in a directory the user knows they never built. - return r.note ? `[graft] ${built} — ${r.note}` : `[graft] ${built}`; + return r.note ? `${tag()} ${built} — ${r.note}` : `${tag()} ${built}`; } diff --git a/src/telemetry/contract.ts b/src/telemetry/contract.ts index 7a25903f9..e3eb5d372 100644 --- a/src/telemetry/contract.ts +++ b/src/telemetry/contract.ts @@ -92,6 +92,7 @@ export const COMMON_KEYS = [ 'node_major', 'ci', 'agent_host', + 'cli_name', 'repo_id', ] as const; diff --git a/src/telemetry/track.ts b/src/telemetry/track.ts index e57f8fb1d..2cdf08908 100644 --- a/src/telemetry/track.ts +++ b/src/telemetry/track.ts @@ -23,6 +23,7 @@ import { enqueue } from './queue.js'; import { telemetryOn } from './gate.js'; import { installId, patchState, readState, repoId } from './identity.js'; import { runningVersion } from '../upkeep.js'; +import { brand } from '../brand.js'; /** One queued event, exactly as it will be sent. */ export interface QueuedEvent { @@ -69,6 +70,10 @@ function commonProps(ctx: TrackContext): Record { node_major: String(process.versions.node.split('.')[0]), ci: 'false', // an event only exists at all when the CI gate passed agent_host: detectHost(ctx.host, ctx.env ?? process.env), + // `trail` or `graft`: which name started this process. The share of + // queries under `trail` is how the rename is measured, and both names + // share one install id, so nobody is counted twice. + cli_name: brand(ctx.env ?? process.env), }; if (ctx.repo) { try { props.repo_id = repoId(ctx.repo); } catch { /* unwritable cache — omit */ } diff --git a/src/upkeep.ts b/src/upkeep.ts index 132fb287a..62f5115f1 100644 --- a/src/upkeep.ts +++ b/src/upkeep.ts @@ -31,7 +31,8 @@ import { homedir } from 'node:os'; import { readJson, writeJsonAtomic, cacheDir } from './util/state.js'; import { HOSTS } from './hosts/registry.js'; import { START } from './hosts/sections.js'; -import { getNpmViewVersion, readCurrentVersion } from './cli-meta.js'; +import { getNpmViewVersion, readCurrentVersion, readPackageName } from './cli-meta.js'; +import { brand } from './brand.js'; import { cacheIsStale, markRulesChecked, readLink, readRulesCache } from './brain/link.js'; import { graftCliPath } from './claude/paths.js'; @@ -183,7 +184,7 @@ export function maybeRefreshBrainRules(repo: string, now = Date.now()): boolean * "you're up to date". */ export function formatUpdateNudge(current: string, latest: string | null | undefined): string | null { if (!isNewer(latest, current)) return null; - return `⬆ graft ${current} → ${latest} available: run \`npm i -g @nanonets/graft@latest\` (restart your agent after).`; + return `⬆ ${brand()} ${current} → ${latest} available: run \`npm i -g ${readPackageName(import.meta.url)}@latest\` (restart your agent after).`; } /* -------------------------------------------------------------------------- */ diff --git a/test/telemetry-contract.test.ts b/test/telemetry-contract.test.ts index 9f7aaa603..1a71b2363 100644 --- a/test/telemetry-contract.test.ts +++ b/test/telemetry-contract.test.ts @@ -66,7 +66,7 @@ test('every event carries the common properties, and no identifier beyond them', const home = sandbox('tel-common'); const ev = track('first_run', {}, { home, env: OPEN }); assert.ok(ev); - for (const k of ['app_version', 'os', 'arch', 'node_major', 'ci', 'agent_host']) { + for (const k of ['app_version', 'os', 'arch', 'node_major', 'ci', 'agent_host', 'cli_name']) { assert.ok(k in ev.properties, `missing common property ${k}`); } // distinct_id is the random install uuid and nothing else. diff --git a/test/trail-name.test.ts b/test/trail-name.test.ts new file mode 100644 index 000000000..c1588b313 --- /dev/null +++ b/test/trail-name.test.ts @@ -0,0 +1,227 @@ +/** + * `trail` and `graft`: one program under two names. + * + * Under graft every command keeps its old name, meaning and output, because + * agents, hooks and CI already parse it. Under trail the layout changes: the + * `graft trail …` group moves to the top level, `check` hands its name to the + * team check, and the freshness check becomes `build --check`. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { brand, cmd, graftNotice, noticeStampPath, tag } from "../src/brand.js"; +import { track } from "../src/telemetry/track.js"; +import { commandInvokesGraft } from "../src/claude/session-metrics.js"; +import { savingsLine, sumSavingsFooters } from "../src/context/savings.js"; +import { homeEnv, tmpRepo } from "./helpers.js"; + +// --- the name itself --- + +test("unset means graft: anything that loads cli.js directly was wired by an older graft", () => { + assert.equal(brand({}), "graft"); + assert.equal(brand({ TRAIL_INVOKED_AS: "trail" }), "trail"); + assert.equal(brand({ TRAIL_INVOKED_AS: "something-else" }), "graft"); + assert.equal(tag({ TRAIL_INVOKED_AS: "trail" }), "[trail]"); + assert.equal(tag({}), "[graft]"); +}); + +test("cmd spells a graft command the way the running name does", () => { + for (const s of ["graft ask", "graft check", "graft trail push", "graft build --deep"]) assert.equal(cmd(s, "graft"), s); + assert.equal(cmd("graft ask", "trail"), "trail ask"); + assert.equal(cmd("graft build --deep", "trail"), "trail build --deep"); + assert.equal(cmd("graft check", "trail"), "trail build --check"); + assert.equal(cmd("graft stats", "trail"), "trail status"); + assert.equal(cmd("graft trail status", "trail"), "trail status"); + assert.equal(cmd("graft trail push", "trail"), "trail push"); + assert.equal(cmd("graft trail connect", "trail"), "trail login"); + assert.equal(cmd("graft trail disconnect", "trail"), "trail logout"); + // A prefix only matches a whole word: `graft checkout` is not `graft check`. + assert.equal(cmd("graft checkout", "trail"), "trail checkout"); +}); + +// --- the once-a-day line for people still typing graft --- + +function scratchHome(): string { + return mkdtempSync(join(tmpdir(), "graft-notice-")); +} + +test("a person typing graft sees the new name once a day, with what it ran", () => { + const home = scratchHome(); + const env = { PATH: "" }; + const now = Date.parse("2026-10-07T10:00:00Z"); + const first = graftNotice({ ran: "graft ask", tty: true, env, home, now }); + assert.equal( + first, + "· graft is now trail. this ran trail ask, and graft keeps working (shown once a day)\n" + + "· run graft upgrade to get the trail command too", + ); + assert.equal(graftNotice({ ran: "graft ask", tty: true, env, home, now: now + 60_000 }), null, "not twice in a day"); + assert.ok(graftNotice({ ran: "graft ask", tty: true, env, home, now: now + 25 * 3600_000 }), "again the next day"); +}); + +test("the notice names the trail spelling of a renamed command", () => { + const out = graftNotice({ ran: "graft check", tty: true, env: { PATH: "" }, home: scratchHome() }); + assert.match(out ?? "", /this ran trail build --check,/); +}); + +test("no upgrade hint once a trail command is on PATH", () => { + const bin = mkdtempSync(join(tmpdir(), "graft-bin-")); + const exe = join(bin, process.platform === "win32" ? "trail.cmd" : "trail"); + writeFileSync(exe, ""); + chmodSync(exe, 0o755); + const out = graftNotice({ ran: "graft grep", tty: true, env: { PATH: bin }, home: scratchHome() }); + assert.equal(out, "· graft is now trail. this ran trail grep, and graft keeps working (shown once a day)"); +}); + +test("agents, pipes, CI and trail itself never see the notice, and it isn't stamped for them", () => { + const home = scratchHome(); + assert.equal(graftNotice({ ran: "graft ask", tty: false, env: {}, home }), null, "no terminal"); + assert.equal(graftNotice({ ran: "graft ask", tty: true, env: { CLAUDECODE: "1" }, home }), null, "Claude Code"); + assert.equal(graftNotice({ ran: "graft ask", tty: true, env: { CI: "true" }, home }), null, "CI"); + assert.equal(graftNotice({ ran: "graft ask", tty: true, env: { TRAIL_INVOKED_AS: "trail" }, home }), null, "trail"); + assert.equal(graftNotice({ ran: "graft ask", tty: true, env: { GRAFT_NO_RENAME_NOTICE: "1" }, home }), null, "opted out"); + assert.equal(existsSync(noticeStampPath(home)), false, "nothing stamped"); +}); + +// --- what parses the output --- + +test("the savings footer is read under either name", () => { + assert.equal(sumSavingsFooters("[graft] tokens saved ≈ 1,200 (80%)\n[trail] tokens saved ≈ 300 (50%)"), 1500); +}); + +test("a shell command running trail counts as a retrieval, like graft", () => { + assert.equal(commandInvokesGraft('trail ask "auth" --source'), true); + assert.equal(commandInvokesGraft("npx -y @trailhq/trail grep foo"), true); + assert.equal(commandInvokesGraft("cd x && trail callers run"), true); + assert.equal(commandInvokesGraft("node dist/bin/trail.js ask x"), true); + assert.equal(commandInvokesGraft("graft ask x"), true); + assert.equal(commandInvokesGraft("mytrail ask x"), false); + assert.equal(commandInvokesGraft("echo trail"), false); +}); + +test("every telemetry event says which name ran it", () => { + process.env.GRAFT_POSTHOG_KEY = "phc_test_key"; + const home = scratchHome(); + const open = { HOME: home, USERPROFILE: home } as NodeJS.ProcessEnv; + const viaTrail = track("first_run", {}, { home, env: { ...open, TRAIL_INVOKED_AS: "trail" } }); + const other = scratchHome(); + const viaGraft = track("first_run", {}, { home: other, env: { HOME: other, USERPROFILE: other } }); + assert.equal(viaTrail?.properties.cli_name, "trail"); + assert.equal(viaGraft?.properties.cli_name, "graft"); +}); + +// --- the two command layouts, end to end --- + +function run(name: "trail" | "graft", args: string[], home: string, cwd = process.cwd()) { + const r = spawnSync(process.execPath, ["--import", "tsx", join(process.cwd(), "src", "bin", `${name}.ts`), ...args], { + cwd, + encoding: "utf8", + env: { ...process.env, ...homeEnv(home), DO_NOT_TRACK: "1", CLAUDECODE: undefined, TRAIL_INVOKED_AS: undefined }, + }); + return { status: r.status, out: r.stdout, err: r.stderr }; +} + +function builtRepo(): string { + const d = tmpRepo("trailname"); + mkdirSync(join(d, "src"), { recursive: true }); + writeFileSync(join(d, "src", "a.ts"), "export function alpha(): number {\n return beta();\n}\n"); + writeFileSync(join(d, "src", "b.ts"), "export function beta(): number {\n return 1;\n}\n"); + spawnSync("git", ["init", "-q"], { cwd: d }); + return d; +} + +test("trail --help groups the commands and lists the cloud ones at the top level", () => { + const home = scratchHome(); + const r = run("trail", ["--help"], home); + assert.equal(r.status, 0); + for (const heading of ["Find code", "Setup:", "Trail cloud:", "CI:"]) assert.ok(r.out.includes(heading), heading); + for (const c of ["login", "logout", "push", "pull", "status"]) assert.match(r.out, new RegExp(`^ ${c} `, "m"), c); + // Still run, just not listed. + for (const c of ["check", "viz", "mcp", "stats", "watch", "connect", "trail"]) assert.doesNotMatch(r.out, new RegExp(`^ ${c} `, "m"), c); +}); + +test("graft --help keeps its old flat list, including check and the trail group", () => { + const r = run("graft", ["--help"], scratchHome()); + assert.equal(r.status, 0); + assert.match(r.out, /^Usage: graft /m); + for (const c of ["check", "stats", "viz", "trail"]) assert.match(r.out, new RegExp(`^ ${c} `, "m"), c); + assert.ok(!r.out.includes("Trail cloud:")); +}); + +test("the freshness check is graft check under graft and trail build --check under trail", () => { + const home = scratchHome(); + const d = builtRepo(); + assert.equal(run("graft", ["build", d], home).status, 0); + const g = run("graft", ["check", d], home); + assert.equal(g.status, 0, g.err); + assert.match(g.out, /graph check: OK/); + assert.match(g.out, /Run `graft build --deep` to summarize them/); + const t = run("trail", ["build", "--check", d], home); + assert.equal(t.status, 0, t.err); + assert.match(t.out, /graph check: OK/); + assert.match(t.out, /Run `trail build --deep` to summarize them/); + assert.equal(run("graft", ["build", "--check", d], home).status, 0, "graft build --check works too"); + // Stale: a new function the graph has never seen fails both, the same way. + writeFileSync(join(d, "src", "c.ts"), "export function gamma(): number {\n return 2;\n}\n"); + assert.equal(run("graft", ["check", d, "--json"], home).status, 1); + assert.equal(run("trail", ["build", "--check", d, "--json"], home).status, 1); +}); + +test("trail check says where the freshness check went, and fails so a renamed CI step can't pass silently", () => { + const r = run("trail", ["check"], scratchHome()); + assert.equal(r.status, 1); + assert.match(r.err, /that's trail build --check/); +}); + +test("trail status puts the code map, .trail/ and the cloud link on one screen", () => { + const home = scratchHome(); + const d = builtRepo(); + run("trail", ["build", d], home); + mkdirSync(join(d, ".trail", "notes"), { recursive: true }); + writeFileSync(join(d, ".trail", "notes", "2026-10-07-bbox.md"), "# bbox\n"); + mkdirSync(join(d, ".trail", "skills", "pdf-coords"), { recursive: true }); + const r = run("trail", ["status", d], home); + assert.equal(r.status, 0, r.err); + assert.match(r.out, /^code map {4}✓ \d+ nodes · in sync with the code$/m); + assert.match(r.out, /^\.trail\/ {5}1 note · 1 skill$/m); + assert.match(r.out, /^cloud {7}not signed in · trail login$/m); + const json = JSON.parse(run("trail", ["status", d, "--json"], home).out); + assert.deepEqual(json.trail, { notes: 1, skills: 1 }); + assert.equal(json.cloud, null); +}); + +test("old spellings keep working under trail: trail trail status, trail brain status, trail stats", () => { + const home = scratchHome(); + const d = builtRepo(); + run("trail", ["build", d], home); + for (const args of [["trail", "status", d], ["brain", "status", d], ["stats", d]]) { + const r = run("trail", args, home); + assert.equal(r.status, 0, `${args.join(" ")}: ${r.err}`); + } + assert.match(run("trail", ["trail", "status", d], home).out, /^code map/m); +}); + +test("graft trail status is unchanged", () => { + const home = scratchHome(); + const d = builtRepo(); + const r = run("graft", ["trail", "status", d], home); + assert.equal(r.status, 0); + assert.match(r.err, /no brain attached — run `graft trail connect :`/); +}); + +test("the savings footer says [trail] under trail and [graft] under graft", () => { + const saved = { baselineChars: 40_000, files: 3 } as Parameters[1]; + const before = process.env.TRAIL_INVOKED_AS; + try { + delete process.env.TRAIL_INVOKED_AS; + assert.match(savingsLine("short pack", saved), /^\[graft\] tokens saved ≈ /); + process.env.TRAIL_INVOKED_AS = "trail"; + assert.match(savingsLine("short pack", saved), /^\[trail\] tokens saved ≈ /); + } finally { + if (before === undefined) delete process.env.TRAIL_INVOKED_AS; + else process.env.TRAIL_INVOKED_AS = before; + } +}); From c1c3427c331f1da88aa513843d96386e0c916ae7 Mon Sep 17 00:00:00 2001 From: anirudhkumar-nanonets Date: Wed, 7 Oct 2026 12:28:37 +0530 Subject: [PATCH 2/3] feat(mcp): trail mcp serves trail_* tools and still answers graft_* --- src/brand.ts | 11 +++++++++ src/mcp/instructions.ts | 10 +++++--- src/mcp/server.ts | 10 +++++--- src/mcp/tool-names.ts | 19 +++++++++++---- test/trail-name.test.ts | 54 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 92 insertions(+), 12 deletions(-) diff --git a/src/brand.ts b/src/brand.ts index 46052ef47..62db81fa1 100644 --- a/src/brand.ts +++ b/src/brand.ts @@ -75,6 +75,17 @@ export function cmd(graftSpelling: string, b: Brand = brand()): string { return graftSpelling; } +/** + * Agent-facing prose and tool names in trail's spelling: `graft_find_code` → + * `trail_find_code`, and the word graft → trail. A `graft` followed by `/` is + * the code map's folder and stays, as do `graft-…` names. Under graft, the + * text comes back unchanged. + */ +export function inBrand(text: string, b: Brand = brand()): string { + if (b === "graft") return text; + return text.replace(/\bgraft_(?=[a-z])/g, "trail_").replace(/\bgraft\b(?![/\w-])/g, "trail"); +} + /* -------------------------------------------------------------------------- */ /* the once-a-day line for people still typing graft */ /* -------------------------------------------------------------------------- */ diff --git a/src/mcp/instructions.ts b/src/mcp/instructions.ts index 2375b7754..c5f78878d 100644 --- a/src/mcp/instructions.ts +++ b/src/mcp/instructions.ts @@ -23,6 +23,7 @@ * Budget: keep this under ~1,000 characters. Observed sibling servers sit at * 660–984, and nothing proves a longer one survives un-truncated. */ +import { brand, inBrand } from '../brand.js'; /** Tool names in the order an agent should reach for them, most-used first. */ const TOOL_ORDER = [ @@ -34,12 +35,13 @@ const TOOL_ORDER = [ ] as const; /** The `select:` argument that loads every graft tool in one lookup. */ -export function toolSearchQuery(prefix = 'mcp__graft__'): string { - return `select:${TOOL_ORDER.map((t) => `${prefix}${t}`).join(',')}`; +export function toolSearchQuery(prefix = `mcp__${brand()}__`): string { + return `select:${TOOL_ORDER.map((t) => `${prefix}${inBrand(t)}`).join(',')}`; } +/** Under trail, the same text with trail's tool names (see brand.ts `inBrand`). */ export function mcpInstructions(): string { - return [ + return inBrand([ 'This repo is indexed by graft: a prebuilt graph of every symbol, its file:line', 'span, and who calls what. Prefer these tools over grep/read — one call usually', 'replaces several file reads.', @@ -53,5 +55,5 @@ export function mcpInstructions(): string { '- graft_repo_map — orientation in an unfamiliar repo.', '', 'Results already reflect uncommitted edits — the graph refreshes before each query.', - ].join('\n'); + ].join('\n')); } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 05d29df6a..3ae6ebbe3 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -10,6 +10,8 @@ import { mainWorktreeRoot } from '../graph/seed.js'; import { runUpkeep } from '../upkeep-run.js'; import { runningVersion } from '../upkeep.js'; import { maybeFlushInBackground, track } from '../telemetry/index.js'; +import { brand, inBrand } from '../brand.js'; +import { canonicalToolName } from './tool-names.js'; /** * MCP tool name → the command enum the telemetry contract knows. A tool absent @@ -104,7 +106,7 @@ export function startMcpServer(root: string, dirOverride?: string, version = '0' reply(id, { protocolVersion: params?.protocolVersion ?? '2024-11-05', capabilities: { tools: {} }, - serverInfo: { name: 'graft', version }, + serverInfo: { name: brand(), version }, // The one channel that survives tool deferral — see ./instructions.ts. instructions: upkeep.length ? `${upkeep.join('\n')}\n\n${mcpInstructions()}` : mcpInstructions(), }); @@ -117,7 +119,8 @@ export function startMcpServer(root: string, dirOverride?: string, version = '0' return; case 'tools/list': { if (isNotification) return; - reply(id, { tools: advertised(root, dirOverride).map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })) }); + // Under trail, the same tools as `trail_*`, described in trail's words. + reply(id, { tools: advertised(root, dirOverride).map((t) => ({ name: inBrand(t.name), description: inBrand(t.description), inputSchema: t.inputSchema })) }); return; } case 'tools/call': { @@ -135,7 +138,8 @@ export function startMcpServer(root: string, dirOverride?: string, version = '0' // hasOwn for the same reason as track()'s: a client is free to send // `{"name":"constructor"}`, and a plain lookup would hand `track` a // function rather than undefined. - const command = Object.hasOwn(TOOL_COMMAND, name) ? TOOL_COMMAND[name] : undefined; + const canonical = canonicalToolName(name); + const command = Object.hasOwn(TOOL_COMMAND, canonical) ? TOOL_COMMAND[canonical] : undefined; track('query', { command, surface: 'mcp' }, { repo: root, host: 'mcp' }); reply(id, { content: [{ type: 'text', text: r.text }], isError: r.isError }); }, diff --git a/src/mcp/tool-names.ts b/src/mcp/tool-names.ts index 23ac2eb54..1ba5bf6a2 100644 --- a/src/mcp/tool-names.ts +++ b/src/mcp/tool-names.ts @@ -29,13 +29,22 @@ export const GRAFT_MCP_TOOL_ALIASES: Record = { graft_check: 'graft_check_freshness', }; -/** Every name graft answers to (canonical + legacy), lower-cased, for a membership - * test that anchors on the real vocabulary instead of a loose `includes('graft')`. */ +/** + * The same tools under trail's name: `trail_find_code` is `graft_find_code`. + * Started as `trail mcp`, the server advertises these; it answers both sets + * either way, so a config, prompt or note written against one name keeps + * working when the server goes by the other. + */ +export const TRAIL_MCP_TOOL_CANONICAL = GRAFT_MCP_TOOL_CANONICAL.map((n) => n.replace(/^graft_/, 'trail_')); + +/** Every name graft answers to (canonical, legacy and trail's), lower-cased, for a + * membership test that anchors on the real vocabulary instead of a loose `includes('graft')`. */ export const GRAFT_MCP_TOOL_NAMES: ReadonlySet = new Set( - [...GRAFT_MCP_TOOL_CANONICAL, ...Object.keys(GRAFT_MCP_TOOL_ALIASES)].map((n) => n.toLowerCase()), + [...GRAFT_MCP_TOOL_CANONICAL, ...Object.keys(GRAFT_MCP_TOOL_ALIASES), ...TRAIL_MCP_TOOL_CANONICAL].map((n) => n.toLowerCase()), ); -/** Canonical name for a requested tool: itself, or what it was renamed to. */ +/** Canonical name for a requested tool: itself, its graft name, or what it was renamed to. */ export function canonicalToolName(name: string): string { - return GRAFT_MCP_TOOL_ALIASES[name] ?? name; + const graftName = name.startsWith('trail_') ? `graft_${name.slice('trail_'.length)}` : name; + return GRAFT_MCP_TOOL_ALIASES[graftName] ?? graftName; } diff --git a/test/trail-name.test.ts b/test/trail-name.test.ts index c1588b313..5cbfe406c 100644 --- a/test/trail-name.test.ts +++ b/test/trail-name.test.ts @@ -225,3 +225,57 @@ test("the savings footer says [trail] under trail and [graft] under graft", () = else process.env.TRAIL_INVOKED_AS = before; } }); + +// --- MCP: trail's tool names, graft's still answered --- + +async function mcpRpc(name: "trail" | "graft", dir: string, messages: object[], expected: number): Promise { + const { spawn } = await import("node:child_process"); + const child = spawn(process.execPath, ["--import", "tsx", join(process.cwd(), "src", "bin", `${name}.ts`), "mcp", dir], { + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, DO_NOT_TRACK: "1", TRAIL_INVOKED_AS: undefined }, + }); + const out: any[] = []; + let buf = ""; + child.stdout.on("data", (d) => { + buf += d.toString(); + let i; + while ((i = buf.indexOf("\n")) !== -1) { + const line = buf.slice(0, i).trim(); + buf = buf.slice(i + 1); + if (line) out.push(JSON.parse(line)); + } + }); + for (const m of messages) child.stdin.write(`${JSON.stringify(m)}\n`); + const deadline = Date.now() + 20000; + while (out.length < expected && Date.now() < deadline) await new Promise((r) => setTimeout(r, 50)); + child.kill(); + return out; +} + +test("trail mcp advertises trail_* tools and answers graft_* calls too; graft mcp is unchanged", async () => { + const d = builtRepo(); + run("graft", ["build", d], scratchHome()); + const init = { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "t", version: "0" } } }; + const list = { jsonrpc: "2.0", id: 2, method: "tools/list" }; + const call = (id: number, tool: string) => ({ jsonrpc: "2.0", id, method: "tools/call", params: { name: tool, arguments: { query: "beta" } } }); + + const t = await mcpRpc("trail", d, [init, list, call(3, "trail_find_code"), call(4, "graft_find_code")], 4); + assert.equal(t.find((r) => r.id === 1)?.result.serverInfo.name, "trail"); + assert.match(t.find((r) => r.id === 1)?.result.instructions, /select:mcp__trail__trail_find_code,/); + const names = t.find((r) => r.id === 2)?.result.tools.map((x: { name: string }) => x.name); + assert.ok(names.includes("trail_find_code") && !names.some((n: string) => n.startsWith("graft_")), String(names)); + for (const id of [3, 4]) assert.equal(t.find((r) => r.id === id)?.result.isError, false, `call ${id}`); + + const g = await mcpRpc("graft", d, [init, list], 2); + assert.equal(g.find((r) => r.id === 1)?.result.serverInfo.name, "graft"); + assert.ok(g.find((r) => r.id === 2)?.result.tools.some((x: { name: string }) => x.name === "graft_find_code")); +}); + +test("both tool vocabularies count as retrievals", async () => { + const { canonicalToolName } = await import("../src/mcp/tool-names.js"); + const { isGraftMcpTool } = await import("../src/claude/session-metrics.js"); + assert.equal(canonicalToolName("trail_find_code"), "graft_find_code"); + assert.equal(canonicalToolName("graft_ask"), "graft_find_code"); + assert.equal(isGraftMcpTool("mcp__trail__trail_trace_calls"), true); + assert.equal(isGraftMcpTool("mcp__graft__graft_find_all"), true); +}); From 915f11f98778eeb15134b79c4016c3931daeae42 Mon Sep 17 00:00:00 2001 From: anirudhkumar-nanonets Date: Wed, 7 Oct 2026 13:46:40 +0530 Subject: [PATCH 3/3] fix: trail status leaves notes to the notes change --- src/cli.ts | 33 ++++----------------------------- test/trail-name.test.ts | 7 +------ 2 files changed, 5 insertions(+), 35 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index a7614876b..f976b719e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -166,8 +166,8 @@ program .name(brand()) .description( TRAIL - ? "Your team's shared memory for coding agents: a code map on your machine, and\n" + - "the notes, decisions and skills your whole team shares." + ? "Memory for your coding agents: a code map, and the notes and skills each\n" + + "session leaves on your machine, shared with your team when you sign in." : "Build a repo's context graph as linked markdown, and keep it in sync with the code.", ) .version(currentVersion, "-v, --version") @@ -2060,24 +2060,6 @@ function loginCommand(name = "login"): Command { }); } -/** Files ending in `ext` directly under `dir`, or 0 when it doesn't exist. */ -function countFiles(dir: string, ext: string): number { - try { - return readdirSync(dir).filter((f) => f.endsWith(ext)).length; - } catch { - return 0; - } -} - -/** Directories directly under `dir`, or 0 when it doesn't exist. */ -function countDirs(dir: string): number { - try { - return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).length; - } catch { - return 0; - } -} - /** Tokens saved by this repo's agent sessions touched in the last 7 days. */ function savedThisWeek(repo: string, now = Date.now()): number { const since = now - 7 * 24 * 60 * 60 * 1000; @@ -2102,9 +2084,8 @@ function shortCount(n: number): string { /** * `trail status`: what was `graft stats` and `graft trail status`, on one - * screen. The code map and whether it matches the code, the team's notes and - * skills in .trail/, the Trail link and how many of its rules still match, and - * what the agents saved this week. + * screen. The code map and whether it matches the code, the Trail link and + * how many of its rules still match, and what the agents saved this week. */ function statusCommand(name = "status"): Command { return new Command(name) @@ -2116,10 +2097,6 @@ function statusCommand(name = "status"): Command { const graph = loadGraphCached(contextDirFor(repo, program.opts().dir)); const g = graph ? await engineFrom().checkGraph(repo) : null; const fresh = g && !g.missing ? g.ok : null; - const trailDir = join(repo, ".trail"); - const hasTrailDir = existsSync(trailDir); - const notes = countFiles(join(trailDir, "notes"), ".md"); - const skills = countDirs(join(trailDir, "skills")); const { link, rules } = brainStatus(repo); const anchored = link && graph ? rulesForPointers(graph.nodes.map((n) => `${n.path}:${n.span}`), rules, graph) : []; const matching = anchored.filter((a) => !a.stale).length; @@ -2130,7 +2107,6 @@ function statusCommand(name = "status"): Command { JSON.stringify( { codeMap: graph ? { nodes: graph.meta.nodeCount, inSync: fresh } : null, - trail: hasTrailDir ? { notes, skills } : null, cloud: link ? { brainId: link.brainId, cached: rules.length, anchored: anchored.length, matching } : null, week: { savedTokens: saved }, }, @@ -2145,7 +2121,6 @@ function statusCommand(name = "status"): Command { if (!graph) row("code map", `✗ not built · run ${cmd("graft build")}`); else if (fresh === false) row("code map", `⚠ ${fmt(graph.meta.nodeCount)} nodes · behind the code, refreshed on the next query`); else row("code map", `✓ ${fmt(graph.meta.nodeCount)} nodes · in sync with the code`); - if (hasTrailDir) row(".trail/", `${notes} note${notes === 1 ? "" : "s"} · ${skills} skill${skills === 1 ? "" : "s"}`); if (!link) row("cloud", `not signed in · ${cmd("graft trail connect")}`); else row( diff --git a/test/trail-name.test.ts b/test/trail-name.test.ts index 5cbfe406c..203baea00 100644 --- a/test/trail-name.test.ts +++ b/test/trail-name.test.ts @@ -176,20 +176,15 @@ test("trail check says where the freshness check went, and fails so a renamed CI assert.match(r.err, /that's trail build --check/); }); -test("trail status puts the code map, .trail/ and the cloud link on one screen", () => { +test("trail status puts the code map and the cloud link on one screen", () => { const home = scratchHome(); const d = builtRepo(); run("trail", ["build", d], home); - mkdirSync(join(d, ".trail", "notes"), { recursive: true }); - writeFileSync(join(d, ".trail", "notes", "2026-10-07-bbox.md"), "# bbox\n"); - mkdirSync(join(d, ".trail", "skills", "pdf-coords"), { recursive: true }); const r = run("trail", ["status", d], home); assert.equal(r.status, 0, r.err); assert.match(r.out, /^code map {4}✓ \d+ nodes · in sync with the code$/m); - assert.match(r.out, /^\.trail\/ {5}1 note · 1 skill$/m); assert.match(r.out, /^cloud {7}not signed in · trail login$/m); const json = JSON.parse(run("trail", ["status", d, "--json"], home).out); - assert.deepEqual(json.trail, { notes: 1, skills: 1 }); assert.equal(json.cloud, null); });