Skip to content
17 changes: 17 additions & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,23 @@ Most commands accept the same root controls:
- `--scope merged|global|project`: choose a discovery view
- `--source builtin|global|project`: filter provenance in list/find/show/graph flows


### Scheduler ownership recovery

New evolution-loop enrollments store an identity-bound ownership receipt in
fclt machine-local state under `automations/codex/`. It binds the automation ID, creation time,
and working directories, so native edits that drop custom TOML fields do not
lose ownership. Re-enabling preserves the existing prompt, model, target,
notification settings, and memory; an explicit cadence update changes only the
requested cadence and status fields.

For an older configured loop whose ownership marker was already lost, inspect
`fclt ai loop repair-scheduler --project --root .ai --dry-run --json`. After
confirming the exact task, use `--approve` instead of `--dry-run` to record its
ownership receipt. Global loops use `--global` and their canonical root. Repair
requires the configured task ID and expected working directory to match, rejects
an explicit different owner, and leaves the task contents and paused/active
status unchanged. It does not enable a schedule or adopt a task by name alone.
### Scheduled review preflight

Before a scheduled review, verify that `fclt --version` succeeds from its configured
Expand Down
16 changes: 16 additions & 0 deletions src/activity-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,17 +215,33 @@ function captureConsole(
const errors: string[] = [];
const originalLog = console.log;
const originalError = console.error;
const originalWrite = process.stdout.write;
process.stdout.write = ((
chunk: string | Uint8Array,
encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),
callback?: (error?: Error | null) => void
) => {
logs.push(
typeof chunk === "string" ? chunk : Buffer.from(chunk).toString()
);
const done =
typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
done?.();
return true;
}) as typeof process.stdout.write;
console.log = (...args: unknown[]) => logs.push(args.join(" "));
console.error = (...args: unknown[]) => errors.push(args.join(" "));
return operation().then(
() => {
console.log = originalLog;
console.error = originalError;
process.stdout.write = originalWrite;
return { errors, logs };
},
(error) => {
console.log = originalLog;
console.error = originalError;
process.stdout.write = originalWrite;
throw error;
}
);
Expand Down
72 changes: 71 additions & 1 deletion src/ai-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,20 @@ async function captureConsole(fn: () => Promise<void>) {
const errors: string[] = [];
const prevLog = console.log;
const prevError = console.error;
const prevWrite = process.stdout.write;
process.stdout.write = ((
chunk: string | Uint8Array,
encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),
callback?: (error?: Error | null) => void
) => {
logs.push(
typeof chunk === "string" ? chunk : Buffer.from(chunk).toString()
);
const done =
typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
done?.();
return true;
}) as typeof process.stdout.write;
console.log = (...args: Parameters<typeof console.log>) => {
logs.push(args.map((value) => String(value)).join(" "));
};
Expand All @@ -39,6 +53,7 @@ async function captureConsole(fn: () => Promise<void>) {
} finally {
console.log = prevLog;
console.error = prevError;
process.stdout.write = prevWrite;
}
return { logs, errors };
}
Expand All @@ -65,6 +80,61 @@ afterEach(async () => {
});

describe("ai CLI", () => {
it("repairs a configured scheduler through the CLI without activating it", async () => {
tempHome = await makeTempHome();
process.env.HOME = tempHome;
process.env.FACULT_ROOT_DIR = join(tempHome, ".ai");
process.env.FACULT_LOCAL_STATE_DIR = join(tempHome, "state");
process.chdir(tempHome);
const { enableEvolutionLoop } = await import("./evolution-loop");
const { facultCodexAutomationOwnershipPath } = await import("./paths");
const enabled = await enableEvolutionLoop({
homeDir: tempHome,
rootDir: process.env.FACULT_ROOT_DIR,
scope: "global",
});
const path = join(enabled.automationPath, "automation.toml");
const current = (await Bun.file(path).text())
.replace('managed_by = "fclt-evolution-loop"\n', "")
.replace('status = "ACTIVE"', 'status = "PAUSED"');
await Bun.write(path, current);
await rm(
facultCodexAutomationOwnershipPath(
tempHome,
enabled.config.automationName
)
);
const preview = await captureConsole(async () => {
await aiCommand([
"loop",
"repair-scheduler",
"--global",
"--dry-run",
"--json",
]);
});
expect(preview.errors).toEqual([]);
expect(JSON.parse(preview.logs.join("\n"))).toMatchObject({
repaired: false,
status: "PAUSED",
});
const applied = await captureConsole(async () => {
await aiCommand([
"loop",
"repair-scheduler",
"--global",
"--approve",
"--json",
]);
});
expect(applied.errors).toEqual([]);
expect(JSON.parse(applied.logs.join("\n"))).toMatchObject({
repaired: true,
status: "PAUSED",
});
expect(await Bun.file(path).text()).toBe(current);
});

it("returns JSON recovery when the loop fails before a report exists", async () => {
tempHome = await makeTempHome();
process.env.HOME = tempHome;
Expand Down Expand Up @@ -418,7 +488,7 @@ describe("ai CLI", () => {
automationPath,
(await Bun.file(automationPath).text()).replace(
'managed_by = "fclt-evolution-loop"\n',
""
'managed_by = "another-owner"\n'
)
);
const disabledOut = await captureConsole(async () => {
Expand Down
17 changes: 17 additions & 0 deletions src/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2832,6 +2832,7 @@ Usage:
fclt ai loop activity [--all|--global|--project] [--json]
fclt ai loop resolve <activity-action-locator> [--json]
fclt ai loop history [--all|--global|--project] [--since <date>] [--until <date>] [--item <id>] [--scope-id <opaque-id>] [--event <type>] [--limit <1-200>] [--cursor <cursor>] [--json]
fclt ai loop repair-scheduler [--approve] [--dry-run] [--json]
fclt ai loop preflight [--json]
fclt ai loop run [--since <date>] [--until <date>] [--source <configured-id>] [--dry-run] [--scheduled] [--json]

Expand Down Expand Up @@ -3018,7 +3019,23 @@ async function loopCommand(argv: string[]) {
evolutionLoopStatus,
latestEvolutionLoopReport,
runEvolutionLoop,
repairEvolutionLoopScheduler,
} = await import("./evolution-loop");
if (sub === "repair-scheduler") {
const result = await repairEvolutionLoopScheduler({
homeDir,
rootDir,
scope: loopScope,
approve: commandArgs.includes("--approve"),
dryRun: commandArgs.includes("--dry-run"),
});
await writeCliOutput(
json
? JSON.stringify(result, null, 2)
: `${result.repaired ? "Repaired" : "Would repair"} scheduler ownership at ${result.automationPath}`
);
return;
}
if (sub === "preflight") {
const { preflightEvolutionLoop } = await import("./evolution-preflight");
const result = await preflightEvolutionLoop({
Expand Down
161 changes: 140 additions & 21 deletions src/evolution-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
disableEvolutionLoop,
enableEvolutionLoop,
evolutionLoopStatus,
repairEvolutionLoopScheduler,
runEvolutionLoop,
} from "./evolution-loop";
import {
Expand All @@ -45,10 +46,17 @@ import {
facultAiReconciliationLockPath,
facultAiReconciliationStatePath,
facultAiWritebackQueuePath,
facultCodexAutomationOwnershipPath,
withFacultRootScope,
} from "./paths";
import { reconcileSources } from "./reconciliation";

const AUTOMATION_PROMPT_RE = /^prompt = .*$/m;
const AUTOMATION_MODEL_RE = /^model = .*$/m;
const AUTOMATION_REASONING_RE = /^reasoning_effort = .*$/m;
const AUTOMATION_RRULE_RE = /^rrule = .*$/m;
const AUTOMATION_CREATED_RE = /^created_at = .*$/m;
const AUTOMATION_CWDS_RE = /^cwds = .*$/m;
const SIGNAL_FAMILY_ID_RE = /^SF-/;
const COMPLETED_RUN_STATUS_RE = /^(complete|degraded)$/;
const temporaryRoots: string[] = [];
Expand Down Expand Up @@ -1350,33 +1358,144 @@ describe("evolution loop", () => {
).toBe(false);
});

it("refuses to update a scheduler after its ownership marker is removed", async () => {
const project = await makeProject();
const enabled = await enableEvolutionLoop({
...project,
now: () => new Date("2026-01-03T00:00:00.000Z"),
});
it.each([
"project",
"global",
] as const)("preserves %s scheduler ownership and native edits after a marker-stripping update", async (scope) => {
const fixture = await makeProject();
const project = {
...fixture,
rootDir:
scope === "global" ? join(fixture.homeDir, ".ai") : fixture.rootDir,
scope,
};
await mkdir(project.rootDir, { recursive: true });
const enabled = await enableEvolutionLoop(project);
const automationPath = join(enabled.automationPath, "automation.toml");
const current = await readFile(automationPath, "utf8");
const updated =
(await readFile(automationPath, "utf8"))
.replace('managed_by = "fclt-evolution-loop"\n', "")
.replace(
AUTOMATION_PROMPT_RE,
'prompt = "Custom approved review and archive policy"'
)
.replace(AUTOMATION_MODEL_RE, 'model = "custom-model"')
.replace(AUTOMATION_REASONING_RE, 'reasoning_effort = "medium"')
.replace(AUTOMATION_RRULE_RE, 'rrule = "RRULE:FREQ=WEEKLY;BYDAY=FR"') +
'\nnotification_policy = "failed_runs_only"\nexecution_environment = "local"\ntarget = { type = "project", project_id = "saved-project" }\n';
await Bun.write(automationPath, updated);
await Bun.write(
automationPath,
current.replace('managed_by = "fclt-evolution-loop"\n', "")
join(enabled.automationPath, "memory.md"),
"Retained automation history\n"
);
expect((await evolutionLoopStatus(project)).scheduler.registered).toBe(
true
);
const reenabled = await enableEvolutionLoop(project);
expect(reenabled.config.rrule).toContain("WEEKLY");
const after = Bun.TOML.parse(
await readFile(automationPath, "utf8")
) as Record<string, unknown>;
const before = Bun.TOML.parse(updated) as Record<string, unknown>;
for (const key of [
"prompt",
"model",
"reasoning_effort",
"cwds",
"rrule",
"created_at",
"target",
"notification_policy",
"execution_environment",
]) {
expect(after[key]).toEqual(before[key]);
}
expect(
await readFile(join(enabled.automationPath, "memory.md"), "utf8")
).toBe("Retained automation history\n");
expect((await disableEvolutionLoop(project)).scheduler?.paused).toBe(true);
expect((await evolutionLoopStatus(project)).scheduler.registered).toBe(
true
);
});

it("refuses a status edit that would match authored multiline prompt content", async () => {
const project = await makeProject();
const enabled = await enableEvolutionLoop(project);
const path = join(enabled.automationPath, "automation.toml");
const current = (await readFile(path, "utf8")).replace(
AUTOMATION_PROMPT_RE,
"prompt = '''\nstatus = \"ACTIVE\"\nupdated_at = 1\nKeep this authored example.\n'''"
);
await Bun.write(path, current);
expect((await disableEvolutionLoop(project)).scheduler?.paused).toBe(false);
expect(await readFile(path, "utf8")).toBe(current);
});

it("repairs legacy ownership only with explicit approval and preserves the complete paused task", async () => {
const project = await makeProject();
const enabled = await enableEvolutionLoop(project);
const path = join(enabled.automationPath, "automation.toml");
const current = (await readFile(path, "utf8"))
.replace('managed_by = "fclt-evolution-loop"\n', "")
.replace('status = "ACTIVE"', 'status = "PAUSED"');
await Bun.write(path, current);
await rm(
facultCodexAutomationOwnershipPath(
project.homeDir,
enabled.config.automationName
)
);
expect((await evolutionLoopStatus(project)).scheduler.registered).toBe(
false
);
await expect(
enableEvolutionLoop({
...project,
rrule: "RRULE:FREQ=WEEKLY;BYDAY=FR",
})
).rejects.toThrow("not owned by the fclt evolution loop");
expect(await readFile(automationPath, "utf8")).not.toContain(
"RRULE:FREQ=WEEKLY"
repairEvolutionLoopScheduler({ ...project, scope: "project" })
).rejects.toThrow("--approve");
const preview = await repairEvolutionLoopScheduler({
...project,
scope: "project",
dryRun: true,
});
expect(preview.repaired).toBe(false);
expect((await evolutionLoopStatus(project)).scheduler.registered).toBe(
false
);
const disabled = await disableEvolutionLoop(project);
expect(disabled.config?.enabled).toBe(false);
expect(disabled.scheduler?.paused).toBe(false);
expect(disabled.scheduler?.error).toContain("not owned");
expect((await evolutionLoopStatus(project)).health).toBe("disabled");
await repairEvolutionLoopScheduler({
...project,
scope: "project",
approve: true,
});
expect((await evolutionLoopStatus(project)).scheduler).toMatchObject({
registered: true,
status: "PAUSED",
});
expect(await readFile(path, "utf8")).toBe(current);
});

it("rejects a replaced or moved scheduler despite a same-name ownership receipt", async () => {
const project = await makeProject();
const enabled = await enableEvolutionLoop(project);
const path = join(enabled.automationPath, "automation.toml");
const current = (await readFile(path, "utf8")).replace(
'managed_by = "fclt-evolution-loop"\n',
""
);
for (const edited of [
current.replace(AUTOMATION_CREATED_RE, "created_at = 1"),
current.replace(AUTOMATION_CWDS_RE, 'cwds = ["/different-project"]'),
`${current}\nmanaged_by = "another-owner"\n`,
]) {
await Bun.write(path, edited);
expect((await evolutionLoopStatus(project)).scheduler.registered).toBe(
false
);
await expect(enableEvolutionLoop(project)).rejects.toThrow("not owned");
expect((await disableEvolutionLoop(project)).scheduler?.paused).toBe(
false
);
expect(await readFile(path, "utf8")).toBe(edited);
}
});

it("refuses to replace a partial scheduler directory", async () => {
Expand Down
Loading
Loading