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
2 changes: 2 additions & 0 deletions TELEMETRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ The events:
| `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 |
| `takeaway_saved` | `new_skill` (`true`/`false`) | `trail learn` saves a correction as a takeaway on a skill, on this machine (`~/.trail/`). Its text is never sent |
| `skills_folded` | `takeaways_bucket` (how many takeaways were folded, bucketed) | `trail skills fold` writes takeaways 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 |

Two rules govern every value above, and both are enforced in code rather than by
Expand Down
8 changes: 5 additions & 3 deletions src/claude/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,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): string {
export function formatOrientation(indexMd: string, budgetBytes = 1500, staleNote?: string, notes = 0, keepsNotes = notes > 0, skills = ''): 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 @@ -231,7 +231,8 @@ export function formatOrientation(indexMd: string, budgetBytes = 1500, staleNote
` Already know the file or symbol to change? Go straight to it: ${cmd('graft grep')} "<symbol>", 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')} <sym> --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);
teamNotesDirective(notes, keepsNotes) +
skills;
const banner = staleNote ? `${staleNote}\n\n` : "";
return `${banner}${directive}\nrepo map (graft/INDEX.md):\n${indexMd.slice(0, budgetBytes)}`;
}
Expand All @@ -251,7 +252,8 @@ export function teamNotesDirective(notes: number, kept = notes > 0): string {
: '';
return (
read +
`When you finish a task that took real digging, leave a note for the next session: \`${cmd('graft note')} --title "<what it was about>"\` 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`
`When you finish a task that took real digging, leave a note for the next session: \`${cmd('graft note')} --title "<what it was about>"\` 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` +
`When the user corrects how something is done here, or states a rule for it, save it so your agent learns it: \`${cmd('graft learn')} <skill>\` with the rule and why on stdin, using a skill that fits (\`${cmd('graft skills')}\` lists them) or a short new name.\n`
);
}

Expand Down
7 changes: 5 additions & 2 deletions src/claude/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ 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 { adoptRepoBrand, tag } from '../brand.js';
import { adoptRepoBrand, cmd, tag } from '../brand.js';
import { keepsNotes, noteCount } from '../notes/home.js';
import { skillsDirective } from '../skills/skills.js';

/** Prompts shorter than this never trigger retrieval — they are almost always
* conversational ("yes go ahead", "thanks") and the coverage gate can't judge
Expand Down Expand Up @@ -483,7 +484,9 @@ export async function main(event: string): Promise<void> {
try {
const idx = readFileSync(join(resolveContextDir(dir), 'INDEX.md'), 'utf8');
const banner = staleBanner(indexFreshness(dir)) ?? undefined;
const orientation = formatOrientation(idx, undefined, banner, noteCount(dir), keepsNotes(dir));
const kept = keepsNotes(dir);
const skills = kept ? skillsDirective(dir, cmd('graft skills show')) : '';
const orientation = formatOrientation(idx, undefined, banner, noteCount(dir), kept, skills);
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 Down
20 changes: 18 additions & 2 deletions src/claude/skill-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,21 @@ export function skillTemplate(brand: Brand = "graft"): string {
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.`;
notes past sessions left on what was decided and ruled out. When the user
corrects how something is done here, or states a rule for it, save it with
trail learn. 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 "<task>" --source\`. Notes from
past sessions on what was decided, tried and ruled out print above the code.
Read them first.
- **When the user corrects how something is done here**, or states a rule for
it, save it with \`trail learn <skill>\` so your agent follows it from then
on. Before working in a skill's area, \`trail skills show <skill>\` prints it
with the corrections not yet folded in. How is under Skills and corrections
below.
- **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.

Expand Down Expand Up @@ -68,6 +75,15 @@ 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\`.

## Skills and corrections
When the user corrects how something is done here, or states a rule for
it, save it so your agent learns it: \`trail learn <skill>\` with the rule
and why on stdin, using a skill that fits (\`trail skills\` lists them) or a
short new name (add \`--description "<what it covers>"\` for a new one).
Corrections stay on this machine. Before working in a skill's area, run
\`trail skills show <skill>\`: it prints the skill with every correction not
yet folded into its SKILL.md.

If \`trail\` isn't installed on this machine but \`graft\` is, it's the same
command under its old name.
`;
Expand Down
149 changes: 147 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import "dotenv/config";
import { Command } from "commander";
import { existsSync, readdirSync, statSync } from "node:fs";
import { basename, join, relative, resolve } from "node:path";
import { basename, dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { Graft } from "./engine.js";
import { resolveConfig, type EngineConfig } from "./ai/providers.js";
Expand Down Expand Up @@ -94,6 +94,7 @@ 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 { confirm, fold, getSkill, learn, listSkills, publish, skillForAgent, skillName, skillStatus, type LearnResult } from "./skills/skills.js";
import { setInputRate } from "./context/savings.js";
import { formatUpdateNudge, maybeRefreshInBackground, readStamp, readUpdateCache, refreshUpdateCache, wiredHostIds, writeStamp } from "./upkeep.js";
import {
Expand Down Expand Up @@ -1075,6 +1076,148 @@ grouped(program.command("note", { hidden: !TRAIL }), GROUP.memory)
await runNoteCommand(resolve(dir), opts);
});

// `trail learn` and `trail skills`: corrections become takeaways on a skill,
// kept on this machine, and takeaways fold into its SKILL.md. Unlisted under
// graft, like `note`, but they run.
grouped(program.command("learn", { hidden: !TRAIL }), GROUP.memory)
.description("Turn a correction into a takeaway on a skill, kept in ~/.trail (the takeaway on stdin)")
.argument("<skill>", "the skill it belongs to, e.g. pdf-coords; a new name makes a new skill")
.argument("[dir]", "repository root", ".")
.option("-m, --text <text>", "the takeaway itself, instead of stdin")
.option("--description <text>", "for a new skill: what it covers")
.option("--section <heading>", "the SKILL.md heading it belongs under when folded")
.option("--author <name>", "who taught it (default: the first word of git config user.name)")
.action(async (skill: string, dir: string, opts: { text?: string; description?: string; section?: string; author?: string }) => {
const repo = resolve(dir);
const text = (opts.text ?? (await readStdin())).trim();
if (!text) {
console.error(`✗ a takeaway needs its text, on stdin or with -m:`);
console.error(` ${cmd("graft learn")} ${skill} <<'EOF'\n Read rotation with page.Rotation(), never pdfcpu: it drops /Rotate on linearized files.\n EOF`);
process.exitCode = 1;
return;
}
const author = opts.author?.trim() || noteAuthor(repo);
let r: LearnResult;
try {
r = learn(repo, { skill, text, author, date: new Date().toISOString().slice(0, 10), description: opts.description, section: opts.section });
} catch (err) {
console.error(`✗ ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
return;
}
track("takeaway_saved", { new_skill: String(r.created) }, { repo });
const kind = r.created ? "new skill" : r.skill.where === "repo" ? "the repo's skill" : "skill";
console.log(`✓ takeaway saved to ${kind} ${r.skill.name}${author ? ` · taught by ${author}` : ""}`);
console.log(`· your agent follows it from now on · kept on this machine, in ${shownPath(dirname(dirname(r.takeaway.path)))}`);
console.log(`· ${cmd("graft skills")} fold ${r.skill.name} writes it into SKILL.md`);
});

const skillsCmd = grouped(program.command("skills", { hidden: !TRAIL }), GROUP.memory)
.description("List this repo's skills, and the takeaways waiting to fold into each")
.argument("[dir]", "repository root", ".")
.option("--json", "output as JSON")
.action((dir: string, opts: { json?: boolean }) => {
const repo = resolve(dir);
const all = listSkills(repo);
if (opts.json) {
console.log(JSON.stringify(all, null, 2));
return;
}
if (all.length === 0) {
console.log(`no skills yet · ${cmd("graft learn")} <skill> saves the first takeaway and makes the skill`);
return;
}
const width = Math.max(...all.map((s) => s.name.length)) + 2;
for (const s of all) console.log(`${s.name.padEnd(width)}${skillStatus(s)}`);
});

/** `✗ no skill called x` and the exit code, for a name that isn't one. */
function noSuchSkill(name: string): void {
console.error(`✗ no skill called ${skillName(name)} · ${cmd("graft skills")} lists them`);
process.exitCode = 1;
}

skillsCmd
.command("show")
.description("Print a skill with every takeaway not yet folded in: what an agent reads before working in its area")
.argument("<skill>", "the skill")
.option("-C, --dir <path>", "repository root", ".")
.action((skill: string, opts: { dir: string }) => {
const s = getSkill(resolve(opts.dir), skill);
if (!s) return noSuchSkill(skill);
process.stdout.write(skillForAgent(s));
});

skillsCmd
.command("fold")
.description("Write a skill's waiting takeaways into its SKILL.md and bump its version")
.argument("[skill]", "one skill; leave out to fold every skill with takeaways waiting")
.option("--confirmed", "only the takeaways someone other than their teacher confirmed")
.option("-C, --dir <path>", "repository root", ".")
.action((skill: string | undefined, opts: { confirmed?: boolean; dir: string }) => {
const repo = resolve(opts.dir);
if (skill && !getSkill(repo, skill)) return noSuchSkill(skill);
const names = skill ? [skill] : listSkills(repo).map((s) => s.name);
let inRepo = false;
let any = false;
for (const n of names) {
const r = fold(repo, n, { confirmed: opts.confirmed });
if (!r) continue;
if (r.folded.length === 0) {
if (skill) console.log(`· ${r.skill} v${r.from} · nothing to fold${r.stillWaiting ? `, ${r.stillWaiting} waiting for a second person` : ""}`);
continue;
}
any = true;
inRepo ||= r.where === "repo";
track("skills_folded", { takeaways_bucket: countBucket(r.folded.length) }, { repo });
console.log(`✓ ${r.skill} v${r.from} → v${r.to} · folded ${r.folded.length} takeaway${r.folded.length === 1 ? "" : "s"}${r.stillWaiting ? `, ${r.stillWaiting} still waiting` : ""}`);
console.log(` M ${r.where === "repo" ? shown(repo, r.file) : shownPath(r.file)} ${r.sections.map((h) => `+ ${h}`).join(" ")}`);
}
if (!any) {
if (!skill) console.log("· nothing to fold");
return;
}
if (inRepo) console.log("· review with git diff, then commit");
else console.log(`· ${cmd("graft skills")} publish <skill> moves a skill into the repo's .claude/skills/, to commit for your team`);
});

skillsCmd
.command("publish")
.description("Move a skill kept on this machine into the repo's .claude/skills/, ready to commit")
.argument("<skill>", "the skill")
.option("-C, --dir <path>", "repository root", ".")
.action((skill: string, opts: { dir: string }) => {
const repo = resolve(opts.dir);
const r = publish(repo, skill);
if (!r.ok) {
if (r.reason === "unknown") return noSuchSkill(skill);
console.log(`· ${skillName(skill)} is already in the repo, in .claude/skills/${skillName(skill)}/`);
return;
}
console.log(`✓ ${skillName(skill)} → ${shown(repo, r.file)}`);
console.log("· commit it to share it: Claude Code loads it for everyone who pulls. its takeaways stay on this machine");
});

skillsCmd
.command("confirm")
.description("Say a teammate's takeaway is right, so `fold --confirmed` takes it")
.argument("<skill>", "the skill")
.argument("[takeaway]", "one takeaway file (default: every one waiting that someone else taught)")
.option("--author <name>", "who is confirming (default: the first word of git config user.name)")
.option("-C, --dir <path>", "repository root", ".")
.action((skill: string, takeaway: string | undefined, opts: { author?: string; dir: string }) => {
const repo = resolve(opts.dir);
if (!getSkill(repo, skill)) return noSuchSkill(skill);
const who = opts.author?.trim() || noteAuthor(repo);
const done = confirm(repo, skill, who, takeaway);
if (done.length === 0) {
console.log(`· nothing to confirm on ${skillName(skill)} — a takeaway needs someone other than its teacher`);
return;
}
for (const t of done) console.log(`✓ confirmed ${t.taughtBy || "a"}'s takeaway from ${shortDate(t.date)} · ${shownPath(t.path)}`);
console.log(`· ${cmd("graft skills")} fold ${skillName(skill)} writes it into SKILL.md`);
});

grouped(program.command("init"), GROUP.setup)
.description(
TRAIL
Expand Down Expand Up @@ -2279,7 +2422,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", "note", "init", "build", "status", "upgrade", "uninstall", "telemetry", "login", "push", "pull", "logout", "blast"];
const order = ["ask", "grep", "skeleton", "callers", "map", "note", "learn", "skills", "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;
Expand All @@ -2294,6 +2437,8 @@ if (TRAIL) {
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",
learn: "turn a correction into a takeaway on a skill",
skills: "list skills, show one, fold takeaways in, publish to the repo",
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",
Expand Down
Loading