diff --git a/TELEMETRY.md b/TELEMETRY.md index b178f80e..ac13426e 100644 --- a/TELEMETRY.md +++ b/TELEMETRY.md @@ -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 diff --git a/src/claude/format.ts b/src/claude/format.ts index 5706c252..f847fbd4 100644 --- a/src/claude/format.ts +++ b/src/claude/format.ts @@ -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 @@ -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')} "", 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')} --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)}`; } @@ -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 ""\` 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 ""\` 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')} \` with the rule and why on stdin, using a skill that fits (\`${cmd('graft skills')}\` lists them) or a short new name.\n` ); } diff --git a/src/claude/hooks.ts b/src/claude/hooks.ts index e363be37..e4b5897b 100644 --- a/src/claude/hooks.ts +++ b/src/claude/hooks.ts @@ -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 @@ -483,7 +484,9 @@ export async function main(event: string): Promise { 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. diff --git a/src/claude/skill-template.ts b/src/claude/skill-template.ts index 8efd3423..f65abc07 100644 --- a/src/claude/skill-template.ts +++ b/src/claude/skill-template.ts @@ -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 "" --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 \` so your agent follows it from then + on. Before working in a skill's area, \`trail skills show \` 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. @@ -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 \` with the rule +and why on stdin, using a skill that fits (\`trail skills\` lists them) or a +short new name (add \`--description ""\` for a new one). +Corrections stay on this machine. Before working in a skill's area, run +\`trail skills show \`: 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. `; diff --git a/src/cli.ts b/src/cli.ts index edad325c..8eec46f4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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"; @@ -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 { @@ -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("", "the skill it belongs to, e.g. pdf-coords; a new name makes a new skill") + .argument("[dir]", "repository root", ".") + .option("-m, --text ", "the takeaway itself, instead of stdin") + .option("--description ", "for a new skill: what it covers") + .option("--section ", "the SKILL.md heading it belongs under when folded") + .option("--author ", "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")} 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("", "the skill") + .option("-C, --dir ", "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 ", "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 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("", "the skill") + .option("-C, --dir ", "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("", "the skill") + .argument("[takeaway]", "one takeaway file (default: every one waiting that someone else taught)") + .option("--author ", "who is confirming (default: the first word of git config user.name)") + .option("-C, --dir ", "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 @@ -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; @@ -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", diff --git a/src/skills/skills.ts b/src/skills/skills.ts new file mode 100644 index 00000000..1a10cf36 --- /dev/null +++ b/src/skills/skills.ts @@ -0,0 +1,418 @@ +/** + * Skills: how-tos an agent follows, and the corrections that improve them. + * + * A correction is a takeaway, one file each, kept on this machine in + * `~/.trail/repos//skills//takeaways/`. It's live the moment it's + * saved: the agent reads a skill's waiting takeaways along with its SKILL.md. + * `trail skills fold` writes them into SKILL.md and bumps its version, so the + * skill stays one readable page instead of a pile of corrections. + * + * The SKILL.md lives in one of two places. A skill the repo already has, in + * `.claude/skills//`, is the team's: takeaways attach to it and fold + * into it, and the diff goes through review like any change. A skill started + * by `trail learn` lives next to its takeaways in `~/.trail` until someone + * chooses to share it: `trail skills publish` moves it into the repo's + * `.claude/skills/`, where Claude Code loads it for everyone who pulls. + * + * Takeaways are never deleted: a folded one keeps its file, with `folded_in` + * saying which version took it, so why a rule exists stays findable. + */ +import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import matter from "gray-matter"; +import { shortDate, slug } from "../notes/notes.js"; +import { ensureRepoHome, repoPlace } from "../notes/home.js"; + +/** Trail's and graft's own skills, rewritten by every `init`: never a place for takeaways. */ +const OWN_SKILLS = new Set(["trail", "graft"]); + +/** Where a repo's skills are: the team's in the repo, and this machine's in ~/.trail. */ +export function skillDirs(dir: string): { repo: string; home: string } { + const place = repoPlace(dir); + return { repo: join(place.checkout, ".claude", "skills"), home: join(place.dir, "skills") }; +} + +export interface Takeaway { + /** Absolute path of its file. */ + path: string; + skill: string; + taughtBy: string; + date: string; + /** Where it came from: `correction` when a person corrected an agent. */ + from: string; + confirmedBy: string[]; + /** The SKILL.md version it was folded into, when it has been. */ + foldedIn?: number; + /** The SKILL.md heading it belongs under, when the teacher named one. */ + section?: string; + text: string; +} + +export interface Skill { + name: string; + description: string; + version: number; + /** SKILL.md without its frontmatter. */ + body: string; + /** `repo`: the team's, in `.claude/skills/`. `home`: only on this machine, in ~/.trail. */ + where: "repo" | "home"; + /** Absolute path of its SKILL.md. */ + file: string; + takeaways: Takeaway[]; +} + +/** A skill name as a folder: `PDF coords` → `pdf-coords`. */ +export function skillName(raw: string): string { + return slug(raw, 8); +} + +function yamlScalar(s: string): string { + return /^[A-Za-z0-9][^:#\n"'{}[\],&*!|>%@`]*$/.test(s) && s.trim() === s ? s : JSON.stringify(s); +} + +function asDate(v: unknown): string { + return v instanceof Date ? v.toISOString().slice(0, 10) : String(v ?? ""); +} + +/* -------------------------------------------------------------------------- */ +/* reading */ +/* -------------------------------------------------------------------------- */ + +export function parseTakeaway(text: string, path: string, skill: string): Takeaway | null { + let parsed: matter.GrayMatterFile; + try { + parsed = matter(text); + } catch { + return null; + } + const d = parsed.data as Record; + const body = parsed.content.trim(); + if (!body) return null; + return { + path, + skill: typeof d.skill === "string" ? d.skill : skill, + taughtBy: typeof d.taught_by === "string" ? d.taught_by : "", + date: asDate(d.date), + from: typeof d.from === "string" ? d.from : "correction", + confirmedBy: Array.isArray(d.confirmed_by) ? d.confirmed_by.map(String) : [], + foldedIn: typeof d.folded_in === "number" ? d.folded_in : undefined, + section: typeof d.section === "string" ? d.section : undefined, + text: body, + }; +} + +export function renderTakeaway(t: Omit): string { + const fm = ["---", `skill: ${t.skill}`, `taught_by: ${yamlScalar(t.taughtBy)}`, `date: ${t.date}`, `from: ${t.from}`]; + fm.push(`confirmed_by: [${t.confirmedBy.map(yamlScalar).join(", ")}]`); + if (t.section) fm.push(`section: ${yamlScalar(t.section)}`); + if (t.foldedIn !== undefined) fm.push(`folded_in: ${t.foldedIn}`); + fm.push("---"); + return `${fm.join("\n")}\n${t.text.trim()}\n`; +} + +function readTakeaways(homeSkills: string, name: string): Takeaway[] { + const tdir = join(homeSkills, name, "takeaways"); + let files: string[] = []; + try { + files = readdirSync(tdir).filter((f) => f.endsWith(".md")).sort(); + } catch { + return []; + } + const out: Takeaway[] = []; + for (const f of files) { + try { + const t = parseTakeaway(readFileSync(join(tdir, f), "utf8"), join(tdir, f), name); + if (t) out.push(t); + } catch { + /* unreadable */ + } + } + return out; +} + +/** The skill's SKILL.md: the repo's when it has one, else this machine's. */ +function skillFile(dirs: { repo: string; home: string }, name: string): { file: string; where: Skill["where"] } | null { + const repo = join(dirs.repo, name, "SKILL.md"); + if (existsSync(repo)) return { file: repo, where: "repo" }; + const home = join(dirs.home, name, "SKILL.md"); + if (existsSync(home)) return { file: home, where: "home" }; + return null; +} + +function readSkill(dirs: { repo: string; home: string }, name: string): Skill | null { + if (OWN_SKILLS.has(name)) return null; + const at = skillFile(dirs, name); + if (!at) return null; + let parsed: matter.GrayMatterFile; + try { + parsed = matter(readFileSync(at.file, "utf8")); + } catch { + return null; + } + const d = parsed.data as Record; + return { + name, + description: typeof d.description === "string" ? d.description.trim() : "", + version: typeof d.version === "number" ? d.version : 1, + body: parsed.content.trim(), + where: at.where, + file: at.file, + takeaways: readTakeaways(dirs.home, name), + }; +} + +function subdirs(dir: string): string[] { + try { + return readdirSync(dir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name); + } catch { + return []; + } +} + +/** Every skill for the repo `dir` is in: the team's in the repo, then this machine's, by name. */ +export function listSkills(dir: string): Skill[] { + const dirs = skillDirs(dir); + const names = [...new Set([...subdirs(dirs.repo), ...subdirs(dirs.home)])].sort(); + return names.map((n) => readSkill(dirs, n)).filter((s): s is Skill => s !== null); +} + +export function getSkill(dir: string, name: string): Skill | null { + return readSkill(skillDirs(dir), skillName(name)); +} + +/** Takeaways not yet in SKILL.md. */ +export function waiting(s: Skill): Takeaway[] { + return s.takeaways.filter((t) => t.foldedIn === undefined); +} + +/** Confirmed means someone other than the teacher said it's right. */ +export function isConfirmed(t: Takeaway): boolean { + return t.confirmedBy.some((c) => c.toLowerCase() !== t.taughtBy.toLowerCase()); +} + +/* -------------------------------------------------------------------------- */ +/* writing */ +/* -------------------------------------------------------------------------- */ + +function renderNewSkill(name: string, description: string): string { + const fm = ["---", `name: ${name}`, `description: ${yamlScalar(description)}`, "version: 1", "---"]; + return `${fm.join("\n")}\n\n# ${name}\n\n${description}.\n`; +} + +/** + * SKILL.md at a new version with a new body. The frontmatter is edited as + * text, only its `version:` line, so a team's own skill keeps every other key + * (`allowed-tools`, `license`, …) exactly as they wrote it. + */ +function rewriteSkill(file: string, version: number, body: string): void { + const lines = readFileSync(file, "utf8").split(/\r?\n/); + const close = lines[0] === "---" ? lines.indexOf("---", 1) : -1; + const fm = close === -1 ? [] : lines.slice(1, close); + const at = fm.findIndex((l) => l.startsWith("version:")); + if (at === -1) fm.push(`version: ${version}`); + else fm[at] = `version: ${version}`; + writeFileSync(file, `---\n${fm.join("\n")}\n---\n\n${body.trim()}\n`); +} + +export interface LearnInput { + skill: string; + text: string; + author: string; + date: string; + /** For a new skill: what it covers. Ignored for an existing one. */ + description?: string; + section?: string; +} + +export interface LearnResult { + skill: Skill; + takeaway: Takeaway; + created: boolean; +} + +/** + * Save one takeaway, on this machine. A skill the repo has gets it as is; a + * name nobody has used starts a skill in ~/.trail, whose SKILL.md is empty + * but for its description: the takeaways are its content until the first fold. + */ +export function learn(dir: string, input: LearnInput): LearnResult { + const name = skillName(input.skill); + if (!name) throw new Error("a skill needs a name, like pdf-coords"); + if (OWN_SKILLS.has(name)) throw new Error(`${name} is the code map's own skill, rewritten by every init — pick a name for what you're teaching, like pdf-coords`); + ensureRepoHome(dir); + const dirs = skillDirs(dir); + let created = false; + if (!skillFile(dirs, name)) { + created = true; + mkdirSync(join(dirs.home, name), { recursive: true }); + const description = input.description?.trim() || `What has been learned about ${name.replace(/-/g, " ")} in this repo`; + writeFileSync(join(dirs.home, name, "SKILL.md"), renderNewSkill(name, description)); + } + const tdir = join(dirs.home, name, "takeaways"); + mkdirSync(tdir, { recursive: true }); + const base = `${input.date}-${slug(input.author, 1) || "someone"}`; + let n = 1; + while (existsSync(join(tdir, `${base}-${n}.md`))) n++; + const file = join(tdir, `${base}-${n}.md`); + const t: Omit = { + skill: name, + taughtBy: input.author, + date: input.date, + from: "correction", + confirmedBy: [], + section: input.section?.trim() || undefined, + text: input.text, + }; + writeFileSync(file, renderTakeaway(t)); + return { skill: readSkill(dirs, name)!, takeaway: { ...t, path: file }, created }; +} + +/** + * Record that `who` checked these takeaways and they're right. The teacher + * confirming their own takeaway doesn't count: confirmation is a second pair + * of eyes. Returns the takeaways that became confirmed. + */ +export function confirm(dir: string, name: string, who: string, only?: string): Takeaway[] { + const s = getSkill(dir, name); + if (!s) return []; + const out: Takeaway[] = []; + for (const t of waiting(s)) { + if (only && !t.path.endsWith(only) && !t.path.endsWith(`${only}.md`)) continue; + if (t.taughtBy.toLowerCase() === who.toLowerCase()) continue; + if (t.confirmedBy.some((c) => c.toLowerCase() === who.toLowerCase())) continue; + t.confirmedBy.push(who); + writeFileSync(t.path, renderTakeaway(t)); + out.push(t); + } + return out; +} + +/** A takeaway as one line in SKILL.md: its text, then who taught it and when. */ +function foldedLine(t: Takeaway): string { + const text = t.text.replace(/\s*\n\s*/g, " ").trim(); + const who = [t.taughtBy, shortDate(t.date)].filter(Boolean).join(", "); + return `- ${text}${who ? ` (${who})` : ""}`; +} + +export interface FoldResult { + skill: string; + where: Skill["where"]; + file: string; + from: number; + to: number; + folded: Takeaway[]; + stillWaiting: number; + /** The SKILL.md headings that gained lines. */ + sections: string[]; +} + +/** + * Write a skill's waiting takeaways into its SKILL.md, under the heading each + * names (made when missing) or under "Takeaways", and bump the version. + * `confirmed` folds only the ones a second person confirmed. Returns null for + * an unknown skill; `folded` is empty when nothing was ready. + */ +export function fold(dir: string, name: string, opts: { confirmed?: boolean } = {}): FoldResult | null { + const s = getSkill(dir, name); + if (!s) return null; + const ready = waiting(s).filter((t) => !opts.confirmed || isConfirmed(t)); + const still = waiting(s).length - ready.length; + const base = { skill: s.name, where: s.where, file: s.file, from: s.version, stillWaiting: still }; + if (ready.length === 0) return { ...base, to: s.version, folded: [], sections: [] }; + + const lines = s.body.split(/\r?\n/); + const sections: string[] = []; + for (const t of ready) { + const heading = t.section?.trim() || "Takeaways"; + let at = lines.findIndex((l) => l.trim().toLowerCase() === `## ${heading.toLowerCase()}`); + if (at === -1) { + while (lines.length && !lines[lines.length - 1]!.trim()) lines.pop(); + lines.push("", `## ${heading}`); + at = lines.length - 1; + } + // After the section's last non-blank line, before the next heading. + let end = at + 1; + while (end < lines.length && !lines[end]!.startsWith("## ")) end++; + let insert = end; + while (insert > at + 1 && !lines[insert - 1]!.trim()) insert--; + lines.splice(insert, 0, foldedLine(t)); + if (!sections.includes(heading)) sections.push(heading); + } + const to = s.version + 1; + rewriteSkill(s.file, to, lines.join("\n")); + for (const t of ready) { + t.foldedIn = to; + writeFileSync(t.path, renderTakeaway(t)); + } + return { ...base, to, folded: ready, sections }; +} + +export type PublishResult = { ok: true; file: string } | { ok: false; reason: "unknown" | "in-repo" }; + +/** + * Move a skill kept on this machine into the repo's `.claude/skills/`, where + * it can be committed and Claude Code loads it for everyone who pulls. Its + * takeaways stay on this machine, and fold into the repo's copy from now on. + */ +export function publish(dir: string, name: string): PublishResult { + const s = getSkill(dir, name); + if (!s) return { ok: false, reason: "unknown" }; + if (s.where === "repo") return { ok: false, reason: "in-repo" }; + const target = join(skillDirs(dir).repo, s.name, "SKILL.md"); + mkdirSync(join(target, ".."), { recursive: true }); + try { + renameSync(s.file, target); + } catch { + // Across filesystems: copy, then remove. + writeFileSync(target, readFileSync(s.file)); + rmSync(s.file, { force: true }); + } + return { ok: true, file: target }; +} + +/** `v3 · 2 takeaways waiting to fold (1 confirmed) · last taught by Sam, Oct 7`. */ +export function skillStatus(s: Skill): string { + const open = waiting(s); + const confirmed = open.filter(isConfirmed).length; + const parts = [`v${s.version}`]; + if (open.length === 0) parts.push("up to date"); + else parts.push(`${open.length} takeaway${open.length === 1 ? "" : "s"} waiting to fold${confirmed ? ` (${confirmed} confirmed)` : ""}`); + const last = [...s.takeaways].sort((a, b) => b.date.localeCompare(a.date) || b.path.localeCompare(a.path))[0]; + if (last && open.length) parts.push(`last taught by ${last.taughtBy || "someone"}, ${shortDate(last.date)}`); + parts.push(s.where === "repo" ? "in the repo" : "on this machine"); + return parts.join(" · "); +} + +/** + * What an agent reads before working in a skill's area: its SKILL.md, then + * every takeaway not yet folded in, newest first, which win where the two + * disagree. `trail skills show` prints it. + */ +export function skillForAgent(s: Skill): string { + const open = waiting(s).sort((a, b) => b.date.localeCompare(a.date) || b.path.localeCompare(a.path)); + const out = [`# ${s.name} (v${s.version}, ${s.where === "repo" ? "the team's" : "kept on this machine"})`, "", s.body.trim()]; + if (open.length) { + out.push("", `## Corrections since v${s.version} (newest first; these win where they disagree with the above)`); + for (const t of open) out.push(`- ${t.text.replace(/\s*\n\s*/g, " ").trim()} (${[t.taughtBy, shortDate(t.date)].filter(Boolean).join(", ")})`); + } + return `${out.join("\n")}\n`; +} + +/** + * The line a session starts with about skills an agent can't see by itself: + * ones kept only on this machine (Claude Code loads `.claude/skills/`, not + * ~/.trail), and the team's ones with corrections waiting. Empty when there + * are none, so the directive is unchanged everywhere else. + */ +export function skillsDirective(dir: string, show: string): string { + const parts: string[] = []; + for (const s of listSkills(dir)) { + const open = waiting(s).length; + if (s.where === "home") parts.push(`${s.name} (on this machine${open ? `, ${open} correction${open === 1 ? "" : "s"}` : ""})`); + else if (open) parts.push(`${s.name} (${open} correction${open === 1 ? "" : "s"} newer than its SKILL.md)`); + } + if (parts.length === 0) return ""; + return `Skills with more than Claude Code loads by itself: ${parts.join(", ")}. Before working in one's area, run \`${show} \`: it prints the skill with every correction, newest first.\n`; +} diff --git a/src/telemetry/contract.ts b/src/telemetry/contract.ts index 1cfebfd5..855beef3 100644 --- a/src/telemetry/contract.ts +++ b/src/telemetry/contract.ts @@ -79,6 +79,13 @@ export const EVENTS: Record> = { * and the paths never travel. The supply side of the notes loop; the * `notes` property on `query` is the demand side. */ note_saved: new Set(['has_cost', 'touches_bucket']), + /** `trail learn` saved a correction as a takeaway on a skill (into + * `~/.trail/`). `new_skill` says whether it started a skill. The text never + * travels. */ + takeaway_saved: new Set(['new_skill']), + /** `trail skills fold` wrote waiting takeaways into a skill's SKILL.md; + * how many, as a bucket. Whether corrections become the skill. */ + skills_folded: new Set(['takeaways_bucket']), /** One closed agent session, summarised. `graft_reads` vs `source_reads` is * the single number that says whether an agent prefers graft to grep; the two * `*_turns` buckets are the follow-up question — of the turns that used graft, diff --git a/test/telemetry-contract.test.ts b/test/telemetry-contract.test.ts index 2756cbe2..084f73dd 100644 --- a/test/telemetry-contract.test.ts +++ b/test/telemetry-contract.test.ts @@ -75,11 +75,11 @@ test('every event carries the common properties, and no identifier beyond them', // Pinned rather than counted, so adding an event is a deliberate edit here and // a matching row in TELEMETRY.md, never something that arrives with a feature. -test('the contract lists exactly the thirteen documented events', () => { +test('the contract lists exactly the fifteen documented events', () => { assert.deepEqual(Object.keys(EVENTS).sort(), [ 'brain_signup_opened', 'brain_signup_settled', 'build_completed', 'build_failed', - 'first_run', 'init_completed', 'install', 'note_saved', 'query', 'session_summary', 'trail_autopush', 'trail_pulled', - 'trail_watch_exit', + 'first_run', 'init_completed', 'install', 'note_saved', 'query', 'session_summary', 'skills_folded', 'takeaway_saved', + 'trail_autopush', 'trail_pulled', 'trail_watch_exit', ]); }); diff --git a/test/trail-notes.test.ts b/test/trail-notes.test.ts index 8c70b3e9..81b61fe3 100644 --- a/test/trail-notes.test.ts +++ b/test/trail-notes.test.ts @@ -332,6 +332,9 @@ test("the trail skill's description names when to use its commands, and the body const moments = content.indexOf("## When to reach for trail"); assert.ok(moments >= 0 && moments < content.indexOf("## The tools"), "the moments come before the tool docs"); assert.match(content, /\*\*When you finish a task that took real digging\*\*/); + assert.match(data.description, /corrects how something is done here, or states a rule for it, save it with trail learn/); + assert.match(content, /\*\*When the user corrects how something is done here\*\*/); + assert.match(content, /`trail skills show ` prints it/); }); test("the session-start directive mentions notes only in a repo that keeps them", async () => { diff --git a/test/trail-skills.test.ts b/test/trail-skills.test.ts new file mode 100644 index 00000000..510c7293 --- /dev/null +++ b/test/trail-skills.test.ts @@ -0,0 +1,221 @@ +/** + * Skills: corrections become takeaways, kept on this machine in ~/.trail, and + * takeaways fold into a SKILL.md: the repo's own skill in `.claude/skills/`, + * or one kept in ~/.trail until someone publishes it into the repo. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + confirm, + fold, + getSkill, + isConfirmed, + learn, + listSkills, + parseTakeaway, + publish, + renderTakeaway, + skillDirs, + skillForAgent, + skillsDirective, + skillStatus, +} from "../src/skills/skills.js"; +import { homeEnv, tmpRepo } from "./helpers.js"; + +process.env.TRAIL_HOME = mkdtempSync(join(tmpdir(), "skills-trail-home-")); + +const DAY = "2026-10-07"; +const ROTATION = "Read rotation with page.Rotation() from internal/pdf/page.go.\nNever pdfcpu: it drops /Rotate on linearized files."; + +/** A repo with a hand-written team skill, extra frontmatter and all. */ +function repoWithTeamSkill(tag: string): string { + const repo = tmpRepo(tag); + mkdirSync(join(repo, ".claude", "skills", "deploys"), { recursive: true }); + writeFileSync( + join(repo, ".claude", "skills", "deploys", "SKILL.md"), + "---\nname: deploys\ndescription: How this repo ships\nallowed-tools: Bash(make:*)\n---\n\n# deploys\n\nShip with make ship.\n", + ); + return repo; +} + +test("learn starts a skill in ~/.trail on first use, every takeaway its own file, and the repo untouched", () => { + const repo = tmpRepo("skills-learn"); + const a = learn(repo, { skill: "PDF coords", text: ROTATION, author: "Sam", date: DAY, description: "Coordinates, boxes and rotation for PDF pages", section: "Rotation" }); + assert.equal(a.created, true); + assert.equal(a.skill.name, "pdf-coords"); + assert.equal(a.skill.where, "home"); + const home = skillDirs(repo).home; + assert.ok(home.startsWith(process.env.TRAIL_HOME!), home); + assert.equal(a.takeaway.path, join(home, "pdf-coords", "takeaways", "2026-10-07-sam-1.md")); + const b = learn(repo, { skill: "pdf-coords", text: "Boxes are in points, not pixels.", author: "Sam", date: DAY }); + assert.equal(b.created, false); + assert.equal(b.takeaway.path, join(home, "pdf-coords", "takeaways", "2026-10-07-sam-2.md")); + const s = getSkill(repo, "pdf-coords")!; + assert.equal(s.version, 1); + assert.equal(s.description, "Coordinates, boxes and rotation for PDF pages"); + assert.equal(s.takeaways.length, 2); + assert.deepEqual(readdirSync(repo), [], "nothing in the repo"); +}); + +test("a takeaway on a skill the repo already has stays on this machine, and folds into the repo's SKILL.md keeping its frontmatter", () => { + const repo = repoWithTeamSkill("skills-team"); + const r = learn(repo, { skill: "deploys", text: "Run the migration check before make ship.", author: "Priya", date: DAY }); + assert.equal(r.created, false); + assert.equal(r.skill.where, "repo"); + assert.ok(r.takeaway.path.startsWith(skillDirs(repo).home)); + assert.deepEqual(readdirSync(join(repo, ".claude", "skills", "deploys")), ["SKILL.md"], "the takeaway isn't in the repo"); + + const f = fold(repo, "deploys")!; + assert.equal(f.where, "repo"); + assert.equal(f.file, join(repo, ".claude", "skills", "deploys", "SKILL.md")); + const md = readFileSync(f.file, "utf8"); + assert.match(md, /^allowed-tools: Bash\(make:\*\)$/m, "the team's own keys survive"); + assert.match(md, /^version: 2$/m); + assert.match(md, /Ship with make ship\.\n\n## Takeaways\n- Run the migration check before make ship\. \(Priya, Oct 7\)/); +}); + +test("trail's and graft's own skills never take takeaways", () => { + const repo = tmpRepo("skills-own"); + mkdirSync(join(repo, ".claude", "skills", "trail"), { recursive: true }); + writeFileSync(join(repo, ".claude", "skills", "trail", "SKILL.md"), "---\nname: trail\n---\nx\n"); + assert.throws(() => learn(repo, { skill: "trail", text: "x", author: "Sam", date: DAY }), /code map's own skill/); + assert.deepEqual(listSkills(repo), []); +}); + +test("a takeaway round-trips through its file", () => { + const t = { skill: "pdf-coords", taughtBy: "Sam", date: DAY, from: "correction", confirmedBy: ["Lena"], section: "Rotation", foldedIn: 4, text: ROTATION }; + assert.deepEqual(parseTakeaway(renderTakeaway(t), "p.md", "pdf-coords"), { ...t, path: "p.md" }); +}); + +test("only someone other than the teacher can confirm", () => { + const repo = tmpRepo("skills-confirm"); + learn(repo, { skill: "pdf-coords", text: ROTATION, author: "Sam", date: DAY }); + assert.deepEqual(confirm(repo, "pdf-coords", "Sam"), [], "the teacher can't confirm their own"); + assert.equal(confirm(repo, "pdf-coords", "Lena").length, 1); + assert.equal(confirm(repo, "pdf-coords", "Lena").length, 0, "confirming twice changes nothing"); + assert.ok(isConfirmed(getSkill(repo, "pdf-coords")!.takeaways[0]!)); +}); + +test("fold writes waiting takeaways under their heading, bumps the version, and keeps the files; --confirmed takes only confirmed ones", () => { + const repo = tmpRepo("skills-fold"); + learn(repo, { skill: "pdf-coords", text: ROTATION, author: "Sam", date: DAY, section: "Rotation" }); + learn(repo, { skill: "pdf-coords", text: "Boxes are in points.", author: "Priya", date: DAY }); + confirm(repo, "pdf-coords", "Lena", "2026-10-07-sam-1"); + + const r = fold(repo, "pdf-coords", { confirmed: true })!; + assert.equal(r.from, 1); + assert.equal(r.to, 2); + assert.equal(r.folded.length, 1); + assert.equal(r.stillWaiting, 1); + assert.deepEqual(r.sections, ["Rotation"]); + const file = join(skillDirs(repo).home, "pdf-coords", "SKILL.md"); + assert.equal(r.file, file); + const md = readFileSync(file, "utf8"); + assert.match(md, /^version: 2$/m); + assert.match(md, /## Rotation\n- Read rotation with page\.Rotation\(\) from internal\/pdf\/page\.go\. Never pdfcpu: it drops \/Rotate on linearized files\. \(Sam, Oct 7\)/); + assert.ok(existsSync(join(skillDirs(repo).home, "pdf-coords", "takeaways", "2026-10-07-sam-1.md")), "history stays"); + assert.equal(getSkill(repo, "pdf-coords")!.takeaways.find((t) => t.taughtBy === "Sam")?.foldedIn, 2); + + // A plain fold takes the rest, under Takeaways since it named no heading. + const rest = fold(repo, "pdf-coords")!; + assert.equal(rest.to, 3); + assert.deepEqual(rest.sections, ["Takeaways"]); + assert.match(readFileSync(file, "utf8"), /## Takeaways\n- Boxes are in points\. \(Priya, Oct 7\)/); + assert.equal(fold(repo, "pdf-coords")!.folded.length, 0, "nothing left"); + assert.equal(fold(repo, "nope"), null); +}); + +test("publish moves a skill from ~/.trail into the repo's .claude/skills/; its takeaways stay and fold into the repo's copy", () => { + const repo = tmpRepo("skills-publish"); + learn(repo, { skill: "pdf-coords", text: ROTATION, author: "Sam", date: DAY, description: "PDF coordinates" }); + fold(repo, "pdf-coords"); + const p = publish(repo, "pdf-coords"); + assert.deepEqual(p, { ok: true, file: join(repo, ".claude", "skills", "pdf-coords", "SKILL.md") }); + assert.match(readFileSync(join(repo, ".claude", "skills", "pdf-coords", "SKILL.md"), "utf8"), /^version: 2$/m); + assert.equal(existsSync(join(skillDirs(repo).home, "pdf-coords", "SKILL.md")), false, "one copy, the repo's"); + assert.equal(getSkill(repo, "pdf-coords")!.where, "repo"); + assert.equal(getSkill(repo, "pdf-coords")!.takeaways.length, 1, "the folded takeaway's history stays here"); + assert.deepEqual(publish(repo, "pdf-coords"), { ok: false, reason: "in-repo" }); + assert.deepEqual(publish(repo, "nope"), { ok: false, reason: "unknown" }); + + learn(repo, { skill: "pdf-coords", text: "Boxes are in points.", author: "Sam", date: DAY }); + assert.equal(fold(repo, "pdf-coords")!.file, join(repo, ".claude", "skills", "pdf-coords", "SKILL.md")); +}); + +test("skills says what each one is waiting on, and where it lives", () => { + const repo = tmpRepo("skills-status"); + learn(repo, { skill: "pdf-coords", text: ROTATION, author: "Sam", date: DAY }); + const s = () => getSkill(repo, "pdf-coords")!; + assert.equal(skillStatus(s()), "v1 · 1 takeaway waiting to fold · last taught by Sam, Oct 7 · on this machine"); + confirm(repo, "pdf-coords", "Lena"); + assert.equal(skillStatus(s()), "v1 · 1 takeaway waiting to fold (1 confirmed) · last taught by Sam, Oct 7 · on this machine"); + fold(repo, "pdf-coords"); + assert.equal(skillStatus(s()), "v2 · up to date · on this machine"); +}); + +test("what an agent reads: the skill, then the corrections not yet in it, newest first", () => { + const repo = repoWithTeamSkill("skills-show"); + learn(repo, { skill: "deploys", text: "Older rule.", author: "Sam", date: "2026-10-05" }); + learn(repo, { skill: "deploys", text: "Newer rule.", author: "Priya", date: DAY }); + const text = skillForAgent(getSkill(repo, "deploys")!); + assert.match(text, /^# deploys \(v1, the team's\)\n\n# deploys\n\nShip with make ship\.\n\n## Corrections since v1/); + assert.ok(text.indexOf("Newer rule. (Priya, Oct 7)") < text.indexOf("Older rule. (Sam, Oct 5)")); +}); + +test("a session hears about skills Claude Code can't load by itself, and nothing when there are none", () => { + const repo = repoWithTeamSkill("skills-directive"); + assert.equal(skillsDirective(repo, "trail skills show"), "", "the team's skill with nothing waiting: Claude Code has it"); + learn(repo, { skill: "pdf-coords", text: ROTATION, author: "Sam", date: DAY }); + learn(repo, { skill: "deploys", text: "Run the migration check first.", author: "Sam", date: DAY }); + const line = skillsDirective(repo, "trail skills show"); + assert.match(line, /deploys \(1 correction newer than its SKILL\.md\)/); + assert.match(line, /pdf-coords \(on this machine, 1 correction\)/); + assert.match(line, /run `trail skills show `/); +}); + +// --- end to end --- + +function run(args: string[], home: string, input?: string) { + const r = spawnSync(process.execPath, ["--import", "tsx", join(process.cwd(), "src", "bin", "trail.ts"), ...args], { + input, + encoding: "utf8", + env: { ...process.env, ...homeEnv(home), DO_NOT_TRACK: "1", CLAUDECODE: undefined, TRAIL_INVOKED_AS: undefined, TRAIL_HOME: undefined }, + }); + return { status: r.status, out: r.stdout, err: r.stderr }; +} + +test("trail learn, skills, show, fold and publish from the command line", () => { + const home = mkdtempSync(join(tmpdir(), "skills-home-")); + const d = tmpRepo("skills-cli"); + spawnSync("git", ["init", "-q"], { cwd: d }); + const learnt = run(["learn", "pdf-coords", d, "--author", "Sam", "--section", "Rotation"], home, ROTATION); + assert.equal(learnt.status, 0, learnt.err); + assert.match(learnt.out, /^✓ takeaway saved to new skill pdf-coords · taught by Sam$/m); + assert.match(learnt.out, /kept on this machine, in ~\/\.trail\/repos\/local\/[^/]+\/skills\/pdf-coords$/m); + assert.equal(existsSync(join(d, ".claude")), false, "nothing in the repo"); + + assert.match(run(["skills", d], home).out, /^pdf-coords {2}v1 · 1 takeaway waiting to fold · last taught by Sam, \w{3} \d+ · on this machine$/m); + assert.match(run(["skills", "show", "pdf-coords", "-C", d], home).out, /## Corrections since v1[^\n]*\n- Read rotation with page\.Rotation\(\)/); + const folded = run(["skills", "fold", "pdf-coords", "-C", d], home); + assert.equal(folded.status, 0, folded.err); + assert.match(folded.out, /✓ pdf-coords v1 → v2 · folded 1 takeaway/); + assert.match(folded.out, /trail skills publish moves a skill into the repo's \.claude\/skills\//); + assert.match(run(["skills", d], home).out, /^pdf-coords {2}v2 · up to date · on this machine$/m); + + const pub = run(["skills", "publish", "pdf-coords", "-C", d], home); + assert.equal(pub.status, 0, pub.err); + assert.match(pub.out, /^✓ pdf-coords → \.claude\/skills\/pdf-coords\/SKILL\.md$/m); + assert.ok(existsSync(join(d, ".claude", "skills", "pdf-coords", "SKILL.md"))); + assert.match(run(["skills", d], home).out, /^pdf-coords {2}v2 · up to date · in the repo$/m); + + const missing = run(["skills", "show", "nope", "-C", d], home); + assert.equal(missing.status, 1); + assert.match(missing.err, /no skill called nope · trail skills lists them/); + const empty = run(["learn", "pdf-coords", d], home, ""); + assert.equal(empty.status, 1); + assert.match(empty.err, /a takeaway needs its text/); +});