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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/ask/ask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? [])}`,
};
}

Expand Down
4 changes: 2 additions & 2 deletions src/claude/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)}`;
}

/**
Expand Down
8 changes: 5 additions & 3 deletions src/claude/hooks.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -677,7 +678,8 @@ export async function main(event: string): Promise<void> {
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.
Expand Down
12 changes: 2 additions & 10 deletions src/claude/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions src/claude/skill-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/(?<![\w./-])graft\/INDEX\.md/g, ".trail/cache/INDEX.md")
.replace(/(?<![\w./-])graft\/(?=[\s.,)])/g, ".trail/cache/")
.replace(/\bgraft (check|stats)\b(?![\w-])/g, (m) => cmd(m, "trail"))
.replace(/\bGraft\b(?![/\w-])/g, "Trail")
.replace(/\bgraft\b(?![/\w-])/g, "trail");
Expand Down
6 changes: 3 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GlobalOpts>().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<GlobalOpts>().dir)}`);
return root;
}

Expand Down Expand Up @@ -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.",
)
Expand Down Expand Up @@ -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(
Expand Down
65 changes: 65 additions & 0 deletions src/context/map-dir.ts
Original file line number Diff line number Diff line change
@@ -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.
}
}
18 changes: 14 additions & 4 deletions src/context/node-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}

/**
Expand All @@ -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")
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/hosts/retract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 11 additions & 1 deletion src/notes/repo-trail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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`);
}
3 changes: 2 additions & 1 deletion src/util/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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);
}

Expand Down
2 changes: 1 addition & 1 deletion test/claude-init-workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});

Expand Down
9 changes: 5 additions & 4 deletions test/hosts-trail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -71,9 +72,9 @@ test('the instruction text under trail names trail commands only; under graft it
assert.match(trail, /`trail ask "<your question>" --source`/);
assert.match(trail, /`trail grep "<literal>"`/);
assert.match(trail, /`trail callers <symbol>`/);
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/);
});
Expand Down
Loading
Loading