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
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
title: A laptop links each repo to one Trail workspace; cloud.json keeps the newest connection at the top level
author: Anirudhkumar-nanonets
date: 2026-10-27
branch: feat/link-repo-to-workspace
id: l-36c8d257
scope: repo
status: active
src: agent
touches:
- src/claude/skill-use.ts
- src/cli.ts
- src/cloud/lookup-activity.ts
- src/cloud/nudge.ts
- src/cloud/pair.ts
- src/cloud/workspace-sync.ts
- src/cloud/workspace.ts
- src/mcp/connector.ts
- src/mcp/server.ts
- test/trail-nudge.test.ts
---

## Decided
- A machine can be in several workspaces, one `trl_` token each. trail-cloud already mints one token per handoff for the workspace picked, so this needed no server change.
- Each repo goes to exactly one workspace from a machine: the newest connection whose `repos` lists it (`cloudForRepo` / `cloudIn`, src/cloud/workspace.ts). Sync, the activity outbox, `recordLookup`, `recordSkillUse`, the MCP connector (by the server's cwd), `team` and `status` all resolve the repo first. `readCloud()` now means only "the newest connection", for things that aren't about one repo.
- `cloud.json` keeps the newest connection at the top level and the rest under `others`. An older trail still reads it as the machine's one connection, and keeps `others` when it writes that connection back, because it spreads the object it read.
- Two connections are the same workspace when their `workspace.id`s match. Re-pointing a workspace at a new address replaces it.
- Signed in, a repo with learnings that no workspace here has gets the link every session ("Pick a Trail workspace for <repo>"), in place of the old "add it?" question asked once ever. Trail's connect page then offers the person's workspaces or "create a new workspace".
- `trail workspace add` needs `--to <slug>` when there are several, and refuses a repo that already goes to another workspace. `trail logout --workspace <slug>` drops one.

## Tried and ruled out
- `sameWorkspace` by URL and id: a test (and a real `--server` move) that re-points the same workspace at a new address ended up with two connections.
- Reusing one pending link across repos: the link registers only the repo it was made in, so a link from repo A connects A even when shown in B. Each repo now gets its own. `waitForConnect` switches to the newest pending link, and the old one is left to expire.

## Watch out
- `flushActivity` drops a use whose repo no connection has, so it always reads every connection from disk and merges in what the caller passes. Passing one cloud never loses another workspace's uses.
- Only the first connection drops the outbox (`dropActivity`). A later link keeps what the other workspaces have waiting.
- graft-to-trail.test.ts, hosts-trail.test.ts and upkeep-hooks.test.ts each had one failure on this machine, on main as well as on this branch (checked 2026-10-27).
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
title: "A sandbox's trail hooks stand down when Claude runs with the real HOME; move the shim out of .claude/helpers"
author: Anirudhkumar-nanonets
date: 2026-10-27
branch: feat/link-repo-to-workspace
id: l-e9301767
scope: repo
status: active
src: agent
touches:
- TELEMETRY.md
- src/claude/format.ts
- src/claude/hooks.ts
- src/claude/note-signals.ts
- src/claude/pre-bash.ts
- src/claude/prompt-hook.ts
- src/claude/settings-merge.ts
- src/claude/skill-template.ts
- src/claude/skill-use.ts
- src/cli.ts
---

## Decided
- Trail now acts at a session's moments through hooks rather than waiting for the agent to run a trail command:
- Before an edit, the PreToolUse `pre-edit` hook hands the agent the learnings on that file (`handlePreEdit`, src/claude/hooks.ts).
- Before `git commit`, `git push` or `gh pr create`, pre-bash runs `trail check` and stops the command once per set of conflicts (src/claude/pre-bash-check.ts). It loads only builtins, brand.ts and trace/gate-file.ts, and the check runs in a child capped at 3.5 s.
- When one of these hooks used trail, the person gets the workspace link as a notice (`hookNotice`, src/cloud/nudge.ts).
- A check run with a folder flag counts for the fixed sign: `go -C backend test`, `npm --prefix web test`. Claude often runs it that way, and without the flag the Stop hook missed a real fail-then-pass.

## Tried and ruled out
- Blaming `claude -p` when the prompt, Stop and PostToolUse hooks went quiet in a sandbox. It runs them all. The cause was `globalCopyCovers` (src/claude/hook-layers.ts): a shim under `<sandbox>/home/.claude/helpers` looks like a repo's copy. Claude ran with the real HOME, whose `~/.claude/settings.json` has trail's hooks, so the shim stood down for a copy that `--setting-sources project,local` never loads.

## Watch out
- In a sandbox that keeps the real HOME for Claude's login, copy the shim out of any `.claude/helpers` folder and point the sandbox settings at the copy (trail-cloud's dev/local-flow/demo-env.sh does this). Also clear the CLAUDE_* variables a parent Claude session exports. `CLAUDE_CODE_CHILD_SESSION` alone turns off transcript saving.
- The first upload after connecting waits for a `git fetch` of the repo's real remote: about 30 s on assign.
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
title: "Testing the cloud nudge locally: rebuild dist first, and a nudge sign-in lands before the first sync"
author: Anirudhkumar-nanonets
date: 2026-10-27
branch: feat/build-map-on-first-use
cost: { minutes: 36, tokens: 418798 }
id: l-e903e143
scope: repo
status: active
src: agent
touches:
- src/cloud/nudge.ts
---

## Decided
- To walk the CLI → trail-cloud connect flow locally: run trail-cloud from a worktree of `origin/main` with its own Postgres (initdb on a spare port) and `TRAIL_DEV_SIMULATE=1`. Point a sandboxed trail at it with `TRAIL_TEST_SERVER=http://localhost:8787`, `TRAIL_HOME=<sandbox>/.trail`, `TRAIL_CLOUD=1` and `DO_NOT_TRACK=1`, running `node <checkout>/dist/bin/trail.js` through a wrapper named `trail`.
- The clone's origin must stay the real GitHub URL. A `local/` repo key never gets the nudge (`cloudLines`, src/cloud/nudge.ts).

## Tried and ruled out
- Running against the existing `dist/`. It was older than src: it pointed at app.trailhq.com and ignored `TRAIL_TEST_SERVER`, so the `_trail-claim` child registered the repo key and its learning count with that real host. Run `npm run build` first, then grep `dist/cloud/workspace.js` for `DEFAULT_TRAIL_URL`.

## Watch out
- A nudge (unpaired) sign-in lands on `/app/?w=<slug>` before the CLI has claimed the token and uploaded anything. On the local run: browser at 24.1 s, claim at 24.25 s, `PUT /api/cli/learnings` at 28.98 s. The first look shows 0 learnings and "Add a repo".
- trail-cloud only sends paired setups to `/start/live`, because nothing reports progress for an unpaired link (`landing` in internal/web/connect.go).
- localhost cookies are shared across ports. To see a signed-out page while another server on localhost has a session, use 127.0.0.1.
2 changes: 1 addition & 1 deletion TELEMETRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ The events:
| `skill_stale` | `reason` (`failed`/`corrected`/`missing`/`unused`/`unfollowed`) | A skill stops reaching the agent as it is: it failed or was corrected twice in a row, something a step needs is gone, it went unused, or the agent mostly didn't follow it |
| `guardrail` | `class` (`no-timeout`/`prod-after-beta`), `outcome` (`hint`/`ask`/`deny`/`sure-fail`/`avoided`) | A built-in guardrail fires, or the fixed command then works (`avoided`). The command is never sent |
| `wall_hit` | `class` (`timeout-missing`/`zsh-no-match`/`git-https-auth`/`docker-rootless`/`npm-e401-eotp`/`claude-not-logged-in`/`enospc`), `had_lesson` (`true`/`false`) | A failing command matches a known wall, and whether you had kept a lesson for it. The command and its output are never sent |
| `hook_ms` | `event` (`session-start`/`prompt`/`pre-bash`/`tool-savings`/`post-bash`/`post-bash-fail`/`post-edit`/`post-edit-sync`/`stop`), `bucket` (`<50ms`/`50-100ms`/`100-300ms`/`300ms-1s`/`1-3s`/`3s+`) | A Claude Code hook finishes: which one, and how long it took, bucketed |
| `hook_ms` | `event` (`session-start`/`prompt`/`pre-bash`/`pre-edit`/`tool-savings`/`post-bash`/`post-bash-fail`/`post-edit`/`post-edit-sync`/`stop`), `bucket` (`<50ms`/`50-100ms`/`100-300ms`/`300ms-1s`/`1-3s`/`3s+`) | A Claude Code hook finishes: which one, and how long it took, bucketed |

Two rules govern every value above, and both are enforced in code rather than by
review:
Expand Down
9 changes: 7 additions & 2 deletions src/claude/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export function notesForPrompt(ask: AskJson, s: SessionState, cap = PROMPT_NOTES
export const TOUCH_NOTES_CAP = 2;

/** What the agent just did to a file: opened it, or changed it. */
export type Touch = { file: string; verb: 'opened' | 'changed' } | { command: string };
export type Touch = { file: string; verb: 'opened' | 'changed' | 'editing' } | { command: string };

/** Whether `command` names `file` as a path of its own, not as part of a longer one. */
function commandNames(command: string, file: string): boolean {
Expand Down Expand Up @@ -172,7 +172,12 @@ export function notesOnTouch(notes: Note[], touch: Touch, s: SessionState, cap =
if (!fresh.length) return null;
s.injectedNotes = [...(s.injectedNotes ?? []), ...fresh.map((n) => n.path || n.title)].slice(-INJECTED_POINTERS_CAP);
const hits: NoteHit[] = fresh.map((note) => ({ note, score: 0, shared: [] }));
const what = 'file' in touch ? `the file you just ${touch.verb}, ${touch.file}` : 'a file that command used';
const what =
'file' in touch
? touch.verb === 'editing'
? `the file you're about to change, ${touch.file}`
: `the file you just ${touch.verb}, ${touch.file}`
: 'a file that command used';
const saved = learningSavingsLine(hits);
const credit = learningCredit(hits);
const one = fresh.length === 1;
Expand Down
20 changes: 18 additions & 2 deletions src/claude/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { keepsNotes, noteCounts, repoPlace, type NoteCounts } from '../notes/hom
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 { hookNotice } from '../cloud/nudge.js';
import { cacheDir, readJson, writeJsonAtomic } from '../util/state.js';
import { countBucket, type AgentHost } from '../telemetry/contract.js';
import { detectHost } from '../trace/host.js';
Expand Down Expand Up @@ -165,7 +166,7 @@ function handleToolUse(input: any, dir: string): void {
* each once a session. A trail command is skipped: its own output already
* carries the learnings that bear on it. Never throws.
*/
export function learningsOnTouch(input: any, dir: string): string | null {
export function learningsOnTouch(input: any, dir: string, before = false): string | null {
try {
if (brand() !== 'trail' || !keepsNotes(dir)) return null;
const ti = input?.tool_input ?? {};
Expand All @@ -174,7 +175,7 @@ export function learningsOnTouch(input: any, dir: string): string | null {
if (path) {
const rel = relative(dir, isAbsolute(path) ? path : join(dir, path)).replace(/\\/g, '/');
if (!rel || rel.startsWith('..') || underGraft(dir, path)) return null;
touch = { file: rel, verb: input?.tool_name === 'Read' ? 'opened' : 'changed' };
touch = { file: rel, verb: before ? 'editing' : input?.tool_name === 'Read' ? 'opened' : 'changed' };
} else if (typeof ti.command === 'string' && ti.command && !commandInvokesGraft(ti.command)) {
touch = { command: ti.command };
} else return null;
Expand All @@ -191,6 +192,19 @@ export function learningsOnTouch(input: any, dir: string): string | null {
}
}

/**
* PreToolUse on Write and Edit: the learnings on the file, before the agent
* changes it, while they can still change the edit. They're marked shown, so
* the PostToolUse after the edit doesn't repeat them. A teammate's learning
* just reached the agent, which is when the person gets the workspace link.
*/
export function handlePreEdit(input: any, dir: string): void {
const learned = learningsOnTouch(input, dir, true);
if (!learned) return;
const notice = hookNotice(dir, "Claude just got your team's learnings on the file it's changing", { session: payloadSessionId(input) }) ?? undefined;
emitHook('PreToolUse', { context: learned, notice });
}

/**
* PostToolUse's one answer: the skills part's, with the learnings about what
* the call touched added to its context. Two JSON objects on stdout would be
Expand Down Expand Up @@ -533,6 +547,8 @@ export async function main(event: string): Promise<void> {
// Claude closing: send the queue from a detached child, so quitting isn't slowed.
if (event === 'session-end') { maybeFlushInBackground(); return; }

if (event === 'pre-edit') { handlePreEdit(input, dir); return; }

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

if (event === 'tool-savings') {
Expand Down
49 changes: 40 additions & 9 deletions src/claude/note-signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
* - `undone`: an edit was undone (`git restore`, `git revert`, …);
* - `chose`: the person picked one of the options the agent offered, and the
* agent went on to change code;
* - `overrode`: `trail check` found the change goes against a learning, and
* the work shipped anyway with the files it named left as they were;
* - `shipped`: `git commit`, `git push` or `gh pr create` after code edits;
* - `explored`: one turn took NOTE_WORTHY_READING tokens of reading to answer,
* with no edit needed: where a feature lives, why a bug happens.
Expand All @@ -26,7 +28,7 @@ import { isAbsolute, relative, resolve, sep } from 'node:path';
import { trailOwnPath } from '../notes/git-facts.js';
import { NOTE_CALL } from '../notes/session-cost.js';

export type NoteSignalKind = 'fixed' | 'corrected' | 'undone' | 'chose' | 'shipped' | 'explored';
export type NoteSignalKind = 'fixed' | 'corrected' | 'undone' | 'chose' | 'overrode' | 'shipped' | 'explored';

export interface NoteSignal {
kind: NoteSignalKind;
Expand Down Expand Up @@ -78,11 +80,14 @@ const LEADING_CD = /^\s*cd\s+(["']?)([^"'&;|]+?)\1\s*(?:&&|;)/;

/**
* A test, build or type check, as typed: `npm test`, `go test`, `pytest`,
* `npx tsc`, `make`. The match is the command's name for itself, so two runs
* of `go test` with different flags are the same check.
* `npx tsc`, `make`; or a deploy or CI step that can fail the same way:
* `docker build`, `kubectl apply`, `terraform plan`, `helm upgrade`. A folder
* flag before the verb counts too (`go -C backend test`, `npm --prefix web test`).
* The match is the command's name for itself, so two runs of `go test` with
* different flags are the same check.
*/
const CHECK =
/(?:^|[;&|(]\s*|\s)((?:npx\s+|bunx\s+|pnpm\s+exec\s+)?(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|build|lint|typecheck|check)(?::[\w-]+)?|go\s+(?:test|build|vet)|cargo\s+(?:test|build|check|clippy)|python3?\s+-m\s+(?:pytest|unittest)|pytest|tsc|jest|vitest|mocha|rspec|node\s+--test|deno\s+test|swift\s+(?:test|build)|dotnet\s+(?:test|build)|mix\s+test|mvn\s+[\w:-]*(?:test|verify|package)|\.?\/?gradlew?\s+[\w:-]*(?:test|build)|make(?:\s+(?!-)[\w-]+)?|eslint|ruff|golangci-lint))(?=$|[\s;&|)])/;
/(?:^|[;&|(]\s*|\s)((?:npx\s+|bunx\s+|pnpm\s+exec\s+)?(?:(?:npm|pnpm|yarn|bun)\s+(?:(?:--prefix|--cwd|--dir|-C)[=\s]\S+\s+)?(?:run\s+)?(?:test|build|lint|typecheck|check)(?::[\w-]+)?|go\s+(?:-C[=\s]\S+\s+)?(?:test|build|vet)|cargo\s+(?:test|build|check|clippy)|python3?\s+-m\s+(?:pytest|unittest)|pytest|tsc|jest|vitest|mocha|rspec|node\s+--test|deno\s+test|swift\s+(?:test|build)|dotnet\s+(?:test|build)|mix\s+test|mvn\s+[\w:-]*(?:test|verify|package)|\.?\/?gradlew?\s+[\w:-]*(?:test|build)|make(?:\s+(?!-)[\w-]+)?|eslint|ruff|golangci-lint|docker\s+(?:build|compose\s+(?:up|build))|kubectl\s+(?:apply|rollout\s+status)|terraform\s+(?:plan|apply|validate)|helm\s+(?:install|upgrade|lint|template)))(?=$|[\s;&|)])/;

/** Output that says a check failed even when a pipe (`| tail`) hid its exit code. */
const FAILED_OUTPUT =
Expand All @@ -91,6 +96,11 @@ const ZERO_FAILED = /\b0 (?:failed|failing)\b|^# fail 0\b/m;

const UNDO = /\bgit\s+(?:restore|revert|reset\s+--hard|checkout\s+(?:\S+\s+)?--\s)/;
const SHIP = /\b(git\s+commit|git\s+push|gh\s+pr\s+create)\b/;
/** `trail check` saying the change goes against a learning: its own exit 1, or the reason pre-bash-check.ts stops a commit with. */
const TRAIL_CHECK = /(?:^|[\s;&|])(?:trail|graft)\s+check\b/;
const CHECK_STOP = /^trail check found \d+ conflicts?\b/m;
/** The files a conflict names: `- path:line: …` in the stop's reason, `✗ path:line` in the check's own output. */
const CONFLICT_FILE = /^(?:- |✗ )([^\s:]+)(?::\d+)?:?/gm;
/** Commands that set tools up rather than work on the repo. */
const SETUP = /(?:^|[\s;&|])(?:trail|graft|npx\s+(?:-y\s+)?@(?:trailhq|nanonets)\/[\w-]+)\s|(?:npm|pnpm|yarn|bun)\s+(?:i|install|add|uninstall)\b|\bbrew\s+install\b|\bpip3?\s+install\b/;

Expand Down Expand Up @@ -306,6 +316,25 @@ export function noteSignals(jsonl: string, repo: string): NoteSignal[] {
const choice = picked();
if (choice) out.push(choice);

// overrode: trail check named a conflict with a learning, and the work shipped anyway, those files left as they were.
const conflict = [...work].reverse().find((t) => {
const c = bash(t);
const r = results.get(t.id);
return !!c && !!r && (CHECK_STOP.test(r.text) || (TRAIL_CHECK.test(c) && r.error));
});
if (conflict) {
const named = [...(results.get(conflict.id)?.text ?? '').matchAll(CONFLICT_FILE)].map((m) => m[1]!);
const after = work.filter((t) => t.at > conflict.at);
const touched = after.some((t) => named.includes(codeEdit(t) ?? '\0'));
const shipped = after.find((t) => {
const c = bash(t);
return !!c && SHIP.test(c) && results.get(t.id)?.error !== true;
});
if (shipped && !touched) {
out.push({ kind: 'overrode', key: `overrode:${shipped.uuid}`, what: `trail check found this change goes against a learning, and it shipped anyway.` });
}
}

// shipped: the work went into a commit, a push or a PR with no learning beside it.
if (work.some((t) => codeEdit(t) !== null)) {
for (const t of [...work].reverse()) {
Expand Down Expand Up @@ -349,10 +378,12 @@ export function noteAsk(s: NoteSignal): string {
? `${s.what} Save it as a learning so nobody has to say it again: ${SAVE}; add --personal if it's about how this person likes to work. ${skip}`
: s.kind === 'undone'
? `${s.what} If you dropped an approach, save why under ## Tried and ruled out: ${SAVE}. ${skip}`
: s.kind === 'chose'
? `${s.what} If the choice matters to the next person, save it under ## Decided, with the options you dropped and why under ## Tried and ruled out: ${SAVE}. ${skip}`
: s.kind === 'explored'
? `${s.what} If it found where something lives or why it works the way it does, and the code won't say so plainly, save it: ${SAVE}. ${skip}`
: `${s.what} If the session found what the diff won't tell the next person (a cause, a dropped approach, how something is done here), save it now so it ships with the change: ${SAVE}. ${skip}`;
: s.kind === 'overrode'
? `${s.what} If the learning no longer holds, save what changed and why, so the next person isn't told the old way: ${SAVE} with \`--replaces <the old learning's file>\`. If the learning still holds and this is an exception, say so and stop.`
: s.kind === 'chose'
? `${s.what} If the choice matters to the next person, save it under ## Decided, with the options you dropped and why under ## Tried and ruled out: ${SAVE}. ${skip}`
: s.kind === 'explored'
? `${s.what} If it found where something lives or why it works the way it does, and the code won't say so plainly, save it: ${SAVE}. ${skip}`
: `${s.what} If the session found what the diff won't tell the next person (a cause, a dropped approach, how something is done here), save it now so it ships with the change: ${SAVE}. ${skip}`;
return `[trail] ${first}\n${CLOSE}`;
}
Loading
Loading