Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
393df6b
feat: ship trail as the command name, keep graft as its fallback
anirudhkumar-nanonets Oct 7, 2026
c1c3427
feat(mcp): trail mcp serves trail_* tools and still answers graft_*
anirudhkumar-nanonets Oct 7, 2026
915f11f
fix: trail status leaves notes to the notes change
anirudhkumar-nanonets Oct 7, 2026
2f1aa66
feat(notes): trail note saves session notes to .trail/, and ask shows…
anirudhkumar-nanonets Oct 7, 2026
5a0633c
feat(notes): hooks, statusline and skill point agents at .trail/ notes
anirudhkumar-nanonets Oct 7, 2026
18ed5e3
feat(notes): repos set up with trail init use trail-named hooks, skil…
anirudhkumar-nanonets Oct 7, 2026
d4cafa9
feat(notes): keep notes in ~/.trail by repo, never in the repo
anirudhkumar-nanonets Oct 7, 2026
6e23105
feat(notes): the trail skill says when to run trail ask and trail not…
anirudhkumar-nanonets Oct 7, 2026
01b80a4
feat(skills): trail learn keeps corrections in ~/.trail, trail skills…
anirudhkumar-nanonets Oct 7, 2026
229856b
feat(skills): the trail skill says to run trail learn when the user c…
anirudhkumar-nanonets Oct 7, 2026
15e59e0
feat(cloud): trail login shares your notes, with ranked search, trail…
anirudhkumar-nanonets Oct 7, 2026
4f80568
feat(cloud): the trail skill and session start say to run trail check…
anirudhkumar-nanonets Oct 7, 2026
95773db
feat(cloud): agents run trail check when work is ready for review, be…
anirudhkumar-nanonets Oct 7, 2026
eec5c69
feat(cloud): Claude offers trail sign-in once a session, from ask, no…
anirudhkumar-nanonets Oct 8, 2026
29c5e28
feat(cloud): trail sign-in links say they're trail's, and TRAIL_WEB_U…
anirudhkumar-nanonets Oct 8, 2026
0617627
feat(notes): the Stop hook asks once a session for a note after real …
anirudhkumar-nanonets Oct 8, 2026
861fc30
feat(cloud): trail ask shows a teammate's note in full and says whose…
anirudhkumar-nanonets Oct 8, 2026
5045537
feat(notes): a note touches the files it names, so trail check finds …
anirudhkumar-nanonets Oct 8, 2026
74bdf1b
fix(cloud): trail check leaves out the files trail init writes
anirudhkumar-nanonets Oct 8, 2026
70f01af
fix(notes): three tool calls count as digging worth a note
anirudhkumar-nanonets Oct 8, 2026
b5ba3d3
fix(notes): notes don't touch the ignore files trail itself edits
anirudhkumar-nanonets Oct 8, 2026
342b566
docs: trail quick start in the README
anirudhkumar-nanonets Oct 8, 2026
37d5376
feat(notes): each prompt brings in the notes earlier sessions left on it
anirudhkumar-nanonets Oct 8, 2026
26cc003
feat(notes): the agent says which earlier note it built on
anirudhkumar-nanonets Oct 8, 2026
881f0e6
fix: the init picker's header names trail when trail runs it
anirudhkumar-nanonets Oct 8, 2026
a603a25
docs(readme): make the trail quick start safe to paste into zsh
anirudhkumar-nanonets Oct 8, 2026
61ac791
feat(check): trail check runs on this machine, against local learnings
anirudhkumar-nanonets Oct 9, 2026
c07b395
feat: learnings in .trail/, overlap from pushed branches, and workspa…
anirudhkumar-nanonets Oct 9, 2026
8ea3193
fix: owner-only workspace token, and unmerged branch learnings shown …
anirudhkumar-nanonets Oct 9, 2026
05d47c9
fix: read only trail-named learning files from branches, and quote th…
anirudhkumar-nanonets Oct 9, 2026
21d249a
fix: skip branches with unusual names instead of renaming them
anirudhkumar-nanonets Oct 9, 2026
58274d3
fix: say learnings, not notes, in the status line and check
anirudhkumar-nanonets Oct 9, 2026
a78b740
chore: call the second teammate Kumar in examples and tests
anirudhkumar-nanonets Oct 9, 2026
83aac45
fix: keep the install path out of the committed hook shims
anirudhkumar-nanonets Oct 9, 2026
1622124
fix(security): read front matter as YAML only, so a ---js learning or…
anirudhkumar-nanonets Oct 13, 2026
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
106 changes: 90 additions & 16 deletions .claude/helpers/graft-hooks.cjs
Original file line number Diff line number Diff line change
@@ -1,19 +1,68 @@
#!/usr/bin/env node
const path = require('path');
const fs = require('fs');
const os = require('os');
const { pathToFileURL } = require('url');
const { execFileSync } = require('child_process');
const dir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
const BAKED = "/Users/shrishdwivedi/Documents/Context graphs/context-graph-engine/dist/claude";

// The dist/claude dir of @nanonets/graft resolved from a base whose node_modules is searched.
function fromPkg(base) {
// The same code ships as @trailhq/trail and as @nanonets/graft, its old name;
// either one answers, and the newer wins.
const PKGS = ['@trailhq/trail', '@nanonets/graft'];
const BINS = process.platform === 'win32' ? ['trail.cmd', 'graft.cmd'] : ['trail', 'graft'];

// The dist/claude dir this machine's last init ran from, kept in the trail home
// rather than in this committed file. Only a file we own and no one else can
// write: it names code we import.
function remembered() {
try {
const pkg = require.resolve('@nanonets/graft/package.json', { paths: [base] });
return path.join(path.dirname(pkg), 'dist', 'claude');
const home = process.env.TRAIL_HOME ? path.resolve(process.env.TRAIL_HOME) : path.join(os.homedir(), '.trail');
const file = path.join(home, "install.json");
const st = fs.statSync(file);
if (process.getuid && (st.uid !== process.getuid() || (st.mode & 0o022))) return null;
const d = JSON.parse(fs.readFileSync(file, 'utf8')).claudeDist;
return typeof d === 'string' && path.isAbsolute(d) ? d : null;
} catch { return null; }
}

// The dist/claude dir of each package resolved from a base whose node_modules is searched.
function fromPkg(base) {
return PKGS.map((name) => {
try {
const pkg = require.resolve(name + '/package.json', { paths: [base] });
return path.join(path.dirname(pkg), 'dist', 'claude');
} catch { return null; }
});
}

// The dist/claude dir of the package a file belongs to: the nearest package.json
// above it that names one of PKGS.
function pkgAbove(file) {
for (let d = path.dirname(file), i = 0; i < 6; d = path.dirname(d), i++) {
try {
if (PKGS.includes(JSON.parse(fs.readFileSync(path.join(d, 'package.json'), 'utf8')).name)) return path.join(d, 'dist', 'claude');
} catch { /* not this one */ }
}
return null;
}

// The package behind each trail/graft command on PATH: the bin is a link into
// the package (dist/cli.js in older releases, dist/bin/<name>.js now), and a
// Windows .cmd sits beside the node_modules that holds it.
function onPath() {
const out = [];
for (const d of (process.env.PATH || '').split(path.delimiter)) {
if (!d || !path.isAbsolute(d)) continue;
for (const bin of BINS) {
const p = path.join(d, bin);
if (!fs.existsSync(p)) continue;
try { out.push(pkgAbove(fs.realpathSync(p))); } catch { /* dangling link */ }
out.push(...fromPkg(d));
}
}
return out;
}

// The global node_modules dir per npm (handles Homebrew/Windows/volta). Queried on demand.
function globalRoot() {
try {
Expand All @@ -22,20 +71,45 @@ function globalRoot() {
} catch { return null; /* npm unavailable */ }
}

function candidates() {
const out = [];
if (BAKED) out.push(BAKED);
const local = fromPkg(dir); if (local) out.push(local);
const legacy = fromPkg(path.join(path.dirname(process.execPath), '..', 'lib')); if (legacy) out.push(legacy);
const gr = globalRoot(); if (gr) out.push(path.join(gr, '@nanonets', 'graft', 'dist', 'claude'));
return out;
// The version of the package a dist/claude dir belongs to, or null if unreadable.
function versionOf(distClaude) {
try {
return JSON.parse(fs.readFileSync(path.join(distClaude, '..', '..', 'package.json'), 'utf8')).version || null;
} catch { return null; }
}

function entry(name) {
for (const d of candidates()) {
const f = path.join(d, name);
if (fs.existsSync(f)) return f;
// Numeric-dotted compare of the release part; an unreadable version loses to any known one.
function newer(a, b) {
if (!a) return false;
if (!b) return true;
const p = (v) => String(v).split('-')[0].split('.').map((n) => Number(n) || 0);
const pa = p(a), pb = p(b);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const d = (pa[i] || 0) - (pb[i] || 0);
if (d !== 0) return d > 0;
}
return false;
}

// The highest-versioned dir in `dirs` that actually contains `name`, or null.
function best(dirs, name) {
let bestDir = null, bestVer = null;
for (const d of dirs) {
if (!d || !fs.existsSync(path.join(d, name))) continue;
const v = versionOf(d);
if (bestDir === null || newer(v, bestVer)) { bestDir = d; bestVer = v; }
}
return bestDir;
}

function entry(name) {
// Cheap candidates first, and only shell out to npm when every one of them misses.
const cheap = [remembered(), ...fromPkg(dir), ...fromPkg(path.join(path.dirname(process.execPath), '..', 'lib')), ...onPath()];
const hit = best(cheap, name);
if (hit) return path.join(hit, name);
const gr = globalRoot();
const global = gr && best(PKGS.map((p) => path.join(gr, ...p.split('/'), 'dist', 'claude')), name);
if (global) return path.join(global, name);
return path.join(dir, 'dist', 'claude', name); // last-ditch; import will no-op if absent
}

Expand Down
106 changes: 90 additions & 16 deletions .claude/helpers/graft-statusline.cjs
Original file line number Diff line number Diff line change
@@ -1,19 +1,68 @@
#!/usr/bin/env node
const path = require('path');
const fs = require('fs');
const os = require('os');
const { pathToFileURL } = require('url');
const { execFileSync } = require('child_process');
const dir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
const BAKED = "/Users/shrishdwivedi/Documents/Context graphs/context-graph-engine/dist/claude";

// The dist/claude dir of @nanonets/graft resolved from a base whose node_modules is searched.
function fromPkg(base) {
// The same code ships as @trailhq/trail and as @nanonets/graft, its old name;
// either one answers, and the newer wins.
const PKGS = ['@trailhq/trail', '@nanonets/graft'];
const BINS = process.platform === 'win32' ? ['trail.cmd', 'graft.cmd'] : ['trail', 'graft'];

// The dist/claude dir this machine's last init ran from, kept in the trail home
// rather than in this committed file. Only a file we own and no one else can
// write: it names code we import.
function remembered() {
try {
const pkg = require.resolve('@nanonets/graft/package.json', { paths: [base] });
return path.join(path.dirname(pkg), 'dist', 'claude');
const home = process.env.TRAIL_HOME ? path.resolve(process.env.TRAIL_HOME) : path.join(os.homedir(), '.trail');
const file = path.join(home, "install.json");
const st = fs.statSync(file);
if (process.getuid && (st.uid !== process.getuid() || (st.mode & 0o022))) return null;
const d = JSON.parse(fs.readFileSync(file, 'utf8')).claudeDist;
return typeof d === 'string' && path.isAbsolute(d) ? d : null;
} catch { return null; }
}

// The dist/claude dir of each package resolved from a base whose node_modules is searched.
function fromPkg(base) {
return PKGS.map((name) => {
try {
const pkg = require.resolve(name + '/package.json', { paths: [base] });
return path.join(path.dirname(pkg), 'dist', 'claude');
} catch { return null; }
});
}

// The dist/claude dir of the package a file belongs to: the nearest package.json
// above it that names one of PKGS.
function pkgAbove(file) {
for (let d = path.dirname(file), i = 0; i < 6; d = path.dirname(d), i++) {
try {
if (PKGS.includes(JSON.parse(fs.readFileSync(path.join(d, 'package.json'), 'utf8')).name)) return path.join(d, 'dist', 'claude');
} catch { /* not this one */ }
}
return null;
}

// The package behind each trail/graft command on PATH: the bin is a link into
// the package (dist/cli.js in older releases, dist/bin/<name>.js now), and a
// Windows .cmd sits beside the node_modules that holds it.
function onPath() {
const out = [];
for (const d of (process.env.PATH || '').split(path.delimiter)) {
if (!d || !path.isAbsolute(d)) continue;
for (const bin of BINS) {
const p = path.join(d, bin);
if (!fs.existsSync(p)) continue;
try { out.push(pkgAbove(fs.realpathSync(p))); } catch { /* dangling link */ }
out.push(...fromPkg(d));
}
}
return out;
}

// The global node_modules dir per npm (handles Homebrew/Windows/volta). Queried on demand.
function globalRoot() {
try {
Expand All @@ -22,20 +71,45 @@ function globalRoot() {
} catch { return null; /* npm unavailable */ }
}

function candidates() {
const out = [];
if (BAKED) out.push(BAKED);
const local = fromPkg(dir); if (local) out.push(local);
const legacy = fromPkg(path.join(path.dirname(process.execPath), '..', 'lib')); if (legacy) out.push(legacy);
const gr = globalRoot(); if (gr) out.push(path.join(gr, '@nanonets', 'graft', 'dist', 'claude'));
return out;
// The version of the package a dist/claude dir belongs to, or null if unreadable.
function versionOf(distClaude) {
try {
return JSON.parse(fs.readFileSync(path.join(distClaude, '..', '..', 'package.json'), 'utf8')).version || null;
} catch { return null; }
}

function entry(name) {
for (const d of candidates()) {
const f = path.join(d, name);
if (fs.existsSync(f)) return f;
// Numeric-dotted compare of the release part; an unreadable version loses to any known one.
function newer(a, b) {
if (!a) return false;
if (!b) return true;
const p = (v) => String(v).split('-')[0].split('.').map((n) => Number(n) || 0);
const pa = p(a), pb = p(b);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const d = (pa[i] || 0) - (pb[i] || 0);
if (d !== 0) return d > 0;
}
return false;
}

// The highest-versioned dir in `dirs` that actually contains `name`, or null.
function best(dirs, name) {
let bestDir = null, bestVer = null;
for (const d of dirs) {
if (!d || !fs.existsSync(path.join(d, name))) continue;
const v = versionOf(d);
if (bestDir === null || newer(v, bestVer)) { bestDir = d; bestVer = v; }
}
return bestDir;
}

function entry(name) {
// Cheap candidates first, and only shell out to npm when every one of them misses.
const cheap = [remembered(), ...fromPkg(dir), ...fromPkg(path.join(path.dirname(process.execPath), '..', 'lib')), ...onPath()];
const hit = best(cheap, name);
if (hit) return path.join(hit, name);
const gr = globalRoot();
const global = gr && best(PKGS.map((p) => path.join(gr, ...p.split('/'), 'dist', 'claude')), name);
if (global) return path.join(global, name);
return path.join(dir, 'dist', 'claude', name); // last-ditch; import will no-op if absent
}

Expand Down
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/
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@

## Contents

- [Trail quick start](#trail-quick-start): learnings your agent leaves for the next session, and your team's
- [Quick start](#quick-start)
- [The problem](#the-problem)
- [What Graft does](#what-graft-does)
Expand All @@ -73,6 +74,32 @@

---

## Trail quick start

```bash
npm install -g @trailhq/trail
trail init
claude
```

Install once. `trail init` asks which agents your team uses, builds the code map and wires up Claude Code. Then work the way you always do.

When a session works something out, your agent saves a learning: what it decided, what it ruled out, and what to watch out for. It goes in the repo's `.trail/learnings/`, and you commit it with the change. The next session on that code, yours or a teammate's, gets it back before it starts digging, and ends with a line saying what it saved, like `📝 Started from Anirudh's learning · saved ~27k tokens`. Teammates' Claude reads the learnings even before they install trail, and learnings on a teammate's pushed branch show up before it merges, along with any overlap with your work.

Before a PR, `trail check` compares your change with what the team has learned and flags anything that goes against it. It runs on your machine; nothing to sign in to.

When you want the team's view across repos, your agent offers a link at the end of a reply. Sign in, create a workspace, tick the repos it gets, and invite your team with a link. The team page shows who is learning what, how often learnings are reused, and where work overlaps.

Trying this branch before `@trailhq/trail` is published:

```bash
git clone -b feat/trail-cloud https://github.com/trailhq/Graft.git trail && cd trail
npm install && npm run build && node scripts/pack-trail.mjs
npm install -g ./build/trail
```

---

## Quick start

```bash
Expand Down
14 changes: 10 additions & 4 deletions 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 All @@ -45,12 +46,17 @@ The events:
| `init_completed` | `agents` (the ids you selected, sorted), `consent` | `graft init` finishes |
| `build_completed` | `files_bucket`, `langs`, `mode` (`fast`/`deep`), `duration_bucket`, `incremental` | A build succeeds |
| `build_failed` | `stage`, `code` — both fixed enums | A build throws |
| `query` | `command`, `surface` (`cli`/`mcp`/`hook`), `hit` (`ask` only) | Any query command |
| `brain_signup_opened` | `mode` (`terminal`/`agent`) | `graft trail push` on a repo with no brain opens your browser to make one |
| `brain_signup_settled` | `outcome` (`linked`/`timed_out`/`bad_callback`/`stopped`, and for an agent-run push `agent_link_opened`/`still_waiting`/`expired`/`unsupported`), `mode` (`terminal`/`agent`), `duration_bucket` | That handoff ends, one way or the other — for an agent-run push, each of its runs |
| `query` | `command`, `surface` (`cli`/`mcp`/`hook`), `hit` (`ask` only), `notes` (`0`/`1`/`2`, how many session notes `ask` showed; only in a repo that keeps notes) | Any query command |
| `brain_signup_opened` | `mode` (`terminal`/`agent`/`nudge`), and for a nudge `command` (`ask`/`note`/`check`) and `wording` (`share`/`learned`) | `graft trail push` on a repo with no brain opens your browser to make one, or a trail command hands the agent a sign-in link to pass on (at most once a session) |
| `brain_signup_settled` | `outcome` (`linked`/`timed_out`/`bad_callback`/`stopped`, and for an agent-run push `agent_link_opened`/`still_waiting`/`expired`/`unsupported`), `mode` (`terminal`/`agent`/`nudge`), `duration_bucket` | That handoff ends, one way or the other — for an agent-run push, each of its runs; for a nudge, each background wait for the sign-in (`linked`/`timed_out`/`expired`/`unsupported`) |
| `trail_pulled` | `outcome` (`written`/`already_present`/`nothing_accepted`/`skipped`/`error`/`dry_run`), `kinds` (which kinds of context file were written: `claude_md`, `folder_claude_md`, `agents_md`, `cursor_rule`, `skill`), `files_bucket`, `changes_bucket`, `skipped_bucket`, `suggested_bucket` (every change Trail has suggested for the wired agents' files, whatever became of it) | `graft trail pull` (or `graft claude-md pull`) finishes |
| `trail_watch_exit` | `reason` (`suggestions`/`accepted`/`timeout`/`refused`/`no_trail`), `suggested_bucket`, `accepted_bucket`, `duration_bucket` (how long it waited) | `graft trail watch` ends |
| `trail_autopush` | `outcome` (`started`/`skipped`), `reason` when skipped (`disabled`/`no_head`/`head_unchanged`/`throttled`/`spawn_failed`) | A Claude Code or Codex session starts in a repo with a trail attached, and the hook decides whether to refresh it with a background `graft trail push`. Repos without a trail send nothing |
| `note_saved` | `has_cost` (`true`/`false`), `touches_bucket` (how many files the learning is about, bucketed), `scope` (`repo`/`personal`) | `trail note` saves a learning, in the repo's `.trail/` or on this machine. The title, the text and the paths are never sent |
| `learning_reused` | `teammate` (`true`/`false`), `saved_tokens_bucket` | `trail ask` shows learnings from earlier sessions. Which ones, and their text, are never sent |
| `repo_seen` | `trail_committed` (`true`/`false`), `learnings_bucket`, `workspace` (`true`/`false`) | Once a day per repo, when a Claude Code session starts in a repo trail runs in. The repo's name and paths are never sent; it's identified only by the random `repo_id` every event carries |
| `takeaway_saved` | `new_skill` (`true`/`false`) | `trail learn` saves a correction as a takeaway on a skill, on this machine (`~/.trail/`). Its text is never sent |
| `skills_folded` | `takeaways_bucket` (how many takeaways were folded, bucketed) | `trail skills fold` writes takeaways into a skill's SKILL.md |
| `session_summary` | `graft_reads_bucket`, `source_reads_bucket`, `saved_tokens_bucket`, `graft_turns_bucket`, `reported_turns_bucket` | Once, after an agent session ends |

Two rules govern every value above, and both are enforced in code rather than by
Expand All @@ -72,7 +78,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
Loading
Loading