diff --git a/TELEMETRY.md b/TELEMETRY.md index bb24aeb3a..b178f80e4 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 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 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 755d2a92d..b94a769b8 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; + /** 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 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 * 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") : ""; + // 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 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/brand.ts b/src/brand.ts index 62db81fa1..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 { mkdirSync, 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"; @@ -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` 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 readFileSync(join(dir, ".claude", "settings.json"), "utf8").includes("trail-hooks.cjs"); + } catch { + 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 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(repoWiredForTrail(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..5706c2520 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)); + // 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. @@ -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,50 @@ 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, 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 // 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, keepsNotes); const banner = staleNote ? `${staleNote}\n\n` : ""; return `${banner}${directive}\nrepo map (graft/INDEX.md):\n${indexMd.slice(0, budgetBytes)}`; } +/** + * 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, 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 ( + 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` + ); +} + 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..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 { 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 @@ -91,7 +92,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; } } @@ -462,6 +463,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 +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); + 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 0ea11d418..0fb944285 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,22 +10,38 @@ 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 { 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[] { +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 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, initBrand?: Brand): Brand { + return initBrand === 'trail' || repoWiredForTrail(dir) ? 'trail' : 'graft'; +} + +/** `claudeTargets` under one name. Uninstall walks both, so either set comes out. */ +export function claudeTargetsFor(dir: string, name: Brand): PlannedWrite[] { 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'), ]; } @@ -64,17 +80,18 @@ 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 [settings, statusline, hooks, skill, mcpTarget] = claudeTargets(dir).map((t) => t.path); + const name = wiringName(dir, opts.brand); + 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; @@ -87,12 +104,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()); + 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 3ee1d15b8..f772a977f 100644 --- a/src/claude/settings-merge.ts +++ b/src/claude/settings-merge.ts @@ -2,16 +2,17 @@ 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 // 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:*)', @@ -28,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 @@ -42,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 @@ -52,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 }] }], }; } /** @@ -66,7 +68,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. */ @@ -87,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( @@ -96,6 +98,7 @@ function applyStatusline( warnings: string[], wanted: boolean, foreignWarning: string, + name: 'graft' | 'trail' = 'graft', ): void { const current = merged[key]; const ours = isGraftStatusline(current); @@ -105,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); @@ -113,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/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..8efd3423c 100644 --- a/src/claude/skill-template.ts +++ b/src/claude/skill-template.ts @@ -1,9 +1,79 @@ +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 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(); +} + +/** + * 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`. 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") + .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 +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 +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 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. +`; +} + +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..67ab78db3 100644 --- a/src/claude/statusline.ts +++ b/src/claude/statusline.ts @@ -2,6 +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 } from '../brand.js'; +import { noteCount } from '../notes/home.js'; /** * The statusline's fast path is the hook-maintained cache (graft/.cache/stats.json). @@ -25,11 +27,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 f976b719e..edad325c9 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"; @@ -22,6 +22,7 @@ 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 { 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"; @@ -90,9 +91,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, noteTargets, renderNote, SECTIONS, sectionLead, shortDate, tokensLabel, writeNote, type Note } from "./notes/notes.js"; +import { currentSessionCost } from "./notes/session-cost.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 { + countBucket, errorCode, filesBucket, durationBucket, @@ -124,6 +129,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 +150,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 +321,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 +719,8 @@ grouped(program.command("ask"), GROUP.find) return; } noteHit(r.hits.length > 0); + // 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)); } else { @@ -1050,6 +1058,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 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, 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") + .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 +1099,83 @@ 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; + // 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(saved.length === 1 ? saved[0] : saved, null, 2)); + return; + } + 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 ? ` 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 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])); + 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("· kept on this machine, never in the repo"); +} + /** `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 } @@ -1201,6 +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, 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)}/`); @@ -1254,6 +1364,17 @@ async function runInitCommand( } } + // Under trail: where this repo's notes are, and how many there already are. + if (TRAIL) { + const home = notesHome.get(repo); + if (home) { + const notes = listNotes(repo); + 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}`); + } + } + // 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. @@ -1287,7 +1408,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/ and your notes stay on this machine" : "graft/ stays local and git-ignored"), + ); } // One epilogue for the whole run. A workspace parent holds no nodes of its @@ -1363,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}`); @@ -2084,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) @@ -2097,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; @@ -2107,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 }, }, @@ -2121,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( @@ -2150,7 +2279,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 +2293,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/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/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 801c5aa97..e9590a3e4 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,13 +424,21 @@ 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); + // 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) }, ] as Target[]) add(t); } diff --git a/src/notes/ask-notes.ts b/src/notes/ask-notes.ts new file mode 100644 index 000000000..d8067f1ed --- /dev/null +++ b/src/notes/ask-notes.ts @@ -0,0 +1,22 @@ +/** + * 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 type { AskResult } from "../ask/ask.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 that keeps no notes on this machine is left alone. */ +export function attachNotes(r: AskResult, repo: string): AskResult { + 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; + return r; +} diff --git a/src/notes/git-facts.ts b/src/notes/git-facts.ts new file mode 100644 index 000000000..7c6a32dd9 --- /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 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"; +} + +/** + * 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/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/notes.ts b/src/notes/notes.ts new file mode 100644 index 000000000..c6c98890b --- /dev/null +++ b/src/notes/notes.ts @@ -0,0 +1,354 @@ +/** + * 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. 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. A summary only, + * never a transcript: the agent writes the body, and nothing here reads + * source files or prompts into it. + */ +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"; +import { changedFiles, ownPath } from "./git-facts.js"; +import { checkoutRoot, childCheckouts, ensureRepoHome, notePlaces, repoPlace, shownPath } from "./home.js"; + +export interface NoteCost { + minutes?: number; + tokens?: number; +} + +export interface Note { + /** Where the note is: an absolute path on this machine. */ + path: string; + title: string; + author: string; + /** YYYY-MM-DD. */ + date: string; + branch?: string; + cost?: NoteCost; + /** `file` or `file#Symbol`, relative to the top of the checkout (or to the folder `listNotes` was asked about). */ + 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; + +/* -------------------------------------------------------------------------- */ +/* 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(), + }; +} + +/** 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 place of notePlaces(here)) { + const notes = join(place.dir, "notes"); + let files: string[]; + try { + files = readdirSync(notes).filter((f) => f.endsWith(".md")); + } catch { + 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))); +} + +/** 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 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(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(notes, 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(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: [] }]; +} + +/* -------------------------------------------------------------------------- */ +/* 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(` ${shownPath(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..e961f4290 --- /dev/null +++ b/src/notes/session-cost.ts @@ -0,0 +1,172 @@ +/** + * 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 { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +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; +/** 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 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 = sessionProjectDir(repo, home); + if (!dir) return null; + 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>) : []; +} + +/** 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/; + +/** + * 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 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; + 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); + } + 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. */ +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..1cfebfd5c 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/`). 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/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-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..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, []); @@ -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/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/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-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 new file mode 100644 index 000000000..8c70b3e98 --- /dev/null +++ b/test/trail-notes.test.ts @@ -0,0 +1,398 @@ +/** + * 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 { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { + costLabel, + 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 { 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.", + "", + "## 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: 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", + 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, 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" }); + 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("ensureRepoHome makes the folder once and writes ~/.trail's README once, keeping someone's edits", () => { + const repo = tmpRepo("notes-dir"); + 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", () => { + 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/repos/github.com/acme/extract/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("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); +}); + +// --- 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, TRAIL_HOME: 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; +} + +/** 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, /✓ 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(); + 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 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); + // 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\/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"); + + // 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.ok(existsSync(join(notesUnder(home), file!)), "notes are the person's, 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/); +}); + +// --- what agents read in a repo that uses trail --- + +test("the skill speaks trail in a repo wired for it, keeps graft/ paths, and teaches 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: trail$/m, "it lives in .claude/skills/trail/"); + assert.match(trail, /`trail ask "" --source`/); + assert.match(trail, /`trail build` \/ `trail build --check`/); + assert.match(trail, /`graft\/` holds a graph/, "the code map folder is still graft/"); + assert.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/); +}); + +// 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/); + 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 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"); + 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"); + 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; + } +});