diff --git a/docs/reference.md b/docs/reference.md index 8c2b4b1..ebbd4b8 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -461,3 +461,26 @@ Most commands accept the same root controls: - `--root /path/to/.ai`: use an explicit canonical root - `--scope merged|global|project`: choose a discovery view - `--source builtin|global|project`: filter provenance in list/find/show/graph flows + +### Scheduled review preflight + +Before a scheduled review, verify that `fclt --version` succeeds from its configured +working directory, then run `fclt ai loop preflight --project --root .ai --json` +in the same execution environment. Use `--global` with the global canonical root +for a global review. Stop if a runtime manager requires trust; do not automatically +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 +reservation or a guarantee against a later permission change. Configure narrowly +scoped host write allowances; fclt does not alter the host sandbox policy. + +Only after preflight succeeds, invoke `fclt ai loop run --project --root .ai --scheduled --json` once. Errors before a report exists return JSON with +`queueAvailable: false`; do not interpret missing queue data as an empty queue. +Large loop JSON is flushed before exit so it can be piped to a bounded projection. + +Automatic drafting supports Markdown targets. Other targets remain proposed and +visible for manual implementation; a skipped `draft-proposal` mutation explains +why no draft was produced. These proposals do not authorize a canonical edit. diff --git a/src/ai-cli.test.ts b/src/ai-cli.test.ts index f91862a..d6bad66 100644 --- a/src/ai-cli.test.ts +++ b/src/ai-cli.test.ts @@ -45,10 +45,18 @@ async function captureConsole(fn: () => Promise) { afterEach(async () => { process.chdir(originalCwd); - process.env.HOME = originalHome; - process.env.FACULT_ROOT_DIR = originalRoot; - process.env.FACULT_ROOT_SCOPE = originalRootScope; - process.env.FACULT_LOCAL_STATE_DIR = originalLocalState; + for (const [key, value] of Object.entries({ + HOME: originalHome, + FACULT_ROOT_DIR: originalRoot, + FACULT_ROOT_SCOPE: originalRootScope, + FACULT_LOCAL_STATE_DIR: originalLocalState, + })) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } process.exitCode = 0; if (tempHome) { await rm(tempHome, { recursive: true, force: true }); @@ -57,6 +65,64 @@ afterEach(async () => { }); describe("ai CLI", () => { + it("returns JSON recovery when the loop fails before a report exists", async () => { + tempHome = await makeTempHome(); + process.env.HOME = tempHome; + process.env.FACULT_ROOT_DIR = join(tempHome, ".ai"); + process.chdir(tempHome); + const out = await captureConsole(async () => { + await aiCommand(["loop", "run", "--global", "--json"]); + }); + expect(out.errors).toEqual([]); + expect(process.exitCode).toBe(1); + expect(JSON.parse(out.logs.join("\n"))).toMatchObject({ + status: "failed", + phase: "command", + queueAvailable: false, + error: expect.stringContaining("disabled"), + recovery: expect.stringContaining("preflight"), + }); + }); + + it.each([ + "run", + "preflight", + ])("returns JSON for %s context resolution failures", async (sub) => { + tempHome = await makeTempHome(); + process.env.HOME = tempHome; + Reflect.deleteProperty(process.env, "FACULT_ROOT_DIR"); + Reflect.deleteProperty(process.env, "FACULT_ROOT_SCOPE"); + process.chdir(tempHome); + const out = await captureConsole(async () => { + await aiCommand(["loop", sub, "--project", "--json"]); + }); + expect(out.errors).toEqual([]); + expect(process.exitCode).toBe(1); + expect(JSON.parse(out.logs.join("\n"))).toMatchObject({ + status: "failed", + phase: "command", + queueAvailable: false, + error: expect.any(String), + }); + }); + + it("reports a disabled preflight without invoking the loop", async () => { + tempHome = await makeTempHome(); + process.env.HOME = tempHome; + process.env.FACULT_ROOT_DIR = join(tempHome, ".ai"); + process.chdir(tempHome); + const out = await captureConsole(async () => { + await aiCommand(["loop", "preflight", "--global", "--json"]); + }); + expect(out.errors).toEqual([]); + expect(process.exitCode).toBe(1); + expect(JSON.parse(out.logs.join("\n"))).toMatchObject({ + status: "blocked", + enabled: false, + loopInvoked: false, + }); + }); + it("initializes and runs a structured source review through the ai namespace", async () => { tempHome = await makeTempHome(); process.env.HOME = tempHome; @@ -289,7 +355,7 @@ describe("ai CLI", () => { `fclt ai loop run --global --root '${rootDir}' --scheduled --json` ); await aiCommand(["loop", "run", "--global", "--root", rootDir, "--json"]); - process.env.FACULT_ROOT_DIR = undefined; + Reflect.deleteProperty(process.env, "FACULT_ROOT_DIR"); const allActivityOut = await captureConsole(async () => { await aiCommand([ "loop", @@ -1162,8 +1228,8 @@ describe("ai CLI", () => { it("keeps custom-global writeback, review, and apply state in global scope", async () => { tempHome = await makeTempHome(); process.env.HOME = tempHome; - process.env.FACULT_ROOT_DIR = undefined; - process.env.FACULT_ROOT_SCOPE = undefined; + Reflect.deleteProperty(process.env, "FACULT_ROOT_DIR"); + Reflect.deleteProperty(process.env, "FACULT_ROOT_SCOPE"); process.env.FACULT_LOCAL_STATE_DIR = join(tempHome, "state"); const rootDir = join(tempHome, "shared", ".ai"); const defaultTarget = join( diff --git a/src/ai.ts b/src/ai.ts index d47cd08..1a239f6 100644 --- a/src/ai.ts +++ b/src/ai.ts @@ -439,6 +439,15 @@ async function firstExistingFile(paths: string[]): Promise { return null; } +export class UnsupportedProposalTargetError extends Error { + constructor(pathValue: string) { + super( + `Automatic drafting and apply support markdown targets only: ${pathValue}. Keep this proposal for manual implementation.` + ); + this.name = "UnsupportedProposalTargetError"; + } +} + function supportedDraftTarget(pathValue: string): boolean { return pathValue.toLowerCase().endsWith(".md"); } @@ -2177,9 +2186,7 @@ async function resolveProposalTargetNode( throw new Error(`Could not resolve target path for ${target}`); } if (!supportedDraftTarget(pathValue)) { - throw new Error( - `Apply currently supports markdown targets only: ${pathValue}` - ); + throw new UnsupportedProposalTargetError(pathValue); } return { ...node, @@ -2825,6 +2832,7 @@ Usage: fclt ai loop activity [--all|--global|--project] [--json] fclt ai loop resolve [--json] fclt ai loop history [--all|--global|--project] [--since ] [--until ] [--item ] [--scope-id ] [--event ] [--limit <1-200>] [--cursor ] [--json] + fclt ai loop preflight [--json] fclt ai loop run [--since ] [--until ] [--source ] [--dry-run] [--scheduled] [--json] The loop keeps a full machine-local review queue and emits a delta for @@ -2990,27 +2998,44 @@ async function loopCommand(argv: string[]) { } return; } - const rootDir = resolveCliContextRoot({ - rootArg: parsed.rootArg, - scope: parsed.scope, - cwd: process.cwd(), - }); - const homeDir = process.env.HOME ?? ""; - const loopScope = - parsed.scope === "global" || parsed.scope === "project" - ? parsed.scope - : projectRootFromAiRoot(rootDir, homeDir) - ? "project" - : "global"; const json = commandArgs.includes("--json"); - const { - disableEvolutionLoop, - enableEvolutionLoop, - evolutionLoopStatus, - latestEvolutionLoopReport, - runEvolutionLoop, - } = await import("./evolution-loop"); try { + const rootDir = resolveCliContextRoot({ + rootArg: parsed.rootArg, + scope: parsed.scope, + cwd: process.cwd(), + }); + const homeDir = process.env.HOME ?? ""; + const loopScope = + parsed.scope === "global" || parsed.scope === "project" + ? parsed.scope + : projectRootFromAiRoot(rootDir, homeDir) + ? "project" + : "global"; + const { + disableEvolutionLoop, + enableEvolutionLoop, + evolutionLoopStatus, + latestEvolutionLoopReport, + runEvolutionLoop, + } = await import("./evolution-loop"); + if (sub === "preflight") { + const { preflightEvolutionLoop } = await import("./evolution-preflight"); + const result = await preflightEvolutionLoop({ + homeDir, + rootDir, + scope: loopScope, + }); + await writeCliOutput( + json + ? JSON.stringify(result, null, 2) + : `loop preflight: ${result.status}\n${result.recovery ?? "Required paths are writable"}` + ); + if (result.status !== "ready") { + process.exitCode = 1; + } + return; + } if (sub === "enable") { const result = await enableEvolutionLoop({ homeDir, @@ -3020,7 +3045,7 @@ async function loopCommand(argv: string[]) { sourceIds: parseRepeatedFlag(commandArgs, "--source"), dryRun: commandArgs.includes("--dry-run"), }); - console.log( + await writeCliOutput( json ? JSON.stringify(result, null, 2) : `${result.dryRun ? "Would enable" : "Enabled"} evolution loop at ${result.automationPath}` @@ -3037,7 +3062,7 @@ async function loopCommand(argv: string[]) { if (!(result.dryRun || result.scheduler?.paused || !result.config)) { process.exitCode = 1; } - console.log( + await writeCliOutput( json ? JSON.stringify(result, null, 2) : result.config @@ -3054,7 +3079,7 @@ async function loopCommand(argv: string[]) { rootDir, scope: loopScope, }); - console.log( + await writeCliOutput( json ? JSON.stringify(result, null, 2) : [ @@ -3075,7 +3100,7 @@ async function loopCommand(argv: string[]) { if (!result) { throw new Error("No evolution loop report has been recorded"); } - console.log( + await writeCliOutput( json ? JSON.stringify(result, null, 2) : [ @@ -3225,7 +3250,7 @@ async function loopCommand(argv: string[]) { dryRun: commandArgs.includes("--dry-run"), trigger: commandArgs.includes("--scheduled") ? "scheduled" : "manual", }); - console.log( + await writeCliOutput( json ? JSON.stringify(result, null, 2) : [ @@ -3243,7 +3268,25 @@ async function loopCommand(argv: string[]) { } throw new Error(`Unknown loop command: ${sub}`); } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); + const message = error instanceof Error ? error.message : String(error); + if (json && (sub === "run" || sub === "preflight")) { + await writeCliOutput( + JSON.stringify( + { + status: "failed", + phase: "command", + queueAvailable: false, + error: message, + recovery: + "Run fclt ai loop preflight for this scope in the same execution environment before another review.", + }, + null, + 2 + ) + ); + } else { + console.error(message); + } process.exitCode = 1; } } @@ -3747,23 +3790,49 @@ export async function aiCommand( } if (!rootScopeActive) { - const parsed = parseCliContextArgs(rest); - const homeDir = process.env.HOME ?? ""; - const rootDir = resolveCliContextRoot({ - homeDir, - rootArg: parsed.rootArg, - scope: parsed.scope, - cwd: process.cwd(), - }); - const scope = resolveCliContextScope({ - homeDir, - rootDir, - scope: parsed.scope, - }); - await withFacultRootScope({ rootDir, scope }, async () => - aiCommand(argv, true) - ); - return; + try { + const parsed = parseCliContextArgs(rest); + const homeDir = process.env.HOME ?? ""; + const rootDir = resolveCliContextRoot({ + homeDir, + rootArg: parsed.rootArg, + scope: parsed.scope, + cwd: process.cwd(), + }); + const scope = resolveCliContextScope({ + homeDir, + rootDir, + scope: parsed.scope, + }); + await withFacultRootScope({ rootDir, scope }, async () => + aiCommand(argv, true) + ); + return; + } catch (error) { + if ( + sub !== "loop" || + !rest.includes("--json") || + (rest[0] !== "run" && rest[0] !== "preflight") + ) { + throw error; + } + await writeCliOutput( + JSON.stringify( + { + status: "failed", + phase: "command", + queueAvailable: false, + error: error instanceof Error ? error.message : String(error), + recovery: + "Resolve the project or global scope before running fclt ai loop preflight in the same execution environment.", + }, + null, + 2 + ) + ); + process.exitCode = 1; + return; + } } if (sub === "writeback") { diff --git a/src/evolution-loop.test.ts b/src/evolution-loop.test.ts index 8cea7cd..ac206b9 100644 --- a/src/evolution-loop.test.ts +++ b/src/evolution-loop.test.ts @@ -1687,6 +1687,60 @@ describe("evolution loop", () => { }); }); + it.each([ + false, + true, + ])("keeps unsupported proposal targets pending without duplication (recovery=%s)", async (recover) => { + const project = await makeProject(); + const targetPath = join( + project.projectRoot, + ".github", + "workflows", + "release.yml" + ); + await mkdir(dirname(targetPath), { recursive: true }); + const original = "name: Release\non: push\n"; + await Bun.write(targetPath, original); + await Bun.write( + join(project.projectRoot, "review.md"), + "## 2026-01-02 Capability review\n\nThe rule in @project/.github/workflows/release.yml needs a durable verification loop.\n" + ); + await enableEvolutionLoop(project); + if (recover) { + const writeback = await addWriteback({ + ...project, + kind: "capability_gap", + summary: "The workflow needs a durable verification loop", + suggestedDestination: "@project/.github/workflows/release.yml", + evidence: [{ type: "session", ref: "interrupted-draft" }], + }); + await proposeEvolution({ ...project, writebackIds: [writeback.id] }); + expect(await listProposals(project)).toHaveLength(1); + } + for (const date of ["2026-01-04", "2026-01-05"]) { + const report = await runEvolutionLoop({ + ...project, + since: "2026-01-01", + until: "2026-01-03", + now: () => new Date(`${date}T00:00:00.000Z`), + }); + expect(report.status).toBe("complete"); + expect(report.coverageComplete).toBe(true); + if (date === "2026-01-04") { + expect(report.mutations).toContainEqual( + expect.objectContaining({ type: "draft-proposal", applied: false }) + ); + } + const proposals = await listProposals(project); + expect(proposals).toHaveLength(1); + expect(proposals[0]?.status).toBe("proposed"); + expect(report.queue).toContainEqual( + expect.objectContaining({ kind: "proposal", state: "approval_needed" }) + ); + expect(await readFile(targetPath, "utf8")).toBe(original); + } + }); + it("records committed mutations when a later materialization step fails", async () => { const project = await makeProject(); await mkdir(join(project.rootDir, "instructions"), { recursive: true }); diff --git a/src/evolution-loop.ts b/src/evolution-loop.ts index b61835f..c74fe49 100644 --- a/src/evolution-loop.ts +++ b/src/evolution-loop.ts @@ -23,6 +23,7 @@ import { listProposals, listWritebacks, proposeEvolution, + UnsupportedProposalTargetError, } from "./ai"; import { facultAiEvolutionLoopAuditPath, @@ -485,7 +486,7 @@ async function appendLoopAudit( ); } -async function loadConfig(args: { +export async function loadEvolutionLoopConfig(args: { homeDir: string; rootDir: string; }): Promise { @@ -598,7 +599,7 @@ async function enableEvolutionLoopScoped(args: { dryRun: boolean; }> { const now = (args.now?.() ?? new Date()).toISOString(); - const current = await loadConfig(args); + const current = await loadEvolutionLoopConfig(args); const inferredScope = projectRootFromAiRoot(args.rootDir, args.homeDir) ? "project" : "global"; @@ -730,7 +731,7 @@ async function disableEvolutionLoopScoped(args: { dryRun: boolean; scheduler: { paused: boolean; error?: string } | null; }> { - const current = await loadConfig(args); + const current = await loadEvolutionLoopConfig(args); if (!current) { return { config: null, @@ -1187,6 +1188,30 @@ async function materializeSignals(args: { if (!args.review.coverageComplete) { return plans; } + const draftForReview = async (proposal: AiProposalRecord, reason: string) => { + try { + const drafted = await draftProposal(proposal.id, { + homeDir: args.homeDir, + rootDir: args.rootDir, + }); + await recordPlan({ + type: "draft-proposal", + target: drafted.id, + reason, + applied: true, + }); + } catch (error) { + if (!(error instanceof UnsupportedProposalTargetError)) { + throw error; + } + await recordPlan({ + type: "draft-proposal", + target: proposal.id, + reason: error.message, + applied: false, + }); + } + }; const existing = await listWritebacks({ homeDir: args.homeDir, rootDir: args.rootDir, @@ -1336,16 +1361,10 @@ async function materializeSignals(args: { }); } if (activeProposal.status === "proposed") { - const drafted = await draftProposal(activeProposal.id, { - homeDir: args.homeDir, - rootDir: args.rootDir, - }); - await recordPlan({ - type: "draft-proposal", - target: drafted.id, - reason: "Recovered an existing undrafted proposal from a prior run", - applied: true, - }); + await draftForReview( + activeProposal, + "Recovered an existing undrafted proposal from a prior run" + ); } continue; } @@ -1361,16 +1380,10 @@ async function materializeSignals(args: { reason: `Assessment recommended a proposal for ${target}`, applied: true, }); - const drafted = await draftProposal(proposal.id, { - homeDir: args.homeDir, - rootDir: args.rootDir, - }); - await recordPlan({ - type: "draft-proposal", - target: drafted.id, - reason: "Drafted the review artifact; canonical apply remains gated", - applied: true, - }); + await draftForReview( + proposal, + "Drafted the review artifact; canonical apply remains gated" + ); } } const projectRoot = @@ -1687,7 +1700,7 @@ async function evolutionLoopStatusScoped(args: { auditPath: string; reportDir: string; }> { - const config = await loadConfig(args); + const config = await loadEvolutionLoopConfig(args); const state = await loadState(args); const scheduler = config ? await automationStatus({ @@ -1774,7 +1787,7 @@ export async function diagnoseEvolutionLoop(args: { async () => { let config: EvolutionLoopConfig | null; try { - config = await loadConfig(args); + config = await loadEvolutionLoopConfig(args); } catch (error) { return { configurationState: "invalid" as const, @@ -2030,7 +2043,7 @@ async function runEvolutionLoopScoped(args: { onLockAcquired?: () => void | Promise; openLockFile?: (path: string, flags: "wx") => Promise; }): Promise { - const loadedConfig = await loadConfig(args); + const loadedConfig = await loadEvolutionLoopConfig(args); if (!(loadedConfig?.enabled || args.dryRun)) { throw new Error( "Evolution loop is disabled. Run `fclt ai loop enable` first." @@ -2061,7 +2074,7 @@ async function runEvolutionLoopScoped(args: { const lockPath = facultAiEvolutionLoopLockPath(args.homeDir, args.rootDir); const execute = async (): Promise => { if (!args.dryRun) { - const lockedConfig = await loadConfig(args); + const lockedConfig = await loadEvolutionLoopConfig(args); if (!lockedConfig?.enabled) { throw new Error( "Evolution loop is disabled. Run `fclt ai loop enable` first." diff --git a/src/evolution-preflight.test.ts b/src/evolution-preflight.test.ts new file mode 100644 index 0000000..0ad23a7 --- /dev/null +++ b/src/evolution-preflight.test.ts @@ -0,0 +1,101 @@ +import { afterEach, expect, it } from "bun:test"; +import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { enableEvolutionLoop } from "./evolution-loop"; +import { preflightEvolutionLoop } from "./evolution-preflight"; +import { + facultAiActivityHistorySegmentDir, + facultAiDraftDir, + facultAiEvolutionLoopConfigPath, + facultAiEvolutionLoopStatePath, + facultAiEvolutionReviewDir, + facultAiReconciliationStatePath, +} from "./paths"; + +const roots: string[] = []; +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); +async function setup(scope: "project" | "global") { + const homeDir = await mkdtemp(join(tmpdir(), "fclt-preflight-test-")); + roots.push(homeDir); + const rootDir = + scope === "project" ? join(homeDir, "repo", ".ai") : join(homeDir, ".ai"); + await mkdir(rootDir, { recursive: true }); + await enableEvolutionLoop({ homeDir, rootDir, scope }); + return { homeDir, rootDir, scope }; +} +for (const scope of ["project", "global"] as const) { + it(`preflights ${scope} writes without creating queue state or leaving probes`, async () => { + const args = await setup(scope); + const result = await preflightEvolutionLoop(args); + expect(result.status).toBe("ready"); + expect(result.loopInvoked).toBe(false); + expect( + await Bun.file( + facultAiEvolutionLoopStatePath(args.homeDir, args.rootDir) + ).exists() + ).toBe(false); + for (const check of result.checks) { + expect(check.writable).toBe(true); + expect( + (await readdir(check.path)).some((name) => + name.startsWith(".fclt-preflight-") + ) + ).toBe(false); + } + }); +} +it("reports an inaccessible review destination before loop execution", async () => { + const args = await setup("project"); + const path = facultAiEvolutionReviewDir(args.homeDir, args.rootDir); + await mkdir(dirname(path), { recursive: true }); + await Bun.write(path, "occupied"); + const result = await preflightEvolutionLoop(args); + expect(result.status).toBe("blocked"); + expect(result.loopInvoked).toBe(false); + expect(result.checks).toContainEqual( + expect.objectContaining({ path, writable: false }) + ); + expect(result.recovery).toContain(path); + expect( + await Bun.file( + facultAiEvolutionLoopStatePath(args.homeDir, args.rootDir) + ).exists() + ).toBe(false); +}); + +it("does not report malformed enabled configuration as ready", async () => { + const args = await setup("project"); + await Bun.write( + facultAiEvolutionLoopConfigPath(args.homeDir, args.rootDir), + JSON.stringify({ enabled: true }) + ); + const result = await preflightEvolutionLoop(args); + expect(result.status).toBe("blocked"); + expect(result.configError).toContain("schema"); + expect(result.loopInvoked).toBe(false); +}); + +for (const destination of [ + (home: string, root: string) => + dirname(facultAiReconciliationStatePath(home, root)), + facultAiActivityHistorySegmentDir, + facultAiDraftDir, +]) { + it("blocks on an unavailable runtime sibling even when the loop directory is writable", async () => { + const args = await setup("project"); + const path = destination(args.homeDir, args.rootDir); + await mkdir(dirname(path), { recursive: true }); + await Bun.write(path, "occupied"); + const result = await preflightEvolutionLoop(args); + expect(result.status).toBe("blocked"); + expect(result.checks).toContainEqual( + expect.objectContaining({ path, writable: false }) + ); + expect(result.loopInvoked).toBe(false); + }); +} diff --git a/src/evolution-preflight.ts b/src/evolution-preflight.ts new file mode 100644 index 0000000..453a1f9 --- /dev/null +++ b/src/evolution-preflight.ts @@ -0,0 +1,97 @@ +import { mkdir, mkdtemp, rmdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { loadEvolutionLoopConfig } from "./evolution-loop"; +import { + facultAiActivityHistoryDir, + facultAiActivityHistorySegmentDir, + facultAiDraftDir, + facultAiEvolutionLoopConfigPath, + facultAiEvolutionLoopLockPath, + facultAiEvolutionLoopReportDir, + facultAiEvolutionReviewDir, + facultAiJournalPath, + facultAiProposalDir, + facultAiReconciliationReviewDir, + facultAiReconciliationStatePath, + facultAiWritebackQueuePath, + facultAiWritebackReviewDir, + projectRootFromAiRoot, + withFacultRootScope, +} from "./paths"; + +/** Probe the actual execution environment without reconciling or creating queue state. */ +export async function preflightEvolutionLoop(args: { + homeDir: string; + rootDir: string; + scope?: "project" | "global"; +}) { + return await withFacultRootScope( + { + rootDir: args.rootDir, + scope: + args.scope ?? + (projectRootFromAiRoot(args.rootDir, args.homeDir) + ? "project" + : "global"), + }, + async () => { + const checks: Array<{ path: string; writable: boolean; error?: string }> = + []; + let enabled = false; + let configError: string | undefined; + try { + const config = await loadEvolutionLoopConfig(args); + enabled = config?.enabled === true; + } catch (error) { + configError = error instanceof Error ? error.message : String(error); + } + const paths = new Set([ + facultAiActivityHistoryDir(args.homeDir, args.rootDir), + facultAiActivityHistorySegmentDir(args.homeDir, args.rootDir), + facultAiDraftDir(args.homeDir, args.rootDir), + dirname(facultAiJournalPath(args.homeDir, args.rootDir)), + facultAiProposalDir(args.homeDir, args.rootDir), + dirname(facultAiReconciliationStatePath(args.homeDir, args.rootDir)), + dirname(facultAiWritebackQueuePath(args.homeDir, args.rootDir)), + + dirname(facultAiEvolutionLoopConfigPath(args.homeDir, args.rootDir)), + dirname(facultAiEvolutionLoopLockPath(args.homeDir, args.rootDir)), + facultAiEvolutionLoopReportDir(args.homeDir, args.rootDir), + facultAiEvolutionReviewDir(args.homeDir, args.rootDir), + facultAiReconciliationReviewDir(args.homeDir, args.rootDir), + facultAiWritebackReviewDir(args.homeDir, args.rootDir), + ]); + for (const path of paths) { + try { + await mkdir(path, { recursive: true }); + const probe = await mkdtemp(join(path, ".fclt-preflight-")); + await rmdir(probe); + checks.push({ path, writable: true }); + } catch (error) { + checks.push({ + path, + writable: false, + error: error instanceof Error ? error.message : String(error), + }); + } + } + const denied = checks.filter((check) => !check.writable); + return { + version: 1, + status: enabled && denied.length === 0 ? "ready" : "blocked", + queueAvailable: false, + loopInvoked: false, + runtime: process.execPath, + enabled, + configError, + checks, + recovery: + denied.length > 0 + ? `Authorize writes to these fclt state/review directories in the task execution environment: ${denied.map((check) => check.path).join(", ")}. Run preflight again before invoking the loop.` + : enabled + ? null + : "Inspect the loop configuration and enable the intended scope before invoking the loop.", + }; + } + ); +} diff --git a/src/paths.test.ts b/src/paths.test.ts index f147b76..19c3c33 100644 --- a/src/paths.test.ts +++ b/src/paths.test.ts @@ -21,6 +21,7 @@ import { } from "./paths"; const ORIGINAL_HOME = process.env.HOME; +const ORIGINAL_ROOT = process.env.FACULT_ROOT_DIR; const ORIGINAL_ROOT_SCOPE = process.env.FACULT_ROOT_SCOPE; let tempHome: string | null = null; @@ -52,9 +53,17 @@ afterEach(async () => { await rm(tempHome, { recursive: true, force: true }); } tempHome = null; - process.env.HOME = ORIGINAL_HOME; - process.env.FACULT_ROOT_DIR = undefined; - process.env.FACULT_ROOT_SCOPE = ORIGINAL_ROOT_SCOPE; + for (const [key, value] of Object.entries({ + HOME: ORIGINAL_HOME, + FACULT_ROOT_DIR: ORIGINAL_ROOT, + FACULT_ROOT_SCOPE: ORIGINAL_ROOT_SCOPE, + })) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } }); describe("paths", () => { diff --git a/src/project-render-apply.test.ts b/src/project-render-apply.test.ts index 575f9bd..53bde9f 100644 --- a/src/project-render-apply.test.ts +++ b/src/project-render-apply.test.ts @@ -550,6 +550,9 @@ sources = ["AGENTS.project.md"] ); unblock?.(); await first; + // A rejected contender must close its descriptor, not leave it to GC. + Bun.gc(true); + await Bun.sleep(20); }); it("fails closed on a malformed ownership receipt", async () => { diff --git a/src/project-render-apply.ts b/src/project-render-apply.ts index 001b460..54e6a01 100644 --- a/src/project-render-apply.ts +++ b/src/project-render-apply.ts @@ -933,22 +933,24 @@ async function withMutationLock( constants.O_CREAT + constants.O_RDWR + (constants.O_NOFOLLOW ?? 0), 0o600 ); - const metadata = await descriptor.stat(); - const expectedOwner = process.getuid?.(); - if ( - !metadata.isFile() || - metadata.nlink !== 1 || - metadata.mode % 0o100 !== 0 || - (expectedOwner !== undefined && metadata.uid !== expectedOwner) - ) { - await descriptor.close(); - throw new Error("Project render mutation lock is unsafe."); - } - const release = acquireExclusiveAdvisoryLock(descriptor.fd); try { - return await operation(); + const metadata = await descriptor.stat(); + const expectedOwner = process.getuid?.(); + if ( + !metadata.isFile() || + metadata.nlink !== 1 || + metadata.mode % 0o100 !== 0 || + (expectedOwner !== undefined && metadata.uid !== expectedOwner) + ) { + throw new Error("Project render mutation lock is unsafe."); + } + const release = acquireExclusiveAdvisoryLock(descriptor.fd); + try { + return await operation(); + } finally { + release(); + } } finally { - release(); await descriptor.close(); } } diff --git a/src/projects.test.ts b/src/projects.test.ts index bcf9ab5..fdb40fb 100644 --- a/src/projects.test.ts +++ b/src/projects.test.ts @@ -1909,9 +1909,8 @@ ghs_APP_ID.${"a".repeat(240)}.${"b".repeat(240)} const updated = nextPlan.canonicalWrites[1]?.content ?? ""; expect(Bun.TOML.parse(updated)).toMatchObject({ "after]quoted": { owned: true }, - custom: { - description: "\n[project]\nthis is authored text, not a table header", - }, + custom: (Bun.TOML.parse(existing) as { custom: { description: string } }) + .custom, project: { cadence: "weekly" }, }); expect(updated).toContain(authoredPrefix); diff --git a/src/remote.ts b/src/remote.ts index f843231..3ecf851 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -602,7 +602,7 @@ Keep the result concise, continuity-aware, and operational. Retain coverage in t `, prompt: `Goal: run the configured fclt closed-loop evolution review for this cwd and report only decision-relevant changes. -Run \`fclt ai loop run {{loopScopeFlag}} {{loopRootArg}} --scheduled --json\` exactly once from the configured cwd. The rendered command uses the native shell contract: PowerShell on Windows and POSIX shell syntax elsewhere. +Before the review, run \`fclt --version\` from the configured cwd. If the runtime manager reports an untrusted configuration, stop and report that trust boundary; never automatically trust or bypass it. Then run \`fclt ai loop preflight {{loopScopeFlag}} {{loopRootArg}} --json\` in the same execution environment. This checks machine-local state and review-directory writes without invoking the loop. If preflight is blocked, report the exact paths and recovery action; do not invoke the loop or claim queue coverage. Do not change sandbox policy automatically.\n\nAfter preflight reports ready, run \`fclt ai loop run {{loopScopeFlag}} {{loopRootArg}} --scheduled --json\` exactly once from the configured cwd. The rendered command uses the native shell contract: PowerShell on Windows and POSIX shell syntax elsewhere. Use the returned full queue for current truth, but keep the user-facing notification delta-only: - report new or changed decisions,