diff --git a/packages/desktop/src/main/worktree-service.test.ts b/packages/desktop/src/main/worktree-service.test.ts index a2ff1085..2d3663a2 100644 --- a/packages/desktop/src/main/worktree-service.test.ts +++ b/packages/desktop/src/main/worktree-service.test.ts @@ -1,11 +1,23 @@ import { afterEach, describe, expect, test } from 'bun:test'; -import { execFile } from 'node:child_process'; -import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { execFile, execFileSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; +import { addOkPathsToGitExclude, getOkArtifactPaths } from '@inkeep/open-knowledge'; +import { initContent } from '@inkeep/open-knowledge-server'; +import { discoverProject } from './folder-admission.ts'; import { clearRecentGitCache } from './worktree-recents.ts'; import { createWorktree, listWorktreeSelector } from './worktree-service.ts'; +import { seedWorktreeProjectSetup } from './worktree-setup-inherit.ts'; const execFileAsync = promisify(execFile); const GIT_ENV = { ...process.env, LANG: 'C', LC_ALL: 'C', GIT_CONFIG_GLOBAL: '/dev/null' }; @@ -463,3 +475,136 @@ describe('worktree-service', () => { expect(res.reason).toBe('invalid-branch'); }); }); + +// --------------------------------------------------------------------------- +// Inherited OK setup — the worktree opens `managed` (no consent dialog), with +// full parity, for both shared and local-only roots. Real git throughout: the +// classification is checked through the actual admission path (`discoverProject` +// → `isProjectRoot`), not a mock. +// --------------------------------------------------------------------------- + +/** True iff `path` is git-ignored inside `cwd` (check-ignore exits 0). */ +async function isIgnored(cwd: string, path: string): Promise { + try { + await git(cwd, 'check-ignore', '-q', '--', path); + return true; + } catch { + return false; + } +} + +/** + * A local-only OK repo: `.ok/config.yml` (+ editor MCP wiring) exists at the + * main root but is NEVER committed — it's excluded via the shared common-dir + * `.git/info/exclude`, exactly as `addOkPathsToGitExclude` does for a + * local-only project. Only README is committed, so a worktree checked out from + * `main` does NOT carry `.ok/config.yml`. + */ +async function makeLocalOnlyRepo(): Promise { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'wt-svc-local-'))); + const mainRepo = join(root, 'main'); + mkdirSync(mainRepo); + await git(mainRepo, 'init', '--initial-branch=main', '.'); + await git(mainRepo, 'config', 'user.email', 'test@example.com'); + await git(mainRepo, 'config', 'user.name', 'Test'); + writeFileSync(join(mainRepo, 'README.md'), '# main\n'); + await git(mainRepo, 'add', '-A'); + await git(mainRepo, 'commit', '-m', 'initial'); + // Scaffold the OK project + a claude wiring at the root, then go local-only. + initContent(mainRepo, { contentDir: 'docs' }); + mkdirSync(join(mainRepo, '.ok'), { recursive: true }); + // A claude project MCP config carrying the OK sentinel (never committed). + writeFileSync( + join(mainRepo, '.mcp.json'), + JSON.stringify({ + mcpServers: { + 'open-knowledge': { command: '/bin/sh', args: ['-l', '-c', '# ok-mcp-v1\nexec ok mcp'] }, + }, + }), + ); + const excl = addOkPathsToGitExclude(mainRepo, getOkArtifactPaths(mainRepo)); + if (excl.kind !== 'updated') throw new Error(`expected local-only exclude, got ${excl.kind}`); + return { root, mainRepo, cleanup: () => rmSync(root, { recursive: true, force: true }) }; +} + +describe('worktree-service — inherited OK setup (no consent dialog)', () => { + let handle: Handle | null = null; + afterEach(() => { + handle?.cleanup(); + handle = null; + clearRecentGitCache(); + }); + + test('HARD GATE: seeding flips a local-only worktree from `fresh` (dialog) to `managed` (silent)', async () => { + handle = await makeLocalOnlyRepo(); + const wtPath = join(handle.root, 'flip-wt'); + // Manual `git worktree add` — un-seeded, so we can observe the classification + // BEFORE and AFTER seeding (createWorktree would auto-seed). + execFileSync('git', ['worktree', 'add', '-b', 'flip', wtPath, 'main'], { + cwd: handle.mainRepo, + env: GIT_ENV, + }); + // Branch `main` never committed `.ok/`, so the worktree has no config.yml. + expect(existsSync(join(wtPath, '.ok', 'config.yml'))).toBe(false); + + // BEFORE: the linked-worktree carveout classifies it `fresh` → consent dialog. + const before = await discoverProject(wtPath, { homeDir: handle.root, dirSizeProbe: null }); + expect(before.kind).toBe('fresh'); + + seedWorktreeProjectSetup(wtPath, handle.mainRepo); + + // AFTER: a real, parseable config.yml now marks the worktree a project root, + // so discovery classifies it `managed` — the consent dialog is suppressed. + expect(existsSync(join(wtPath, '.ok', 'config.yml'))).toBe(true); + const after = await discoverProject(wtPath, { homeDir: handle.root, dirSizeProbe: null }); + expect(after.kind).toBe('managed'); + if (after.kind !== 'managed') return; + expect(after.projectDir).toBe(wtPath); + expect(after.ancestorPromoted).toBe(false); + }); + + test('local-only root: the seeded config.yml (+ editor wiring) stays UNTRACKED in the worktree', async () => { + handle = await makeLocalOnlyRepo(); + const wtPath = join(handle.root, 'local-wt'); + execFileSync('git', ['worktree', 'add', '-b', 'local-branch', wtPath, 'main'], { + cwd: handle.mainRepo, + env: GIT_ENV, + }); + + seedWorktreeProjectSetup(wtPath, handle.mainRepo); + + // Root wired claude → the worktree gets `.mcp.json`, adapted (byte-identical + // resilient chain) under the worktree path. + expect(existsSync(join(wtPath, '.mcp.json'))).toBe(true); + // content.dir was inherited from the root. + expect(readFileSync(join(wtPath, '.ok', 'config.yml'), 'utf-8')).toContain('dir: docs'); + + // The shared common-dir exclude covers the worktree's copies → ignored, + // hence UNTRACKED. The seed never stages anything. + expect(await isIgnored(wtPath, '.ok/config.yml')).toBe(true); + expect(await isIgnored(wtPath, '.mcp.json')).toBe(true); + const status = await git(wtPath, 'status', '--porcelain'); + expect(status).not.toContain('.ok/config.yml'); + expect(status).not.toContain('.mcp.json'); + }); + + test('shared root (config committed): createWorktree opens managed and never clobbers the committed config', async () => { + // makeRepo commits `.ok/config.yml` as `version: 1\n` (shared posture). + handle = await makeRepo(); + const res = await createWorktree({ + anchorPath: handle.mainRepo, + branch: 'shared-wt', + createBranch: true, + }); + expect(res.ok).toBe(true); + if (!res.ok) return; + + // Seed is writeIfMissing → the committed config is preserved byte-for-byte. + expect(readFileSync(join(res.path, '.ok', 'config.yml'), 'utf-8')).toBe('version: 1\n'); + + const disc = await discoverProject(res.path, { homeDir: handle.root, dirSizeProbe: null }); + expect(disc.kind).toBe('managed'); + if (disc.kind !== 'managed') return; + expect(disc.projectDir).toBe(res.path); + }); +}); diff --git a/packages/desktop/src/main/worktree-service.ts b/packages/desktop/src/main/worktree-service.ts index b9514e6d..49a00a1b 100644 --- a/packages/desktop/src/main/worktree-service.ts +++ b/packages/desktop/src/main/worktree-service.ts @@ -38,6 +38,7 @@ import { import { listGitWorktrees } from './list-git-worktrees.ts'; import { seedWorktreeAutoSync } from './worktree-autosync-inherit.ts'; import { clearRecentGitCache } from './worktree-recents.ts'; +import { seedWorktreeProjectSetup } from './worktree-setup-inherit.ts'; const execFileAsync = promisify(execFile); @@ -176,6 +177,16 @@ export async function createWorktree(args: CreateWorktreeArgs): Promise { + for (const d of dirs) rmSync(d, { recursive: true, force: true }); + dirs.length = 0; +}); + +/** Write `/.ok/config.yml` with the given YAML body. */ +function writeRootConfig(dir: string, body: string): void { + mkdirSync(join(dir, '.ok'), { recursive: true }); + writeFileSync(join(dir, '.ok', 'config.yml'), body); +} + +/** Wire an editor at the root the same way OK's chain does — a project MCP + * config whose bytes carry the OK sentinel. `relPath` is the editor's + * project-scope config path. */ +function wireEditorAtRoot(dir: string, relPath: string, sentinel = '# ok-mcp-v1'): void { + const abs = join(dir, relPath); + mkdirSync(join(abs, '..'), { recursive: true }); + writeFileSync( + abs, + JSON.stringify({ + mcpServers: { + 'open-knowledge': { command: '/bin/sh', args: ['-l', '-c', `${sentinel}\nexec ok mcp`] }, + }, + }), + ); +} + +describe('readRootContentDir', () => { + test('returns a non-default content.dir from the root config', () => { + const root = tmp(); + writeRootConfig(root, 'content:\n dir: docs\n'); + expect(readRootContentDir(root)).toBe('docs'); + }); + + test('returns undefined for the default "." content.dir', () => { + const root = tmp(); + writeRootConfig(root, 'content:\n dir: .\n'); + expect(readRootContentDir(root)).toBeUndefined(); + }); + + test('returns undefined when content.dir is absent', () => { + const root = tmp(); + writeRootConfig(root, 'version: 1\n'); + expect(readRootContentDir(root)).toBeUndefined(); + }); + + test('returns undefined (no throw) when the config is missing or unparseable', () => { + const missing = tmp(); + expect(readRootContentDir(missing)).toBeUndefined(); + const bad = tmp(); + writeRootConfig(bad, ': : not valid yaml : :\n\t- ['); + expect(readRootContentDir(bad)).toBeUndefined(); + }); +}); + +describe('detectRootWiredEditors', () => { + test('detects only the editors whose project MCP config carries the OK sentinel', () => { + const root = tmp(); + wireEditorAtRoot(root, '.mcp.json'); // claude + wireEditorAtRoot(root, join('.cursor', 'mcp.json')); // cursor + // codex NOT wired → excluded + const editors = detectRootWiredEditors(root); + expect(editors).toContain('claude'); + expect(editors).toContain('cursor'); + expect(editors).not.toContain('codex'); + // Global-only editors have no projectConfigPath and never appear. + expect(editors).not.toContain('claude-desktop'); + expect(editors).not.toContain('openclaw'); + }); + + test('accepts the Windows sentinel too', () => { + const root = tmp(); + wireEditorAtRoot(root, '.mcp.json', '# ok-mcp-win-v1'); + expect(detectRootWiredEditors(root)).toContain('claude'); + }); + + test('survives a sentinel version bump — detection keys on the version-independent prefix', () => { + // A future `# ok-mcp-v2` / `# ok-mcp-win-v9` must still be recognized so the + // worktree never silently loses its inherited editor wiring on a bump. + const rootUnix = tmp(); + wireEditorAtRoot(rootUnix, '.mcp.json', '# ok-mcp-v2'); + expect(detectRootWiredEditors(rootUnix)).toContain('claude'); + const rootWin = tmp(); + wireEditorAtRoot(rootWin, '.mcp.json', '# ok-mcp-win-v9'); + expect(detectRootWiredEditors(rootWin)).toContain('claude'); + }); + + test('a project config WITHOUT the sentinel (foreign tool) is not counted', () => { + const root = tmp(); + mkdirSync(root, { recursive: true }); + writeFileSync( + join(root, '.mcp.json'), + JSON.stringify({ mcpServers: { other: { command: 'x' } } }), + ); + expect(detectRootWiredEditors(root)).not.toContain('claude'); + }); + + test('returns empty when the root has wired no project-scope editors', () => { + const root = tmp(); + writeRootConfig(root, 'version: 1\n'); + expect(detectRootWiredEditors(root)).toEqual([]); + }); +}); + +describe('seedWorktreeProjectSetup', () => { + test('materializes a valid .ok/config.yml (+ scaffold) at the worktree — the consent-dialog marker', () => { + const root = tmp(); + const wt = tmp(); + writeRootConfig(root, 'version: 1\n'); + + expect(existsSync(join(wt, '.ok', 'config.yml'))).toBe(false); + seedWorktreeProjectSetup(wt, root); + + // The HARD GATE marker: a real, parseable config.yml at the worktree root. + const configPath = join(wt, '.ok', 'config.yml'); + expect(existsSync(configPath)).toBe(true); + expect(() => parseYaml(readFileSync(configPath, 'utf-8'))).not.toThrow(); + // Full `.ok/` scaffold parity with a fresh setup. + expect(existsSync(join(wt, '.ok', '.gitignore'))).toBe(true); + expect(existsSync(join(wt, '.okignore'))).toBe(true); + }); + + test('derives content.dir from the root config', () => { + const root = tmp(); + const wt = tmp(); + writeRootConfig(root, 'content:\n dir: knowledge\n'); + seedWorktreeProjectSetup(wt, root); + const parsed = parseYaml(readFileSync(join(wt, '.ok', 'config.yml'), 'utf-8')); + expect(parsed.content.dir).toBe('knowledge'); + }); + + test('mirrors exactly the editors the root wired (writes their project MCP configs), skipping unwired editors', () => { + const root = tmp(); + const wt = tmp(); + writeRootConfig(root, 'version: 1\n'); + wireEditorAtRoot(root, '.mcp.json'); // claude + wireEditorAtRoot(root, join('.cursor', 'mcp.json')); // cursor + + seedWorktreeProjectSetup(wt, root); + + // Claude + Cursor mirrored to the worktree, carrying the OK sentinel. + const wtMcp = join(wt, '.mcp.json'); + const wtCursor = join(wt, '.cursor', 'mcp.json'); + expect(existsSync(wtMcp)).toBe(true); + expect(readFileSync(wtMcp, 'utf-8')).toContain('# ok-mcp'); + expect(existsSync(wtCursor)).toBe(true); + // Claude also gets its launcher entry (path-independent chain). + expect(existsSync(join(wt, '.claude', 'launch.json'))).toBe(true); + // Codex was NOT wired at the root → not written to the worktree. + expect(existsSync(join(wt, '.codex', 'config.toml'))).toBe(false); + }); + + test('does NOT wire any editors when the root wired none', () => { + const root = tmp(); + const wt = tmp(); + writeRootConfig(root, 'version: 1\n'); + seedWorktreeProjectSetup(wt, root); + expect(existsSync(join(wt, '.mcp.json'))).toBe(false); + expect(existsSync(join(wt, '.cursor', 'mcp.json'))).toBe(false); + // But the .ok/config.yml marker is still seeded. + expect(existsSync(join(wt, '.ok', 'config.yml'))).toBe(true); + }); + + test('idempotent: never clobbers a config.yml already checked out by the branch (shared + committed root)', () => { + const root = tmp(); + const wt = tmp(); + writeRootConfig(root, 'content:\n dir: docs\n'); + // Simulate the worktree branch having already checked out a committed config. + const committed = + 'version: 7\n# hand-authored, must survive\ncontent:\n dir: committed-scope\n'; + mkdirSync(join(wt, '.ok'), { recursive: true }); + writeFileSync(join(wt, '.ok', 'config.yml'), committed); + + seedWorktreeProjectSetup(wt, root); + + // Byte-for-byte preserved — writeIfMissing, no clobber. + expect(readFileSync(join(wt, '.ok', 'config.yml'), 'utf-8')).toBe(committed); + }); + + test('is safe to run twice (second run is a no-op)', () => { + const root = tmp(); + const wt = tmp(); + writeRootConfig(root, 'version: 1\n'); + wireEditorAtRoot(root, '.mcp.json'); + seedWorktreeProjectSetup(wt, root); + const firstConfig = readFileSync(join(wt, '.ok', 'config.yml'), 'utf-8'); + const firstMcp = readFileSync(join(wt, '.mcp.json'), 'utf-8'); + seedWorktreeProjectSetup(wt, root); + expect(readFileSync(join(wt, '.ok', 'config.yml'), 'utf-8')).toBe(firstConfig); + expect(readFileSync(join(wt, '.mcp.json'), 'utf-8')).toBe(firstMcp); + }); +}); diff --git a/packages/desktop/src/main/worktree-setup-inherit.ts b/packages/desktop/src/main/worktree-setup-inherit.ts new file mode 100644 index 00000000..05c86e6c --- /dev/null +++ b/packages/desktop/src/main/worktree-setup-inherit.ts @@ -0,0 +1,191 @@ +/** + * Seed a freshly-created worktree's OpenKnowledge project setup from the root + * project, so opening a worktree window doesn't re-prompt the + * "Setup OpenKnowledge in this folder?" (Shared vs Local) consent dialog. The + * worktree inherits the root's setup SILENTLY: the same `.ok/` scaffold and + * editor/MCP wiring a fresh setup writes, carrying the root's `content.dir` and + * its wired-editor set, so agents/editors work in the worktree out of the box. + * + * Parity is exact for a shared root whose branch committed `.ok/config.yml`: + * that committed config lands in the worktree via checkout and is inherited + * verbatim (the seed's `writeIfMissing` never clobbers it). For a local-only + * root (`.ok/` uncommitted), the seed reproduces the scaffold + wiring and + * inherits `content.dir`, but hand-edited UNCOMMITTED extras that never reach + * the branch (e.g. a customized `.okignore`, a rarely-set `appearance.theme`) + * are not copied — those are not part of the setup flow's output either. + * + * ## Why the consent dialog fires without this + * `openProject` → `discoverProject` (`folder-admission.ts`) classifies a + * freshly-created linked worktree via the linked-worktree carveout: an + * UN-initialized worktree (no `/.ok/config.yml`) returns + * `kind:'fresh'` → the consent dialog. The gate hinges on `isProjectRoot` = + * `/.ok/config.yml` exists as a regular file (`OK_PROJECT_MARKER`). + * Seeding a real, valid `config.yml` here flips the carveout off: the ancestor + * walk then classifies the worktree `managed` (projectDir === worktree) and it + * opens silently. + * + * ## What is seeded, and the writers reused (no reinvention) + * 1. `initContent(worktree, { contentDir })` — the SAME writer the consent / + * create-new flows use (`@inkeep/open-knowledge-server`). Writes + * `.ok/config.yml` + `.ok/.gitignore` + `.okignore`. `writeIfMissing` + * semantics mean a config.yml already checked out by the branch (shared + + * committed root) is NOT clobbered. `contentDir` is read from the root's + * `.ok/config.yml`; it is relative to the project root, so the same value + * is correct in the worktree (no path rewriting). + * 2. `writeProjectAiIntegrations(worktree, editors)` — the SAME writer the + * consent / create-new flows use. `editors` is the set the ROOT has wired, + * detected from each editor's project MCP config carrying the OK sentinel. + * The published MCP entries and `.claude/launch.json` recipe are the + * resilient `/bin/sh` chain — project-path-INDEPENDENT (they resolve the + * runtime at spawn time and derive the project from cwd), so writing them + * under the worktree path IS the path adaptation. + * + * ## Sharing mode (shared vs local-only) is inherited for free + * `.git/info/exclude` lives in the git COMMON dir, shared across every worktree + * of a repo. A local-only root already excluded `.ok/`, `.okignore`, the editor + * MCP configs, etc. (unanchored/relative patterns), so those same entries cover + * the worktree's copies: the seeded `.ok/config.yml` and editor configs are + * ignored → they stay UNTRACKED, and `readSharingMode(worktree)` already reads + * `local-only`. This seed performs pure file writes and never stages anything, + * so it cannot accidentally add the worktree's `.ok/` to the index. + * + * Best-effort: every failure is logged, never thrown — the worktree already + * exists on disk, and a missing seed just falls back to the normal onboarding + * prompt (the prior behavior). Sits beside `seedWorktreeAutoSync` + * (`worktree-autosync-inherit.ts`) and follows the same shape. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { EDITOR_TARGETS, writeProjectAiIntegrations } from '@inkeep/open-knowledge'; +import { ALL_EDITOR_IDS, type EditorId } from '@inkeep/open-knowledge-core'; +import { initContent } from '@inkeep/open-knowledge-server'; +import { parse as parseYaml } from 'yaml'; +import { getLogger } from './desktop-logger.ts'; + +/** + * Version-INDEPENDENT prefix of OK's MCP chain sentinels. Every OK MCP entry — + * unix (`# ok-mcp-v1`, `CHAIN_VERSION_SENTINEL`) and Windows (`# ok-mcp-win-v1`, + * `CHAIN_WIN_VERSION_SENTINEL`, both in the CLI's `editors.ts`) — embeds a line + * starting with this prefix, verbatim, regardless of format (JSON or TOML), and + * `# ok-mcp-win-…` also contains it, so one prefix covers both platforms. + * + * Deliberately keys on the PREFIX rather than the exact `-v1` sentinel (the way + * `editorWiredForOk` in `skill-reclaim.ts` does for its own, different purpose): + * a sentinel bump only ever appends a new suffix (`v2`, …) and never changes + * this prefix, so detection survives a bump with no edit here. If this were + * pinned to `-v1` and the sentinel bumped, `detectRootWiredEditors` would + * silently return `[]` and the worktree would lose its inherited editor wiring. + */ +const OK_MCP_MARKER_PREFIX = '# ok-mcp-'; + +function isObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +/** + * Read the root project's `content.dir` from `/.ok/config.yml`. + * Returns the value only when it's a non-default (`!== '.'`), non-empty string + * so `initContent` uncomments the `content.dir` line; otherwise `undefined` + * (the default commented placeholder). Never throws — a missing/unparseable + * root config yields `undefined` and the worktree gets the default scope. + * + * `content.dir` is relative to the project root, so the root's value is + * correct verbatim in the worktree — no path rewriting. + */ +export function readRootContentDir(mainRoot: string): string | undefined { + const configPath = `${mainRoot}/.ok/config.yml`; + if (!existsSync(configPath)) return undefined; + let parsed: unknown; + try { + parsed = parseYaml(readFileSync(configPath, 'utf-8')); + } catch { + return undefined; + } + if (!isObject(parsed)) return undefined; + const content = parsed.content; + if (!isObject(content)) return undefined; + const dir = content.dir; + if (typeof dir !== 'string') return undefined; + const trimmed = dir.trim(); + return trimmed.length > 0 && trimmed !== '.' ? dir : undefined; +} + +/** + * True iff the editor's project MCP config at `configPath` exists and carries + * an OK chain sentinel (any version). Analogous to `editorWiredForOk` in + * `skill-reclaim.ts`. Fail-soft: an unreadable file is treated as not-wired. + */ +function editorWiredForOk(configPath: string | undefined): boolean { + if (!configPath) return false; + try { + if (!existsSync(configPath)) return false; + const bytes = readFileSync(configPath, 'utf-8'); + return bytes.includes(OK_MCP_MARKER_PREFIX); + } catch { + return false; + } +} + +/** + * The set of editors the ROOT project has wired for OK — each editor whose + * project MCP config (`EDITOR_TARGETS[id].projectConfigPath(mainRoot)`) carries + * an OK sentinel. Editors with no project-scope surface (`claude-desktop`, + * `openclaw`) have no `projectConfigPath` and are never returned. Order follows + * `ALL_EDITOR_IDS`. + */ +export function detectRootWiredEditors(mainRoot: string): EditorId[] { + const wired: EditorId[] = []; + for (const id of ALL_EDITOR_IDS) { + const projectConfigPath = EDITOR_TARGETS[id]?.projectConfigPath?.(mainRoot); + if (editorWiredForOk(projectConfigPath)) wired.push(id); + } + return wired; +} + +/** + * Seed the new worktree's `.ok/` scaffold and editor/MCP wiring from the root + * project, so the worktree opens `managed` (no consent dialog) with full setup + * parity. Idempotent and best-effort — see the module docstring. Never throws. + */ +export function seedWorktreeProjectSetup(worktreePath: string, mainRoot: string): void { + const logger = getLogger('worktree-setup'); + + // 1. `.ok/config.yml` (+ `.ok/.gitignore` + `.okignore`) — the marker that + // suppresses the consent dialog. `writeIfMissing`, so a committed config + // already in the worktree is never clobbered. `initContent` can throw only + // via its symlink guard; contain it so wiring still gets a chance and the + // worktree open never fails on a seed error. + try { + initContent(worktreePath, { contentDir: readRootContentDir(mainRoot) }); + } catch (err) { + logger.warn( + { worktreePath, err: err instanceof Error ? err.message : String(err) }, + 'failed to seed inherited .ok/ scaffold', + ); + } + + // 2. Editor/MCP wiring, mirroring exactly the editors the root has wired. + // `writeProjectAiIntegrations` never throws; per-(editor × integration) + // failures land in its outcomes. Nothing to do when the root wired no + // project-scope editors. + try { + const editors = detectRootWiredEditors(mainRoot); + if (editors.length > 0) { + const result = writeProjectAiIntegrations(worktreePath, editors); + const failed = result.integrations.filter((o) => o.action === 'failed'); + if (failed.length > 0) { + logger.warn( + { worktreePath, editors, failed: failed.map((o) => `${o.editorId}:${o.integration}`) }, + 'some inherited editor integrations failed to seed', + ); + } + } + } catch (err) { + // Defensive: the orchestrator is contract-bound not to throw, but a future + // change must never let a wiring error abort the worktree open. + logger.warn( + { worktreePath, err: err instanceof Error ? err.message : String(err) }, + 'failed to seed inherited editor integrations', + ); + } +}