From a29dcd252fbf9c6a427d359b471823bd5d6b8bca Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 12 Oct 2026 13:04:41 -0400 Subject: [PATCH 1/4] fix(loop): block preflight in generated worktrees without review config (HACK-1045) Generated/linked worktrees never carry the gitignored .ai review config, and machine state is keyed by per-worktree execution identity, so scheduled preflight reported a generic config error and created orphan per-worktree state directories. Detect linked worktrees (git-dir != git-common-dir, with inherited Git env stripped), return a specific blocker naming the primary checkout, and skip write probes. Add structured blocker codes for other cases. --- src/evolution-preflight.test.ts | 173 +++++++++++++++++++++++++++++++- src/evolution-preflight.ts | 103 ++++++++++++++++++- 2 files changed, 274 insertions(+), 2 deletions(-) diff --git a/src/evolution-preflight.test.ts b/src/evolution-preflight.test.ts index 3d0422d..d1c6f4e 100644 --- a/src/evolution-preflight.test.ts +++ b/src/evolution-preflight.test.ts @@ -1,7 +1,8 @@ import { afterEach, expect, it } from "bun:test"; -import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readdir, realpath, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; +import { runFixtureGit } from "../test/git-fixture"; import { enableEvolutionLoop } from "./evolution-loop"; import { preflightEvolutionLoop } from "./evolution-preflight"; import { @@ -146,3 +147,173 @@ for (const scenario of [ ).toBe(false); }); } + +it("blocks a generated worktree without loop config and names the primary checkout", async () => { + const homeDir = await realpath( + await mkdtemp(join(tmpdir(), "fclt-preflight-wt-")) + ); + roots.push(homeDir); + const primary = join(homeDir, "repo"); + await mkdir(join(primary, ".ai"), { recursive: true }); + const git = (cwd: string, ...argv: string[]) => + runFixtureGit({ + argv: ["-C", cwd, ...argv], + repoDir: primary, + homeDir: join(homeDir, ".git-home"), + }); + await git(primary, "init", "-q"); + await Bun.write(join(primary, ".gitignore"), ".ai/\n"); + await git(primary, "add", ".gitignore"); + await git( + primary, + "-c", + "user.name=fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "-qm", + "init" + ); + await Bun.write( + join(primary, ".ai", "reconciliation.json"), + JSON.stringify({ + version: 1, + sources: [{ id: "writebacks", type: "writebacks" }], + }) + ); + await enableEvolutionLoop({ + homeDir, + rootDir: join(primary, ".ai"), + scope: "project", + }); + const worktree = join(homeDir, "wt"); + await git(primary, "worktree", "add", "-q", "--detach", worktree); + const rootDir = join(worktree, ".ai"); + const result = await preflightEvolutionLoop({ + homeDir, + rootDir, + scope: "project", + }); + expect(result.status).toBe("blocked"); + expect(result.blocker).toBe("linked-worktree-without-loop-config"); + expect(result.primaryCheckout).toBe(primary); + expect(result.loopInvoked).toBe(false); + expect(result.checks).toEqual([]); + expect(result.recovery).toContain(primary); + const primaryResult = await preflightEvolutionLoop({ + homeDir, + rootDir: join(primary, ".ai"), + scope: "project", + }); + expect(primaryResult.status).toBe("ready"); + expect(primaryResult.blocker).toBeNull(); + const previous = { + dir: process.env.GIT_DIR, + common: process.env.GIT_COMMON_DIR, + }; + process.env.GIT_DIR = join(homeDir, "missing-git-dir"); + process.env.GIT_COMMON_DIR = join(homeDir, "missing-common-dir"); + try { + const inherited = await preflightEvolutionLoop({ + homeDir, + rootDir, + scope: "project", + }); + expect(inherited.blocker).toBe("linked-worktree-without-loop-config"); + expect(inherited.primaryCheckout).toBe(primary); + expect(inherited.checks).toEqual([]); + } finally { + for (const [name, value] of [ + ["GIT_DIR", previous.dir], + ["GIT_COMMON_DIR", previous.common], + ] as const) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + } +}); + +it("does not report a configured but disabled linked worktree as missing config", async () => { + const homeDir = await realpath( + await mkdtemp(join(tmpdir(), "fclt-preflight-wt-")) + ); + roots.push(homeDir); + const primary = join(homeDir, "repo"); + await mkdir(primary, { recursive: true }); + const git = (cwd: string, ...argv: string[]) => + runFixtureGit({ + argv: ["-C", cwd, ...argv], + repoDir: primary, + homeDir: join(homeDir, ".git-home"), + }); + await git(primary, "init", "-q"); + await git( + primary, + "-c", + "user.name=fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "-q", + "--allow-empty", + "-m", + "init" + ); + const worktree = join(homeDir, "wt"); + await git(primary, "worktree", "add", "-q", "--detach", worktree); + const rootDir = join(worktree, ".ai"); + await mkdir(rootDir, { recursive: true }); + await Bun.write( + join(rootDir, "reconciliation.json"), + JSON.stringify({ + version: 1, + sources: [{ id: "writebacks", type: "writebacks" }], + }) + ); + await enableEvolutionLoop({ homeDir, rootDir, scope: "project" }); + const config = JSON.parse( + await Bun.file(facultAiEvolutionLoopConfigPath(homeDir, rootDir)).text() + ); + await Bun.write( + facultAiEvolutionLoopConfigPath(homeDir, rootDir), + JSON.stringify({ ...config, enabled: false }) + ); + const result = await preflightEvolutionLoop({ + homeDir, + rootDir, + scope: "project", + }); + expect(result.status).toBe("blocked"); + expect(result.blocker).toBe("loop-disabled"); + expect(result.checks.length).toBeGreaterThan(0); +}); + +it("does not treat a --separate-git-dir checkout as a generated worktree", async () => { + const homeDir = await realpath( + await mkdtemp(join(tmpdir(), "fclt-preflight-sep-")) + ); + roots.push(homeDir); + const primary = join(homeDir, "repo"); + await mkdir(primary, { recursive: true }); + await runFixtureGit({ + argv: [ + "init", + "-q", + "--separate-git-dir", + join(homeDir, "meta.git"), + primary, + ], + repoDir: primary, + homeDir: join(homeDir, ".git-home"), + }); + const result = await preflightEvolutionLoop({ + homeDir, + rootDir: join(primary, ".ai"), + scope: "project", + }); + expect(result.blocker).not.toBe("linked-worktree-without-loop-config"); + expect(result.loopInvoked).toBe(false); +}); diff --git a/src/evolution-preflight.ts b/src/evolution-preflight.ts index 6b32322..36febb2 100644 --- a/src/evolution-preflight.ts +++ b/src/evolution-preflight.ts @@ -1,5 +1,7 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, lstatSync } from "node:fs"; import { mkdir, mkdtemp, rmdir } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; import { loadEvolutionLoopConfig } from "./evolution-loop"; import { facultAiActivityHistoryDir, @@ -22,6 +24,66 @@ import { loadReconciliationConfig, selectReconciliationSources, } from "./reconciliation-config"; +import { withoutLocalGitEnvironment } from "./util/git-environment"; + +/** + * Resolve the primary checkout when a project root is a linked (generated) Git + * worktree. `linked` is true whenever `.git` is a pointer file; `primary` is + * null when it cannot be resolved (the worktree is still treated as linked). + */ +export function linkedWorktreePrimaryCheckout(projectRoot: string): { + linked: boolean; + primary: string | null; +} { + const unlinked = { linked: false, primary: null }; + try { + if (!lstatSync(join(projectRoot, ".git")).isFile()) { + return unlinked; + } + } catch { + return unlinked; + } + // A `.git` pointer file alone also describes submodules and + // `--separate-git-dir` checkouts. Only a linked worktree has a private Git + // directory that differs from the shared common directory. + const result = spawnSync( + "git", + [ + "-C", + projectRoot, + "rev-parse", + "--path-format=absolute", + "--git-dir", + "--git-common-dir", + ], + { + encoding: "utf8", + env: { + ...withoutLocalGitEnvironment(process.env), + GIT_OPTIONAL_LOCKS: "0", + }, + stdio: ["ignore", "pipe", "ignore"], + } + ); + const [gitDir, commonDir] = + result.status === 0 && typeof result.stdout === "string" + ? result.stdout.trim().split("\n") + : []; + if (!(gitDir && commonDir)) { + // Unresolvable pointer: treat as linked so preflight never probes writes. + return { linked: true, primary: null }; + } + if (resolve(gitDir) === resolve(commonDir)) { + return unlinked; + } + const common = resolve(commonDir); + const primary = basename(common) === ".git" ? dirname(common) : null; + return { + linked: true, + primary: + primary && resolve(primary) !== resolve(projectRoot) ? primary : null, + }; +} /** Probe the actual execution environment without reconciling or creating queue state. */ export async function preflightEvolutionLoop(args: { @@ -43,14 +105,45 @@ export async function preflightEvolutionLoop(args: { []; let enabled = false; let configError: string | undefined; + let loopConfigAbsent = false; try { const config = await loadEvolutionLoopConfig(args); + loopConfigAbsent = config === null; enabled = config?.enabled === true; const reconciliation = await loadReconciliationConfig(args); selectReconciliationSources(reconciliation.config, config?.sourceIds); } catch (error) { configError = error instanceof Error ? error.message : String(error); } + const projectRoot = projectRootFromAiRoot(args.rootDir, args.homeDir); + const worktree = projectRoot + ? linkedWorktreePrimaryCheckout(projectRoot) + : { linked: false, primary: null }; + const reviewConfigAbsent = + loopConfigAbsent || + !existsSync(join(args.rootDir, "reconciliation.json")); + if (worktree.linked && reviewConfigAbsent) { + const primaryCheckout = worktree.primary; + const target = primaryCheckout + ? `the primary checkout ${primaryCheckout}` + : "the repository's primary checkout (it could not be resolved from this worktree)"; + // `.ai/` review configuration is machine-local and ignored, so a + // generated worktree never carries the enrolled loop. Probing writes + // here would only create orphaned per-worktree state directories. + return { + version: 1, + status: "blocked" as const, + blocker: "linked-worktree-without-loop-config", + queueAvailable: false, + loopInvoked: false, + runtime: process.execPath, + enabled, + configError, + primaryCheckout, + checks: [], + recovery: `This is a generated/linked worktree without the enrolled review configuration. Run the scheduled review from ${target} (task execution environment: local, not a generated worktree). Do not invoke the loop here.`, + }; + } const paths = new Set([ facultAiActivityHistoryDir(args.homeDir, args.rootDir), facultAiActivityHistorySegmentDir(args.homeDir, args.rootDir), @@ -86,6 +179,14 @@ export async function preflightEvolutionLoop(args: { version: 1, status: enabled && !configError && denied.length === 0 ? "ready" : "blocked", + blocker: + denied.length > 0 + ? "state-write-denied" + : configError + ? "loop-config-invalid" + : enabled + ? null + : "loop-disabled", queueAvailable: false, loopInvoked: false, runtime: process.execPath, From 6336a8c230317d3997b957d139c98dcb034eb834 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 12 Oct 2026 13:11:21 -0400 Subject: [PATCH 2/4] fix(loop): close HACK-1045 review gaps in worktree preflight - Always ask git (with a 5 s timeout) so .ai in a worktree subdirectory is caught - Resolve the primary checkout for worktrees of submodules via core.worktree - Report missing/hung/unreadable git as worktree-unresolved, not a guess - Drop the misleading 'review init' hint in the worktree blocker - Always include primaryCheckout; document blocker codes in docs/reference.md - Tests: no per-worktree state created, subdirectory .ai, submodule worktree, unresolvable pointer, missing git, stronger separate-git-dir assertions --- docs/reference.md | 15 ++- src/evolution-preflight.test.ts | 163 ++++++++++++++++++++++++++++++++ src/evolution-preflight.ts | 154 +++++++++++++++++++----------- 3 files changed, 278 insertions(+), 54 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index 6889e01..bc7b614 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -500,7 +500,20 @@ trust a generated checkout or bypass the runtime manager. Preflight creates missing state/review directories and removes temporary write probes. It does not invoke reconciliation, acquire the semantic loop lock, or create queue state. Its `ready`/`blocked` result reports each required directory -and the recovery action. A successful probe is point-in-time evidence, not a +(`checks`), a machine-readable `blocker` code, `primaryCheckout`, and the +recovery action. Blocker codes: `null` (ready), `state-write-denied`, +`loop-config-invalid`, `loop-disabled`, `linked-worktree-without-loop-config`, +and `worktree-unresolved`. + +Generated or linked Git worktrees do not carry the ignored `.ai/` review +configuration. When preflight runs in one (including from a subdirectory) and +the configuration is absent, it returns `linked-worktree-without-loop-config` +with `primaryCheckout` set when it can be resolved (including worktrees of +submodules), probes no directories, and creates no per-worktree state. Run the +scheduled review from the primary checkout instead; do not run +`fclt ai review init` in the worktree. If Git is unavailable, times out, or +cannot read existing metadata, preflight returns `worktree-unresolved` instead +of guessing. A successful probe is point-in-time evidence, not a reservation or a guarantee against a later permission change. Configure narrowly scoped host write allowances; fclt does not alter the host sandbox policy. diff --git a/src/evolution-preflight.test.ts b/src/evolution-preflight.test.ts index d1c6f4e..e645b02 100644 --- a/src/evolution-preflight.test.ts +++ b/src/evolution-preflight.test.ts @@ -315,5 +315,168 @@ it("does not treat a --separate-git-dir checkout as a generated worktree", async scope: "project", }); expect(result.blocker).not.toBe("linked-worktree-without-loop-config"); + expect(result.blocker).not.toBe("worktree-unresolved"); + expect(result.checks.length).toBeGreaterThan(0); expect(result.loopInvoked).toBe(false); }); + +async function worktreeFixture(prefix: string) { + const homeDir = await realpath(await mkdtemp(join(tmpdir(), prefix))); + roots.push(homeDir); + const gitHome = join(homeDir, ".git-home"); + const git = (repo: string, ...argv: string[]) => + runFixtureGit({ + argv: ["-C", repo, ...argv], + repoDir: repo, + homeDir: gitHome, + }); + const commit = (repo: string) => + git( + repo, + "-c", + "user.name=fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "-q", + "--allow-empty", + "-m", + "init" + ); + return { homeDir, git, commit }; +} + +function stateDirsFor(homeDir: string, rootDir: string) { + return [ + facultAiEvolutionReviewDir(homeDir, rootDir), + facultAiDraftDir(homeDir, rootDir), + dirname(facultAiEvolutionLoopConfigPath(homeDir, rootDir)), + dirname(facultAiReconciliationStatePath(homeDir, rootDir)), + ]; +} + +for (const subdir of ["", "packages/app"]) { + it(`creates no per-worktree state for a generated worktree (.ai at ${subdir || "root"})`, async () => { + const { homeDir, git, commit } = + await worktreeFixture("fclt-preflight-ns-"); + const primary = join(homeDir, "repo"); + await mkdir(primary, { recursive: true }); + await git(primary, "init", "-q"); + await commit(primary); + const worktree = join(homeDir, "wt"); + await git(primary, "worktree", "add", "-q", "--detach", worktree); + const rootDir = join(worktree, subdir, ".ai"); + await mkdir(rootDir, { recursive: true }); + const dirs = stateDirsFor(homeDir, rootDir); + const result = await preflightEvolutionLoop({ + homeDir, + rootDir, + scope: "project", + }); + expect(result.blocker).toBe("linked-worktree-without-loop-config"); + expect(result.primaryCheckout).toBe(primary); + expect(result.checks).toEqual([]); + expect(result.configError ?? "").not.toContain("review init"); + expect(result.recovery).toContain("Do not run `fclt ai review init`"); + for (const dir of dirs) { + expect( + await readdir(dir).then( + () => true, + () => false + ) + ).toBe(false); + } + }); +} + +it("resolves the primary checkout for a worktree of a submodule", async () => { + const { homeDir, git, commit } = await worktreeFixture("fclt-preflight-sub-"); + const upstream = join(homeDir, "upstream"); + const superRepo = join(homeDir, "super"); + await mkdir(upstream, { recursive: true }); + await mkdir(superRepo, { recursive: true }); + await git(upstream, "init", "-q"); + await commit(upstream); + await git(superRepo, "init", "-q"); + await commit(superRepo); + await git( + superRepo, + "-c", + "protocol.file.allow=always", + "submodule", + "add", + "-q", + upstream, + "mod" + ); + const moduleRoot = join(superRepo, "mod"); + const worktree = join(homeDir, "modwt"); + await git(moduleRoot, "worktree", "add", "-q", "--detach", worktree); + const rootDir = join(worktree, ".ai"); + await mkdir(rootDir, { recursive: true }); + const result = await preflightEvolutionLoop({ + homeDir, + rootDir, + scope: "project", + }); + expect(result.blocker).toBe("linked-worktree-without-loop-config"); + expect(result.primaryCheckout).toBe(moduleRoot); + const plain = await preflightEvolutionLoop({ + homeDir, + rootDir: join(moduleRoot, ".ai"), + scope: "project", + }); + expect(plain.blocker).not.toBe("linked-worktree-without-loop-config"); +}); + +it("reports an unresolvable Git pointer as worktree-unresolved without probing", async () => { + const homeDir = await realpath( + await mkdtemp(join(tmpdir(), "fclt-preflight-bad-")) + ); + roots.push(homeDir); + const checkout = join(homeDir, "broken"); + await mkdir(join(checkout, ".ai"), { recursive: true }); + await Bun.write( + join(checkout, ".git"), + `gitdir: ${join(homeDir, "missing")}\n` + ); + const rootDir = join(checkout, ".ai"); + const result = await preflightEvolutionLoop({ + homeDir, + rootDir, + scope: "project", + }); + expect(result.blocker).toBe("worktree-unresolved"); + expect(result.checks).toEqual([]); + expect(result.recovery).toContain("could not read"); +}); + +it("reports missing git as worktree-unresolved with an accurate message", async () => { + const { homeDir, git, commit } = await worktreeFixture( + "fclt-preflight-nogit-" + ); + const primary = join(homeDir, "repo"); + await mkdir(primary, { recursive: true }); + await git(primary, "init", "-q"); + await commit(primary); + const worktree = join(homeDir, "wt"); + await git(primary, "worktree", "add", "-q", "--detach", worktree); + const rootDir = join(worktree, ".ai"); + await mkdir(rootDir, { recursive: true }); + const emptyBin = join(homeDir, "empty-bin"); + await mkdir(emptyBin); + const previousPath = process.env.PATH; + process.env.PATH = emptyBin; + try { + const result = await preflightEvolutionLoop({ + homeDir, + rootDir, + scope: "project", + }); + expect(result.blocker).toBe("worktree-unresolved"); + expect(result.recovery).toContain("not available on PATH"); + expect(result.checks).toEqual([]); + } finally { + process.env.PATH = previousPath; + } +}); diff --git a/src/evolution-preflight.ts b/src/evolution-preflight.ts index 36febb2..d916d2f 100644 --- a/src/evolution-preflight.ts +++ b/src/evolution-preflight.ts @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process"; -import { existsSync, lstatSync } from "node:fs"; +import { existsSync } from "node:fs"; import { mkdir, mkdtemp, rmdir } from "node:fs/promises"; import { basename, dirname, join, resolve } from "node:path"; import { loadEvolutionLoopConfig } from "./evolution-loop"; @@ -26,62 +26,102 @@ import { } from "./reconciliation-config"; import { withoutLocalGitEnvironment } from "./util/git-environment"; -/** - * Resolve the primary checkout when a project root is a linked (generated) Git - * worktree. `linked` is true whenever `.git` is a pointer file; `primary` is - * null when it cannot be resolved (the worktree is still treated as linked). - */ -export function linkedWorktreePrimaryCheckout(projectRoot: string): { +const GIT_TIMEOUT_MS = 5000; +const REVIEW_INIT_HINT_RE = /\s*Run fclt ai review init\.?/; + +export interface WorktreeClassification { linked: boolean; primary: string | null; -} { - const unlinked = { linked: false, primary: null }; - try { - if (!lstatSync(join(projectRoot, ".git")).isFile()) { - return unlinked; + /** Set when Git metadata exists but could not be inspected. */ + unresolved: string | null; +} + +function runGitProbe(argv: string[]) { + return spawnSync("git", argv, { + encoding: "utf8", + env: { + ...withoutLocalGitEnvironment(process.env), + GIT_OPTIONAL_LOCKS: "0", + }, + stdio: ["ignore", "pipe", "ignore"], + timeout: GIT_TIMEOUT_MS, + }); +} + +function hasGitMetadataAbove(start: string): boolean { + let current = resolve(start); + for (;;) { + if (existsSync(join(current, ".git"))) { + return true; } - } catch { - return unlinked; - } - // A `.git` pointer file alone also describes submodules and - // `--separate-git-dir` checkouts. Only a linked worktree has a private Git - // directory that differs from the shared common directory. - const result = spawnSync( - "git", - [ - "-C", - projectRoot, - "rev-parse", - "--path-format=absolute", - "--git-dir", - "--git-common-dir", - ], - { - encoding: "utf8", - env: { - ...withoutLocalGitEnvironment(process.env), - GIT_OPTIONAL_LOCKS: "0", - }, - stdio: ["ignore", "pipe", "ignore"], + const parent = dirname(current); + if (parent === current) { + return false; } - ); - const [gitDir, commonDir] = + current = parent; + } +} + +function submodulePrimary(commonDir: string): string | null { + const result = runGitProbe([ + `--git-dir=${commonDir}`, + "config", + "--get", + "core.worktree", + ]); + const value = + result.status === 0 && typeof result.stdout === "string" + ? result.stdout.trim() + : ""; + return value ? resolve(commonDir, value) : null; +} + +/** + * Classify the repository containing `projectRoot` (which may be a + * subdirectory). Only a linked worktree has a private Git directory that + * differs from the shared common directory; submodules and + * `--separate-git-dir` checkouts are not linked. + */ +export function linkedWorktreePrimaryCheckout( + projectRoot: string +): WorktreeClassification { + const result = runGitProbe([ + "-C", + projectRoot, + "rev-parse", + "--path-format=absolute", + "--git-dir", + "--git-common-dir", + "--show-toplevel", + ]); + const [gitDir, commonDir, topLevel] = result.status === 0 && typeof result.stdout === "string" ? result.stdout.trim().split("\n") : []; if (!(gitDir && commonDir)) { - // Unresolvable pointer: treat as linked so preflight never probes writes. - return { linked: true, primary: null }; + if (!hasGitMetadataAbove(projectRoot)) { + return { linked: false, primary: null, unresolved: null }; + } + const error = result.error as NodeJS.ErrnoException | undefined; + const unresolved = + error?.code === "ENOENT" + ? "git is not available on PATH in this execution environment" + : error?.code === "ETIMEDOUT" || result.signal + ? `git did not answer within ${GIT_TIMEOUT_MS} ms` + : "git could not read this repository's metadata"; + return { linked: false, primary: null, unresolved }; } if (resolve(gitDir) === resolve(commonDir)) { - return unlinked; + return { linked: false, primary: null, unresolved: null }; } const common = resolve(commonDir); - const primary = basename(common) === ".git" ? dirname(common) : null; + const primary = + basename(common) === ".git" ? dirname(common) : submodulePrimary(common); + const self = topLevel ? resolve(topLevel) : resolve(projectRoot); return { linked: true, - primary: - primary && resolve(primary) !== resolve(projectRoot) ? primary : null, + primary: primary && resolve(primary) !== self ? primary : null, + unresolved: null, }; } @@ -116,32 +156,39 @@ export async function preflightEvolutionLoop(args: { configError = error instanceof Error ? error.message : String(error); } const projectRoot = projectRootFromAiRoot(args.rootDir, args.homeDir); - const worktree = projectRoot + const worktree: WorktreeClassification = projectRoot ? linkedWorktreePrimaryCheckout(projectRoot) - : { linked: false, primary: null }; + : { linked: false, primary: null, unresolved: null }; const reviewConfigAbsent = loopConfigAbsent || !existsSync(join(args.rootDir, "reconciliation.json")); - if (worktree.linked && reviewConfigAbsent) { - const primaryCheckout = worktree.primary; - const target = primaryCheckout - ? `the primary checkout ${primaryCheckout}` - : "the repository's primary checkout (it could not be resolved from this worktree)"; + if ((worktree.linked || worktree.unresolved) && reviewConfigAbsent) { // `.ai/` review configuration is machine-local and ignored, so a // generated worktree never carries the enrolled loop. Probing writes // here would only create orphaned per-worktree state directories. + const primaryCheckout = worktree.primary; + const target = primaryCheckout + ? `the primary checkout ${primaryCheckout}` + : "the repository's primary checkout (it could not be resolved from here)"; + const recovery = worktree.unresolved + ? `Review configuration is absent and the checkout type could not be determined: ${worktree.unresolved}. Fix Git access in the task execution environment and run preflight again. Do not run \`fclt ai review init\` or invoke the loop here.` + : `This is a generated/linked worktree without the enrolled review configuration. Run the scheduled review from ${target} (task execution environment: local, not a generated worktree). Do not run \`fclt ai review init\` or invoke the loop here; that would create orphaned per-worktree state.`; return { version: 1, status: "blocked" as const, - blocker: "linked-worktree-without-loop-config", + blocker: worktree.unresolved + ? "worktree-unresolved" + : "linked-worktree-without-loop-config", queueAvailable: false, loopInvoked: false, runtime: process.execPath, enabled, - configError, + configError: configError + ? configError.replace(REVIEW_INIT_HINT_RE, "") + : undefined, primaryCheckout, checks: [], - recovery: `This is a generated/linked worktree without the enrolled review configuration. Run the scheduled review from ${target} (task execution environment: local, not a generated worktree). Do not invoke the loop here.`, + recovery, }; } const paths = new Set([ @@ -192,6 +239,7 @@ export async function preflightEvolutionLoop(args: { runtime: process.execPath, enabled, configError, + primaryCheckout: null, checks, recovery: denied.length > 0 From 30102901293af561c42df0c1ba294295b2bcecdb Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 12 Oct 2026 13:14:15 -0400 Subject: [PATCH 3/4] fix(loop): reject pre-2.31 rev-parse output and map subdirectory primaries Address second-round review: require exactly three absolute rev-parse paths (older git echoes --path-format) and report worktree-unresolved otherwise; point primaryCheckout at the matching subdirectory when .ai is nested; clarify docs that worktree-unresolved applies only when review config is also absent. --- docs/reference.md | 8 +++++--- src/evolution-preflight.test.ts | 2 +- src/evolution-preflight.ts | 34 ++++++++++++++++++++++++++++----- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index bc7b614..83ae2f6 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -511,9 +511,11 @@ the configuration is absent, it returns `linked-worktree-without-loop-config` with `primaryCheckout` set when it can be resolved (including worktrees of submodules), probes no directories, and creates no per-worktree state. Run the scheduled review from the primary checkout instead; do not run -`fclt ai review init` in the worktree. If Git is unavailable, times out, or -cannot read existing metadata, preflight returns `worktree-unresolved` instead -of guessing. A successful probe is point-in-time evidence, not a +`fclt ai review init` in the worktree. If Git is unavailable, times out, is +older than 2.31, or cannot read existing metadata while the review configuration +is also absent, preflight returns `worktree-unresolved` instead of guessing. +`primaryCheckout` names the matching subdirectory when `.ai` is not at the +repository root. A successful probe is point-in-time evidence, not a reservation or a guarantee against a later permission change. Configure narrowly scoped host write allowances; fclt does not alter the host sandbox policy. diff --git a/src/evolution-preflight.test.ts b/src/evolution-preflight.test.ts index e645b02..59a0957 100644 --- a/src/evolution-preflight.test.ts +++ b/src/evolution-preflight.test.ts @@ -374,7 +374,7 @@ for (const subdir of ["", "packages/app"]) { scope: "project", }); expect(result.blocker).toBe("linked-worktree-without-loop-config"); - expect(result.primaryCheckout).toBe(primary); + expect(result.primaryCheckout).toBe(join(primary, subdir)); expect(result.checks).toEqual([]); expect(result.configError ?? "").not.toContain("review init"); expect(result.recovery).toContain("Do not run `fclt ai review init`"); diff --git a/src/evolution-preflight.ts b/src/evolution-preflight.ts index d916d2f..9b190d7 100644 --- a/src/evolution-preflight.ts +++ b/src/evolution-preflight.ts @@ -1,7 +1,14 @@ import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import { mkdir, mkdtemp, rmdir } from "node:fs/promises"; -import { basename, dirname, join, resolve } from "node:path"; +import { + basename, + dirname, + isAbsolute, + join, + relative, + resolve, +} from "node:path"; import { loadEvolutionLoopConfig } from "./evolution-loop"; import { facultAiActivityHistoryDir, @@ -94,11 +101,24 @@ export function linkedWorktreePrimaryCheckout( "--git-common-dir", "--show-toplevel", ]); - const [gitDir, commonDir, topLevel] = + const lines = result.status === 0 && typeof result.stdout === "string" ? result.stdout.trim().split("\n") : []; - if (!(gitDir && commonDir)) { + // Git < 2.31 echoes the unknown --path-format option instead of failing; + // accept only exactly three absolute paths. + const parsed = + lines.length === 3 && lines.every((line) => isAbsolute(line)) ? lines : []; + const [gitDir, commonDir, topLevel] = parsed; + if (lines.length > 0 && parsed.length === 0) { + return { + linked: false, + primary: null, + unresolved: + "git returned unexpected rev-parse output (git 2.31 or newer is required)", + }; + } + if (!(gitDir && commonDir && topLevel)) { if (!hasGitMetadataAbove(projectRoot)) { return { linked: false, primary: null, unresolved: null }; } @@ -117,10 +137,14 @@ export function linkedWorktreePrimaryCheckout( const common = resolve(commonDir); const primary = basename(common) === ".git" ? dirname(common) : submodulePrimary(common); - const self = topLevel ? resolve(topLevel) : resolve(projectRoot); + const self = resolve(topLevel); + // Map a subdirectory .ai root to the matching subdirectory in the primary. + const matching = primary + ? join(primary, relative(self, resolve(projectRoot))) + : null; return { linked: true, - primary: primary && resolve(primary) !== self ? primary : null, + primary: primary && resolve(primary) !== self ? matching : null, unresolved: null, }; } From 7c353e6de49a57e65dc1796e1f801e0abb42d09b Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 12 Oct 2026 13:15:35 -0400 Subject: [PATCH 4/4] fix(loop): realpath the project root before mapping the primary checkout --show-toplevel is symlink-resolved, so a symlinked worktree path (macOS /tmp, /var/folders, symlinked home) mapped primaryCheckout back onto the worktree. Add a symlinked-path test for root and subdirectory .ai. --- src/evolution-preflight.test.ts | 35 ++++++++++++++++++++++++++++++++- src/evolution-preflight.ts | 13 ++++++++---- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/evolution-preflight.test.ts b/src/evolution-preflight.test.ts index 59a0957..10ebcdc 100644 --- a/src/evolution-preflight.test.ts +++ b/src/evolution-preflight.test.ts @@ -1,5 +1,12 @@ import { afterEach, expect, it } from "bun:test"; -import { mkdir, mkdtemp, readdir, realpath, rm } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + readdir, + realpath, + rm, + symlink, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { runFixtureGit } from "../test/git-fixture"; @@ -480,3 +487,29 @@ it("reports missing git as worktree-unresolved with an accurate message", async process.env.PATH = previousPath; } }); + +it("names the real primary checkout when the worktree path goes through a symlink", async () => { + const { homeDir, git, commit } = await worktreeFixture( + "fclt-preflight-link-" + ); + const primary = join(homeDir, "repo"); + await mkdir(primary, { recursive: true }); + await git(primary, "init", "-q"); + await commit(primary); + const worktree = join(homeDir, "wt"); + await git(primary, "worktree", "add", "-q", "--detach", worktree); + await mkdir(join(worktree, "packages", "app", ".ai"), { recursive: true }); + const alias = join(homeDir, "alias"); + await symlink(homeDir, alias); + for (const sub of ["", join("packages", "app")]) { + const rootDir = join(alias, "wt", sub, ".ai"); + await mkdir(rootDir, { recursive: true }); + const result = await preflightEvolutionLoop({ + homeDir, + rootDir, + scope: "project", + }); + expect(result.blocker).toBe("linked-worktree-without-loop-config"); + expect(result.primaryCheckout).toBe(join(primary, sub)); + } +}); diff --git a/src/evolution-preflight.ts b/src/evolution-preflight.ts index 9b190d7..9de8876 100644 --- a/src/evolution-preflight.ts +++ b/src/evolution-preflight.ts @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, realpathSync } from "node:fs"; import { mkdir, mkdtemp, rmdir } from "node:fs/promises"; import { basename, @@ -138,10 +138,15 @@ export function linkedWorktreePrimaryCheckout( const primary = basename(common) === ".git" ? dirname(common) : submodulePrimary(common); const self = resolve(topLevel); + // --show-toplevel is symlink-resolved; compare against the real project path. + let realProject = resolve(projectRoot); + try { + realProject = realpathSync.native(realProject); + } catch { + // Keep the lexical path; relative() below then degrades to the root. + } // Map a subdirectory .ai root to the matching subdirectory in the primary. - const matching = primary - ? join(primary, relative(self, resolve(projectRoot))) - : null; + const matching = primary ? join(primary, relative(self, realProject)) : null; return { linked: true, primary: primary && resolve(primary) !== self ? matching : null,