From f9996014ad655a1e785154d24c24d67771eac620 Mon Sep 17 00:00:00 2001 From: anirudhkumar-nanonets Date: Thu, 15 Oct 2026 18:22:24 +0530 Subject: [PATCH 1/2] feat(trail): split init into a shared hook and personal setup --- src/brand.ts | 4 +- src/claude/hook-layers.ts | 8 +- src/claude/hooks.ts | 51 +++++++- src/claude/init.ts | 206 +++++++++++++++++++++++++----- src/claude/settings-merge.ts | 102 ++++++++++++++- src/cli.ts | 18 ++- src/hosts/claude-global.ts | 42 ++++-- src/hosts/config-write.ts | 2 +- src/hosts/retract.ts | 19 ++- src/notes/notes.ts | 12 ++ src/notes/repo-trail.ts | 26 +++- src/upkeep-run.ts | 7 +- src/upkeep.ts | 4 +- test/claude-shim-template.test.ts | 4 +- test/graft-to-trail.test.ts | 5 +- test/hosts-claude-global.test.ts | 5 +- test/trail-init-layout.test.ts | 177 +++++++++++++++++++++++++ test/trail-notes.test.ts | 38 ++++-- 18 files changed, 631 insertions(+), 99 deletions(-) create mode 100644 test/trail-init-layout.test.ts diff --git a/src/brand.ts b/src/brand.ts index 8f11434e..58d91d1e 100644 --- a/src/brand.ts +++ b/src/brand.ts @@ -90,9 +90,11 @@ export function inBrand(text: string, b: Brand = brand()): string { * teammate's agent speaks the same name whichever command they installed. */ export function repoWiredForTrail(dir: string): boolean { + if (existsSync(join(dir, ".trail", "hook.cjs"))) return true; if (existsSync(join(dir, ".claude", "helpers", "trail-hooks.cjs")) || existsSync(join(dir, ".claude", "skills", "trail", "SKILL.md"))) return true; try { - return readFileSync(join(dir, ".claude", "settings.json"), "utf8").includes("trail-hooks.cjs"); + const settings = readFileSync(join(dir, ".claude", "settings.json"), "utf8"); + return settings.includes("trail-hooks.cjs") || settings.includes("/.trail/hook.cjs"); } catch { return false; } diff --git a/src/claude/hook-layers.ts b/src/claude/hook-layers.ts index 0e9582b3..1d36929f 100644 --- a/src/claude/hook-layers.ts +++ b/src/claude/hook-layers.ts @@ -32,9 +32,11 @@ const COMMAND = /^node\s+"([^"]+)"\s+(\S+)$/; export function globalCopyCovers(event: string, shim: string | undefined, home: string = homedir()): boolean { if (!shim) return false; const at = resolve(dirname(shim)); - // Only Claude Code's repo copy: Cursor's and Codex's shims call the same - // entry, and Claude's ~/.claude hooks never run for them. - if (basename(at) !== "helpers" || basename(dirname(at)) !== ".claude") return false; + // Only Claude Code's repo copy, in `.claude/helpers/` or trail's `.trail/`: + // Cursor's and Codex's shims call the same entry, and Claude's ~/.claude + // hooks never run for them. + const repoCopy = basename(at) === ".trail" || (basename(at) === "helpers" && basename(dirname(at)) === ".claude"); + if (!repoCopy) return false; const helpers = resolve(home, ".claude", "helpers"); if (at === helpers) return false; // this is the ~/.claude copy let settings: { hooks?: Record }; diff --git a/src/claude/hooks.ts b/src/claude/hooks.ts index d0886b16..1a5d5fc3 100644 --- a/src/claude/hooks.ts +++ b/src/claude/hooks.ts @@ -4,7 +4,7 @@ import { join, basename, isAbsolute, resolve } from 'node:path'; import { homedir } from 'node:os'; import { readWiring } from './stats.js'; import { formatBlastRadius, relevantRetrieval, formatOrientation, notesForPrompt, standingDirective } from './format.js'; -import { commitLines, standingLearnings } from '../notes/notes.js'; +import { commitLines, setupCommitLine, standingLearnings } from '../notes/notes.js'; import { noteAuthor, teammatesOn } from '../notes/git-facts.js'; import { indexFreshness, staleBanner } from '../context/check.js'; import { patchStats, readStats, acquireLock, readSession, writeSession, resolveContextDir } from './state.js'; @@ -13,6 +13,7 @@ import { runUpkeep } from '../upkeep-run.js'; import { runningVersion, wiredHostIds } from '../upkeep.js'; import { hostsWiredForTrail } from '../hosts/registry.js'; import { flushClosedSessions, summarizeSession } from '../telemetry/sessions.js'; +import { maybeFlushInBackground } from '../telemetry/flush.js'; import { hasSavingsTally, lastAssistantTurn, lastTurnBilling } from './tally.js'; import { scopeOf, scopesOfGraph } from '../graph/scopes.js'; import { classifyToolUse, isMcpToolName, isGraftMcpTool, parseSavings, recordToolUse, type ToolKind } from './session-metrics.js'; @@ -570,10 +571,15 @@ export function uncommittedDirective(dir: string, now = Date.now()): string { cwd: place.checkout, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000, }); const n = status.split('\n').filter((l) => l.trimEnd().endsWith('.md')).length; - if (!n) return ''; + const setup = setupUncommitted(place.checkout); + if (!n && !setup) return ''; const mark = join(cacheDir(place.checkout), 'trail-uncommitted-said.json'); if (now - (readJson<{ at?: number }>(mark)?.at ?? 0) < DAY_MS) return ''; writeJsonAtomic(mark, { at: now }, true); + if (setup) { + const line = setupCommitLine(teammatesOn(place.checkout)); + return `${tag()} trail's shared setup here (.trail/ and .claude/settings.json) isn't committed yet, so teammates' Claude can't read the learnings. Say so once, at the end of your first reply, with this line, word for word: ${line}\n`; + } const line = commitLines({}, teammatesOn(place.checkout), 'repo').uncommitted; const them = n === 1 ? "learning in .trail/learnings/ isn't committed yet, so no teammate's agent has it" : `learnings in .trail/learnings/ aren't committed yet, so no teammate's agent has them`; return `${tag()} ${n} team ${them}. Say so once, at the end of your first reply, with this line, word for word: ${line}\n`; @@ -582,6 +588,26 @@ export function uncommittedDirective(dir: string, now = Date.now()): string { } } +/** + * trail's shared setup isn't in the last commit: `.trail/hook.cjs` isn't + * tracked, or HEAD's `.claude/settings.json` doesn't run it. Only checked + * where the hook is on disk, so a repo still set up the old way says nothing. + */ +export function setupUncommitted(checkout: string): boolean { + if (!existsSync(join(checkout, '.trail', 'hook.cjs'))) return false; + const git = (args: string[]) => execFileSync('git', args, { cwd: checkout, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000 }); + try { + git(['ls-files', '--error-unmatch', '--', '.trail/hook.cjs']); + } catch { + return true; + } + try { + return !git(['show', 'HEAD:.claude/settings.json']).includes('/.trail/hook.cjs'); + } catch { + return true; + } +} + /** The most the session-start hook waits on `git fetch` for teammates' branches. */ export const BRANCH_FETCH_CAP_MS = 3000; @@ -607,6 +633,15 @@ function trailAtStart(dir: string): void { trackLearningsCommitted(dir); } +/** The repo's committed `.mcp.json` still registers trail's server (set up before 0.23). */ +function repoNamesTrailServer(dir: string): boolean { + try { + return Boolean(JSON.parse(readFileSync(join(dir, '.mcp.json'), 'utf8'))?.mcpServers?.trail); + } catch { + return false; + } +} + export async function main(event: string): Promise { // trail's own headless Claude calls (the first-run read of past sessions) run with this set. if (process.env.TRAIL_NO_HOOKS === '1') return; @@ -626,12 +661,15 @@ export async function main(event: string): Promise { // tells the person how to get it. const handover = wiredHostIds(dir).includes('claude') ? trailHandoverLine() : null; if (handover) upkeep.push(handover); - // Roll up any session that ended since we were last here. Queue-only — the - // hook still sends nothing; the CLI or the MCP server sends it later. + // Roll up any session that ended since we were last here, then send the + // queue from a detached child: what session end didn't get to (a killed + // terminal never fires it) goes now. Once a day at most, never waited on. flushClosedSessions(dir); + maybeFlushInBackground(); trailAtStart(dir); // A repo on trail, opened on a machine that has graft but not the trail command. - if (repoWiredForTrail(dir)) fitTrailServerToMachine(homedir(), resolve(dir), { trail: trailOnPath(), graft: commandOnPath('graft') }); + // Only while its .mcp.json still names a trail server: once it doesn't, trail: true takes back the stand-in. + if (repoWiredForTrail(dir)) fitTrailServerToMachine(homedir(), resolve(dir), { trail: trailOnPath() || !repoNamesTrailServer(dir), graft: commandOnPath('graft') }); try { const idx = readFileSync(join(resolveContextDir(dir), 'INDEX.md'), 'utf8'); const banner = staleBanner(indexFreshness(dir)) ?? undefined; @@ -648,6 +686,9 @@ export async function main(event: string): Promise { return; } + // Claude closing: send the queue from a detached child, so quitting isn't slowed. + if (event === 'session-end') { maybeFlushInBackground(); return; } + if (event === 'post-edit') { await handlePostEdit(input, dir); return; } if (event === 'tool-savings') { handleToolUse(input, dir); return; } diff --git a/src/claude/init.ts b/src/claude/init.ts index 6888297e..365f5fab 100644 --- a/src/claude/init.ts +++ b/src/claude/init.ts @@ -1,23 +1,32 @@ -import { mkdirSync, writeFileSync, readFileSync, chmodSync, rmSync } from 'node:fs'; -import { join, dirname, resolve } from 'node:path'; +import { mkdirSync, writeFileSync, readFileSync, chmodSync, rmSync, rmdirSync, existsSync, appendFileSync } from 'node:fs'; +import { join, dirname, resolve, isAbsolute } from 'node:path'; import { homedir } from 'node:os'; import { execFileSync } from 'node:child_process'; import { disableGraftServerIn, installClaudeGlobal, type GlobalWrite } from '../hosts/claude-global.js'; -import { mergeGraftSettings } from './settings-merge.js'; +import { mergeGraftSettings, mergeTrailShared, mergeTrailLocal } from './settings-merge.js'; import { statuslineShim, hooksShim, rememberInstall } from './shim-template.js'; import { skillTemplate } from './skill-template.js'; import { mergeJsonKey, serverEntry, type McpWrite } from '../hosts/mcp-config.js'; +import { readJsonObject } from '../hosts/config-write.js'; import { hasGraftIndex } from '../graph/root.js'; import type { PlannedWrite } from '../hosts/plan.js'; import { repoWiredForTrail, type Brand } from '../brand.js'; +/** trail's committed hook, inside `.trail/` where a `.claude/*` ignore rule can't drop it. */ +export const TRAIL_HOOK = join('.trail', 'hook.cjs'); +/** The status line's shim. Also in `.trail/`, but ignored there: only people with trail draw it. */ +export const TRAIL_STATUSLINE = join('.trail', 'statusline.cjs'); +/** Where trail's skill goes: the user's own `~/.claude`, never the repo. */ +export function trailSkillPath(home: string = homedir()): string { + return join(home, '.claude', 'skills', 'trail', 'SKILL.md'); +} + /** * The files `runInit` writes — pure, no writes, so `--dry-run` and the picker - * can report them up front. All repo-local: the Claude Code layer never writes - * outside the project. + * can report them up front. */ -export function claudeTargets(dir: string, initBrand?: Brand): PlannedWrite[] { - return claudeTargetsFor(dir, wiringName(dir, initBrand)); +export function claudeTargets(dir: string, initBrand?: Brand, home?: string): PlannedWrite[] { + return claudeTargetsFor(dir, wiringName(dir, initBrand), home); } /** @@ -30,8 +39,36 @@ export function wiringName(dir: string, initBrand?: Brand): Brand { return initBrand === 'trail' || repoWiredForTrail(dir) ? 'trail' : 'graft'; } -/** `claudeTargets` under one name. Uninstall walks both, so either set comes out. */ -export function claudeTargetsFor(dir: string, name: Brand): PlannedWrite[] { +/** + * `claudeTargets` under one name. + * + * trail splits what it writes in two. Shared, committed once by whoever sets + * the repo up: `.trail/hook.cjs` and one SessionStart entry in + * `.claude/settings.json`, which is all a teammate without trail needs to be + * shown the learnings and offered trail. Personal, where git never looks: the + * status line and allowlist in `.claude/settings.local.json`, the skill in + * `~/.claude/skills/`, and the other hooks in `~/.claude` (hosts/claude-global.ts). + * No MCP server: the hooks and the `trail` command do what it did. + */ +export function claudeTargetsFor(dir: string, name: Brand, home: string = homedir()): PlannedWrite[] { + if (name !== 'trail') return legacyClaudeTargets(dir, name); + const t = (path: string, what: string): PlannedWrite => ({ hostId: 'claude', id: 'claude', path, scope: 'repo', kind: 'claude', what }); + return [ + t(join(dir, '.claude', 'settings.json'), 'trail SessionStart hook, shared'), + t(join(dir, TRAIL_HOOK), 'hooks shim, shared'), + t(join(dir, '.claude', 'settings.local.json'), 'trail statusline + allowlist, yours'), + t(join(dir, TRAIL_STATUSLINE), 'statusline shim, git-ignored'), + { hostId: 'claude', id: 'claude', path: trailSkillPath(home), scope: 'global', kind: 'claude', what: 'trail skill' }, + ]; +} + +/** + * The layout every graft repo has, and trail repos had before 0.23: shims in + * `.claude/helpers/`, the skill in `.claude/skills/`, the server in `.mcp.json`, + * in that order. `runInit` writes it for graft; for trail it's what a refresh + * clears away, and what uninstall removes under both names. + */ +export function legacyClaudeTargets(dir: string, name: Brand): PlannedWrite[] { const t = (path: string, what: string, kind: PlannedWrite['kind'] = 'claude'): PlannedWrite => ({ hostId: 'claude', id: 'claude', path, scope: 'repo', kind, what }); return [ @@ -68,9 +105,10 @@ export function buildGraphIfMissing(dir: string, opts: { build?: boolean; cliPat export interface InitResult { settingsPath: string; shims: string[]; + /** Where the skill went; '' when nothing was written (trail under `--no-global`). */ skill: string; - /** the `.mcp.json` write registering the graft MCP server for Claude Code. */ - mcp: McpWrite; + /** the `.mcp.json` write registering the graft MCP server for Claude Code; trail writes none. */ + mcp: McpWrite | null; /** the user-level writes under `~/.claude`, empty when `global: false`. */ global: GlobalWrite[]; warnings: string[]; @@ -81,16 +119,15 @@ export function runInit( dir: string, opts: { build?: boolean; cliPath?: string; statusline?: boolean; global?: boolean; home?: string; brand?: Brand } = {}, ): InitResult { + if (wiringName(dir, opts.brand) === 'trail') return runTrailInit(dir, opts); // Same list `--dry-run` and the picker report, so the two can't drift apart. - const name = wiringName(dir, opts.brand); - const [settings, statusline, hooks, skill, mcpTarget] = claudeTargetsFor(dir, name).map((t) => t.path); + const [settings, statusline, hooks, skill, mcpTarget] = legacyClaudeTargets(dir, 'graft').map((t) => t.path); mkdirSync(dirname(statusline), { recursive: true }); const settingsPath = settings; - let existing: Record = {}; - try { existing = JSON.parse(readFileSync(settingsPath, 'utf8')); } catch { /* none/invalid → start fresh */ } - const { merged, warnings } = mergeGraftSettings(existing, { statusline: opts.statusline, name }); + const existing = readSettings(settingsPath); + const { merged, warnings } = mergeGraftSettings(existing, { statusline: opts.statusline, name: 'graft' }); writeFileSync(settingsPath, `${JSON.stringify(merged, null, 2)}\n`); const sl = statusline; @@ -105,32 +142,139 @@ export function runInit( // greps source. Overwritten each run (graft owns this file), like the shims above. const skillPath = skill; mkdirSync(dirname(skillPath), { recursive: true }); - writeFileSync(skillPath, skillTemplate(name)); - - // A repo moving from graft's names to trail's: the graft-named shims and - // skill go, so the agent doesn't load two copies. Their settings entries - // were already replaced by the merge above. - if (name === 'trail') { - const [, oldSl, oldHk, oldSkill] = claudeTargetsFor(dir, 'graft').map((t) => t.path); - for (const f of [oldSl, oldHk]) rmSync(f, { force: true }); - rmSync(dirname(oldSkill), { recursive: true, force: true }); - } + writeFileSync(skillPath, skillTemplate('graft')); // Register the graft MCP server in the project's .mcp.json so Claude Code // exposes graft_find_code/graft_trace_calls/etc. as tools — the same keyed merge the // other hosts use (existing servers preserved; unparseable files skipped). - const mcp = mergeJsonKey('claude', mcpTarget, 'mcpServers', serverEntry({ brand: name }), name, name === 'trail' ? ['graft'] : []); + const mcp = mergeJsonKey('claude', mcpTarget, 'mcpServers', serverEntry({ brand: 'graft' }), 'graft'); // The same wiring again, one level up in `~/.claude`, because everything above // this line can be erased by a `.gitignore` and lost to `git worktree add`. See // hosts/claude-global.ts for the failure that motivates it. Gated on the same // flag `registerMcpConfigs` uses, so `--no-global` still means "nothing outside // this repo". - const global = opts.global === false ? [] : installClaudeGlobal(opts.home ?? homedir(), name); - // A trail repo on a machine with graft's server installed for everything: - // graft goes off in this folder, so Claude lists one server here. - if (name === 'trail' && opts.global !== false) global.push(disableGraftServerIn(opts.home ?? homedir(), resolve(dir))); + const global = opts.global === false ? [] : installClaudeGlobal(opts.home ?? homedir(), 'graft'); const built = buildGraphIfMissing(dir, opts); return { settingsPath, shims: [sl, hk], skill: skillPath, mcp, global, warnings, built }; } + +function readSettings(path: string): Record { + try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return {}; } // none/invalid → start fresh +} + +/** + * `runInit` for a repo on trail. Shared: `.trail/hook.cjs` and its one + * SessionStart entry in `.claude/settings.json`. Personal: everything else. + * Run again over a repo set up the old way, it moves it: the old shims, the + * repo skill and trail's `.mcp.json` server go. + */ +function runTrailInit( + dir: string, + opts: { build?: boolean; cliPath?: string; statusline?: boolean; global?: boolean; home?: string }, +): InitResult { + const home = opts.home ?? homedir(); + const global = opts.global !== false; + const [settings, hook, local, statusline, skill] = claudeTargetsFor(dir, 'trail', home).map((t) => t.path); + + mkdirSync(dirname(hook), { recursive: true }); + writeFileSync(hook, hooksShim()); chmodSync(hook, 0o755); + writeFileSync(statusline, statuslineShim()); chmodSync(statusline, 0o755); + ignoreInTrailDir(dir, 'statusline.cjs'); + rememberInstall(home); + + const shared = mergeTrailShared(readSettings(settings)); + mkdirSync(dirname(settings), { recursive: true }); + writeFileSync(settings, `${JSON.stringify(shared, null, 2)}\n`); + + // Hooks for every other event come from `~/.claude`. Only when that's off do + // they go in the personal file, beside the status line. + const { merged, warnings } = mergeTrailLocal(readSettings(local), { statusline: opts.statusline, hooks: !global }); + writeFileSync(local, `${JSON.stringify(merged, null, 2)}\n`); + keepOutOfGit(dir, join('.claude', 'settings.local.json')); + + let skillPath = ''; + if (global) { + mkdirSync(dirname(skill), { recursive: true }); + writeFileSync(skill, skillTemplate('trail')); + skillPath = skill; + } + + clearOldLayout(dir); + + const globalWrites = global ? installClaudeGlobal(home, 'trail') : []; + // A trail repo on a machine with graft's server installed for everything: + // graft goes off in this folder, so its tools don't show here. + if (global) globalWrites.push(disableGraftServerIn(home, resolve(dir))); + + const built = buildGraphIfMissing(dir, opts); + return { settingsPath: settings, shims: [statusline, hook], skill: skillPath, mcp: null, global: globalWrites, warnings, built }; +} + +/** + * What trail wrote before 0.23 and doesn't any more: both names' shims in + * `.claude/helpers/`, the skill in `.claude/skills/`, and trail's server in + * `.mcp.json`. Settings entries are already gone: the merges replace them. + */ +function clearOldLayout(dir: string): void { + for (const n of ['graft', 'trail'] as const) { + const [, oldSl, oldHk, oldSkill] = legacyClaudeTargets(dir, n).map((t) => t.path); + rmSync(oldSl, { force: true }); + rmSync(oldHk, { force: true }); + rmSync(dirname(oldSkill), { recursive: true, force: true }); + } + // The folders they were in, once nothing else is: rmdir refuses a non-empty one. + for (const d of [join(dir, '.claude', 'helpers'), join(dir, '.claude', 'skills')]) { + try { rmdirSync(d); } catch { /* holds the user's files, or was never there */ } + } + dropServer(join(dir, '.mcp.json'), ['trail', 'graft']); +} + +/** Take trail's (or graft's) server out of an `.mcp.json`; the file goes if nothing else was in it. */ +function dropServer(path: string, names: string[]): void { + try { + const loaded = readJsonObject(path); + if (loaded === 'unparseable' || !loaded.existed) return; + const root = loaded.root; + const servers = root.mcpServers; + if (!servers || typeof servers !== 'object' || !names.some((n) => n in servers)) return; + for (const n of names) delete servers[n]; + if (Object.keys(servers).length === 0) delete root.mcpServers; + if (Object.keys(root).length === 0) rmSync(path, { force: true }); + else writeFileSync(path, `${JSON.stringify(root, null, 2)}\n`); + } catch { /* leave it as the user has it */ } +} + +/** One line in `.trail/.gitignore`, added once. */ +export function ignoreInTrailDir(dir: string, entry: string): void { + const path = join(dir, '.trail', '.gitignore'); + let text = ''; + try { text = readFileSync(path, 'utf8'); } catch { /* new */ } + if (text.split('\n').some((l) => l.trim() === entry)) return; + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${text}${text && !text.endsWith('\n') ? '\n' : ''}${entry}\n`); +} + +/** + * Keep a personal file out of git without touching the repo's `.gitignore`: + * if nothing ignores it already, it goes in `.git/info/exclude`, which is + * never committed. A folder that isn't a git checkout is left alone. + */ +export function keepOutOfGit(dir: string, rel: string): void { + try { + execFileSync('git', ['check-ignore', '-q', '--', rel], { cwd: dir, stdio: 'ignore' }); + return; // exit 0: already ignored + } catch (e) { + if ((e as { status?: number }).status !== 1) return; // 128: not a git checkout + } + try { + let exclude = execFileSync('git', ['rev-parse', '--git-path', 'info/exclude'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + if (!isAbsolute(exclude)) exclude = join(dir, exclude); + const line = `/${rel.split('\\').join('/')}`; + const text = existsSync(exclude) ? readFileSync(exclude, 'utf8') : ''; + if (text.split('\n').some((l) => l.trim() === line)) return; + mkdirSync(dirname(exclude), { recursive: true }); + appendFileSync(exclude, `${text && !text.endsWith('\n') ? '\n' : ''}${line}\n`); + } catch { /* best-effort: git status shows the file, and nothing else breaks */ } +} diff --git a/src/claude/settings-merge.ts b/src/claude/settings-merge.ts index f772a977..439520e4 100644 --- a/src/claude/settings-merge.ts +++ b/src/claude/settings-merge.ts @@ -3,9 +3,11 @@ import { isGraftEntry } from '../hosts/config-write.js'; type Json = Record; const slCmd = (name: 'graft' | 'trail' = 'graft') => `node "\${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/${name}-statusline.cjs"`; +/** trail's status line, from the shim in `.trail/` (see claude/init.ts). */ +const TRAIL_SL_CMD = 'node "${CLAUDE_PROJECT_DIR:-.}/.trail/statusline.cjs"'; /** Stable marker for "this statusLine is Graft's", not the full command string — * an older shim path or a GRAFT_DIR wrapper still names this file. Either name. */ -const STATUSLINE_HELPER = /(graft|trail)-statusline\.cjs/; +const STATUSLINE_HELPER = /(graft|trail)-statusline\.cjs|\.trail\/statusline\.cjs/; const FOOTER = 'graft/[\\w./-]+\\.md'; // Every form graft is actually invoked as. 'graft:*' covers a global install; // the other two cover a repo working on graft itself (or any consumer running it @@ -32,11 +34,17 @@ const REPO_HELPERS = '${CLAUDE_PROJECT_DIR:-.}/.claude/helpers'; function hookCmd(arg: string, helpers: string = REPO_HELPERS, name: 'graft' | 'trail' = 'graft'): string { return `node "${helpers}/${name}-hooks.cjs" ${arg}`; } +/** trail's committed hook, `.trail/hook.cjs`, run for one event. */ +export function trailHookCmd(arg: string): string { + return `node "\${CLAUDE_PROJECT_DIR:-.}/.trail/hook.cjs" ${arg}`; +} function graftBlocks(helpers?: string, name: 'graft' | 'trail' = 'graft'): Record { - const hookCmd_ = (arg: string, h?: string) => hookCmd(arg, h, name); + return hookBlocks((arg) => hookCmd(arg, helpers, name)); +} +function hookBlocks(hookCmd_: (arg: string) => string): Record { return { PostToolUse: [ - { matcher: 'Write|Edit|MultiEdit', hooks: [{ type: 'command', command: hookCmd_('post-edit', helpers), timeout: 10000 }] }, + { matcher: 'Write|Edit|MultiEdit', hooks: [{ type: 'command', command: hookCmd_('post-edit'), timeout: 10000 }] }, // Score the usage mix and sum token savings. A graft retrieval (CLI `graft …` // via Bash, or the `graft_*` MCP tools) prints a `[graft] tokens saved ≈ N` // footer this hook sums into the session total; the same hook classifies @@ -44,7 +52,7 @@ function graftBlocks(helpers?: string, name: 'graft' | 'trail' = 'graft'): Recor // `graft stats` and the `session_summary` graft-vs-grep ratio. Broad matcher, // but the handler no-ops instantly unless there is something to record, so an // unrelated Bash or a plain Read costs only a stdin read. - { matcher: 'Bash|mcp__graft__|mcp__trail__|Read|Grep|Glob', hooks: [{ type: 'command', command: hookCmd_('tool-savings', helpers), timeout: 8000 }] }, + { matcher: 'Bash|mcp__graft__|mcp__trail__|Read|Grep|Glob', hooks: [{ type: 'command', command: hookCmd_('tool-savings'), timeout: 8000 }] }, ], // Longer budget than the other hooks: its `graft ask` is a real query, and a // query now brings the graph up to date first (graph/refresh.ts) — usually @@ -54,9 +62,12 @@ function graftBlocks(helpers?: string, name: 'graft' | 'trail' = 'graft'): Recor // this bump (8s) keeps a child that fits inside 8s. Changing the number here is // therefore safe on its own — but it only reaches an existing repo when someone // re-runs `graft init`, since that is the only caller of this function. - UserPromptSubmit: [{ hooks: [{ type: 'command', command: hookCmd_('prompt', helpers), timeout: 15000 }] }], - SessionStart: [{ hooks: [{ type: 'command', command: hookCmd_('session-start', helpers), timeout: 8000 }] }], - Stop: [{ hooks: [{ type: 'command', command: hookCmd_('stop', helpers), timeout: 8000 }] }], + UserPromptSubmit: [{ hooks: [{ type: 'command', command: hookCmd_('prompt'), timeout: 15000 }] }], + SessionStart: [{ hooks: [{ type: 'command', command: hookCmd_('session-start'), timeout: 8000 }] }], + Stop: [{ hooks: [{ type: 'command', command: hookCmd_('stop'), timeout: 8000 }] }], + // Sends the telemetry queue in a detached child, so closing Claude isn't + // slowed. Not fired when the terminal is killed; session start sends what's left. + SessionEnd: [{ hooks: [{ type: 'command', command: hookCmd_('session-end'), timeout: 5000 }] }], }; } /** @@ -183,3 +194,80 @@ export function mergeGraftHooks(existing: Json, helpers: string): { merged: Json } return { merged }; } + +/** Drop every fragment graft or trail wrote from a settings object, keeping the user's. */ +function withoutOurs(existing: Json): Json { + const merged: Json = { ...(existing ?? {}) }; + for (const key of ['statusLine', 'subagentStatusLine'] as const) if (isGraftStatusline(merged[key])) delete merged[key]; + if (merged.hooks && typeof merged.hooks === 'object' && !Array.isArray(merged.hooks)) { + const hooks: Json = {}; + for (const [event, prior] of Object.entries(merged.hooks)) { + const kept = Array.isArray(prior) ? prior.filter((e: Json) => !isGraftEntry(e)) : prior; + if (!Array.isArray(kept) || kept.length) hooks[event] = kept; + } + if (Object.keys(hooks).length) merged.hooks = hooks; + else delete merged.hooks; + } + if (Array.isArray(merged.footerLinksRegexes)) { + const kept = merged.footerLinksRegexes.filter((r: unknown) => !isGraftFooterRegex(r)); + if (kept.length) merged.footerLinksRegexes = kept; + else delete merged.footerLinksRegexes; + } + if (merged.permissions && typeof merged.permissions === 'object' && Array.isArray(merged.permissions.allow)) { + const allow = merged.permissions.allow.filter((e: unknown) => !isGraftAllowEntry(e)); + const permissions: Json = { ...merged.permissions }; + if (allow.length) permissions.allow = allow; + else delete permissions.allow; + if (Object.keys(permissions).length) merged.permissions = permissions; + else delete merged.permissions; + } + return merged; +} + +/** + * trail's part of the committed `.claude/settings.json`: one SessionStart entry + * running `.trail/hook.cjs`, and nothing else. For a teammate without trail it + * shows the learnings and offers trail; with trail it hands off to the install. + * Anything an older init put here (status line, the other hooks, allowlist, + * footer regex) comes out, so the file a team commits is the same on every machine. + */ +export function mergeTrailShared(existing: Json): Json { + const merged = withoutOurs(existing); + merged.hooks = { ...(merged.hooks ?? {}) }; + merged.hooks.SessionStart = [ + ...(Array.isArray(merged.hooks.SessionStart) ? merged.hooks.SessionStart : []), + { hooks: [{ type: 'command', command: trailHookCmd('session-start'), timeout: 8000 }] }, + ]; + return merged; +} + +/** + * trail's part of `.claude/settings.local.json`, the person's own: the status + * line, the footer regex and the allowlist, plus, when `~/.claude` doesn't carry + * them (`--no-global`), the hooks for every event but session start, which the + * shared file already runs once. + */ +export function mergeTrailLocal( + existing: Json, + opts: { statusline?: boolean; hooks?: boolean } = {}, +): { merged: Json; warnings: string[] } { + const merged = withoutOurs(existing); + const warnings: string[] = []; + if (statuslineWanted(opts)) { + for (const key of ['statusLine', 'subagentStatusLine'] as const) { + if (!merged[key]) merged[key] = { type: 'command', command: TRAIL_SL_CMD }; + else if (key === 'statusLine') warnings.push('Existing statusLine left untouched (a session allows only one). To use Trail, point it at .trail/statusline.cjs.'); + } + } + if (opts.hooks) { + merged.hooks = { ...(merged.hooks ?? {}) }; + for (const [event, blocks] of Object.entries(hookBlocks(trailHookCmd))) { + if (event === 'SessionStart') continue; + merged.hooks[event] = [...(Array.isArray(merged.hooks[event]) ? merged.hooks[event] : []), ...blocks]; + } + } + merged.footerLinksRegexes = [...(merged.footerLinksRegexes ?? []), FOOTER]; + merged.permissions = { ...(merged.permissions ?? {}) }; + merged.permissions.allow = [...(merged.permissions.allow ?? []), ...ALLOW_ENTRIES]; + return { merged, warnings }; +} diff --git a/src/cli.ts b/src/cli.ts index f555f680..5f09f6c0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1664,8 +1664,11 @@ async function runInitCommand(dir: string, opts: InitOptions): Promise<{ ids: st const tops: string[] = []; for (const w of selectedWrites(plan, ids)) { if (w.scope !== "repo") continue; - const parts = shown(repo, w.path).split("/"); - const top = parts.length > 1 ? `${parts[0]}/` : parts[0]!; + const rel = shown(repo, w.path); + // trail's Claude Code setup shares one file in .claude/; the rest there is personal. + if (TRAIL && rel.startsWith(".claude/") && rel !== ".claude/settings.json") continue; + const parts = rel.split("/"); + const top = TRAIL && rel === ".claude/settings.json" ? rel : 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 ${brand()}`); @@ -1678,8 +1681,9 @@ async function runInitCommand(dir: string, opts: InitOptions): Promise<{ ids: st tops.splice(0, tops.length, ...changedTops); if (tops.length) console.error( - `· commit ${tops.slice(0, 5).join(" ")}${tops.length > 5 ? ` +${tops.length - 5} more` : ""} to share it — ` + - (TRAIL ? "teammates' Claude reads the learnings even without trail installed; the code map in graft/ stays on this machine" : "graft/ stays local and git-ignored"), + TRAIL + ? `· commit ${tops.slice(0, 5).join(" and ")}${tops.length > 5 ? ` +${tops.length - 5} more` : ""} once — after that, teammates' Claude reads your learnings even before they install trail` + : `· commit ${tops.slice(0, 5).join(" ")}${tops.length > 5 ? ` +${tops.length - 5} more` : ""} to share it — graft/ stays local and git-ignored`, ); } @@ -1759,8 +1763,10 @@ function wireTarget( const res = runInit(repo, { build: opts.build, cliPath, statusline: wantStatusline, global: opts.global, home, brand: brand() }); say(`✓ wrote ${res.settingsPath}`); for (const s of res.shims) say(`✓ wrote ${s}`); - say(`✓ wrote ${res.skill}`); - if (res.mcp.action === "skipped-unparseable") + if (res.skill) say(`✓ wrote ${res.skill}`); + if (!res.mcp) { + // trail registers no server for Claude Code: the hooks and the `trail` command cover it. + } else if (res.mcp.action === "skipped-unparseable") say(`⚠ .mcp.json: ${res.mcp.path} left unchanged (not valid JSON) — add the graft server manually`); else if (res.mcp.action === "unchanged") say(`· mcp claude: ${res.mcp.path} (already registered)`); diff --git a/src/hosts/claude-global.ts b/src/hosts/claude-global.ts index 792a7575..c773a0ba 100644 --- a/src/hosts/claude-global.ts +++ b/src/hosts/claude-global.ts @@ -57,11 +57,13 @@ export function globalHelpersDir(home: string): string { export function claudeGlobalTargets(home: string, brand: Brand = 'graft'): PlannedWrite[] { const g = (id: string, path: string, kind: PlannedWrite['kind'], what: string): PlannedWrite => ({ hostId: 'claude', id, path, scope: 'global', kind, what }); - return [ + const writes = [ g('claude-global-shim', join(globalHelpersDir(home), 'graft-hooks.cjs'), 'hook', 'hooks shim (user level)'), - g('claude-global-hooks', join(home, '.claude', 'settings.json'), 'hook', 'SessionStart / UserPromptSubmit / PostToolUse / Stop'), + g('claude-global-hooks', join(home, '.claude', 'settings.json'), 'hook', 'SessionStart / UserPromptSubmit / PostToolUse / Stop / SessionEnd'), g('claude-global-mcp', join(home, '.claude.json'), 'mcp', `mcpServers.${brand}`), ]; + // trail registers no server for Claude Code: the hooks and the `trail` command do its work. + return brand === 'trail' ? writes.slice(0, 2) : writes; } /** Merge graft's hook blocks into a settings file, preserving everything else. */ @@ -91,7 +93,8 @@ function upsertGlobalHooks(id: string, path: string, helpers: string): GlobalWri * action, never raised, so a bad `~/.claude.json` can't fail a `graft init`. */ export function installClaudeGlobal(home: string, brand: Brand = 'graft'): GlobalWrite[] { - const [shim, settings, mcp] = claudeGlobalTargets(home, brand); + const [shim, settings] = claudeGlobalTargets(home, brand); + const mcp = { id: 'claude-global-mcp', path: join(home, '.claude.json') }; const out: GlobalWrite[] = []; try { @@ -115,17 +118,15 @@ export function installClaudeGlobal(home: string, brand: Brand = 'graft'): Globa } } - // Under trail the server is `trail`, unless graft's is already registered - // here: that one keeps serving every repo still wired as graft, and a trail - // repo turns it off for its own folder instead (see disableGraftServerIn). + // trail: no server, and the `trail` one an older trail registered here goes. + // graft's keeps serving every repo still wired as graft; a trail repo turns + // it off for its own folder (see disableGraftServerIn). + if (brand === 'trail') { + out.push(dropUserServer(mcp.id, mcp.path, 'trail')); + return out; + } try { - const loaded = readJsonObject(mcp.path); - const hasGraft = loaded !== 'unparseable' && Boolean(loaded.root.mcpServers?.graft); - out.push( - brand === 'trail' && hasGraft - ? { id: mcp.id, path: mcp.path, action: 'unchanged' } - : mergeJsonKey(mcp.id, mcp.path, 'mcpServers', serverEntry({ brand }), brand), - ); + out.push(mergeJsonKey(mcp.id, mcp.path, 'mcpServers', serverEntry({ brand }), brand)); } catch { out.push({ id: mcp.id, path: mcp.path, action: 'skipped-unparseable' }); } @@ -133,6 +134,21 @@ export function installClaudeGlobal(home: string, brand: Brand = 'graft'): Globa return out; } +/** Remove one user-scope server from `~/.claude.json`, leaving everything else. */ +function dropUserServer(id: string, path: string, name: string): GlobalWrite { + try { + const loaded = readJsonObject(path); + if (loaded === 'unparseable') return { id, path, action: 'skipped-unparseable' }; + const servers = loaded.root.mcpServers; + if (!servers || typeof servers !== 'object' || !(name in servers)) return { id, path, action: 'unchanged' }; + delete servers[name]; + writeFileSync(path, `${JSON.stringify(loaded.root, null, 2)}\n`); + return { id, path, action: 'updated' }; + } catch { + return { id, path, action: 'skipped-unparseable' }; + } +} + /** * In a repo wired for trail, Claude Code would list graft's user-level server * beside the repo's own `trail` one: the same tools twice. Claude Code keeps a diff --git a/src/hosts/config-write.ts b/src/hosts/config-write.ts index 52c07ffb..d98277be 100644 --- a/src/hosts/config-write.ts +++ b/src/hosts/config-write.ts @@ -41,7 +41,7 @@ export function writeOwned(id: string, path: string, content: string, mode?: num * `undefined`, whose `.includes` would throw. */ export function isGraftEntry(entry: unknown): boolean { const s = JSON.stringify(entry ?? ''); - return s.includes('graft-hooks.cjs') || s.includes('trail-hooks.cjs'); + return s.includes('graft-hooks.cjs') || s.includes('trail-hooks.cjs') || s.includes('/.trail/hook.cjs'); } /** diff --git a/src/hosts/retract.ts b/src/hosts/retract.ts index 15e94526..ce5b4e53 100644 --- a/src/hosts/retract.ts +++ b/src/hosts/retract.ts @@ -33,7 +33,7 @@ import { hookTargets } from './codex-hooks.js'; import { cursorHookTargets } from './cursor-hooks.js'; import { antigravitySkillTargets } from './antigravity.js'; import { claudeGlobalTargets } from './claude-global.js'; -import { claudeTargets, claudeTargetsFor } from '../claude/init.js'; +import { claudeTargetsFor, legacyClaudeTargets, TRAIL_HOOK, TRAIL_STATUSLINE, trailSkillPath } from '../claude/init.js'; import { isGraftAllowEntry, isGraftFooterRegex } from '../claude/settings-merge.js'; import type { WriteScope } from './plan.js'; import type { Brand } from '../brand.js'; @@ -238,7 +238,7 @@ function stripClaudeSettings(path: string, apply: boolean): RetractAction { } if (typeof root !== 'object' || root === null || Array.isArray(root)) return 'skipped-unparseable'; const before = JSON.stringify(root); - const isGraftCmd = (v: unknown) => /(graft|trail)-statusline\.cjs/.test(JSON.stringify(v ?? '')); + const isGraftCmd = (v: unknown) => /(graft|trail)-statusline\.cjs|\.trail\/statusline\.cjs/.test(JSON.stringify(v ?? '')); for (const key of ['statusLine', 'subagentStatusLine']) { if (root[key] !== undefined && isGraftCmd(root[key])) delete root[key]; @@ -248,7 +248,7 @@ function stripClaudeSettings(path: string, apply: boolean): RetractAction { for (const event of Object.keys(root.hooks)) { const prior = root.hooks[event]; if (!Array.isArray(prior)) continue; - const kept = prior.filter((e: unknown) => !/(graft|trail)-hooks\.cjs/.test(JSON.stringify(e ?? ''))); + const kept = prior.filter((e: unknown) => !/(graft|trail)-hooks\.cjs|\/\.trail\/hook\.cjs/.test(JSON.stringify(e ?? ''))); if (kept.length === 0) delete root.hooks[event]; else root.hooks[event] = kept; } @@ -390,7 +390,7 @@ function targets(repo: string, opts: RetractOpts): Target[] { } for (const t of mcpTargets(repo, [...exclude], { home })) keptPaths.add(t.path); if (exclude.has('claude')) { - for (const n of NAMES) for (const t of claudeTargetsFor(repo, n)) keptPaths.add(t.path); + for (const n of NAMES) for (const t of [...claudeTargetsFor(repo, n, home), ...legacyClaudeTargets(repo, n)]) keptPaths.add(t.path); for (const t of claudeGlobalTargets(home)) keptPaths.add(t.path); } if (exclude.has('agents')) for (const t of hookTargets(home)) keptPaths.add(t.path); @@ -441,11 +441,14 @@ function targets(repo: string, opts: RetractOpts): Target[] { // 3. Claude Code: settings fragments, both shims, the skill, and the .mcp.json key. if (!exclude.has('claude')) { - const [settings, , , , mcp] = claudeTargets(repo).map((t) => t.path); + const [settings, , , , mcp] = legacyClaudeTargets(repo, 'graft').map((t) => t.path); + const local = join(repo, '.claude', 'settings.local.json'); + const trailHook = join(repo, TRAIL_HOOK); + const trailSl = join(repo, TRAIL_STATUSLINE); // The shims and skill under both names: a repo that moved to trail's names // still comes out clean, and so does one a teammate on graft re-wired. const named = (['graft', 'trail'] as const).flatMap((n) => { - const [, statusline, hooks, skill] = claudeTargetsFor(repo, n).map((t) => t.path); + const [, statusline, hooks, skill] = legacyClaudeTargets(repo, n).map((t) => t.path); return [ { hostId: 'claude', path: statusline, what: 'statusline shim', scope: 'repo', run: (a: boolean) => removeFile(statusline, a) }, { hostId: 'claude', path: hooks, what: 'hooks shim', scope: 'repo', run: (a: boolean) => removeFile(hooks, a) }, @@ -454,6 +457,9 @@ function targets(repo: string, opts: RetractOpts): Target[] { }); for (const t of [ { hostId: 'claude', path: settings, what: 'statusline + hooks + allowlist + footer regex', scope: 'repo', run: (a) => stripClaudeSettings(settings, a) }, + { hostId: 'claude', path: local, what: 'trail statusline + allowlist', scope: 'repo', run: (a) => stripClaudeSettings(local, a) }, + { hostId: 'claude', path: trailHook, what: 'hooks shim', scope: 'repo', run: (a) => removeFile(trailHook, a) }, + { hostId: 'claude', path: trailSl, what: 'statusline shim', scope: 'repo', run: (a) => removeFile(trailSl, a) }, ...named, { hostId: 'claude', path: mcp, what: 'mcpServers entry', scope: 'repo', run: (a) => removeJsonKey(mcp, 'mcpServers', a) }, ] as Target[]) add(t); @@ -477,6 +483,7 @@ function targets(repo: string, opts: RetractOpts): Target[] { { hostId: 'claude', path: shim.path, what: shim.what, scope: 'global', run: (a) => removeFile(shim.path, a) }, { hostId: 'claude', path: settings.path, what: settings.what, scope: 'global', run: (a) => stripClaudeSettings(settings.path, a) }, { hostId: 'claude', path: mcp.path, what: mcp.what, scope: 'global', run: (a) => removeJsonKey(mcp.path, 'mcpServers', a) }, + { hostId: 'claude', path: trailSkillPath(home), what: 'trail skill', scope: 'global', run: (a) => removeFile(trailSkillPath(home), a) }, ] as Target[]) add(t); } if (!exclude.has('agents')) { diff --git a/src/notes/notes.ts b/src/notes/notes.ts index 6aedc095..5df9a09b 100644 --- a/src/notes/notes.ts +++ b/src/notes/notes.ts @@ -571,6 +571,18 @@ export function commitLines( }; } +/** + * The line asking for trail's shared setup to be committed, once: `.trail/` + * and the SessionStart entry in `.claude/settings.json` are what let a + * teammate's Claude read the learnings before they install anything. + */ +export function setupCommitLine(teammates: string[]): string { + const ask = "Commit .trail/ and .claude/settings.json once"; + if (!teammates.length) return `🌱 ${ask}, and your teammates' Claude starts from your learnings, even before they install trail`; + const work = teammates.length === 1 ? "works" : "work"; + return `🌱 ${namesLabel(teammates)} ${work} in this repo. ${ask}, and their Claude starts from your learnings, even before they install trail`; +} + /** * What starting from these learnings is worth, for the agent's closing line: * the tokens they saved, less what reading them costs, when that's worth diff --git a/src/notes/repo-trail.ts b/src/notes/repo-trail.ts index a5a95eab..49279352 100644 --- a/src/notes/repo-trail.ts +++ b/src/notes/repo-trail.ts @@ -4,6 +4,7 @@ * * .trail/ * README.md what the folder is, for anyone who opens it + * hook.cjs what Claude Code runs at session start (claude/init.ts) * learnings/--.md * * Personal learnings (about one person's setup, not the code) stay on their @@ -28,6 +29,25 @@ export const REPO_TRAIL_README = `# .trail What coding sessions on this repo worked out, kept next to the code so the next session, yours or a teammate's, starts from it instead of digging again. +- learnings/ one file per session that took real digging: what was decided, + what was tried and ruled out, and what to watch out for, plus + what it took to work out +- hook.cjs what Claude Code runs at session start (\`.claude/settings.json\`). + With trail installed it hands over to trail. Without it, it + shows Claude these learnings and offers to set trail up. + +Commit this folder and \`.claude/settings.json\` once. After that, commit new +learnings with the change they came from. Learnings are short summaries, never +transcripts, and plain markdown without trail installed. What +\`.trail/.gitignore\` lists stays on each machine. +`; + +/** The README before 0.23, which pointed at the repo skill. Replaced where it's untouched. */ +const OLD_READMES = [`# .trail + +What coding sessions on this repo worked out, kept next to the code so the +next session, yours or a teammate's, starts from it instead of digging again. + - learnings/ one file per session that took real digging: what was decided, what was tried and ruled out, and what to watch out for, plus what it took to work out @@ -35,7 +55,7 @@ next session, yours or a teammate's, starts from it instead of digging again. Learnings are short summaries, never transcripts. Commit them with the change they came from. Claude Code reads them through the trail skill (\`.claude/skills/trail/\`); without trail installed they are plain markdown. -`; +`]; /** `/.trail/learnings`. */ export function learningsDir(checkout: string): string { @@ -52,7 +72,9 @@ export function ensureRepoTrail(checkout: string): boolean { const created = !hasRepoTrail(checkout); mkdirSync(learningsDir(checkout), { recursive: true }); const readme = join(checkout, TRAIL_DIR, "README.md"); - if (!existsSync(readme)) writeFileSync(readme, REPO_TRAIL_README); + let current: string | null = null; + try { current = readFileSync(readme, "utf8"); } catch { /* none yet */ } + if (current === null || OLD_READMES.includes(current)) writeFileSync(readme, REPO_TRAIL_README); return created; } diff --git a/src/upkeep-run.ts b/src/upkeep-run.ts index 82619647..75977ad6 100644 --- a/src/upkeep-run.ts +++ b/src/upkeep-run.ts @@ -6,7 +6,7 @@ * re-run the wiring, and it is deliberately thin. */ import { spawn } from 'node:child_process'; -import { runInit } from './claude/init.js'; +import { runInit, wiringName } from './claude/init.js'; import { runHostsInit } from './hosts/init.js'; import { graftCliPath } from './claude/paths.js'; import { BRAND_ENV, repoWiredForTrail, setBrand, trailOnPath } from './brand.js'; @@ -46,8 +46,9 @@ export interface UpkeepResult { function rewriteWiring(repo: string, hosts: string[], opts: WiringOpts, toTrail = false): void { // Moving to trail: everything written from here speaks trail, the repo gets // its .trail/, and the hosts are rewired under trail's names. - if (toTrail) { - setBrand('trail'); + if (toTrail) setBrand('trail'); + // Under trail, each refresh also brings `.trail/` and its README up to date. + if (toTrail || wiringName(repo) === 'trail') { const { place } = ensureRepoHome(repo); if (place.git) ensureRepoTrail(place.checkout); } diff --git a/src/upkeep.ts b/src/upkeep.ts index d71be4ae..3414bdb6 100644 --- a/src/upkeep.ts +++ b/src/upkeep.ts @@ -230,7 +230,7 @@ export function writeStamp( */ export function wiredHostIds(repo: string): string[] { const ids: string[] = []; - if (['graft', 'trail'].some((n) => existsSync(join(repo, '.claude', 'helpers', `${n}-hooks.cjs`)))) ids.push('claude'); + if (existsSync(join(repo, '.trail', 'hook.cjs')) || ['graft', 'trail'].some((n) => existsSync(join(repo, '.claude', 'helpers', `${n}-hooks.cjs`)))) ids.push('claude'); for (const host of HOSTS) { // Under either name: a repo on trail has trail's files and blocks. const wired = (['graft', 'trail'] as const).some((n) => { @@ -309,7 +309,7 @@ export function formatWiringRefresh(r: WiringRefresh | null): string | null { return ( `[trail] graft is now trail, and this repo just moved to it: the same commands under the new name, plus learnings in .trail/learnings/ that the whole team shares. ` + `trail is reading the user's past Claude sessions here in the background, and what it learns is there from the next session. ` + - `Tell the user this once, in a line at the end of your first reply, and that committing .claude/, .mcp.json and .trail/ moves their team over too.` + `Tell the user this once, in a line at the end of your first reply, and that committing .trail/ and .claude/settings.json once moves their team over too.` ); // Name the out-of-repo writes explicitly: those are machine-wide and shared by // every repo, so a user seeing this line should not have to guess what moved. diff --git a/test/claude-shim-template.test.ts b/test/claude-shim-template.test.ts index 9d2b6808..25144265 100644 --- a/test/claude-shim-template.test.ts +++ b/test/claude-shim-template.test.ts @@ -79,8 +79,8 @@ test('the shims init commits are the same bytes on every machine', () => { runInit(repo, { build: false, home, global: false, brand: 'trail' }); installCursorHooks(repo); const written = { - statusline: readFileSync(join(repo, '.claude', 'helpers', 'trail-statusline.cjs'), 'utf8'), - hooks: readFileSync(join(repo, '.claude', 'helpers', 'trail-hooks.cjs'), 'utf8'), + statusline: readFileSync(join(repo, '.trail', 'statusline.cjs'), 'utf8'), + hooks: readFileSync(join(repo, '.trail', 'hook.cjs'), 'utf8'), cursor: readFileSync(join(repo, '.cursor', 'hooks', 'graft-hooks.cjs'), 'utf8'), }; assert.equal(written.statusline, statuslineShim()); diff --git a/test/graft-to-trail.test.ts b/test/graft-to-trail.test.ts index d607fbcd..4f509af9 100644 --- a/test/graft-to-trail.test.ts +++ b/test/graft-to-trail.test.ts @@ -63,8 +63,9 @@ test('a graft repo moves to trail once the trail command is installed, and only const first = runUpkeep(repo, runningVersion(), { background: false, home }); assert.match(first.lines.join('\n'), /graft is now trail, and this repo just moved to it/); assert.ok(repoWiredForTrail(repo)); - assert.ok(existsSync(join(repo, '.claude', 'skills', 'trail', 'SKILL.md'))); - assert.ok(!existsSync(join(repo, '.claude', 'skills', 'graft')), 'one skill, not two'); + assert.ok(existsSync(join(repo, '.trail', 'hook.cjs')), "trail's shared hook"); + assert.ok(existsSync(join(home, '.claude', 'skills', 'trail', 'SKILL.md')), 'the skill is the person’s own'); + assert.ok(!existsSync(join(repo, '.claude', 'skills')), 'no skill in the repo, under either name'); assert.ok(existsSync(join(repo, '.trail')), 'the repo has its .trail/'); assert.deepEqual(readStamp(repo)?.hosts, ['claude'], 'the same hosts as before'); diff --git a/test/hosts-claude-global.test.ts b/test/hosts-claude-global.test.ts index 6a9cbe9a..7a1ad12f 100644 --- a/test/hosts-claude-global.test.ts +++ b/test/hosts-claude-global.test.ts @@ -241,9 +241,10 @@ test('under trail, graft keeps its user-level server and is turned off only in t assert.deepEqual(cfg.projects['/w/trail-repo'].disabledMcpServers, ['graft']); assert.deepEqual(cfg.projects['/w/other'].disabledMcpServers, ['paper'], 'other folders untouched'); - // A machine with no graft server gets trail's, and nothing to turn off. + // trail registers no server of its own, and takes back the one an older trail registered. const fresh = tmpRepo('cgtrailfresh'); + writeFileSync(userMcpOf(fresh), JSON.stringify({ mcpServers: { trail: { command: 'trail', args: ['mcp'] }, paper: { command: 'paper' } } }, null, 2)); installClaudeGlobal(fresh, 'trail'); - assert.ok(readJson(userMcpOf(fresh)).mcpServers.trail); + assert.deepEqual(Object.keys(readJson(userMcpOf(fresh)).mcpServers), ['paper']); assert.equal(disableGraftServerIn(fresh, '/w/x').action, 'unchanged'); }); diff --git a/test/trail-init-layout.test.ts b/test/trail-init-layout.test.ts new file mode 100644 index 00000000..8af1d439 --- /dev/null +++ b/test/trail-init-layout.test.ts @@ -0,0 +1,177 @@ +/** + * What `trail init` shares and what it keeps personal. Shared, committed once: + * `.trail/hook.cjs` and one SessionStart entry in `.claude/settings.json`. + * Personal, never in git: the status line, allowlist and (under --no-global) + * the other hooks in `.claude/settings.local.json`, and the skill in ~/.claude. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; +import { tmpRepo } from "./helpers.js"; +import { runInit, keepOutOfGit } from "../src/claude/init.js"; +import { mergeTrailLocal, mergeTrailShared } from "../src/claude/settings-merge.js"; +import { globalCopyCovers } from "../src/claude/hook-layers.js"; +import { setupUncommitted } from "../src/claude/hooks.js"; +import { setupCommitLine } from "../src/notes/notes.js"; +import { ensureRepoTrail, REPO_TRAIL_README } from "../src/notes/repo-trail.js"; +import { hooksShim } from "../src/claude/shim-template.js"; + +const git = (cwd: string, ...args: string[]) => + spawnSync("git", ["-c", "commit.gpgsign=false", "-c", "user.name=T", "-c", "user.email=t@t.test", ...args], { cwd, encoding: "utf8" }); + +function gitRepo(tag: string): string { + const d = tmpRepo(tag); + git(d, "init", "-q"); + return d; +} + +test("the shared settings get one SessionStart entry, and lose what an older init put there", () => { + const older = { + statusLine: { type: "command", command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/trail-statusline.cjs"' }, + hooks: { + SessionStart: [ + { hooks: [{ type: "command", command: "./scripts/fetch-secrets.sh" }] }, + { hooks: [{ type: "command", command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/trail-hooks.cjs" session-start' }] }, + ], + UserPromptSubmit: [{ hooks: [{ type: "command", command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/trail-hooks.cjs" prompt' }] }], + }, + permissions: { allow: ["Bash(trail:*)", "Bash(make:*)"] }, + footerLinksRegexes: ["graft/[\\w./-]+\\.md"], + }; + const shared = mergeTrailShared(older); + assert.equal(shared.statusLine, undefined); + assert.deepEqual(Object.keys(shared.hooks), ["SessionStart"]); + assert.equal(shared.hooks.SessionStart[0].hooks[0].command, "./scripts/fetch-secrets.sh", "the team's own hook stays, first"); + assert.equal(shared.hooks.SessionStart[1].hooks[0].command, 'node "${CLAUDE_PROJECT_DIR:-.}/.trail/hook.cjs" session-start'); + assert.deepEqual(shared.permissions, { allow: ["Bash(make:*)"] }); + assert.equal(shared.footerLinksRegexes, undefined); + assert.deepEqual(mergeTrailShared(shared), shared, "a second run changes nothing"); +}); + +test("the personal settings carry the hooks only when ~/.claude doesn't, and never session start", () => { + const withHooks = mergeTrailLocal({}, { hooks: true }).merged; + assert.match(withHooks.statusLine.command, /\.trail\/statusline\.cjs/); + assert.equal(withHooks.hooks.SessionStart, undefined, "the shared file runs it, once"); + for (const e of ["UserPromptSubmit", "Stop", "SessionEnd", "PostToolUse"]) assert.ok(withHooks.hooks[e], e); + assert.deepEqual(mergeTrailLocal(withHooks, { hooks: true }).merged, withHooks, "a second run changes nothing"); + + const withoutHooks = mergeTrailLocal(withHooks, { hooks: false }).merged; + assert.equal(withoutHooks.hooks, undefined, "~/.claude carries them now"); + assert.ok(withoutHooks.permissions.allow.includes("Bash(trail:*)")); + + const mine = { statusLine: { type: "command", command: "my-line" } }; + const kept = mergeTrailLocal(mine); + assert.equal(kept.merged.statusLine.command, "my-line"); + assert.equal(kept.warnings.length, 1); +}); + +test("trail init leaves git status with only the shared files, and writes the skill to ~/.claude", () => { + const d = gitRepo("layout"); + const home = tmpRepo("layout-home"); + writeFileSync(join(d, ".gitignore"), "node_modules/\n"); + git(d, "add", "-A"); + git(d, "commit", "-qm", "base"); + + runInit(d, { build: false, home, brand: "trail" }); + const status = git(d, "status", "--porcelain", "--untracked-files=all").stdout.trim().split("\n").map((l) => l.slice(3)).sort(); + assert.deepEqual(status, [".claude/settings.json", ".trail/.gitignore", ".trail/hook.cjs"]); + assert.equal(readFileSync(join(d, ".gitignore"), "utf8"), "node_modules/\n", ".gitignore untouched"); + assert.equal(existsSync(join(d, ".mcp.json")), false, "no MCP server for Claude Code"); + assert.ok(existsSync(join(home, ".claude", "skills", "trail", "SKILL.md"))); + assert.equal(existsSync(join(d, ".claude", "skills")), false); + assert.equal(readFileSync(join(d, ".trail", "hook.cjs"), "utf8"), hooksShim()); +}); + +test("init over a repo set up the old way moves it: old shims, repo skill and trail's server go", () => { + const d = gitRepo("old-layout"); + const home = tmpRepo("old-layout-home"); + mkdirSync(join(d, ".claude", "helpers"), { recursive: true }); + mkdirSync(join(d, ".claude", "skills", "trail"), { recursive: true }); + writeFileSync(join(d, ".claude", "helpers", "trail-hooks.cjs"), "old"); + writeFileSync(join(d, ".claude", "helpers", "trail-statusline.cjs"), "old"); + writeFileSync(join(d, ".claude", "skills", "trail", "SKILL.md"), "old"); + writeFileSync(join(d, ".mcp.json"), JSON.stringify({ mcpServers: { trail: { command: "trail", args: ["mcp"] }, db: { command: "pg" } } })); + + runInit(d, { build: false, home, global: false }); + assert.equal(existsSync(join(d, ".claude", "helpers")), false); + assert.equal(existsSync(join(d, ".claude", "skills")), false); + assert.deepEqual(JSON.parse(readFileSync(join(d, ".mcp.json"), "utf8")), { mcpServers: { db: { command: "pg" } } }, "the team's other servers stay"); + assert.ok(existsSync(join(d, ".trail", "hook.cjs"))); +}); + +test("a personal file goes in .git/info/exclude only when nothing ignores it yet", (t) => { + // This machine's own git config may ignore it already (Claude Code adds it to the global excludes file). + const saved = { global: process.env.GIT_CONFIG_GLOBAL, xdg: process.env.XDG_CONFIG_HOME }; + const empty = tmpRepo("exclude-gitconfig"); + writeFileSync(join(empty, "config"), ""); + process.env.GIT_CONFIG_GLOBAL = join(empty, "config"); + process.env.XDG_CONFIG_HOME = empty; + t.after(() => { + for (const [k, v] of [["GIT_CONFIG_GLOBAL", saved.global], ["XDG_CONFIG_HOME", saved.xdg]] as const) + if (v === undefined) delete process.env[k]; else process.env[k] = v; + }); + const d = gitRepo("exclude"); + keepOutOfGit(d, join(".claude", "settings.local.json")); + const exclude = readFileSync(join(d, ".git", "info", "exclude"), "utf8"); + assert.match(exclude, /^\/\.claude\/settings\.local\.json$/m); + keepOutOfGit(d, join(".claude", "settings.local.json")); + assert.equal(readFileSync(join(d, ".git", "info", "exclude"), "utf8"), exclude, "once"); + + const ignored = gitRepo("exclude-ignored"); + writeFileSync(join(ignored, ".gitignore"), ".claude/settings.local.json\n"); + const before = readFileSync(join(ignored, ".git", "info", "exclude"), "utf8"); + keepOutOfGit(ignored, join(".claude", "settings.local.json")); + assert.equal(readFileSync(join(ignored, ".git", "info", "exclude"), "utf8"), before, "already ignored: nothing added"); + + keepOutOfGit(tmpRepo("not-git"), join(".claude", "settings.local.json")); // no checkout: no throw +}); + +test("the shared hook stands down when ~/.claude answers the event, like the old repo copy", () => { + const home = tmpRepo("layers-home"); + const helpers = join(home, ".claude", "helpers"); + mkdirSync(helpers, { recursive: true }); + const globalShim = join(helpers, "graft-hooks.cjs"); + writeFileSync(globalShim, hooksShim()); + const cmd = `node "${globalShim.replace(/\\/g, "/")}" session-start`; + writeFileSync(join(home, ".claude", "settings.json"), JSON.stringify({ hooks: { SessionStart: [{ hooks: [{ type: "command", command: cmd }] }] } })); + assert.equal(globalCopyCovers("session-start", join(tmpRepo("layers-repo"), ".trail", "hook.cjs"), home), true); + assert.equal(globalCopyCovers("prompt", join(tmpRepo("layers-repo2"), ".trail", "hook.cjs"), home), false); +}); + +test("the first reply asks for the shared setup until it's committed", () => { + const d = gitRepo("setup-nudge"); + const home = tmpRepo("setup-nudge-home"); + writeFileSync(join(d, "README.md"), "x\n"); + git(d, "add", "-A"); + git(d, "commit", "-qm", "base"); + assert.equal(setupUncommitted(d), false, "no trail here"); + + runInit(d, { build: false, home, global: false, brand: "trail" }); + assert.equal(setupUncommitted(d), true, "the hook isn't tracked"); + git(d, "add", ".trail/hook.cjs"); + git(d, "commit", "-qm", "hook only"); + assert.equal(setupUncommitted(d), true, "HEAD's settings don't run it"); + git(d, "add", "-A"); + git(d, "commit", "-qm", "settings"); + assert.equal(setupUncommitted(d), false); + + assert.equal( + setupCommitLine(["Anay Shah", "Rushil"]), + "🌱 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", + ); +}); + +test("an untouched old README is brought up to date, an edited one is left alone", () => { + const d = tmpRepo("readme"); + mkdirSync(join(d, ".trail"), { recursive: true }); + const old = "# .trail\n\nWhat coding sessions on this repo worked out, kept next to the code so the\nnext session, yours or a teammate's, starts from it instead of digging again.\n\n- learnings/ one file per session that took real digging: what was decided,\n what was tried and ruled out, and what to watch out for, plus\n what it took to work out\n\nLearnings are short summaries, never transcripts. Commit them with the change\nthey came from. Claude Code reads them through the trail skill\n(`.claude/skills/trail/`); without trail installed they are plain markdown.\n"; + writeFileSync(join(d, ".trail", "README.md"), old); + ensureRepoTrail(d); + assert.equal(readFileSync(join(d, ".trail", "README.md"), "utf8"), REPO_TRAIL_README); + + writeFileSync(join(d, ".trail", "README.md"), "# ours\n"); + ensureRepoTrail(d); + assert.equal(readFileSync(join(d, ".trail", "README.md"), "utf8"), "# ours\n"); +}); diff --git a/test/trail-notes.test.ts b/test/trail-notes.test.ts index 059c716a..9ced3a1f 100644 --- a/test/trail-notes.test.ts +++ b/test/trail-notes.test.ts @@ -241,7 +241,7 @@ test("trail init makes the repo's .trail/learnings/ and says to commit it; graft const t = run("trail", ["init", d, "--agents", "claude", "--yes", "--no-global"], home); assert.equal(t.status, 0, t.err); assert.match(t.err, /✓ learnings {2}\.trail\/learnings\/ · sessions that take real digging leave one there, for your whole team/); - assert.match(t.err, /· commit .*\.trail\/ to share it/); + assert.match(t.err, /· commit \.claude\/settings\.json and \.trail\/ once — after that, teammates' Claude reads your learnings even before they install trail/); assert.ok(existsSync(join(d, ".trail", "learnings"))); assert.match(readFileSync(join(d, ".trail", "README.md"), "utf8"), /^# \.trail/); assert.equal(readFileSync(join(d, "CLAUDE.md"), "utf8"), "# extract\n\nTeam rules.\n", "the team's CLAUDE.md is untouched"); @@ -421,25 +421,37 @@ test("trail init moves a graft-wired repo to trail's names, and uninstall takes const t = run("trail", ["init", d, "--agents", "claude", "--yes", "--no-global"], home); assert.equal(t.status, 0, t.err); - for (const f of ["helpers/trail-hooks.cjs", "helpers/trail-statusline.cjs", "skills/trail/SKILL.md"]) assert.ok(existsSync(join(d, ".claude", f)), f); - for (const f of ["helpers/graft-hooks.cjs", "helpers/graft-statusline.cjs", "skills/graft"]) assert.equal(existsSync(join(d, ".claude", f)), false, f); - const settings = readFileSync(join(d, ".claude", "settings.json"), "utf8"); - assert.match(settings, /trail-hooks\.cjs\\" session-start/); - assert.doesNotMatch(settings, /graft-hooks\.cjs/); - assert.match(settings, /trail-statusline\.cjs/); - const mcp = JSON.parse(readFileSync(join(d, ".mcp.json"), "utf8")); - assert.deepEqual(Object.keys(mcp.mcpServers), ["trail"]); - assert.deepEqual(mcp.mcpServers.trail, { command: "npx", args: ["-y", "@trailhq/trail-code", "mcp"] }); + // Shared: the hook in .trail/ and one SessionStart entry. The old layout is gone. + for (const f of [".trail/hook.cjs", ".trail/statusline.cjs"]) assert.ok(existsSync(join(d, f)), f); + for (const f of ["helpers", "skills/graft", "skills/trail"]) assert.equal(existsSync(join(d, ".claude", f)), false, f); + const settings = JSON.parse(readFileSync(join(d, ".claude", "settings.json"), "utf8")); + assert.deepEqual(Object.keys(settings.hooks), ["SessionStart"]); + assert.match(JSON.stringify(settings.hooks.SessionStart), /\/\.trail\/hook\.cjs\\" session-start/); + for (const k of ["statusLine", "permissions", "footerLinksRegexes"]) assert.equal(settings[k], undefined, k); + // Personal: status line, allowlist, and under --no-global every other hook, never session start. + const local = JSON.parse(readFileSync(join(d, ".claude", "settings.local.json"), "utf8")); + assert.match(local.statusLine.command, /\.trail\/statusline\.cjs/); + assert.ok(local.permissions.allow.includes("Bash(trail:*)")); + assert.equal(local.hooks.SessionStart, undefined); + assert.match(JSON.stringify(local.hooks.UserPromptSubmit), /\.trail\/hook\.cjs\\" prompt/); + assert.match(JSON.stringify(local.hooks.SessionEnd), /\.trail\/hook\.cjs\\" session-end/); + // No MCP server for Claude Code: graft's .mcp.json had only graft's, so it goes. + assert.equal(existsSync(join(d, ".mcp.json")), false); + // Nothing personal shows in git, and .gitignore is untouched by the setup. + assert.match(readFileSync(join(d, ".trail", ".gitignore"), "utf8"), /^statusline\.cjs$/m); + for (const f of [".claude/settings.local.json", ".trail/statusline.cjs"]) assert.equal(spawnSync("git", ["check-ignore", "-q", f], { cwd: d }).status, 0, f); // Running it again changes nothing. + const before = [".claude/settings.json", ".claude/settings.local.json"].map((f) => readFileSync(join(d, f), "utf8")); const again = run("trail", ["init", d, "--agents", "claude", "--yes", "--no-global"], home); assert.equal(again.status, 0, again.err); - assert.deepEqual(Object.keys(JSON.parse(readFileSync(join(d, ".mcp.json"), "utf8")).mcpServers), ["trail"]); + assert.deepEqual([".claude/settings.json", ".claude/settings.local.json"].map((f) => readFileSync(join(d, f), "utf8")), before); const un = run("trail", ["uninstall", d, "-y", "--no-global"], home); assert.equal(un.status, 0, un.err); - for (const f of ["helpers/trail-hooks.cjs", "skills/trail/SKILL.md"]) assert.equal(existsSync(join(d, ".claude", f)), false, f); - assert.equal(existsSync(join(d, ".mcp.json")), false); + for (const f of [".trail/hook.cjs", ".trail/statusline.cjs"]) assert.equal(existsSync(join(d, f)), false, f); + // Both held only what init wrote, so they go with it. + for (const f of ["settings.json", "settings.local.json"]) assert.equal(existsSync(join(d, ".claude", f)), false, f); } finally { delete process.env.GRAFT_MCP_NPX; } From ff124cf8af47671416bddf6ea35b4185504a6983 Mon Sep 17 00:00:00 2001 From: anirudhkumar-nanonets Date: Thu, 15 Oct 2026 18:38:32 +0530 Subject: [PATCH 2/2] feat(trail): keep the code map in .trail/cache/ under trail --- src/ask/ask.ts | 2 +- src/claude/format.ts | 4 +- src/claude/hooks.ts | 8 ++- src/claude/init.ts | 12 +--- src/claude/skill-template.ts | 10 +++- src/cli.ts | 6 +- src/context/map-dir.ts | 65 ++++++++++++++++++++ src/context/node-file.ts | 18 ++++-- src/hosts/retract.ts | 2 + src/notes/repo-trail.ts | 12 +++- src/util/state.ts | 3 +- test/claude-init-workspace.test.ts | 2 +- test/hosts-trail.test.ts | 9 +-- test/trail-map-dir.test.ts | 95 ++++++++++++++++++++++++++++++ test/trail-notes.test.ts | 4 +- 15 files changed, 217 insertions(+), 35 deletions(-) create mode 100644 src/context/map-dir.ts create mode 100644 test/trail-map-dir.test.ts diff --git a/src/ask/ask.ts b/src/ask/ask.ts index 689ec170..655fe69f 100644 --- a/src/ask/ask.ts +++ b/src/ask/ask.ts @@ -1201,7 +1201,7 @@ function lexical( // so a query that missed everywhere still tells the caller where to look. note: scored.length ? undefined - : `no matching nodes — try different words, or \`${cmd("graft build")}\` if graft/ is empty${scopesHereClause(scopes ?? [])}`, + : `no matching nodes — try different words, or \`${cmd("graft build")}\` if the code map is empty${scopesHereClause(scopes ?? [])}`, }; } diff --git a/src/claude/format.ts b/src/claude/format.ts index c23200f6..211262d6 100644 --- a/src/claude/format.ts +++ b/src/claude/format.ts @@ -242,7 +242,7 @@ export function relevantRetrieval(ask: AskJson, s: SessionState, cap = 3): strin return txt; } -export function formatOrientation(indexMd: string, budgetBytes = 1500, staleNote?: string, notes = 0, keepsNotes = notes > 0, skills = ''): string { +export function formatOrientation(indexMd: string, budgetBytes = 1500, staleNote?: string, notes = 0, keepsNotes = notes > 0, skills = '', mapDir = 'graft'): string { // Always-on directive (cached, seen turn 0) so the agent reaches for graft's // commands without waiting for the discretionary skill to load. This is the // reliable steering channel (fires every session, unlike the discretionary @@ -265,7 +265,7 @@ export function formatOrientation(indexMd: string, budgetBytes = 1500, staleNote teamNotesDirective(notes, keepsNotes) + skills; const banner = staleNote ? `${staleNote}\n\n` : ""; - return `${banner}${directive}\nrepo map (graft/INDEX.md):\n${indexMd.slice(0, budgetBytes)}`; + return `${banner}${directive}\nrepo map (${mapDir}/INDEX.md):\n${indexMd.slice(0, budgetBytes)}`; } /** diff --git a/src/claude/hooks.ts b/src/claude/hooks.ts index 1a5d5fc3..fcead00c 100644 --- a/src/claude/hooks.ts +++ b/src/claude/hooks.ts @@ -1,6 +1,6 @@ import { readFileSync, existsSync } from 'node:fs'; import { execFileSync, spawn } from 'node:child_process'; -import { join, basename, isAbsolute, resolve } from 'node:path'; +import { join, basename, isAbsolute, relative, resolve } from 'node:path'; import { homedir } from 'node:os'; import { readWiring } from './stats.js'; import { formatBlastRadius, relevantRetrieval, formatOrientation, notesForPrompt, standingDirective } from './format.js'; @@ -47,7 +47,8 @@ function projectDir(input: any): string { } export function underGraft(dir: string, file: string): boolean { const rel = file.startsWith(dir) ? file.slice(dir.length) : file; - return rel.replace(/^[/\\]+/, '').replace(/\\/g, '/').startsWith('graft/'); + const posix = rel.replace(/^[/\\]+/, '').replace(/\\/g, '/'); + return posix.startsWith('graft/') || posix.startsWith('.trail/cache/'); } /** Default budget for a graft child process invoked from a hook, matching the 8s * the installed hook entries carry. */ @@ -677,7 +678,8 @@ export async function main(event: string): Promise { const skills = kept ? skillsDirective(dir, cmd('graft skills show')) : ''; const underTrail = kept && brand() === 'trail'; const standing = underTrail ? standingDirective(standingLearnings(dir)) + uncommittedDirective(dir) : ''; - const orientation = formatOrientation(idx, undefined, banner, noteCount(dir), kept, skills + standing); + const mapDir = relative(dir, resolveContextDir(dir)).split('\\').join('/') || 'graft'; + const orientation = formatOrientation(idx, undefined, banner, noteCount(dir), kept, skills + standing, mapDir); emit('SessionStart', upkeep.length ? `${upkeep.join('\n')}\n\n${orientation}` : orientation); } catch { // No INDEX.md (never built here). An upgrade nudge is still worth saying. diff --git a/src/claude/init.ts b/src/claude/init.ts index 365f5fab..e54df085 100644 --- a/src/claude/init.ts +++ b/src/claude/init.ts @@ -8,6 +8,8 @@ import { statuslineShim, hooksShim, rememberInstall } from './shim-template.js'; import { skillTemplate } from './skill-template.js'; import { mergeJsonKey, serverEntry, type McpWrite } from '../hosts/mcp-config.js'; import { readJsonObject } from '../hosts/config-write.js'; +import { ignoreInTrailDir } from '../notes/repo-trail.js'; +export { ignoreInTrailDir }; import { hasGraftIndex } from '../graph/root.js'; import type { PlannedWrite } from '../hosts/plan.js'; import { repoWiredForTrail, type Brand } from '../brand.js'; @@ -246,16 +248,6 @@ function dropServer(path: string, names: string[]): void { } catch { /* leave it as the user has it */ } } -/** One line in `.trail/.gitignore`, added once. */ -export function ignoreInTrailDir(dir: string, entry: string): void { - const path = join(dir, '.trail', '.gitignore'); - let text = ''; - try { text = readFileSync(path, 'utf8'); } catch { /* new */ } - if (text.split('\n').some((l) => l.trim() === entry)) return; - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, `${text}${text && !text.endsWith('\n') ? '\n' : ''}${entry}\n`); -} - /** * Keep a personal file out of git without touching the repo's `.gitignore`: * if nothing ignores it already, it goes in `.git/info/exclude`, which is diff --git a/src/claude/skill-template.ts b/src/claude/skill-template.ts index e663cc95..7e04885c 100644 --- a/src/claude/skill-template.ts +++ b/src/claude/skill-template.ts @@ -68,12 +68,16 @@ about trail. /** * Agent-facing graft text in trail's spelling, for every file trail writes * for an agent: the commands as trail spells them (`graft check` became - * `trail build --check`, see `cmd`), and the name in prose. The code map - * still lives in `graft/`, so a `graft` followed by `/` is a path and stays, - * as do `graft-…` and `graft_…` names. + * `trail build --check`, see `cmd`), and the name in prose. Under trail the + * code map is in `.trail/cache/` (context/map-dir.ts), so the folder's name + * changes too; any other `graft` followed by `/` stays, as do `graft-…` and + * `graft_…` names. */ export function trailSpelling(text: string): string { return text + .replace(/`graft\/`/g, "`.trail/cache/`") + .replace(/(? cmd(m, "trail")) .replace(/\bGraft\b(?![/\w-])/g, "Trail") .replace(/\bgraft\b(?![/\w-])/g, "trail"); diff --git a/src/cli.ts b/src/cli.ts index 5f09f6c0..6db77e6a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -210,7 +210,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(`${tag()} no graft/ here — answering from ${root}/graft`); + if (levels > 0) console.error(`${tag()} no code map here — answering from ${contextDirFor(root, program.opts().dir)}`); return root; } @@ -434,7 +434,7 @@ function moveHereToTrail(dir: string): void { grouped(program.command("build"), GROUP.setup) .description( TRAIL - ? "Rebuild the code map in graft/ ($0, no key). --check fails if it's stale; --deep adds LLM summaries." + ? "Rebuild the code map in .trail/cache/ ($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.", ) @@ -858,7 +858,7 @@ async function runCheckCommand( if (opts.json) { console.log(JSON.stringify({ context: r, graph: g.missing ? null : g }, null, 2)); } else if (bothMissing) { - console.log(`${label}: NO GRAPH\n\nNo graft/ graph found. Run \`${cmd("graft build")}\` first.`); + console.log(`${label}: NO GRAPH\n\nNo code map found. Run \`${cmd("graft build")}\` first.`); } else { if (r.missing) { console.log( diff --git a/src/context/map-dir.ts b/src/context/map-dir.ts new file mode 100644 index 00000000..cee7f125 --- /dev/null +++ b/src/context/map-dir.ts @@ -0,0 +1,65 @@ +/** + * Where a repo's code map lives. + * + * In a repo on trail, `.trail/cache/`: inside the one folder a team commits, + * and kept out of git by `.trail/.gitignore`, so neither the repo's own + * `.gitignore` nor a root `.ignore` has to name it. Everywhere else `graft/`, + * as before. Being a hidden folder, `.trail/cache/` is skipped by a grep from + * the repo root; the agent reaches it through `trail ask` and the path the + * session-start hook names, and a grep given that path searches it. + * + * A map still in `graft/` from before the repo moved to trail is renamed into + * place the first time anyone asks where the map is, so a session never + * rebuilds what's already on disk. + */ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, renameSync } from "node:fs"; +import { join } from "node:path"; +import { repoWiredForTrail } from "../brand.js"; +import { ignoreInTrailDir } from "../notes/repo-trail.js"; + +/** The map's folder in a repo on trail, relative to the repo. */ +export const TRAIL_MAP_DIR = join(".trail", "cache"); +/** The map's folder everywhere else. */ +export const GRAFT_MAP_DIR = "graft"; + +/** + * Absolute path of `root`'s code map folder. A repo is on trail once its + * hooks say so or it has `.trail/learnings/`, which `trail init` makes before + * anything else, so nothing it writes lands in `graft/` first. + */ +export function mapDirFor(root: string): string { + const trail = join(root, TRAIL_MAP_DIR); + if (existsSync(trail)) return trail; + if (!existsSync(join(root, ".trail", "learnings")) && !repoWiredForTrail(root)) return join(root, GRAFT_MAP_DIR); + moveOldMap(root, trail); + return trail; +} + +/** Whether `dir` is a repo's trail map folder, which `.trail/.gitignore` keeps out of git. */ +export function isTrailMapDir(root: string, dir: string): boolean { + return join(root, TRAIL_MAP_DIR) === join(dir); +} + +/** + * `graft/` holding a map from before the repo moved to trail: renamed to + * `.trail/cache/`. Left alone when git tracks it (a map committed back when + * it was shared), since moving it would delete committed files. + */ +function moveOldMap(root: string, to: string): void { + const from = join(root, GRAFT_MAP_DIR); + if (!existsSync(join(from, "INDEX.md")) && !existsSync(join(from, ".graph"))) return; + try { + execFileSync("git", ["ls-files", "--error-unmatch", "--", "graft/INDEX.md"], { cwd: root, stdio: "ignore", timeout: 3000 }); + return; + } catch { + // untracked, or not a git checkout: ours to move + } + try { + mkdirSync(join(root, ".trail"), { recursive: true }); + ignoreInTrailDir(root, "cache/"); + renameSync(from, to); + } catch { + // Another process moved it first, or it can't move: the next build makes a new one. + } +} diff --git a/src/context/node-file.ts b/src/context/node-file.ts index dcfbee54..53183752 100644 --- a/src/context/node-file.ts +++ b/src/context/node-file.ts @@ -28,6 +28,8 @@ import { contentHash, normalizeName } from "../util/id.js"; import { relPosix, stripTrailingSlashes } from "../util/paths.js"; // Value-only import of a constant; `write.ts` pulls in nothing from here, so no cycle. import { GRAPH_DIR } from "../graph/write.js"; +import { mapDirFor, isTrailMapDir } from "./map-dir.js"; +import { ignoreInTrailDir } from "../notes/repo-trail.js"; /** A source file a node was derived from, with its content hash at generation time. */ export interface SourceRef { @@ -103,12 +105,13 @@ export function digestSources(sources: SourceRef[]): string { return contentHash(lines); } -/** Absolute path of the `graft/` directory for a repo root. Visible (not - * dot-prefixed) on purpose: default ripgrep skips hidden dirs, so the agent's - * grep/ls/find reflex must be able to land on the graph. */ +/** Absolute path of a repo's code map: `graft/`, or `.trail/cache/` in a repo + * on trail (see context/map-dir.ts). An explicit `--dir` wins. `graft/` is + * visible (not dot-prefixed) on purpose: default ripgrep skips hidden dirs, so + * a grep can land on its cards; under trail the hooks name the path instead. */ export function contextDirFor(root: string, override?: string): string { if (override) return override; - return join(root, "graft"); + return mapDirFor(root); } /** @@ -128,6 +131,11 @@ export const LINK_NOTE = "graft's brain link — holds a read token, never commi export function ensureGitignored(root: string, contextDir: string, note = GRAPH_CACHE_NOTE): void { if (envTruthy("GRAFT_NO_GITIGNORE")) return; + // trail's map: `.trail/.gitignore` keeps it out, and the repo's own file is never touched. + if (isTrailMapDir(root, contextDir)) { + try { ignoreInTrailDir(root, "cache/"); } catch { /* best-effort */ } + return; + } const rel = relPosix(root, contextDir); if (rel === "" || rel.startsWith("..")) return; // dir is at/above the repo root — nothing sane to ignore const bare = stripTrailingSlashes(rel); // "graft" (or a `--dir` subpath like "tools/ctx") @@ -173,6 +181,8 @@ export function ensureGitignored(root: string, contextDir: string, note = GRAPH_ */ export function ensureSearchable(root: string, contextDir: string): void { if (envTruthy("GRAFT_NO_IGNORE")) return; + // trail's map is reached by the path the hooks name, not by a grep from the root: no `.ignore`. + if (isTrailMapDir(root, contextDir)) return; const rel = relPosix(root, contextDir); if (rel === "" || rel.startsWith("..")) return; // outside the repo — nothing to re-admit const dir = stripTrailingSlashes(rel); diff --git a/src/hosts/retract.ts b/src/hosts/retract.ts index ce5b4e53..faa073c9 100644 --- a/src/hosts/retract.ts +++ b/src/hosts/retract.ts @@ -505,10 +505,12 @@ function targets(repo: string, opts: RetractOpts): Target[] { // here can't strand the wiring half-retracted. if (opts.cache !== false) { const cache = join(repo, 'graft'); + const trailCache = join(repo, '.trail', 'cache'); const gitignore = join(repo, '.gitignore'); const ignore = join(repo, '.ignore'); for (const t of [ { hostId: 'graph', path: cache, what: 'local graph cache', scope: 'repo', run: (a) => removeDir(cache, a) }, + { hostId: 'graph', path: trailCache, what: 'local graph cache', scope: 'repo', run: (a) => removeDir(trailCache, a) }, { hostId: 'graph', path: gitignore, what: 'graft/ ignore entry', scope: 'repo', run: (a) => stripIgnoreEntries(gitignore, [/^\/?graft\/?$/], a) }, { hostId: 'graph', path: ignore, what: 'graft/ search re-admit entries', scope: 'repo', run: (a) => stripIgnoreEntries(ignore, [/^!?graft\/?$/, /^graft\/\.(cache|graph)\/?$/], a) }, ] as Target[]) add(t); diff --git a/src/notes/repo-trail.ts b/src/notes/repo-trail.ts index 49279352..781939f5 100644 --- a/src/notes/repo-trail.ts +++ b/src/notes/repo-trail.ts @@ -17,7 +17,7 @@ * background `git fetch` refreshes (branches.ts), so nothing here spawns git. */ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; /** The folder, relative to the top of the checkout. */ export const TRAIL_DIR = ".trail"; @@ -97,3 +97,13 @@ export function readLearningFile(checkout: string, name: string): string | null return null; } } + +/** One line in `.trail/.gitignore`, added once. */ +export function ignoreInTrailDir(dir: string, entry: string): void { + const path = join(dir, ".trail", ".gitignore"); + let text = ""; + try { text = readFileSync(path, "utf8"); } catch { /* new */ } + if (text.split("\n").some((l) => l.trim() === entry)) return; + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${text}${text && !text.endsWith("\n") ? "\n" : ""}${entry}\n`); +} diff --git a/src/util/state.ts b/src/util/state.ts index 0730a3a0..b17de7a9 100644 --- a/src/util/state.ts +++ b/src/util/state.ts @@ -11,6 +11,7 @@ */ import { readFileSync, writeFileSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs'; import { join, dirname, isAbsolute } from 'node:path'; +import { mapDirFor } from '../context/map-dir.js'; export interface Stats { nodeCount: number; edgeCount: number; languages: string[]; @@ -42,7 +43,7 @@ const LOCK_FILE = '.sync.lock'; */ export function resolveContextDir(projectDir: string): string { const override = process.env.GRAFT_DIR; - if (!override) return join(projectDir, 'graft'); + if (!override) return mapDirFor(projectDir); return isAbsolute(override) ? override : join(projectDir, override); } diff --git a/test/claude-init-workspace.test.ts b/test/claude-init-workspace.test.ts index 2c0ebe57..04c6f2fd 100644 --- a/test/claude-init-workspace.test.ts +++ b/test/claude-init-workspace.test.ts @@ -96,7 +96,7 @@ test("a query from a subdirectory answers from the repo's graph one level up", ( const r = cliIn(join(child, "src"), ["map"], home); assert.equal(r.status, 0, r.stderr); - assert.match(r.stderr, /no graft\/ here — answering from/, "the walk must announce itself"); + assert.match(r.stderr, /no code map here — answering from/, "the walk must announce itself"); assert.match(r.stdout + r.stderr, /main\.ts/, "must actually answer from the child's graph"); }); diff --git a/test/hosts-trail.test.ts b/test/hosts-trail.test.ts index af2b5e45..622d957f 100644 --- a/test/hosts-trail.test.ts +++ b/test/hosts-trail.test.ts @@ -58,11 +58,12 @@ function agentHome(): string { return home; } -test('trail spelling: renamed commands, the name in prose, the code map folder kept', () => { +test('trail spelling: renamed commands, the name in prose, the code map folder moved', () => { assert.equal(trailSpelling('run `graft check` in CI'), 'run `trail build --check` in CI'); assert.equal(trailSpelling('`graft stats --json`'), '`trail status --json`'); assert.equal(trailSpelling('`graft checkout x` and `graft ask "q"`'), '`trail checkout x` and `trail ask "q"`'); - assert.equal(trailSpelling('## Graft — see graft/INDEX.md, graft-hooks.cjs, graft_find_code'), '## Trail — see graft/INDEX.md, graft-hooks.cjs, graft_find_code'); + assert.equal(trailSpelling('## Graft — see graft/INDEX.md, graft-hooks.cjs, graft_find_code'), '## Trail — see .trail/cache/INDEX.md, graft-hooks.cjs, graft_find_code'); + assert.equal(trailSpelling('`graft/` holds it, unlike .claude/skills/graft/ or @nanonets/graft/x'), '`.trail/cache/` holds it, unlike .claude/skills/graft/ or @nanonets/graft/x'); }); test('the instruction text under trail names trail commands only; under graft it is what it was', () => { @@ -71,9 +72,9 @@ test('the instruction text under trail names trail commands only; under graft it assert.match(trail, /`trail ask "" --source`/); assert.match(trail, /`trail grep ""`/); assert.match(trail, /`trail callers `/); - assert.match(trail, /`graft\/INDEX\.md`/, 'the code map still lives in graft/'); + assert.match(trail, /`\.trail\/cache\/INDEX\.md`/, 'under trail the code map is in .trail/cache/'); assert.doesNotMatch(trail, GRAFT_COMMAND); - assert.match(cursorRule('trail'), /^---\ndescription: Use the Trail context graph in graft\/ before exploring source\nalwaysApply: true\n---\n## Trail/); + assert.match(cursorRule('trail'), /^---\ndescription: Use the Trail context graph in \.trail\/cache\/ before exploring source\nalwaysApply: true\n---\n## Trail/); assert.equal(instructionBody('graft'), instructionBody()); assert.match(instructionBody(), /^## Graft — repo context graph/); }); diff --git a/test/trail-map-dir.test.ts b/test/trail-map-dir.test.ts new file mode 100644 index 00000000..a63610ea --- /dev/null +++ b/test/trail-map-dir.test.ts @@ -0,0 +1,95 @@ +/** + * Where the code map lives: `graft/` as before, `.trail/cache/` in a repo on + * trail, kept out of git by `.trail/.gitignore` so the repo's own ignore files + * are never touched. A map already in `graft/` moves the first time it's asked for. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; +import { tmpRepo } from "./helpers.js"; +import { mapDirFor } from "../src/context/map-dir.js"; +import { contextDirFor, ensureGitignored, ensureSearchable } from "../src/context/node-file.js"; +import { underGraft } from "../src/claude/hooks.js"; + +const git = (cwd: string, ...args: string[]) => + spawnSync("git", ["-c", "commit.gpgsign=false", "-c", "user.name=T", "-c", "user.email=t@t.test", ...args], { cwd, encoding: "utf8" }); + +/** A git repo, wired for trail when `trail` is set (its committed hook is what says so). */ +function repo(tag: string, trail: boolean): string { + const d = tmpRepo(tag); + git(d, "init", "-q"); + if (trail) { + mkdirSync(join(d, ".trail"), { recursive: true }); + writeFileSync(join(d, ".trail", "hook.cjs"), "// hook\n"); + } + return d; +} + +/** An old map in `graft/`: an index, a card and the cache beside them. */ +function oldMap(d: string): void { + mkdirSync(join(d, "graft", ".cache"), { recursive: true }); + writeFileSync(join(d, "graft", "INDEX.md"), "# map\n"); + writeFileSync(join(d, "graft", "node.md"), "card\n"); + writeFileSync(join(d, "graft", ".cache", "wiring-stamp.json"), "{}"); +} + +test("the map is in graft/ for a repo not on trail, and in .trail/cache/ for one that is", () => { + const plain = repo("map-plain", false); + assert.equal(mapDirFor(plain), join(plain, "graft")); + assert.equal(contextDirFor(plain), join(plain, "graft")); + assert.equal(contextDirFor(plain, "/elsewhere"), "/elsewhere", "an explicit --dir wins"); + + const onTrail = repo("map-trail", true); + assert.equal(mapDirFor(onTrail), join(onTrail, ".trail", "cache")); +}); + +test("a map already in graft/ moves into .trail/cache/ once the repo is on trail, cache and all", () => { + const d = repo("map-move", true); + oldMap(d); + const dir = mapDirFor(d); + assert.equal(dir, join(d, ".trail", "cache")); + assert.equal(existsSync(join(d, "graft")), false); + assert.equal(readFileSync(join(dir, "node.md"), "utf8"), "card\n"); + assert.ok(existsSync(join(dir, ".cache", "wiring-stamp.json")), "the stamp and caches come along, so nothing is redone"); + assert.match(readFileSync(join(d, ".trail", ".gitignore"), "utf8"), /^cache\/$/m); + assert.equal(git(d, "status", "--porcelain", "--untracked-files=all", "--", ".trail/cache").stdout, "", "git doesn't see it"); + assert.equal(mapDirFor(d), dir, "asked again: same place, nothing to move"); +}); + +test("a graft/ map that git tracks stays where it is", () => { + const d = repo("map-tracked", true); + oldMap(d); + git(d, "add", "graft/INDEX.md"); + git(d, "commit", "-qm", "map committed back when it was shared"); + mapDirFor(d); + assert.ok(existsSync(join(d, "graft", "INDEX.md")), "committed files aren't deleted by a move"); +}); + +test("building the trail map writes .trail/.gitignore, never the repo's .gitignore or a root .ignore", () => { + const d = repo("map-ignore", true); + writeFileSync(join(d, ".gitignore"), "node_modules/\n"); + const dir = mapDirFor(d); + ensureGitignored(d, dir); + ensureSearchable(d, dir); + assert.equal(readFileSync(join(d, ".gitignore"), "utf8"), "node_modules/\n"); + assert.equal(existsSync(join(d, ".ignore")), false); + assert.match(readFileSync(join(d, ".trail", ".gitignore"), "utf8"), /^cache\/$/m); + + const plain = repo("map-ignore-plain", false); + ensureGitignored(plain, mapDirFor(plain)); + assert.match(readFileSync(join(plain, ".gitignore"), "utf8"), /^\/graft\/$/m, "graft's map is ignored as before"); +}); + +test("an edit inside the map is the map's own, not the code's", () => { + assert.equal(underGraft("/r", "/r/.trail/cache/node.md"), true); + assert.equal(underGraft("/r", "/r/graft/node.md"), true); + assert.equal(underGraft("/r", "/r/.trail/learnings/x.md"), false); +}); + +test("a repo with .trail/learnings/ is on trail for the map, even before its hook is written", () => { + const d = repo("map-learnings", false); + mkdirSync(join(d, ".trail", "learnings"), { recursive: true }); + assert.equal(mapDirFor(d), join(d, ".trail", "cache")); +}); diff --git a/test/trail-notes.test.ts b/test/trail-notes.test.ts index 9ced3a1f..878e64f2 100644 --- a/test/trail-notes.test.ts +++ b/test/trail-notes.test.ts @@ -328,7 +328,7 @@ test("trail note without a body says how to write one", () => { // --- what agents read in a repo that uses trail --- -test("the skill speaks trail in a repo wired for it, keeps graft/ paths, and teaches learnings", async () => { +test("the skill speaks trail in a repo wired for it, names the map in .trail/cache/, and teaches learnings", async () => { const { skillTemplate } = await import("../src/claude/skill-template.js"); const graft = skillTemplate("graft"); const trail = skillTemplate("trail"); @@ -337,7 +337,7 @@ test("the skill speaks trail in a repo wired for it, keeps graft/ paths, and tea assert.match(trail, /^name: trail$/m, "it lives in .claude/skills/trail/"); assert.match(trail, /`trail ask "" --source`/); assert.match(trail, /`trail build` \/ `trail build --check`/); - assert.match(trail, /`graft\/` holds a graph/, "the code map folder is still graft/"); + assert.match(trail, /`\.trail\/cache\/` holds a graph/, "under trail the code map is in .trail/cache/"); assert.doesNotMatch(trail, /\bgraft (ask|grep|skeleton|callers|map|build|check)\b/); assert.match(trail, /trail note --title/); assert.match(trail, /It's written to `\.trail\/learnings\/`\. Commit it\n with the change it came from/);