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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ npm install -g @trailhq/trail-code && trail init
| Greps and opens file after file to find the code it needs | Gets the right files and functions with your prompt |
| Re-explores code a teammate already figured out last week | Starts from their learning: what they decided, what they ruled out, what to watch out for |
| Undoes a teammate's decision, because it never knew about it | `trail check` flags it before the PR, quoting who decided it and why |
| Finds out at merge time that a teammate changed the same file | Opens its reply with the overlap: `● overlap · Kumar's branch kumar/retry also changes client.go` |
| Finds out at merge time that a teammate changed the same code | Says so right after it edits code a teammate's open branch also changes, and whether the two clash |
| Has to be told the same things every session | Starts from what you've told it before: `trail init` finds what you said more than once in your past sessions |
| You write and update CLAUDE.md by hand | Learnings are saved from your conversations, in files you can read |

Expand Down Expand Up @@ -226,10 +226,10 @@ checking 1 changed file against 1 learning and 0 skills

Kumar's Claude explains the override in the commit.

**4. Anirudh asks about the same code again.** Kumar's branch isn't merged yet, but Anirudh's Claude opens its reply with:
**4. Anirudh edits the same constant.** Kumar's branch isn't merged yet. Right after the edit, Anirudh's Claude reads Kumar's change and says something like:

```text
● overlap · Kumar's branch kumar/retry also changes client.go (pushed today 15:32)
🔀 trail · Kumar is also changing defaultRetryMax on kumar/retry. He raises it to 10 and you're lowering it, so the two clash.
```

None of this needs a server. Learnings travel through git, like the code they are about.
Expand Down Expand Up @@ -612,7 +612,7 @@ When Claude Code is selected (it is by default), it gets more than the skill fil
- **auto-sync**: every query brings the graph up to date first, so an answer always describes the code as it is right now, uncommitted edits included. A query refreshes only what it reads; the markdown under `graft/` is refreshed by the background rebuild at the end of a turn that touched code. Both are structural and `$0`; auto-sync never calls the LLM on its own
- **context on tap**: each prompt pulls in the matching nodes and the learnings left on that code; editing a file surfaces what depends on it ("blast radius"); new sessions start with the repo map and how many learnings there are
- **the ask to save a learning**: after a turn of real digging with nothing saved, Claude is asked once a session to save one
- **overlap with teammates**: at session start, and every few minutes, it reads which files teammates' recent pushed branches change; when one changes the code your prompt is about, Claude says so first
- **overlap with teammates**: at session start, and every few minutes, it reads what teammates' open branches change, skipping any already merged or closed; when Claude edits a function or constant one of them also changes, it reads their change and tells you whether the two clash

<p align="center">
<img src="assets/graft-hook-blast-radius-demo.gif" alt="The post-edit hook: editing node-file.ts prints its blast radius (who depends on it) inline, the statusline flips stale → syncing → synced on its own, and the same dependents light up in the viewer" width="820"/>
Expand Down
4 changes: 0 additions & 4 deletions src/ask/ask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,6 @@ export interface AskResult {
notesChecked?: boolean;
/** One line about the notes, e.g. that this many would search better ranked. */
notesHint?: string;
/** Teammates' pushed branches that change the files these results point into
* (notes/branches.ts), one line each. */
overlaps?: string[];
/** 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 @@ -1498,7 +1495,6 @@ export function formatAsk(r: AskResult): string {
: [];
const learned = r.notes?.length ? learningSavingsLine(r.notes) : null;
if (learned) teamNotes.push(learned, "");
if (r.overlaps?.length) teamNotes.push(...r.overlaps, "");
if (r.notesHint) teamNotes.push(r.notesHint, "");
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`;
Expand Down
19 changes: 0 additions & 19 deletions src/claude/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,6 @@ export interface AskJson {
coverageStrong?: number;
/** Notes from earlier sessions that bear on the query, when this repo keeps them. */
notes?: NoteHit[];
/** Teammates' pushed branches that change the files the hits point into, one line each. */
overlaps?: string[];
}

/** How many notes one prompt may bring in. */
Expand Down Expand Up @@ -127,23 +125,6 @@ export function notesForPrompt(ask: AskJson, s: SessionState, cap = PROMPT_NOTES
);
}

/**
* Teammates' pushed branches that change the code this prompt is about, each
* at most once a session, for the agent to mention: two people on the same
* thing is worth knowing before either goes further.
*/
export function overlapsForPrompt(ask: AskJson, s: SessionState): string | null {
const seen = new Set(s.injectedOverlaps ?? []);
// The overlap lines themselves; `ask`'s own instruction to the agent is restated below.
const fresh = (ask.overlaps ?? []).filter((o) => o.startsWith('●') && !seen.has(o));
if (!fresh.length) return null;
s.injectedOverlaps = [...(s.injectedOverlaps ?? []), ...fresh].slice(-INJECTED_POINTERS_CAP);
return (
`${tag()} a teammate's pushed branch changes the same code. Start your reply with ${fresh.length === 1 ? 'this line' : 'these lines'}, word for word, so the user knows before going further:\n` +
fresh.join('\n')
);
}

function tokensOf(chars: number): number { return Math.round(chars / 4); }

/** The retrieval pack body — pointers, snippets, and (in --source mode) the
Expand Down
47 changes: 41 additions & 6 deletions src/claude/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { execFileSync, spawn } from 'node:child_process';
import { join, basename, isAbsolute, resolve } from 'node:path';
import { homedir } from 'node:os';
import { readWiring } from './stats.js';
import { formatBlastRadius, relevantRetrieval, formatOrientation, notesForPrompt, overlapsForPrompt, standingDirective } from './format.js';
import { formatBlastRadius, relevantRetrieval, formatOrientation, notesForPrompt, standingDirective } from './format.js';
import { commitLines, standingLearnings } from '../notes/notes.js';
import { teammatesOn } from '../notes/git-facts.js';
import { noteAuthor, teammatesOn } from '../notes/git-facts.js';
import { indexFreshness, staleBanner } from '../context/check.js';
import { patchStats, readStats, acquireLock, readSession, writeSession, resolveContextDir } from './state.js';
import { graftCliPath, claudeScriptPath } from './paths.js';
Expand All @@ -21,7 +21,7 @@ import { fitTrailServerToMachine } from '../hosts/claude-global.js';
import { globalCopyCovers } from './hook-layers.js';
import { NOTE_CALL } from '../notes/session-cost.js';
import { keepsNotes, noteCount, repoPlace } from '../notes/home.js';
import { branchesStale, refreshBranches, SESSION_REFRESH_MS } from '../notes/branches.js';
import { branchesStale, overlapMark, readBranchCache, refreshBranches, SESSION_REFRESH_MS } from '../notes/branches.js';
import { learningFiles } from '../notes/repo-trail.js';
import { inWorkspace, maybeSyncInBackground } from '../cloud/workspace-sync.js';
import { cacheDir, readJson, writeJsonAtomic } from '../util/state.js';
Expand Down Expand Up @@ -200,8 +200,44 @@ async function handlePostEdit(input: any, dir: string): Promise<void> {
const file = editedFilePath(input, dir);
if (!file || underGraft(dir, file)) return;
patchStats(dir, { dirty: true, staleCount: checkStaleCount(dir), lastFile: basename(file) });
const out: string[] = [];
const w = readWiring(dir);
if (w) { const br = formatBlastRadius(w, file); if (br) emit('PostToolUse', br); }
if (w) { const br = formatBlastRadius(w, file); if (br) out.push(br); }
const overlap = await editOverlap(input, dir, file);
if (overlap) out.push(overlap);
// One hook, one answer: two JSON objects on stdout would be read as neither.
if (out.length) emit('PostToolUse', out.join('\n\n'));
}

/** Branch-and-function pairs remembered per session, so each is said once. */
const INJECTED_OVERLAPS_CAP = 40;

/**
* Under trail, a teammate's open branch that changes the function this edit
* just changed, for the agent to read and tell the person about, each branch
* and function once a session. Parsing loads grammars, so it's imported only
* when the edited file is one some branch changes. Never throws.
*/
async function editOverlap(input: any, dir: string, file: string): Promise<string | null> {
if (brand() !== 'trail' || !keepsNotes(dir)) return null;
try {
const top = repoPlace(dir);
if (!top.git) return null;
const { functionOverlaps, editOverlapDirective } = await import('../notes/edit-overlap.js');
const found = await functionOverlaps(top.checkout, resolve(dir, file), input?.tool_input, noteAuthor(top.checkout));
if (!found.length) return null;
const id = input?.session_id || 'default';
const s = readSession(dir, id);
const seen = new Set(s.injectedOverlaps ?? []);
const fresh = found.filter((o) => o.symbols.some((x) => !seen.has(`${o.ref} ${x.id}`)));
if (!fresh.length) return null;
s.injectedOverlaps = [...seen, ...fresh.flatMap((o) => o.symbols.map((x) => `${o.ref} ${x.id}`))].slice(-INJECTED_OVERLAPS_CAP);
writeSession(dir, id, s);
const base = readBranchCache(top.checkout)?.base ?? 'origin/main';
return fresh.slice(0, 2).map((o) => editOverlapDirective(o, base, tag(), overlapMark())).join('\n\n');
} catch {
return null;
}
}

/**
Expand Down Expand Up @@ -653,8 +689,7 @@ export async function main(event: string): Promise<void> {
if (agent) s.perAgentQuery[agent] = prompt;
// Learnings first: what an earlier session worked out matters more than where code is.
const notes = brand() === 'trail' ? notesForPrompt(ask, s) : null;
const overlaps = brand() === 'trail' ? overlapsForPrompt(ask, s) : null;
const txt = [notes, overlaps, relevantRetrieval(ask, s)].filter(Boolean).join('\n\n');
const txt = [notes, relevantRetrieval(ask, s)].filter(Boolean).join('\n\n');
if (txt) emit('UserPromptSubmit', txt);
writeSession(dir, id, s);
}
Expand Down
2 changes: 1 addition & 1 deletion src/claude/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export interface SessionState {
/** Notes already put in front of the agent this session by the per-prompt
* hook, by path, so each is shown once (see `notesForPrompt`). */
injectedNotes?: string[];
/** Overlap lines (teammates' pushed branches on the same code) already put in front of the agent this session. */
/** `<branch> <function id>` pairs: a teammate's open branch on a function the agent edited, already put in front of it this session. */
injectedOverlaps?: string[];
/** Weak-match nudges spent this session, capped so the line stays signal.
* Optional for the same backwards-compatibility reason as above. */
Expand Down
15 changes: 5 additions & 10 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,10 +338,10 @@ program
// Hidden: spawned detached by maybeRefreshBranches.
program
.command("_trail-branches", { hidden: true })
.description("internal: fetch, then note which files teammates' pushed branches change and the learnings they carry")
.description("internal: fetch, then note which files teammates' open branches change and the learnings they carry")
.argument("[dir]", "target repo directory", ".")
.action((dir: string) => {
refreshBranches(resolve(dir));
refreshBranches(resolve(dir), { prs: true });
});

program
Expand Down Expand Up @@ -723,23 +723,18 @@ function pointerFile(pointer: string): string {
}

/**
* After `ask`, under trail: which shown learnings are a teammate's, which
* teammates' pushed branches change the files the answer points into, and the
* After `ask`, under trail: which shown learnings are a teammate's, and the
* shown learnings recorded as a use, which is what the team page counts as
* reuse. Then the background upkeep that keeps branches and the workspace
* current. Spawns no git itself.
* current. Spawns no git itself. Teammates' branches come up after an edit
* instead, once there's a function to compare (notes/edit-overlap.ts).
*/
function afterAsk(r: AskResult, repo: string): void {
if (!r.notesChecked) return;
rememberRepo(repo);
maybeRefreshBranches(repo);
const me = noteAuthor(repo);
const files = [...new Set(r.hits.map((h) => pointerFile(h.pointer)))];
const overlaps = overlapsWith(repo, files, me).slice(0, 2);
if (overlaps.length) {
r.overlaps = overlaps.map((o) => formatOverlap(o));
if (!process.stdout.isTTY) r.overlaps.push(`${tag()} Start your reply with the overlap line${overlaps.length === 1 ? "" : "s"} above, word for word, so the user knows.`);
}
const shown = r.notes ?? [];
if (shown.length) {
for (const h of shown) h.note.teammate = !!h.note.author && !!me && h.note.author !== me;
Expand Down
Loading
Loading