Skip to content

trail init: share one hook and the code map in .trail/, keep the rest personal - #619

Merged
anirudhkumar-nanonets merged 2 commits into
mainfrom
feat/trail-init-layout
Oct 15, 2026
Merged

anirudhkumar-nanonets merged 2 commits into
mainfrom
feat/trail-init-layout

Conversation

@anirudhkumar-nanonets

@anirudhkumar-nanonets anirudhkumar-nanonets commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Moves: learnings_committed, the number of clones whose committed learnings went up in the last 7 days (PostHog query in daily/project.md). I didn't check today's value, because this session has no Metabase access. Today, a team that commits .trail/ but not the hook script gets nothing for teammates who haven't installed trail. In a repo like NanoNets/assign, whose .gitignore excludes .claude/*, the hook script can't be committed at all. This change makes the shared part one folder and one settings line, and after trail init the repo's .gitignore and a root .ignore don't change either.

What changes

trail init now writes two kinds of files.

Shared, committed once by whoever sets the repo up

  • .trail/hook.cjs: the hook script, moved from .claude/helpers/trail-hooks.cjs. Inside .trail/, a .claude/* ignore rule can't drop it.
  • .claude/settings.json: one SessionStart entry that runs it, and nothing else of trail's. The status line, the other hooks, the allowlist and the footer regex come out of this file.

For a teammate without trail, that hook lists the learnings, credits one, then offers to install once, as before. For someone with trail, it hands off to their install.

Personal, kept out of git without touching .gitignore

  • .claude/settings.local.json: the status line and the allowlist.
    • Under --no-global it also holds the other hooks, including the new SessionEnd. It never holds a second SessionStart.
    • If nothing ignores this file yet, it goes in .git/info/exclude.
  • .trail/statusline.cjs: the status line's script, listed in .trail/.gitignore.
  • ~/.claude/skills/trail/: the skill. The repo copy in .claude/skills/ is gone.
  • ~/.claude/settings.json: the other hooks, as before. A repo hook that ~/.claude also covers stands down, and that now includes .trail/hook.cjs.

No MCP server for Claude Code

  • init stops writing .mcp.json and the user-scope trail server, and removes trail's entry from both where an older init added it. Other servers in those files stay.
  • Cursor keeps its server.
  • The graft mcp stand-in for teammates with only graft is no longer added once a repo's .mcp.json has no trail server.

Telemetry is sent from the hooks

  • SessionEnd sends the queue from a detached child, so quitting Claude isn't slowed.
  • Session start sends what's left, for terminals that were killed before SessionEnd fired.
  • Both keep the existing once-a-day limit. Without the MCP server, nothing else sent it during a session.

Messages

  • init's last line: · commit .claude/settings.json and .trail/ once — after that, teammates' Claude reads your learnings even before they install trail
  • Claude's first reply, once a day, until the shared setup is in HEAD: 🌱 Anay Shah and Rushil work in this repo. Commit .trail/ and .claude/settings.json once, and their Claude starts from your learnings, even before they install trail. After that, the existing line about uncommitted learnings applies.
  • The note about a repo moving from graft: it now asks for .trail/ and .claude/settings.json instead of .claude/, .mcp.json and .trail/.
  • .trail/README.md: it explains hook.cjs and the commit-once step. A README still holding the old text is replaced, and an edited one is left alone.

Repos set up the old way
The next wiring refresh, at session start after an upgrade or on trail init, moves a repo to the new layout:

  • it removes the old shims in .claude/helpers/ and the repo skill, plus those folders once they're empty
  • it removes trail's .mcp.json server
  • it rewrites the settings

The person who upgrades first sees .claude/settings.json changed and the old files deleted, and the first-reply line asks them to commit it once.

The code map moves to .trail/cache/

Where the map lives (src/context/map-dir.ts)

  • In a repo on trail, the code map lives in .trail/cache/. A repo counts as on trail when it has .trail/learnings/ or trail's hooks.
  • Everywhere else it stays in graft/.
  • contextDirFor and resolveContextDir both go through this one rule, so the CLI, the hooks, the status line and upkeep agree. --dir and GRAFT_DIR still win.

Kept out of git by .trail/.gitignore

  • For the trail map, the build adds cache/ to .trail/.gitignore.
  • It doesn't write the repo's .gitignore or a root .ignore. graft's map is ignored exactly as before.

An existing map moves instead of being rebuilt

  • In a repo on trail, a graft/ that holds a map is renamed to .trail/cache/ the first time anything asks where the map is.
  • Its cache folder moves with it, so the wiring stamp, session state and branch caches carry over, and nothing is redone or rebuilt.
  • A graft/ that git tracks is left alone: it dates from when the map was committed, and moving it would delete committed files.
  • Old /graft/ lines in .gitignore and old .ignore blocks are left as they are. They're harmless, and removing them would touch files this change promises to leave alone.

Finding it

  • .trail/ is hidden, so a ripgrep from the repo root skips the cards. Given .trail/cache as the path, it searches them. I checked both.
  • The session-start hook now names the real folder (repo map (.trail/cache/INDEX.md)).
  • trail's skill and agent instructions say .trail/cache/ where they said graft/.
  • trail ask, grep and callers read the map wherever it is.

Smaller

  • An edit inside .trail/cache/ counts as the map's own, not the code's.
  • Uninstall removes .trail/cache/ too.
  • Three CLI lines no longer name graft/: the subdirectory notice, trail build's help, and "no code map found". Neither does ask's "if the code map is empty".

Tests

The init layout

  • New trail-init-layout:
    • the shared merge keeps the team's own hooks and removes what an older init wrote
    • the personal merge never adds a SessionStart, and adds the other hooks only under --no-global
    • after trail init, git status shows only .claude/settings.json and .trail/
    • an old-layout repo moves, keeping the team's other MCP servers
    • .git/info/exclude is written only when nothing ignores the file
    • .trail/hook.cjs stands down when ~/.claude covers the event
    • the commit-once nudge, and the README update
  • Updated:
    • trail-notes: the graft-to-trail move, idempotence and uninstall
    • graft-to-trail, hosts-claude-global and claude-shim-template, for the new paths and no MCP server
  • By hand: in a scratch repo with a node binary outside nvm, so no trail could be found, .trail/hook.cjs session-start printed the learnings list and the install offer. Other events printed nothing.

The code map move

  • New trail-map-dir:
    • each repo kind gets the right folder
    • a graft/ map moves with its cache, and git doesn't see it
    • a tracked graft/ stays where it is
    • building the trail map writes only .trail/.gitignore
    • a repo with .trail/learnings/ counts as on trail before its hook exists
    • edits inside the map are recognised as the map's own
  • Updated: trail's spelling tests and the skill test now expect .trail/cache/, and the subdirectory notice test expects the new wording.
  • By hand, in scratch repos:
    • After trail init with a real build, git status showed only .claude/settings.json, .trail/.gitignore, .trail/README.md and .trail/hook.cjs. There was no graft/ and no .ignore, and trail grep NormalizeBox answered from .trail/cache/.
    • A repo set up by graft init, with its map in graft/, then trail init: the map moved to .trail/cache/ without a rebuild.

Both

  • Full suite on my Mac: 1,436 pass, 3 fail, 1 skipped.
    • The 3 failures are graft-to-trail tests that depend on the machine. They fail on main here too, because a global trail is installed for a demo.
    • Rerun with a node binary that can't see that install, all 20 tests in those files pass. CI has no global trail, and both Ubuntu and Windows passed.

🤖 Generated with Claude Code

@trailhq-graft

trailhq-graft Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

🌱 graft blast radius

5 areas changed → 13 areas can be affected. 63 dependent symbols, depth 2.
Tests: 5 areas updated their tests.
Tag: @shhdwi — 15 of 18 areas · @Frankie-Xu — 3 of 18 areas · @qoole — Dependency Graph

flowchart TB
  A0(("Dependency Graph<br/>22 symbols"))
  A1(("Host Integration<br/>10 symbols"))
  A2(("Build Orchestration<br/>7 symbols"))
  A3(("Engine API<br/>5 symbols"))
  A4(("MCP Server<br/>4 symbols"))
  AX(("8 smaller areas<br/>15 symbols"))
  classDef reached fill:#D9EDF3,stroke:#3AA7C9,stroke-width:1.5px,color:#0E313C;
  class A0,A1,A2,A3,A4 reached;
  classDef tail fill:#EEF2F3,stroke:#9AA4A9,stroke-width:1px,color:#3A4247;
  class AX tail;
Loading
Can be affected Symbols Nearest hop Reached from
Dependency Graph 22 src/graph/build.ts:L204-L472 buildGraph — calls, depth 1 Trail Wiring, Context Directory +1
Host Integration 10 src/hosts/codex-hooks.ts:L60-L89 installCodexHooks — calls, depth 1 Trail Wiring, Claude Integration +1
Build Orchestration 7 src/claude/stats.ts:L7-L11 readWiring — calls, depth 1 Trail Wiring, Context Directory
Engine API 5 src/engine.ts:L109-L119 ask — calls, depth 2 Trail Wiring, Context Directory
MCP Server 4 src/mcp/tools.ts:L248-L330 callSingleTool — calls, depth 1 Context Directory, Trail Wiring
Pull Request Review 4 src/app/review.ts:L45-L99 reviewPullRequest — calls, depth 1 Context Directory
7 smaller areas 11 Context Building, Findings Cache, Viewer Build, … see below
Who knows this code — 4 people across 18 areas
Area Who knows it
Context Directory · changed @Frankie-Xu — 3 commits, last 2mo ago · @shhdwi — 1 commit, last 2mo ago
Trail Wiring · changed @shhdwi — 39 commits, last yesterday
Claude Integration · changed @shhdwi — 20 commits, last yesterday
Graft Configuration · changed @shhdwi — 1 commit, last yesterday · @afeddersen — 1 commit, last 2mo ago
Dependency Graph · affected @shhdwi — 18 commits, last yesterday · @qoole — 5 commits, last 3mo ago
Host Integration · affected @shhdwi — 15 commits, last yesterday
Build Orchestration · affected @shhdwi — 10 commits, last 3mo ago
Engine API · affected @shhdwi — 3 commits, last 2mo ago · @Frankie-Xu — 1 commit, last 2mo ago
…10 further areas

Ownership is git history over each area's own files, weighted towards recent work (120-day half-life). Merge commits and bots are dropped, and you are dropped from your own PR. A name with no @ has no GitHub handle in its commit email — tag them by hand, or add a .mailmap entry. A suggestion from history, not a CODEOWNERS rule.

All 63 dependent symbols, grouped by area

Dependency Graph — 22 symbols in 8 files

  • src/graph/build.ts:L204-L472 — buildGraph (calls, depth 1)
    209: const outDir = contextDirFor(root, opts.contextDir);
  • src/graph/check.ts:L59-L159 — checkGraph (calls, depth 1)
    64: const outDir = contextDirFor(root, opts.contextDir);
  • src/graph/refresh.ts:L151-L228 — ensureFreshGraph (calls, depth 1)
    155: const outDir = contextDirFor(dir, opts.contextDir);
  • src/graph/root.ts:L23-L25 — hasGraftIndex (calls, depth 1)
    24: return existsSync(wiringPath(contextDirFor(dir))) || existsSync(workspacePath(dir));
  • src/graph/seed.ts:L211-L228 — seedGraph (calls, depth 1)
    215: const outDir = contextDirFor(dir);
  • src/graph/traverse-cli.ts:L167-L243 — runCallersCommand (calls, depth 1)
    169: const contextDir = contextDirFor(root, opts.globalDir);
  • src/graph/workspace-cli.ts:L52-L100 — runWorkspaceBuild (calls, depth 1)
    54: // collected here and printed once at the end, not once per repo.
  • src/graph/workspace.ts:L125-L127 — clearParentGraft (calls, depth 1)
    126: rmSync(contextDirFor(root, override), { recursive: true, force: true });
  • src/graph/workspace.ts:L106-L108 — hasMegaGraph (calls, depth 1)
    107: return existsSync(wiringPath(contextDirFor(root, override)));
  • src/graph/workspace.ts:L146-L157 — loadWorkspaceGraphs (calls, depth 1)
    152: const graph = loadGraphCached(contextDirFor(join(root, child)));
  • src/graph/workspace.ts:L67-L69 — workspacePath (calls, depth 1)
    68: return join(contextDirFor(root, override), WORKSPACE_FILE);
  • src/graph/workspace.ts:L85-L92 — writeWorkspace (calls, depth 1)
    86: const dir = contextDirFor(root, override);
  • src/graph/refresh.ts:L236-L262 — ensureFreshChildren (calls, depth 2)
    245: // Deliberately NOT `opts`: `contextDirFor` returns an override verbatim and
  • src/graph/refresh.ts:L125-L142 — seedUnderLock (calls, depth 2)
  • src/graph/root.ts:L35-L45 — nearestGraftRoot (calls, depth 2)
  • src/graph/workspace.ts:L241-L559 — federateAsk (calls, depth 2)
    260: throw new Error(`no workspace repo "${name}" - repos: ${allChildren.join(", ")}`);
  • src/graph/workspace.ts:L631-L656 — federateCheck (calls, depth 2)
    636: const lines = [`workspace check — ${wg.loaded.length + wg.missing.length} repo(s)`, ""];
  • src/graph/workspace.ts:L710-L723 — splitWorkspace (calls, depth 2)
  • src/graph/workspace.ts:L660-L696 — federateCallers (calls, depth 2)
    690: const base = `no symbol "${symbol}" in any of the ${wg.loaded.length} workspace repo(s) — check spelling or run ${cmd("graft build")}`;
  • src/graph/workspace.ts:L564-L604 — federateGrep (calls, depth 2)
  • src/graph/workspace.ts:L608-L627 — federateMap (calls, depth 2)
    622: const head = `workspace map — ${wg.loaded.length} repo(s)`;
  • src/graph/workspace.ts:L73-L82 — readWorkspace (calls, depth 2)

Host Integration — 10 symbols in 6 files

  • src/hosts/codex-hooks.ts:L60-L89 — installCodexHooks (calls, depth 1)
    61: const targets = hookTargets(home);
  • src/hosts/cursor-hooks.ts:L86-L114 — installCursorHooks (calls, depth 1)
    86: export function installCursorHooks(repo: string, name: Brand = 'graft'): ConfigWrite[] {
  • src/hosts/instructions.ts:L12-L14 — inName (calls, depth 1)
    13: return name === 'trail' ? trailSpelling(text) : text;
  • src/hosts/plan.ts:L69-L95 — planInit (calls, depth 1)
    69: export function planInit(repo: string, opts: { home?: string; ids?: string[] } = {}): HostPlan[] {
  • src/hosts/registry.ts:L183-L185 — hostWiringName (calls, depth 1)
    183: export function hostWiringName(repo: string, initBrand?: Brand): Brand {
  • src/hosts/init.ts:L53-L119 — runHostsInit (calls, depth 2)
    54: repo: string,
  • src/hosts/instructions.ts:L62-L69 — cursorRule (calls, depth 2)
  • src/hosts/instructions.ts:L16-L18 — instructionBody (calls, depth 2)
  • src/hosts/instructions.ts:L71-L77 — kiroSteering (calls, depth 2)
  • src/hosts/instructions.ts:L79-L82 — windsurfRule (calls, depth 2)

Build Orchestration — 7 symbols in 5 files

  • src/claude/stats.ts:L7-L11 — readWiring (calls, depth 1)
    9: return JSON.parse(readFileSync(join(resolveContextDir(projectDir), '.graph', 'wiring.json'), 'utf8')) as GraphV1;
  • src/claude/sync-run.ts:L8-L17 — realBuild (calls, depth 1)
    14: // unconfigured repo's rebuild sees byte-identical argv to before this existed.
  • src/claude/build-progress.ts:L45-L83 — buildGraphWithProgress (calls, depth 2)
  • src/claude/state.ts:L94-L94 — sessionDir (calls, depth 2)
  • src/claude/statusline.ts:L26-L38 — main (calls, depth 2)
    26: export function main(): void {
  • src/claude/statusline.ts:L18-L24 — resolveStats (calls, depth 2)
  • src/claude/sync-run.ts:L19-L33 — runSync (calls, depth 2)

Engine API — 5 symbols in 1 file

  • src/engine.ts:L109-L119 — ask (calls, depth 2)
  • src/engine.ts:L64-L74 — init (calls, depth 2)
  • src/engine.ts:L77-L79 — check (calls, depth 2)
  • src/engine.ts:L92-L102 — graph (calls, depth 2)
  • src/engine.ts:L83-L85 — checkGraph (calls, depth 2)

MCP Server — 4 symbols in 2 files

  • src/mcp/tools.ts:L248-L330 — callSingleTool (calls, depth 1)
    284: // never the FILE id) aren't silently dropped.
  • src/mcp/server.ts:L61-L66 — advertised (calls, depth 2)
    64: const main = mainWorktreeRoot(root);
  • src/mcp/server.ts:L73-L155 — startMcpServer (calls, depth 2)
    123: reply(id, { tools: advertised(root, dirOverride).map((t) => ({ name: inBrand(t.name), description: inBrand(t.description), inputSchema: t.inputSchema })) });
  • src/mcp/tools.ts:L217-L245 — callTool (calls, depth 2)

Pull Request Review — 4 symbols in 4 files

  • src/app/review.ts:L45-L99 — reviewPullRequest (calls, depth 1)
    51: const checkout = checkoutPullRequest({ owner: job.owner, repo: job.repo, number: job.number, baseRef: job.baseRef, token, log });
  • src/app/review-process.ts:L180-L184 — childReviewer (references, depth 2)
  • src/app/review-worker.ts:L67-L87 — run (calls, depth 2)
    77: send({ t: "publish", seq: id, html });
  • src/app/server.ts:L31-L37 — AppSeams (references, depth 2)

Context Building — 3 symbols in 2 files

  • src/context/build.ts:L146-L374 — buildContext (calls, depth 1)
    148: const outDir = contextDirFor(root, opts.contextDir);
  • src/context/check.ts:L39-L112 — checkContext (calls, depth 1)
    41: const outDir = contextDirFor(root, opts.contextDir);
  • src/context/check.ts:L131-L141 — indexFreshness (calls, depth 1)
    133: const outDir = contextDirFor(root, opts.contextDir);

Findings Cache — 3 symbols in 3 files

  • src/notes/branches.ts:L75-L77 — cachePath (calls, depth 2)
    75: function cachePath(repo: string): string {
  • src/notes/cold-start.ts:L589-L604 — saveFindings (calls, depth 2)
    589: export function saveFindings(repo: string, findings: Finding[], author = noteAuthor(repo), date = new Date().toISOString().slice(0, 10)): Note[] {
  • src/notes/pr-reviews.ts:L360-L375 — saveReviewFindings (calls, depth 2)
    360: export function saveReviewFindings(repo: string, findings: ReviewFinding[], author = noteAuthor(repo), date = new Date().toISOString().slice(0, 10)): Note[] {

Viewer Build — 1 symbol in 1 file

  • scripts/build-viewer.mjs:L1-L45 — build-viewer.mjs (calls, depth 2)
    16: entryPoints: [join(root, "viewer", "main.ts")],

Repository Identity — 1 symbol in 1 file

  • src/telemetry/identity.ts:L92-L99 — repoId (calls, depth 2)
    92: export function repoId(repo: string): string {

…3 further symbols not listed.

Test signal per changed area — 5 ✓

Reached = a node under a test path has a resolved edge into the changed symbol. It undercounts anything called indirectly — through a CLI, a spawned process or a dynamic import — so read a low ratio as “look here”, never as a coverage gate.

  • ✓ Context Directory — 4 of 6 reached · 1 test file changed here: test/trail-map-dir.test.ts
    • not reached: isTrailMapDir, moveOldMap
  • ✓ Trail Wiring — 4 of 10 reached · 2 test files changed here: test/graft-to-trail.test.ts, test/hosts-trail.test.ts
    • not reached: queryRoot, runCheckCommand, runInitCommand, wireTarget, rewriteWiring, lexical
  • ✓ Repository Trail — 2 of 3 reached · 1 test file changed here: test/trail-init-layout.test.ts
    • not reached: ignoreInTrailDir
  • ✓ Claude Integration — 9 of 24 reached · 7 test files changed here: test/claude-shim-template.test.ts, test/graft-to-trail.test.ts, test/hosts-claude-global.test.ts, test/hosts-trail.test.ts, test/trail-init-layout.test.ts, test/trail-map-dir.test.ts, test/trail-notes.test.ts
    • not reached: git, repoNamesTrailServer, main, trailSkillPath, claudeTargets, t, legacyClaudeTargets, clearOldLayout, …7 more
  • ✓ Graft Configuration — 3 of 7 reached · 1 test file changed here: test/hosts-claude-global.test.ts
    • not reached: dropUserServer, isGraftCmd, stripClaudeSettings, targets
56 test suites also reference this code

65 symbols, kept out of the diagram and the table so they cannot crowd out the areas a reviewer has to look at.

  • test/ask-index.test.ts
  • test/ask.test.ts
  • test/claude-format.test.ts
  • test/claude-hook-layers.test.ts
  • test/claude-hooks.test.ts
  • test/claude-init.test.ts
  • test/claude-settings-merge.test.ts
  • test/claude-state.test.ts
  • test/claude-stats.test.ts
  • test/cli-picker.test.ts
  • test/container-extract.test.ts
  • test/context-checkpoint.test.ts
  • test/context-only-dir.test.ts
  • test/context.test.ts
  • test/covers.test.ts
  • test/generic-extract.test.ts
  • test/graph-go.test.ts
  • test/graph-incremental.test.ts
  • test/graph-invariants.test.ts
  • test/graph-java.test.ts
  • …36 more

graft blast · origin/main...HEAD · depth 2 · 27 changed files

Open the interactive graph → — click an area to see its dependent symbols at file:line.

github-actions Bot added a commit that referenced this pull request Oct 15, 2026
@anirudhkumar-nanonets anirudhkumar-nanonets changed the title trail init: share one hook in .trail/, keep the rest personal trail init: share one hook and the code map in .trail/, keep the rest personal Oct 15, 2026
github-actions Bot added a commit that referenced this pull request Oct 15, 2026
@anirudhkumar-nanonets
anirudhkumar-nanonets merged commit f8b5dad into main Oct 15, 2026
8 checks passed
github-actions Bot added a commit that referenced this pull request Oct 15, 2026
shhdwi added a commit that referenced this pull request Oct 15, 2026
* fix(init): make .trail/learnings before the first telemetry event

The past-session read sends cold_start before init wired anything, so
the repo didn't count as on trail yet and the event's repo id went to
graft/.cache/. Since #619 nothing ignores graft/, so every fresh init
with telemetry on left an untracked graft/ folder in git status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(release): 0.23.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Shrish Dwivedi <shrish@nanonets.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant