Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
80 changes: 73 additions & 7 deletions src/ai-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,18 @@ async function captureConsole(fn: () => Promise<void>) {

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 });
Expand All @@ -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;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down
159 changes: 114 additions & 45 deletions src/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,15 @@ async function firstExistingFile(paths: string[]): Promise<string | null> {
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");
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2825,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 preflight [--json]
fclt ai loop run [--since <date>] [--until <date>] [--source <configured-id>] [--dry-run] [--scheduled] [--json]

The loop keeps a full machine-local review queue and emits a delta for
Expand Down Expand Up @@ -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,
Expand All @@ -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}`
Expand All @@ -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
Expand All @@ -3054,7 +3079,7 @@ async function loopCommand(argv: string[]) {
rootDir,
scope: loopScope,
});
console.log(
await writeCliOutput(
json
? JSON.stringify(result, null, 2)
: [
Expand All @@ -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)
: [
Expand Down Expand Up @@ -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)
: [
Expand All @@ -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")) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle context-resolution failures in JSON mode

The new JSON error handler only covers exceptions raised after entering this try, while parseCliContextArgs, resolveCliContextRoot, scope inference, and the evolution-loop import all run beforehand. For example, loop run --project --json from a directory without a resolvable project throws during context resolution and produces an unstructured top-level error instead of the promised {status:"failed", queueAvailable:false} response. Move context setup inside the JSON-aware error boundary so automation can reliably parse every pre-report failure.

AGENTS.md reference: AGENTS.md:L130-L130

Useful? React with 👍 / 👎.

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;
}
}
Expand Down Expand Up @@ -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")
) {
Comment on lines +3812 to +3816

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Identify the parsed subcommand before filtering JSON errors

Fresh evidence after the context-boundary fix is that parseCliContextArgs accepts context flags anywhere, but this check inspects the raw first argument. Thus fclt ai loop --project run --json from outside a project fails context resolution and is rethrown as an unstructured top-level error because rest[0] is --project, despite being semantically identical to the covered loop run --project --json form. Base this decision on the parsed argv/subcommand so every supported ordering preserves the JSON error contract.

AGENTS.md reference: AGENTS.md:L130-L130

Useful? React with 👍 / 👎.

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") {
Expand Down
Loading
Loading