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/);