diff --git a/.trail/learnings/2026-10-27-a-laptop-links-each-repo-to-anirudhkumar.md b/.trail/learnings/2026-10-27-a-laptop-links-each-repo-to-anirudhkumar.md new file mode 100644 index 000000000..c09938277 --- /dev/null +++ b/.trail/learnings/2026-10-27-a-laptop-links-each-repo-to-anirudhkumar.md @@ -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 "), 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 ` when there are several, and refuses a repo that already goes to another workspace. `trail logout --workspace ` 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). diff --git a/.trail/learnings/2026-10-27-a-sandbox-s-trail-hooks-stand-anirudhkumar.md b/.trail/learnings/2026-10-27-a-sandbox-s-trail-hooks-stand-anirudhkumar.md new file mode 100644 index 000000000..7b58d777f --- /dev/null +++ b/.trail/learnings/2026-10-27-a-sandbox-s-trail-hooks-stand-anirudhkumar.md @@ -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 `/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. diff --git a/.trail/learnings/2026-10-27-testing-the-cloud-nudge-locally-rebuild-anirudhkumar.md b/.trail/learnings/2026-10-27-testing-the-cloud-nudge-locally-rebuild-anirudhkumar.md new file mode 100644 index 000000000..83fc696e2 --- /dev/null +++ b/.trail/learnings/2026-10-27-testing-the-cloud-nudge-locally-rebuild-anirudhkumar.md @@ -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=/.trail`, `TRAIL_CLOUD=1` and `DO_NOT_TRACK=1`, running `node /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=` 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. diff --git a/TELEMETRY.md b/TELEMETRY.md index 4266f61a4..4e3040310 100644 --- a/TELEMETRY.md +++ b/TELEMETRY.md @@ -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: diff --git a/src/claude/format.ts b/src/claude/format.ts index 53439ff7f..880e98b26 100644 --- a/src/claude/format.ts +++ b/src/claude/format.ts @@ -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 { @@ -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; diff --git a/src/claude/hooks.ts b/src/claude/hooks.ts index bcb7992de..50fda6fa6 100644 --- a/src/claude/hooks.ts +++ b/src/claude/hooks.ts @@ -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'; @@ -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 ?? {}; @@ -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; @@ -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 @@ -533,6 +547,8 @@ export async function main(event: string): Promise { // 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') { diff --git a/src/claude/note-signals.ts b/src/claude/note-signals.ts index 79a84320b..79ca55840 100644 --- a/src/claude/note-signals.ts +++ b/src/claude/note-signals.ts @@ -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. @@ -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; @@ -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 = @@ -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/; @@ -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()) { @@ -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 \`. 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}`; } diff --git a/src/claude/pre-bash-check.ts b/src/claude/pre-bash-check.ts new file mode 100644 index 000000000..3b6f62f87 --- /dev/null +++ b/src/claude/pre-bash-check.ts @@ -0,0 +1,116 @@ +/** + * Before `git commit`, `git push` or `gh pr create`: `trail check` on the + * branch, so the agent doesn't have to remember to run it. A conflict with a + * learning stops the command once, with what the check found. The agent fixes + * the change, or says why it's right in the commit message or PR and runs the + * same command again, which goes through; a conflict it hasn't seen yet stops + * it again. Loaded by pre-bash.ts only for those commands. + * + * Bounded by CHECK_BUDGET_MS, well inside the 5 s the hook is given, and fails + * open: a check that is slow, broken or has nothing to say lets the command run. + * Like the rest of the PreToolUse path it loads only builtins and small files: + * the check runs in a child, and the mark that lets the second try through is + * a file beside the session's gate file. + */ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { brand } from '../brand.js'; +import { sessionPath } from '../trace/gate-file.js'; +import type { PreBashDecision } from './pre-bash.js'; + +/** One conflict as `trail check --json` reports it (check/types.ts CheckFinding, the fields used here). */ +export interface ShipConflict { + file: string; + line?: number; + verdict: string; + summary: string; + source: { title?: string; author?: string; path?: string }; +} + +/** How long the check may take before the command runs without it. */ +export const CHECK_BUDGET_MS = 3500; + +/** The words the reason starts with, which the Stop hook's `overrode` sign looks for (note-signals.ts). */ +export const SHIP_CHECK_LEAD = 'trail check found'; + +/** The conflicts `trail check --json` reports for the branch at `dir`, or null when it couldn't say in time. */ +export function branchConflicts(dir: string, env: NodeJS.ProcessEnv = process.env): ShipConflict[] | null { + try { + // dist/cli.js beside this file's folder (paths.ts graftCliPath, without loading it here). + const cli = join(dirname(fileURLToPath(import.meta.url)), '..', 'cli.js'); + const r = spawnSync(process.execPath, [cli, 'check', '--json'], { + cwd: dir, + encoding: 'utf8', + timeout: CHECK_BUDGET_MS, + // No fetch inside a hook, no hooks of its own, and the trail command whatever name ran this. + env: { ...env, TRAIL_NO_FETCH: '1', TRAIL_NO_HOOKS: '1', TRAIL_INVOKED_AS: 'trail' }, + windowsHide: true, + }); + if (r.error || typeof r.stdout !== 'string' || !r.stdout.trim().startsWith('{')) return null; + const out = JSON.parse(r.stdout) as { findings?: ShipConflict[] }; + return (out.findings ?? []).filter((f) => f.verdict === 'conflict'); + } catch { + return null; + } +} + +/** What the command is about to do, in the reason's words. */ +function shipping(command: string): string { + return /\bgh\s+pr\s+create\b/.test(command) ? 'opening this PR' : /\bgit\s+push\b/.test(command) ? 'this push' : 'this commit'; +} + +/** The reason the agent reads: each conflict, then the way through. */ +export function shipReason(conflicts: ShipConflict[], command: string): string { + const one = conflicts.length === 1; + const lines = conflicts.slice(0, 5).map((f) => { + const who = [f.source.title, f.source.author].filter(Boolean).join(', '); + return `- ${f.file}${f.line ? `:${f.line}` : ''}: ${f.summary}${who ? ` (${who})` : ''}`; + }); + const old = conflicts.map((f) => f.source.path).find(Boolean); + return [ + `${SHIP_CHECK_LEAD} ${conflicts.length} conflict${one ? '' : 's'} with the team's learnings before ${shipping(command)}:`, + ...lines, + `Fix the change, or if it's right anyway, say why in the commit message or PR description and run the same command again: it goes through then.` + + (old ? ` If the learning no longer holds, save a new learning that says what changed, with \`--replaces ${old.split('/').pop()}\`.` : ''), + ].join('\n'); +} + +/** The session a payload names, as a file name: Claude Code's `session_id`, else 'default' (hook-io.ts payloadSessionId, without its load cost). */ +function sessionOf(input: unknown): string { + const p = input && typeof input === 'object' ? (input as Record) : {}; + const raw = [p.session_id, p.sessionId, p.conversation_id].find((v): v is string => typeof v === 'string' && v !== ''); + return raw && /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,127}$/.test(raw) ? raw : 'default'; +} + +/** + * The decision for a commit, push or PR command: deny once per set of + * conflicts in a session, null otherwise. `check` is the branch's conflicts + * (tests pass a stand-in). + */ +export function shipCheck(input: unknown, dir: string, command: string, check: (dir: string) => ShipConflict[] | null = branchConflicts, env: NodeJS.ProcessEnv = process.env): PreBashDecision | null { + try { + if (brand(env) !== 'trail') return null; + const conflicts = check(dir); + if (!conflicts?.length) return null; + const key = createHash('sha256') + .update(conflicts.map((f) => `${f.file}|${f.source.path ?? f.source.title ?? ''}`).sort().join('\n')) + .digest('hex') + .slice(0, 16); + const mark = sessionPath(sessionOf(input), '.ship-checks.json', env); + let seen: string[] = []; + try { + seen = JSON.parse(readFileSync(mark, 'utf8')) as string[]; + } catch { + /* none yet */ + } + if (seen.includes(key)) return null; + mkdirSync(dirname(mark), { recursive: true }); + writeFileSync(mark, JSON.stringify([...seen, key].slice(-20))); + return { permissionDecision: 'deny', reason: shipReason(conflicts, command) }; + } catch { + return null; + } +} diff --git a/src/claude/pre-bash.ts b/src/claude/pre-bash.ts index ea6fd2542..386b1e1da 100644 --- a/src/claude/pre-bash.ts +++ b/src/claude/pre-bash.ts @@ -38,6 +38,9 @@ const DECISIONS = new Set(['allow', 'ask', 'deny']); * The decision for one Bash call, or null to let it run untouched. Phase 0: * always null. Never throws. */ +/** A commit, a push or a PR: the commands `trail check` runs before (pre-bash-check.ts). One regex for every other command. */ +export const SHIP_COMMAND = /\b(?:git\s+(?:commit|push)|gh\s+pr\s+create)\b/; + export function preBash(_input: unknown, _dir: string): PreBashDecision | null { return null; } @@ -247,6 +250,14 @@ export async function main(event = 'pre-bash', decide: (input: unknown, dir: str // Registered in the repo and in ~/.claude, Claude Code runs both: one // answers. Copilot CLI runs only the repo's, so the payload decides first. const standsDown = async () => (await import('./hook-layers.js')).globalCopyCovers(event, process.argv[1], undefined, undefined, input); + if (SHIP_COMMAND.test(command)) { + // It writes (the session's checked mark), so only the copy that answers runs it. + if (await standsDown()) return; + const { shipCheck } = await import('./pre-bash-check.js'); + const decision = shipCheck(input, dir, command); + const reply = preBashOutput(input, dir, () => decision); + if (reply) return void process.stdout.write(reply); + } if (skillsPrefilter(input, command)) { // The skills part writes (receipts, asked marks): only the copy that answers runs it. if (await standsDown()) return; diff --git a/src/claude/prompt-hook.ts b/src/claude/prompt-hook.ts index ae44a4131..0d0251cd8 100644 --- a/src/claude/prompt-hook.ts +++ b/src/claude/prompt-hook.ts @@ -15,6 +15,7 @@ import { homedir } from 'node:os'; import { brand, tag } from '../brand.js'; import { scopeOf, scopesOfGraph } from '../graph/scopes.js'; import { keepsNotes } from '../notes/home.js'; +import { hookNotice, linkedNews } from '../cloud/nudge.js'; import { findNotes, listNotes, type NoteHit } from '../notes/notes.js'; import { INJECTED_POINTERS_CAP, notesForPrompt, relevantRetrieval, type AskJson } from './format.js'; import { CHILD_TIMEOUT_MS, emitHook, graftJsonWithin, payloadSessionId, withContextDirArg } from './hook-io.js'; @@ -263,7 +264,10 @@ export async function handlePrompt(input: any, dir: string, opts: { budgetMs?: n recordSkillUse(skillOfPrompt(input?.prompt), input, dir); const skills = await promptPart(input, dir); if (skills.block) return void emitHook('UserPromptSubmit', { block: skills.block }); - const say = (txt: string) => emitHook('UserPromptSubmit', { context: [skills.context, txt].filter(Boolean).join('\n\n'), notice: skills.notice }); + // That this machine just connected reaches the person on the next prompt, whatever it is. + const news = linkedNews(); + const say = (txt: string, offer?: string | null) => + emitHook('UserPromptSubmit', { context: [skills.context, txt].filter(Boolean).join('\n\n'), notice: [skills.notice, news ?? offer].filter(Boolean).join('\n') || undefined }); const prompt = promptWords(input?.prompt); if (prompt.length < MIN_CUE_CHARS) return say(''); const underTrail = brand() === 'trail'; @@ -286,7 +290,9 @@ export async function handlePrompt(input: any, dir: string, opts: { budgetMs?: n // A count, never the prompt: nothing the person typed is kept (spec section 6). if (ask) s.promptAsks = (s.promptAsks ?? 0) + 1; // Teammates' branches on the same code come after an edit instead (hooks.ts editOverlap). - say([notes, retrieval].filter(Boolean).join('\n\n')); + // A teammate's learning just reached the agent: the moment the person gets the workspace link. + const offer = notes ? hookNotice(dir, "Claude just got your team's learnings for this", { session: id }) : null; + say([notes, retrieval].filter(Boolean).join('\n\n'), offer); if (ask || notes || said.size) writeSession(dir, id, s); } diff --git a/src/claude/settings-merge.ts b/src/claude/settings-merge.ts index 70a9b9f09..6731d9700 100644 --- a/src/claude/settings-merge.ts +++ b/src/claude/settings-merge.ts @@ -88,7 +88,11 @@ function hookBlocks(command: (arg: string) => string): Record { // command that fails open too (see failOpen). Claude Code reads `timeout` // in seconds, so 5 caps the wait a wedged hook could add to one command, // after which the command runs anyway. - PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: hookCmd_('pre-bash'), timeout: 5 }] }], + PreToolUse: [ + // The learnings on a file, before the agent changes it: while they can still change the edit. + { matcher: 'Write|Edit|MultiEdit', hooks: [{ type: 'command', command: hookCmd_('pre-edit'), timeout: 5 }] }, + { matcher: 'Bash', hooks: [{ type: 'command', command: hookCmd_('pre-bash'), timeout: 5 }] }, + ], // A Bash call that failed fires this, never PostToolUse (D34): learned // skills mark the outcome for the next catch-up. Fails open like // PreToolUse, since some hosts read a non-zero exit as a refusal. diff --git a/src/claude/skill-template.ts b/src/claude/skill-template.ts index 62145cf4c..422a26460 100644 --- a/src/claude/skill-template.ts +++ b/src/claude/skill-template.ts @@ -38,9 +38,18 @@ const TRAIL_DESCRIPTION = `Use for ANY task in this repo. What past sessions wor /** 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\`. Learnings - from past sessions, yours and your teammates', on what was decided, tried - and ruled out print above the code. Read them first. +- **Before exploring code**, run \`trail ask "" --source\`, even for a + plain "where is" or "how does this work" question, before you grep or open + files. Learnings from past sessions, yours and your teammates', on what was + decided, tried and ruled out print above the code. Read them first. +- **When you start from a ticket, an issue or a bug report**, run + \`trail ask\` with its words before you plan, so you start from what was + already decided about that area. +- **When a test or command fails while you debug**, run \`trail ask\` on that + area before you try a fix: a past session may have tried and ruled out the + fix you're about to try. +- **Before you change a file**, trail puts the learnings on it in front of + you. Read them before you write the edit. - **When the session finds what the code can't tell the next person**, save a learning with \`trail note\`, and commit it with the change. What counts is under Saving a learning below. diff --git a/src/claude/skill-use.ts b/src/claude/skill-use.ts index 2cbdaddc4..9fb824283 100644 --- a/src/claude/skill-use.ts +++ b/src/claude/skill-use.ts @@ -14,7 +14,7 @@ import { payloadSessionId } from "./hook-io.js"; import { readSession, writeSession } from "./state.js"; import { repoPlace } from "../notes/home.js"; import { noteAuthor } from "../notes/git-facts.js"; -import { readCloud, type Cloud } from "../cloud/workspace.js"; +import { cloudIn, readClouds, type Cloud } from "../cloud/workspace.js"; import { recordActivity, records } from "../cloud/workspace-sync.js"; /** The same skill again within this long in one session is the same run (the person typed `/name`, then the agent loaded it). */ @@ -44,10 +44,11 @@ export function recordSkillUse(name: string, input: unknown, dir: string, opts: // Most tool calls and prompts aren't a skill: they stop here, before any file is read. if (!name) return false; try { - const cloud = opts.cloud === undefined ? readCloud() : opts.cloud; + const clouds = opts.cloud === undefined ? readClouds() : opts.cloud ? [opts.cloud] : []; const now = opts.now ?? Date.now(); - if (!cloud) return false; + if (!clouds.length) return false; const place = repoPlace(dir); + const cloud = cloudIn(clouds, place.key); if (!records(cloud, place.key)) return false; if (!existsSync(join(place.checkout, ".claude", "skills", name, "SKILL.md"))) return false; const session = payloadSessionId(input); diff --git a/src/cli.ts b/src/cli.ts index ba12ce6be..9d147363e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -74,7 +74,7 @@ import { gateNote, namesRepoSymbol, symbolCandidates, trackedFiles } from "./not import { recordLookup } from "./cloud/lookup-activity.js"; import { clearPending, cloudLines, loginLines, loginLink, sessionKey, startWaiting, WAIT_MS, waitForConnect } from "./cloud/nudge.js"; import { teamWorkspace } from "./cloud/team-file.js"; -import { addRepo, claimLoginCode, clearCloud, cloudEnabled, failPair, fetchMe, fetchTeam, isError, readCloud, serverUrl, trailUrl, useServer, writeCloud, type Cloud, type SetupFailure } from "./cloud/workspace.js"; +import { addRepo, claimLoginCode, clearCloud, cloudEnabled, cloudForRepo, cloudIn, failPair, fetchMe, fetchTeam, isError, readCloud, readClouds, sameWorkspace, serverUrl, trailUrl, useServer, writeCloud, type Cloud, type Me, type SetupFailure } from "./cloud/workspace.js"; import { claimConnectCode, connectAgain, @@ -2204,7 +2204,9 @@ grouped(program.command("uninstall"), GROUP.setup) * terminal it opens the browser and waits. Run by an agent, it prints the * link for the agent's reply and leaves a background wait to finish it, so * the agent never sits blocked. `--workspace` (or the repo's - * `.trail/workspace.json`) picks the workspace on that page. + * `.trail/workspace.json`) picks the workspace on that page. A laptop + * already signed in links another repo the same way, to the same + * workspace or another one: each repo goes to one workspace. * * `--code` skips the browser: Trail's web app made the one-time code for the * person already signed in there, so the laptop trades it for its token @@ -2231,10 +2233,12 @@ function loginCommand(name = "login"): Command { return; } const slug = opts.workspace?.trim().toLowerCase() || (server ? undefined : teamWorkspace(repo)?.workspace); - const cloud = readCloud(); + // The workspace this repo goes to: a laptop signed in elsewhere still links this one. + const here = workspaceRepo(repo); + const cloud = here.git ? cloudForRepo(here.key) : readCloud(); if (cloud) { if (slug && slug !== cloud.workspace.slug) { - console.error(`✗ this laptop is signed in to workspace ${cloud.workspace.name} · run ${cmd("graft logout")}, then ${cmd("graft login")} --workspace ${slug}`); + console.error(`✗ this repo goes to workspace ${cloud.workspace.name} · run ${cmd("graft logout")} --workspace ${cloud.workspace.slug}, then ${cmd("graft login")} --workspace ${slug}`); process.exitCode = 1; return; } @@ -2274,7 +2278,7 @@ async function loginWithCode(repo: string, code: string, base: string): Promise< } const cloud: Cloud = { ...got.cloud, url: base, linkedAt: Date.now() }; if (before && (before.workspace.slug !== cloud.workspace.slug || before.url !== base)) { - console.error(`· this laptop was signed in to workspace ${before.workspace.name}; it's now ${cloud.workspace.name}`); + console.error(`· this laptop is also signed in to workspace ${before.workspace.name}; each repo goes to one of them (${cmd("graft workspace")})`); } // Nothing recorded before signing in goes up. if (!before) dropActivity(); @@ -2314,9 +2318,24 @@ function addAgentEntries(): void { function logoutCommand(name = "logout"): Command { return new Command(name) - .description("Disconnect this machine from its Trail workspace (learnings stay in your repos)") - .action(() => { - const cloud = readCloud(); + .description("Disconnect this machine from its Trail workspaces (learnings stay in your repos)") + .option("--workspace ", "disconnect only this workspace (trail workspace lists them)") + .action((opts: { workspace?: string }) => { + const all = readClouds(); + if (opts.workspace !== undefined) { + const gone = all.find((c) => c.workspace.slug === opts.workspace); + if (!gone || !clearCloud(process.env, opts.workspace)) { + console.error(`✗ not connected to a workspace "${opts.workspace}" · ${cmd("graft workspace")} lists them`); + process.exitCode = 1; + return; + } + if (all.length === 1) { + clearPending(); + removeBrainsServer(homedir()); + } + console.error(`✓ disconnected from workspace ${gone.workspace.name} · learnings stay in your repos' .trail/`); + return; + } clearPending(); removeBrainsServer(homedir()); removeCursorBrainsServer(homedir()); @@ -2324,7 +2343,8 @@ function logoutCommand(name = "logout"): Command { console.error("· not connected to a workspace"); return; } - console.error(`✓ disconnected from workspace ${cloud?.workspace.name ?? ""} · learnings stay in your repos' .trail/`.replace(" ", " ")); + const names = all.map((c) => c.workspace.name).join(", "); + console.error(`✓ disconnected from workspace${all.length > 1 ? "s" : ""} ${names} · learnings stay in your repos' .trail/`.replace(" ", " ")); }); } @@ -2336,49 +2356,66 @@ function workspaceRepo(repo: string): { key: string; name: string; checkout: str function workspaceCommand(name = "workspace"): Command { const ws = new Command(name) - .description("Your Trail workspace: its repos, the team page and the invite link") + .description("Your Trail workspaces: their repos, the team page and the invite link") .argument("[dir]", "repository root", ".") .option("--json", "output as JSON") .action(async (dir: string, opts: { json?: boolean }) => { const repo = resolve(dir); - const cloud = readCloud(); - if (!cloud) { + const all = readClouds(); + if (!all.length) { console.log("· not connected to a workspace"); console.log(`· Claude offers a link once this repo has learnings, or run ${cmd("graft login")}`); return; } - const me = await fetchMe(cloud); - if (isError(me)) { - console.error(`✗ ${me.error}`); - process.exitCode = 1; - return; + const here = workspaceRepo(repo); + const seen: Array<{ cloud: Cloud; me: Me }> = []; + for (const cloud of all) { + const me = await fetchMe(cloud); + if (isError(me)) { + console.error(`✗ ${all.length > 1 ? `${cloud.workspace.name}: ` : ""}${me.error}`); + process.exitCode = 1; + continue; + } + // What the workspace has now, for every command that reads cloud.json. + const fresh = { ...cloud, workspace: me.workspace, repos: me.repos.map((r) => r.key) }; + writeCloud(fresh); + seen.push({ cloud: fresh, me }); } - // What the workspace has now, for every command that reads cloud.json. - writeCloud({ ...cloud, workspace: me.workspace, repos: me.repos.map((r) => r.key) }); + if (!seen.length) return; + // The workspace this repo goes to, else the newest; every one when there are several. + const mine = here.git ? cloudIn(seen.map((x) => x.cloud), here.key) : null; if (opts.json) { - console.log(JSON.stringify(me, null, 2)); + const pick = seen.find((x) => mine && sameWorkspace(x.cloud, mine)) ?? seen[0]; + console.log(JSON.stringify(seen.length > 1 ? { ...pick.me, workspaces: seen.map((x) => x.me.workspace) } : pick.me, null, 2)); return; } - console.log(`${me.workspace.name} · ${me.workspace.url}`); - console.log(`invite ${me.workspace.invite_url}`); - console.log(""); - const here = workspaceRepo(repo); - const width = Math.max(...me.repos.map((r) => r.name.length), here.name.length) + 3; - for (const r of me.repos) { - const by = r.added_by ? ` · added by ${r.added_by}` : ""; - console.log(` ✓ ${r.name.padEnd(width)}${r.learnings} learning${r.learnings === 1 ? "" : "s"}${by}`); - } - if (here.git && !me.repos.some((r) => r.key === here.key)) { - console.log(` · ${here.name.padEnd(width)}not in the workspace · ${cmd("graft workspace add")} shares its learnings`); + let width = here.name.length + 3; + seen.forEach(({ cloud, me }, i) => { + if (i > 0) console.log(""); + console.log(`${me.workspace.name}${seen.length > 1 ? ` (${me.workspace.slug})` : ""} · ${me.workspace.url}`); + console.log(`invite ${me.workspace.invite_url}`); + console.log(""); + width = Math.max(...me.repos.map((r) => r.name.length), here.name.length) + 3; + for (const r of me.repos) { + const by = r.added_by ? ` · added by ${r.added_by}` : ""; + // A repo in two workspaces goes to one from this machine: the newest link. + const elsewhere = seen.length > 1 && cloudIn(seen.map((x) => x.cloud), r.key) !== cloud ? " · goes to another workspace from here" : ""; + console.log(` ✓ ${r.name.padEnd(width)}${r.learnings} learning${r.learnings === 1 ? "" : "s"}${by}${elsewhere}`); + } + }); + if (here.git && !mine) { + const where = seen.length > 1 ? "in any of your workspaces" : "in the workspace"; + console.log(` · ${here.name.padEnd(width)}not ${where} · ${cmd("graft workspace add")}${seen.length > 1 ? " --to " : ""} shares its learnings`); } else if (here.git) printLines(leftOutLines(here.checkout)); }); ws.command("add") - .description("Add this repo to your workspace and share its learnings") + .description("Add this repo to a workspace and share its learnings") .argument("[dir]", "repository root", ".") - .action(async (dir: string) => { + .option("--to ", "the workspace, when this machine is in more than one") + .action(async (dir: string, opts: { to?: string }) => { const repo = resolve(dir); - const cloud = readCloud(); - if (!cloud) { + const all = readClouds(); + if (!all.length) { console.error(`✗ not connected to a workspace · run ${cmd("graft login")}`); process.exitCode = 1; return; @@ -2389,6 +2426,20 @@ function workspaceCommand(name = "workspace"): Command { process.exitCode = 1; return; } + const cloud = opts.to !== undefined ? all.find((c) => c.workspace.slug === opts.to) : all.length === 1 ? all[0] : undefined; + if (!cloud) { + if (opts.to !== undefined) console.error(`✗ not connected to a workspace "${opts.to}" · ${cmd("graft workspace")} lists them`); + else console.error(`✗ this machine is in ${all.length} workspaces · pick one: ${cmd("graft workspace add")} --to <${all.map((c) => c.workspace.slug).join("|")}>`); + process.exitCode = 1; + return; + } + // One workspace per repo on this machine: moving it is a logout of the other first. + const now = cloudIn(all, here.key); + if (now && !sameWorkspace(now, cloud)) { + console.error(`✗ ${here.name} already goes to workspace ${now.workspace.name} · ${cmd("graft logout")} --workspace ${now.workspace.slug} first to send it to ${cloud.workspace.name}`); + process.exitCode = 1; + return; + } const added = await addRepo(cloud, { key: here.key, name: here.name }); if (isError(added)) { console.error(`✗ ${added.error}`); @@ -2451,13 +2502,14 @@ function teamCommand(name = "team"): Command { .action(async (dir: string, opts: { days: string; json?: boolean }) => { const repo = resolve(dir); const days = Math.max(1, Math.min(90, Number(opts.days) || 7)); - const cloud = readCloud(); + const here = workspaceRepo(repo); + // The workspace this repo goes to, else the newest one this machine is in. + const cloud = (here.git ? cloudForRepo(here.key) : null) ?? readCloud(); if (!cloud) { localTeam(repo, days, opts.json === true); return; } maybeSyncInBackground(repo); - const here = workspaceRepo(repo); const t = await fetchTeam(cloud, { repo: cloud.repos.includes(here.key) ? here.key : undefined, days }); if (isError(t)) { console.error(`✗ ${t.error}`); @@ -2694,7 +2746,9 @@ function statusCommand(name = "status"): Command { const inRepo = all.filter((n) => n.scope === "repo" && !n.ref).length; const onBranches = all.filter((n) => n.ref).length; const personal = all.filter((n) => n.scope === "personal").length; - const cloud = readCloud(); + const clouds = readClouds(); + // The workspace this repo goes to, else the newest one this machine is in. + const cloud = cloudIn(clouds, repoPlace(repo).key) ?? clouds[0] ?? null; const shared = cloud ? inWorkspace(repo, cloud) : false; const saved = savedThisWeek(repo); @@ -2731,7 +2785,7 @@ function statusCommand(name = "status"): Command { } else row( "workspace", - `${cloud.workspace.name} · ${cloud.repos.length} repo${cloud.repos.length === 1 ? "" : "s"}${shared ? ", this one included" : `, not this one (${cmd("graft workspace add")})`} · ${cloud.workspace.url}`, + `${cloud.workspace.name} · ${cloud.repos.length} repo${cloud.repos.length === 1 ? "" : "s"}${shared ? ", this one included" : `, not this one (${cmd("graft workspace add")})`} · ${cloud.workspace.url}${clouds.length > 1 ? ` · ${clouds.length - 1} more workspace${clouds.length === 2 ? "" : "s"} (${cmd("graft workspace")})` : ""}`, ); row("this week", saved > 0 ? `~${shortCount(saved)} tokens saved` : "nothing saved yet"); // Phase 2 (4.17, D137): Codex's trust of Trail's hooks, and why it keeps nothing new there yet. diff --git a/src/cloud/lookup-activity.ts b/src/cloud/lookup-activity.ts index 972e3c122..5cb5159c3 100644 --- a/src/cloud/lookup-activity.ts +++ b/src/cloud/lookup-activity.ts @@ -8,7 +8,7 @@ import { repoPlace } from "../notes/home.js"; import { noteAuthor } from "../notes/git-facts.js"; import { noteName } from "../notes/notes.js"; import { sessionKey } from "./nudge.js"; -import { readCloud, type Cloud } from "./workspace.js"; +import { cloudForRepo, type Cloud } from "./workspace.js"; import { recordActivity, records } from "./workspace-sync.js"; /** The file part of a hit's `file:Lx-Ly` pointer. */ @@ -25,12 +25,14 @@ function pointerFile(pointer: string): string { * from a pack the caller already rendered (see formatAskWithSaved), so the * lookup isn't rendered a second time. Never throws. */ -export function recordLookup(r: AskResult, repo: string, cloud: Cloud | null = readCloud(), savedTokens?: number): void { +export function recordLookup(r: AskResult, repo: string, cloud?: Cloud | null, savedTokens?: number): void { try { // Cheapest checks first: most lookups are on a machine that isn't connected, // or in a repo outside the workspace, and must cost nothing here. const key = repoPlace(repo).key; - if (!records(cloud, key)) return; + // Unless told, the workspace this machine sends the repo to. + const to = cloud === undefined ? cloudForRepo(key) : cloud; + if (!records(to, key)) return; const shown = (r.notes ?? []).filter((h) => h.note.scope !== "personal"); const saved = savedTokens ?? askSavedTokens(r); if (!shown.length && saved <= 0) return; @@ -49,7 +51,7 @@ export function recordLookup(r: AskResult, repo: string, cloud: Cloud | null = r ...(saved > 0 ? { saved_tokens: saved } : {}), at: new Date().toISOString(), }, - cloud, + to, ); } catch { /* a lost event undercounts by one */ diff --git a/src/cloud/nudge.ts b/src/cloud/nudge.ts index c76214850..4395c0ccb 100644 --- a/src/cloud/nudge.ts +++ b/src/cloud/nudge.ts @@ -13,8 +13,10 @@ * Then it saves the connection, uploads that repo's committed learnings, and * leaves the news for the next trail command, which says so once. * - * Connected, a repo with learnings that isn't in the workspace gets one - * question, ever: add it? + * A machine can be in several workspaces. Signed in already, a repo with + * learnings that no workspace here has gets the same link, worded for linking: + * Trail opens signed in, and the person picks the workspace it goes to (one + * of theirs, a teammate's, or a new one). */ import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; @@ -27,7 +29,7 @@ import { durationBucket } from "../telemetry/contract.js"; import { track } from "../telemetry/track.js"; import { ensurePrivateDir } from "../util/private-file.js"; import { readJson, writeJsonAtomic } from "../util/state.js"; -import { claimHandoff, readCloud, registerHandoff, SECRET_FILE_MODE, serverUrl, trailUrl, writeCloud, type ClaimResult, type HandoffRepo } from "./workspace.js"; +import { claimHandoff, cloudForRepo, cloudIn, readClouds, registerHandoff, SECRET_FILE_MODE, serverUrl, trailUrl, writeCloud, type ClaimResult, type HandoffRepo } from "./workspace.js"; import { dropActivity, syncWorkspaceRepos } from "./workspace-sync.js"; /** What the link says, in turn: one per session, so it never reads the same twice running. */ @@ -45,7 +47,8 @@ export const WAIT_MS = 20 * 60 * 1000; /** Without a session id, commands this close together count as one session. */ export const SESSION_GAP_MS = 4 * 60 * 60 * 1000; -export type NudgeCommand = "ask" | "note" | "check"; +/** What offered the link: a trail command, or a hook that just did trail's work for the person. */ +export type NudgeCommand = "ask" | "note" | "check" | "hook"; interface Pending { /** The state in the link, which is what claims the connection. */ @@ -70,8 +73,6 @@ interface NudgeState { pending?: Pending; /** Connected by the background wait, and not yet told to the agent. */ linked?: { workspace: string; url: string; repos: number; learnings: number }; - /** Repos already asked about adding to the workspace, by key. */ - asked?: Record; /** A paired setup's connect not yet made: `_trail-pair` waits on it until `waitUntil`, then the next command waits again. */ pair?: PendingPair; } @@ -165,22 +166,21 @@ export function cloudLines(repo: string, opts: NudgeOptions): string[] { const agent = opts.agent ?? !process.stdout.isTTY; resumePair(env, now); const st = readState(env); - const cloud = readCloud(env); - if (!cloud && env.TRAIL_CLOUD !== "1") return []; + const clouds = readClouds(env); + if (!clouds.length && env.TRAIL_CLOUD !== "1") return []; - if (cloud) { - if (st.linked) { - writeState({ linked: undefined }, env); - return connectedLines(st.linked, agent); - } - // Connected from the onboarding: only summaries go up, so there's no repo to add. - if (cloud.via === "connect") return []; - return addRepoLines(repo, cloud.workspace.name, cloud.repos, st, agent, env, now); + if (st.linked) { + writeState({ linked: undefined }, env); + return connectedLines(st.linked, agent); } // A repo with no remote has no name a workspace could know it by. const place = repoPlace(repo, env); if (!place.git || place.key.startsWith("local/") || !keepsNotes(repo, env)) return []; + // Linked already: its learnings go to that workspace. + if (cloudIn(clouds, place.key)) return []; + // Connected from the onboarding only: those send summaries, never a repo's learnings. + if (clouds.length && clouds.every((c) => c.via === "connect")) return []; const startWait = opts.startWait ?? (() => startWaiting(env)); // A link already out keeps being waited on, whether or not this command offers it. const pending = livePending(st, now); @@ -191,18 +191,62 @@ export function cloudLines(repo: string, opts: NudgeOptions): string[] { const session = sessionKey(repo, env, now); if (offeredThisSession(st, session, now)) return []; - const link = pending ?? { state: newState(), createdAt: now, repo: place.key, server: trailUrl(env) }; + // One link per repo: one made in another repo would connect that one. + const link = pending && (pending.repo ?? place.key) === place.key ? pending : { state: newState(), createdAt: now, repo: place.key, server: trailUrl(env) }; const shown = st.shown ?? 0; const i = shown % NUDGE_WORDS.length; writeState({ session: session ?? undefined, at: now, shown: shown + 1, pending: link }, env); startWait(); - track("brain_signup_opened", { mode: "nudge", command: opts.command, wording: WORDING_IDS[i] }, { repo }); - return nudgeLines(NUDGE_WORDS[i], connectUrl(link.state, linkServer(link, env)), agent); + const signedIn = clouds.length > 0; + track("brain_signup_opened", { mode: "nudge", command: opts.command, wording: signedIn ? "link" : WORDING_IDS[i] }, { repo }); + const url = connectUrl(link.state, linkServer(link, env)); + if (signedIn) return linkLines(repoName(place.key, place.checkout), url, agent); + return nudgeLines(NUDGE_WORDS[i], url, agent); } catch { return []; } } +/** + * The same offer, as a hook's notice for the person, at a moment a trail hook + * just did something for them (`why`: a teammate's learning put in front of + * the agent). It doesn't wait for the agent to run a trail command, which it + * often doesn't. Once a session, on cloudLines' rules, with the session told + * apart by the hook's payload; the news that this machine just connected goes + * the same way. Null when there's nothing to say. Never throws. + */ +export function hookNotice(repo: string, why: string, opts: { session?: string; env?: NodeJS.ProcessEnv; now?: number; startWait?: () => void } = {}): string | null { + try { + const env = opts.env ?? process.env; + const lines = cloudLines(repo, { + command: "hook", + agent: false, + env: opts.session ? { ...env, CLAUDE_CODE_SESSION_ID: opts.session } : env, + now: opts.now, + startWait: opts.startWait, + }); + if (!lines.length) return null; + // The connected news says what happened already; the offer gets why it comes now. + if (lines[0]!.startsWith("✓")) return lines.join("\n"); + return `${brand()} · ${why} · ${lines[0]!.replace(/^· /, "")}`; + } catch { + return null; + } +} + +/** The news that this machine just connected, once, for the person: on the next prompt rather than the next trail command. */ +export function linkedNews(env: NodeJS.ProcessEnv = process.env): string | null { + try { + if (brand() !== "trail") return null; + const st = readState(env); + if (!st.linked) return null; + writeState({ linked: undefined }, env); + return connectedLines(st.linked, false).join("\n"); + } catch { + return null; + } +} + /** The link, worded for whoever reads the output. */ export function nudgeLines(words: string, url: string, agent: boolean): string[] { if (!agent) return [`· ${words} ${url}`]; @@ -225,16 +269,13 @@ function connectedLines(l: NonNullable, agent: boolean): s return lines; } -/** The one question about a repo that has learnings and isn't in the workspace. */ -function addRepoLines(repo: string, workspace: string, repos: string[], st: NudgeState, agent: boolean, env: NodeJS.ProcessEnv, now: number): string[] { - const place = repoPlace(repo, env); - if (!place.git || place.key.startsWith("local/") || repos.includes(place.key) || st.asked?.[place.key]) return []; - if (noteCount(repo, env) === 0) return []; - writeState({ asked: { ...(st.asked ?? {}), [place.key]: now } }, env); - const name = repoName(place.key, place.checkout); - if (!agent) return [`· ${name} isn't in workspace ${workspace} yet · ${brand()} workspace add shares its learnings with your team`]; +/** The link for a repo no workspace on this machine has, when already signed in. */ +export function linkLines(name: string, url: string, agent: boolean): string[] { + const words = `Pick a Trail workspace for ${name}`; + if (!agent) return [`· ${name} isn't in any of your Trail workspaces · pick one: ${url}`]; return [ - `[${brand()}] Ask the user once, at the end of your reply: add ${name} to their Trail workspace ${workspace}, so their team sees what's learned here? If they say yes, run \`${brand()} workspace add\`. Don't ask again.`, + `[${brand()}] End your reply with this line, word for word: [${words}](${url})`, + ` This repo's learnings aren't going to any Trail workspace yet. The link opens Trail signed in, to pick where they go: one of the user's workspaces, a teammate's, or a new one. Offer it; don't run ${brand()} login yourself.`, ]; } @@ -278,7 +319,7 @@ export function resumePair(env: NodeJS.ProcessEnv = process.env, now = Date.now( try { const p = readState(env).pair; if (!p) return; - if (now - p.createdAt >= LINK_TTL_MS || readCloud(env)) { + if (now - p.createdAt >= LINK_TTL_MS || cloudForRepo(repoPlace(p.repo, env).key, env)) { writeState({ pair: undefined }, env); return; } @@ -322,8 +363,8 @@ export function startWaiting(env: NodeJS.ProcessEnv = process.env, now = Date.no * What the picker may list before anyone has opened the link: the one repo it * was offered in, with how many learnings it has here. Never this machine's * other repos: the link sits in an agent's reply, and nobody agreed yet to - * tell Trail what else is on the laptop. The rest are offered one at a time - * once connected (`addRepoLines`). + * tell Trail what else is on the laptop. The rest get their own link, one at + * a time, in the repo they're in (`linkLines`). */ export function pickerRepos(repoKey: string | undefined, env: NodeJS.ProcessEnv = process.env): HandoffRepo[] { const r = repoKey ? knownRepos(env).find((k) => k.key === repoKey) : undefined; @@ -356,33 +397,42 @@ export async function waitForConnect(opts: WaitOptions = {}): Promise new Promise((r) => setTimeout(r, ms))); const started = now(); - const st = readState(env); - const pending = livePending(st, started); const settled = (outcome: string) => track("brain_signup_settled", { outcome, mode: "nudge", duration_bucket: durationBucket(now() - started) }, {}); const done = (patch: Partial = {}) => writeState({ waitingSince: undefined, ...patch }, env); + // A link for a repo that's linked since has nothing left to wait for. + const live = (): Pending | null => { + const p = livePending(readState(env), now()); + return p && !(p.repo && cloudForRepo(p.repo, env)) ? p : null; + }; try { - if (!pending || readCloud(env)) { + let pending = live(); + if (!pending) { done(); return null; } // The Trail the link was made for, and only that one: nothing in the environment moves it. - const base = linkServer(pending, env); - const repos = pickerRepos(pending.repo, env); - const current = repos[0]; - // No name either: whoever opens the link would see it before signing in. - const reg = await registerHandoff( - base, - { state: pending.state, repo: current ? { key: current.key, name: current.name } : null, repos, author: "" }, - opts.fetchImpl, - ); - if ("url" in reg) opts.onRegistered?.(reg.url); + const register = async (p: Pending): Promise => { + const base = linkServer(p, env); + const repos = pickerRepos(p.repo, env); + const current = repos[0]; + // No name either: whoever opens the link would see it before signing in. + const reg = await registerHandoff( + base, + { state: p.state, repo: current ? { key: current.key, name: current.name } : null, repos, author: "" }, + opts.fetchImpl, + ); + if ("url" in reg) opts.onRegistered?.(reg.url); + return base; + }; + let base = await register(pending); for (;;) { const got = await claimHandoff(base, pending.state, opts.fetchImpl); if ("cloud" in got) { const cloud = { ...got.cloud, url: base, linkedAt: now() }; - // Nothing from before signing in goes up: an older trail queued uses - // whether or not anyone had connected. - dropActivity(env); + // Nothing from before the first connection goes up: an older trail + // queued uses whether or not anyone had connected. A later link keeps + // what the workspaces already here have waiting. + if (!readClouds(env).length) dropActivity(env); writeCloud(cloud, env); const sent = await syncWorkspaceRepos(cloud, opts.fetchImpl); done({ pending: undefined, linked: { workspace: cloud.workspace.name, url: cloud.workspace.url, repos: sent.repos, learnings: sent.learnings } }); @@ -400,6 +450,16 @@ export async function waitForConnect(opts: WaitOptions = {}): Promise c.workspace.slug === team.workspace)) return ""; + return teamLine(team.name, team.workspace); } catch { return ""; } diff --git a/src/cloud/workspace-sync.ts b/src/cloud/workspace-sync.ts index b571a3539..2c56d4c5e 100644 --- a/src/cloud/workspace-sync.ts +++ b/src/cloud/workspace-sync.ts @@ -39,7 +39,7 @@ import { appendPrivateFile, ensurePrivateDir, PRIVATE_FILE_MODE, writePrivateFil import { cacheDir, readJson, writeJsonAtomic } from "../util/state.js"; import { headLearnings } from "./head-learnings.js"; import { writeTeamFile } from "./team-file.js"; -import { isError, postActivity, readCloud, uploadLearnings, type ActivityEvent, type ApiError, type Cloud, type SkillFileUpload, type UploadedLearning } from "./workspace.js"; +import { cloudForRepo, cloudIn, isError, postActivity, readClouds, sameWorkspace, uploadLearnings, type ActivityEvent, type ApiError, type Cloud, type SkillFileUpload, type UploadedLearning } from "./workspace.js"; /** A failed sync is retried after this long, not on every command. */ const RETRY_MS = 5 * 60 * 1000; @@ -329,13 +329,16 @@ function statePath(repo: string): string { } /** - * Whether the repo at `checkout` is one of the workspace's, so its learnings - * and uses go up. Never on a machine connected by `trail init --connect`: that - * one sends a repo summary only (titles and counts, no learning bodies, no - * searches), whatever repos the workspace lists. + * Whether the repo at `checkout` is one of the workspace's (by default, of the + * workspace this machine sends it to), so its learnings and uses go up. Never + * on a connection made by `trail init --connect`: that one sends a repo + * summary only (titles and counts, no learning bodies, no searches), whatever + * repos the workspace lists. */ -export function inWorkspace(checkout: string, cloud: Cloud | null = readCloud()): boolean { - return !!cloud && cloud.via !== "connect" && cloud.repos.includes(repoPlace(checkout).key); +export function inWorkspace(checkout: string, cloud?: Cloud | null): boolean { + const key = repoPlace(checkout).key; + const c = cloud === undefined ? cloudForRepo(key) : cloud; + return !!c && c.via !== "connect" && c.repos.includes(key); } /** @@ -449,12 +452,17 @@ function onlySent(e: ActivityEvent, sent: Sent): ActivityEvent | null { return { ...e, shown_paths: names, shown_authors: e.kind === "check" ? [...new Set(authors)] : authors }; } -/** Sync every repo of the workspace that's on this machine. Returns what went up. */ +/** + * Sync every repo of the workspace that's on this machine and goes to it (a + * repo a newer link sends elsewhere is left to that one). Returns what went up. + */ export async function syncWorkspaceRepos(cloud: Cloud, fetchImpl?: typeof fetch): Promise<{ repos: number; learnings: number }> { let repos = 0; let learnings = 0; + const others = readClouds().filter((c) => !sameWorkspace(c, cloud)); for (const r of knownRepos()) { if (!cloud.repos.includes(r.key) || !existsSync(r.path)) continue; + if (others.some((c) => (c.linkedAt || 0) > (cloud.linkedAt || 0) && c.repos.includes(r.key))) continue; const res = await syncRepo(r.path, cloud, { fetchImpl }); if (isError(res)) continue; repos++; @@ -516,12 +524,13 @@ export function activityEvent(raw: unknown): ActivityEvent | null { /** * Remember one use of the learnings, to send with the next sync: only while - * this machine is connected, and only for a repo in the workspace. Not - * connected, or another repo, and nothing is written. Never throws. + * this machine is connected, and only for a repo in a workspace (by default, + * the one it's sent to). Not connected, or another repo, and nothing is + * written. Never throws. */ -export function recordActivity(use: ActivityEvent, cloud: Cloud | null = readCloud()): void { +export function recordActivity(use: ActivityEvent, cloud?: Cloud | null): void { const e = activityEvent(use); - if (!e || !records(cloud, e.repo)) return; + if (!e || !records(cloud === undefined ? cloudForRepo(e.repo) : cloud, e.repo)) return; try { const p = outboxPath(); appendPrivateFile(p, `${JSON.stringify(e)}\n`); @@ -557,14 +566,15 @@ function outboxHasEvents(): boolean { } /** - * Send the outbox. It's moved aside first, so events recorded while the - * request is out land in a fresh file; on failure they're put back. A use - * from before this connection, or for a repo the workspace doesn't have - * (left by an older trail), is dropped, never sent; so is the name of any - * learning the workspace didn't get from this machine's last upload of that - * repo, put back or sent. + * Send the outbox, each use to the workspace its repo goes to: `clouds` (the + * ones just changed, say), and every other connection on this machine. It's + * moved aside first, so events recorded while the request is out land in a + * fresh file; on failure they're put back. A use from before its workspace's + * connection, or for a repo no workspace has (left by an older trail), is + * dropped, never sent; so is the name of any learning the workspace didn't get + * from this machine's last upload of that repo, put back or sent. */ -export async function flushActivity(cloud: Cloud, fetchImpl?: typeof fetch): Promise { +export async function flushActivity(clouds: Cloud | Cloud[], fetchImpl?: typeof fetch): Promise { const p = outboxPath(); if (!outboxHasEvents()) return 0; const sending = `${p}.${process.pid}.sending`; @@ -573,28 +583,34 @@ export async function flushActivity(cloud: Cloud, fetchImpl?: typeof fetch): Pro } catch { return 0; } + const given = Array.isArray(clouds) ? clouds : [clouds]; + // What the caller holds is newer than the file for its workspace; newest link first, as cloudForRepo picks. + const all = [...given, ...readClouds().filter((c) => !given.some((g) => sameWorkspace(g, c)))].sort((a, b) => (b.linkedAt || 0) - (a.linkedAt || 0)); const has = readSent(); - const events: ActivityEvent[] = []; + const byCloud = new Map(); for (const line of readFileSync(sending, "utf8").split("\n")) { try { if (!line.trim()) continue; const e = activityEvent(JSON.parse(line)); - const out = e && sendable(e, cloud) ? onlySent(e, has) : null; - if (out) events.push(out); + const to = e ? cloudIn(all, e.repo) : null; + const out = e && to && sendable(e, to) ? onlySent(e, has) : null; + if (out && to) byCloud.set(to, [...(byCloud.get(to) ?? []), out]); } catch { /* a torn line */ } } let sent = 0; - for (let i = 0; i < events.length; i += 500) { - const res = await postActivity(cloud, events.slice(i, i + 500), fetchImpl); - if (isError(res)) { - // Put back what didn't go, ahead of anything recorded since. - const rest = events.slice(i).map((e) => JSON.stringify(e)).join("\n"); - appendPrivateFile(p, `${rest}\n`); - break; + for (const [to, events] of byCloud) { + for (let i = 0; i < events.length; i += 500) { + const res = await postActivity(to, events.slice(i, i + 500), fetchImpl); + if (isError(res)) { + // Put back what didn't go, ahead of anything recorded since. + const rest = events.slice(i).map((e) => JSON.stringify(e)).join("\n"); + appendPrivateFile(p, `${rest}\n`); + break; + } + sent += Math.min(500, events.length - i); } - sent += Math.min(500, events.length - i); } // Sent, or put back above: either way this copy is done with. rmSync(sending, { force: true }); @@ -615,19 +631,20 @@ export function syncIsDue(checkout: string, now = Date.now()): boolean { /** * Sync in a detached child when this machine is connected and either the - * repo (one of the workspace's) has learnings the workspace hasn't seen, or + * repo (one of a workspace's) has learnings its workspace hasn't seen, or * there are uses waiting to go. Never waits, never throws. */ export function maybeSyncInBackground(dir: string, now = Date.now()): boolean { try { - const cloud = readCloud(); - if (!cloud) { + const clouds = readClouds(); + if (!clouds.length) { // Whatever an older trail queued here can never go: connecting drops it. dropActivity(); return false; } - const checkout = repoPlace(dir).checkout; - const due = (inWorkspace(checkout, cloud) && syncIsDue(checkout, now)) || outboxHasEvents(); + const place = repoPlace(dir); + const checkout = place.checkout; + const due = (inWorkspace(checkout, cloudIn(clouds, place.key)) && syncIsDue(checkout, now)) || outboxHasEvents(); if (!due) return false; // Marked first, so commands run while the child works don't start another. const prior = readJson(statePath(checkout)); @@ -642,11 +659,12 @@ export function maybeSyncInBackground(dir: string, now = Date.now()): boolean { /** `_trail-sync`: what the background child does. */ export async function runSyncChild(dir: string): Promise { - const cloud = readCloud(); - if (!cloud) return; - const checkout = repoPlace(dir).checkout; - if (inWorkspace(checkout, cloud)) await syncRepo(checkout, cloud); - await flushActivity(cloud); + const clouds = readClouds(); + if (!clouds.length) return; + const place = repoPlace(dir); + const cloud = cloudIn(clouds, place.key); + if (cloud && inWorkspace(place.checkout, cloud)) await syncRepo(place.checkout, cloud); + await flushActivity(clouds); } /** The name a learning's file goes by, from a path or a `ref:path`. */ diff --git a/src/cloud/workspace.ts b/src/cloud/workspace.ts index dccb961d7..673ca335c 100644 --- a/src/cloud/workspace.ts +++ b/src/cloud/workspace.ts @@ -2,10 +2,12 @@ * The Trail workspace this machine is connected to, and the calls that talk * to it (trail-cloud's `/api/cli/…`). * - * One connection per machine, not per repo: a person signs in once, through - * the link their agent offers, and picks which of their repos the workspace - * gets. It lives in `~/.trail/cloud.json` next to their notes, never in a - * repo. Learnings themselves travel through git (`.trail/` is committed); + * One connection per workspace, and a laptop can hold several: a person signs + * in through the link their agent offers and picks the workspace a repo goes + * to, so a client's repo and the company's can go to different teams. Each + * repo goes to one workspace on this machine, the newest link that has it. + * The connections live in `~/.trail/cloud.json` next to their notes, never in + * a repo. Learnings themselves travel through git (`.trail/` is committed); * the workspace adds what one clone can't see: who is learning what across * the team's repos, how often a learning is reused, and the team page. * @@ -110,11 +112,47 @@ function cloudPath(env: NodeJS.ProcessEnv = process.env): string { return join(trailHome(env), "cloud.json"); } -/** The connection, or null when this machine hasn't connected a workspace. */ +/** + * What `cloud.json` holds: the newest connection at the top level, so an older + * trail still reads it as the machine's one connection (and keeps `others` when + * it writes that one back), and the rest under `others`. + */ +type StoredClouds = Cloud & { others?: unknown[] }; + +/** A file edited by hand, or cut short, is no connection: every caller reads the address and the token. */ +function validCloud(c: unknown): c is Cloud { + const x = c as Cloud | null; + return !!x && typeof x === "object" && typeof x.token === "string" && !!x.token && typeof x.url === "string" && !!serverUrl(x.url) && !!x.workspace?.slug && Array.isArray(x.repos); +} + +/** Every workspace this machine is connected to, the newest link first. */ +export function readClouds(env: NodeJS.ProcessEnv = process.env): Cloud[] { + const raw = readJson(cloudPath(env)); + if (!raw || typeof raw !== "object") return []; + const { others, ...first } = raw; + const all = [first, ...(Array.isArray(others) ? others : [])].filter(validCloud); + return all.sort((a, b) => (b.linkedAt || 0) - (a.linkedAt || 0)); +} + +/** The newest connection, or null when this machine hasn't connected a workspace. For what isn't about one repo. */ export function readCloud(env: NodeJS.ProcessEnv = process.env): Cloud | null { - const c = readJson(cloudPath(env)); - // A file edited by hand, or cut short, is no connection: every caller reads the address and the token. - return c && typeof c.token === "string" && c.token && typeof c.url === "string" && serverUrl(c.url) && c.workspace?.slug ? c : null; + return readClouds(env)[0] ?? null; +} + +/** The connection among `clouds` that a repo's learnings and uses go to: the newest whose workspace has it. */ +export function cloudIn(clouds: Cloud[], repoKey: string): Cloud | null { + return clouds.find((c) => c.repos.includes(repoKey)) ?? null; +} + +/** The connection a repo's learnings and uses go to, or null when no workspace on this machine has it. */ +export function cloudForRepo(repoKey: string, env: NodeJS.ProcessEnv = process.env): Cloud | null { + return cloudIn(readClouds(env), repoKey); +} + +/** Whether two connections are to the same workspace: by its id, which a new address for the same Trail keeps. */ +export function sameWorkspace(a: Cloud, b: Cloud): boolean { + if (a.workspace.id && b.workspace.id) return a.workspace.id === b.workspace.id; + return a.url === b.url && a.workspace.slug === b.workspace.slug; } /** @@ -130,16 +168,29 @@ export function cloudEnabled(env: NodeJS.ProcessEnv = process.env): boolean { /** Owner-only: the file holds the workspace token. */ export const SECRET_FILE_MODE = PRIVATE_FILE_MODE; -/** Owner-only, in an owner-only folder: the file holds the workspace token. */ +/** + * Save one connection, in place of the one to the same workspace. Owner-only, + * in an owner-only folder: the file holds the workspace tokens. + */ export function writeCloud(c: Cloud, env: NodeJS.ProcessEnv = process.env): void { + storeClouds([c, ...readClouds(env).filter((x) => !sameWorkspace(x, c))], env); +} + +function storeClouds(list: Cloud[], env: NodeJS.ProcessEnv): void { + const [first, ...others] = [...list].sort((a, b) => (b.linkedAt || 0) - (a.linkedAt || 0)); ensurePrivateDir(trailHome(env)); - writeJsonAtomic(cloudPath(env), c, false, SECRET_FILE_MODE); + writeJsonAtomic(cloudPath(env), others.length ? { ...first, others } : first, false, SECRET_FILE_MODE); } -export function clearCloud(env: NodeJS.ProcessEnv = process.env): boolean { +/** Forget every connection, or only the one to `workspace` (its slug). False when there was nothing to forget. */ +export function clearCloud(env: NodeJS.ProcessEnv = process.env, workspace?: string): boolean { const p = cloudPath(env); if (!existsSync(p)) return false; - rmSync(p, { force: true }); + const all = readClouds(env); + const keep = workspace === undefined ? [] : all.filter((c) => c.workspace.slug !== workspace); + if (workspace !== undefined && keep.length === all.length) return false; + if (keep.length) storeClouds(keep, env); + else rmSync(p, { force: true }); return true; } diff --git a/src/mcp/connector.ts b/src/mcp/connector.ts index 586cf7817..11fb03bd6 100644 --- a/src/mcp/connector.ts +++ b/src/mcp/connector.ts @@ -22,7 +22,8 @@ * Nothing here throws. A listing that can't be had is null; a call that * can't be made is an MCP error result the agent can read. */ -import { isLocalServer, readCloud, serverUrl, type Cloud } from '../cloud/workspace.js'; +import { cloudIn, isLocalServer, readClouds, serverUrl, type Cloud } from '../cloud/workspace.js'; +import { repoPlace } from '../notes/home.js'; /** * The workspace's MCP server: `/mcp`, or null when the pairing has no @@ -151,11 +152,23 @@ export class WorkspaceConnector { private failed: { at: number; err: unknown } | null = null; constructor( - private readonly opts: { env?: NodeJS.ProcessEnv; fetchImpl?: Fetch; timeoutMs?: number; callTimeoutMs?: number; version?: string } = {}, + private readonly opts: { env?: NodeJS.ProcessEnv; fetchImpl?: Fetch; timeoutMs?: number; callTimeoutMs?: number; version?: string; cwd?: string } = {}, ) {} + /** The repo this server runs in, by key: its workspace answers. Read once; the agent doesn't move repos. */ + private repoKey: string | null | undefined; + + /** The workspace of the repo the agent is in, or else the newest connection (a session outside any linked repo). */ private cloud(): Cloud | null { - return readCloud(this.opts.env); + const all = readClouds(this.opts.env); + if (this.repoKey === undefined) { + try { + this.repoKey = repoPlace(this.opts.cwd ?? process.cwd(), this.opts.env).key; + } catch { + this.repoKey = null; + } + } + return (this.repoKey ? cloudIn(all, this.repoKey) : null) ?? all[0] ?? null; } /** Whether this machine is paired with a workspace, so its tools are asked for. */ diff --git a/src/mcp/server.ts b/src/mcp/server.ts index f367bacc0..4be61f0f1 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -122,7 +122,7 @@ export function startMcpServer(root: string, dirOverride?: string, version = '0' // The workspace's own MCP server, when this machine is paired with one: // started now, so its instructions are in hand by the time the client asks. - const workspace = new WorkspaceConnector({ version }); + const workspace = new WorkspaceConnector({ version, cwd: root }); // One wait for the workspace, from boot: initialize and the first lists share it, and once it has // passed they answer at once with what they have, and `list_changed` brings the rest. const deadline = Date.now() + WORKSPACE_WAIT_MS; diff --git a/src/telemetry/contract.ts b/src/telemetry/contract.ts index 0cc012286..d33140572 100644 --- a/src/telemetry/contract.ts +++ b/src/telemetry/contract.ts @@ -149,7 +149,7 @@ export const VALUES: Record>> = { had_lesson: new Set(['true', 'false']), }, hook_ms: { - event: new Set(['session-start', 'prompt', 'pre-bash', 'tool-savings', 'post-bash', 'post-bash-fail', 'post-edit', 'post-edit-sync', 'stop']), + event: new Set(['session-start', 'prompt', 'pre-bash', 'pre-edit', 'tool-savings', 'post-bash', 'post-bash-fail', 'post-edit', 'post-edit-sync', 'stop']), bucket: new Set(HOOK_MS_BUCKETS), }, }; diff --git a/test/claude-hook-timeouts.test.ts b/test/claude-hook-timeouts.test.ts index c7b6ac914..83c25f690 100644 --- a/test/claude-hook-timeouts.test.ts +++ b/test/claude-hook-timeouts.test.ts @@ -31,7 +31,7 @@ function timeouts(hooks: Hooks): Record { return out; } -const EXPECTED = { PostToolUse: [10, 8], UserPromptSubmit: [15], PreToolUse: [5], PostToolUseFailure: [5], SessionStart: [10], Stop: [10], SessionEnd: [5] }; +const EXPECTED = { PostToolUse: [10, 8], UserPromptSubmit: [15], PreToolUse: [5, 5], PostToolUseFailure: [5], SessionStart: [10], Stop: [10], SessionEnd: [5] }; test('the repo and ~/.claude installs write every hook timeout in seconds', () => { assert.deepEqual(timeouts(mergeGraftSettings({}, { name: 'trail' }).merged.hooks), EXPECTED); diff --git a/test/claude-pre-bash-wiring.test.ts b/test/claude-pre-bash-wiring.test.ts index f9cc2f8a2..1271f0595 100644 --- a/test/claude-pre-bash-wiring.test.ts +++ b/test/claude-pre-bash-wiring.test.ts @@ -33,6 +33,7 @@ test('the repo install runs pre-bash before every Bash call, under either name', for (const name of ['graft', 'trail'] as const) { const { merged } = mergeGraftSettings({}, { name }); assert.deepEqual(merged.hooks.PreToolUse, [ + { matcher: 'Write|Edit|MultiEdit', hooks: [{ type: 'command', command: `node "\${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/${name}-hooks.cjs" pre-edit || exit 0`, timeout: 5 }] }, { matcher: 'Bash', hooks: [{ type: 'command', command: `node "\${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/${name}-hooks.cjs" pre-bash || exit 0`, timeout: 5 }] }, ]); } @@ -42,6 +43,7 @@ test("under trail the shared settings run only session start; the personal file assert.deepEqual(Object.keys(mergeTrailShared({}).hooks), ['SessionStart']); const local = mergeTrailLocal({}, { hooks: true }).merged; assert.deepEqual(local.hooks.PreToolUse, [ + { matcher: 'Write|Edit|MultiEdit', hooks: [{ type: 'command', command: 'node "${CLAUDE_PROJECT_DIR:-.}/.trail/hook.cjs" pre-edit || exit 0', timeout: 5 }] }, { matcher: 'Bash', hooks: [{ type: 'command', command: 'node "${CLAUDE_PROJECT_DIR:-.}/.trail/hook.cjs" pre-bash || exit 0', timeout: 5 }] }, ]); assert.deepEqual(local.hooks.PostToolUseFailure, [ @@ -57,7 +59,7 @@ test('re-running the repo merge keeps one pre-bash entry, after the person\'s ow const once = mergeGraftSettings({ hooks: { PreToolUse: [THEIRS] } }, { name: 'trail' }).merged; const twice = mergeGraftSettings(once, { name: 'trail' }).merged; assert.deepEqual(twice, once); - assert.equal(twice.hooks.PreToolUse.length, 2); + assert.equal(twice.hooks.PreToolUse.length, 3, "theirs, then the file's learnings before an edit, then pre-bash"); assert.deepEqual(twice.hooks.PreToolUse[0], THEIRS); assert.equal(preBashEntries(twice.hooks).length, 1); }); @@ -67,6 +69,7 @@ test('the user-level merge names the shim by absolute path, and converges', () = const once = mergeGraftHooks({ hooks: { PreToolUse: [THEIRS] } }, helpers).merged; assert.deepEqual(once.hooks.PreToolUse, [ THEIRS, + { matcher: 'Write|Edit|MultiEdit', hooks: [{ type: 'command', command: `node "${helpers}/graft-hooks.cjs" pre-edit || exit 0`, timeout: 5 }] }, { matcher: 'Bash', hooks: [{ type: 'command', command: `node "${helpers}/graft-hooks.cjs" pre-bash || exit 0`, timeout: 5 }] }, ]); assert.deepEqual(mergeGraftHooks(once, helpers).merged, once); diff --git a/test/claude-pre-bash.test.ts b/test/claude-pre-bash.test.ts index 89f3a14f0..8aea94560 100644 --- a/test/claude-pre-bash.test.ts +++ b/test/claude-pre-bash.test.ts @@ -140,8 +140,9 @@ test('the PreToolUse module loads only node builtins and small files, never the const names = files.map((f) => relative(SRC, f).split('\\').join('/')); for (const heavy of ['cli.ts', 'claude/hooks.ts', 'upkeep.ts', 'upkeep-run.ts']) assert.ok(!names.includes(heavy), `${heavy} stays out`); assert.ok(!names.some((n) => /^(telemetry|graph|ask|notes|cloud)\//.test(n)), `nothing heavier: ${names.join(', ')}`); - // Itself, the one-copy check (hook-layers, brand, host) and the skills fast path below. - assert.ok(files.length <= 12, `a small closure: ${names.join(', ')}`); + // Itself, the one-copy check (hook-layers, brand, host), the skills fast path below, and the + // commit check (pre-bash-check.ts), loaded only before a commit, a push or a PR. + assert.ok(files.length <= 13, `a small closure: ${names.join(', ')}`); // Loading it costs nothing but itself: no other file, and no ESM import of // node:fs, which alone costs Node several ms before every shell command. const atLoad = importClosure(join(SRC, 'claude', 'pre-bash.ts'), true); @@ -192,7 +193,9 @@ test('a plain command with no gate file loads none of the skills code; a skills assert.equal(r.stdout, ''); return existsSync(loaded) ? readFileSync(loaded, 'utf8').trim().split('\n') : []; }; - for (const command of ['npm test', 'git push origin beta', 'echo skillset', 'cat README.md']) assert.deepEqual(run(command), [], command); + for (const command of ['npm test', 'echo skillset', 'cat README.md']) assert.deepEqual(run(command), [], command); + // A push runs trail check first (pre-bash-check.ts), after the one-copy check; never the skills part. + assert.deepEqual(run('git push origin beta'), ['hook-layers'], 'git push origin beta'); assert.deepEqual(run('trail skills on'), ['hook-layers', 'pre-bash-skills'], 'the one-copy check first, then the skills part'); assert.deepEqual(run('tr""ail sk\'ills\' on'), ['hook-layers', 'pre-bash-skills'], 'quotes inside the words still read as skills'); assert.deepEqual(run('node ~/.claude/helpers/trail-hooks.cjs prompt'), ['hook-layers', 'pre-bash-skills'], 'a hook script by hand'); diff --git a/test/fixtures/skills/trail/SKILL.md b/test/fixtures/skills/trail/SKILL.md index b32197f5b..9950962c4 100644 --- a/test/fixtures/skills/trail/SKILL.md +++ b/test/fixtures/skills/trail/SKILL.md @@ -17,9 +17,18 @@ description: Use for ANY task in this repo. What past sessions worked out, decid # trail ## When to reach for trail -- **Before exploring code**, run `trail ask "" --source`. Learnings - from past sessions, yours and your teammates', on what was decided, tried - and ruled out print above the code. Read them first. +- **Before exploring code**, run `trail ask "" --source`, even for a + plain "where is" or "how does this work" question, before you grep or open + files. Learnings from past sessions, yours and your teammates', on what was + decided, tried and ruled out print above the code. Read them first. +- **When you start from a ticket, an issue or a bug report**, run + `trail ask` with its words before you plan, so you start from what was + already decided about that area. +- **When a test or command fails while you debug**, run `trail ask` on that + area before you try a fix: a past session may have tried and ruled out the + fix you're about to try. +- **Before you change a file**, trail puts the learnings on it in front of + you. Read them before you write the edit. - **When the session finds what the code can't tell the next person**, save a learning with `trail note`, and commit it with the change. What counts is under Saving a learning below. diff --git a/test/trail-init-upgrade.test.ts b/test/trail-init-upgrade.test.ts index c09f2e03a..3247f1ada 100644 --- a/test/trail-init-upgrade.test.ts +++ b/test/trail-init-upgrade.test.ts @@ -66,7 +66,7 @@ function assertWired(repo: string, home: string): void { assert.deepEqual(ours(user), { PostToolUse: ["Write|Edit|MultiEdit:post-edit", "Bash|mcp__graft__|mcp__trail__|Read|Grep|Glob|Skill:tool-savings"], UserPromptSubmit: [":prompt"], - PreToolUse: ["Bash:pre-bash"], + PreToolUse: ["Write|Edit|MultiEdit:pre-edit", "Bash:pre-bash"], PostToolUseFailure: ["Bash:post-bash-fail"], SessionStart: [":session-start"], Stop: [":stop"], diff --git a/test/trail-nudge.test.ts b/test/trail-nudge.test.ts index 09da06099..83e8c23e5 100644 --- a/test/trail-nudge.test.ts +++ b/test/trail-nudge.test.ts @@ -4,7 +4,7 @@ * reply with, at most once a session and in wording that changes from one * session to the next. A background wait registers the link with Trail, picks * up the sign-in, uploads the picked repos' learnings, and the next command - * says so once. Connected, a repo outside the workspace gets one question. + * says so once. Signed in, a repo no workspace here has gets the link to pick one. * All of it is off unless TRAIL_CLOUD=1, or the machine is already connected. */ import { test } from "node:test"; @@ -266,16 +266,31 @@ function connect(repos: string[]): void { writeCloud(c); } -test("connected: a repo with learnings outside the workspace gets one question, ever", () => { +test("signed in: a repo no workspace here has gets the link to pick one, once a session", () => { freshHome(); const inside = repo(); const outside = repo(); connect([inside.key]); - assert.deepEqual(lines(inside.dir, "ask", "s1"), [], "nothing to say about a repo already in it"); - const asked = lines(outside.dir, "ask", "s1"); - assert.equal(asked.length, 1); - assert.match(asked[0]!, /^\[trail\] Ask the user once, at the end of your reply: add acme\/extract-\d+ to their Trail workspace Acme/); - assert.match(asked[0]!, /run `trail workspace add`/); - assert.deepEqual(lines(outside.dir, "ask", "s2"), [], "never again"); + assert.deepEqual(lines(inside.dir, "ask", "s1"), [], "nothing to say about a repo already in one"); + const offered = lines(outside.dir, "ask", "s1"); + assert.equal(offered.length, 2); + assert.match(offered[0]!, /^\[trail\] End your reply with this line, word for word: \[Pick a Trail workspace for acme\/extract-\d+\]\(http:\/\/trail\.test\/connect\?state=/); + assert.match(offered[1]!, /one of the user's workspaces, a teammate's, or a new one/); + assert.equal(stateOf().pending && (stateOf().pending as { repo?: string }).repo, outside.key, "the link is for this repo"); + assert.deepEqual(lines(outside.dir, "ask", "s1"), [], "once a session"); + assert.equal(lines(outside.dir, "ask", "s2").length, 2, "and again the next one"); rmSync(join(process.env.TRAIL_HOME!, "cloud.json")); }); + +test("a link made in one repo isn't offered in another: each repo gets its own", () => { + freshHome(); + const a = repo(); + const b = repo(); + lines(a.dir, "ask", "s1"); + const first = stateOf().pending as { state: string; repo?: string }; + assert.equal(first.repo, a.key); + lines(b.dir, "ask", "s2"); + const second = stateOf().pending as { state: string; repo?: string }; + assert.equal(second.repo, b.key); + assert.notEqual(second.state, first.state); +}); diff --git a/test/trail-scenario-hooks.test.ts b/test/trail-scenario-hooks.test.ts new file mode 100644 index 000000000..b356f236e --- /dev/null +++ b/test/trail-scenario-hooks.test.ts @@ -0,0 +1,125 @@ +/** + * Trail at the moments a session needs it, without waiting for the agent to + * run a trail command: a file's learnings before the edit, `trail check` + * before a commit, push or PR, the save signs for a conflict shipped anyway and + * for a deploy step fixed, and the workspace link when a hook just put a + * teammate's learning in front of the agent. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { BRAND_ENV } from '../src/brand.js'; +import { notesOnTouch } from '../src/claude/format.js'; +import { noteAsk, noteSignals } from '../src/claude/note-signals.js'; +import { shipCheck, shipReason } from '../src/claude/pre-bash-check.js'; +import { SHIP_COMMAND } from '../src/claude/pre-bash.js'; +import type { ShipConflict as CheckFinding } from '../src/claude/pre-bash-check.js'; +import { hookNotice, linkedNews } from '../src/cloud/nudge.js'; +import { writeNote, type Note } from '../src/notes/notes.js'; +import { tmpRepo } from './helpers.js'; + +process.env.DO_NOT_TRACK = '1'; +process.env[BRAND_ENV] = 'trail'; +process.env.TRAIL_TEST_SERVER = 'http://trail.test'; +process.env.TRAIL_NO_FETCH = '1'; + +let n = 0; +const uid = () => `u${++n}`; +const prompt = (text: string) => [{ type: 'user', uuid: uid(), message: { role: 'user', content: text } }]; +function tool(name: string, input: object, out = 'ok', error = false) { + const id = `tu${++n}`; + return [ + { type: 'assistant', uuid: uid(), message: { role: 'assistant', content: [{ type: 'tool_use', id, name, input }] } }, + { type: 'user', uuid: uid(), message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content: out, is_error: error }] } }, + ]; +} +const bash = (command: string, out = 'ok', error = false) => tool('Bash', { command }, out, error); +const REPO = '/work/app'; +const edit = (file: string) => tool('Edit', { file_path: join(REPO, file), old_string: 'a', new_string: 'b' }); +const jsonl = (...parts: object[][]) => parts.flat().map((e) => JSON.stringify(e)).join('\n'); +const kinds = (text: string) => noteSignals(text, REPO).map((s) => s.kind); + +const conflict: CheckFinding = { + file: 'src/retry.ts', + line: 12, + verdict: 'conflict', + summary: 'retries again on a 429', + source: { kind: 'note', title: 'Retries trip the rate limit', author: 'Priya', path: '.trail/learnings/2026-10-07-retries-trip-priya.md' }, +}; + +test('the learnings on a file are worded for the edit about to happen', () => { + const note = { title: 'Retries trip the rate limit', path: '.trail/learnings/r.md', touches: ['src/retry.ts'], author: 'Priya', body: '## Decided\nThree retries.', date: '2026-10-07' } as unknown as Note; + const text = notesOnTouch([note], { file: 'src/retry.ts', verb: 'editing' }, {}); + assert.match(text ?? '', /left on the file you're about to change, src\/retry\.ts\. Read it before going on/); + assert.match(notesOnTouch([note], { file: 'src/retry.ts', verb: 'changed' }, {}) ?? '', /the file you just changed, src\/retry\.ts/); +}); + +test('a commit, a push and a PR are what the check runs before; nothing else pays for it', () => { + for (const c of ['git commit -m "x"', 'cd app && git push origin HEAD', 'gh pr create --fill']) assert.ok(SHIP_COMMAND.test(c), c); + for (const c of ['git status', 'git log --oneline', 'gh pr view 12', 'npm test']) assert.ok(!SHIP_COMMAND.test(c), c); +}); + +test('a conflict stops the commit once, saying what and the way through; the same try again goes', () => { + const dir = tmpRepo('ship-check'); + const input = { session_id: 'ship-1', tool_name: 'Bash', tool_input: { command: 'git commit -m "retry on 429"' } }; + const first = shipCheck(input, dir, 'git commit -m "retry on 429"', () => [conflict]); + assert.equal(first?.permissionDecision, 'deny'); + assert.match(first!.reason, /^trail check found 1 conflict with the team's learnings before this commit:\n- src\/retry\.ts:12: retries again on a 429 \(Retries trip the rate limit, Priya\)\n/); + assert.match(first!.reason, /say why in the commit message or PR description and run the same command again: it goes through then\. If the learning no longer holds, save a new learning that says what changed, with `--replaces 2026-10-07-retries-trip-priya\.md`\./); + assert.equal(shipCheck(input, dir, 'git commit -m "retry on 429"', () => [conflict]), null, 'the second try goes through'); + const other = { ...conflict, file: 'src/client.ts' }; + assert.equal(shipCheck(input, dir, 'git push', () => [conflict, other])?.permissionDecision, 'deny', 'a conflict it has not seen stops it again'); + assert.equal(shipCheck({ ...input, session_id: 'ship-2' }, dir, 'git commit', () => []), null, 'no conflict, no stop'); + assert.equal(shipCheck({ ...input, session_id: 'ship-3' }, dir, 'git commit', () => null), null, 'a check that could not say lets it run'); + assert.match(shipReason([conflict], 'gh pr create --fill'), /before opening this PR:/); +}); + +test('a conflict the work shipped anyway, files untouched, asks for a replacing learning', () => { + const stop = shipReason([conflict], 'git commit -m x'); + const overrode = jsonl(prompt('ship it'), edit('src/retry.ts'), bash('git commit -m x', stop, true), bash('git commit -m "x: 429 is retried once by design"')); + assert.deepEqual(kinds(overrode), ['overrode', 'shipped']); + const s = noteSignals(overrode, REPO)[0]!; + assert.match(noteAsk(s), /^\[trail\] trail check found this change goes against a learning, and it shipped anyway\. If the learning no longer holds, save what changed and why/); + assert.match(noteAsk(s), /--replaces /); + + const fixed = jsonl(prompt('ship it'), edit('src/retry.ts'), bash('git commit -m x', stop, true), edit('src/retry.ts'), bash('git commit -m x')); + assert.ok(!kinds(fixed).includes('overrode'), 'the named file changed after the stop: fixed, not overridden'); + const byHand = jsonl(prompt('ship it'), edit('src/a.ts'), bash('trail check', '✗ src/a.ts:3\n goes against …', true), bash('git push')); + assert.ok(kinds(byHand).includes('overrode'), "trail check's own exit 1, then a push"); + assert.ok(!kinds(jsonl(prompt('x'), edit('src/a.ts'), bash('trail check', '✗ src/a.ts:3', true))).includes('overrode'), 'nothing shipped'); +}); + +test('a check run from another folder (go -C, npm --prefix) is still a check', () => { + const goC = jsonl(prompt('fix it'), bash('go -C backend test -run TestX ./internal/taskstate/ 2>&1 | tail -30', '--- FAIL: TestX\nFAIL'), edit('backend/internal/taskstate/apply.go'), bash('go -C backend test -count=1 ./internal/taskstate/', 'ok \tgithub.com/x/taskstate\t0.9s')); + assert.deepEqual(kinds(goC), ['fixed']); + assert.match(noteSignals(goC, REPO)[0]!.what, /^go -C backend test failed once, then passed after your change to backend\/internal\/taskstate\/apply\.go\.$/); + assert.deepEqual(kinds(jsonl(prompt('x'), bash('npm --prefix web test', '1 failed', true), edit('web/a.ts'), bash('npm --prefix web test', 'ok'))), ['fixed']); +}); + +test('a deploy or CI step that failed, then passed after an edit, is a fix', () => { + const tf = jsonl(prompt('fix the plan'), bash('terraform plan', 'Error: Invalid reference', true), edit('infra/main.tf'), bash('terraform plan', 'Plan: 1 to add')); + assert.deepEqual(kinds(tf), ['fixed']); + const kube = jsonl(prompt('x'), bash('kubectl apply -f deploy/app.yml', 'error: unable', true), edit('deploy/app.yml'), bash('kubectl apply -f deploy/app.yml')); + assert.deepEqual(kinds(kube), ['fixed']); + assert.deepEqual(kinds(jsonl(prompt('x'), bash('docker build .', 'ERROR', true), edit('Dockerfile'), bash('docker build .'))), ['fixed']); +}); + +test('the workspace link reaches the person when a hook used trail: once a session, with why it comes now', () => { + process.env.TRAIL_HOME = mkdtempSync(join(tmpdir(), 'scenario-trail-home-')); + process.env.TRAIL_CLOUD = '1'; + const dir = tmpRepo('hook-link'); + const git = (args: string[]) => spawnSync('git', ['-c', 'commit.gpgsign=false', ...args], { cwd: dir }); + git(['init', '-q', '-b', 'main']); + git(['remote', 'add', 'origin', 'https://github.com/acme/hooks.git']); + writeNote(dir, { title: 'Retries trip the rate limit', body: '## Decided\nThree retries.', author: 'Priya', date: '2026-10-07', touches: [] }); + const noWait = () => {}; + const shown = hookNotice(dir, "Claude just got your team's learnings for this", { session: 'h1', startWait: noWait }); + assert.match(shown ?? '', /^trail · Claude just got your team's learnings for this · Share your learnings with your team\? http:\/\/trail\.test\/connect\?state=/); + assert.equal(hookNotice(dir, 'again', { session: 'h1', startWait: noWait }), null, 'once a session'); + assert.match(hookNotice(dir, 'next', { session: 'h2', startWait: noWait }) ?? '', /^trail · next · /); + assert.equal(linkedNews(), null, 'no news before connecting'); + delete process.env.TRAIL_CLOUD; +}); diff --git a/test/trail-workspaces.test.ts b/test/trail-workspaces.test.ts new file mode 100644 index 000000000..640948809 --- /dev/null +++ b/test/trail-workspaces.test.ts @@ -0,0 +1,108 @@ +/** + * A machine in several Trail workspaces: `cloud.json` keeps one connection per + * workspace, the newest at the top level so an older trail still reads it, and + * each repo's learnings and uses go to the newest workspace that has the repo. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { clearCloud, cloudForRepo, readCloud, readClouds, writeCloud, type Cloud } from "../src/cloud/workspace.js"; +import { flushActivity, recordActivity } from "../src/cloud/workspace-sync.js"; + +process.env.DO_NOT_TRACK = "1"; + +function freshHome(): NodeJS.ProcessEnv { + process.env.TRAIL_HOME = mkdtempSync(join(tmpdir(), "workspaces-trail-home-")); + return process.env; +} + +function cloud(id: string, slug: string, repos: string[], linkedAt: number, url = `http://${slug}.test`): Cloud { + return { + url, + token: `trl_${slug}`, + user: { name: "Priya", email: "priya@acme.test" }, + workspace: { id, name: slug[0]!.toUpperCase() + slug.slice(1), slug, url: `${url}/app/?w=${slug}`, invite_url: `${url}/join/x` }, + repos, + linkedAt, + }; +} + +const X = "github.com/acme/extract"; +const Y = "github.com/client/portal"; + +test("each repo goes to the newest workspace that has it", () => { + freshHome(); + writeCloud(cloud("w1", "acme", [X], 1000)); + writeCloud(cloud("w2", "client", [Y, X], 2000)); + assert.deepEqual(readClouds().map((c) => c.workspace.slug), ["client", "acme"], "newest link first"); + assert.equal(cloudForRepo(Y)?.workspace.slug, "client"); + assert.equal(cloudForRepo(X)?.workspace.slug, "client", "a repo in both goes to the newest link"); + assert.equal(cloudForRepo("github.com/acme/other"), null); + assert.equal(readCloud()?.workspace.slug, "client"); +}); + +test("an older trail still reads the newest connection, and the others ride along", () => { + freshHome(); + writeCloud(cloud("w1", "acme", [X], 1000)); + writeCloud(cloud("w2", "client", [Y], 2000)); + const raw = JSON.parse(readFileSync(join(process.env.TRAIL_HOME!, "cloud.json"), "utf8")); + assert.equal(raw.token, "trl_client", "the newest at the top level, as one connection"); + assert.equal(raw.others.length, 1); + // What an older trail writes back: the top-level connection, spread with whatever else the file held. + writeFileSync(join(process.env.TRAIL_HOME!, "cloud.json"), JSON.stringify({ ...raw, repos: [Y, "github.com/client/api"] })); + assert.deepEqual(readClouds().map((c) => c.workspace.slug), ["client", "acme"]); + assert.equal(cloudForRepo("github.com/client/api")?.workspace.slug, "client"); +}); + +test("a file from before several workspaces reads as one connection", () => { + freshHome(); + writeFileSync(join(process.env.TRAIL_HOME!, "cloud.json"), JSON.stringify(cloud("w1", "acme", [X], 1000))); + assert.equal(readClouds().length, 1); + assert.equal(cloudForRepo(X)?.workspace.slug, "acme"); +}); + +test("the same workspace again replaces it, even at a new address", () => { + freshHome(); + writeCloud(cloud("w1", "acme", [X], 1000)); + writeCloud(cloud("w1", "acme", [X, Y], 1000, "http://127.0.0.1:9999")); + assert.equal(readClouds().length, 1); + assert.equal(readCloud()?.url, "http://127.0.0.1:9999"); +}); + +test("logout of one workspace keeps the rest; plain logout forgets all", () => { + freshHome(); + writeCloud(cloud("w1", "acme", [X], 1000)); + writeCloud(cloud("w2", "client", [Y], 2000)); + assert.equal(clearCloud(process.env, "nope"), false); + assert.equal(clearCloud(process.env, "client"), true); + assert.deepEqual(readClouds().map((c) => c.workspace.slug), ["acme"]); + assert.equal(cloudForRepo(Y), null); + writeCloud(cloud("w2", "client", [Y], 2000)); + assert.equal(clearCloud(), true); + assert.deepEqual(readClouds(), []); +}); + +test("uses go to the workspace their repo goes to, and only there", async () => { + freshHome(); + writeCloud(cloud("w1", "acme", [X], Date.now() - 60_000)); + writeCloud(cloud("w2", "client", [Y], Date.now() - 30_000)); + const at = new Date().toISOString(); + const use = (repo: string) => ({ repo, kind: "search" as const, asker: "Priya", session: "s1", file_count: 0, shown_paths: [], shown_authors: [], saved_tokens: 900, at }); + recordActivity(use(X)); + recordActivity(use(Y)); + recordActivity(use("github.com/acme/other")); + const got: Array<{ url: string; auth: string; repos: string[] }> = []; + const fake = (async (url: string | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)); + got.push({ url: String(url), auth: String((init?.headers as Record).authorization), repos: body.events.map((e: { repo: string }) => e.repo) }); + return new Response(JSON.stringify({ stored: body.events.length }), { status: 200, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + assert.equal(await flushActivity(readClouds(), fake), 2, "a repo no workspace has isn't recorded at all"); + const byUrl = Object.fromEntries(got.map((g) => [new URL(g.url).host, g])); + assert.deepEqual(byUrl["acme.test"]?.repos, [X]); + assert.equal(byUrl["acme.test"]?.auth, "Bearer trl_acme"); + assert.deepEqual(byUrl["client.test"]?.repos, [Y]); + assert.equal(byUrl["client.test"]?.auth, "Bearer trl_client"); +});