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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion TELEMETRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 23 additions & 8 deletions src/ask/ask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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)`
);
}

Expand Down Expand Up @@ -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 ?? [])}`,
};
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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}` : "";
Expand All @@ -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
Expand All @@ -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}` : "";
Expand Down Expand Up @@ -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 \"<literal>\"` for every occurrence · `graft skeleton <file>` for a file's full API · `graft callers <symbol>` for who-uses."
`\`${cmd("graft grep")} "<literal>"\` for every occurrence · \`${cmd("graft skeleton")} <file>\` for a file's full API · \`${cmd("graft callers")} <symbol>\` for who-uses.`
);
}

Expand Down
35 changes: 34 additions & 1 deletion src/brand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 */
/* -------------------------------------------------------------------------- */
Expand Down
Loading