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
2 changes: 1 addition & 1 deletion src/ask/ask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1201,7 +1201,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 \`${cmd("graft build")}\` if graft/ is empty${scopesHereClause(scopes ?? [])}`,
: `no matching nodes — try different words, or \`${cmd("graft build")}\` if the code map is empty${scopesHereClause(scopes ?? [])}`,
};
}

Expand Down
4 changes: 3 additions & 1 deletion src/brand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,11 @@ export function inBrand(text: string, b: Brand = brand()): string {
* teammate's agent speaks the same name whichever command they installed.
*/
export function repoWiredForTrail(dir: string): boolean {
if (existsSync(join(dir, ".trail", "hook.cjs"))) return true;
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");
const settings = readFileSync(join(dir, ".claude", "settings.json"), "utf8");
return settings.includes("trail-hooks.cjs") || settings.includes("/.trail/hook.cjs");
} catch {
return false;
}
Expand Down
4 changes: 2 additions & 2 deletions src/claude/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ export function relevantRetrieval(ask: AskJson, s: SessionState, cap = 3): strin
return txt;
}

export function formatOrientation(indexMd: string, budgetBytes = 1500, staleNote?: string, notes = 0, keepsNotes = notes > 0, skills = ''): string {
export function formatOrientation(indexMd: string, budgetBytes = 1500, staleNote?: string, notes = 0, keepsNotes = notes > 0, skills = '', mapDir = 'graft'): 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
Expand All @@ -265,7 +265,7 @@ export function formatOrientation(indexMd: string, budgetBytes = 1500, staleNote
teamNotesDirective(notes, keepsNotes) +
skills;
const banner = staleNote ? `${staleNote}\n\n` : "";
return `${banner}${directive}\nrepo map (graft/INDEX.md):\n${indexMd.slice(0, budgetBytes)}`;
return `${banner}${directive}\nrepo map (${mapDir}/INDEX.md):\n${indexMd.slice(0, budgetBytes)}`;
}

/**
Expand Down
8 changes: 5 additions & 3 deletions src/claude/hook-layers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,11 @@ const COMMAND = /^node\s+"([^"]+)"\s+(\S+)$/;
export function globalCopyCovers(event: string, shim: string | undefined, home: string = homedir()): boolean {
if (!shim) return false;
const at = resolve(dirname(shim));
// Only Claude Code's repo copy: Cursor's and Codex's shims call the same
// entry, and Claude's ~/.claude hooks never run for them.
if (basename(at) !== "helpers" || basename(dirname(at)) !== ".claude") return false;
// Only Claude Code's repo copy, in `.claude/helpers/` or trail's `.trail/`:
// Cursor's and Codex's shims call the same entry, and Claude's ~/.claude
// hooks never run for them.
const repoCopy = basename(at) === ".trail" || (basename(at) === "helpers" && basename(dirname(at)) === ".claude");
if (!repoCopy) return false;
const helpers = resolve(home, ".claude", "helpers");
if (at === helpers) return false; // this is the ~/.claude copy
let settings: { hooks?: Record<string, unknown> };
Expand Down
59 changes: 51 additions & 8 deletions src/claude/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { readFileSync, existsSync } from 'node:fs';
import { execFileSync, spawn } from 'node:child_process';
import { join, basename, isAbsolute, resolve } from 'node:path';
import { join, basename, isAbsolute, relative, resolve } from 'node:path';
import { homedir } from 'node:os';
import { readWiring } from './stats.js';
import { formatBlastRadius, relevantRetrieval, formatOrientation, notesForPrompt, standingDirective } from './format.js';
import { commitLines, standingLearnings } from '../notes/notes.js';
import { commitLines, setupCommitLine, standingLearnings } from '../notes/notes.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';
Expand All @@ -13,6 +13,7 @@ import { runUpkeep } from '../upkeep-run.js';
import { runningVersion, wiredHostIds } from '../upkeep.js';
import { hostsWiredForTrail } from '../hosts/registry.js';
import { flushClosedSessions, summarizeSession } from '../telemetry/sessions.js';
import { maybeFlushInBackground } from '../telemetry/flush.js';
import { hasSavingsTally, lastAssistantTurn, lastTurnBilling } from './tally.js';
import { scopeOf, scopesOfGraph } from '../graph/scopes.js';
import { classifyToolUse, isMcpToolName, isGraftMcpTool, parseSavings, recordToolUse, type ToolKind } from './session-metrics.js';
Expand Down Expand Up @@ -46,7 +47,8 @@ function projectDir(input: any): string {
}
export function underGraft(dir: string, file: string): boolean {
const rel = file.startsWith(dir) ? file.slice(dir.length) : file;
return rel.replace(/^[/\\]+/, '').replace(/\\/g, '/').startsWith('graft/');
const posix = rel.replace(/^[/\\]+/, '').replace(/\\/g, '/');
return posix.startsWith('graft/') || posix.startsWith('.trail/cache/');
}
/** Default budget for a graft child process invoked from a hook, matching the 8s
* the installed hook entries carry. */
Expand Down Expand Up @@ -570,10 +572,15 @@ export function uncommittedDirective(dir: string, now = Date.now()): string {
cwd: place.checkout, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000,
});
const n = status.split('\n').filter((l) => l.trimEnd().endsWith('.md')).length;
if (!n) return '';
const setup = setupUncommitted(place.checkout);
if (!n && !setup) return '';
const mark = join(cacheDir(place.checkout), 'trail-uncommitted-said.json');
if (now - (readJson<{ at?: number }>(mark)?.at ?? 0) < DAY_MS) return '';
writeJsonAtomic(mark, { at: now }, true);
if (setup) {
const line = setupCommitLine(teammatesOn(place.checkout));
return `${tag()} trail's shared setup here (.trail/ and .claude/settings.json) isn't committed yet, so teammates' Claude can't read the learnings. Say so once, at the end of your first reply, with this line, word for word: ${line}\n`;
}
const line = commitLines({}, teammatesOn(place.checkout), 'repo').uncommitted;
const them = n === 1 ? "learning in .trail/learnings/ isn't committed yet, so no teammate's agent has it" : `learnings in .trail/learnings/ aren't committed yet, so no teammate's agent has them`;
return `${tag()} ${n} team ${them}. Say so once, at the end of your first reply, with this line, word for word: ${line}\n`;
Expand All @@ -582,6 +589,26 @@ export function uncommittedDirective(dir: string, now = Date.now()): string {
}
}

/**
* trail's shared setup isn't in the last commit: `.trail/hook.cjs` isn't
* tracked, or HEAD's `.claude/settings.json` doesn't run it. Only checked
* where the hook is on disk, so a repo still set up the old way says nothing.
*/
export function setupUncommitted(checkout: string): boolean {
if (!existsSync(join(checkout, '.trail', 'hook.cjs'))) return false;
const git = (args: string[]) => execFileSync('git', args, { cwd: checkout, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000 });
try {
git(['ls-files', '--error-unmatch', '--', '.trail/hook.cjs']);
} catch {
return true;
}
try {
return !git(['show', 'HEAD:.claude/settings.json']).includes('/.trail/hook.cjs');
} catch {
return true;
}
}

/** The most the session-start hook waits on `git fetch` for teammates' branches. */
export const BRANCH_FETCH_CAP_MS = 3000;

Expand All @@ -607,6 +634,15 @@ function trailAtStart(dir: string): void {
trackLearningsCommitted(dir);
}

/** The repo's committed `.mcp.json` still registers trail's server (set up before 0.23). */
function repoNamesTrailServer(dir: string): boolean {
try {
return Boolean(JSON.parse(readFileSync(join(dir, '.mcp.json'), 'utf8'))?.mcpServers?.trail);
} catch {
return false;
}
}

export async function main(event: string): Promise<void> {
// trail's own headless Claude calls (the first-run read of past sessions) run with this set.
if (process.env.TRAIL_NO_HOOKS === '1') return;
Expand All @@ -626,20 +662,24 @@ export async function main(event: string): Promise<void> {
// tells the person how to get it.
const handover = wiredHostIds(dir).includes('claude') ? trailHandoverLine() : null;
if (handover) upkeep.push(handover);
// Roll up any session that ended since we were last here. Queue-only — the
// hook still sends nothing; the CLI or the MCP server sends it later.
// Roll up any session that ended since we were last here, then send the
// queue from a detached child: what session end didn't get to (a killed
// terminal never fires it) goes now. Once a day at most, never waited on.
flushClosedSessions(dir);
maybeFlushInBackground();
trailAtStart(dir);
// A repo on trail, opened on a machine that has graft but not the trail command.
if (repoWiredForTrail(dir)) fitTrailServerToMachine(homedir(), resolve(dir), { trail: trailOnPath(), graft: commandOnPath('graft') });
// Only while its .mcp.json still names a trail server: once it doesn't, trail: true takes back the stand-in.
if (repoWiredForTrail(dir)) fitTrailServerToMachine(homedir(), resolve(dir), { trail: trailOnPath() || !repoNamesTrailServer(dir), graft: commandOnPath('graft') });
try {
const idx = readFileSync(join(resolveContextDir(dir), 'INDEX.md'), 'utf8');
const banner = staleBanner(indexFreshness(dir)) ?? undefined;
const kept = keepsNotes(dir);
const skills = kept ? skillsDirective(dir, cmd('graft skills show')) : '';
const underTrail = kept && brand() === 'trail';
const standing = underTrail ? standingDirective(standingLearnings(dir)) + uncommittedDirective(dir) : '';
const orientation = formatOrientation(idx, undefined, banner, noteCount(dir), kept, skills + standing);
const mapDir = relative(dir, resolveContextDir(dir)).split('\\').join('/') || 'graft';
const orientation = formatOrientation(idx, undefined, banner, noteCount(dir), kept, skills + standing, mapDir);
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.
Expand All @@ -648,6 +688,9 @@ export async function main(event: string): Promise<void> {
return;
}

// Claude closing: send the queue from a detached child, so quitting isn't slowed.
if (event === 'session-end') { maybeFlushInBackground(); return; }

if (event === 'post-edit') { await handlePostEdit(input, dir); return; }

if (event === 'tool-savings') { handleToolUse(input, dir); return; }
Expand Down
Loading
Loading