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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ npm install -g @trailhq/trail-code && trail init
Trail-code has two parts. Both run on your machine.

1. **The graft map: your code.** `trail init` parses your repo with tree-sitter into a map of every file, function and who calls what. No model, no API key. It refreshes itself when the code changes, uncommitted edits included. Your agent gets pointers to the matching files and functions with each prompt, and sees what depends on a file right after it edits it.
2. **Learnings: what you, your agents and your team know.** When a session works something out, or you correct how something is done, your agent saves it as a learning. The next session on that code, yours or a teammate's, starts from it instead of digging again. Learnings about the code are committed with the code; learnings about you stay on your machine. On day one, `trail init` starts you off with what you told Claude more than once in your past sessions.
2. **Learnings: what you, your agents and your team know.** When a session works something out, or you correct how something is done, your agent saves it as a learning. The next session on that code, yours or a teammate's, starts from it instead of digging again. Learnings about the code are committed with the code; learnings about you stay on your machine. On day one, `trail init` starts you off with what you told Claude more than once in your past sessions, and what reviewers flagged on more than one of the repo's merged PRs.

The graft map tells your agent *where* things are. Learnings tell it *why* they are that way, and *how* the work gets done here.

Expand Down Expand Up @@ -312,6 +312,7 @@ Coming soon: a Trail workspace, for your team's view across repos. A team page w
- **Personal learnings.** Kept in `~/.trail`. Never committed, never uploaded.
- **Repo learnings.** Files in your repo, shared through git like any other file.
- **Your past sessions.** `trail init` reads your own messages from Claude Code's transcripts for the repo, on your machine. Your own Claude Code does the reading, with Claude Sonnet under your login, so your messages go only to Anthropic, and it counts toward your Claude usage. None of it goes to Trail.
- **Your PR reviews.** When `gh` is installed and signed in, `trail init` reads the review comments on the repo's last 50 merged PRs with it, under your GitHub login. Anything that looks like a secret is taken out, and your own Claude Code reads them the same way. None of it goes to Trail.
- **If you connect a Trail workspace.** No learning goes to Trail until you do. Once connected, the learnings in the repos you picked go to Trail, including those on teammates' pushed branches. So does a record of each search and check that used them: the search text, the files, and whose learnings were shown. That is how the team page counts reuse.
- **Anonymous usage stats.** Buckets and fixed labels only, never the title, text or paths of a learning. [`TELEMETRY.md`](TELEMETRY.md) lists every field. Turn it off with `trail telemetry disable` or `DO_NOT_TRACK=1`.

Expand Down
2 changes: 1 addition & 1 deletion TELEMETRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ The events:
| `learning_reused` | `teammate` (`true`/`false`), `saved_tokens_bucket` | `trail ask` shows learnings from earlier sessions. Which ones, and their text, are never sent |
| `repo_seen` | `trail_committed` (`true`/`false`), `learnings_bucket`, `workspace` (`true`/`false`) | Once a day per repo, when a Claude Code session starts in a repo trail runs in. The repo's name and paths are never sent; it's identified only by the random `repo_id` every event carries |
| `learnings_committed` | `added_bucket`, `total_bucket` (learnings committed on `HEAD`, bucketed), `first_look` (`true`/`false`) | A Claude Code session starts in a repo trail runs in and more learnings are committed there than at the last session. Their names and text are never sent |
| `cold_start` | `outcome` (`found`/`none_found`/`no_sessions`/`no_claude`/`failed`/`cancelled`), `sessions_bucket`, `offered_bucket`, `picked_bucket` (how many it saved: the ones the person kept), `duration_bucket`, `model` | `trail init` reads your past Claude Code sessions in the repo, once, for things you told Claude more than once, and saves them as learnings. The reading is done by your own Claude Code; your messages and what it found are never sent to trail |
| `cold_start` | `source` (`sessions`/`reviews`), `outcome` (`found`/`none_found`/`no_sessions`/`no_reviews`/`no_gh`/`no_claude`/`failed`/`cancelled`), `sessions_bucket` (sessions) or `reviews_bucket` (merged PRs with a reviewer's comment; reviews), `offered_bucket`, `picked_bucket` (how many it saved: the ones the person kept), `duration_bucket`, `model` | `trail init` reads your past Claude Code sessions in the repo, once, for things you told Claude more than once, and the review comments on the repo's last 50 merged PRs, once, for things reviewers flagged on more than one PR, and saves them as learnings. The reviews are fetched with your own `gh` (`no_gh` when it isn't installed or signed in) and the reading is done by your own Claude Code; your messages, the comments and what it found are never sent to trail |
| `takeaway_saved` | `new_skill` (`true`/`false`) | `trail learn` adds a correction to a skill, on this machine (`~/.trail/`). Its text is never sent |
| `skills_folded` | `takeaways_bucket` (how many corrections were folded, bucketed) | `trail skills fold` writes waiting corrections into a skill's SKILL.md |
| `session_summary` | `graft_reads_bucket`, `source_reads_bucket`, `saved_tokens_bucket`, `graft_turns_bucket`, `reported_turns_bucket` | Once, after an agent session ends |
Expand Down
14 changes: 10 additions & 4 deletions src/cli-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,12 +407,14 @@ async function drivePicker(initial: PickerState, title?: string | null): Promise
}
}

/** One thing `trail init` found the person kept telling Claude, as the picker shows it. */
/** One thing `trail init` found the person kept telling Claude, or reviewers kept flagging, as the picker shows it. */
export interface LearningChoice {
title: string;
forTeam: boolean;
/** Past sessions it was said in. */
sessions: number;
sessions?: number;
/** Merged PRs reviewers flagged it on, for one from PR reviews. */
prs?: number;
}

/** One row per learning init found, every one ticked: the person unticks what Claude shouldn't follow. */
Expand All @@ -423,7 +425,7 @@ export function learningsPickerState(items: LearningChoice[]): PickerState {
kind: 'host' as const,
detected: true,
hasGlobal: false,
summary: `${it.forTeam ? 'for your team' : 'just for you'} · told Claude in ${it.sessions} sessions`,
summary: `${it.forTeam ? 'for your team' : 'just for you'} · ${it.prs ? `flagged in review on ${it.prs} PRs` : `told Claude in ${it.sessions ?? 0} sessions`}`,
}));
return { rows, cursor: 0, checked: new Set(rows.map((r) => r.id)), done: false, aborted: false };
}
Expand All @@ -435,7 +437,11 @@ export function keptLearnings(state: PickerState): number[] {

/** Ask which learnings to keep: their indexes, or null when the person cancelled. */
export async function pickLearnings(items: LearningChoice[]): Promise<number[] | null> {
const title = `${brand()} init — you told Claude these more than once. Keep the ones it should follow:`;
const fromReviews = items.filter((it) => it.prs).length;
const title =
fromReviews === 0
? `${brand()} init — you told Claude these more than once. Keep the ones it should follow:`
: `${brand()} init — ${fromReviews === items.length ? 'reviewers flagged these on more than one PR' : 'these came up more than once in your sessions and PR reviews'}. Keep the ones Claude should follow:`;
const state = await drivePicker(learningsPickerState(items), title);
return state ? keptLearnings(state) : null;
}
Expand Down
110 changes: 75 additions & 35 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import { listSessionIds, readSession, sessionDir } from "./claude/state.js";
import { costLabel, listNotes, noteName, notePlace, noteTargets, readingTokens, savedByNote, commitLines, PERSONAL_SAVED_LINE, SECTIONS, sectionLead, shortDate, tokensLabel, writeNote, type Note, mentionedFiles } from "./notes/notes.js";
import { currentSessionCost } from "./notes/session-cost.js";
import { beginPastRead, finishPastRead, type Finding } from "./notes/cold-start.js";
import { beginReviewRead, finishReviewRead, type ReviewFinding } from "./notes/pr-reviews.js";
import { currentBranch, noteAuthor, teammatesOn } from "./notes/git-facts.js";
import { confirm, fold, getSkill, learn, listSkills, publish, skillForAgent, skillName, skillStatus, type LearnResult } from "./skills/skills.js";
import { clearPending, cloudLines, loginLink, sessionKey, waitForConnect } from "./cloud/nudge.js";
Expand Down Expand Up @@ -313,14 +314,16 @@ function spelling(action: Command): string {
// Hidden: spawned detached by maybeSyncInBackground when a workspace repo's
// learnings changed, or uses are waiting to go. Running it by hand syncs now.
// A repo that moved from graft to trail at session start reads its past
// sessions here, detached, so the session doesn't wait on it.
// sessions and PR reviews here, detached, so the session doesn't wait on it.
program
.command("_trail-past-read", { hidden: true })
.argument("<dir>")
.action(async (dir: string) => {
const repo = resolve(dir);
const read = beginPastRead(repo);
const reviews = beginReviewRead(repo);
if (read) await finishPastRead(repo, read);
if (reviews) await finishReviewRead(repo, reviews);
});

program
Expand Down Expand Up @@ -1510,10 +1513,13 @@ async function runInitCommand(dir: string, opts: InitOptions): Promise<{ ids: st
const cliPath = fileURLToPath(import.meta.url);

// Under trail, with Claude Code picked: what the person kept telling Claude
// in this repo's past sessions, read while the rest of init runs and saved
// as learnings at the end. Once per repo, and never asked about first.
// in this repo's past sessions, and what reviewers kept flagging on its
// merged PRs, read while the rest of init runs and saved as learnings at
// the end. Once per repo, and never asked about first.
const pastRead = TRAIL && wantClaude ? beginPastRead(repo) : null;
if (pastRead) console.error(`· reading your ${pastRead.sessions} past Claude sessions here for what you keep telling Claude`);
const reviewRead = TRAIL && wantClaude ? beginReviewRead(repo) : null;
if (reviewRead) console.error("· reading review comments on this repo's merged PRs for what reviewers keep flagging");

if (children.length)
console.error(`· workspace: wiring ${repo} and ${children.length} child repo(s) — ${children.join(", ")}`);
Expand Down Expand Up @@ -1575,41 +1581,75 @@ async function runInitCommand(dir: string, opts: InitOptions): Promise<{ ids: st
}
}

// Under trail: what init learned from past sessions, where this repo's
// learnings are, and how many there already are.
// Under trail: what init learned from past sessions and PR reviews, where
// this repo's learnings are, and how many there already are.
let learnedNow = false;
if (TRAIL && pastRead) {
if (!pastRead.done) console.error("· still reading your past sessions…");
// On a terminal, the person ticks which to keep, all ticked to start;
// a scripted run, or one with --yes, keeps them all.
const keep =
process.stdin.isTTY && process.stderr.isTTY && !opts.yes
? async (found: Finding[]) => {
const picked = await pickLearnings(found.map((f) => ({ title: f.title, forTeam: f.for === "team", sessions: f.sessions.length })));
return picked && picked.map((i) => found[i]!);
}
if (TRAIL && (pastRead || reviewRead)) {
if (pastRead && !pastRead.done) console.error("· still reading your past sessions…");
const fromSessions = pastRead ? (await pastRead.result).findings : [];
if (reviewRead && !reviewRead.done) console.error("· still reading PR reviews…");
const fromReviews = reviewRead ? (await reviewRead.result).findings : [];
// On a terminal, the person ticks which to keep in one list for both
// sources, all ticked to start; a scripted run, or one with --yes, keeps
// them all. Cancelling saves none, and the next init asks again.
const picked =
process.stdin.isTTY && process.stderr.isTTY && !opts.yes && fromSessions.length + fromReviews.length
? await pickLearnings([
...fromSessions.map((f) => ({ title: f.title, forTeam: f.for === "team", sessions: f.sessions.length })),
...fromReviews.map((f) => ({ title: f.title, forTeam: true, prs: f.prs.length })),
])
: undefined;
const { result, saved, kept, cancelled } = await finishPastRead(repo, pastRead, keep);
learnedNow = saved.length > 0;
const offered = result.findings.length;
if (saved.length) {
const n = saved.length;
const what = n < offered ? `kept ${n} of the ${offered} things` : `learned ${n} thing${n === 1 ? "" : "s"}`;
console.error(`✓ ${what} you told Claude more than once, from your last ${result.read} sessions here`);
// Each with how many sessions it was said in: saveFindings keeps the findings' order.
saved.forEach((note, i) => {
const said = kept[i]?.sessions.length ?? 0;
const count = said > 1 ? `told Claude in ${said} sessions: ` : "";
console.error(` ${note.scope === "personal" ? "just for you " : "for your team"} · ${count}${note.title}`);
});
// The picked rows that are `found`'s, which start at `from` in the list.
const keepFrom =
<T>(from: number) =>
picked === undefined
? undefined
: async (found: T[]) => picked && picked.filter((i) => i >= from && i < from + found.length).map((i) => found[i - from]!);
const learned: Note[] = [];
if (pastRead) {
const { result, saved, kept, cancelled } = await finishPastRead(repo, pastRead, keepFrom<Finding>(0));
const offered = result.findings.length;
if (saved.length) {
const n = saved.length;
const what = n < offered ? `kept ${n} of the ${offered} things` : `learned ${n} thing${n === 1 ? "" : "s"}`;
console.error(`✓ ${what} you told Claude more than once, from your last ${result.read} sessions here`);
// Each with how many sessions it was said in: saveFindings keeps the findings' order.
saved.forEach((note, i) => {
const said = kept[i]?.sessions.length ?? 0;
const count = said > 1 ? `told Claude in ${said} sessions: ` : "";
console.error(` ${note.scope === "personal" ? "just for you " : "for your team"} · ${count}${note.title}`);
});
} else if (cancelled) {
console.error(`· saved none of the ${offered} things you told Claude more than once; ${cmd("graft init")} again asks again`);
} else if (offered) {
console.error(`· kept none of the ${offered} things you told Claude more than once`);
} else if (result.reason === "failed") {
console.error(`· couldn't read your past sessions this time; ${cmd("graft init")} again tries again`);
}
learned.push(...saved);
}
if (reviewRead) {
const { result, saved, kept, cancelled } = await finishReviewRead(repo, reviewRead, keepFrom<ReviewFinding>(fromSessions.length));
const offered = result.findings.length;
if (saved.length) {
const n = saved.length;
const what = n < offered ? `kept ${n} of the ${offered} things` : `learned ${n} thing${n === 1 ? "" : "s"}`;
console.error(`✓ ${what} reviewers flagged on more than one PR, from the last ${result.prs} merged PRs here`);
// saveReviewFindings keeps the findings' order.
saved.forEach((note, i) => console.error(` for your team · flagged in review on ${kept[i]?.prs.length ?? 2} PRs: ${note.title}`));
} else if (cancelled) {
console.error(`· saved none of the ${offered} things reviewers flagged; ${cmd("graft init")} again asks again`);
} else if (offered) {
console.error(`· kept none of the ${offered} things reviewers flagged`);
} else if (result.reason === "failed") {
console.error(`· couldn't read PR reviews this time; ${cmd("graft init")} again tries again`);
}
learned.push(...saved);
}
learnedNow = learned.length > 0;
if (learnedNow) {
console.error("· Claude follows them from your next session");
if (saved.some((s) => s.scope === "repo")) console.error(commitLines({}, teammatesOn(repo), "repo").uncommitted);
} else if (cancelled) {
console.error(`· saved none of the ${offered} things you told Claude more than once; ${cmd("graft init")} again asks again`);
} else if (offered) {
console.error(`· kept none of the ${offered} things you told Claude more than once`);
} else if (result.reason === "failed") {
console.error(`· couldn't read your past sessions this time; ${cmd("graft init")} again tries again`);
if (learned.some((s) => s.scope === "repo")) console.error(commitLines({}, teammatesOn(repo), "repo").uncommitted);
}
}
if (TRAIL && !learnedNow) {
Expand Down
Loading
Loading