chore: use worktreeinclude for local state setup (#2709)

This commit is contained in:
Patrick Erichsen
2026-06-17 12:54:47 -07:00
committed by GitHub
parent 5f0e1ee639
commit 4ccc9a1c62
8 changed files with 447 additions and 32 deletions
+23
View File
@@ -33,6 +33,29 @@ Simple fallback:
Prefer `npx convex ai-files install` over copying rules by hand when possible.
## Command Preflight
Before running any `bunx convex ...` command in ClawHub, explicitly identify:
- target runtime: `local`, `dev`, or `prod`
- deployment: exact name or URL when known, such as `wry-manatee-359` for prod
- code state: whether the function/schema changes have already been pushed with
`bunx convex dev --once`, `bunx convex deploy`, or the production deploy
workflow
Use the current Convex CLI flag shape:
- read data: `bunx convex data --deployment <deployment> <table>`
- run a function: `bunx convex run --deployment <deployment> <function> '<json>'`
- readonly inline query:
`bunx convex run --deployment <deployment> --inline-query '<query>'`
- single-table import:
`bunx convex import --deployment <deployment> --table <table> --replace -y <file>`
If `--env-file .env.local` produces `401 MissingAccessToken`, omit the env file
and target the deployment directly with `--deployment <deployment>` or `--prod`.
Do not use stale `--deployment-name` guidance.
## Route to the Right Skill
After that, use the most specific Convex skill for the task:
@@ -35,6 +35,11 @@ temporary migration code.
- Never run a destructive production apply step until after presenting dry-run
results and receiving explicit user confirmation in the current thread.
- Before implementing anything, classify the requested "migration" as one of:
code deploy, existing Convex function run, operator import/export command,
schema narrowing, data cleanup, or cleanup-code removal. Do not invent a new
Convex migration function when the issue or PR specifies an operator command
such as `convex import --replace`.
- Before any production migration apply, force the operator to visit
`https://dashboard.convex.dev/`, manually click **Backup Now** on the target
deployment, wait for completion, and explicitly confirm in the thread. Do not
+2 -2
View File
@@ -2,10 +2,10 @@
url = "http://127.0.0.1:{{ (repo ~ '-' ~ branch) | hash_port }}"
[[pre-start]]
env = "bun run setup:worktree -- --quiet"
env = "wt step copy-ignored || true; bun run setup:worktree -- --quiet --force --prefer-fallback"
[[pre-start]]
deps = "wt step copy-ignored || true; test -x node_modules/.bin/vite || bun install"
deps = "test -x node_modules/.bin/vite || bun install"
[post-start]
dev = "bun scripts/dev-worktree.ts --detach --seed --port {{ (repo ~ '-' ~ branch) | hash_port }}"
+2
View File
@@ -1 +1,3 @@
.env.local
.convex/
node_modules/
+4 -2
View File
@@ -23,7 +23,8 @@ Keep this section as the command map agents normally need, not a full `package.j
- `bun run dev` — foreground local app server at `http://localhost:3000`.
- `bunx convex dev --typecheck=disable` — local Convex backend/function watcher for manual setup.
- `bunx convex codegen` — regenerate `convex/_generated` after Convex API/schema changes.
- `bun run setup:worktree` — link `.env.local` and `.convex` from a usable source worktree into the current worktree. Use `-- --from <path>` or `CLAWHUB_WORKTREE_SOURCE=<path>` when auto-discovery picks the wrong source.
- `.worktreeinclude` — Codex-managed worktrees copy ignored local state (`.env.local`, `.convex/`, and `node_modules/`) from the local checkout at creation time.
- `bun run setup:worktree` — validate copied `.env.local` / `.convex` state, or link missing fallback state from a usable source worktree. Use `-- --from <path>` or `CLAWHUB_WORKTREE_SOURCE=<path>` when auto-discovery picks the wrong source.
- `bun run dev:worktree` — Worktrunk-managed detached worktree server that also seeds local fixtures plus the public corpus once before starting the app when `VITE_CONVEX_URL` and `CONVEX_DEPLOYMENT` are local. Requires `wt` on `PATH`; from that worktree use `wt --yes url` to print the branch URL and `wt --yes stop` to stop it.
- `bun run seed:dev` — manual reseed path; runs worktree setup, waits for local Convex, seeds local fixtures plus the public corpus, and refreshes stats.
- `bun run build` — production build (Vite + Nitro).
@@ -99,9 +100,10 @@ Specialized corpus, scanner, security-worker, UI proof, proof publishing, Crabbo
## Convex Ops (Gotchas)
- Before any `bunx convex ...` command, name the target runtime (`local`, `dev`, or `prod`), the exact deployment when known, and whether the current function/schema code has already been pushed or deployed.
- New Convex functions must be pushed before `convex run`: use `bunx convex dev --once` (dev) or `bunx convex deploy` (prod).
- For non-interactive prod deploys, use `bunx convex deploy -y` to skip confirmation.
- If `bunx convex run --env-file .env.local ...` returns `401 MissingAccessToken` despite `bunx convex login`, workaround: omit `--env-file` and use `--deployment-name <name>` / `--prod`.
- If `bunx convex run --env-file .env.local ...` returns `401 MissingAccessToken` despite `bunx convex login`, workaround: omit `--env-file` and use `--deployment <name>` / `--prod`.
## Convex Migrations & Backfills
+283 -3
View File
@@ -1,14 +1,24 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import {
existsSync,
lstatSync,
mkdtempSync,
mkdirSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { findSource } from "./setup-worktree";
import { describeSetupResult, findSource, setupWorktree } from "./setup-worktree";
function writeWorktree(
path: string,
deploymentName: string,
options?: {
env?: Record<string, string | null>;
adminKey?: string;
ports?: { cloud: number; site?: number };
},
) {
@@ -38,7 +48,11 @@ function writeWorktree(
);
writeFileSync(
join(path, ".convex/local/default/config.json"),
JSON.stringify({ deploymentName, ports: options?.ports ?? { cloud: 3210, site: 3211 } }),
JSON.stringify({
...(options?.adminKey ? { adminKey: options.adminKey } : {}),
deploymentName,
ports: options?.ports ?? { cloud: 3210, site: 3211 },
}),
);
}
@@ -55,7 +69,256 @@ function withSourceAndCurrent(run: (source: string, current: string) => void) {
}
}
function runGit(cwd: string, args: string[]) {
const result = spawnSync("git", args, { cwd, encoding: "utf8" });
if (result.status !== 0) {
throw new Error(`git ${args.join(" ")} failed: ${result.stderr || result.stdout}`);
}
}
describe("setup-worktree", () => {
it("describes copied local files and fallback links clearly", () => {
expect(
describeSetupResult({
convexLinked: false,
envLinked: false,
mode: "local",
sourcePath: "/repo",
}),
).toBe("validated copied local .env.local and .convex");
expect(
describeSetupResult({
convexLinked: true,
envLinked: false,
mode: "fallback",
sourcePath: "/repo",
}),
).toBe("fallback source /repo (env: existing, convex: linked)");
});
it("uses copied local worktree files without replacing them with symlinks", () => {
withSourceAndCurrent((_source, current) => {
writeWorktree(current, "local-clawhub");
expect(
setupWorktree({
cwd: current,
options: {
force: false,
from: null,
quiet: true,
},
}),
).toEqual({
convexLinked: false,
envLinked: false,
mode: "local",
sourcePath: current,
});
expect(lstatSync(join(current, ".env.local")).isSymbolicLink()).toBe(false);
expect(lstatSync(join(current, ".convex")).isSymbolicLink()).toBe(false);
});
});
it("keeps a copied env file and links only missing Convex state from a fallback source", () => {
withSourceAndCurrent((source, current) => {
writeWorktree(source, "local-clawhub");
mkdirSync(current, { recursive: true });
writeFileSync(
join(current, ".env.local"),
"CONVEX_DEPLOYMENT=local:local-clawhub\nVITE_CONVEX_URL=http://127.0.0.1:3210\nVITE_CONVEX_SITE_URL=http://127.0.0.1:3211\nCONVEX_SITE_URL=http://127.0.0.1:3211\n",
);
expect(
setupWorktree({
cwd: current,
options: {
force: false,
from: source,
quiet: true,
},
}),
).toEqual({
convexLinked: true,
envLinked: false,
mode: "fallback",
sourcePath: source,
});
expect(lstatSync(join(current, ".env.local")).isSymbolicLink()).toBe(false);
expect(lstatSync(join(current, ".convex")).isSymbolicLink()).toBe(true);
expect(existsSync(join(current, ".convex/local/default/config.json"))).toBe(true);
});
});
it("keeps copied Convex state and links only a missing env file from a fallback source", () => {
withSourceAndCurrent((source, current) => {
writeWorktree(source, "local-clawhub");
mkdirSync(join(current, ".convex/local/default"), { recursive: true });
writeFileSync(
join(current, ".convex/local/default/config.json"),
JSON.stringify({ deploymentName: "local-clawhub", ports: { cloud: 3210, site: 3211 } }),
);
expect(
setupWorktree({
cwd: current,
options: {
force: false,
from: source,
quiet: true,
},
}),
).toEqual({
convexLinked: false,
envLinked: true,
mode: "fallback",
sourcePath: source,
});
expect(lstatSync(join(current, ".env.local")).isSymbolicLink()).toBe(true);
expect(lstatSync(join(current, ".convex")).isSymbolicLink()).toBe(false);
});
});
it("replaces stale copied state when forced by automated worktree setup", () => {
withSourceAndCurrent((source, current) => {
writeWorktree(source, "local-clawhub");
writeWorktree(current, "stale-clawhub", { ports: { cloud: 4321, site: 4322 } });
expect(
setupWorktree({
cwd: current,
options: {
force: true,
from: source,
quiet: true,
},
}),
).toEqual({
convexLinked: true,
envLinked: true,
mode: "fallback",
sourcePath: source,
});
expect(lstatSync(join(current, ".env.local")).isSymbolicLink()).toBe(true);
expect(lstatSync(join(current, ".convex")).isSymbolicLink()).toBe(true);
});
});
it("prefers a fallback source over valid current copied state in automated worktree setup", () => {
const root = mkdtempSync(join(tmpdir(), "clawhub-worktree-git-"));
try {
const source = join(root, "source");
const current = join(root, "current");
mkdirSync(source);
runGit(source, ["init"]);
runGit(source, ["checkout", "-b", "main"]);
writeFileSync(join(source, "README.md"), "# test\n");
runGit(source, ["add", "README.md"]);
runGit(source, [
"-c",
"user.email=test@example.com",
"-c",
"user.name=Test User",
"commit",
"-m",
"init",
]);
runGit(source, ["worktree", "add", "-b", "feature", current]);
writeWorktree(source, "local-clawhub");
writeWorktree(current, "stale-clawhub", { ports: { cloud: 4321, site: 4322 } });
const result = setupWorktree({
cwd: current,
options: {
force: true,
preferFallback: true,
from: null,
quiet: true,
},
});
expect(result).toEqual({
convexLinked: true,
envLinked: true,
mode: "fallback",
sourcePath: realpathSync(source),
});
expect(lstatSync(join(current, ".env.local")).isSymbolicLink()).toBe(true);
expect(lstatSync(join(current, ".convex")).isSymbolicLink()).toBe(true);
} finally {
rmSync(root, { force: true, recursive: true });
}
});
it("rejects an invalid copied env file instead of silently keeping it", () => {
withSourceAndCurrent((source, current) => {
writeWorktree(source, "local-clawhub");
writeFileSync(
join(current, ".env.local"),
"CONVEX_DEPLOYMENT=local:local-clawhub\nVITE_CONVEX_URL=http://127.0.0.1:3210\nVITE_CONVEX_SITE_URL=http://127.0.0.1:3210\nCONVEX_SITE_URL=http://127.0.0.1:3211\n",
);
expect(() =>
setupWorktree({
cwd: current,
options: {
force: false,
from: source,
quiet: true,
},
}),
).toThrow(".env.local already exists as a regular local path");
});
});
it("rejects a copied env file that does not match fallback runtime URLs", () => {
withSourceAndCurrent((source, current) => {
writeWorktree(source, "local-clawhub");
writeFileSync(
join(current, ".env.local"),
"CONVEX_DEPLOYMENT=local:local-clawhub\nVITE_CONVEX_URL=http://localhost:3210\nVITE_CONVEX_SITE_URL=http://127.0.0.1:3211\nCONVEX_SITE_URL=http://127.0.0.1:3211\n",
);
expect(() =>
setupWorktree({
cwd: current,
options: {
force: false,
from: source,
quiet: true,
},
}),
).toThrow(".env.local already exists as a regular local path");
});
});
it("rejects a copied Convex config that does not match the fallback admin key", () => {
withSourceAndCurrent((source, current) => {
writeWorktree(source, "local-clawhub", { adminKey: "source-admin-key" });
mkdirSync(join(current, ".convex/local/default"), { recursive: true });
writeFileSync(
join(current, ".convex/local/default/config.json"),
JSON.stringify({
adminKey: "stale-admin-key",
deploymentName: "local-clawhub",
ports: { cloud: 3210, site: 3211 },
}),
);
expect(() =>
setupWorktree({
cwd: current,
options: {
force: false,
from: source,
quiet: true,
},
}),
).toThrow(".convex already exists as a regular local path");
});
});
it("honors an explicit source even when the current worktree is already configured", () => {
const root = mkdtempSync(join(tmpdir(), "clawhub-worktree-"));
try {
@@ -121,6 +384,23 @@ describe("setup-worktree", () => {
});
});
it("rejects local sources without the Convex function URL used by dev startup", () => {
withSourceAndCurrent((source, current) => {
writeWorktree(source, "local-clawhub", { env: { VITE_CONVEX_URL: null } });
expect(() =>
findSource(
{
force: false,
from: source,
quiet: true,
},
current,
),
).toThrow("VITE_CONVEX_URL is required for local Convex");
});
});
it("rejects local site URLs without an explicit site proxy port", () => {
withSourceAndCurrent((source, current) => {
writeWorktree(source, "local-clawhub", {
+117 -16
View File
@@ -6,16 +6,34 @@ import { basename, resolve } from "node:path";
type Options = {
from: string | null;
force: boolean;
preferFallback?: boolean;
quiet: boolean;
};
type Source = {
path: string;
env: Record<string, string>;
convexConfig: { deploymentName?: string; ports?: { cloud?: number; site?: number } } | null;
convexConfig: {
adminKey?: string;
deploymentName?: string;
ports?: { cloud?: number; site?: number };
} | null;
};
type SetupResult = {
convexLinked: boolean;
envLinked: boolean;
mode: "local" | "fallback";
sourcePath: string;
};
const LOCAL_CONVEX_CONFIG = ".convex/local/default/config.json";
const REQUIRED_ENV_MATCH_KEYS = [
"CONVEX_DEPLOYMENT",
"VITE_CONVEX_URL",
"VITE_CONVEX_SITE_URL",
"CONVEX_SITE_URL",
] as const;
function parseArgs(argv: string[]): Options {
const options: Options = {
@@ -33,6 +51,8 @@ function parseArgs(argv: string[]): Options {
options.from = arg.slice("--from=".length);
} else if (arg === "--force") {
options.force = true;
} else if (arg === "--prefer-fallback") {
options.preferFallback = true;
} else if (arg === "--quiet") {
options.quiet = true;
}
@@ -60,9 +80,9 @@ function parseEnv(text: string) {
return env;
}
function listGitWorktrees() {
function listGitWorktrees(cwd: string) {
const result = spawnSync("git", ["worktree", "list", "--porcelain"], {
cwd: process.cwd(),
cwd,
encoding: "utf8",
});
if (result.status !== 0 || typeof result.stdout !== "string") return [];
@@ -77,16 +97,18 @@ function readSource(path: string): Source | null {
const envPath = resolve(path, ".env.local");
if (!existsSync(envPath)) return null;
const configPath = resolve(path, LOCAL_CONVEX_CONFIG);
const convexConfig = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : null;
return {
path,
env: parseEnv(readFileSync(envPath, "utf8")),
convexConfig,
convexConfig: readConvexConfig(resolve(path, ".convex")),
};
}
function readConvexConfig(convexPath: string) {
const configPath = resolve(convexPath, "local/default/config.json");
return existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : null;
}
function isLocalHost(hostname: string) {
return (
hostname === "localhost" ||
@@ -131,6 +153,7 @@ function validateSource(source: Source) {
}
const convexUrl = source.env.VITE_CONVEX_URL;
if (!convexUrl) return "VITE_CONVEX_URL is required for local Convex";
const configPort = source.convexConfig.ports?.cloud;
if (convexUrl && configPort) {
try {
@@ -166,14 +189,14 @@ function validateSource(source: Source) {
export function findSource(options: Options, cwd = process.cwd()) {
const currentPath = resolve(cwd);
if (!options.from) {
if (!options.from && !options.preferFallback) {
const current = readSource(currentPath);
if (current && !validateSource(current)) return current;
}
const candidates = options.from
? [resolve(options.from)]
: listGitWorktrees().filter((worktree) => worktree !== currentPath);
: listGitWorktrees(cwd).filter((worktree) => worktree !== currentPath);
const rejected: string[] = [];
for (const candidate of candidates) {
@@ -184,6 +207,11 @@ export function findSource(options: Options, cwd = process.cwd()) {
rejected.push(`${candidate}: ${invalid}`);
}
if (!options.from && options.preferFallback) {
const current = readSource(currentPath);
if (current && !validateSource(current)) return current;
}
const suffix = rejected.length ? `\nRejected sources:\n- ${rejected.join("\n- ")}` : "";
throw new Error(
`Could not find a usable worktree source with .env.local and matching Convex local config.${suffix}`,
@@ -195,10 +223,48 @@ function replaceableLocal(path: string) {
return lstatSync(path).isSymbolicLink();
}
function linkFromSource(name: string, sourcePath: string, force: boolean) {
const target = resolve(process.cwd(), name);
function existingEnvMatchesSource(target: string, source: Source) {
if (!existsSync(target)) return false;
const targetEnv = parseEnv(readFileSync(target, "utf8"));
for (const key of REQUIRED_ENV_MATCH_KEYS) {
if (targetEnv[key] !== source.env[key]) return false;
}
return (
validateSource({
path: resolve(target, ".."),
env: targetEnv,
convexConfig: source.convexConfig,
}) === null
);
}
function existingConvexMatchesSource(target: string, source: Source) {
if (!existsSync(target)) return false;
const targetConfig = readConvexConfig(target);
if (!source.convexConfig) return targetConfig === null;
if (!targetConfig) return false;
return (
targetConfig.deploymentName === source.convexConfig.deploymentName &&
targetConfig.adminKey === source.convexConfig.adminKey &&
targetConfig.ports?.cloud === source.convexConfig.ports?.cloud &&
targetConfig.ports?.site === source.convexConfig.ports?.site
);
}
function linkFromSource(
name: string,
source: Source,
sourcePath: string,
force: boolean,
cwd: string,
) {
const target = resolve(cwd, name);
if (resolve(sourcePath) === target) return false;
if (existsSync(target)) {
if (!force && !lstatSync(target).isSymbolicLink()) {
if (name === ".env.local" && existingEnvMatchesSource(target, source)) return false;
if (name === ".convex" && existingConvexMatchesSource(target, source)) return false;
}
if (!force && !replaceableLocal(target)) {
throw new Error(
`${name} already exists as a regular local path. Move it aside or rerun setup with --force.`,
@@ -210,15 +276,50 @@ function linkFromSource(name: string, sourcePath: string, force: boolean) {
return true;
}
export function setupWorktree({ cwd, options }: { cwd: string; options: Options }): SetupResult {
const current = readSource(cwd);
const source = findSource(options, cwd);
const mode = current && source.path === current.path ? "local" : "fallback";
const envLinked = linkFromSource(
".env.local",
source,
resolve(source.path, ".env.local"),
options.force,
cwd,
);
const convexLinked = linkFromSource(
".convex",
source,
resolve(source.path, ".convex"),
options.force,
cwd,
);
return {
convexLinked,
envLinked,
mode,
sourcePath: source.path,
};
}
export function describeSetupResult(result: SetupResult) {
if (result.mode === "local") {
return "validated copied local .env.local and .convex";
}
const envState = result.envLinked ? "linked" : "existing";
const convexState = result.convexLinked ? "linked" : "existing";
return `fallback source ${result.sourcePath} (env: ${envState}, convex: ${convexState})`;
}
function main() {
const options = parseArgs(process.argv.slice(2));
const source = findSource(options);
linkFromSource(".env.local", resolve(source.path, ".env.local"), options.force);
linkFromSource(".convex", resolve(source.path, ".convex"), options.force);
const result = setupWorktree({ cwd: process.cwd(), options });
if (!options.quiet) {
console.log(`Worktree env setup complete using ${source.path}`);
console.log(`Worktree env setup complete: ${describeSetupResult(result)}`);
}
}
+11 -9
View File
@@ -19,15 +19,15 @@ The source of truth for the worktree lifecycle is:
- `package.json` scripts for public entrypoints.
- `.config/wt.toml` for Worktrunk hooks, branch-hashed URLs, detached startup, and stop cleanup.
- `.worktreeinclude` for ignored assets Worktrunk should copy when possible.
- `scripts/setup-worktree.ts` for ignored local state discovery and symlinking.
- `.worktreeinclude` for ignored local assets Codex-managed worktrees should copy at creation time. Worktrunk also uses it through `wt step copy-ignored` when possible.
- `scripts/setup-worktree.ts` for copied local state validation and fallback symlinking.
- `scripts/dev-worktree.ts` for detached app startup, local Convex reachability, and seeding.
`.codex/environments/environment.toml` is Codex app configuration. It can expose convenient actions, but it is not the source of truth for the developer workflow. Update it only when the corresponding package script or worktree contract changes.
## Environment Contract
`setup:worktree` must link `.env.local` and `.convex` from a coherent source worktree into the current worktree. A source is coherent when it has `.env.local` and, for `local:` Convex deployments, a matching `.convex/local/default/config.json`.
Fresh Codex-managed worktrees should receive `.env.local`, `.convex/`, and `node_modules/` through `.worktreeinclude`. `setup:worktree` must then validate the copied `.env.local` and `.convex` state. If copied state is missing or incomplete, it may link missing state from a coherent source worktree as a fallback. A source is coherent when it has `.env.local` and, for `local:` Convex deployments, a matching `.convex/local/default/config.json`.
When auto-discovery picks the wrong source, contributors should pass an explicit source:
@@ -36,25 +36,27 @@ bun run setup:worktree -- --from /path/to/source/worktree
CLAWHUB_WORKTREE_SOURCE=/path/to/source/worktree bun run setup:worktree
```
The setup helper validates common local Convex mistakes before linking:
Manual setup refuses to overwrite regular local `.env.local` or `.convex/` paths unless they already match the chosen source. Use `--force` only for automated repair paths or when intentionally replacing copied stale state with links to the selected source. Use `--prefer-fallback` only for automated setup after an ignored-file copy, where a copied but stale current worktree should not win over a coherent fallback source.
The setup helper validates common local Convex mistakes before accepting copied state or linking fallback state:
- missing `CONVEX_DEPLOYMENT`
- local deployment name mismatch
- `VITE_CONVEX_URL` port mismatch
- missing `VITE_CONVEX_URL` or local function port mismatch
- `VITE_CONVEX_SITE_URL` or `CONVEX_SITE_URL` missing or pointing at the wrong local site port
## Worktrunk Contract
`bun run dev:worktree` requires the `wt` executable on `PATH`. The current repo contract treats Worktrunk as mandatory for the detached worktree path and keeps the non-Worktrunk fallback as the manual path.
Worktrunk runs the configured pre-start hooks before starting the detached server:
Worktrunk runs the configured pre-start hooks before starting the detached server. It should copy ignored files before setup validation:
```text
bun run setup:worktree -- --quiet
wt step copy-ignored || true; test -x node_modules/.bin/vite || bun install
wt step copy-ignored || true; bun run setup:worktree -- --quiet --force --prefer-fallback
test -x node_modules/.bin/vite || bun install
```
The copy step is best effort. If `.convex` is already a symlink to the source worktree, Worktrunk may report that it refused to copy `.convex` outside the destination worktree. That is acceptable as long as `setup:worktree` linked `.convex` and `.env.local`, and dependencies are present.
The copy step is best effort. Codex-managed worktrees copy ignored files when they are created, and Worktrunk-created or older worktrees may still need the setup fallback. The follow-up setup step uses `--force --prefer-fallback` because a Worktrunk copy can materialize stale regular `.env.local` / `.convex/` paths before setup selects a coherent fallback source. If `.convex` is already a symlink to the source worktree, Worktrunk may report that it refused to copy `.convex` outside the destination worktree. That is acceptable as long as `setup:worktree` validates or links `.convex` and `.env.local`, and dependencies are present.
## Runtime Contract