From 2f1aa66ade790e1f467be67b912f76326b0684db Mon Sep 17 00:00:00 2001 From: anirudhkumar-nanonets Date: Wed, 7 Oct 2026 11:55:20 +0530 Subject: [PATCH 1/5] feat(notes): trail note saves session notes to .trail/, and ask shows them --- TELEMETRY.md | 3 +- src/ask/ask.ts | 31 ++- src/claude/settings-merge.ts | 3 +- src/cli.ts | 146 ++++++++++++- src/engine.ts | 4 +- src/hosts/retract.ts | 6 +- src/hosts/sections.ts | 11 +- src/notes/ask-notes.ts | 21 ++ src/notes/git-facts.ts | 66 ++++++ src/notes/instructions.ts | 54 +++++ src/notes/notes.ts | 328 +++++++++++++++++++++++++++++ src/notes/session-cost.ts | 143 +++++++++++++ src/telemetry/contract.ts | 7 +- test/claude-init.test.ts | 6 +- test/claude-settings-merge.test.ts | 14 +- test/telemetry-contract.test.ts | 4 +- test/trail-notes.test.ts | 268 +++++++++++++++++++++++ 17 files changed, 1082 insertions(+), 33 deletions(-) create mode 100644 src/notes/ask-notes.ts create mode 100644 src/notes/git-facts.ts create mode 100644 src/notes/instructions.ts create mode 100644 src/notes/notes.ts create mode 100644 src/notes/session-cost.ts create mode 100644 test/trail-notes.test.ts diff --git a/TELEMETRY.md b/TELEMETRY.md index bb24aeb3a..6c676ba6d 100644 --- a/TELEMETRY.md +++ b/TELEMETRY.md @@ -46,12 +46,13 @@ The events: | `init_completed` | `agents` (the ids you selected, sorted), `consent` | `graft init` finishes | | `build_completed` | `files_bucket`, `langs`, `mode` (`fast`/`deep`), `duration_bucket`, `incremental` | A build succeeds | | `build_failed` | `stage`, `code` — both fixed enums | A build throws | -| `query` | `command`, `surface` (`cli`/`mcp`/`hook`), `hit` (`ask` only) | Any query command | +| `query` | `command`, `surface` (`cli`/`mcp`/`hook`), `hit` (`ask` only), `notes` (`0`/`1`/`2`, how many teammates' notes `ask` showed; only in a repo with `.trail/notes/`) | Any query command | | `brain_signup_opened` | `mode` (`terminal`/`agent`) | `graft trail push` on a repo with no brain opens your browser to make one | | `brain_signup_settled` | `outcome` (`linked`/`timed_out`/`bad_callback`/`stopped`, and for an agent-run push `agent_link_opened`/`still_waiting`/`expired`/`unsupported`), `mode` (`terminal`/`agent`), `duration_bucket` | That handoff ends, one way or the other — for an agent-run push, each of its runs | | `trail_pulled` | `outcome` (`written`/`already_present`/`nothing_accepted`/`skipped`/`error`/`dry_run`), `kinds` (which kinds of context file were written: `claude_md`, `folder_claude_md`, `agents_md`, `cursor_rule`, `skill`), `files_bucket`, `changes_bucket`, `skipped_bucket`, `suggested_bucket` (every change Trail has suggested for the wired agents' files, whatever became of it) | `graft trail pull` (or `graft claude-md pull`) finishes | | `trail_watch_exit` | `reason` (`suggestions`/`accepted`/`timeout`/`refused`/`no_trail`), `suggested_bucket`, `accepted_bucket`, `duration_bucket` (how long it waited) | `graft trail watch` ends | | `trail_autopush` | `outcome` (`started`/`skipped`), `reason` when skipped (`disabled`/`no_head`/`head_unchanged`/`throttled`/`spawn_failed`) | A Claude Code or Codex session starts in a repo with a trail attached, and the hook decides whether to refresh it with a background `graft trail push`. Repos without a trail send nothing | +| `note_saved` | `has_cost` (`true`/`false`), `touches_bucket` (how many files the note is about, bucketed) | `trail note` saves a note into `.trail/notes/`. The title, the text and the paths are never sent | | `session_summary` | `graft_reads_bucket`, `source_reads_bucket`, `saved_tokens_bucket`, `graft_turns_bucket`, `reported_turns_bucket` | Once, after an agent session ends | Two rules govern every value above, and both are enforced in code rather than by diff --git a/src/ask/ask.ts b/src/ask/ask.ts index 755d2a92d..f311ae9b5 100644 --- a/src/ask/ask.ts +++ b/src/ask/ask.ts @@ -50,7 +50,8 @@ import { readSourceFile } from "../util/source.js"; import { counts, tokenize, type AskIndex, type AskIndexDoc } from "./index-file.js"; import { rulesForPointers, formatRules, type AppliedRule } from "../brain/attach.js"; import { readLink, readRulesCache } from "../brain/link.js"; -import { tag } from "../brand.js"; +import { cmd, tag } from "../brand.js"; +import { formatNoteHits, type NoteHit } from "../notes/notes.js"; export interface AskHit { kind: "concept" | "symbol" | "caller" | "callee"; @@ -109,6 +110,12 @@ export interface AskResult { * callers must not re-sort this list by `score`. */ hits: AskHit[]; note?: string; + /** Teammates' notes from `.trail/notes/` that bear on this query, best first. + * Set by `attachNotes` (notes/ask-notes.ts), never by the ranker. */ + notes?: NoteHit[]; + /** The repo has a `.trail/notes/` folder, so an empty `notes` means "none + * about this yet" rather than "this repo keeps no notes". */ + notesChecked?: boolean; /** Token-saving estimate, set only in `--source` (retriever) mode: the whole * size of the distinct files these hits point into, i.e. the baseline cost of * reading them instead of this pack. Computed from file sizes stored at build. */ @@ -350,7 +357,7 @@ type StructuralOutcome = { result: AskResult } | { fallthroughNote: string } | n function fallthroughNoteFor(subject: string): string { return ( `structural index: no entries for '${subject}' — showing lexical matches; ` + - `for precise edges try graft callers '${subject}', or graft grep '${subject}' for every reference (loosen the pattern if it returns nothing)` + `for precise edges try ${cmd("graft callers")} '${subject}', or ${cmd("graft grep")} '${subject}' for every reference (loosen the pattern if it returns nothing)` ); } @@ -1199,7 +1206,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 \`graft build\` if graft/ is empty${scopesHereClause(scopes ?? [])}`, + : `no matching nodes — try different words, or \`${cmd("graft build")}\` if graft/ is empty${scopesHereClause(scopes ?? [])}`, }; } @@ -1439,7 +1446,7 @@ export interface SkeletonResult { export function skeleton(dir: string, file: string, opts: { contextDir?: string } = {}): SkeletonResult { const outDir = contextDirFor(resolve(dir), opts.contextDir); const graph = loadGraphCached(outDir); - if (!graph) return { file, entries: [], note: "no wiring graph — run `graft build` first" }; + if (!graph) return { file, entries: [], note: `no wiring graph — run \`${cmd("graft build")}\` first` }; let defs = graph.nodes.filter((n) => n.kind !== "file" && n.path === file); if (!defs.length) { @@ -1470,7 +1477,7 @@ export function skeleton(dir: string, file: string, opts: { contextDir?: string /** Render a {@link SkeletonResult} as compact markdown. */ export function formatSkeleton(r: SkeletonResult): string { - const head = `graft skeleton — ${r.file}`; + const head = `${cmd("graft skeleton")} — ${r.file}`; if (!r.entries.length) return `${head}\n\n${r.note ?? "no definitions."}\n`; const lines = r.entries.map((e) => { const sig = e.signature ? ` ${e.signature}` : ""; @@ -1488,7 +1495,7 @@ function toTokens(chars: number): number { /** Render an {@link AskResult} as a compact markdown context pack. */ export function formatAsk(r: AskResult): string { - const head = `graft ask — "${r.query}" (${r.mode})`; + const head = `${cmd("graft ask")} — "${r.query}" (${r.mode})`; // The note prints as its own prominent line(s) right under the header — // above every hit — so a loud structural-fallthrough note (or the // no-structural-edges / no-lexical-match note) can never be missed by only @@ -1503,10 +1510,18 @@ export function formatAsk(r: AskResult): string { .map((l) => (l.startsWith("structural index:") ? `⚠ ${l}` : l)) .join("\n") : ""; + // Teammates' notes go first: a decision someone already made, or an approach + // they already ruled out, changes how the code below should be read. + const teamNotes = r.notes?.length + ? formatNoteHits(r.notes) + : r.notesChecked + ? ["· no notes in .trail/ about this yet", ""] + : []; if (r.hits.length === 0) { + if (r.notes?.length) return `${head}\n\n${teamNotes.join("\n").trimEnd()}\n\n${noteBlock || "no code matches."}${escalationNudge(r)}\n`; return `${head}\n\n${noteBlock || "no matches."}${escalationNudge(r)}\n`; } - const lines = noteBlock ? [head, "", noteBlock, ""] : [head, ""]; + const lines = noteBlock ? [head, "", noteBlock, "", ...teamNotes] : [head, "", ...teamNotes]; if (r.mode === "structural") { for (const h of r.hits) { const tail = h.snippet ? ` — ${h.snippet}` : ""; @@ -1545,7 +1560,7 @@ function escalationNudge(r: AskResult): string { const n = r.hits.length; return ( `\n\n${tag()} ${n === 0 ? "no hits" : `only ${n} hit${n === 1 ? "" : "s"}`} — don't re-ask with new wording; switch tool: ` + - "`graft grep \"\"` for every occurrence · `graft skeleton ` for a file's full API · `graft callers ` for who-uses." + `\`${cmd("graft grep")} ""\` for every occurrence · \`${cmd("graft skeleton")} \` for a file's full API · \`${cmd("graft callers")} \` for who-uses.` ); } diff --git a/src/claude/settings-merge.ts b/src/claude/settings-merge.ts index 3ee1d15b8..97f12f2b2 100644 --- a/src/claude/settings-merge.ts +++ b/src/claude/settings-merge.ts @@ -12,6 +12,7 @@ const FOOTER = 'graft/[\\w./-]+\\.md'; // from a checkout), where the binary is not on PATH under that name. A retrieval // call that raises a permission prompt loses to grep, which never does. const ALLOW_ENTRIES = [ + 'Bash(trail:*)', 'Bash(graft:*)', 'Bash(npx graft:*)', 'Bash(graft-dev:*)', @@ -66,7 +67,7 @@ function graftBlocks(helpers?: string): Record { * upgrade instead of accumulating beside its replacement. */ export function isGraftAllowEntry(entry: unknown): boolean { - return /^Bash\((?:graft|npx graft|graft-dev|node dist\/cli\.js)(?::|\))/.test(String(entry)); + return /^Bash\((?:trail|graft|npx graft|graft-dev|node dist\/cli\.js)(?::|\))/.test(String(entry)); } /** Is this footer regex graft's? It points at the card tree, which is graft's alone. */ diff --git a/src/cli.ts b/src/cli.ts index f976b719e..b8a4f92dc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,7 +11,7 @@ import "dotenv/config"; import { Command } from "commander"; import { existsSync, readdirSync, statSync } from "node:fs"; -import { join, relative, resolve } from "node:path"; +import { basename, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { Graft } from "./engine.js"; import { resolveConfig, type EngineConfig } from "./ai/providers.js"; @@ -21,7 +21,9 @@ import { formatGraphCheckReport } from "./graph/check.js"; import { buildGraphIfMissing, runInit } from "./claude/init.js"; import { statuslineWanted } from "./claude/settings-merge.js"; import { runHostsInit } from "./hosts/init.js"; -import { hostIds } from "./hosts/registry.js"; +import { HOSTS, hostIds } from "./hosts/registry.js"; +import { ensureTrailDir } from "./notes/notes.js"; +import { writeTrailBlocks } from "./notes/instructions.js"; import { parseBrainArg, connectBrain, pullBrain, brainStatus } from "./brain/connect.js"; import { rulesForPointers } from "./brain/attach.js"; import { clearLink, type BrainLink } from "./brain/link.js"; @@ -90,9 +92,13 @@ import { patchBuildConfig, type BuildConfig } from "./util/state.js"; import { normalizePathPrefix } from "./util/paths.js"; import { latestSession, formatSessionStats, sessionInputRate } from "./claude/session-metrics.js"; import { listSessionIds, readSession, sessionDir } from "./claude/state.js"; +import { costLabel, listNotes, renderNote, SECTIONS, sectionLead, shortDate, tokensLabel, writeNote } from "./notes/notes.js"; +import { currentSessionCost } from "./notes/session-cost.js"; +import { changedFiles, currentBranch, noteAuthor } from "./notes/git-facts.js"; import { setInputRate } from "./context/savings.js"; import { formatUpdateNudge, maybeRefreshInBackground, readStamp, readUpdateCache, refreshUpdateCache, wiredHostIds, writeStamp } from "./upkeep.js"; import { + countBucket, errorCode, filesBucket, durationBucket, @@ -124,6 +130,7 @@ const TRAIL = brand() === "trail"; /** Help-screen groups under trail; graft keeps its one flat list. */ const GROUP = { find: "Find code · your agent runs these:", + memory: "Team memory:", setup: "Setup:", cloud: "Trail cloud:", ci: "CI:", @@ -144,7 +151,7 @@ function grouped(c: Command, heading: string): Command { * centrally — and because a command that calls `process.exit` should simply * report nothing, which falls out of never emitting until postAction. */ -let queryNote: { repo?: string; hit?: "yes" | "no"; command?: string } = {}; +let queryNote: { repo?: string; hit?: "yes" | "no"; command?: string; notes?: string } = {}; /** Record the repo a query ran against, and pass it straight through so call * sites stay one line. */ @@ -315,7 +322,7 @@ program.hook("postAction", (_parent, action) => { // `trail build --check` is graft's `check`, and counts as it always has. const name = queryNote.command ?? action.name(); if (!isTrackedCommand(name)) return; - track("query", { command: name, surface: "cli", hit: queryNote.hit }, { repo: queryNote.repo }); + track("query", { command: name, surface: "cli", hit: queryNote.hit, notes: queryNote.notes }, { repo: queryNote.repo }); }); /** `graft ask`, `graft trail status`: a command as graft spells it, whatever ran it. */ @@ -713,6 +720,8 @@ grouped(program.command("ask"), GROUP.find) return; } noteHit(r.hits.length > 0); + // How many teammates' notes reached the agent: the demand side of .trail/. + if (r.notesChecked) queryNote.notes = String(Math.min(r.notes?.length ?? 0, 2)); if (opts.json) { console.log(JSON.stringify(r, null, 2)); } else { @@ -1050,6 +1059,23 @@ grouped(program.command("map"), GROUP.find) process.stdout.write(formatRepoMap(map)); }); +// `trail note`: what a session worked out, saved for the next person. Listed +// under trail; under graft it still runs, unlisted, for a teammate on graft in +// a repo where someone else set up trail. +grouped(program.command("note", { hidden: !TRAIL }), GROUP.memory) + .description("Save what this session decided, tried and ruled out, as a note in .trail/notes/ (the note itself on stdin)") + .requiredOption("--title ", "what the session was about, in a few words") + .option("-m, --body ", "the note itself, instead of stdin") + .option("--touches ", "comma-separated files or file#Symbol it's about (default: the files this session changed)") + .option("--author ", "who wrote it (default: the first word of git config user.name)") + .option("--minutes ", "what it took to work out, when the agent's transcript can't say") + .option("--tokens ", "the same, in tokens") + .option("--json", "print the saved note as JSON") + .argument("[dir]", "repository root", ".") + .action(async (dir: string, opts: { title: string; body?: string; touches?: string; author?: string; minutes?: string; tokens?: string; json?: boolean }) => { + await runNoteCommand(resolve(dir), opts); + }); + grouped(program.command("init"), GROUP.setup) .description( TRAIL @@ -1074,6 +1100,81 @@ grouped(program.command("init"), GROUP.setup) await runInitCommand(dir, opts); }); +/** Everything on stdin, or "" when nothing is piped in. */ +async function readStdin(): Promise { + if (process.stdin.isTTY) return ""; + const chunks: Buffer[] = []; + for await (const c of process.stdin) chunks.push(typeof c === "string" ? Buffer.from(c) : c); + return Buffer.concat(chunks).toString("utf8"); +} + +async function runNoteCommand( + repo: string, + opts: { title: string; body?: string; touches?: string; author?: string; minutes?: string; tokens?: string; json?: boolean }, +): Promise { + const body = (opts.body ?? (await readStdin())).trim(); + if (!opts.title.trim() || !body) { + console.error(`✗ a note needs a --title and a body, on stdin or with -m:`); + console.error(` ${cmd("graft note")} --title "What it was about" <<'EOF'`); + console.error(" ## Decided\n …\n ## Tried and ruled out\n …\n ## Watch out\n …\n EOF"); + process.exitCode = 1; + return; + } + const wholeNumber = (raw: string | undefined, flag: string): number | undefined => { + if (raw === undefined) return undefined; + const n = Number(raw); + if (!Number.isFinite(n) || n < 0) { + console.error(`✗ ${flag} takes a number, got "${raw}"`); + process.exit(1); + } + return Math.round(n); + }; + const minutes = wholeNumber(opts.minutes, "--minutes"); + const tokens = wholeNumber(opts.tokens, "--tokens"); + // What the session spent, from the agent's own transcript when there is one; + // the flags win, since only the agent knows where its task began. + const spent = currentSessionCost(repo); + const cost = + minutes !== undefined || tokens !== undefined || spent + ? { minutes: minutes ?? spent?.minutes, tokens: tokens ?? spent?.tokens } + : undefined; + const touches = opts.touches + ? opts.touches.split(",").map((t) => t.trim()).filter(Boolean) + : changedFiles(repo, spent?.startedAt); + const earlier = listNotes(repo); + const note = writeNote(repo, { + title: opts.title, + body, + author: opts.author?.trim() || noteAuthor(repo), + date: new Date().toISOString().slice(0, 10), + branch: currentBranch(repo), + cost, + touches, + }); + track("note_saved", { has_cost: String(cost?.tokens !== undefined || cost?.minutes !== undefined), touches_bucket: countBucket(touches.length) }, { repo }); + + if (opts.json) { + console.log(JSON.stringify(note, null, 2)); + return; + } + console.log(`✓ note saved · ${note.path}`); + const took = costLabel(note.cost); + if (took) { + // Name a code file if the note touches one; docs are rarely what it's about. + const about = touches.find((t) => !/\.(md|mdx|txt)$/i.test(t.split("#")[0]!)) ?? touches[0]; + const first = about ? ` who touches ${basename(about.split("#")[0]!)}` : ""; + const reading = tokensLabel(Math.ceil(renderNote(note).length / 4)).replace(" tokens", ""); + console.log(`· this ${took.replace(" to work out", " to figure out")}. the next person${first} gets it for ${reading}`); + } + // Notes about the same files, from someone else: this one builds on theirs. + const files = new Set(touches.map((t) => t.split("#")[0])); + const prior = earlier.find((n) => n.author !== note.author && n.touches.some((t) => files.has(t.split("#")[0]))); + if (prior) console.log(`· builds on ${prior.author ? `${prior.author}'s` : "a"} note from ${shortDate(prior.date)}`); + if (!SECTIONS.some((s) => sectionLead(body, s.heading) !== null)) + console.log("· tip: notes read best under ## Decided, ## Tried and ruled out and ## Watch out"); + console.log("· commit .trail/ so your team gets this"); +} + /** `graft init`'s flags, as commander hands them over. */ interface InitOptions { build?: boolean; agents?: string[]; allAgents?: boolean; listAgents?: boolean; mcp?: boolean; hooks?: boolean; statusline?: boolean; dryRun?: boolean; yes?: boolean; global?: boolean; trail?: string; verbose?: boolean } @@ -1254,6 +1355,33 @@ async function runInitCommand( } } + // Under trail: the team's notes folder, and the block that tells every + // agent to read it first and leave a note when done. In each repo of a + // workspace, since each is committed on its own. + let trailCommit: string[] = []; + if (TRAIL) { + const sectionFiles = [...new Set(HOSTS.filter((h) => ids.includes(h.id) && h.kind === "section").map((h) => h.relPath))]; + for (const target of targets) { + const { created } = ensureTrailDir(target); + const writes = writeTrailBlocks(target, { claude: wantClaude, files: sectionFiles }); + if (target !== repo) continue; + const notes = listNotes(repo); + const skills = countDirs(join(repo, ".trail", "skills")); + if (created || notes.length === 0) { + console.error("✓ .trail/ ready · every session you finish leaves a short note here"); + } else { + const authors = [...new Set(notes.map((n) => n.author).filter(Boolean))]; + const from = authors.length ? ` from ${joinAnd(authors.length > 3 ? [...authors.slice(0, 2), `${authors.length - 2} others`] : authors)}` : ""; + console.error(`✓ .trail/ ${notes.length} note${notes.length === 1 ? "" : "s"}${from}${skills ? ` · ${skills} skill${skills === 1 ? "" : "s"}` : ""}`); + } + for (const w of writes) { + if (w.action === "unchanged") continue; + console.error(`✓ ${shown(repo, w.path).padEnd(10)} Trail block ${w.action === "replaced" ? "updated" : "added"}: check .trail/ before exploring`); + } + trailCommit = [".trail/", ...writes.map((w) => shown(repo, w.path))]; + } + } + // The brain comes last, after the graph exists: its rules are anchored to // symbols, and `graft trail status` can only report how many of them resolve // once there is a graph to resolve them against. @@ -1278,7 +1406,7 @@ async function runInitCommand( if (!verbose && how.epilogue !== false) { // What there is to commit: the top-level entries the picked agents wrote. - const tops: string[] = []; + const tops: string[] = [...trailCommit]; for (const w of selectedWrites(plan, ids)) { if (w.scope !== "repo") continue; const parts = shown(repo, w.path).split("/"); @@ -1287,7 +1415,10 @@ async function runInitCommand( } console.error(`· restart your agents so a new session picks up ${brand()}`); if (tops.length) - console.error(`· commit ${tops.slice(0, 4).join(" ")}${tops.length > 4 ? ` +${tops.length - 4} more` : ""} to share it — graft/ stays local and git-ignored`); + console.error( + `· commit ${tops.slice(0, 4).join(" ")}${tops.length > 4 ? ` +${tops.length - 4} more` : ""} to share it — ` + + (TRAIL ? "the code map in graft/ stays on this machine, .trail/ is meant to be committed" : "graft/ stays local and git-ignored"), + ); } // One epilogue for the whole run. A workspace parent holds no nodes of its @@ -2150,7 +2281,7 @@ if (TRAIL) { habit.addCommand(c); // Help lists the groups in this order, and the commands within each. - const order = ["ask", "grep", "skeleton", "callers", "map", "init", "build", "status", "upgrade", "uninstall", "telemetry", "login", "push", "pull", "logout", "blast"]; + const order = ["ask", "grep", "skeleton", "callers", "map", "note", "init", "build", "status", "upgrade", "uninstall", "telemetry", "login", "push", "pull", "logout", "blast"]; const rank = (c: Command) => { const i = order.indexOf(c.name()); return i === -1 ? order.length : i; @@ -2164,6 +2295,7 @@ if (TRAIL) { skeleton: "a file's whole API in ~200 tokens", callers: "who calls it, or what it calls with --direction out", map: "directory clusters, hubs and hotspots", + note: "save what this session decided, tried and ruled out", init: "wire trail into your agents", build: "rebuild the code map · --check fails if it's stale", status: "code map, notes, cloud link and tokens saved", diff --git a/src/engine.ts b/src/engine.ts index 5ec744721..01216cf83 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -21,6 +21,7 @@ import { checkContext, type CheckResult } from "./context/check.js"; import { buildGraph, type GraphBuildOptions, type GraphBuildResult } from "./graph/build.js"; import { checkGraph, type GraphCheckResult } from "./graph/check.js"; import { ask, type AskResult } from "./ask/ask.js"; +import { attachNotes } from "./notes/ask-notes.js"; export { CODE_EXTENSIONS }; export type { BuildResult, BuildProgress, CheckResult, GraphBuildResult, GraphCheckResult, AskResult }; @@ -106,7 +107,7 @@ export class Graft { * edges and everything else to a lexical rank over concepts + symbols. */ ask(dir: string, query: string, opts: { limit?: number; source?: boolean; full?: boolean; in?: string; graphRank?: boolean } = {}): AskResult { - return ask(dir, query, { + const r = ask(dir, query, { contextDir: this.cfg.contextDir, limit: opts.limit, source: opts.source, @@ -114,6 +115,7 @@ export class Graft { in: opts.in, graphRank: opts.graphRank, }); + return attachNotes(r, dir); } private _chatModel?: ChatModel; diff --git a/src/hosts/retract.ts b/src/hosts/retract.ts index 801c5aa97..5082a7423 100644 --- a/src/hosts/retract.ts +++ b/src/hosts/retract.ts @@ -27,7 +27,7 @@ import { readFileSync, writeFileSync, existsSync, rmSync, rmdirSync, statSync, r import { join, dirname } from 'node:path'; import { homedir } from 'node:os'; import { HOSTS } from './registry.js'; -import { ALL_MARKERS, type Markers } from './sections.js'; +import { ALL_MARKERS, TRAIL_MARKERS, type Markers } from './sections.js'; import { mcpTargets, stripTomlSection } from './mcp-config.js'; import { hookTargets } from './codex-hooks.js'; import { antigravitySkillTargets } from './antigravity.js'; @@ -423,12 +423,16 @@ 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, statusline, hooks, skill, mcp] = claudeTargets(repo).map((t) => t.path); + const claudeMd = join(repo, 'CLAUDE.md'); for (const t of [ { hostId: 'claude', path: settings, what: 'statusline + hooks + allowlist + footer regex', scope: 'repo', run: (a) => stripClaudeSettings(settings, a) }, { hostId: 'claude', path: statusline, what: 'statusline shim', scope: 'repo', run: (a) => removeFile(statusline, a) }, { hostId: 'claude', path: hooks, what: 'hooks shim', scope: 'repo', run: (a) => removeFile(hooks, a) }, { hostId: 'claude', path: skill, what: 'graft skill', scope: 'repo', run: (a) => removeFile(skill, a) }, { hostId: 'claude', path: mcp, what: 'mcpServers.graft', scope: 'repo', run: (a) => removeJsonKey(mcp, 'mcpServers', a) }, + // The Trail block `trail init` adds to CLAUDE.md. Only that block: the + // file is the team's, and .trail/ itself is the team's notes, kept. + { hostId: 'claude', path: claudeMd, what: 'Trail block', scope: 'repo', run: (a) => stripSection(claudeMd, a, [TRAIL_MARKERS]) }, ] as Target[]) add(t); } diff --git a/src/hosts/sections.ts b/src/hosts/sections.ts index 12dd2ed3b..c7a88b1f7 100644 --- a/src/hosts/sections.ts +++ b/src/hosts/sections.ts @@ -30,8 +30,17 @@ export interface Markers { export const GRAFT_MARKERS: Markers = { start: START, end: END }; export const BRAIN_MARKERS: Markers = { start: BRAIN_START, end: BRAIN_END }; +/** + * The Trail block: tells an agent to check the team's notes in `.trail/` + * before exploring, and to leave one when it's done. Its own region, so it + * lives beside graft's code-map block and is removed with the rest. + */ +export const TRAIL_START = ''; +export const TRAIL_END = ''; +export const TRAIL_MARKERS: Markers = { start: TRAIL_START, end: TRAIL_END }; + /** Every managed region graft may own in a user-owned file. */ -export const ALL_MARKERS: Markers[] = [GRAFT_MARKERS, BRAIN_MARKERS]; +export const ALL_MARKERS: Markers[] = [GRAFT_MARKERS, BRAIN_MARKERS, TRAIL_MARKERS]; export type UpsertAction = 'created' | 'appended' | 'replaced' | 'unchanged'; diff --git a/src/notes/ask-notes.ts b/src/notes/ask-notes.ts new file mode 100644 index 000000000..4b3af5821 --- /dev/null +++ b/src/notes/ask-notes.ts @@ -0,0 +1,21 @@ +/** + * Puts teammates' notes next to a query's results, for every surface that + * answers through `Graft.ask` (the CLI's `ask` and the MCP `find_code`). + */ +import { existsSync } from "node:fs"; +import type { AskResult } from "../ask/ask.js"; +import { findNotes, listNotes, notesDir } from "./notes.js"; + +/** The file part of a hit's `file:Lx-Ly` pointer. */ +function pointerFile(pointer: string): string { + return pointer.replace(/:L\d+(-L?\d+)?$/, ""); +} + +/** Attach the notes that bear on `r`, in place. A repo without `.trail/notes/` is left alone. */ +export function attachNotes(r: AskResult, repo: string): AskResult { + if (!existsSync(notesDir(repo))) return r; + r.notesChecked = true; + const hits = findNotes(listNotes(repo), r.query, r.hits.map((h) => pointerFile(h.pointer))); + if (hits.length) r.notes = hits; + return r; +} diff --git a/src/notes/git-facts.ts b/src/notes/git-facts.ts new file mode 100644 index 000000000..58cf19e79 --- /dev/null +++ b/src/notes/git-facts.ts @@ -0,0 +1,66 @@ +/** + * What a note says about where it came from: who wrote it, on which branch, + * and which files the session changed. All from git, all best-effort: a + * missing answer leaves the field out rather than guessing. + */ +import { execFileSync } from "node:child_process"; +import { userInfo } from "node:os"; + +/** git's stdout, trimmed unless `raw` (porcelain status starts lines with a meaningful space). */ +function git(repo: string, args: string[], raw = false): string | null { + try { + const out = execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); + return raw ? out : out.trim(); + } catch { + return null; + } +} + +/** First name from `git config user.name`, capitalised; the OS user as a last resort. */ +export function noteAuthor(repo: string): string { + const name = git(repo, ["config", "user.name"]) || safeUser(); + const first = name.trim().split(/\s+/)[0] ?? ""; + return first ? first[0]!.toUpperCase() + first.slice(1) : ""; +} + +function safeUser(): string { + try { + return userInfo().username; + } catch { + return ""; + } +} + +export function currentBranch(repo: string): string | undefined { + const b = git(repo, ["rev-parse", "--abbrev-ref", "HEAD"]); + return b && b !== "HEAD" ? b : undefined; +} + +/** Paths that are trail's own or agent wiring, never what a note is about. */ +function ownPath(p: string): boolean { + return /^(\.trail|graft|\.graft|\.claude|\.cursor|\.codex)(\/|$)/.test(p) || p === ".mcp.json"; +} + +/** + * Files this session changed: uncommitted changes, plus files in commits made + * since `since` (epoch ms). At most `limit`, in the order git lists them. + */ +export function changedFiles(repo: string, since?: number, limit = 10): string[] { + const out: string[] = []; + const add = (p: string) => { + const f = p.trim(); + if (f && !ownPath(f) && !out.includes(f)) out.push(f); + }; + const status = git(repo, ["status", "--porcelain", "--untracked-files=all"], true); + for (const line of (status ?? "").split("\n")) { + if (!line.trim()) continue; + // `XY path` or `XY old -> new`: the path now. + const path = line.slice(3).split(" -> ").pop() ?? ""; + add(path.replace(/^"|"$/g, "")); + } + if (since !== undefined) { + const log = git(repo, ["log", `--since=@${Math.floor(since / 1000)}`, "--name-only", "--pretty=format:"]); + for (const p of (log ?? "").split("\n")) add(p); + } + return out.slice(0, limit); +} diff --git a/src/notes/instructions.ts b/src/notes/instructions.ts new file mode 100644 index 000000000..5c38ae1ff --- /dev/null +++ b/src/notes/instructions.ts @@ -0,0 +1,54 @@ +/** + * The Trail block written into CLAUDE.md and each agent's instruction file + * (AGENTS.md, GEMINI.md, …) by `trail init`. + * + * It is how a teammate's agent finds out the repo has notes at all: a + * teammate who has never heard of Trail pulls, opens their agent, and the + * agent reads this. So it says what `.trail/` is, how to search it, what to + * do when `trail` isn't installed, and to leave a note when the work is done. + */ +import { join } from "node:path"; +import { TRAIL_MARKERS, upsertSection, type UpsertAction } from "../hosts/sections.js"; + +/** `agent` is the `--agents` id to install for, or undefined to wire whatever is detected. */ +export function trailBlock(agent?: string): string { + const init = agent ? `trail init --agents ${agent} --yes` : "trail init --yes"; + return [ + "## Trail", + "Before exploring code, check `.trail/`: notes from teammates' sessions on", + 'what they decided, tried and ruled out. `trail ask "" --source` searches', + "the notes and the code map together. If `trail` isn't installed, tell the user", + "their teammates have left notes in `.trail/` and offer to run", + `\`npm install -g @trailhq/trail && ${init}\`.`, + "", + "When you finish a task that took real digging, leave a note for the next", + 'person: `trail note --title ""` with the note on stdin,', + "under `## Decided`, `## Tried and ruled out` and `## Watch out`. A summary,", + "never a transcript, and nothing secret: it gets committed.", + ].join("\n"); +} + +export interface TrailBlockWrite { + path: string; + action: UpsertAction; +} + +/** + * Write the block into the repo's CLAUDE.md (when Claude Code is wired) and + * into each other agent's instruction file. Best-effort per file. + */ +export function writeTrailBlocks(repo: string, opts: { claude: boolean; files: string[] }): TrailBlockWrite[] { + const out: TrailBlockWrite[] = []; + const targets: Array<[string, string | undefined]> = []; + if (opts.claude) targets.push(["CLAUDE.md", "claude"]); + for (const f of opts.files) if (!targets.some(([p]) => p === f)) targets.push([f, undefined]); + for (const [rel, agent] of targets) { + const path = join(repo, rel); + try { + out.push({ path, action: upsertSection(path, trailBlock(agent), TRAIL_MARKERS).action }); + } catch { + /* unwritable: the others still go in */ + } + } + return out; +} diff --git a/src/notes/notes.ts b/src/notes/notes.ts new file mode 100644 index 000000000..7e17086c6 --- /dev/null +++ b/src/notes/notes.ts @@ -0,0 +1,328 @@ +/** + * `.trail/notes/`: one short note per coding session, committed with the code. + * + * A note is what a session worked out that the code itself doesn't say: what + * was decided, what was tried and ruled out, and what to watch out for, plus + * what it cost to figure out. Teammates' agents read it before exploring, so + * the next person who touches the same code doesn't pay for it again. + * + * One file per session and never edited after it's written, so two people + * writing notes on the same day never conflict on a pull. A summary only, + * never a transcript: the agent writes the body, and nothing here reads + * source files or prompts into it. + */ +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import matter from "gray-matter"; + +export const TRAIL_DIR = ".trail"; + +export function trailDir(repo: string): string { + return join(repo, TRAIL_DIR); +} + +export function notesDir(repo: string): string { + return join(repo, TRAIL_DIR, "notes"); +} + +export interface NoteCost { + minutes?: number; + tokens?: number; +} + +export interface Note { + /** Repo-relative, forward slashes. */ + path: string; + title: string; + author: string; + /** YYYY-MM-DD. */ + date: string; + branch?: string; + cost?: NoteCost; + /** `file` or `file#Symbol`, repo-relative. */ + touches: string[]; + body: string; +} + +/** The three sections a note is written under. Notes may have others; these are the ones `ask` quotes. */ +export const SECTIONS = [ + { key: "decided", heading: "Decided", label: "decided" }, + { key: "ruledOut", heading: "Tried and ruled out", label: "ruled out" }, + { key: "watchOut", heading: "Watch out", label: "watch out" }, +] as const; + +/* -------------------------------------------------------------------------- */ +/* the folder */ +/* -------------------------------------------------------------------------- */ + +export const TRAIL_README = `# .trail + +Notes from this team's coding sessions, kept by Trail. Each file is one +session: what got decided, what was tried and ruled out, and what it took +to figure out. Coding agents read these before exploring, so nobody pays +to work the same thing out twice. + +- notes/ one file per session, never edited after it's written +- skills/ how-tos your agents follow, and the corrections that improve them + +Get it: npm install -g @trailhq/trail && trail init +`; + +/** + * Create `.trail/` with its README and an empty `notes/`. Never touches a + * README someone has edited. Returns whether the folder was new. + */ +export function ensureTrailDir(repo: string): { created: boolean } { + const dir = trailDir(repo); + const created = !existsSync(dir); + mkdirSync(join(dir, "notes"), { recursive: true }); + const readme = join(dir, "README.md"); + if (!existsSync(readme)) writeFileSync(readme, TRAIL_README); + // git keeps no empty folders; this keeps notes/ in the first commit. + const keep = join(dir, "notes", ".gitkeep"); + if (!existsSync(keep) && readdirSync(join(dir, "notes")).length === 0) writeFileSync(keep, ""); + return { created }; +} + +/* -------------------------------------------------------------------------- */ +/* reading and writing one note */ +/* -------------------------------------------------------------------------- */ + +/** A YAML scalar that round-trips: plain when it can be, double-quoted otherwise. */ +function scalar(s: string): string { + return /^[A-Za-z0-9][^:#\n"'{}[\],&*!|>%@`]*$/.test(s) && s.trim() === s ? s : JSON.stringify(s); +} + +export function renderNote(n: Omit): string { + const fm = ["---", `title: ${scalar(n.title)}`, `author: ${scalar(n.author)}`, `date: ${n.date}`]; + if (n.branch) fm.push(`branch: ${scalar(n.branch)}`); + const cost: string[] = []; + if (n.cost?.minutes !== undefined) cost.push(`minutes: ${n.cost.minutes}`); + if (n.cost?.tokens !== undefined) cost.push(`tokens: ${n.cost.tokens}`); + if (cost.length) fm.push(`cost: { ${cost.join(", ")} }`); + if (n.touches.length) fm.push("touches:", ...n.touches.map((t) => ` - ${scalar(t)}`)); + fm.push("---", ""); + return `${fm.join("\n")}\n${n.body.trim()}\n`; +} + +function num(v: unknown): number | undefined { + return typeof v === "number" && Number.isFinite(v) ? v : undefined; +} + +/** A note file, or null when it isn't one (no title in its frontmatter). */ +export function parseNote(text: string, path: string): Note | null { + let parsed: matter.GrayMatterFile; + try { + parsed = matter(text); + } catch { + return null; + } + const d = parsed.data as Record; + if (typeof d.title !== "string" || !d.title.trim()) return null; + const date = d.date instanceof Date ? d.date.toISOString().slice(0, 10) : String(d.date ?? ""); + const cost = (d.cost ?? {}) as Record; + const minutes = num(cost.minutes); + const tokens = num(cost.tokens); + return { + path, + title: d.title.trim(), + author: typeof d.author === "string" ? d.author : "", + date, + branch: typeof d.branch === "string" ? d.branch : undefined, + cost: minutes !== undefined || tokens !== undefined ? { minutes, tokens } : undefined, + touches: Array.isArray(d.touches) ? d.touches.map(String) : [], + body: parsed.content.trim(), + }; +} + +/** Every note in `.trail/notes/`, newest first. Unreadable files are skipped. */ +export function listNotes(repo: string): Note[] { + const dir = notesDir(repo); + let files: string[]; + try { + files = readdirSync(dir).filter((f) => f.endsWith(".md")); + } catch { + return []; + } + const out: Note[] = []; + for (const f of files) { + try { + const n = parseNote(readFileSync(join(dir, f), "utf8"), relative(repo, join(dir, f)).split("\\").join("/")); + if (n) out.push(n); + } catch { + /* unreadable: skip */ + } + } + return out.sort((a, b) => (a.date === b.date ? b.path.localeCompare(a.path) : b.date.localeCompare(a.date))); +} + +/** The first line of a section's text, or null when the note has no such section. */ +export function sectionLead(body: string, heading: string): string | null { + const lines = body.split(/\r?\n/); + const at = lines.findIndex((l) => l.trim().toLowerCase() === `## ${heading.toLowerCase()}`); + if (at === -1) return null; + for (let i = at + 1; i < lines.length; i++) { + const l = lines[i]!.trim(); + if (l.startsWith("## ")) break; + if (l) return l.replace(/^[-*]\s+/, ""); + } + return null; +} + +/** `Bbox coordinates are off on rotated PDFs` → `bbox-coordinates-are-off-on-rotated`. */ +export function slug(s: string, words = 6): string { + return s + .toLowerCase() + .normalize("NFKD") + .replace(/[^a-z0-9\s-]/g, " ") + .split(/[\s-]+/) + .filter(Boolean) + .slice(0, words) + .join("-"); +} + +export interface WriteNoteInput { + title: string; + body: string; + author: string; + date: string; + branch?: string; + cost?: NoteCost; + touches?: string[]; +} + +/** + * Write a new note and return its repo-relative path. Never overwrites: a + * second note with the same name on the same day gets `-2`, `-3`. + */ +export function writeNote(repo: string, input: WriteNoteInput): Note { + ensureTrailDir(repo); + const dir = notesDir(repo); + const base = [input.date, slug(input.title) || "note", slug(input.author, 1)].filter(Boolean).join("-"); + let name = `${base}.md`; + for (let i = 2; existsSync(join(dir, name)); i++) name = `${base}-${i}.md`; + const note: Omit = { + title: input.title.trim(), + author: input.author, + date: input.date, + branch: input.branch, + cost: input.cost, + touches: input.touches ?? [], + body: input.body, + }; + writeFileSync(join(dir, name), renderNote(note)); + return { ...note, path: `${TRAIL_DIR}/notes/${name}` }; +} + +/* -------------------------------------------------------------------------- */ +/* finding the notes that matter for a query */ +/* -------------------------------------------------------------------------- */ + +const STOP = new Set( + "the and for with from that this what when where which into does how why are was were not but you your our out its it's can should would could about after before over under use using used fix find make".split( + " ", + ), +); + +function terms(s: string): string[] { + return [...new Set(s.toLowerCase().split(/[^a-z0-9_]+/).filter((t) => t.length >= 3 && !STOP.has(t)))]; +} + +/** The file part of a `file#Symbol` touch. */ +function touchFile(t: string): string { + return t.split("#")[0]!; +} + +export interface NoteHit { + note: Note; + score: number; + /** Files the note touches that this query's results also point into. */ + shared: string[]; +} + +/** + * The notes worth showing next to a query's results: ones whose title or body + * share the query's words, or that touch a file the results point into. At + * most `limit`, best first. A note has to clear a bar — two of the query's + * words, or one in its title, or a shared file — so an unrelated note never + * rides along just because it exists. + */ +export function findNotes(notes: Note[], query: string, files: string[] = [], limit = 2): NoteHit[] { + const q = terms(query); + const hitFiles = new Set(files); + const out: NoteHit[] = []; + for (const note of notes) { + const title = new Set(terms(note.title)); + const text = new Set(terms(`${note.body} ${note.touches.join(" ")}`)); + let score = 0; + let matched = 0; + for (const t of q) { + if (title.has(t)) { + score += 3; + matched++; + } else if (text.has(t)) { + score += 1; + matched++; + } + } + const shared = [...new Set(note.touches.map(touchFile))].filter((f) => hitFiles.has(f)); + score += shared.length * 4; + const clears = shared.length > 0 || matched >= 2 || [...title].some((t) => q.includes(t)); + if (clears && score >= 3) out.push({ note, score, shared }); + } + return out.sort((a, b) => b.score - a.score || b.note.date.localeCompare(a.note.date)).slice(0, limit); +} + +/* -------------------------------------------------------------------------- */ +/* how a note reads inside a query's output */ +/* -------------------------------------------------------------------------- */ + +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +/** `2026-10-07` → `Oct 7`; anything else as it came. */ +export function shortDate(date: string): string { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date); + if (!m) return date; + return `${MONTHS[Number(m[2]) - 1] ?? m[2]} ${Number(m[3])}`; +} + +/** `~9.6k tokens`, `~600 tokens`. */ +export function tokensLabel(n: number): string { + if (n >= 1000) return `~${(n / 1000).toFixed(n >= 10_000 ? 0 : 1).replace(/\.0$/, "")}k tokens`; + return `~${n} tokens`; +} + +/** `took 12 min and ~9.6k tokens to work out`, or null when the note has no cost. */ +export function costLabel(cost?: NoteCost): string | null { + if (!cost) return null; + const parts: string[] = []; + if (cost.minutes !== undefined) parts.push(`${cost.minutes} min`); + if (cost.tokens !== undefined) parts.push(tokensLabel(cost.tokens)); + return parts.length ? `took ${parts.join(" and ")} to work out` : null; +} + +function clip(s: string, n = 88): string { + return s.length > n ? `${s.slice(0, n - 1).trimEnd()}…` : s; +} + +/** + * The block `ask` prints above its results: who wrote each note, when, what it + * was about and what it cost, then the first line of each section, and the + * path to read the rest. An agent relays the cost when it says what the turn + * saved, which is how the saving becomes countable. + */ +export function formatNoteHits(hits: NoteHit[]): string[] { + const lines: string[] = []; + for (const { note } of hits) { + const who = note.author ? `${note.author}'s note` : "a note"; + lines.push(`from ${who} · ${shortDate(note.date)} · ${note.title}`); + for (const s of SECTIONS) { + const lead = sectionLead(note.body, s.heading); + if (lead) lines.push(` ${s.label.padEnd(11)} ${clip(lead)}`); + } + const cost = costLabel(note.cost); + lines.push(` ${note.path}${cost ? ` · ${cost}` : ""}`); + lines.push(""); + } + return lines; +} diff --git a/src/notes/session-cost.ts b/src/notes/session-cost.ts new file mode 100644 index 000000000..8e3d3e69a --- /dev/null +++ b/src/notes/session-cost.ts @@ -0,0 +1,143 @@ +/** + * What the current agent session spent working something out, for a note's + * cost line: active minutes, and tokens. + * + * Read from Claude Code's own transcript, which it keeps as JSONL under + * `~/.claude/projects//`, one + * file per session. The one being written right now is the newest. Other + * agents keep no transcript we can read, so their notes carry no cost unless + * the agent passes `--minutes` / `--tokens`. + * + * Counted from the session's last finished `trail note` (or its start), so two + * notes from one long session don't both claim the same work. Tokens are the + * ones the model actually processed fresh: input, cache writes and output. + * Cache reads are left out, since they re-read the same context every turn and + * would make an hour's work look like millions. Minutes are active minutes: a + * gap of more than ten minutes between two entries counts as nothing, so a + * session left open over lunch doesn't claim the lunch. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export interface SessionCost { + minutes: number; + tokens: number; + /** When the counted stretch began, epoch ms. */ + startedAt: number; +} + +const IDLE_MS = 10 * 60 * 1000; +/** A transcript untouched this long belongs to some earlier session. */ +const ACTIVE_MS = 30 * 60 * 1000; + +export function claudeProjectDir(repo: string, home: string = homedir()): string { + return join(home, ".claude", "projects", repo.replace(/[^A-Za-z0-9]/g, "-")); +} + +/** The transcript of the session running in `repo` now, or null. */ +export function currentTranscript(repo: string, home?: string, now = Date.now()): string | null { + const dir = claudeProjectDir(repo, home); + let best: { path: string; mtime: number } | null = null; + let files: string[]; + try { + files = readdirSync(dir).filter((f) => f.endsWith(".jsonl")); + } catch { + return null; + } + for (const f of files) { + try { + const mtime = statSync(join(dir, f)).mtimeMs; + if (!best || mtime > best.mtime) best = { path: join(dir, f), mtime }; + } catch { + /* gone */ + } + } + return best && now - best.mtime <= ACTIVE_MS ? best.path : null; +} + +interface Entry { + type?: string; + timestamp?: string; + message?: { + id?: string; + usage?: { input_tokens?: number; cache_creation_input_tokens?: number; output_tokens?: number }; + content?: unknown; + }; +} + +function blocks(e: Entry): Array> { + const c = e.message?.content; + return Array.isArray(c) ? (c as Array>) : []; +} + +const NOTE_CALL = /(^|[|&;]\s*|\s)(npx\s+(-y\s+)?(@trailhq\/|@nanonets\/)?)?(trail|graft)\s+note\b/; + +/** + * The cost of one transcript since its last finished note. Pure, for tests: + * `text` is the JSONL, `now` closes the last stretch when the transcript ends + * mid-turn (it always does: the note call itself is in flight). + */ +export function costFromTranscript(text: string, now = Date.now()): SessionCost | null { + const entries: Entry[] = []; + for (const line of text.split("\n")) { + if (!line.trim()) continue; + try { + entries.push(JSON.parse(line) as Entry); + } catch { + /* a half-written last line */ + } + } + // A note call is finished once its tool_result is in the transcript; the + // call that is running right now has none yet, so it never counts as the + // boundary. + const noteCalls = new Set(); + let boundary = -1; + entries.forEach((e, i) => { + for (const b of blocks(e)) { + if (b.type === "tool_use" && typeof b.id === "string") { + const cmd = (b.input as { command?: unknown } | undefined)?.command; + if (typeof cmd === "string" && NOTE_CALL.test(cmd)) noteCalls.add(b.id); + } + if (b.type === "tool_result" && typeof b.tool_use_id === "string" && noteCalls.has(b.tool_use_id)) boundary = i; + } + }); + + const counted = entries.slice(boundary + 1).filter((e) => e.timestamp); + if (counted.length === 0) return null; + const times = counted.map((e) => Date.parse(e.timestamp!)).filter((t) => Number.isFinite(t)); + if (times.length === 0) return null; + times.push(now); + let activeMs = 0; + for (let i = 1; i < times.length; i++) { + const gap = times[i]! - times[i - 1]!; + if (gap > 0 && gap <= IDLE_MS) activeMs += gap; + } + + // Claude Code writes one line per content block, each repeating its + // message's usage, so a message is counted once by its id. + const seen = new Set(); + let tokens = 0; + for (const e of counted) { + if (e.type !== "assistant" || !e.message?.usage) continue; + const id = e.message.id; + if (id) { + if (seen.has(id)) continue; + seen.add(id); + } + const u = e.message.usage; + tokens += (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.output_tokens ?? 0); + } + return { minutes: Math.max(1, Math.round(activeMs / 60_000)), tokens, startedAt: times[0]! }; +} + +/** The running session's cost in `repo`, or null when there is no transcript to read. */ +export function currentSessionCost(repo: string, home?: string, now = Date.now()): SessionCost | null { + const path = currentTranscript(repo, home, now); + if (!path) return null; + try { + return costFromTranscript(readFileSync(path, "utf8"), now); + } catch { + return null; + } +} diff --git a/src/telemetry/contract.ts b/src/telemetry/contract.ts index e3eb5d372..cbcc047fb 100644 --- a/src/telemetry/contract.ts +++ b/src/telemetry/contract.ts @@ -50,7 +50,7 @@ export const EVENTS: Record> = { /** A build threw. `stage`/`code` are enums; the message never travels. */ build_failed: new Set(['stage', 'code']), /** One query, from any surface. The DAU backbone and the dead-command detector. */ - query: new Set(['command', 'surface', 'hit']), + query: new Set(['command', 'surface', 'hit', 'notes']), /** `graft trail push` in a repo with no brain, sending the user to Trail to * make one. Queued the moment the link is printed, so a signup somebody * walked away from is still counted — the settle below never fires for those, @@ -74,6 +74,11 @@ export const EVENTS: Record> = { * `skipped` with a `reason` from a closed set (TRAIL_AUTOPUSH_SKIPS). Only for * a repo with a trail attached — every other repo sends nothing. */ trail_autopush: new Set(['outcome', 'reason']), + /** `trail note` saved a session note into `.trail/notes/`. Whether it carries + * a cost, and how many files it is about, as a bucket. The title, the text + * and the paths never travel. The supply side of the notes loop; the + * `notes` property on `query` is the demand side. */ + note_saved: new Set(['has_cost', 'touches_bucket']), /** One closed agent session, summarised. `graft_reads` vs `source_reads` is * the single number that says whether an agent prefers graft to grep; the two * `*_turns` buckets are the follow-up question — of the turns that used graft, diff --git a/test/claude-init.test.ts b/test/claude-init.test.ts index 3894bf045..ad5a5e3cd 100644 --- a/test/claude-init.test.ts +++ b/test/claude-init.test.ts @@ -35,7 +35,7 @@ test('runInit scaffolds settings + both shims + the skill (build skipped)', () = const s = JSON.parse(readFileSync(join(d, '.claude', 'settings.json'), 'utf8')); assert.ok(s.statusLine.command.includes('graft-statusline.cjs')); assert.ok(s.hooks.Stop[0].hooks[0].command.includes('graft-hooks.cjs')); - assert.deepEqual(s.permissions.allow, ['Bash(graft:*)', 'Bash(npx graft:*)', 'Bash(graft-dev:*)', 'Bash(node dist/cli.js:*)']); + assert.deepEqual(s.permissions.allow, ['Bash(trail:*)', 'Bash(graft:*)', 'Bash(npx graft:*)', 'Bash(graft-dev:*)', 'Bash(node dist/cli.js:*)']); }); test('runInit overwrites a stale skill file', () => { @@ -101,7 +101,7 @@ test('runInit is idempotent', () => { runInit(d, { build: false, home: fresh() }); const s = JSON.parse(readFileSync(join(d, '.claude', 'settings.json'), 'utf8')); assert.equal(s.hooks.PostToolUse.length, 2); // post-edit + tool-savings, not duplicated on re-init - assert.deepEqual(s.permissions.allow, ['Bash(graft:*)', 'Bash(npx graft:*)', 'Bash(graft-dev:*)', 'Bash(node dist/cli.js:*)']); + assert.deepEqual(s.permissions.allow, ['Bash(trail:*)', 'Bash(graft:*)', 'Bash(npx graft:*)', 'Bash(graft-dev:*)', 'Bash(node dist/cli.js:*)']); }); test('runInit appends the allowlist to a pre-existing permissions block, preserving unrelated entries', () => { @@ -110,7 +110,7 @@ test('runInit appends the allowlist to a pre-existing permissions block, preserv writeFileSync(join(d, '.claude', 'settings.json'), JSON.stringify({ permissions: { allow: ['Bash(ls)'] } })); runInit(d, { build: false, home: fresh() }); const s = JSON.parse(readFileSync(join(d, '.claude', 'settings.json'), 'utf8')); - assert.deepEqual(s.permissions.allow, ['Bash(ls)', 'Bash(graft:*)', 'Bash(npx graft:*)', 'Bash(graft-dev:*)', 'Bash(node dist/cli.js:*)']); + assert.deepEqual(s.permissions.allow, ['Bash(ls)', 'Bash(trail:*)', 'Bash(graft:*)', 'Bash(npx graft:*)', 'Bash(graft-dev:*)', 'Bash(node dist/cli.js:*)']); }); test('postinstall prints the nudge in a fresh dir', () => { diff --git a/test/claude-settings-merge.test.ts b/test/claude-settings-merge.test.ts index ad424557a..32ac7251b 100644 --- a/test/claude-settings-merge.test.ts +++ b/test/claude-settings-merge.test.ts @@ -99,41 +99,41 @@ test('re-running is idempotent (no duplicate Graft entries or footer)', () => { test('foreign top-level keys survive', () => { const { merged } = mergeGraftSettings({ model: 'claude-sonnet-5', permissions: { allow: ['Bash(ls)'] } }); assert.equal(merged.model, 'claude-sonnet-5'); - assert.deepEqual(merged.permissions.allow, ['Bash(ls)', 'Bash(graft:*)', 'Bash(npx graft:*)', 'Bash(graft-dev:*)', 'Bash(node dist/cli.js:*)']); + assert.deepEqual(merged.permissions.allow, ['Bash(ls)', 'Bash(trail:*)', 'Bash(graft:*)', 'Bash(npx graft:*)', 'Bash(graft-dev:*)', 'Bash(node dist/cli.js:*)']); }); test('fresh init adds the graft CLI allowlist', () => { const { merged } = mergeGraftSettings({}); - assert.deepEqual(merged.permissions.allow, ['Bash(graft:*)', 'Bash(npx graft:*)', 'Bash(graft-dev:*)', 'Bash(node dist/cli.js:*)']); + assert.deepEqual(merged.permissions.allow, ['Bash(trail:*)', 'Bash(graft:*)', 'Bash(npx graft:*)', 'Bash(graft-dev:*)', 'Bash(node dist/cli.js:*)']); }); test('re-init does not duplicate allowlist entries', () => { const once = mergeGraftSettings({}).merged; const twice = mergeGraftSettings(once).merged; - assert.deepEqual(twice.permissions.allow, ['Bash(graft:*)', 'Bash(npx graft:*)', 'Bash(graft-dev:*)', 'Bash(node dist/cli.js:*)']); + assert.deepEqual(twice.permissions.allow, ['Bash(trail:*)', 'Bash(graft:*)', 'Bash(npx graft:*)', 'Bash(graft-dev:*)', 'Bash(node dist/cli.js:*)']); }); test('pre-existing unrelated allow entries are preserved and ours appended', () => { const existing = { permissions: { allow: ['Bash(ls)', 'Bash(git:*)'] } }; const { merged } = mergeGraftSettings(existing); - assert.deepEqual(merged.permissions.allow, ['Bash(ls)', 'Bash(git:*)', 'Bash(graft:*)', 'Bash(npx graft:*)', 'Bash(graft-dev:*)', 'Bash(node dist/cli.js:*)']); + assert.deepEqual(merged.permissions.allow, ['Bash(ls)', 'Bash(git:*)', 'Bash(trail:*)', 'Bash(graft:*)', 'Bash(npx graft:*)', 'Bash(graft-dev:*)', 'Bash(node dist/cli.js:*)']); }); test('a partially-present allowlist gains only what it lacks, in order', () => { const existing = { permissions: { allow: ['Bash(graft:*)'] } }; const { merged } = mergeGraftSettings(existing); - assert.deepEqual(merged.permissions.allow, ['Bash(graft:*)', 'Bash(npx graft:*)', 'Bash(graft-dev:*)', 'Bash(node dist/cli.js:*)']); + assert.deepEqual(merged.permissions.allow, ['Bash(trail:*)', 'Bash(graft:*)', 'Bash(npx graft:*)', 'Bash(graft-dev:*)', 'Bash(node dist/cli.js:*)']); }); test('pre-existing allow entries are kept and only the missing ones appended', () => { const existing = { permissions: { allow: ['Bash(graft:*)', 'Bash(npx graft:*)'] } }; const { merged } = mergeGraftSettings(existing); - assert.deepEqual(merged.permissions.allow, ['Bash(graft:*)', 'Bash(npx graft:*)', 'Bash(graft-dev:*)', 'Bash(node dist/cli.js:*)']); + assert.deepEqual(merged.permissions.allow, ['Bash(trail:*)', 'Bash(graft:*)', 'Bash(npx graft:*)', 'Bash(graft-dev:*)', 'Bash(node dist/cli.js:*)']); }); test('permissions object with no allow key gets one added; other keys preserved', () => { const existing = { permissions: { deny: ['Bash(rm:*)'] } }; const { merged } = mergeGraftSettings(existing); assert.deepEqual(merged.permissions.deny, ['Bash(rm:*)']); - assert.deepEqual(merged.permissions.allow, ['Bash(graft:*)', 'Bash(npx graft:*)', 'Bash(graft-dev:*)', 'Bash(node dist/cli.js:*)']); + assert.deepEqual(merged.permissions.allow, ['Bash(trail:*)', 'Bash(graft:*)', 'Bash(npx graft:*)', 'Bash(graft-dev:*)', 'Bash(node dist/cli.js:*)']); }); diff --git a/test/telemetry-contract.test.ts b/test/telemetry-contract.test.ts index 1a71b2363..2756cbe22 100644 --- a/test/telemetry-contract.test.ts +++ b/test/telemetry-contract.test.ts @@ -75,10 +75,10 @@ test('every event carries the common properties, and no identifier beyond them', // Pinned rather than counted, so adding an event is a deliberate edit here and // a matching row in TELEMETRY.md, never something that arrives with a feature. -test('the contract lists exactly the twelve documented events', () => { +test('the contract lists exactly the thirteen documented events', () => { assert.deepEqual(Object.keys(EVENTS).sort(), [ 'brain_signup_opened', 'brain_signup_settled', 'build_completed', 'build_failed', - 'first_run', 'init_completed', 'install', 'query', 'session_summary', 'trail_autopush', 'trail_pulled', + 'first_run', 'init_completed', 'install', 'note_saved', 'query', 'session_summary', 'trail_autopush', 'trail_pulled', 'trail_watch_exit', ]); }); diff --git a/test/trail-notes.test.ts b/test/trail-notes.test.ts new file mode 100644 index 000000000..07e93b0a6 --- /dev/null +++ b/test/trail-notes.test.ts @@ -0,0 +1,268 @@ +/** + * `.trail/notes/`: one note per session, written by `trail note`, read back by + * `ask` for the next person who works on the same thing. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + costLabel, + ensureTrailDir, + findNotes, + formatNoteHits, + listNotes, + parseNote, + renderNote, + sectionLead, + shortDate, + slug, + tokensLabel, + writeNote, + type Note, +} from "../src/notes/notes.js"; +import { costFromTranscript } from "../src/notes/session-cost.js"; +import { trailBlock } from "../src/notes/instructions.js"; +import { homeEnv, tmpRepo } from "./helpers.js"; + +const BODY = [ + "## Decided", + "Rotate each box in `NormalizeBox` by its own page's `/Rotate` before scaling.", + "", + "## Tried and ruled out", + "- Rotating the image before OCR. Works, but doubles latency on long files.", + "", + "## Watch out", + "`/Rotate` can be 270 on a single page inside an otherwise upright file.", +].join("\n"); + +function note(over: Partial = {}): Note { + return { + path: ".trail/notes/2026-10-07-bbox-priya.md", + title: "Bbox coordinates are off on rotated PDFs", + author: "Priya", + date: "2026-10-07", + branch: "fix/bbox-rotation", + cost: { minutes: 12, tokens: 9600 }, + touches: ["internal/ocr/bbox.go#NormalizeBox", "internal/ocr/client.go"], + body: BODY, + ...over, + }; +} + +// --- the file format --- + +test("a note round-trips through its file, frontmatter and all", () => { + const n = note(); + const text = renderNote(n); + assert.match(text, /^---\ntitle: Bbox coordinates are off on rotated PDFs\nauthor: Priya\ndate: 2026-10-07\nbranch: fix\/bbox-rotation\ncost: \{ minutes: 12, tokens: 9600 \}\ntouches:\n - "internal\/ocr\/bbox.go#NormalizeBox"\n - internal\/ocr\/client.go\n---\n\n## Decided/); + assert.deepEqual(parseNote(text, n.path), n); +}); + +test("titles that YAML would misread are quoted, and still come back intact", () => { + const n = note({ title: "Retries: why #3 fails", touches: [], cost: undefined, branch: undefined }); + assert.equal(parseNote(renderNote(n), n.path)?.title, "Retries: why #3 fails"); +}); + +test("a file without a title isn't a note", () => { + assert.equal(parseNote("# just markdown\n", "x.md"), null); + assert.equal(parseNote("---\nauthor: x\n---\nbody", "x.md"), null); +}); + +test("writeNote names the file by date, title and author, and never overwrites", () => { + const repo = tmpRepo("notes-write"); + const a = writeNote(repo, { title: "Bbox coordinates are off on rotated PDFs", body: BODY, author: "Priya", date: "2026-10-07" }); + const b = writeNote(repo, { title: "Bbox coordinates are off on rotated PDFs", body: "again", author: "Priya", date: "2026-10-07" }); + assert.equal(a.path, ".trail/notes/2026-10-07-bbox-coordinates-are-off-on-rotated-priya.md"); + assert.equal(b.path, ".trail/notes/2026-10-07-bbox-coordinates-are-off-on-rotated-priya-2.md"); + assert.match(readFileSync(join(repo, a.path), "utf8"), /Rotate each box/); + assert.equal(listNotes(repo).length, 2); +}); + +test("ensureTrailDir writes the README once and keeps someone's edits", () => { + const repo = tmpRepo("notes-dir"); + assert.equal(ensureTrailDir(repo).created, true); + assert.match(readFileSync(join(repo, ".trail", "README.md"), "utf8"), /^# \.trail/); + assert.ok(existsSync(join(repo, ".trail", "notes", ".gitkeep"))); + writeFileSync(join(repo, ".trail", "README.md"), "ours\n"); + assert.equal(ensureTrailDir(repo).created, false); + assert.equal(readFileSync(join(repo, ".trail", "README.md"), "utf8"), "ours\n"); +}); + +test("small formatting helpers", () => { + assert.equal(slug("Bbox coordinates are off on rotated PDFs!"), "bbox-coordinates-are-off-on-rotated"); + assert.equal(shortDate("2026-10-07"), "Oct 7"); + assert.equal(tokensLabel(9600), "~9.6k tokens"); + assert.equal(tokensLabel(31_200), "~31k tokens"); + assert.equal(tokensLabel(600), "~600 tokens"); + assert.equal(costLabel({ minutes: 12, tokens: 9600 }), "took 12 min and ~9.6k tokens to work out"); + assert.equal(costLabel(undefined), null); + assert.equal(sectionLead(BODY, "Tried and ruled out"), "Rotating the image before OCR. Works, but doubles latency on long files."); + assert.equal(sectionLead(BODY, "Missing"), null); +}); + +// --- finding the notes that bear on a query --- + +test("a note shows up for a query that shares its words, or for results in a file it touches", () => { + const notes = [note(), note({ path: "b.md", title: "Webhook retries back off too fast", body: "## Decided\nUse jitter.", touches: ["internal/hooks/retry.go"] })]; + assert.equal(findNotes(notes, "bbox coordinates rotated pdf")[0]?.note.title, "Bbox coordinates are off on rotated PDFs"); + // Words in common with neither title, but the results land in a file the note touches. + const byFile = findNotes(notes, "thumbnail crop area", ["internal/ocr/bbox.go"]); + assert.equal(byFile.length, 1); + assert.deepEqual(byFile[0]?.shared, ["internal/ocr/bbox.go"]); +}); + +test("an unrelated note never rides along", () => { + assert.deepEqual(findNotes([note()], "logging setup for the worker"), []); + // One stray body word is not enough. + assert.deepEqual(findNotes([note()], "latency dashboards"), []); +}); + +test("ask's notes block names the author, the date, each section's lead and the cost", () => { + const lines = formatNoteHits([{ note: note(), score: 9, shared: [] }]); + assert.deepEqual(lines, [ + "from Priya's note · Oct 7 · Bbox coordinates are off on rotated PDFs", + " decided Rotate each box in `NormalizeBox` by its own page's `/Rotate` before scaling.", + " ruled out Rotating the image before OCR. Works, but doubles latency on long files.", + " watch out `/Rotate` can be 270 on a single page inside an otherwise upright file.", + " .trail/notes/2026-10-07-bbox-priya.md · took 12 min and ~9.6k tokens to work out", + "", + ]); +}); + +// --- what a session cost, from Claude Code's transcript --- + +function line(o: object): string { + return JSON.stringify(o); +} +const at = (min: number) => new Date(Date.parse("2026-10-07T10:00:00Z") + min * 60_000).toISOString(); +const usage = (input: number, created: number, output: number, read = 50_000) => ({ + input_tokens: input, + cache_creation_input_tokens: created, + cache_read_input_tokens: read, + output_tokens: output, +}); + +test("cost counts fresh tokens once per message, and active minutes only", () => { + const t = [ + line({ type: "user", timestamp: at(0), message: { content: "fix bbox" } }), + // One message, two content blocks: the usage repeats and must count once. + line({ type: "assistant", timestamp: at(1), message: { id: "m1", usage: usage(100, 2000, 300), content: [{ type: "text", text: "looking" }] } }), + line({ type: "assistant", timestamp: at(1), message: { id: "m1", usage: usage(100, 2000, 300), content: [{ type: "tool_use", id: "t1", name: "Bash", input: { command: "trail ask x" } }] } }), + // 40 minutes away from the keyboard: not counted. + line({ type: "assistant", timestamp: at(41), message: { id: "m2", usage: usage(50, 1000, 200), content: [] } }), + ].join("\n"); + const c = costFromTranscript(t, Date.parse(at(45))); + assert.equal(c?.tokens, 2400 + 1250); + assert.equal(c?.minutes, 5, "1 min, then the 40-min gap skipped, then 4 min to now"); +}); + +test("cost starts after the session's last finished note, not the one being written", () => { + const t = [ + line({ type: "assistant", timestamp: at(0), message: { id: "a", usage: usage(0, 9000, 1000), content: [{ type: "tool_use", id: "n1", name: "Bash", input: { command: "trail note --title first <<'EOF'" } }] } }), + line({ type: "user", timestamp: at(1), message: { content: [{ type: "tool_result", tool_use_id: "n1", content: "✓ note saved" }] } }), + line({ type: "assistant", timestamp: at(2), message: { id: "b", usage: usage(10, 400, 90), content: [] } }), + // The note call in flight now: no result yet, so it is not a boundary. + line({ type: "assistant", timestamp: at(3), message: { id: "c", usage: usage(0, 0, 100), content: [{ type: "tool_use", id: "n2", name: "Bash", input: { command: "trail note --title second" } }] } }), + ].join("\n"); + assert.equal(costFromTranscript(t, Date.parse(at(3)))?.tokens, 600); +}); + +test("no transcript entries, no cost", () => { + assert.equal(costFromTranscript("", Date.now()), null); + assert.equal(costFromTranscript("not json\n", Date.now()), null); +}); + +// --- the block that tells agents about .trail/ --- + +test("the Trail block tells an agent to read .trail/, how to install trail, and to leave a note", () => { + const b = trailBlock("claude"); + assert.match(b, /check `\.trail\/`/); + assert.match(b, /npm install -g @trailhq\/trail && trail init --agents claude --yes/); + assert.match(b, /trail note --title/); + assert.match(trailBlock(), /trail init --yes/); +}); + +// --- end to end --- + +/** Runs from this checkout (so `--import tsx` resolves); the repo goes in `args`. */ +function run(name: "trail" | "graft", args: string[], home: string, input?: string) { + const r = spawnSync(process.execPath, ["--import", "tsx", join(process.cwd(), "src", "bin", `${name}.ts`), ...args], { + input, + encoding: "utf8", + env: { ...process.env, ...homeEnv(home), DO_NOT_TRACK: "1", CLAUDECODE: undefined, TRAIL_INVOKED_AS: undefined }, + }); + return { status: r.status, out: r.stdout, err: r.stderr }; +} + +function repoWithCode(): string { + const d = tmpRepo("notes-e2e"); + mkdirSync(join(d, "internal", "ocr"), { recursive: true }); + writeFileSync(join(d, "internal", "ocr", "bbox.go"), "package ocr\n\nfunc NormalizeBox(x float64, rotate int) float64 {\n\treturn x\n}\n"); + writeFileSync(join(d, "CLAUDE.md"), "# extract\n\nTeam rules.\n"); + spawnSync("git", ["init", "-q"], { cwd: d }); + spawnSync("git", ["config", "user.name", "Priya Raman"], { cwd: d }); + spawnSync("git", ["config", "user.email", "p@example.com"], { cwd: d }); + spawnSync("git", ["add", "-A"], { cwd: d }); + spawnSync("git", ["-c", "commit.gpgsign=false", "commit", "-qm", "init"], { cwd: d }); + return d; +} + +test("trail init adds .trail/ and the Trail block; graft init adds neither", () => { + const home = mkdtempSync(join(tmpdir(), "notes-home-")); + const d = repoWithCode(); + const t = run("trail", ["init", d, "--agents", "claude", "--yes", "--no-global"], home); + assert.equal(t.status, 0, t.err); + assert.match(t.err, /✓ \.trail\/ {4}ready · every session you finish leaves a short note here/); + assert.match(t.err, /✓ CLAUDE\.md {2}Trail block added/); + assert.ok(existsSync(join(d, ".trail", "README.md"))); + const md = readFileSync(join(d, "CLAUDE.md"), "utf8"); + assert.ok(md.startsWith("# extract\n\nTeam rules.\n"), "the team's own text stays first"); + assert.match(md, /\n## Trail\n/); + + const g = repoWithCode(); + assert.equal(run("graft", ["init", g, "--agents", "claude", "--yes", "--no-global"], home).status, 0); + assert.equal(existsSync(join(g, ".trail")), false); + assert.doesNotMatch(readFileSync(join(g, "CLAUDE.md"), "utf8"), /trail:start/); +}); + +test("trail note saves the note, ask shows it to the next person, uninstall leaves it", () => { + const home = mkdtempSync(join(tmpdir(), "notes-home-")); + const d = repoWithCode(); + run("trail", ["init", d, "--agents", "claude", "--yes", "--no-global"], home); + // Init's files go in with the setup commit, as they would for a real team. + spawnSync("git", ["add", "-A"], { cwd: d }); + spawnSync("git", ["-c", "commit.gpgsign=false", "commit", "-qm", "wire trail"], { cwd: d }); + writeFileSync(join(d, "internal", "ocr", "bbox.go"), "package ocr\n\nfunc NormalizeBox(x float64, rotate int) float64 {\n\treturn -x\n}\n"); + + const saved = run("trail", ["note", d, "--title", "Bbox coordinates are off on rotated PDFs", "--minutes", "12", "--tokens", "9600"], home, BODY); + assert.equal(saved.status, 0, saved.err); + assert.match(saved.out, /^✓ note saved · \.trail\/notes\/\d{4}-\d{2}-\d{2}-bbox-coordinates-are-off-on-rotated-priya\.md$/m); + assert.match(saved.out, /this took 12 min and ~9\.6k tokens to figure out\. the next person who touches bbox\.go gets it for ~\d+/); + const [file] = readdirSync(join(d, ".trail", "notes")).filter((f) => f.endsWith(".md")); + const written = parseNote(readFileSync(join(d, ".trail", "notes", file!), "utf8"), "x"); + assert.equal(written?.author, "Priya"); + assert.deepEqual(written?.touches, ["internal/ocr/bbox.go"], "the changed file, not the wiring init wrote"); + + // A teammate still on graft gets the note too. + for (const name of ["trail", "graft"] as const) { + const ask = run(name, ["ask", "bbox rotated pdf", d], home); + assert.equal(ask.status, 0, ask.err); + assert.match(ask.out, /from Priya's note · \w{3} \d+ · Bbox coordinates are off on rotated PDFs/); + assert.match(ask.out, /ruled out {3}Rotating the image before OCR/); + } + + const un = run("trail", ["uninstall", d, "-y", "--no-global"], home); + assert.equal(un.status, 0, un.err); + assert.doesNotMatch(readFileSync(join(d, "CLAUDE.md"), "utf8"), /trail:start/); + assert.ok(existsSync(join(d, ".trail", "notes", file!)), "the team's notes are theirs, never uninstalled"); +}); + +test("trail note without a body says how to write one", () => { + const home = mkdtempSync(join(tmpdir(), "notes-home-")); + const r = run("trail", ["note", repoWithCode(), "--title", "x"], home, ""); + assert.equal(r.status, 1); + assert.match(r.err, /a note needs a --title and a body/); +}); From 5a0633cb1c9e48dc7a77f819b26776b0193c651a Mon Sep 17 00:00:00 2001 From: anirudhkumar-nanonets Date: Wed, 7 Oct 2026 12:00:09 +0530 Subject: [PATCH 2/5] feat(notes): hooks, statusline and skill point agents at .trail/ notes --- src/brand.ts | 35 +++++++++++++++++++- src/claude/format.ts | 54 +++++++++++++++++++++---------- src/claude/hooks.ts | 5 +-- src/claude/init.ts | 3 +- src/claude/shim-template.ts | 22 ++++++++----- src/claude/skill-template.ts | 43 +++++++++++++++++++++++- src/claude/statusline.ts | 4 ++- src/cli.ts | 6 +++- test/claude-shim-template.test.ts | 4 ++- test/trail-notes.test.ts | 35 ++++++++++++++++++++ 10 files changed, 178 insertions(+), 33 deletions(-) diff --git a/src/brand.ts b/src/brand.ts index 62db81fa1..e59c60f76 100644 --- a/src/brand.ts +++ b/src/brand.ts @@ -16,7 +16,7 @@ * * This file imports nothing heavier than node:fs, because the hooks load it. */ -import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { delimiter, dirname, join } from "node:path"; @@ -86,6 +86,39 @@ export function inBrand(text: string, b: Brand = brand()): string { return text.replace(/\bgraft_(?=[a-z])/g, "trail_").replace(/\bgraft\b(?![/\w-])/g, "trail"); } +/* -------------------------------------------------------------------------- */ +/* hooks and the statusline */ +/* -------------------------------------------------------------------------- */ + +/** `trail init` ran here: the repo keeps team notes in `.trail/`. */ +export function repoUsesTrail(repo: string): boolean { + return existsSync(join(repo, ".trail")); +} + +/** How many notes `.trail/notes/` holds, or 0. */ +export function noteCount(repo: string): number { + try { + return readdirSync(join(repo, ".trail", "notes")).filter((f) => f.endsWith(".md")).length; + } catch { + return 0; + } +} + +/** + * The name hooks and the statusline speak in `repo`. They run under whatever + * shim set the repo up, often one an older graft committed, so no `dist/bin` + * entry has set a name. They say trail in a repo that uses it, but only when + * the `trail` command is installed on this machine: a hook that tells the + * agent to run a command the machine doesn't have costs a failed tool call. + * A name already set (by the CLI that spawned this) is kept. + */ +export function adoptRepoBrand(repo: string, env: NodeJS.ProcessEnv = process.env): Brand { + if (env[BRAND_ENV] !== "trail" && env[BRAND_ENV] !== "graft") { + setBrand(repoUsesTrail(repo) && trailOnPath(env) ? "trail" : "graft", env); + } + return brand(env); +} + /* -------------------------------------------------------------------------- */ /* the once-a-day line for people still typing graft */ /* -------------------------------------------------------------------------- */ diff --git a/src/claude/format.ts b/src/claude/format.ts index e45bf9c52..396d9c072 100644 --- a/src/claude/format.ts +++ b/src/claude/format.ts @@ -6,6 +6,7 @@ import type { GraphV1, EdgeV1 } from '../graph/types.js'; // question in both places, and one set of calibrated numbers beats two. import { HIGH_FLOOR, STRONG_FLOOR } from '../ask/fuse.js'; import { dollarsSaved, formatDollars } from '../context/price.js'; +import { brand, cmd, tag } from '../brand.js'; const C = { indigo: (s: string) => `\x1b[38;2;84;111;255m${s}\x1b[0m`, @@ -25,13 +26,15 @@ export function freshnessSegment(s: Stats): string { export function renderStatusline( stats: Stats | null, session: SessionState | null, - ctx: { ctxPct: number | null }, + ctx: { ctxPct: number | null; notes?: number }, ): string[] { if (!stats) { - return [C.muted('◤ graft · not built · run ') + C.text('graft build')]; + return [C.muted(`◤ ${brand()} · not built · run `) + C.text(cmd('graft build'))]; } - const top = [C.muted('◤ ') + C.indigo('graft'), C.text(`${stats.nodeCount} nodes / ${stats.edgeCount} edges`)]; + const top = [C.muted('◤ ') + C.indigo(brand()), C.text(`${stats.nodeCount} nodes / ${stats.edgeCount} edges`)]; top.push(freshnessSegment(stats)); + // The team's notes, so a session that starts with some knows they're there. + if (ctx.notes) top.push(C.amber(`${ctx.notes} note${ctx.notes === 1 ? '' : 's'} in .trail/`)); const saved = session?.savedTokens ?? 0; if (saved > 0) { // Dollars only once a turn has actually been billed — see context/price.ts. @@ -110,8 +113,8 @@ function retrievalBody(hits: AskJson['hits']): string { // pulling spans itself via `graft ask --source` (push→pull: per-prompt injected // tokens are always fresh full-price input, so the pack stays tiny). const header = hits.some((h) => h.code) - ? '[graft] retrieved context, read these spans; do not re-open the files:' - : '[graft] starting points for this task: pull the code inline with `graft ask "" --source`, trace impact with `graft callers `, or search with `graft grep ""`:'; + ? `${tag()} retrieved context, read these spans; do not re-open the files:` + : `${tag()} starting points for this task: pull the code inline with \`${cmd('graft ask')} "" --source\`, trace impact with \`${cmd('graft callers')} \`, or search with \`${cmd('graft grep')} ""\`:`; return `${header}\n${blocks.join('\n')}`; } @@ -133,7 +136,7 @@ export function formatRetrieval(ask: AskJson, cap = 5): string | null { const base = tokensOf(ask.saved!.baselineChars); const pct = Math.round((saved / base) * 100); return ( - `${body}\n[graft] tokens saved ≈ ${saved.toLocaleString()} (${pct}%); this pack ≈ ` + + `${body}\n${tag()} tokens saved ≈ ${saved.toLocaleString()} (${pct}%); this pack ≈ ` + `${tokensOf(body.length).toLocaleString()} tok vs reading the ${ask.saved!.files} file(s) whole ≈ ` + `${base.toLocaleString()} tok (estimate).` ); @@ -208,28 +211,45 @@ export function relevantRetrieval(ask: AskJson, s: SessionState, cap = 3): strin return txt; } -export function formatOrientation(indexMd: string, budgetBytes = 1500, staleNote?: string): string { +export function formatOrientation(indexMd: string, budgetBytes = 1500, staleNote?: string, notes = 0): 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 // skill): it carries a one-line description of each tool AND the call-discipline // that keeps the agent from over-tooling. Positive only, names the tools, - // forbids nothing. + // forbids nothing. Spelled by the running name (brand.ts): under graft, byte + // for byte what it always was. + const b = brand(); const directive = - `[graft] This repo is indexed by graft. To find, understand, or change code, reach for graft first; it answers from a prebuilt graph with exact file:line, faster than grep/read. Pick the ONE tool that fits and act on its answer. Most tasks need a single call. If one isn't enough, switch to the tool that fits the next need; don't call the same tool again and again or re-ask a question reworded:\n` + - ` • graft ask "" --source: locate + understand. Ranked nodes with the code inlined at each file:line (the ≤8-line crux; add --full for the whole span). The default for "how does X work" / "where is Y".\n` + - ` • graft grep "": exhaustive find. Every occurrence, grouped by enclosing symbol; use when you need them ALL (ask is ranked top-N and misses instances).\n` + - ` • graft skeleton : a file's whole API in ~200 tokens, every signature + span, ~10x cheaper than reading the file.\n` + - ` • graft callers [--direction out] [--depth N|all]: exact edges. Who calls it (default), what it calls (--direction out), or the full blast radius (--depth 2, or --depth all for every connected source). Run before you change a symbol.\n` + - ` • graft map: orientation for an unfamiliar repo, directory clusters, hubs, hotspots. map alone is the answer; don't then skeleton every subsystem it names.\n` + + `${tag()} This repo is indexed by ${b}. To find, understand, or change code, reach for ${b} first; it answers from a prebuilt graph with exact file:line, faster than grep/read. Pick the ONE tool that fits and act on its answer. Most tasks need a single call. If one isn't enough, switch to the tool that fits the next need; don't call the same tool again and again or re-ask a question reworded:\n` + + ` • ${cmd('graft ask')} "" --source: locate + understand. Ranked nodes with the code inlined at each file:line (the ≤8-line crux; add --full for the whole span). The default for "how does X work" / "where is Y".\n` + + ` • ${cmd('graft grep')} "": exhaustive find. Every occurrence, grouped by enclosing symbol; use when you need them ALL (ask is ranked top-N and misses instances).\n` + + ` • ${cmd('graft skeleton')} : a file's whole API in ~200 tokens, every signature + span, ~10x cheaper than reading the file.\n` + + ` • ${cmd('graft callers')} [--direction out] [--depth N|all]: exact edges. Who calls it (default), what it calls (--direction out), or the full blast radius (--depth 2, or --depth all for every connected source). Run before you change a symbol.\n` + + ` • ${cmd('graft map')}: orientation for an unfamiliar repo, directory clusters, hubs, hotspots. map alone is the answer; don't then skeleton every subsystem it names.\n` + ` In a monorepo, add --in / to ask/grep/callers to scope to one sub-project; hits are labeled [scope/].\n` + - ` Already know the file or symbol to change? Go straight to it: graft grep "", read the span, edit. Save ask for when you don't yet know where the code lives.\n` + - ` Refactor, rename, or multi-file change? Run graft callers --depth all FIRST to map every connected file; editing the primary file and stopping is the classic miss (platform siblings, a new file to extract).\n` + - `Each tool opens its output with a "[graft] tokens saved ≈ N" line, sometimes with its dollar value; when you used graft this turn, close your reply with a one-line tally of the total saved, dollars included when given (e.g. 🌱 graft saved ~12k tokens (~$0.04) this turn, 3 calls). Never price tokens yourself; never pipe graft through head/tail — it is already capped, and clipping drops that line.\n`; + ` Already know the file or symbol to change? Go straight to it: ${cmd('graft grep')} "", read the span, edit. Save ask for when you don't yet know where the code lives.\n` + + ` Refactor, rename, or multi-file change? Run ${cmd('graft callers')} --depth all FIRST to map every connected file; editing the primary file and stopping is the classic miss (platform siblings, a new file to extract).\n` + + `Each tool opens its output with a "${tag()} tokens saved ≈ N" line, sometimes with its dollar value; when you used ${b} this turn, close your reply with a one-line tally of the total saved, dollars included when given (e.g. 🌱 ${b} saved ~12k tokens (~$0.04) this turn, 3 calls). Never price tokens yourself; never pipe ${b} through head/tail — it is already capped, and clipping drops that line.\n` + + teamNotesDirective(notes); const banner = staleNote ? `${staleNote}\n\n` : ""; return `${banner}${directive}\nrepo map (graft/INDEX.md):\n${indexMd.slice(0, budgetBytes)}`; } +/** + * The paragraph about `.trail/`, for a repo whose team keeps notes there: + * read them first, credit them in the tally, and leave one when done. Empty + * for a repo with none, so graft's directive is unchanged everywhere else. + */ +export function teamNotesDirective(notes: number): string { + if (notes <= 0) return ''; + return ( + `Teammates have left ${notes} note${notes === 1 ? '' : 's'} in .trail/ on what they decided, tried and ruled out. \`${cmd('graft ask')}\` puts the ones that bear on a task above the code; read them before exploring. ` + + `When a note saved you work, add to your tally how much of it came from that note, using the cost printed with it (e.g. ~9k tokens and ~12 min of it came from Priya's note on bbox rotation).\n` + + `When you finish a task that took real digging, leave a note for the next person: \`${cmd('graft note')} --title ""\` with the note on stdin, under ## Decided, ## Tried and ruled out and ## Watch out. A summary, never a transcript, and nothing secret: it gets committed.\n` + ); +} + export function renderSubagent(agentName: string, session: SessionState | null): string { const q = session?.perAgentQuery?.[agentName]; const tail = q ? SEP + C.muted('graft: ') + C.text(q) : ''; diff --git a/src/claude/hooks.ts b/src/claude/hooks.ts index 6bc61be10..c28b2f365 100644 --- a/src/claude/hooks.ts +++ b/src/claude/hooks.ts @@ -17,7 +17,7 @@ import { readLink } from '../brain/link.js'; import { pickedAgents } from '../brain/push.js'; import { readTrailSnapshot, trailContextLine } from '../brain/watch-trail.js'; import { maybeAutopush, readTrailPushState, recordSeenSuggestions, type AutopushDeps } from '../brain/autopush.js'; -import { tag } from '../brand.js'; +import { adoptRepoBrand, noteCount, tag } from '../brand.js'; /** Prompts shorter than this never trigger retrieval — they are almost always * conversational ("yes go ahead", "thanks") and the coverage gate can't judge @@ -462,6 +462,7 @@ export async function trailAtSessionStart( export async function main(event: string): Promise { const input = readStdin(); const dir = projectDir(input); + adoptRepoBrand(dir); if (event === 'session-start') { // The trail's quick look goes first, so its requests are in flight while the @@ -481,7 +482,7 @@ export async function main(event: string): Promise { try { const idx = readFileSync(join(resolveContextDir(dir), 'INDEX.md'), 'utf8'); const banner = staleBanner(indexFreshness(dir)) ?? undefined; - const orientation = formatOrientation(idx, undefined, banner); + const orientation = formatOrientation(idx, undefined, banner, noteCount(dir)); 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 0ea11d418..4972a3ee2 100644 --- a/src/claude/init.ts +++ b/src/claude/init.ts @@ -10,6 +10,7 @@ import { claudeDistDir } from './paths.js'; import { mergeJsonKey, serverEntry, type McpWrite } from '../hosts/mcp-config.js'; import { hasGraftIndex } from '../graph/root.js'; import type { PlannedWrite } from '../hosts/plan.js'; +import { repoUsesTrail } from '../brand.js'; /** * The files `runInit` writes — pure, no writes, so `--dry-run` and the picker @@ -87,7 +88,7 @@ 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()); + writeFileSync(skillPath, skillTemplate(repoUsesTrail(dir) ? "trail" : "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 diff --git a/src/claude/shim-template.ts b/src/claude/shim-template.ts index 39c4775cc..7bcb2a66c 100644 --- a/src/claude/shim-template.ts +++ b/src/claude/shim-template.ts @@ -27,12 +27,18 @@ const { execFileSync } = require('child_process'); const dir = process.env.CLAUDE_PROJECT_DIR || process.cwd(); const BAKED = ${JSON.stringify(bakedDir)}; -// The dist/claude dir of @nanonets/graft resolved from a base whose node_modules is searched. +// The same code ships as @trailhq/trail and as @nanonets/graft, its old name; +// either one answers, and the newer wins. +const PKGS = ['@trailhq/trail', '@nanonets/graft']; + +// The dist/claude dir of each package resolved from a base whose node_modules is searched. function fromPkg(base) { - try { - const pkg = require.resolve('@nanonets/graft/package.json', { paths: [base] }); - return path.join(path.dirname(pkg), 'dist', 'claude'); - } catch { return null; } + return PKGS.map((name) => { + try { + const pkg = require.resolve(name + '/package.json', { paths: [base] }); + return path.join(path.dirname(pkg), 'dist', 'claude'); + } catch { return null; } + }); } // The global node_modules dir per npm (handles Homebrew/Windows/volta). Queried on demand. @@ -76,12 +82,12 @@ function best(dirs, name) { function entry(name) { // Cheap candidates first, and only shell out to npm when every one of them misses. - const cheap = [BAKED, fromPkg(dir), fromPkg(path.join(path.dirname(process.execPath), '..', 'lib'))]; + const cheap = [BAKED, ...fromPkg(dir), ...fromPkg(path.join(path.dirname(process.execPath), '..', 'lib'))]; const hit = best(cheap, name); if (hit) return path.join(hit, name); const gr = globalRoot(); - const global = gr && path.join(gr, '@nanonets', 'graft', 'dist', 'claude'); - if (global && fs.existsSync(path.join(global, name))) return path.join(global, name); + const global = gr && best(PKGS.map((p) => path.join(gr, ...p.split('/'), 'dist', 'claude')), name); + if (global) return path.join(global, name); return path.join(dir, 'dist', 'claude', name); // last-ditch; import will no-op if absent } diff --git a/src/claude/skill-template.ts b/src/claude/skill-template.ts index 57fac7dc7..fa56aab96 100644 --- a/src/claude/skill-template.ts +++ b/src/claude/skill-template.ts @@ -1,9 +1,50 @@ +import type { Brand } from "../brand.js"; // The graft Claude Code skill, bundled as a string so `graft init` can write it into a // consumer repo's .claude/skills/graft/SKILL.md (no network fetch, version-locked to the // installed graft). This is the single source of truth for the skill text; graft's own // repo copy is regenerated from here when `init` runs in this repo. Mirrors the `.cjs` // shim pattern in shim-template.ts. -export function skillTemplate(): string { +// +// `brand` is the repo's name for the tool, not the machine's: a repo with +// `.trail/` gets the trail text, whoever refreshes it, so teammates on graft and +// trail never flip the committed file back and forth. +export function skillTemplate(brand: Brand = "graft"): string { + return brand === "trail" ? trailSkill(graftSkill()) : graftSkill(); +} + +/** + * The graft text with trail's spelling. The code map still lives in `graft/`, + * so a `graft` followed by `/` is a path and stays; `graft check` became + * `trail build --check`. Then the part only trail has: the team's notes. + */ +function trailSkill(graft: string): string { + const text = graft + .replace("### 6 · Lifecycle: \`graft build\` / \`graft check\`", "### 6 · Lifecycle: \`trail build\` / \`trail build --check\`") + .replace("\`check\` fails when \`graft/\` is stale, for CI.", "\`build --check\` fails when \`graft/\` is stale, for CI.") + .replace(/\bgraft\b(?![/\w-])/g, "trail") + // A skill's name matches its folder, and this one still lives in + // .claude/skills/graft/ until the wiring moves to trail names. + .replace(/^name: trail$/m, "name: graft"); + return `${text.trimEnd()} + +## Team notes in .trail/ +Teammates' sessions leave notes in \`.trail/notes/\`: what they decided, tried +and ruled out, and what it took to work out. \`trail ask\` puts the ones that +bear on your task above the code, so read them before exploring. When a note +saved you work, add to your tally how much of it came from that note, using +the cost printed with it. + +When you finish a task that took real digging, leave a note for the next +person: \`trail note --title ""\` with the note on stdin, +under \`## Decided\`, \`## Tried and ruled out\` and \`## Watch out\`. A summary, +never a transcript, and nothing secret: it gets committed. + +If \`trail\` isn't installed on this machine but \`graft\` is, it's the same +command under its old name. +`; +} + +function graftSkill(): string { return `--- name: graft description: This repo is indexed by graft/. For ANY task here, whether diff --git a/src/claude/statusline.ts b/src/claude/statusline.ts index e4c78a4c4..3952b6596 100644 --- a/src/claude/statusline.ts +++ b/src/claude/statusline.ts @@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'; import { renderStatusline, renderSubagent } from './format.js'; import { readStats, readSession, emptyStats, type Stats } from './state.js'; import { readWiring, computeStats } from './stats.js'; +import { adoptRepoBrand, noteCount } from '../brand.js'; /** * The statusline's fast path is the hook-maintained cache (graft/.cache/stats.json). @@ -25,11 +26,12 @@ export function main(): void { let input: any = {}; try { input = JSON.parse(readFileSync(0, 'utf8')); } catch { /* no/invalid stdin */ } const dir = process.env.CLAUDE_PROJECT_DIR || input.cwd || process.cwd(); + adoptRepoBrand(dir); const session = readSession(dir, input.session_id || 'default'); const agent = input?.agent?.name; if (agent) { process.stdout.write(renderSubagent(agent, session)); return; } const stats = resolveStats(dir); const raw = input?.context_window?.used_percentage; const ctxPct = typeof raw === 'number' ? Math.round(raw) : null; - process.stdout.write(renderStatusline(stats, session, { ctxPct }).join('\n')); + process.stdout.write(renderStatusline(stats, session, { ctxPct, notes: noteCount(dir) }).join('\n')); } diff --git a/src/cli.ts b/src/cli.ts index b8a4f92dc..233d5c5f2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1302,6 +1302,10 @@ async function runInitCommand( // Today's output, file by file, behind --verbose. Everything else gets one // line per step: the graph, then one per agent. const verbose = opts.verbose === true; + // Under trail, `.trail/` goes in first: the skill and hooks written next + // read its presence to decide they speak trail (brand.ts, repoUsesTrail). + const trailCreated = new Map(); + if (TRAIL) for (const target of targets) trailCreated.set(target, ensureTrailDir(target).created); const reports: WireReport[] = []; for (const target of targets) { if (verbose && target !== repo) console.error(`\n— ${relative(repo, target)}/`); @@ -1362,7 +1366,7 @@ async function runInitCommand( if (TRAIL) { const sectionFiles = [...new Set(HOSTS.filter((h) => ids.includes(h.id) && h.kind === "section").map((h) => h.relPath))]; for (const target of targets) { - const { created } = ensureTrailDir(target); + const created = trailCreated.get(target) === true; const writes = writeTrailBlocks(target, { claude: wantClaude, files: sectionFiles }); if (target !== repo) continue; const notes = listNotes(repo); diff --git a/test/claude-shim-template.test.ts b/test/claude-shim-template.test.ts index 08157c3c4..202791466 100644 --- a/test/claude-shim-template.test.ts +++ b/test/claude-shim-template.test.ts @@ -13,7 +13,9 @@ for (const [name, src] of [['statusline', statuslineShim(BAKED)], ['hooks', hook // 1. baked dir is present as the first candidate assert.match(src, new RegExp(`const BAKED = "${BAKED}"`)); // 2. repo node_modules via require.resolve from the project dir - assert.match(src, /require\.resolve\('@nanonets\/graft\/package\.json', \{ paths: \[base\] \}\)/); + // under both names: a teammate with only @trailhq/trail installed still gets hooks + assert.match(src, /const PKGS = \['@trailhq\/trail', '@nanonets\/graft'\];/); + assert.match(src, /require\.resolve\(name \+ '\/package\.json', \{ paths: \[base\] \}\)/); assert.match(src, /fromPkg\(dir\)/); // 3. legacy execPath/../lib guess retained assert.match(src, /path\.join\(path\.dirname\(process\.execPath\), '\.\.', 'lib'\)/); diff --git a/test/trail-notes.test.ts b/test/trail-notes.test.ts index 07e93b0a6..65b27ffcc 100644 --- a/test/trail-notes.test.ts +++ b/test/trail-notes.test.ts @@ -266,3 +266,38 @@ test("trail note without a body says how to write one", () => { assert.equal(r.status, 1); assert.match(r.err, /a note needs a --title and a body/); }); + +// --- what agents read in a repo that uses trail --- + +test("the skill speaks trail in a repo with .trail/, keeps graft/ paths, and teaches notes", async () => { + const { skillTemplate } = await import("../src/claude/skill-template.js"); + const graft = skillTemplate("graft"); + const trail = skillTemplate("trail"); + assert.equal(skillTemplate(), graft, "graft is the default, unchanged"); + assert.doesNotMatch(graft, /\.trail\//); + assert.match(trail, /^name: graft$/m, "the name still matches its folder"); + 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.doesNotMatch(trail, /\bgraft (ask|grep|skeleton|callers|map|build|check)\b/); + assert.match(trail, /trail note --title/); +}); + +test("the session-start directive mentions notes only where there are some", async () => { + const { formatOrientation } = await import("../src/claude/format.js"); + assert.doesNotMatch(formatOrientation("# map", 100), /\.trail\//); + assert.match(formatOrientation("# map", 100, undefined, 3), /Teammates have left 3 notes in \.trail\//); +}); + +test("hooks speak trail only in a repo that uses it, on a machine with trail installed", async () => { + const { adoptRepoBrand } = await import("../src/brand.js"); + const bin = mkdtempSync(join(tmpdir(), "notes-bin-")); + writeFileSync(join(bin, process.platform === "win32" ? "trail.cmd" : "trail"), ""); + const withTrail = tmpRepo("brand-repo"); + ensureTrailDir(withTrail); + const plain = tmpRepo("brand-plain"); + assert.equal(adoptRepoBrand(withTrail, { PATH: bin }), "trail"); + assert.equal(adoptRepoBrand(withTrail, { PATH: "" }), "graft", "trail not installed: don't suggest it"); + assert.equal(adoptRepoBrand(plain, { PATH: bin }), "graft"); + assert.equal(adoptRepoBrand(plain, { PATH: bin, TRAIL_INVOKED_AS: "trail" }), "trail", "a name already set is kept"); +}); From 18ed5e3b010e6b488b1621248efc7693573a1bf5 Mon Sep 17 00:00:00 2001 From: anirudhkumar-nanonets Date: Wed, 7 Oct 2026 12:33:03 +0530 Subject: [PATCH 3/5] feat(notes): repos set up with trail init use trail-named hooks, skill and MCP server --- src/claude/hooks.ts | 2 +- src/claude/init.ts | 46 +++++++++++++++++++++++------- src/claude/settings-merge.ts | 37 +++++++++++++----------- src/claude/skill-template.ts | 5 +--- src/hosts/config-write.ts | 3 +- src/hosts/mcp-config.ts | 24 ++++++++++++---- src/hosts/retract.ts | 36 ++++++++++++++--------- src/upkeep.ts | 2 +- test/claude-settings-merge.test.ts | 2 +- test/trail-notes.test.ts | 40 +++++++++++++++++++++++++- 10 files changed, 143 insertions(+), 54 deletions(-) diff --git a/src/claude/hooks.ts b/src/claude/hooks.ts index c28b2f365..368f0daae 100644 --- a/src/claude/hooks.ts +++ b/src/claude/hooks.ts @@ -91,7 +91,7 @@ function hookTimeoutIn(file: string, event: string): number | null { if (!Array.isArray(blocks)) return null; for (const block of blocks) { for (const h of block?.hooks ?? []) { - if (typeof h?.command === 'string' && h.command.includes('graft-hooks.cjs') && typeof h.timeout === 'number') { + if (typeof h?.command === 'string' && /(graft|trail)-hooks\.cjs/.test(h.command) && typeof h.timeout === 'number') { return h.timeout; } } diff --git a/src/claude/init.ts b/src/claude/init.ts index 4972a3ee2..ef5f68cbc 100644 --- a/src/claude/init.ts +++ b/src/claude/init.ts @@ -1,4 +1,4 @@ -import { mkdirSync, writeFileSync, readFileSync, chmodSync } from 'node:fs'; +import { mkdirSync, writeFileSync, readFileSync, chmodSync, rmSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { homedir } from 'node:os'; import { execFileSync } from 'node:child_process'; @@ -10,7 +10,7 @@ import { claudeDistDir } from './paths.js'; import { mergeJsonKey, serverEntry, type McpWrite } from '../hosts/mcp-config.js'; import { hasGraftIndex } from '../graph/root.js'; import type { PlannedWrite } from '../hosts/plan.js'; -import { repoUsesTrail } from '../brand.js'; +import { repoUsesTrail, type Brand } from '../brand.js'; /** * The files `runInit` writes — pure, no writes, so `--dry-run` and the picker @@ -18,15 +18,29 @@ import { repoUsesTrail } from '../brand.js'; * outside the project. */ export function claudeTargets(dir: string): PlannedWrite[] { + return claudeTargetsFor(dir, wiringName(dir)); +} + +/** + * The name the Claude Code wiring goes by in `dir`: trail's in a repo that uses + * trail (`trail init` made `.trail/`), graft's everywhere else. Decided by the + * repo, not the machine, so teammates on either command write the same files. + */ +export function wiringName(dir: string): Brand { + return repoUsesTrail(dir) ? 'trail' : 'graft'; +} + +/** `claudeTargets` under one name. Uninstall walks both, so either set comes out. */ +export function claudeTargetsFor(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 [ - t(join(dir, '.claude', 'settings.json'), 'graft statusline + hook blocks'), - t(join(dir, '.claude', 'helpers', 'graft-statusline.cjs'), 'statusline shim'), - t(join(dir, '.claude', 'helpers', 'graft-hooks.cjs'), 'hooks shim'), - t(join(dir, '.claude', 'skills', 'graft', 'SKILL.md'), 'graft skill'), + t(join(dir, '.claude', 'settings.json'), `${name} statusline + hook blocks`), + t(join(dir, '.claude', 'helpers', `${name}-statusline.cjs`), 'statusline shim'), + t(join(dir, '.claude', 'helpers', `${name}-hooks.cjs`), 'hooks shim'), + t(join(dir, '.claude', 'skills', name, 'SKILL.md'), `${name} skill`), // Tagged 'mcp' so the picker doesn't label Claude Code as having no MCP. - t(join(dir, '.mcp.json'), 'mcpServers.graft', 'mcp'), + t(join(dir, '.mcp.json'), `mcpServers.${name}`, 'mcp'), ]; } @@ -68,14 +82,15 @@ export function runInit( opts: { build?: boolean; cliPath?: string; statusline?: boolean; global?: boolean; home?: string } = {}, ): InitResult { // Same list `--dry-run` and the picker report, so the two can't drift apart. - const [settings, statusline, hooks, skill, mcpTarget] = claudeTargets(dir).map((t) => t.path); + const name = wiringName(dir); + const [settings, statusline, hooks, skill, mcpTarget] = claudeTargetsFor(dir, name).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 }); + const { merged, warnings } = mergeGraftSettings(existing, { statusline: opts.statusline, name }); writeFileSync(settingsPath, `${JSON.stringify(merged, null, 2)}\n`); const sl = statusline; @@ -88,12 +103,21 @@ 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(repoUsesTrail(dir) ? "trail" : "graft")); + 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 }); + } // 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()); + const mcp = mergeJsonKey('claude', mcpTarget, 'mcpServers', serverEntry({ brand: name }), name, name === 'trail' ? ['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 diff --git a/src/claude/settings-merge.ts b/src/claude/settings-merge.ts index 97f12f2b2..f772a977f 100644 --- a/src/claude/settings-merge.ts +++ b/src/claude/settings-merge.ts @@ -2,10 +2,10 @@ import { isGraftEntry } from '../hosts/config-write.js'; type Json = Record; -const SL_CMD = 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/graft-statusline.cjs"'; +const slCmd = (name: 'graft' | 'trail' = 'graft') => `node "\${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/${name}-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. */ -const GRAFT_STATUSLINE_HELPER = 'graft-statusline.cjs'; + * an older shim path or a GRAFT_DIR wrapper still names this file. Either name. */ +const STATUSLINE_HELPER = /(graft|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 @@ -29,13 +29,14 @@ const REPO_HELPERS = '${CLAUDE_PROJECT_DIR:-.}/.claude/helpers'; * `graft init` wrote a shim into that project — which is exactly the case the * global copy exists to cover, so it cannot reuse the repo form. */ -function hookCmd(arg: string, helpers: string = REPO_HELPERS): string { - return `node "${helpers}/graft-hooks.cjs" ${arg}`; +function hookCmd(arg: string, helpers: string = REPO_HELPERS, name: 'graft' | 'trail' = 'graft'): string { + return `node "${helpers}/${name}-hooks.cjs" ${arg}`; } -function graftBlocks(helpers?: string): Record { +function graftBlocks(helpers?: string, name: 'graft' | 'trail' = 'graft'): Record { + const hookCmd_ = (arg: string, h?: string) => hookCmd(arg, h, name); 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', helpers), 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 @@ -43,7 +44,7 @@ function graftBlocks(helpers?: string): Record { // `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__|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', helpers), 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 @@ -53,9 +54,9 @@ function graftBlocks(helpers?: string): Record { // 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', helpers), timeout: 15000 }] }], + SessionStart: [{ hooks: [{ type: 'command', command: hookCmd_('session-start', helpers), timeout: 8000 }] }], + Stop: [{ hooks: [{ type: 'command', command: hookCmd_('stop', helpers), timeout: 8000 }] }], }; } /** @@ -88,7 +89,7 @@ export function statuslineWanted(opts: { statusline?: boolean } = {}): boolean { function isGraftStatusline(value: unknown): boolean { if (!value || typeof value !== 'object') return false; const command = (value as Json).command; - return typeof command === 'string' && command.includes(GRAFT_STATUSLINE_HELPER); + return typeof command === 'string' && STATUSLINE_HELPER.test(command); } function applyStatusline( @@ -97,6 +98,7 @@ function applyStatusline( warnings: string[], wanted: boolean, foreignWarning: string, + name: 'graft' | 'trail' = 'graft', ): void { const current = merged[key]; const ours = isGraftStatusline(current); @@ -106,7 +108,7 @@ function applyStatusline( return; } if (!current || ours) { - merged[key] = { type: 'command', command: SL_CMD }; + merged[key] = { type: 'command', command: slCmd(name) }; return; } warnings.push(foreignWarning); @@ -114,23 +116,26 @@ function applyStatusline( export function mergeGraftSettings( existing: Json, - opts: { statusline?: boolean } = {}, + opts: { statusline?: boolean; name?: 'graft' | 'trail' } = {}, ): { merged: Json; warnings: string[] } { const merged: Json = { ...(existing ?? {}) }; + const name = opts.name ?? 'graft'; const warnings: string[] = []; const wanted = statuslineWanted(opts); applyStatusline( merged, 'statusLine', warnings, wanted, - 'Existing statusLine left untouched (a session allows only one). To use Graft, point it at .claude/helpers/graft-statusline.cjs.', + `Existing statusLine left untouched (a session allows only one). To use ${name === 'trail' ? 'Trail' : 'Graft'}, point it at .claude/helpers/${name}-statusline.cjs.`, + name, ); applyStatusline( merged, 'subagentStatusLine', warnings, wanted, 'Existing subagentStatusLine left untouched.', + name, ); merged.hooks = { ...(merged.hooks ?? {}) }; - for (const [event, blocks] of Object.entries(graftBlocks())) { + for (const [event, blocks] of Object.entries(graftBlocks(undefined, name))) { const prior = Array.isArray(merged.hooks[event]) ? merged.hooks[event] : []; const foreign = prior.filter((e: Json) => !isGraftEntry(e)); // drop old Graft entries → idempotent merged.hooks[event] = [...foreign, ...blocks]; diff --git a/src/claude/skill-template.ts b/src/claude/skill-template.ts index fa56aab96..0d1441818 100644 --- a/src/claude/skill-template.ts +++ b/src/claude/skill-template.ts @@ -21,10 +21,7 @@ function trailSkill(graft: string): string { const text = graft .replace("### 6 · Lifecycle: \`graft build\` / \`graft check\`", "### 6 · Lifecycle: \`trail build\` / \`trail build --check\`") .replace("\`check\` fails when \`graft/\` is stale, for CI.", "\`build --check\` fails when \`graft/\` is stale, for CI.") - .replace(/\bgraft\b(?![/\w-])/g, "trail") - // A skill's name matches its folder, and this one still lives in - // .claude/skills/graft/ until the wiring moves to trail names. - .replace(/^name: trail$/m, "name: graft"); + .replace(/\bgraft\b(?![/\w-])/g, "trail"); return `${text.trimEnd()} ## Team notes in .trail/ diff --git a/src/hosts/config-write.ts b/src/hosts/config-write.ts index e83d7439c..52c07ffb9 100644 --- a/src/hosts/config-write.ts +++ b/src/hosts/config-write.ts @@ -40,7 +40,8 @@ export function writeOwned(id: string, path: string, content: string, mode?: num * `?? ''` guards a stray `undefined` entry: `JSON.stringify(undefined)` is * `undefined`, whose `.includes` would throw. */ export function isGraftEntry(entry: unknown): boolean { - return JSON.stringify(entry ?? '').includes('graft-hooks.cjs'); + const s = JSON.stringify(entry ?? ''); + return s.includes('graft-hooks.cjs') || s.includes('trail-hooks.cjs'); } /** diff --git a/src/hosts/mcp-config.ts b/src/hosts/mcp-config.ts index 111da50f3..eb637f3a7 100644 --- a/src/hosts/mcp-config.ts +++ b/src/hosts/mcp-config.ts @@ -13,6 +13,7 @@ import { dirname, join } from 'node:path'; import { homedir } from 'node:os'; import type { PlannedWrite } from './plan.js'; import { readJsonObject, type ConfigWrite } from './config-write.js'; +import { trailOnPath } from '../brand.js'; /** MCP registration reports the same write-result record every installer does. */ export type McpWrite = ConfigWrite; @@ -45,6 +46,10 @@ export interface McpTarget extends PlannedWrite { */ const NPX_LAUNCH = { command: 'npx', args: ['-y', '@nanonets/graft', 'mcp'] }; const BIN_LAUNCH = { command: 'graft', args: ['mcp'] }; +// The same server under trail's name, for a repo that uses trail: its tools are +// `trail_*` (and it still answers `graft_*`). +const TRAIL_NPX_LAUNCH = { command: 'npx', args: ['-y', '@trailhq/trail', 'mcp'] }; +const TRAIL_BIN_LAUNCH = { command: 'trail', args: ['mcp'] }; function graftOnPath(): boolean { const r = spawnSync('graft', ['--version'], { stdio: 'ignore', timeout: 5000 }); @@ -59,9 +64,11 @@ function graftOnPath(): boolean { * don't depend on whether the machine running them happens to have graft installed. * `opts.onPath` is the same override for direct unit tests of both branches. */ -export function serverEntry(opts: { onPath?: boolean } = {}): { command: string; args: string[] } { +export function serverEntry(opts: { onPath?: boolean; brand?: 'graft' | 'trail' } = {}): { command: string; args: string[] } { + const trail = opts.brand === 'trail'; const forced = process.env.GRAFT_MCP_NPX; - if (forced !== undefined && forced !== '' && forced !== '0' && forced !== 'false') return NPX_LAUNCH; + if (forced !== undefined && forced !== '' && forced !== '0' && forced !== 'false') return trail ? TRAIL_NPX_LAUNCH : NPX_LAUNCH; + if (trail) return (opts.onPath ?? trailOnPath()) ? TRAIL_BIN_LAUNCH : TRAIL_NPX_LAUNCH; return (opts.onPath ?? graftOnPath()) ? BIN_LAUNCH : NPX_LAUNCH; } @@ -75,7 +82,12 @@ function dirExists(p: string): boolean { try { return statSync(p).isDirectory(); } catch { return false; } } -export function mergeJsonKey(id: string, path: string, topKey: string, entry: object): McpWrite { +/** + * `key` is the server's name in the file (`graft`, or `trail` in a repo that + * uses trail); `drop` names entries this write replaces, so a repo moving to + * trail doesn't start the same server twice. + */ +export function mergeJsonKey(id: string, path: string, topKey: string, entry: object, key = 'graft', drop: string[] = []): McpWrite { const loaded = readJsonObject(path); if (loaded === 'unparseable') return { id, path, action: 'skipped-unparseable' }; const { root, existed } = loaded; @@ -83,9 +95,11 @@ export function mergeJsonKey(id: string, path: string, topKey: string, entry: ob if (typeof bucket !== 'object' || bucket === null || Array.isArray(bucket)) { return { id, path, action: 'skipped-unparseable' }; } - if (JSON.stringify(bucket.graft) === JSON.stringify(entry)) return { id, path, action: 'unchanged' }; + const dropping = drop.filter((k) => k in bucket); + if (JSON.stringify(bucket[key]) === JSON.stringify(entry) && dropping.length === 0) return { id, path, action: 'unchanged' }; const action = existed ? 'updated' : 'created'; - bucket.graft = entry; + for (const k of dropping) delete bucket[k]; + bucket[key] = entry; mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(root, null, 2)}\n`); return { id, path, action }; diff --git a/src/hosts/retract.ts b/src/hosts/retract.ts index 5082a7423..802b0235c 100644 --- a/src/hosts/retract.ts +++ b/src/hosts/retract.ts @@ -32,7 +32,7 @@ import { mcpTargets, stripTomlSection } from './mcp-config.js'; import { hookTargets } from './codex-hooks.js'; import { antigravitySkillTargets } from './antigravity.js'; import { claudeGlobalTargets } from './claude-global.js'; -import { claudeTargets } from '../claude/init.js'; +import { claudeTargets, claudeTargetsFor } from '../claude/init.js'; import { isGraftAllowEntry, isGraftFooterRegex } from '../claude/settings-merge.js'; import type { WriteScope } from './plan.js'; @@ -183,11 +183,13 @@ function removeJsonKey(path: string, topKey: string, apply: boolean): RetractAct const bucket = root[topKey]; if (typeof bucket !== 'object' || bucket === null || Array.isArray(bucket)) return 'absent'; const map = bucket as Record; - if (!('graft' in map)) return 'absent'; + // Either name: a repo that uses trail registers the server as `trail`. + const ours = ['graft', 'trail'].filter((k) => k in map); + if (ours.length === 0) return 'absent'; if (!apply) { - return Object.keys(map).length === 1 && Object.keys(root).length === 1 ? 'deleted' : 'removed'; + return Object.keys(map).length === ours.length && Object.keys(root).length === 1 ? 'deleted' : 'removed'; } - delete map.graft; + for (const k of ours) delete map[k]; if (Object.keys(map).length === 0) delete root[topKey]; if (Object.keys(root).length === 0) return removeFile(path, true); writeFileSync(path, `${JSON.stringify(root, null, 2)}\n`); @@ -229,7 +231,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) => JSON.stringify(v ?? '').includes('graft-statusline.cjs'); + const isGraftCmd = (v: unknown) => /(graft|trail)-statusline\.cjs/.test(JSON.stringify(v ?? '')); for (const key of ['statusLine', 'subagentStatusLine']) { if (root[key] !== undefined && isGraftCmd(root[key])) delete root[key]; @@ -239,7 +241,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) => !JSON.stringify(e ?? '').includes('graft-hooks.cjs')); + const kept = prior.filter((e: unknown) => !/(graft|trail)-hooks\.cjs/.test(JSON.stringify(e ?? ''))); if (kept.length === 0) delete root.hooks[event]; else root.hooks[event] = kept; } @@ -282,7 +284,7 @@ function stripCodexHooks(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) => !JSON.stringify(e ?? '').includes('graft-hooks.cjs')); + const kept = prior.filter((e: unknown) => !/(graft|trail)-hooks\.cjs/.test(JSON.stringify(e ?? ''))); if (kept.length === 0) delete root.hooks[event]; else root.hooks[event] = kept; } @@ -374,7 +376,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 t of claudeTargets(repo)) keptPaths.add(t.path); + for (const n of ['graft', 'trail'] as const) for (const t of claudeTargetsFor(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); @@ -422,14 +424,22 @@ 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, statusline, hooks, skill, mcp] = claudeTargets(repo).map((t) => t.path); + const [settings, , , , mcp] = claudeTargets(repo).map((t) => t.path); const claudeMd = join(repo, 'CLAUDE.md'); + // 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); + 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) }, + { hostId: 'claude', path: skill, what: `${n} skill`, scope: 'repo', run: (a: boolean) => removeFile(skill, a) }, + ]; + }); for (const t of [ { hostId: 'claude', path: settings, what: 'statusline + hooks + allowlist + footer regex', scope: 'repo', run: (a) => stripClaudeSettings(settings, a) }, - { hostId: 'claude', path: statusline, what: 'statusline shim', scope: 'repo', run: (a) => removeFile(statusline, a) }, - { hostId: 'claude', path: hooks, what: 'hooks shim', scope: 'repo', run: (a) => removeFile(hooks, a) }, - { hostId: 'claude', path: skill, what: 'graft skill', scope: 'repo', run: (a) => removeFile(skill, a) }, - { hostId: 'claude', path: mcp, what: 'mcpServers.graft', scope: 'repo', run: (a) => removeJsonKey(mcp, 'mcpServers', a) }, + ...named, + { hostId: 'claude', path: mcp, what: 'mcpServers entry', scope: 'repo', run: (a) => removeJsonKey(mcp, 'mcpServers', a) }, // The Trail block `trail init` adds to CLAUDE.md. Only that block: the // file is the team's, and .trail/ itself is the team's notes, kept. { hostId: 'claude', path: claudeMd, what: 'Trail block', scope: 'repo', run: (a) => stripSection(claudeMd, a, [TRAIL_MARKERS]) }, diff --git a/src/upkeep.ts b/src/upkeep.ts index 62f5115f1..c231b3d7d 100644 --- a/src/upkeep.ts +++ b/src/upkeep.ts @@ -265,7 +265,7 @@ export function writeStamp( */ export function wiredHostIds(repo: string): string[] { const ids: string[] = []; - if (existsSync(join(repo, '.claude', 'helpers', 'graft-hooks.cjs'))) ids.push('claude'); + if (['graft', 'trail'].some((n) => existsSync(join(repo, '.claude', 'helpers', `${n}-hooks.cjs`)))) ids.push('claude'); for (const host of HOSTS) { const path = join(repo, host.relPath); if (!existsSync(path)) continue; diff --git a/test/claude-settings-merge.test.ts b/test/claude-settings-merge.test.ts index 32ac7251b..4a898d908 100644 --- a/test/claude-settings-merge.test.ts +++ b/test/claude-settings-merge.test.ts @@ -17,7 +17,7 @@ test('empty settings gets the full Graft blocks', () => { // accumulator over the retrieval tools (Bash `graft …`, the graft_* MCP tools) // and the source-read tools (Read/Grep/Glob) it scores against. const savings = merged.hooks.PostToolUse[1]; - assert.equal(savings.matcher, 'Bash|mcp__graft__|Read|Grep|Glob'); + assert.equal(savings.matcher, 'Bash|mcp__graft__|mcp__trail__|Read|Grep|Glob'); assert.ok(savings.hooks[0].command.includes('tool-savings'), 'savings hook wired'); assert.ok(merged.footerLinksRegexes.includes('graft/[\\w./-]+\\.md')); assert.deepEqual(warnings, []); diff --git a/test/trail-notes.test.ts b/test/trail-notes.test.ts index 65b27ffcc..85fc2049b 100644 --- a/test/trail-notes.test.ts +++ b/test/trail-notes.test.ts @@ -275,7 +275,7 @@ test("the skill speaks trail in a repo with .trail/, keeps graft/ paths, and tea const trail = skillTemplate("trail"); assert.equal(skillTemplate(), graft, "graft is the default, unchanged"); assert.doesNotMatch(graft, /\.trail\//); - assert.match(trail, /^name: graft$/m, "the name still matches its folder"); + 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/"); @@ -301,3 +301,41 @@ test("hooks speak trail only in a repo that uses it, on a machine with trail ins assert.equal(adoptRepoBrand(plain, { PATH: bin }), "graft"); assert.equal(adoptRepoBrand(plain, { PATH: bin, TRAIL_INVOKED_AS: "trail" }), "trail", "a name already set is kept"); }); + +// --- wiring under trail's names --- + +test("trail init moves a graft-wired repo to trail's names, and uninstall takes either set out", () => { + const home = mkdtempSync(join(tmpdir(), "notes-home-")); + const d = repoWithCode(); + const env = { GRAFT_MCP_NPX: "1" }; + Object.assign(process.env, env); + try { + assert.equal(run("graft", ["init", d, "--agents", "claude", "--yes", "--no-global"], home).status, 0); + assert.ok(existsSync(join(d, ".claude", "helpers", "graft-hooks.cjs"))); + assert.ok(existsSync(join(d, ".claude", "skills", "graft", "SKILL.md"))); + + 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", "mcp"] }); + + // Running it again changes nothing. + 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"]); + + 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); + } finally { + delete process.env.GRAFT_MCP_NPX; + } +}); From d4cafa94c2e923734177e69aaa3fd48e31b2c194 Mon Sep 17 00:00:00 2001 From: anirudhkumar-nanonets Date: Wed, 7 Oct 2026 13:46:05 +0530 Subject: [PATCH 4/5] feat(notes): keep notes in ~/.trail by repo, never in the repo --- TELEMETRY.md | 4 +- src/ask/ask.ts | 10 +- src/brand.ts | 24 +-- src/claude/format.ts | 29 +-- src/claude/hooks.ts | 5 +- src/claude/init.ts | 21 +- src/claude/skill-template.ts | 26 +-- src/claude/statusline.ts | 3 +- src/cli.ts | 108 +++++------ src/hosts/plan.ts | 3 +- src/hosts/retract.ts | 6 +- src/hosts/sections.ts | 11 +- src/notes/ask-notes.ts | 11 +- src/notes/git-facts.ts | 6 +- src/notes/home.ts | 362 +++++++++++++++++++++++++++++++++++ src/notes/instructions.ts | 54 ------ src/notes/notes.ts | 174 ++++++++++------- src/notes/session-cost.ts | 39 +++- src/telemetry/contract.ts | 2 +- test/trail-home.test.ts | 267 ++++++++++++++++++++++++++ test/trail-notes.test.ts | 150 +++++++++------ 21 files changed, 989 insertions(+), 326 deletions(-) create mode 100644 src/notes/home.ts delete mode 100644 src/notes/instructions.ts create mode 100644 test/trail-home.test.ts diff --git a/TELEMETRY.md b/TELEMETRY.md index 6c676ba6d..b178f80e4 100644 --- a/TELEMETRY.md +++ b/TELEMETRY.md @@ -46,13 +46,13 @@ The events: | `init_completed` | `agents` (the ids you selected, sorted), `consent` | `graft init` finishes | | `build_completed` | `files_bucket`, `langs`, `mode` (`fast`/`deep`), `duration_bucket`, `incremental` | A build succeeds | | `build_failed` | `stage`, `code` — both fixed enums | A build throws | -| `query` | `command`, `surface` (`cli`/`mcp`/`hook`), `hit` (`ask` only), `notes` (`0`/`1`/`2`, how many teammates' notes `ask` showed; only in a repo with `.trail/notes/`) | Any query command | +| `query` | `command`, `surface` (`cli`/`mcp`/`hook`), `hit` (`ask` only), `notes` (`0`/`1`/`2`, how many session notes `ask` showed; only in a repo that keeps notes) | Any query command | | `brain_signup_opened` | `mode` (`terminal`/`agent`) | `graft trail push` on a repo with no brain opens your browser to make one | | `brain_signup_settled` | `outcome` (`linked`/`timed_out`/`bad_callback`/`stopped`, and for an agent-run push `agent_link_opened`/`still_waiting`/`expired`/`unsupported`), `mode` (`terminal`/`agent`), `duration_bucket` | That handoff ends, one way or the other — for an agent-run push, each of its runs | | `trail_pulled` | `outcome` (`written`/`already_present`/`nothing_accepted`/`skipped`/`error`/`dry_run`), `kinds` (which kinds of context file were written: `claude_md`, `folder_claude_md`, `agents_md`, `cursor_rule`, `skill`), `files_bucket`, `changes_bucket`, `skipped_bucket`, `suggested_bucket` (every change Trail has suggested for the wired agents' files, whatever became of it) | `graft trail pull` (or `graft claude-md pull`) finishes | | `trail_watch_exit` | `reason` (`suggestions`/`accepted`/`timeout`/`refused`/`no_trail`), `suggested_bucket`, `accepted_bucket`, `duration_bucket` (how long it waited) | `graft trail watch` ends | | `trail_autopush` | `outcome` (`started`/`skipped`), `reason` when skipped (`disabled`/`no_head`/`head_unchanged`/`throttled`/`spawn_failed`) | A Claude Code or Codex session starts in a repo with a trail attached, and the hook decides whether to refresh it with a background `graft trail push`. Repos without a trail send nothing | -| `note_saved` | `has_cost` (`true`/`false`), `touches_bucket` (how many files the note is about, bucketed) | `trail note` saves a note into `.trail/notes/`. The title, the text and the paths are never sent | +| `note_saved` | `has_cost` (`true`/`false`), `touches_bucket` (how many files the note is about, bucketed) | `trail note` saves a session note on this machine (`~/.trail/`). The title, the text and the paths are never sent | | `session_summary` | `graft_reads_bucket`, `source_reads_bucket`, `saved_tokens_bucket`, `graft_turns_bucket`, `reported_turns_bucket` | Once, after an agent session ends | Two rules govern every value above, and both are enforced in code rather than by diff --git a/src/ask/ask.ts b/src/ask/ask.ts index f311ae9b5..b94a769b8 100644 --- a/src/ask/ask.ts +++ b/src/ask/ask.ts @@ -110,10 +110,10 @@ export interface AskResult { * callers must not re-sort this list by `score`. */ hits: AskHit[]; note?: string; - /** Teammates' notes from `.trail/notes/` that bear on this query, best first. + /** Past sessions' notes (notes/home.ts) that bear on this query, best first. * Set by `attachNotes` (notes/ask-notes.ts), never by the ranker. */ notes?: NoteHit[]; - /** The repo has a `.trail/notes/` folder, so an empty `notes` means "none + /** The repo keeps notes on this machine, so an empty `notes` means "none * about this yet" rather than "this repo keeps no notes". */ notesChecked?: boolean; /** Token-saving estimate, set only in `--source` (retriever) mode: the whole @@ -1510,12 +1510,12 @@ export function formatAsk(r: AskResult): string { .map((l) => (l.startsWith("structural index:") ? `⚠ ${l}` : l)) .join("\n") : ""; - // Teammates' notes go first: a decision someone already made, or an approach - // they already ruled out, changes how the code below should be read. + // Notes go first: a decision a past session already made, or an approach it + // already ruled out, changes how the code below should be read. const teamNotes = r.notes?.length ? formatNoteHits(r.notes) : r.notesChecked - ? ["· no notes in .trail/ about this yet", ""] + ? ["· no notes about this yet", ""] : []; if (r.hits.length === 0) { if (r.notes?.length) return `${head}\n\n${teamNotes.join("\n").trimEnd()}\n\n${noteBlock || "no code matches."}${escalationNudge(r)}\n`; diff --git a/src/brand.ts b/src/brand.ts index e59c60f76..ce7493279 100644 --- a/src/brand.ts +++ b/src/brand.ts @@ -16,7 +16,7 @@ * * This file imports nothing heavier than node:fs, because the hooks load it. */ -import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { delimiter, dirname, join } from "node:path"; @@ -90,31 +90,31 @@ export function inBrand(text: string, b: Brand = brand()): string { /* hooks and the statusline */ /* -------------------------------------------------------------------------- */ -/** `trail init` ran here: the repo keeps team notes in `.trail/`. */ -export function repoUsesTrail(repo: string): boolean { - return existsSync(join(repo, ".trail")); -} - -/** How many notes `.trail/notes/` holds, or 0. */ -export function noteCount(repo: string): number { +/** + * `trail init` wired this repo under trail's names: its committed hooks shim, + * skill or settings say trail. Read from the repo, not the machine, so every + * teammate's agent speaks the same name whichever command they installed. + */ +export function repoWiredForTrail(dir: string): boolean { + if (existsSync(join(dir, ".claude", "helpers", "trail-hooks.cjs")) || existsSync(join(dir, ".claude", "skills", "trail", "SKILL.md"))) return true; try { - return readdirSync(join(repo, ".trail", "notes")).filter((f) => f.endsWith(".md")).length; + return readFileSync(join(dir, ".claude", "settings.json"), "utf8").includes("trail-hooks.cjs"); } catch { - return 0; + return false; } } /** * The name hooks and the statusline speak in `repo`. They run under whatever * shim set the repo up, often one an older graft committed, so no `dist/bin` - * entry has set a name. They say trail in a repo that uses it, but only when + * entry has set a name. They say trail in a repo wired for it, but only when * the `trail` command is installed on this machine: a hook that tells the * agent to run a command the machine doesn't have costs a failed tool call. * A name already set (by the CLI that spawned this) is kept. */ export function adoptRepoBrand(repo: string, env: NodeJS.ProcessEnv = process.env): Brand { if (env[BRAND_ENV] !== "trail" && env[BRAND_ENV] !== "graft") { - setBrand(repoUsesTrail(repo) && trailOnPath(env) ? "trail" : "graft", env); + setBrand(repoWiredForTrail(repo) && trailOnPath(env) ? "trail" : "graft", env); } return brand(env); } diff --git a/src/claude/format.ts b/src/claude/format.ts index 396d9c072..5706c2520 100644 --- a/src/claude/format.ts +++ b/src/claude/format.ts @@ -33,8 +33,8 @@ export function renderStatusline( } const top = [C.muted('◤ ') + C.indigo(brand()), C.text(`${stats.nodeCount} nodes / ${stats.edgeCount} edges`)]; top.push(freshnessSegment(stats)); - // The team's notes, so a session that starts with some knows they're there. - if (ctx.notes) top.push(C.amber(`${ctx.notes} note${ctx.notes === 1 ? '' : 's'} in .trail/`)); + // Past sessions' notes, so a session that starts with some knows they're there. + if (ctx.notes) top.push(C.amber(`${ctx.notes} note${ctx.notes === 1 ? '' : 's'}`)); const saved = session?.savedTokens ?? 0; if (saved > 0) { // Dollars only once a turn has actually been billed — see context/price.ts. @@ -211,7 +211,7 @@ export function relevantRetrieval(ask: AskJson, s: SessionState, cap = 3): strin return txt; } -export function formatOrientation(indexMd: string, budgetBytes = 1500, staleNote?: string, notes = 0): string { +export function formatOrientation(indexMd: string, budgetBytes = 1500, staleNote?: string, notes = 0, keepsNotes = notes > 0): 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 @@ -231,22 +231,27 @@ export function formatOrientation(indexMd: string, budgetBytes = 1500, staleNote ` Already know the file or symbol to change? Go straight to it: ${cmd('graft grep')} "", read the span, edit. Save ask for when you don't yet know where the code lives.\n` + ` Refactor, rename, or multi-file change? Run ${cmd('graft callers')} --depth all FIRST to map every connected file; editing the primary file and stopping is the classic miss (platform siblings, a new file to extract).\n` + `Each tool opens its output with a "${tag()} tokens saved ≈ N" line, sometimes with its dollar value; when you used ${b} this turn, close your reply with a one-line tally of the total saved, dollars included when given (e.g. 🌱 ${b} saved ~12k tokens (~$0.04) this turn, 3 calls). Never price tokens yourself; never pipe ${b} through head/tail — it is already capped, and clipping drops that line.\n` + - teamNotesDirective(notes); + teamNotesDirective(notes, keepsNotes); const banner = staleNote ? `${staleNote}\n\n` : ""; return `${banner}${directive}\nrepo map (graft/INDEX.md):\n${indexMd.slice(0, budgetBytes)}`; } /** - * The paragraph about `.trail/`, for a repo whose team keeps notes there: - * read them first, credit them in the tally, and leave one when done. Empty - * for a repo with none, so graft's directive is unchanged everywhere else. + * The paragraph about notes, for a repo that keeps them on this machine + * (`kept`): read the ones there are first, credit them in the tally, and + * leave one when done. Empty for a repo that keeps none, so graft's directive + * is unchanged everywhere else. */ -export function teamNotesDirective(notes: number): string { - if (notes <= 0) return ''; +export function teamNotesDirective(notes: number, kept = notes > 0): string { + if (!kept) return ''; + const read = + notes > 0 + ? `This repo has ${notes} note${notes === 1 ? '' : 's'} from past sessions on what was decided, tried and ruled out. \`${cmd('graft ask')}\` puts the ones that bear on a task above the code; read them before exploring. ` + + `When a note saved you work, add to your tally how much of it came from that note, using the cost printed with it (e.g. ~9k tokens and ~12 min of it came from Priya's note on bbox rotation).\n` + : ''; return ( - `Teammates have left ${notes} note${notes === 1 ? '' : 's'} in .trail/ on what they decided, tried and ruled out. \`${cmd('graft ask')}\` puts the ones that bear on a task above the code; read them before exploring. ` + - `When a note saved you work, add to your tally how much of it came from that note, using the cost printed with it (e.g. ~9k tokens and ~12 min of it came from Priya's note on bbox rotation).\n` + - `When you finish a task that took real digging, leave a note for the next person: \`${cmd('graft note')} --title ""\` with the note on stdin, under ## Decided, ## Tried and ruled out and ## Watch out. A summary, never a transcript, and nothing secret: it gets committed.\n` + read + + `When you finish a task that took real digging, leave a note for the next session: \`${cmd('graft note')} --title ""\` with the note on stdin, under ## Decided, ## Tried and ruled out and ## Watch out. A summary, never a transcript, and nothing secret. It stays on this machine until its owner shares it.\n` ); } diff --git a/src/claude/hooks.ts b/src/claude/hooks.ts index 368f0daae..e363be379 100644 --- a/src/claude/hooks.ts +++ b/src/claude/hooks.ts @@ -17,7 +17,8 @@ import { readLink } from '../brain/link.js'; import { pickedAgents } from '../brain/push.js'; import { readTrailSnapshot, trailContextLine } from '../brain/watch-trail.js'; import { maybeAutopush, readTrailPushState, recordSeenSuggestions, type AutopushDeps } from '../brain/autopush.js'; -import { adoptRepoBrand, noteCount, tag } from '../brand.js'; +import { adoptRepoBrand, tag } from '../brand.js'; +import { keepsNotes, noteCount } from '../notes/home.js'; /** Prompts shorter than this never trigger retrieval — they are almost always * conversational ("yes go ahead", "thanks") and the coverage gate can't judge @@ -482,7 +483,7 @@ export async function main(event: string): Promise { try { const idx = readFileSync(join(resolveContextDir(dir), 'INDEX.md'), 'utf8'); const banner = staleBanner(indexFreshness(dir)) ?? undefined; - const orientation = formatOrientation(idx, undefined, banner, noteCount(dir)); + const orientation = formatOrientation(idx, undefined, banner, noteCount(dir), keepsNotes(dir)); 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 ef5f68cbc..0fb944285 100644 --- a/src/claude/init.ts +++ b/src/claude/init.ts @@ -10,24 +10,25 @@ import { claudeDistDir } from './paths.js'; import { mergeJsonKey, serverEntry, type McpWrite } from '../hosts/mcp-config.js'; import { hasGraftIndex } from '../graph/root.js'; import type { PlannedWrite } from '../hosts/plan.js'; -import { repoUsesTrail, type Brand } from '../brand.js'; +import { repoWiredForTrail, type Brand } from '../brand.js'; /** * 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. */ -export function claudeTargets(dir: string): PlannedWrite[] { - return claudeTargetsFor(dir, wiringName(dir)); +export function claudeTargets(dir: string, initBrand?: Brand): PlannedWrite[] { + return claudeTargetsFor(dir, wiringName(dir, initBrand)); } /** - * The name the Claude Code wiring goes by in `dir`: trail's in a repo that uses - * trail (`trail init` made `.trail/`), graft's everywhere else. Decided by the - * repo, not the machine, so teammates on either command write the same files. + * The name the Claude Code wiring goes by in `dir`: trail's where `trail init` + * is wiring it now (`initBrand`) or already did, graft's everywhere else. + * Otherwise decided by the repo, not the machine, so a teammate on graft + * refreshing the wiring writes the same files and never flips them back. */ -export function wiringName(dir: string): Brand { - return repoUsesTrail(dir) ? 'trail' : 'graft'; +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. */ @@ -79,10 +80,10 @@ export interface InitResult { export function runInit( dir: string, - opts: { build?: boolean; cliPath?: string; statusline?: boolean; global?: boolean; home?: string } = {}, + opts: { build?: boolean; cliPath?: string; statusline?: boolean; global?: boolean; home?: string; brand?: Brand } = {}, ): InitResult { // Same list `--dry-run` and the picker report, so the two can't drift apart. - const name = wiringName(dir); + const name = wiringName(dir, opts.brand); const [settings, statusline, hooks, skill, mcpTarget] = claudeTargetsFor(dir, name).map((t) => t.path); mkdirSync(dirname(statusline), { recursive: true }); diff --git a/src/claude/skill-template.ts b/src/claude/skill-template.ts index 0d1441818..466f399f6 100644 --- a/src/claude/skill-template.ts +++ b/src/claude/skill-template.ts @@ -5,9 +5,9 @@ import type { Brand } from "../brand.js"; // repo copy is regenerated from here when `init` runs in this repo. Mirrors the `.cjs` // shim pattern in shim-template.ts. // -// `brand` is the repo's name for the tool, not the machine's: a repo with -// `.trail/` gets the trail text, whoever refreshes it, so teammates on graft and -// trail never flip the committed file back and forth. +// `brand` is the repo's name for the tool, not the machine's: a repo wired by +// `trail init` gets the trail text, whoever refreshes it, so teammates on graft +// and trail never flip the committed file back and forth. export function skillTemplate(brand: Brand = "graft"): string { return brand === "trail" ? trailSkill(graftSkill()) : graftSkill(); } @@ -15,7 +15,7 @@ export function skillTemplate(brand: Brand = "graft"): string { /** * The graft text with trail's spelling. The code map still lives in `graft/`, * so a `graft` followed by `/` is a path and stays; `graft check` became - * `trail build --check`. Then the part only trail has: the team's notes. + * `trail build --check`. Then the part only trail has: session notes. */ function trailSkill(graft: string): string { const text = graft @@ -24,17 +24,19 @@ function trailSkill(graft: string): string { .replace(/\bgraft\b(?![/\w-])/g, "trail"); return `${text.trimEnd()} -## Team notes in .trail/ -Teammates' sessions leave notes in \`.trail/notes/\`: what they decided, tried -and ruled out, and what it took to work out. \`trail ask\` puts the ones that -bear on your task above the code, so read them before exploring. When a note -saved you work, add to your tally how much of it came from that note, using -the cost printed with it. +## Session notes +Past sessions on this repo leave notes: what they decided, tried and ruled +out, and what it took to work out. They're kept on each person's machine in +\`~/.trail/\`, never in the repo. \`trail ask\` puts the ones that bear on +your task above the code, so read them before exploring. When a note saved +you work, add to your tally how much of it came from that note, using the +cost printed with it. When you finish a task that took real digging, leave a note for the next -person: \`trail note --title ""\` with the note on stdin, +session: \`trail note --title ""\` with the note on stdin, under \`## Decided\`, \`## Tried and ruled out\` and \`## Watch out\`. A summary, -never a transcript, and nothing secret: it gets committed. +never a transcript, and nothing secret. It stays on this machine until its +owner shares it with \`trail login\`. If \`trail\` isn't installed on this machine but \`graft\` is, it's the same command under its old name. diff --git a/src/claude/statusline.ts b/src/claude/statusline.ts index 3952b6596..67ab78db3 100644 --- a/src/claude/statusline.ts +++ b/src/claude/statusline.ts @@ -2,7 +2,8 @@ import { readFileSync } from 'node:fs'; import { renderStatusline, renderSubagent } from './format.js'; import { readStats, readSession, emptyStats, type Stats } from './state.js'; import { readWiring, computeStats } from './stats.js'; -import { adoptRepoBrand, noteCount } from '../brand.js'; +import { adoptRepoBrand } from '../brand.js'; +import { noteCount } from '../notes/home.js'; /** * The statusline's fast path is the hook-maintained cache (graft/.cache/stats.json). diff --git a/src/cli.ts b/src/cli.ts index 233d5c5f2..edad325c9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -21,9 +21,8 @@ import { formatGraphCheckReport } from "./graph/check.js"; import { buildGraphIfMissing, runInit } from "./claude/init.js"; import { statuslineWanted } from "./claude/settings-merge.js"; import { runHostsInit } from "./hosts/init.js"; -import { HOSTS, hostIds } from "./hosts/registry.js"; -import { ensureTrailDir } from "./notes/notes.js"; -import { writeTrailBlocks } from "./notes/instructions.js"; +import { hostIds } from "./hosts/registry.js"; +import { ensureRepoHome, keepsNotes, noteCount, repoPlace, shownPath } from "./notes/home.js"; import { parseBrainArg, connectBrain, pullBrain, brainStatus } from "./brain/connect.js"; import { rulesForPointers } from "./brain/attach.js"; import { clearLink, type BrainLink } from "./brain/link.js"; @@ -92,9 +91,9 @@ import { patchBuildConfig, type BuildConfig } from "./util/state.js"; import { normalizePathPrefix } from "./util/paths.js"; import { latestSession, formatSessionStats, sessionInputRate } from "./claude/session-metrics.js"; import { listSessionIds, readSession, sessionDir } from "./claude/state.js"; -import { costLabel, listNotes, renderNote, SECTIONS, sectionLead, shortDate, tokensLabel, writeNote } from "./notes/notes.js"; +import { costLabel, listNotes, noteTargets, renderNote, SECTIONS, sectionLead, shortDate, tokensLabel, writeNote, type Note } from "./notes/notes.js"; import { currentSessionCost } from "./notes/session-cost.js"; -import { changedFiles, currentBranch, noteAuthor } from "./notes/git-facts.js"; +import { currentBranch, noteAuthor } from "./notes/git-facts.js"; import { setInputRate } from "./context/savings.js"; import { formatUpdateNudge, maybeRefreshInBackground, readStamp, readUpdateCache, refreshUpdateCache, wiredHostIds, writeStamp } from "./upkeep.js"; import { @@ -720,7 +719,7 @@ grouped(program.command("ask"), GROUP.find) return; } noteHit(r.hits.length > 0); - // How many teammates' notes reached the agent: the demand side of .trail/. + // How many notes reached the agent: the demand side of `trail note`. if (r.notesChecked) queryNote.notes = String(Math.min(r.notes?.length ?? 0, 2)); if (opts.json) { console.log(JSON.stringify(r, null, 2)); @@ -1063,10 +1062,10 @@ grouped(program.command("map"), GROUP.find) // under trail; under graft it still runs, unlisted, for a teammate on graft in // a repo where someone else set up trail. grouped(program.command("note", { hidden: !TRAIL }), GROUP.memory) - .description("Save what this session decided, tried and ruled out, as a note in .trail/notes/ (the note itself on stdin)") + .description("Save what this session decided, tried and ruled out, as a note kept in ~/.trail (the note itself on stdin)") .requiredOption("--title ", "what the session was about, in a few words") .option("-m, --body ", "the note itself, instead of stdin") - .option("--touches ", "comma-separated files or file#Symbol it's about (default: the files this session changed)") + .option("--touches ", "comma-separated files or file#Symbol it's about (default: the files this session changed, in every repo it changed)") .option("--author ", "who wrote it (default: the first word of git config user.name)") .option("--minutes ", "what it took to work out, when the agent's transcript can't say") .option("--tokens ", "the same, in tokens") @@ -1138,33 +1137,35 @@ async function runNoteCommand( minutes !== undefined || tokens !== undefined || spent ? { minutes: minutes ?? spent?.minutes, tokens: tokens ?? spent?.tokens } : undefined; - const touches = opts.touches - ? opts.touches.split(",").map((t) => t.trim()).filter(Boolean) - : changedFiles(repo, spent?.startedAt); - const earlier = listNotes(repo); - const note = writeNote(repo, { - title: opts.title, - body, - author: opts.author?.trim() || noteAuthor(repo), - date: new Date().toISOString().slice(0, 10), - branch: currentBranch(repo), - cost, - touches, - }); + // Which repos it goes to: the one it was saved from, plus any other the + // session edited. Explicit --touches means just this repo. + const targets = opts.touches + ? [{ dir: repo, touches: opts.touches.split(",").map((t) => t.trim()).filter(Boolean) }] + : noteTargets(repo, spent?.edited ?? [], spent?.startedAt); + const author = opts.author?.trim() || noteAuthor(repo); + const date = new Date().toISOString().slice(0, 10); + const earlier: Note[] = []; + const saved: Note[] = []; + for (const t of targets) { + earlier.push(...listNotes(t.dir)); + saved.push(writeNote(t.dir, { title: opts.title, body, author, date, branch: currentBranch(t.dir), cost, touches: t.touches })); + } + const touches = targets.flatMap((t) => t.touches); track("note_saved", { has_cost: String(cost?.tokens !== undefined || cost?.minutes !== undefined), touches_bucket: countBucket(touches.length) }, { repo }); if (opts.json) { - console.log(JSON.stringify(note, null, 2)); + console.log(JSON.stringify(saved.length === 1 ? saved[0] : saved, null, 2)); return; } - console.log(`✓ note saved · ${note.path}`); + for (const n of saved) console.log(`✓ note saved · ${shownPath(n.path)}`); + const note = saved[0]!; const took = costLabel(note.cost); if (took) { // Name a code file if the note touches one; docs are rarely what it's about. const about = touches.find((t) => !/\.(md|mdx|txt)$/i.test(t.split("#")[0]!)) ?? touches[0]; - const first = about ? ` who touches ${basename(about.split("#")[0]!)}` : ""; + const first = about ? ` that touches ${basename(about.split("#")[0]!)}` : ""; const reading = tokensLabel(Math.ceil(renderNote(note).length / 4)).replace(" tokens", ""); - console.log(`· this ${took.replace(" to work out", " to figure out")}. the next person${first} gets it for ${reading}`); + console.log(`· this ${took.replace(" to work out", " to figure out")}. the next session${first} gets it for ${reading}`); } // Notes about the same files, from someone else: this one builds on theirs. const files = new Set(touches.map((t) => t.split("#")[0])); @@ -1172,7 +1173,7 @@ async function runNoteCommand( if (prior) console.log(`· builds on ${prior.author ? `${prior.author}'s` : "a"} note from ${shortDate(prior.date)}`); if (!SECTIONS.some((s) => sectionLead(body, s.heading) !== null)) console.log("· tip: notes read best under ## Decided, ## Tried and ruled out and ## Watch out"); - console.log("· commit .trail/ so your team gets this"); + console.log("· kept on this machine, never in the repo"); } /** `graft init`'s flags, as commander hands them over. */ @@ -1302,10 +1303,14 @@ async function runInitCommand( // Today's output, file by file, behind --verbose. Everything else gets one // line per step: the graph, then one per agent. const verbose = opts.verbose === true; - // Under trail, `.trail/` goes in first: the skill and hooks written next - // read its presence to decide they speak trail (brand.ts, repoUsesTrail). - const trailCreated = new Map(); - if (TRAIL) for (const target of targets) trailCreated.set(target, ensureTrailDir(target).created); + // Under trail, each repo gets its notes folder in ~/.trail. Nothing goes + // into the repo for it: notes stay on this machine until shared. + const notesHome = new Map(); + if (TRAIL) + for (const target of targets) { + const { place, created } = ensureRepoHome(target); + notesHome.set(target, { created, dir: place.dir }); + } const reports: WireReport[] = []; for (const target of targets) { if (verbose && target !== repo) console.error(`\n— ${relative(repo, target)}/`); @@ -1359,30 +1364,14 @@ async function runInitCommand( } } - // Under trail: the team's notes folder, and the block that tells every - // agent to read it first and leave a note when done. In each repo of a - // workspace, since each is committed on its own. - let trailCommit: string[] = []; + // Under trail: where this repo's notes are, and how many there already are. if (TRAIL) { - const sectionFiles = [...new Set(HOSTS.filter((h) => ids.includes(h.id) && h.kind === "section").map((h) => h.relPath))]; - for (const target of targets) { - const created = trailCreated.get(target) === true; - const writes = writeTrailBlocks(target, { claude: wantClaude, files: sectionFiles }); - if (target !== repo) continue; + const home = notesHome.get(repo); + if (home) { const notes = listNotes(repo); - const skills = countDirs(join(repo, ".trail", "skills")); - if (created || notes.length === 0) { - console.error("✓ .trail/ ready · every session you finish leaves a short note here"); - } else { - const authors = [...new Set(notes.map((n) => n.author).filter(Boolean))]; - const from = authors.length ? ` from ${joinAnd(authors.length > 3 ? [...authors.slice(0, 2), `${authors.length - 2} others`] : authors)}` : ""; - console.error(`✓ .trail/ ${notes.length} note${notes.length === 1 ? "" : "s"}${from}${skills ? ` · ${skills} skill${skills === 1 ? "" : "s"}` : ""}`); - } - for (const w of writes) { - if (w.action === "unchanged") continue; - console.error(`✓ ${shown(repo, w.path).padEnd(10)} Trail block ${w.action === "replaced" ? "updated" : "added"}: check .trail/ before exploring`); - } - trailCommit = [".trail/", ...writes.map((w) => shown(repo, w.path))]; + const where = shownPath(home.dir); + if (home.created || notes.length === 0) console.error(`✓ notes kept in ${where} · every session you finish leaves a short note there, never in the repo`); + else console.error(`✓ notes ${notes.length} note${notes.length === 1 ? "" : "s"} in ${where}`); } } @@ -1410,7 +1399,7 @@ async function runInitCommand( if (!verbose && how.epilogue !== false) { // What there is to commit: the top-level entries the picked agents wrote. - const tops: string[] = [...trailCommit]; + const tops: string[] = []; for (const w of selectedWrites(plan, ids)) { if (w.scope !== "repo") continue; const parts = shown(repo, w.path).split("/"); @@ -1421,7 +1410,7 @@ async function runInitCommand( if (tops.length) console.error( `· commit ${tops.slice(0, 4).join(" ")}${tops.length > 4 ? ` +${tops.length - 4} more` : ""} to share it — ` + - (TRAIL ? "the code map in graft/ stays on this machine, .trail/ is meant to be committed" : "graft/ stays local and git-ignored"), + (TRAIL ? "the code map in graft/ and your notes stay on this machine" : "graft/ stays local and git-ignored"), ); } @@ -1498,7 +1487,7 @@ function wireTarget( // `global`/`home` are threaded through alongside `statusline`: the claude layer // writes under `~/.claude` now (hosts/claude-global.ts), so --no-global has to // reach it or the flag would silently mean "no out-of-repo writes, except three". - const res = runInit(repo, { build: opts.build, cliPath, statusline: wantStatusline, global: opts.global, home }); + 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}`); @@ -2219,8 +2208,9 @@ function shortCount(n: number): string { /** * `trail status`: what was `graft stats` and `graft trail status`, on one - * screen. The code map and whether it matches the code, the Trail link and - * how many of its rules still match, and what the agents saved this week. + * screen. The code map and whether it matches the code, the notes kept for + * this repo in ~/.trail, the Trail link and how many of its rules still match, + * and what the agents saved this week. */ function statusCommand(name = "status"): Command { return new Command(name) @@ -2232,6 +2222,8 @@ function statusCommand(name = "status"): Command { const graph = loadGraphCached(contextDirFor(repo, program.opts().dir)); const g = graph ? await engineFrom().checkGraph(repo) : null; const fresh = g && !g.missing ? g.ok : null; + const kept = keepsNotes(repo); + const notes = noteCount(repo); const { link, rules } = brainStatus(repo); const anchored = link && graph ? rulesForPointers(graph.nodes.map((n) => `${n.path}:${n.span}`), rules, graph) : []; const matching = anchored.filter((a) => !a.stale).length; @@ -2242,6 +2234,7 @@ function statusCommand(name = "status"): Command { JSON.stringify( { codeMap: graph ? { nodes: graph.meta.nodeCount, inSync: fresh } : null, + notes: kept ? { count: notes, dir: repoPlace(repo).dir } : null, cloud: link ? { brainId: link.brainId, cached: rules.length, anchored: anchored.length, matching } : null, week: { savedTokens: saved }, }, @@ -2256,6 +2249,7 @@ function statusCommand(name = "status"): Command { if (!graph) row("code map", `✗ not built · run ${cmd("graft build")}`); else if (fresh === false) row("code map", `⚠ ${fmt(graph.meta.nodeCount)} nodes · behind the code, refreshed on the next query`); else row("code map", `✓ ${fmt(graph.meta.nodeCount)} nodes · in sync with the code`); + if (kept) row("notes", `${notes} · in ${shownPath(repoPlace(repo).dir)}`); if (!link) row("cloud", `not signed in · ${cmd("graft trail connect")}`); else row( diff --git a/src/hosts/plan.ts b/src/hosts/plan.ts index 38b225b38..ba88e4be0 100644 --- a/src/hosts/plan.ts +++ b/src/hosts/plan.ts @@ -16,6 +16,7 @@ import { hookTargets } from './codex-hooks.js'; import { cursorHookTargets } from './cursor-hooks.js'; import { antigravitySkillTargets } from './antigravity.js'; import { claudeTargets } from '../claude/init.js'; +import { brand } from '../brand.js'; import { claudeGlobalTargets } from './claude-global.js'; /** Where a write lands. 'global' = outside the repo, affects every project. */ @@ -74,7 +75,7 @@ export function planInit(repo: string, opts: { home?: string; ids?: string[] } = // Repo writes plus the user-level copy under `~/.claude` — the picker and // `--dry-run` render 'global' writes in their own section, so a user sees // what lands outside the repo before agreeing to it. - { id: 'claude', name: 'Claude Code', detected: true, writes: [...claudeTargets(repo), ...claudeGlobalTargets(home)] }, + { id: 'claude', name: 'Claude Code', detected: true, writes: [...claudeTargets(repo, brand()), ...claudeGlobalTargets(home)] }, ...HOSTS.map((host) => ({ id: host.id, name: host.name, diff --git a/src/hosts/retract.ts b/src/hosts/retract.ts index 802b0235c..e9590a3e4 100644 --- a/src/hosts/retract.ts +++ b/src/hosts/retract.ts @@ -27,7 +27,7 @@ import { readFileSync, writeFileSync, existsSync, rmSync, rmdirSync, statSync, r import { join, dirname } from 'node:path'; import { homedir } from 'node:os'; import { HOSTS } from './registry.js'; -import { ALL_MARKERS, TRAIL_MARKERS, type Markers } from './sections.js'; +import { ALL_MARKERS, type Markers } from './sections.js'; import { mcpTargets, stripTomlSection } from './mcp-config.js'; import { hookTargets } from './codex-hooks.js'; import { antigravitySkillTargets } from './antigravity.js'; @@ -425,7 +425,6 @@ 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 claudeMd = join(repo, 'CLAUDE.md'); // 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) => { @@ -440,9 +439,6 @@ function targets(repo: string, opts: RetractOpts): Target[] { { hostId: 'claude', path: settings, what: 'statusline + hooks + allowlist + footer regex', scope: 'repo', run: (a) => stripClaudeSettings(settings, a) }, ...named, { hostId: 'claude', path: mcp, what: 'mcpServers entry', scope: 'repo', run: (a) => removeJsonKey(mcp, 'mcpServers', a) }, - // The Trail block `trail init` adds to CLAUDE.md. Only that block: the - // file is the team's, and .trail/ itself is the team's notes, kept. - { hostId: 'claude', path: claudeMd, what: 'Trail block', scope: 'repo', run: (a) => stripSection(claudeMd, a, [TRAIL_MARKERS]) }, ] as Target[]) add(t); } diff --git a/src/hosts/sections.ts b/src/hosts/sections.ts index c7a88b1f7..12dd2ed3b 100644 --- a/src/hosts/sections.ts +++ b/src/hosts/sections.ts @@ -30,17 +30,8 @@ export interface Markers { export const GRAFT_MARKERS: Markers = { start: START, end: END }; export const BRAIN_MARKERS: Markers = { start: BRAIN_START, end: BRAIN_END }; -/** - * The Trail block: tells an agent to check the team's notes in `.trail/` - * before exploring, and to leave one when it's done. Its own region, so it - * lives beside graft's code-map block and is removed with the rest. - */ -export const TRAIL_START = ''; -export const TRAIL_END = ''; -export const TRAIL_MARKERS: Markers = { start: TRAIL_START, end: TRAIL_END }; - /** Every managed region graft may own in a user-owned file. */ -export const ALL_MARKERS: Markers[] = [GRAFT_MARKERS, BRAIN_MARKERS, TRAIL_MARKERS]; +export const ALL_MARKERS: Markers[] = [GRAFT_MARKERS, BRAIN_MARKERS]; export type UpsertAction = 'created' | 'appended' | 'replaced' | 'unchanged'; diff --git a/src/notes/ask-notes.ts b/src/notes/ask-notes.ts index 4b3af5821..d8067f1ed 100644 --- a/src/notes/ask-notes.ts +++ b/src/notes/ask-notes.ts @@ -1,19 +1,20 @@ /** - * Puts teammates' notes next to a query's results, for every surface that + * Puts past sessions' notes next to a query's results, for every surface that * answers through `Graft.ask` (the CLI's `ask` and the MCP `find_code`). */ -import { existsSync } from "node:fs"; import type { AskResult } from "../ask/ask.js"; -import { findNotes, listNotes, notesDir } from "./notes.js"; +import { findRepoPlace, keepsNotes } from "./home.js"; +import { findNotes, listNotes } from "./notes.js"; /** The file part of a hit's `file:Lx-Ly` pointer. */ function pointerFile(pointer: string): string { return pointer.replace(/:L\d+(-L?\d+)?$/, ""); } -/** Attach the notes that bear on `r`, in place. A repo without `.trail/notes/` is left alone. */ +/** Attach the notes that bear on `r`, in place. A repo that keeps no notes on this machine is left alone. */ export function attachNotes(r: AskResult, repo: string): AskResult { - if (!existsSync(notesDir(repo))) return r; + findRepoPlace(repo); // a repo whose remote changed finds its notes again + if (!keepsNotes(repo)) return r; r.notesChecked = true; const hits = findNotes(listNotes(repo), r.query, r.hits.map((h) => pointerFile(h.pointer))); if (hits.length) r.notes = hits; diff --git a/src/notes/git-facts.ts b/src/notes/git-facts.ts index 58cf19e79..7c6a32dd9 100644 --- a/src/notes/git-facts.ts +++ b/src/notes/git-facts.ts @@ -36,9 +36,9 @@ export function currentBranch(repo: string): string | undefined { return b && b !== "HEAD" ? b : undefined; } -/** Paths that are trail's own or agent wiring, never what a note is about. */ -function ownPath(p: string): boolean { - return /^(\.trail|graft|\.graft|\.claude|\.cursor|\.codex)(\/|$)/.test(p) || p === ".mcp.json"; +/** Paths that are the code map or agent wiring, never what a note is about. */ +export function ownPath(p: string): boolean { + return /^(graft|\.graft|\.claude|\.cursor|\.codex)(\/|$)/.test(p) || p === ".mcp.json"; } /** diff --git a/src/notes/home.ts b/src/notes/home.ts new file mode 100644 index 000000000..30c6de506 --- /dev/null +++ b/src/notes/home.ts @@ -0,0 +1,362 @@ +/** + * Where a repo's notes and takeaways live: on this machine, under + * `~/.trail/repos//`, never inside the repo. Nothing trail saves about a + * session shows up in `git status` or in anyone's pull request, and nothing + * leaves the machine until its owner signs in to share it. + * + * The key names the repo, not the folder it happens to be checked out in, so + * every checkout of one repo reads and writes the same notes: + * + * - a linked worktree reads its main checkout's git config, so it gets the + * same key; + * - a checkout with a remote is keyed by it (`github.com/nanonets/assign`), + * so a second clone somewhere else finds the same notes; + * - a submodule, or any repo nested in another, is its own repo with its own + * key: the walk up stops at the nearest `.git`; + * - a subfolder of a monorepo is part of its repo; + * - a repo with no remote, and a folder outside git, are keyed by path. + * + * A folder outside git that holds repos (a workspace) has its own key for + * notes written there, and reads its child repos' notes too. + * + * A key can change under a repo: a remote renamed, or added for the first + * time. The first write under a new key looks for a folder already holding + * notes for a repo with the same first commit, and points the new key at it + * in `repos/index.json`, so the notes carry over instead of starting empty. + * + * Finding the key spawns no git, because hooks and the statusline call it on + * every turn: it reads `.git` and the repo's config file directly. Only that + * first write runs git, once, for the first commit. + */ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { readJson, writeJsonAtomic } from "../util/state.js"; + +/** `~/.trail`, or `$TRAIL_HOME`. */ +export function trailHome(env: NodeJS.ProcessEnv = process.env): string { + return env.TRAIL_HOME ? resolve(env.TRAIL_HOME) : join(homedir(), ".trail"); +} + +/** A path as people read it: `~/.trail/…` under the home folder, absolute otherwise. */ +export function shownPath(p: string): string { + if (!isAbsolute(p)) return p; + const home = homedir(); + const rel = relative(home, p); + return rel && !rel.startsWith("..") && !isAbsolute(rel) ? `~/${rel.split(sep).join("/")}` : p.split(sep).join("/"); +} + +/* -------------------------------------------------------------------------- */ +/* reading git without git */ +/* -------------------------------------------------------------------------- */ + +/** The nearest folder at or above `start` holding a `.git` (folder or file), or null outside git. */ +export function checkoutRoot(start: string): string | null { + let dir = resolve(start); + for (;;) { + if (existsSync(join(dir, ".git"))) return dir; + const up = dirname(dir); + if (up === dir) return null; + dir = up; + } +} + +interface GitDirs { + /** This checkout's own git dir. */ + gitDir: string; + /** The one its config lives in: the main `.git` for a linked worktree, the same dir otherwise. */ + commonDir: string; +} + +const GITDIR_KEY = "gitdir:"; + +/** + * A checkout's git dirs, from its `.git`. A folder: a main checkout. A file + * pointing at `
/.git/worktrees/`: a linked worktree, whose + * `commondir` leads back to the main `.git`. A file pointing anywhere else + * (`/.git/modules/`): a submodule, which is a repo of its own. + */ +function gitDirs(checkout: string): GitDirs | null { + const dot = join(checkout, ".git"); + try { + if (statSync(dot).isDirectory()) return { gitDir: dot, commonDir: dot }; + // split/startsWith, not a regex: see mainWorktreeRoot in graph/seed.ts. + const line = readFileSync(dot, "utf8").split("\n").find((l) => l.startsWith(GITDIR_KEY)); + const target = line?.slice(GITDIR_KEY.length).trim(); + if (!target) return null; + const gitDir = isAbsolute(target) ? target : resolve(checkout, target); + let commonDir = gitDir; + try { + const rel = readFileSync(join(gitDir, "commondir"), "utf8").trim(); + if (rel) commonDir = isAbsolute(rel) ? rel : resolve(gitDir, rel); + } catch { + /* no commondir: a submodule, its own git dir */ + } + return { gitDir, commonDir }; + } catch { + return null; + } +} + +/** The main checkout behind a linked worktree; the checkout itself otherwise. */ +function mainCheckout(checkout: string, dirs: GitDirs | null): string { + if (!dirs || dirs.gitDir === dirs.commonDir) return checkout; + if (basename(dirname(dirs.gitDir)) !== "worktrees" || basename(dirs.commonDir) !== ".git") return checkout; + return dirname(dirs.commonDir); +} + +/** origin's url from a git config file's text, else the first remote's, else null. */ +export function remoteUrl(config: string): string | null { + let section = ""; + let first: string | null = null; + for (const raw of config.split("\n")) { + const line = raw.trim(); + if (line.startsWith("[")) { + section = line; + continue; + } + if (!section.startsWith('[remote "')) continue; + const eq = line.indexOf("="); + if (eq === -1 || line.slice(0, eq).trim().toLowerCase() !== "url") continue; + const url = line.slice(eq + 1).trim(); + if (!url) continue; + if (section === '[remote "origin"]') return url; + first ??= url; + } + return first; +} + +/** One path segment of a key: lowercase, safe as a folder name, never `.` or `..`. */ +function segment(s: string): string { + const out = s.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, ""); + return /^\.+$/.test(out) ? "" : out; +} + +/** + * `git@github.com:NanoNets/Graft.git`, `https://user:tok@github.com/NanoNets/Graft` + * and `ssh://git@github.com:22/NanoNets/Graft.git` all → `github.com/nanonets/graft`. + * Null for a remote that is a path on this machine: those are keyed by path. + */ +export function keyFromRemote(url: string): string | null { + const s = url.trim(); + let host: string; + let path: string; + const scheme = s.indexOf("://"); + if (scheme !== -1) { + if (s.slice(0, scheme).toLowerCase() === "file") return null; + const rest = s.slice(scheme + 3); + const slash = rest.indexOf("/"); + if (slash === -1) return null; + host = rest.slice(0, slash); + path = rest.slice(slash + 1); + } else { + const colon = s.indexOf(":"); + // No colon, or a Windows drive letter: a local path. + if (colon <= 1 || s.slice(0, colon).includes("/")) return null; + host = s.slice(0, colon); + path = s.slice(colon + 1); + } + host = segment(host.slice(host.lastIndexOf("@") + 1).replace(/:\d*$/, "")); + const parts = path + .replace(/\.git\/?$/i, "") + .split("/") + .map(segment) + .filter(Boolean); + if (!host || parts.length === 0) return null; + return [host, ...parts].join("/"); +} + +/** `local/Users-priya-src-extract`: the key for a repo with no remote, or a folder outside git. */ +function pathKey(dir: string): string { + return `local/${segment(resolve(dir)) || "root"}`; +} + +/* -------------------------------------------------------------------------- */ +/* the place */ +/* -------------------------------------------------------------------------- */ + +export interface RepoPlace { + /** The top of the checkout the folder is in (a worktree's own top), or the folder itself outside git. */ + checkout: string; + /** Inside a git checkout. */ + git: boolean; + /** `github.com/nanonets/assign`, or `local/…`. */ + key: string; + /** `/repos/`: where this repo's notes and takeaways are. */ + dir: string; +} + +interface Index { + /** A key that changed, to the one whose folder has the notes. */ + aliases: Record; + /** A repo's first commit, to the key whose folder has its notes. */ + roots: Record; +} + +function indexPath(home: string): string { + return join(home, "repos", "index.json"); +} + +function readIndex(home: string): Index { + const raw = readJson>(indexPath(home)); + return { aliases: raw?.aliases ?? {}, roots: raw?.roots ?? {} }; +} + +function keyDir(home: string, key: string): string { + return join(home, "repos", ...key.split("/")); +} + +/** Where the notes for the folder `start` live. Reads only; spawns nothing. */ +export function repoPlace(start: string, env: NodeJS.ProcessEnv = process.env): RepoPlace { + const home = trailHome(env); + const top = checkoutRoot(start); + const dirs = top ? gitDirs(top) : null; + let url: string | null = null; + if (dirs) { + try { + url = remoteUrl(readFileSync(join(dirs.commonDir, "config"), "utf8")); + } catch { + /* no config: keyed by path */ + } + } + const checkout = top ?? resolve(start); + const fresh = (url && keyFromRemote(url)) || pathKey(top ? mainCheckout(top, dirs) : checkout); + const key = existsSync(keyDir(home, fresh)) ? fresh : (readIndex(home).aliases[fresh] ?? fresh); + return { checkout, git: top !== null, key, dir: keyDir(home, key) }; +} + +/** A repo's first commits (more than one after merging unrelated histories), sorted; [] without git or commits. */ +function rootCommits(checkout: string): string[] { + try { + const out = execFileSync("git", ["rev-list", "--max-parents=0", "HEAD"], { + cwd: checkout, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 5000, + }); + return out.split("\n").map((l) => l.trim()).filter(Boolean).sort(); + } catch { + return []; + } +} + +/** + * When `place`'s key has no folder yet but a repo with the same first commit + * does (its remote was renamed, or added after its first notes), point the + * key at that folder and return the place there. Runs git once, and only on a + * machine that already keeps notes for some repo; otherwise `place` as it was. + */ +function followMovedRepo(home: string, place: RepoPlace, index: Index, roots: string[]): RepoPlace { + if (existsSync(place.dir)) return place; + const known = roots.map((r) => index.roots[r]).find((k): k is string => !!k && k !== place.key && existsSync(keyDir(home, k))); + if (!known) return place; + index.aliases[place.key] = known; + return { ...place, key: known, dir: keyDir(home, known) }; +} + +/** + * Where the notes for `start` are, following a repo whose key changed since + * they were written. For reads that can afford one git call (`ask`, not the + * statusline); a machine with no notes anywhere never spawns it. + */ +export function findRepoPlace(start: string, env: NodeJS.ProcessEnv = process.env): RepoPlace { + const place = repoPlace(start, env); + if (existsSync(place.dir) || !place.git) return place; + const home = trailHome(env); + const index = readIndex(home); + if (Object.keys(index.roots).length === 0) return place; + const moved = followMovedRepo(home, place, index, rootCommits(place.checkout)); + if (moved !== place) writeJsonAtomic(indexPath(home), index); + return moved; +} + +export const HOME_README = `# ~/.trail + +Notes and corrections from your coding sessions, kept by Trail on this +machine. Nothing here is in your repos, and nothing leaves this machine +until you run \`trail login\` to share it with your team. + +- repos//notes/ one note per session: what was decided, tried and + ruled out, and what it took to figure out +- repos//skills/ corrections you taught your agent, per skill + + is the repo's remote (github.com/owner/name), so every clone and +worktree of one repo shares its notes. Repos without a remote are under +local/, by path. +`; + +/** + * Make the folder for `start`'s repo, and return where it is. A repo seen for + * the first time under this key, whose first commit already has a folder (a + * renamed remote, a remote added later), is pointed at that folder. + */ +export function ensureRepoHome(start: string, env: NodeJS.ProcessEnv = process.env): { place: RepoPlace; created: boolean } { + const home = trailHome(env); + const index = readIndex(home); + const before = JSON.stringify(index); + const fresh = repoPlace(start, env); + const roots = fresh.git ? rootCommits(fresh.checkout) : []; + const place = followMovedRepo(home, fresh, index, roots); + for (const r of roots) index.roots[r] ??= place.key; + const created = !existsSync(place.dir); + mkdirSync(join(place.dir, "notes"), { recursive: true }); + const readme = join(home, "README.md"); + if (!existsSync(readme)) writeFileSync(readme, HOME_README); + if (JSON.stringify(index) !== before) writeJsonAtomic(indexPath(home), index); + return { place, created }; +} + +/* -------------------------------------------------------------------------- */ +/* a folder, and the repos under it */ +/* -------------------------------------------------------------------------- */ + +/** Most repos a workspace folder reads notes from. */ +const MAX_CHILDREN = 64; + +/** The git checkouts directly inside a folder that isn't one: a workspace's repos. */ +export function childCheckouts(dir: string): string[] { + let names: string[]; + try { + names = readdirSync(dir, { withFileTypes: true }) + .filter((e) => e.isDirectory() && !e.name.startsWith(".")) + .map((e) => e.name) + .sort(); + } catch { + return []; + } + return names + .map((n) => join(dir, n)) + .filter((d) => existsSync(join(d, ".git"))) + .slice(0, MAX_CHILDREN); +} + +/** + * Every place whose notes bear on the folder `dir`: its repo's, or, for a + * folder outside git, its own and each child repo's. One per key, so two + * worktrees of one repo side by side don't list its notes twice. + */ +export function notePlaces(dir: string, env: NodeJS.ProcessEnv = process.env): RepoPlace[] { + const here = repoPlace(dir, env); + const places = [here, ...(here.git ? [] : childCheckouts(here.checkout).map((c) => repoPlace(c, env)))]; + const seen = new Set(); + return places.filter((p) => !seen.has(p.key) && seen.add(p.key)); +} + +/** This repo keeps notes on this machine: `trail init` or a first note made its folder. */ +export function keepsNotes(dir: string, env: NodeJS.ProcessEnv = process.env): boolean { + return notePlaces(dir, env).some((p) => existsSync(p.dir)); +} + +/** How many notes bear on `dir`, counted without reading them. */ +export function noteCount(dir: string, env: NodeJS.ProcessEnv = process.env): number { + let n = 0; + for (const p of notePlaces(dir, env)) { + try { + n += readdirSync(join(p.dir, "notes")).filter((f) => f.endsWith(".md")).length; + } catch { + /* none */ + } + } + return n; +} diff --git a/src/notes/instructions.ts b/src/notes/instructions.ts deleted file mode 100644 index 5c38ae1ff..000000000 --- a/src/notes/instructions.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * The Trail block written into CLAUDE.md and each agent's instruction file - * (AGENTS.md, GEMINI.md, …) by `trail init`. - * - * It is how a teammate's agent finds out the repo has notes at all: a - * teammate who has never heard of Trail pulls, opens their agent, and the - * agent reads this. So it says what `.trail/` is, how to search it, what to - * do when `trail` isn't installed, and to leave a note when the work is done. - */ -import { join } from "node:path"; -import { TRAIL_MARKERS, upsertSection, type UpsertAction } from "../hosts/sections.js"; - -/** `agent` is the `--agents` id to install for, or undefined to wire whatever is detected. */ -export function trailBlock(agent?: string): string { - const init = agent ? `trail init --agents ${agent} --yes` : "trail init --yes"; - return [ - "## Trail", - "Before exploring code, check `.trail/`: notes from teammates' sessions on", - 'what they decided, tried and ruled out. `trail ask "" --source` searches', - "the notes and the code map together. If `trail` isn't installed, tell the user", - "their teammates have left notes in `.trail/` and offer to run", - `\`npm install -g @trailhq/trail && ${init}\`.`, - "", - "When you finish a task that took real digging, leave a note for the next", - 'person: `trail note --title ""` with the note on stdin,', - "under `## Decided`, `## Tried and ruled out` and `## Watch out`. A summary,", - "never a transcript, and nothing secret: it gets committed.", - ].join("\n"); -} - -export interface TrailBlockWrite { - path: string; - action: UpsertAction; -} - -/** - * Write the block into the repo's CLAUDE.md (when Claude Code is wired) and - * into each other agent's instruction file. Best-effort per file. - */ -export function writeTrailBlocks(repo: string, opts: { claude: boolean; files: string[] }): TrailBlockWrite[] { - const out: TrailBlockWrite[] = []; - const targets: Array<[string, string | undefined]> = []; - if (opts.claude) targets.push(["CLAUDE.md", "claude"]); - for (const f of opts.files) if (!targets.some(([p]) => p === f)) targets.push([f, undefined]); - for (const [rel, agent] of targets) { - const path = join(repo, rel); - try { - out.push({ path, action: upsertSection(path, trailBlock(agent), TRAIL_MARKERS).action }); - } catch { - /* unwritable: the others still go in */ - } - } - return out; -} diff --git a/src/notes/notes.ts b/src/notes/notes.ts index 7e17086c6..c6c98890b 100644 --- a/src/notes/notes.ts +++ b/src/notes/notes.ts @@ -1,29 +1,22 @@ /** - * `.trail/notes/`: one short note per coding session, committed with the code. + * Session notes: one short note per coding session, kept on this machine in + * `~/.trail/repos//notes/` (see home.ts for how a repo gets its key). * * A note is what a session worked out that the code itself doesn't say: what * was decided, what was tried and ruled out, and what to watch out for, plus - * what it cost to figure out. Teammates' agents read it before exploring, so - * the next person who touches the same code doesn't pay for it again. + * what it cost to figure out. The next session on the same code reads it + * before exploring, so nobody pays for it twice. * - * One file per session and never edited after it's written, so two people - * writing notes on the same day never conflict on a pull. A summary only, + * One file per session and never edited after it's written. A summary only, * never a transcript: the agent writes the body, and nothing here reads * source files or prompts into it. */ -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join, relative } from "node:path"; +import { readdirSync, readFileSync, existsSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, relative, resolve, sep } from "node:path"; import matter from "gray-matter"; - -export const TRAIL_DIR = ".trail"; - -export function trailDir(repo: string): string { - return join(repo, TRAIL_DIR); -} - -export function notesDir(repo: string): string { - return join(repo, TRAIL_DIR, "notes"); -} +import { changedFiles, ownPath } from "./git-facts.js"; +import { checkoutRoot, childCheckouts, ensureRepoHome, notePlaces, repoPlace, shownPath } from "./home.js"; export interface NoteCost { minutes?: number; @@ -31,7 +24,7 @@ export interface NoteCost { } export interface Note { - /** Repo-relative, forward slashes. */ + /** Where the note is: an absolute path on this machine. */ path: string; title: string; author: string; @@ -39,7 +32,7 @@ export interface Note { date: string; branch?: string; cost?: NoteCost; - /** `file` or `file#Symbol`, repo-relative. */ + /** `file` or `file#Symbol`, relative to the top of the checkout (or to the folder `listNotes` was asked about). */ touches: string[]; body: string; } @@ -51,39 +44,6 @@ export const SECTIONS = [ { key: "watchOut", heading: "Watch out", label: "watch out" }, ] as const; -/* -------------------------------------------------------------------------- */ -/* the folder */ -/* -------------------------------------------------------------------------- */ - -export const TRAIL_README = `# .trail - -Notes from this team's coding sessions, kept by Trail. Each file is one -session: what got decided, what was tried and ruled out, and what it took -to figure out. Coding agents read these before exploring, so nobody pays -to work the same thing out twice. - -- notes/ one file per session, never edited after it's written -- skills/ how-tos your agents follow, and the corrections that improve them - -Get it: npm install -g @trailhq/trail && trail init -`; - -/** - * Create `.trail/` with its README and an empty `notes/`. Never touches a - * README someone has edited. Returns whether the folder was new. - */ -export function ensureTrailDir(repo: string): { created: boolean } { - const dir = trailDir(repo); - const created = !existsSync(dir); - mkdirSync(join(dir, "notes"), { recursive: true }); - const readme = join(dir, "README.md"); - if (!existsSync(readme)) writeFileSync(readme, TRAIL_README); - // git keeps no empty folders; this keeps notes/ in the first commit. - const keep = join(dir, "notes", ".gitkeep"); - if (!existsSync(keep) && readdirSync(join(dir, "notes")).length === 0) writeFileSync(keep, ""); - return { created }; -} - /* -------------------------------------------------------------------------- */ /* reading and writing one note */ /* -------------------------------------------------------------------------- */ @@ -135,22 +95,38 @@ export function parseNote(text: string, path: string): Note | null { }; } -/** Every note in `.trail/notes/`, newest first. Unreadable files are skipped. */ -export function listNotes(repo: string): Note[] { - const dir = notesDir(repo); - let files: string[]; - try { - files = readdirSync(dir).filter((f) => f.endsWith(".md")); - } catch { - return []; - } +/** A touch saved relative to `checkout`, as a path relative to `here`. */ +function rebaseTouch(t: string, checkout: string, here: string): string { + if (checkout === here) return t; + const [file, ...sym] = t.split("#"); + const rel = relative(here, join(checkout, file!)).split(sep).join("/"); + return [rel, ...sym].join("#"); +} + +/** + * Every note that bears on the folder `dir`, newest first: its repo's, or, for + * a folder that holds several repos, its own and each repo's. Touches come + * back relative to `dir`, so they line up with a query run there. + * Unreadable files are skipped. + */ +export function listNotes(dir: string): Note[] { + const here = resolve(dir); const out: Note[] = []; - for (const f of files) { + for (const place of notePlaces(here)) { + const notes = join(place.dir, "notes"); + let files: string[]; try { - const n = parseNote(readFileSync(join(dir, f), "utf8"), relative(repo, join(dir, f)).split("\\").join("/")); - if (n) out.push(n); + files = readdirSync(notes).filter((f) => f.endsWith(".md")); } catch { - /* unreadable: skip */ + continue; + } + for (const f of files) { + try { + const n = parseNote(readFileSync(join(notes, f), "utf8"), join(notes, f)); + if (n) out.push({ ...n, touches: n.touches.map((t) => rebaseTouch(t, place.checkout, here)) }); + } catch { + /* unreadable: skip */ + } } } return out.sort((a, b) => (a.date === b.date ? b.path.localeCompare(a.path) : b.date.localeCompare(a.date))); @@ -192,15 +168,15 @@ export interface WriteNoteInput { } /** - * Write a new note and return its repo-relative path. Never overwrites: a - * second note with the same name on the same day gets `-2`, `-3`. + * Write a new note into the repo `dir` is in, and return it. Never + * overwrites: a second note with the same name on the same day gets `-2`, + * `-3`. */ -export function writeNote(repo: string, input: WriteNoteInput): Note { - ensureTrailDir(repo); - const dir = notesDir(repo); +export function writeNote(dir: string, input: WriteNoteInput): Note { + const notes = join(ensureRepoHome(dir).place.dir, "notes"); const base = [input.date, slug(input.title) || "note", slug(input.author, 1)].filter(Boolean).join("-"); let name = `${base}.md`; - for (let i = 2; existsSync(join(dir, name)); i++) name = `${base}-${i}.md`; + for (let i = 2; existsSync(join(notes, name)); i++) name = `${base}-${i}.md`; const note: Omit = { title: input.title.trim(), author: input.author, @@ -210,8 +186,58 @@ export function writeNote(repo: string, input: WriteNoteInput): Note { touches: input.touches ?? [], body: input.body, }; - writeFileSync(join(dir, name), renderNote(note)); - return { ...note, path: `${TRAIL_DIR}/notes/${name}` }; + writeFileSync(join(notes, name), renderNote(note)); + return { ...note, path: join(notes, name) }; +} + +export interface NoteTarget { + /** A folder in the repo the note goes to. */ + dir: string; + /** What the session touched there, relative to the top of that checkout. */ + touches: string[]; +} + +/** Most files one note lists per repo. */ +const TOUCH_LIMIT = 10; + +/** + * Which repos a session's note belongs in, and what it touched in each. + * + * The repo the note was saved from gets the files it has uncommitted or + * committed since `since`. Every other repo the session edited (`edited`: + * absolute paths, from the agent's transcript) gets the note too, with its + * own files, so a session that changed a backend and the library it calls + * leaves a note in both. Saved from a folder that holds several repos, the + * note goes to each repo the session changed, or stays with the folder when + * it changed none. Two worktrees of one repo are one repo. + */ +export function noteTargets(dir: string, edited: string[] = [], since?: number): NoteTarget[] { + const here = repoPlace(dir); + const byKey = new Map(); + const add = (checkout: string, files: string[]) => { + const key = repoPlace(checkout).key; + const entry = byKey.get(key) ?? { dir: checkout, files: [] }; + for (const f of files) if (f && !ownPath(f) && !entry.files.includes(f)) entry.files.push(f); + byKey.set(key, entry); + }; + if (here.git) add(here.checkout, changedFiles(here.checkout, since)); + else if (edited.length === 0) for (const c of childCheckouts(here.checkout)) add(c, changedFiles(c, since)); + // The agent's own files (memory, plans, scratch under ~/.claude) aren't what a note is about. + const agentFiles = join(homedir(), ".claude") + sep; + for (const f of edited) { + if (f.startsWith(agentFiles)) continue; + const top = checkoutRoot(dirname(f)); + if (top) add(top, [relative(top, f).split(sep).join("/")]); + else if (!here.git && !relative(here.checkout, f).startsWith("..")) add(here.checkout, [relative(here.checkout, f).split(sep).join("/")]); + } + const out = [...byKey.entries()] + .filter(([key, e]) => e.files.length > 0 || key === here.key) + .map(([, e]) => ({ dir: e.dir, touches: e.files.slice(0, TOUCH_LIMIT) })); + // The repo it was saved from first; a folder of repos only when nothing else was touched. + out.sort((a, b) => Number(repoPlace(b.dir).key === here.key) - Number(repoPlace(a.dir).key === here.key)); + const touched = out.filter((t) => t.touches.length > 0); + if (!here.git && touched.length > 0) return touched; + return out.length ? out : [{ dir: here.checkout, touches: [] }]; } /* -------------------------------------------------------------------------- */ @@ -321,7 +347,7 @@ export function formatNoteHits(hits: NoteHit[]): string[] { if (lead) lines.push(` ${s.label.padEnd(11)} ${clip(lead)}`); } const cost = costLabel(note.cost); - lines.push(` ${note.path}${cost ? ` · ${cost}` : ""}`); + lines.push(` ${shownPath(note.path)}${cost ? ` · ${cost}` : ""}`); lines.push(""); } return lines; diff --git a/src/notes/session-cost.ts b/src/notes/session-cost.ts index 8e3d3e69a..e961f4290 100644 --- a/src/notes/session-cost.ts +++ b/src/notes/session-cost.ts @@ -16,15 +16,17 @@ * gap of more than ten minutes between two entries counts as nothing, so a * session left open over lunch doesn't claim the lunch. */ -import { readdirSync, readFileSync, statSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { dirname, join, resolve } from "node:path"; export interface SessionCost { minutes: number; tokens: number; /** When the counted stretch began, epoch ms. */ startedAt: number; + /** Files the agent edited or wrote in that stretch, absolute, in order. */ + edited: string[]; } const IDLE_MS = 10 * 60 * 1000; @@ -35,9 +37,23 @@ export function claudeProjectDir(repo: string, home: string = homedir()): string return join(home, ".claude", "projects", repo.replace(/[^A-Za-z0-9]/g, "-")); } +/** + * The project folder of the session that `dir` is part of: `dir`'s own, or + * the nearest ancestor's. A note saved from a subfolder, or from one repo of + * a session started in the folder above several, still finds its session. + */ +function sessionProjectDir(dir: string, home?: string): string | null { + for (let d = resolve(dir); ; d = dirname(d)) { + const p = claudeProjectDir(d, home); + if (existsSync(p)) return p; + if (dirname(d) === d) return null; + } +} + /** The transcript of the session running in `repo` now, or null. */ export function currentTranscript(repo: string, home?: string, now = Date.now()): string | null { - const dir = claudeProjectDir(repo, home); + const dir = sessionProjectDir(repo, home); + if (!dir) return null; let best: { path: string; mtime: number } | null = null; let files: string[]; try { @@ -71,6 +87,9 @@ function blocks(e: Entry): Array> { return Array.isArray(c) ? (c as Array>) : []; } +/** Claude Code's tools that change a file, each naming it in `file_path` (`notebook_path` for notebooks). */ +const EDIT_TOOLS = new Set(["Edit", "MultiEdit", "Write", "NotebookEdit"]); + const NOTE_CALL = /(^|[|&;]\s*|\s)(npx\s+(-y\s+)?(@trailhq\/|@nanonets\/)?)?(trail|graft)\s+note\b/; /** @@ -103,7 +122,8 @@ export function costFromTranscript(text: string, now = Date.now()): SessionCost } }); - const counted = entries.slice(boundary + 1).filter((e) => e.timestamp); + const after = entries.slice(boundary + 1); + const counted = after.filter((e) => e.timestamp); if (counted.length === 0) return null; const times = counted.map((e) => Date.parse(e.timestamp!)).filter((t) => Number.isFinite(t)); if (times.length === 0) return null; @@ -128,7 +148,16 @@ export function costFromTranscript(text: string, now = Date.now()): SessionCost const u = e.message.usage; tokens += (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.output_tokens ?? 0); } - return { minutes: Math.max(1, Math.round(activeMs / 60_000)), tokens, startedAt: times[0]! }; + const edited: string[] = []; + for (const e of after) { + for (const b of blocks(e)) { + if (b.type !== "tool_use" || !EDIT_TOOLS.has(String(b.name))) continue; + const input = (b.input ?? {}) as { file_path?: unknown; notebook_path?: unknown }; + const file = typeof input.file_path === "string" ? input.file_path : input.notebook_path; + if (typeof file === "string" && file && !edited.includes(file)) edited.push(file); + } + } + return { minutes: Math.max(1, Math.round(activeMs / 60_000)), tokens, startedAt: times[0]!, edited }; } /** The running session's cost in `repo`, or null when there is no transcript to read. */ diff --git a/src/telemetry/contract.ts b/src/telemetry/contract.ts index cbcc047fb..1cfebfd5c 100644 --- a/src/telemetry/contract.ts +++ b/src/telemetry/contract.ts @@ -74,7 +74,7 @@ export const EVENTS: Record> = { * `skipped` with a `reason` from a closed set (TRAIL_AUTOPUSH_SKIPS). Only for * a repo with a trail attached — every other repo sends nothing. */ trail_autopush: new Set(['outcome', 'reason']), - /** `trail note` saved a session note into `.trail/notes/`. Whether it carries + /** `trail note` saved a session note (into `~/.trail/`). Whether it carries * a cost, and how many files it is about, as a bucket. The title, the text * and the paths never travel. The supply side of the notes loop; the * `notes` property on `query` is the demand side. */ diff --git a/test/trail-home.test.ts b/test/trail-home.test.ts new file mode 100644 index 000000000..676d5431a --- /dev/null +++ b/test/trail-home.test.ts @@ -0,0 +1,267 @@ +/** + * Which repo a folder belongs to, for notes kept in ~/.trail/repos//: + * one key per repo however it's checked out (clones, worktrees, subfolders), + * its own key for a repo inside another (submodules, nested repos), a key + * that survives a renamed remote, and a folder of repos that reads them all. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { + childCheckouts, + ensureRepoHome, + findRepoPlace, + keepsNotes, + keyFromRemote, + noteCount, + notePlaces, + remoteUrl, + repoPlace, + shownPath, +} from "../src/notes/home.js"; +import { listNotes, noteTargets, writeNote } from "../src/notes/notes.js"; +import { tmpRepo } from "./helpers.js"; + +/** A fresh ~/.trail per test, so keys and aliases never leak between them. */ +function home(): NodeJS.ProcessEnv { + const env = { ...process.env, TRAIL_HOME: mkdtempSync(join(tmpdir(), "trail-home-")) }; + process.env.TRAIL_HOME = env.TRAIL_HOME; + return env; +} + +function git(cwd: string, ...args: string[]): string { + const r = spawnSync("git", ["-c", "commit.gpgsign=false", "-c", "init.defaultBranch=main", ...args], { cwd, encoding: "utf8" }); + assert.equal(r.status, 0, `git ${args.join(" ")}: ${r.stderr}`); + return r.stdout.trim(); +} + +/** A repo with one commit, and `origin` when given. */ +function repo(tag: string, origin?: string, files: Record = { "main.go": "package main\n" }): string { + const d = tmpRepo(tag); + for (const [f, text] of Object.entries(files)) { + mkdirSync(join(d, f, ".."), { recursive: true }); + writeFileSync(join(d, f), text); + } + git(d, "init", "-q"); + git(d, "config", "user.name", "Priya Raman"); + git(d, "config", "user.email", "p@example.com"); + git(d, "add", "-A"); + git(d, "commit", "-qm", "init"); + if (origin) git(d, "remote", "add", "origin", origin); + return d; +} + +const BODY = "## Decided\nRotate each box by its page's /Rotate."; + +// --- a remote, as a key --- + +test("a remote becomes host/owner/name, however it's spelled", () => { + for (const url of [ + "git@github.com:NanoNets/Graft.git", + "https://github.com/NanoNets/Graft", + "https://github.com/nanonets/graft.git/", + "https://x-access-token:ghs_abc@github.com/NanoNets/Graft.git", + "ssh://git@github.com:22/NanoNets/Graft.git", + ]) + assert.equal(keyFromRemote(url), "github.com/nanonets/graft", url); + assert.equal(keyFromRemote("git@gitlab.com:acme/platform/extract.git"), "gitlab.com/acme/platform/extract", "subgroups stay"); + assert.equal(keyFromRemote("https://github.com/acme/../../etc"), "github.com/acme/etc", "never climbs out of ~/.trail"); +}); + +test("a remote that's a path on this machine isn't a key: those repos go by path", () => { + for (const url of ["/srv/git/extract.git", "../extract", "file:///srv/git/extract.git", "C:\\repos\\extract"]) assert.equal(keyFromRemote(url), null, url); +}); + +test("origin wins; a repo with other remotes uses the first; none, none", () => { + const cfg = '[core]\n\tbare = false\n[remote "upstream"]\n\turl = git@github.com:acme/up.git\n[remote "origin"]\n\turl = git@github.com:priya/fork.git\n'; + assert.equal(remoteUrl(cfg), "git@github.com:priya/fork.git"); + assert.equal(remoteUrl('[remote "upstream"]\n url = git@github.com:acme/up.git\n'), "git@github.com:acme/up.git"); + assert.equal(remoteUrl("[core]\n\tbare = false\n"), null); +}); + +// --- one repo, however it's checked out --- + +test("two clones, a subfolder and a worktree of one repo share one folder", () => { + const env = home(); + const a = repo("clone-a", "git@github.com:acme/extract.git", { "internal/ocr/bbox.go": "package ocr\n" }); + const b = repo("clone-b", "https://github.com/acme/extract.git"); + const wt = join(tmpRepo("wt-parent"), "extract-wt"); + git(a, "worktree", "add", "-q", "-b", "fix/bbox", wt); + + const want = join(env.TRAIL_HOME!, "repos", "github.com", "acme", "extract"); + for (const dir of [a, b, join(a, "internal", "ocr"), wt]) assert.equal(repoPlace(dir).dir, want, dir); + assert.equal(repoPlace(join(a, "internal")).checkout, a, "the checkout is the top of the repo"); + assert.equal(repoPlace(wt).checkout, wt, "a worktree reads touches against its own top"); +}); + +test("a worktree of a repo with no remote shares its main checkout's folder", () => { + home(); + const main = repo("local-main"); + const wt = join(tmpRepo("wt-local"), "wt"); + git(main, "worktree", "add", "-q", "-b", "side", wt); + assert.match(repoPlace(main).key, /^local\//); + assert.equal(repoPlace(wt).key, repoPlace(main).key); +}); + +test("a repo inside another is its own repo: a nested clone, and a submodule's .git file", () => { + home(); + const outer = repo("outer", "git@github.com:acme/app.git"); + // A clone sitting inside another repo's tree. + const nested = join(outer, "vendor", "ocr-lib"); + mkdirSync(nested, { recursive: true }); + git(nested, "init", "-q"); + git(nested, "remote", "add", "origin", "git@github.com:acme/ocr-lib.git"); + assert.equal(repoPlace(join(nested)).key, "github.com/acme/ocr-lib"); + // A submodule: `.git` is a file pointing into the superproject's .git/modules. + const mod = join(outer, "libs", "pdf"); + mkdirSync(mod, { recursive: true }); + const modGit = join(outer, ".git", "modules", "libs", "pdf"); + mkdirSync(modGit, { recursive: true }); + writeFileSync(join(modGit, "config"), '[remote "origin"]\n\turl = https://github.com/acme/pdf.git\n'); + writeFileSync(join(mod, ".git"), "gitdir: ../../.git/modules/libs/pdf\n"); + assert.equal(repoPlace(mod).key, "github.com/acme/pdf"); + assert.equal(repoPlace(join(outer, "libs")).key, "github.com/acme/app", "the folder around it is still the outer repo"); +}); + +test("a folder outside git is keyed by its path", () => { + home(); + const d = tmpRepo("plain"); + const p = repoPlace(d); + assert.equal(p.git, false); + assert.match(p.key, /^local\/[a-z0-9._-]+$/); + assert.equal(p.checkout, d); +}); + +// --- a key that changes --- + +test("a renamed remote finds its notes again, through the repo's first commit", () => { + home(); + const d = repo("renamed", "git@github.com:nanonets/graft.git"); + writeNote(d, { title: "Bbox rotation", body: BODY, author: "Priya", date: "2026-10-07" }); + git(d, "remote", "set-url", "origin", "git@github.com:trailhq/trail.git"); + assert.equal(repoPlace(d).key, "github.com/trailhq/trail", "reads alone don't run git"); + assert.equal(findRepoPlace(d).key, "github.com/nanonets/graft", "ask's lookup follows the first commit"); + assert.equal(repoPlace(d).key, "github.com/nanonets/graft", "and remembers it for everything after"); + assert.equal(listNotes(d).length, 1); + + // A fresh clone under the new name, somewhere else, lands on the same notes. + const clone = tmpRepo("renamed-clone"); + git(clone, "clone", "-q", d, "trail"); + git(join(clone, "trail"), "remote", "set-url", "origin", "https://github.com/trailhq/trail"); + assert.equal(listNotes(join(clone, "trail")).length, 1); +}); + +test("notes from before a repo had a remote carry over once it gets one", () => { + home(); + const d = repo("no-remote-yet"); + writeNote(d, { title: "First note", body: BODY, author: "Priya", date: "2026-10-07" }); + git(d, "remote", "add", "origin", "git@github.com:acme/extract.git"); + assert.equal(ensureRepoHome(d).place.key, repoPlace(d).key); + assert.match(repoPlace(d).key, /^local\//, "the new key points at the old folder"); + assert.equal(listNotes(d).length, 1); +}); + +test("a different repo never inherits notes: no shared first commit, no alias", () => { + const env = home(); + const a = repo("first-a", "git@github.com:acme/a.git", { "a.go": "package a\n" }); + writeNote(a, { title: "A's note", body: BODY, author: "Priya", date: "2026-10-07" }); + const b = repo("first-b", "git@github.com:acme/b.git", { "b.go": "package b\n" }); + assert.equal(findRepoPlace(b).key, "github.com/acme/b"); + assert.equal(listNotes(b).length, 0); + assert.equal(keepsNotes(b), false); + assert.ok(!repoPlace(b).dir.startsWith(join(env.TRAIL_HOME!, "repos", "github.com", "acme", "a"))); +}); + +test("a machine with no notes anywhere never runs git to look for moved ones", () => { + home(); + const d = repo("no-notes", "git@github.com:acme/none.git"); + // No index.json: findRepoPlace returns the plain place without spawning. + assert.equal(findRepoPlace(d).key, "github.com/acme/none"); +}); + +// --- a folder of repos --- + +test("a folder holding several repos reads each one's notes, with touches under the repo's folder", () => { + home(); + const ws = tmpRepo("workspace"); + for (const [name, remote] of [["api", "git@github.com:acme/api.git"], ["web", "git@github.com:acme/web.git"]]) { + const d = join(ws, name!); + mkdirSync(d); + git(d, "init", "-q"); + git(d, "remote", "add", "origin", remote!); + } + mkdirSync(join(ws, "notes-scratch")); + assert.deepEqual(childCheckouts(ws).map((c) => c.slice(ws.length + 1)), ["api", "web"]); + writeNote(join(ws, "api"), { title: "Retry budget", body: BODY, author: "Priya", date: "2026-10-06", touches: ["internal/retry.go"] }); + writeNote(join(ws, "web"), { title: "Bbox overlay", body: BODY, author: "Sam", date: "2026-10-07", touches: ["src/Overlay.tsx#Overlay"] }); + + assert.equal(noteCount(ws), 2); + assert.equal(keepsNotes(ws), true); + const notes = listNotes(ws); + assert.deepEqual( + notes.map((n) => n.touches), + [["web/src/Overlay.tsx#Overlay"], ["api/internal/retry.go"]], + ); + assert.equal(notePlaces(ws).length, 3, "the folder itself, then each repo"); + // From inside one repo, only that repo's notes. + assert.deepEqual(listNotes(join(ws, "api")).map((n) => n.title), ["Retry budget"]); +}); + +test("touches read relative to the folder asked about, so they line up with a code map built in a subfolder", () => { + home(); + const d = repo("mono", "git@github.com:acme/mono.git", { "backend/ocr/bbox.go": "package ocr\n", "web/a.ts": "" }); + writeNote(d, { title: "Bbox", body: BODY, author: "Priya", date: "2026-10-07", touches: ["backend/ocr/bbox.go#NormalizeBox", "web/a.ts"] }); + assert.deepEqual(listNotes(join(d, "backend"))[0]?.touches, ["ocr/bbox.go#NormalizeBox", "../web/a.ts"]); + assert.deepEqual(listNotes(d)[0]?.touches, ["backend/ocr/bbox.go#NormalizeBox", "web/a.ts"]); +}); + +// --- which repos a note goes to --- + +test("a session that edited two repos leaves its note in both, each with its own files", () => { + home(); + const api = repo("edit-api", "git@github.com:acme/api.git", { "internal/retry.go": "package internal\n" }); + const lib = repo("edit-lib", "git@github.com:acme/lib.git", { "retry/backoff.go": "package retry\n" }); + writeFileSync(join(api, "internal", "retry.go"), "package internal\n// changed\n"); + const targets = noteTargets(api, [join(lib, "retry", "backoff.go"), join(api, "internal", "retry.go"), join(homedir(), ".claude", "plans", "x.md")]); + assert.deepEqual(targets, [ + { dir: api, touches: ["internal/retry.go"] }, + { dir: lib, touches: ["retry/backoff.go"] }, + ]); +}); + +test("saved from a folder of repos, a note goes to the repos the session changed, or to the folder", () => { + home(); + const ws = tmpRepo("ws-note"); + const api = join(ws, "api"); + const web = join(ws, "web"); + for (const d of [api, web]) { + mkdirSync(d); + git(d, "init", "-q"); + } + writeFileSync(join(web, "app.ts"), "x\n"); + assert.deepEqual(noteTargets(ws, [join(web, "app.ts")]), [{ dir: web, touches: ["app.ts"] }]); + // No transcript: each repo's own uncommitted changes say what was touched. + assert.deepEqual(noteTargets(ws), [{ dir: web, touches: ["app.ts"] }]); + // Nothing changed anywhere: the note stays with the folder. + const quiet = tmpRepo("ws-quiet"); + assert.deepEqual(noteTargets(quiet), [{ dir: quiet, touches: [] }]); +}); + +test("a session that edited a repo through both its checkout and a worktree leaves one note", () => { + home(); + const main = repo("both-main", "git@github.com:acme/both.git", { "a.go": "package a\n", "b.go": "package b\n" }); + const wt = join(tmpRepo("both-wt"), "wt"); + git(main, "worktree", "add", "-q", "-b", "side", wt); + const targets = noteTargets(main, [join(main, "a.go"), join(wt, "b.go")]); + assert.equal(targets.length, 1); + assert.deepEqual(targets[0]!.touches.sort(), ["a.go", "b.go"]); +}); + +test("paths under the home folder read as ~/", () => { + assert.equal(shownPath(join(homedir(), ".trail", "repos", "x")), "~/.trail/repos/x"); + assert.equal(shownPath("/srv/elsewhere"), "/srv/elsewhere"); + assert.equal(shownPath("notes/a.md"), "notes/a.md"); +}); diff --git a/test/trail-notes.test.ts b/test/trail-notes.test.ts index 85fc2049b..2d2ccdc99 100644 --- a/test/trail-notes.test.ts +++ b/test/trail-notes.test.ts @@ -1,16 +1,16 @@ /** - * `.trail/notes/`: one note per session, written by `trail note`, read back by - * `ask` for the next person who works on the same thing. + * Session notes: one note per session, written by `trail note` into + * `~/.trail/repos//notes/`, read back by `ask` for the next session on + * the same thing. Never in the repo. */ import { test } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { costLabel, - ensureTrailDir, findNotes, formatNoteHits, listNotes, @@ -24,9 +24,13 @@ import { type Note, } from "../src/notes/notes.js"; import { costFromTranscript } from "../src/notes/session-cost.js"; -import { trailBlock } from "../src/notes/instructions.js"; +import { ensureRepoHome, repoPlace } from "../src/notes/home.js"; import { homeEnv, tmpRepo } from "./helpers.js"; +// Everything this file writes in-process goes to a scratch ~/.trail. +const TRAIL_HOME = mkdtempSync(join(tmpdir(), "notes-trail-home-")); +process.env.TRAIL_HOME = TRAIL_HOME; + const BODY = [ "## Decided", "Rotate each box in `NormalizeBox` by its own page's `/Rotate` before scaling.", @@ -40,7 +44,7 @@ const BODY = [ function note(over: Partial = {}): Note { return { - path: ".trail/notes/2026-10-07-bbox-priya.md", + path: join(homedir(), ".trail", "repos", "github.com", "acme", "extract", "notes", "2026-10-07-bbox-priya.md"), title: "Bbox coordinates are off on rotated PDFs", author: "Priya", date: "2026-10-07", @@ -71,24 +75,28 @@ test("a file without a title isn't a note", () => { assert.equal(parseNote("---\nauthor: x\n---\nbody", "x.md"), null); }); -test("writeNote names the file by date, title and author, and never overwrites", () => { +test("writeNote names the file by date, title and author, never overwrites, and writes nothing into the repo", () => { const repo = tmpRepo("notes-write"); const a = writeNote(repo, { title: "Bbox coordinates are off on rotated PDFs", body: BODY, author: "Priya", date: "2026-10-07" }); const b = writeNote(repo, { title: "Bbox coordinates are off on rotated PDFs", body: "again", author: "Priya", date: "2026-10-07" }); - assert.equal(a.path, ".trail/notes/2026-10-07-bbox-coordinates-are-off-on-rotated-priya.md"); - assert.equal(b.path, ".trail/notes/2026-10-07-bbox-coordinates-are-off-on-rotated-priya-2.md"); - assert.match(readFileSync(join(repo, a.path), "utf8"), /Rotate each box/); + const notes = join(repoPlace(repo).dir, "notes"); + assert.ok(notes.startsWith(join(TRAIL_HOME, "repos", "local") + "/"), notes); + assert.equal(a.path, join(notes, "2026-10-07-bbox-coordinates-are-off-on-rotated-priya.md")); + assert.equal(b.path, join(notes, "2026-10-07-bbox-coordinates-are-off-on-rotated-priya-2.md")); + assert.match(readFileSync(a.path, "utf8"), /Rotate each box/); assert.equal(listNotes(repo).length, 2); + assert.deepEqual(readdirSync(repo), [], "the repo itself is untouched"); }); -test("ensureTrailDir writes the README once and keeps someone's edits", () => { +test("ensureRepoHome makes the folder once and writes ~/.trail's README once, keeping someone's edits", () => { const repo = tmpRepo("notes-dir"); - assert.equal(ensureTrailDir(repo).created, true); - assert.match(readFileSync(join(repo, ".trail", "README.md"), "utf8"), /^# \.trail/); - assert.ok(existsSync(join(repo, ".trail", "notes", ".gitkeep"))); - writeFileSync(join(repo, ".trail", "README.md"), "ours\n"); - assert.equal(ensureTrailDir(repo).created, false); - assert.equal(readFileSync(join(repo, ".trail", "README.md"), "utf8"), "ours\n"); + assert.equal(ensureRepoHome(repo).created, true); + assert.ok(existsSync(join(repoPlace(repo).dir, "notes"))); + const readme = join(TRAIL_HOME, "README.md"); + assert.match(readFileSync(readme, "utf8"), /^# ~\/\.trail/); + writeFileSync(readme, "ours\n"); + assert.equal(ensureRepoHome(repo).created, false); + assert.equal(readFileSync(readme, "utf8"), "ours\n"); }); test("small formatting helpers", () => { @@ -127,7 +135,7 @@ test("ask's notes block names the author, the date, each section's lead and the " decided Rotate each box in `NormalizeBox` by its own page's `/Rotate` before scaling.", " ruled out Rotating the image before OCR. Works, but doubles latency on long files.", " watch out `/Rotate` can be 270 on a single page inside an otherwise upright file.", - " .trail/notes/2026-10-07-bbox-priya.md · took 12 min and ~9.6k tokens to work out", + " ~/.trail/repos/github.com/acme/extract/notes/2026-10-07-bbox-priya.md · took 12 min and ~9.6k tokens to work out", "", ]); }); @@ -170,21 +178,31 @@ test("cost starts after the session's last finished note, not the one being writ assert.equal(costFromTranscript(t, Date.parse(at(3)))?.tokens, 600); }); +test("the cost carries the files the session edited since its last note, for the repos the note goes to", () => { + const edit = (id: string, name: string, input: object) => + line({ type: "assistant", timestamp: at(1), message: { id, usage: usage(0, 0, 10), content: [{ type: "tool_use", id, name, input }] } }); + const t = [ + edit("e0", "Edit", { file_path: "/src/extract/old.go" }), + line({ type: "assistant", timestamp: at(1), message: { id: "n", usage: usage(0, 0, 1), content: [{ type: "tool_use", id: "n1", name: "Bash", input: { command: "trail note --title first" } }] } }), + line({ type: "user", timestamp: at(1), message: { content: [{ type: "tool_result", tool_use_id: "n1", content: "✓" }] } }), + edit("e1", "Edit", { file_path: "/src/extract/internal/ocr/bbox.go" }), + edit("e2", "Write", { file_path: "/src/ocr-lib/rotate.py" }), + edit("e3", "MultiEdit", { file_path: "/src/extract/internal/ocr/bbox.go" }), + edit("e4", "NotebookEdit", { notebook_path: "/src/extract/notebooks/rotation.ipynb" }), + edit("e5", "Read", { file_path: "/src/extract/README.md" }), + ].join("\n"); + assert.deepEqual(costFromTranscript(t, Date.parse(at(2)))?.edited, [ + "/src/extract/internal/ocr/bbox.go", + "/src/ocr-lib/rotate.py", + "/src/extract/notebooks/rotation.ipynb", + ]); +}); + test("no transcript entries, no cost", () => { assert.equal(costFromTranscript("", Date.now()), null); assert.equal(costFromTranscript("not json\n", Date.now()), null); }); -// --- the block that tells agents about .trail/ --- - -test("the Trail block tells an agent to read .trail/, how to install trail, and to leave a note", () => { - const b = trailBlock("claude"); - assert.match(b, /check `\.trail\/`/); - assert.match(b, /npm install -g @trailhq\/trail && trail init --agents claude --yes/); - assert.match(b, /trail note --title/); - assert.match(trailBlock(), /trail init --yes/); -}); - // --- end to end --- /** Runs from this checkout (so `--import tsx` resolves); the repo goes in `args`. */ @@ -192,7 +210,7 @@ function run(name: "trail" | "graft", args: string[], home: string, input?: stri const r = spawnSync(process.execPath, ["--import", "tsx", join(process.cwd(), "src", "bin", `${name}.ts`), ...args], { input, encoding: "utf8", - env: { ...process.env, ...homeEnv(home), DO_NOT_TRACK: "1", CLAUDECODE: undefined, TRAIL_INVOKED_AS: undefined }, + env: { ...process.env, ...homeEnv(home), DO_NOT_TRACK: "1", CLAUDECODE: undefined, TRAIL_INVOKED_AS: undefined, TRAIL_HOME: undefined }, }); return { status: r.status, out: r.stdout, err: r.stderr }; } @@ -210,25 +228,31 @@ function repoWithCode(): string { return d; } -test("trail init adds .trail/ and the Trail block; graft init adds neither", () => { +/** The one folder of notes under a scratch HOME's ~/.trail/repos. */ +function notesUnder(home: string): string { + const local = join(home, ".trail", "repos", "local"); + const [key] = readdirSync(local); + return join(local, key!, "notes"); +} + +test("trail init keeps notes in ~/.trail and adds nothing about them to the repo; graft init makes no notes folder", () => { const home = mkdtempSync(join(tmpdir(), "notes-home-")); const d = repoWithCode(); const t = run("trail", ["init", d, "--agents", "claude", "--yes", "--no-global"], home); assert.equal(t.status, 0, t.err); - assert.match(t.err, /✓ \.trail\/ {4}ready · every session you finish leaves a short note here/); - assert.match(t.err, /✓ CLAUDE\.md {2}Trail block added/); - assert.ok(existsSync(join(d, ".trail", "README.md"))); - const md = readFileSync(join(d, "CLAUDE.md"), "utf8"); - assert.ok(md.startsWith("# extract\n\nTeam rules.\n"), "the team's own text stays first"); - assert.match(md, /\n## Trail\n/); + assert.match(t.err, /✓ notes {6}kept in ~\/\.trail\/repos\/local\/[^ ]+ · every session you finish leaves a short note there, never in the repo/); + assert.match(t.err, /the code map in graft\/ and your notes stay on this machine/); + assert.ok(existsSync(notesUnder(home))); + assert.equal(existsSync(join(d, ".trail")), false); + assert.equal(readFileSync(join(d, "CLAUDE.md"), "utf8"), "# extract\n\nTeam rules.\n", "the team's CLAUDE.md is untouched"); const g = repoWithCode(); - assert.equal(run("graft", ["init", g, "--agents", "claude", "--yes", "--no-global"], home).status, 0); - assert.equal(existsSync(join(g, ".trail")), false); - assert.doesNotMatch(readFileSync(join(g, "CLAUDE.md"), "utf8"), /trail:start/); + const home2 = mkdtempSync(join(tmpdir(), "notes-home-")); + assert.equal(run("graft", ["init", g, "--agents", "claude", "--yes", "--no-global"], home2).status, 0); + assert.equal(existsSync(join(home2, ".trail")), false); }); -test("trail note saves the note, ask shows it to the next person, uninstall leaves it", () => { +test("trail note saves the note in ~/.trail, ask shows it to the next session, uninstall leaves it", () => { const home = mkdtempSync(join(tmpdir(), "notes-home-")); const d = repoWithCode(); run("trail", ["init", d, "--agents", "claude", "--yes", "--no-global"], home); @@ -239,25 +263,33 @@ test("trail note saves the note, ask shows it to the next person, uninstall leav const saved = run("trail", ["note", d, "--title", "Bbox coordinates are off on rotated PDFs", "--minutes", "12", "--tokens", "9600"], home, BODY); assert.equal(saved.status, 0, saved.err); - assert.match(saved.out, /^✓ note saved · \.trail\/notes\/\d{4}-\d{2}-\d{2}-bbox-coordinates-are-off-on-rotated-priya\.md$/m); - assert.match(saved.out, /this took 12 min and ~9\.6k tokens to figure out\. the next person who touches bbox\.go gets it for ~\d+/); - const [file] = readdirSync(join(d, ".trail", "notes")).filter((f) => f.endsWith(".md")); - const written = parseNote(readFileSync(join(d, ".trail", "notes", file!), "utf8"), "x"); + assert.match(saved.out, /^✓ note saved · ~\/\.trail\/repos\/local\/[^/]+\/notes\/\d{4}-\d{2}-\d{2}-bbox-coordinates-are-off-on-rotated-priya\.md$/m); + assert.match(saved.out, /this took 12 min and ~9\.6k tokens to figure out\. the next session that touches bbox\.go gets it for ~\d+/); + assert.match(saved.out, /^· kept on this machine, never in the repo$/m); + const [file] = readdirSync(notesUnder(home)).filter((f) => f.endsWith(".md")); + const written = parseNote(readFileSync(join(notesUnder(home), file!), "utf8"), "x"); assert.equal(written?.author, "Priya"); assert.deepEqual(written?.touches, ["internal/ocr/bbox.go"], "the changed file, not the wiring init wrote"); + const status = spawnSync("git", ["status", "--porcelain"], { cwd: d, encoding: "utf8" }).stdout; + assert.equal(status, " M internal/ocr/bbox.go\n", "git sees only the code change"); - // A teammate still on graft gets the note too. - for (const name of ["trail", "graft"] as const) { - const ask = run(name, ["ask", "bbox rotated pdf", d], home); + // Asked from either name, and from a subfolder of the repo. + for (const [name, dir] of [["trail", d], ["graft", d], ["trail", join(d, "internal")]] as const) { + const ask = run(name, ["ask", "bbox rotated pdf", dir], home); assert.equal(ask.status, 0, ask.err); assert.match(ask.out, /from Priya's note · \w{3} \d+ · Bbox coordinates are off on rotated PDFs/); assert.match(ask.out, /ruled out {3}Rotating the image before OCR/); + assert.match(ask.out, /~\/\.trail\/repos\/local\/[^/]+\/notes\//); } + const st = run("trail", ["status", d], home); + assert.equal(st.status, 0, st.err); + assert.match(st.out, /^notes {7}1 · in ~\/\.trail\/repos\/local\/[^/]+$/m); + assert.equal(JSON.parse(run("trail", ["status", d, "--json"], home).out).notes.count, 1); + const un = run("trail", ["uninstall", d, "-y", "--no-global"], home); assert.equal(un.status, 0, un.err); - assert.doesNotMatch(readFileSync(join(d, "CLAUDE.md"), "utf8"), /trail:start/); - assert.ok(existsSync(join(d, ".trail", "notes", file!)), "the team's notes are theirs, never uninstalled"); + assert.ok(existsSync(join(notesUnder(home), file!)), "notes are the person's, never uninstalled"); }); test("trail note without a body says how to write one", () => { @@ -269,7 +301,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 with .trail/, keeps graft/ paths, and teaches notes", async () => { +test("the skill speaks trail in a repo wired for it, keeps graft/ paths, and teaches notes", async () => { const { skillTemplate } = await import("../src/claude/skill-template.js"); const graft = skillTemplate("graft"); const trail = skillTemplate("trail"); @@ -281,20 +313,28 @@ test("the skill speaks trail in a repo with .trail/, keeps graft/ paths, and tea assert.match(trail, /`graft\/` holds a graph/, "the code map folder is still graft/"); assert.doesNotMatch(trail, /\bgraft (ask|grep|skeleton|callers|map|build|check)\b/); assert.match(trail, /trail note --title/); + assert.match(trail, /`~\/\.trail\/`, never in the repo/); + assert.doesNotMatch(trail, /\.trail\/notes|gets committed/); }); -test("the session-start directive mentions notes only where there are some", async () => { +test("the session-start directive mentions notes only in a repo that keeps them", async () => { const { formatOrientation } = await import("../src/claude/format.js"); - assert.doesNotMatch(formatOrientation("# map", 100), /\.trail\//); - assert.match(formatOrientation("# map", 100, undefined, 3), /Teammates have left 3 notes in \.trail\//); + assert.doesNotMatch(formatOrientation("# map", 100), /note/); + const some = formatOrientation("# map", 100, undefined, 3, true); + assert.match(some, /This repo has 3 notes from past sessions/); + assert.match(some, /leave a note for the next session/); + const none = formatOrientation("# map", 100, undefined, 0, true); + assert.doesNotMatch(none, /This repo has/); + assert.match(none, /leave a note for the next session/, "a repo set up for notes is told to leave one from the first session"); }); -test("hooks speak trail only in a repo that uses it, on a machine with trail installed", async () => { +test("hooks speak trail only in a repo wired for it, on a machine with trail installed", async () => { const { adoptRepoBrand } = await import("../src/brand.js"); const bin = mkdtempSync(join(tmpdir(), "notes-bin-")); writeFileSync(join(bin, process.platform === "win32" ? "trail.cmd" : "trail"), ""); const withTrail = tmpRepo("brand-repo"); - ensureTrailDir(withTrail); + mkdirSync(join(withTrail, ".claude", "helpers"), { recursive: true }); + writeFileSync(join(withTrail, ".claude", "helpers", "trail-hooks.cjs"), ""); const plain = tmpRepo("brand-plain"); assert.equal(adoptRepoBrand(withTrail, { PATH: bin }), "trail"); assert.equal(adoptRepoBrand(withTrail, { PATH: "" }), "graft", "trail not installed: don't suggest it"); From 6e231052eb5d426453ba57937ec6eb9aff3df08c Mon Sep 17 00:00:00 2001 From: anirudhkumar-nanonets Date: Wed, 7 Oct 2026 17:53:00 +0530 Subject: [PATCH 5/5] feat(notes): the trail skill says when to run trail ask and trail note, up front --- src/claude/skill-template.ts | 36 +++++++++++++++++++++++++++++++++--- test/trail-notes.test.ts | 17 +++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/claude/skill-template.ts b/src/claude/skill-template.ts index 466f399f6..8efd3423c 100644 --- a/src/claude/skill-template.ts +++ b/src/claude/skill-template.ts @@ -12,17 +12,47 @@ export function skillTemplate(brand: Brand = "graft"): string { return brand === "trail" ? trailSkill(graftSkill()) : graftSkill(); } +/** + * When Claude Code loads the trail skill. It reads only the description up + * front and opens the body when the description fits the moment, so the + * description names every moment a trail command belongs to, not just code + * search. It's a plain YAML scalar, so no ": " and no " #". + */ +const TRAIL_DESCRIPTION = `Use for ANY task in this repo, whether understanding how something works, + finding where code lives, tracing what calls a symbol or scoping an edit. + Run trail ask before grepping or reading source. It finds the code and the + notes past sessions left on what was decided and ruled out. When you finish + a task that took real digging, leave a note with trail note.`; + +/** The same moments, first in the body, so they're read before the tool docs. */ +const TRAIL_MOMENTS = `## When to reach for trail +- **Before exploring code**, run \`trail ask "" --source\`. Notes from + past sessions on what was decided, tried and ruled out print above the code. + Read them first. +- **When you finish a task that took real digging**, leave a note for the next + session with \`trail note\`. How is under Session notes below. + +## The code map +`; + /** * The graft text with trail's spelling. The code map still lives in `graft/`, * so a `graft` followed by `/` is a path and stays; `graft check` became - * `trail build --check`. Then the part only trail has: session notes. + * `trail build --check`. Trail has its own frontmatter and opens with the + * moments to reach for it. Then the part only trail has: session notes. */ function trailSkill(graft: string): string { const text = graft + .replace(/^---\n[\s\S]*?\n---\n/, "") .replace("### 6 · Lifecycle: \`graft build\` / \`graft check\`", "### 6 · Lifecycle: \`trail build\` / \`trail build --check\`") .replace("\`check\` fails when \`graft/\` is stale, for CI.", "\`build --check\` fails when \`graft/\` is stale, for CI.") - .replace(/\bgraft\b(?![/\w-])/g, "trail"); - return `${text.trimEnd()} + .replace(/\bgraft\b(?![/\w-])/g, "trail") + .replace("# trail\n", `# trail\n\n${TRAIL_MOMENTS}`); + return `--- +name: trail +description: ${TRAIL_DESCRIPTION} +--- +${text.trimEnd()} ## Session notes Past sessions on this repo leave notes: what they decided, tried and ruled diff --git a/test/trail-notes.test.ts b/test/trail-notes.test.ts index 2d2ccdc99..8c70b3e98 100644 --- a/test/trail-notes.test.ts +++ b/test/trail-notes.test.ts @@ -317,6 +317,23 @@ test("the skill speaks trail in a repo wired for it, keeps graft/ paths, and tea assert.doesNotMatch(trail, /\.trail\/notes|gets committed/); }); +// Claude Code reads only a skill's description up front, so the moments to +// reach for trail have to be named there, and lead the body. +test("the trail skill's description names when to use its commands, and the body leads with them", async () => { + const { skillTemplate } = await import("../src/claude/skill-template.js"); + const matter = (await import("gray-matter")).default; + const { data, content } = matter(skillTemplate("trail")); + assert.equal(data.name, "trail"); + assert.ok(data.description.length <= 1024, "skill descriptions are capped at 1,024 characters"); + assert.match(data.description, /ANY task in this repo/); + assert.match(data.description, /trail ask before grepping/); + assert.match(data.description, /When you finish a task .* trail note/); + assert.doesNotMatch(data.description, /graft/, "nothing about the old name or the graft\/ folder"); + const moments = content.indexOf("## When to reach for trail"); + assert.ok(moments >= 0 && moments < content.indexOf("## The tools"), "the moments come before the tool docs"); + assert.match(content, /\*\*When you finish a task that took real digging\*\*/); +}); + test("the session-start directive mentions notes only in a repo that keeps them", async () => { const { formatOrientation } = await import("../src/claude/format.js"); assert.doesNotMatch(formatOrientation("# map", 100), /note/);