Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
3 changes: 2 additions & 1 deletion TELEMETRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"./package.json": "./package.json"
},
"bin": {
"graft": "dist/cli.js"
"graft": "dist/bin/graft.js"
},
"files": [
"dist",
Expand Down
53 changes: 53 additions & 0 deletions scripts/pack-trail.mjs
Original file line number Diff line number Diff line change
@@ -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');
19 changes: 17 additions & 2 deletions scripts/postinstall.mjs
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down Expand Up @@ -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 */
}
5 changes: 3 additions & 2 deletions src/ask/ask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 \"<literal>\"` for every occurrence · `graft skeleton <file>` for a file's full API · `graft callers <symbol>` for who-uses."
);
}
Expand All @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions src/bin/graft.ts
Original file line number Diff line number Diff line change
@@ -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");
6 changes: 6 additions & 0 deletions src/bin/trail.ts
Original file line number Diff line number Diff line change
@@ -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");
154 changes: 154 additions & 0 deletions src/brand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* 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<Brand, string> = {
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<readonly [string, string]> = [
["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;
}

/**
* 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 */
/* -------------------------------------------------------------------------- */

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");
}
7 changes: 4 additions & 3 deletions src/claude/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}
Expand Down
12 changes: 7 additions & 5 deletions src/claude/session-metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
Loading
Loading