From eaa720d3a5f9e7f382e57f7ad1a5e90645d489c8 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 14 Sep 2026 12:33:07 -0400 Subject: [PATCH 1/2] fix: preserve review provenance and validate loop readiness --- docs/writeback-evolution.md | 22 +++++++++ src/activity-action-contract.ts | 4 ++ src/ai-cli.test.ts | 9 ++++ src/ai.ts | 69 ++++++++++++++++++--------- src/evolution-loop.test.ts | 82 +++++++++++++++++++++++++++++---- src/evolution-loop.ts | 42 +++++++++++------ src/evolution-preflight.test.ts | 47 +++++++++++++++++++ src/evolution-preflight.ts | 17 +++++-- src/reconciliation-config.ts | 42 +++++++++++++++-- src/reconciliation-types.ts | 1 + src/reconciliation.test.ts | 13 ++++++ src/reconciliation.ts | 42 ++++++++--------- 12 files changed, 317 insertions(+), 73 deletions(-) diff --git a/docs/writeback-evolution.md b/docs/writeback-evolution.md index 49476876..42f1e290 100644 --- a/docs/writeback-evolution.md +++ b/docs/writeback-evolution.md @@ -48,6 +48,28 @@ receipt. Global and plugin changes always remain proposal-only. Manual writeback remains useful, but it is no longer the only source of review signal. Setup creates `reconciliation.json` in the selected canonical root. +Keep the portable source definition in the project's canonical root and include +it in version control. A missing definition can be restored with `fclt ai review +init --project --dry-run --json`, followed by the same command without `--dry-run`. +Initialization preserves existing valid configuration and does not reset writebacks, +queues, or cursors. Run `fclt ai loop preflight --project --json` afterward: readiness +requires valid source configuration, a valid source selection, and writable state. +Missing or invalid configuration is deterministic and is not retried by the loop. + +Machine-specific scheduler identity, queues, cursors, and history belong in fclt's +OS application-data store. Review mirrors live under the global review root with +project identity. Do not move project evidence into the global queue to solve a +missing project configuration. Shared operating instructions and reusable source +recipes may be global; each project's source selection and capability decisions +remain explicit and project-scoped. + +The loop reuses source writebacks when creating proposals, preserving their original +context instead of copying them into synthetic capability gaps. A linked pending +proposal does not resolve its signal family. Use an explicit terminal disposition +only after outcome evidence supports it. Successful scans whose observed latest +source timestamp is already covered do not become stale merely because the source +is quiet; newer uncovered activity and unverified old cursors still warn. + Run a bounded review window before deciding that nothing is pending: ```bash diff --git a/src/activity-action-contract.ts b/src/activity-action-contract.ts index b1e6a8c0..1c121ac7 100644 --- a/src/activity-action-contract.ts +++ b/src/activity-action-contract.ts @@ -125,6 +125,10 @@ export function activityActionClass(args: { item: LoopQueueItem; proposal?: AiProposalRecord | null; }): ActivityActionClass | null { + // Linked signals remain open, but only the proposal owns its decision action. + if (args.item.kind === "signal" && args.item.proposalId) { + return null; + } if (args.item.state === "resolved" || args.item.state === "deferred") { return null; } diff --git a/src/ai-cli.test.ts b/src/ai-cli.test.ts index 913c9a32..49f84a51 100644 --- a/src/ai-cli.test.ts +++ b/src/ai-cli.test.ts @@ -1104,6 +1104,15 @@ describe("ai CLI", () => { expect(draftOut.errors).toEqual([]); expect(draftOut.logs.join("\n")).toContain("Drafted EV-00001"); + const reviewOut = await captureConsole(async () => { + await aiCommand(["evolve", "review", "EV-00001", "--json"]); + }); + expect(reviewOut.errors).toEqual([]); + expect(JSON.parse(reviewOut.logs.join(""))).toMatchObject({ + id: "EV-00001", + status: "in_review", + }); + const acceptOut = await captureConsole(async () => { await aiCommand(["evolve", "accept", "EV-00001"]); }); diff --git a/src/ai.ts b/src/ai.ts index 6c95b5cc..cded875c 100644 --- a/src/ai.ts +++ b/src/ai.ts @@ -3489,18 +3489,24 @@ async function writebackCommand(argv: string[]) { evidence: parseEvidence(commandArgs), }); if (commandArgs.includes("--json")) { - console.log(JSON.stringify(portableWritebackRecord(record), null, 2)); + await writeCliOutput( + JSON.stringify(portableWritebackRecord(record), null, 2) + ); return; } console.log(`Recorded writeback ${record.id}`); - console.log(JSON.stringify(portableWritebackRecord(record), null, 2)); + await writeCliOutput( + JSON.stringify(portableWritebackRecord(record), null, 2) + ); return; } if (sub === "list") { const rows = await listWritebacks({ rootDir }); if (commandArgs.includes("--json")) { - console.log(JSON.stringify(rows.map(portableWritebackRecord), null, 2)); + await writeCliOutput( + JSON.stringify(rows.map(portableWritebackRecord), null, 2) + ); return; } console.log(`writebacks root: ${rootDir}`); @@ -3527,7 +3533,7 @@ async function writebackCommand(argv: string[]) { ? await groupWritebacks({ rootDir, by: byValue }) : await summarizeWritebacks({ rootDir, by: byValue }); if (commandArgs.includes("--json")) { - console.log(JSON.stringify(rows, null, 2)); + await writeCliOutput(JSON.stringify(rows, null, 2)); return; } for (const row of rows) { @@ -3547,7 +3553,9 @@ async function writebackCommand(argv: string[]) { if (!row) { throw new Error(`Writeback not found: ${id}`); } - console.log(JSON.stringify(portableWritebackRecord(row), null, 2)); + await writeCliOutput( + JSON.stringify(portableWritebackRecord(row), null, 2) + ); return; } @@ -3558,8 +3566,12 @@ async function writebackCommand(argv: string[]) { throw new Error("writeback link requires an id and --issue"); } const row = await linkWritebackIssue(id, issue, { rootDir }); - console.log(`Linked ${row.id} to ${issue}`); - console.log(JSON.stringify(portableWritebackRecord(row), null, 2)); + if (!commandArgs.includes("--json")) { + console.log(`Linked ${row.id} to ${issue}`); + } + await writeCliOutput( + JSON.stringify(portableWritebackRecord(row), null, 2) + ); return; } @@ -3586,8 +3598,12 @@ async function writebackCommand(argv: string[]) { nextTrigger: parseStringFlag(commandArgs, "--next-trigger"), expectedOutcome: parseStringFlag(commandArgs, "--expected-outcome"), }); - console.log(`Updated disposition for ${row.id}`); - console.log(JSON.stringify(portableWritebackRecord(row), null, 2)); + if (!commandArgs.includes("--json")) { + console.log(`Updated disposition for ${row.id}`); + } + await writeCliOutput( + JSON.stringify(portableWritebackRecord(row), null, 2) + ); return; } @@ -3600,8 +3616,14 @@ async function writebackCommand(argv: string[]) { sub === "dismiss" ? await dismissWriteback(id, { rootDir }) : await promoteWriteback(id, { rootDir }); - console.log(`${sub === "dismiss" ? "Dismissed" : "Promoted"} ${row.id}`); - console.log(JSON.stringify(portableWritebackRecord(row), null, 2)); + if (!commandArgs.includes("--json")) { + console.log( + `${sub === "dismiss" ? "Dismissed" : "Promoted"} ${row.id}` + ); + } + await writeCliOutput( + JSON.stringify(portableWritebackRecord(row), null, 2) + ); return; } @@ -3639,7 +3661,7 @@ async function evolveCommand(argv: string[]) { asset: parseStringFlag(commandArgs, "--asset"), }); if (commandArgs.includes("--json")) { - console.log(JSON.stringify(assessment, null, 2)); + await writeCliOutput(JSON.stringify(assessment, null, 2)); return; } console.log(`recommendation: ${assessment.recommendation}`); @@ -3664,7 +3686,7 @@ async function evolveCommand(argv: string[]) { asset: parseStringFlag(commandArgs, "--asset"), }); if (commandArgs.includes("--json")) { - console.log(JSON.stringify(proposals, null, 2)); + await writeCliOutput(JSON.stringify(proposals, null, 2)); return; } for (const proposal of proposals) { @@ -3678,7 +3700,7 @@ async function evolveCommand(argv: string[]) { if (sub === "list") { const rows = await listProposals({ rootDir }); if (commandArgs.includes("--json")) { - console.log(JSON.stringify(rows, null, 2)); + await writeCliOutput(JSON.stringify(rows, null, 2)); return; } for (const row of rows) { @@ -3696,7 +3718,7 @@ async function evolveCommand(argv: string[]) { if (!row) { throw new Error(`Proposal not found: ${id}`); } - console.log(JSON.stringify(row, null, 2)); + await writeCliOutput(JSON.stringify(row, null, 2)); return; } @@ -3723,8 +3745,10 @@ async function evolveCommand(argv: string[]) { note: parseStringFlag(commandArgs, "--note"), allowEarly: commandArgs.includes("--allow-early"), }); - console.log(`Verified ${row.id} as ${effectiveness}`); - console.log(JSON.stringify(row, null, 2)); + if (!commandArgs.includes("--json")) { + console.log(`Verified ${row.id} as ${effectiveness}`); + } + await writeCliOutput(JSON.stringify(row, null, 2)); return; } @@ -3795,8 +3819,9 @@ async function evolveCommand(argv: string[]) { : sub === "promote" ? "Promoted" : "Applied"; - console.log(`${verb} ${row.id}`); - console.log(JSON.stringify(row, null, 2)); + await writeCliOutput( + `${commandArgs.includes("--json") ? "" : `${verb} ${row.id}\n`}${JSON.stringify(row, null, 2)}` + ); return; } @@ -3840,7 +3865,7 @@ async function reviewCommand(argv: string[]): Promise { dryRun: commandArgs.includes("--dry-run"), force: commandArgs.includes("--force"), }); - console.log( + await writeCliOutput( json ? JSON.stringify(result, null, 2) : `${result.created ? "Initialized" : "Using"} reconciliation config ${result.path}` @@ -3850,7 +3875,7 @@ async function reviewCommand(argv: string[]): Promise { if (sub === "status") { const { reconciliationStatus } = await import("./reconciliation"); const result = await reconciliationStatus({ homeDir, rootDir }); - console.log( + await writeCliOutput( json ? JSON.stringify(result, null, 2) : `reconciliation: ${result.configured ? (result.coverageState ?? "not-run") : "not-configured"}\nconfig: ${result.configPath}\nstate: ${result.statePath}` @@ -3872,7 +3897,7 @@ async function reviewCommand(argv: string[]): Promise { sourceIds: parseRepeatedFlag(commandArgs, "--source"), incremental: commandArgs.includes("--incremental"), }); - console.log( + await writeCliOutput( json ? JSON.stringify(result, null, 2) : [ diff --git a/src/evolution-loop.test.ts b/src/evolution-loop.test.ts index 5e29b746..eee8dbe7 100644 --- a/src/evolution-loop.test.ts +++ b/src/evolution-loop.test.ts @@ -1685,7 +1685,7 @@ describe("evolution loop", () => { expect(item?.approvalRequired).toBe(false); }); - it("records retry failure state and audit history without hiding the error", async () => { + it("records permanent configuration failure and audit history without retrying", async () => { const project = await makeProject(); await enableEvolutionLoop({ ...project, @@ -1700,7 +1700,7 @@ describe("evolution loop", () => { now: () => new Date("2026-01-03T00:00:00.000Z"), }); expect(failed.status).toBe("failed"); - expect(failed.attempts).toHaveLength(3); + expect(failed.attempts).toHaveLength(1); expect(await Bun.file(failed.artifactPath).exists()).toBe(true); const state = JSON.parse( await readFile( @@ -1708,13 +1708,13 @@ describe("evolution loop", () => { "utf8" ) ); - expect(state.lastFailure.attempts).toBe(3); + expect(state.lastFailure.attempts).toBe(1); const audit = await readFile( facultAiEvolutionLoopAuditPath(project.homeDir, project.rootDir), "utf8" ); expect(audit).toContain('"status":"failed"'); - expect(audit).toContain('"attempt":3'); + expect(audit).toContain('"attempt":1'); }); it("keeps proposal action locators in failed-run activity snapshots", async () => { @@ -1793,7 +1793,7 @@ describe("evolution loop", () => { }); expect(failed.status).toBe("failed"); - expect(failed.attempts).toHaveLength(3); + expect(failed.attempts).toHaveLength(1); expect(failed.attempts[0]?.error).toContain("missing-source"); const history = await queryActivityHistory({ homeDir: project.homeDir, @@ -2448,7 +2448,7 @@ describe("evolution loop", () => { const canonicalSignal = third.queue.find( (item) => item.kind === "signal" && item.familyId === familyA ); - expect(canonicalSignal?.state).toBe("resolved"); + expect(canonicalSignal?.state).toBe("open"); expect(canonicalSignal?.proposalId).toBe(aliasProposal!.id); expect(canonicalSignal?.familyAliases).toContain(familyB!); expect( @@ -2470,7 +2470,7 @@ describe("evolution loop", () => { const postMergeSignal = fourth.queue.find( (item) => item.kind === "signal" && item.familyId === familyA ); - expect(postMergeSignal?.state).toBe("resolved"); + expect(postMergeSignal?.state).toBe("open"); expect(postMergeSignal?.proposalId).toBe(aliasProposal!.id); expect(postMergeSignal?.familyAliases).toContain(familyB!); expect(await listWritebacks(project)).toHaveLength(1); @@ -2641,7 +2641,7 @@ describe("evolution loop", () => { first.queue.filter( (item) => item.kind === "signal" && item.state !== "resolved" ) - ).toHaveLength(0); + ).toHaveLength(2); expect( writebacks.flatMap((entry) => entry.issueLinks ?? []).sort() ).toEqual(["EXAMPLE-101", "EXAMPLE-102", "EXAMPLE-201", "EXAMPLE-202"]); @@ -2737,6 +2737,72 @@ describe("evolution loop", () => { expect(report.mutations.every((mutation) => !mutation.applied)).toBe(true); }); + it("reuses source writebacks and keeps pending proposal families unresolved", async () => { + const project = await makeProject(); + await Bun.write( + join(project.rootDir, "reconciliation.json"), + JSON.stringify({ + version: 1, + sources: [{ id: "writebacks", type: "writebacks" }], + }) + ); + const row = await addWriteback({ + ...project, + kind: "capability_gap", + summary: "Keep integration checks read-only.", + suggestedDestination: "@project/instructions/CHECKS.md", + evidence: [{ type: "test", ref: "source-evidence" }], + }); + await setWritebackDisposition(row.id, "apply-local", { + ...project, + target: "@project/instructions/CHECKS.md", + expectedOutcome: "Checks remain read-only", + }); + await enableEvolutionLoop(project); + const report = await runEvolutionLoop({ ...project, since: "2020-01-01" }); + expect(report.status).toBe("complete"); + expect(await listWritebacks(project)).toHaveLength(1); + const proposals = await listProposals(project); + expect(proposals).toHaveLength(1); + expect(proposals[0]?.sourceWritebacks).toEqual([row.id]); + expect(report.queue.find((item) => item.kind === "signal")?.state).not.toBe( + "resolved" + ); + await setWritebackDisposition(row.id, "resolve-watch", { + ...project, + target: "@project/instructions/CHECKS.md", + expectedOutcome: "Implemented and verified", + }); + await rejectProposal(proposals[0]!.id, { + ...project, + reason: "Already implemented and verified", + }); + const resolved = await runEvolutionLoop({ + ...project, + since: "2020-01-01", + }); + expect(resolved.queue.find((item) => item.kind === "signal")?.state).toBe( + "resolved" + ); + expect( + resolved.mutations.some((item) => item.type === "create-proposal") + ).toBe(false); + expect(await listWritebacks(project)).toHaveLength(1); + expect(await listProposals(project)).toHaveLength(1); + }); + + it("records deterministic missing configuration once instead of retrying it", async () => { + const project = await makeProject(); + await enableEvolutionLoop(project); + await rm(join(project.rootDir, "reconciliation.json")); + const report = await runEvolutionLoop(project); + expect(report.status).toBe("failed"); + expect(report.attempts).toHaveLength(1); + expect(report.attempts[0]?.error).toContain( + "Reconciliation config not found" + ); + }); + it("covers signal, proposal, explicit apply, regression reopen, and verified improvement end to end", async () => { const project = await makeProject(); const writeback = await addWriteback({ diff --git a/src/evolution-loop.ts b/src/evolution-loop.ts index 6700a594..99f00a40 100644 --- a/src/evolution-loop.ts +++ b/src/evolution-loop.ts @@ -41,7 +41,10 @@ import { processStartIdentityMatches, } from "./process-identity"; import { reconcileSources, reconciliationStatus } from "./reconciliation"; -import { DEFAULT_SOURCE_FRESHNESS_THRESHOLD_HOURS } from "./reconciliation-config"; +import { + DEFAULT_SOURCE_FRESHNESS_THRESHOLD_HOURS, + ReconciliationConfigurationError, +} from "./reconciliation-config"; import type { CorrelatedSignal, ReconciliationFreshness, @@ -923,12 +926,14 @@ function rawQueue(args: { ); const bridgeWritebackIds = new Set( args.writebacks - .filter((entry) => - entry.evidence.some( - (evidence) => - evidence.type === "reconciliation" && - familyEvidenceRefs.has(evidence.ref) - ) + .filter( + (entry) => + signal.writebackRefs.includes(entry.id) || + entry.evidence.some( + (evidence) => + evidence.type === "reconciliation" && + familyEvidenceRefs.has(evidence.ref) + ) ) .map((entry) => entry.id) ); @@ -942,7 +947,10 @@ function rawQueue(args: { id: `family:${familyId}`, kind: "signal" as const, title: signal.title, - state: linkedProposal ? ("resolved" as const) : signalQueueState(signal), + state: + linkedProposal && signal.unresolved + ? ("open" as const) + : signalQueueState(signal), disposition: signal.disposition, familyId, familyAliases: signal.familyAliases ?? [], @@ -1231,6 +1239,9 @@ async function materializeSignals(args: { rootDir: args.rootDir, }); for (const signal of args.review.signals) { + if (!signal.unresolved) { + continue; + } if ( signal.disposition !== "propose" && signal.disposition !== "apply-local" @@ -1256,11 +1267,13 @@ async function materializeSignals(args: { ), ]); let createdWriteback = false; - let writeback = existing.find((entry) => - entry.evidence.some( - (evidence) => - evidence.type === "reconciliation" && evidenceRefs.has(evidence.ref) - ) + let writeback = existing.find( + (entry) => + signal.writebackRefs.includes(entry.id) || + entry.evidence.some( + (evidence) => + evidence.type === "reconciliation" && evidenceRefs.has(evidence.ref) + ) ); if (!(writeback || args.dryRun)) { writeback = await addWriteback({ @@ -2153,6 +2166,9 @@ async function runEvolutionLoopScoped(args: { ok: false, error: error instanceof Error ? error.message : String(error), }); + if (error instanceof ReconciliationConfigurationError) { + break; + } } } if (!review) { diff --git a/src/evolution-preflight.test.ts b/src/evolution-preflight.test.ts index 0ad23a78..3d0422dc 100644 --- a/src/evolution-preflight.test.ts +++ b/src/evolution-preflight.test.ts @@ -25,6 +25,13 @@ async function setup(scope: "project" | "global") { const rootDir = scope === "project" ? join(homeDir, "repo", ".ai") : join(homeDir, ".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 }); return { homeDir, rootDir, scope }; } @@ -99,3 +106,43 @@ for (const destination of [ expect(result.loopInvoked).toBe(false); }); } + +for (const scenario of [ + "missing", + "malformed", + "empty", + "selection", +] as const) { + it(`blocks ${scenario} source configuration before a review attempt`, async () => { + const args = await setup("project"); + const path = join(args.rootDir, "reconciliation.json"); + if (scenario === "missing") { + await rm(path); + } + if (scenario === "malformed") { + await Bun.write(path, "{broken"); + } + if (scenario === "empty") { + await Bun.write(path, JSON.stringify({ version: 1, sources: [] })); + } + if (scenario === "selection") { + const loopPath = facultAiEvolutionLoopConfigPath( + args.homeDir, + args.rootDir + ); + const config = await Bun.file(loopPath).json(); + config.sourceIds = ["missing-source"]; + await Bun.write(loopPath, JSON.stringify(config)); + } + const result = await preflightEvolutionLoop(args); + expect(result.status).toBe("blocked"); + expect(result.configError).toBeTruthy(); + expect(result.loopInvoked).toBe(false); + expect(result.recovery).toContain("configuration"); + expect( + await Bun.file( + facultAiEvolutionLoopStatePath(args.homeDir, args.rootDir) + ).exists() + ).toBe(false); + }); +} diff --git a/src/evolution-preflight.ts b/src/evolution-preflight.ts index 453a1f97..6b323225 100644 --- a/src/evolution-preflight.ts +++ b/src/evolution-preflight.ts @@ -18,6 +18,10 @@ import { projectRootFromAiRoot, withFacultRootScope, } from "./paths"; +import { + loadReconciliationConfig, + selectReconciliationSources, +} from "./reconciliation-config"; /** Probe the actual execution environment without reconciling or creating queue state. */ export async function preflightEvolutionLoop(args: { @@ -42,6 +46,8 @@ export async function preflightEvolutionLoop(args: { try { const config = await loadEvolutionLoopConfig(args); enabled = config?.enabled === true; + const reconciliation = await loadReconciliationConfig(args); + selectReconciliationSources(reconciliation.config, config?.sourceIds); } catch (error) { configError = error instanceof Error ? error.message : String(error); } @@ -78,7 +84,8 @@ export async function preflightEvolutionLoop(args: { const denied = checks.filter((check) => !check.writable); return { version: 1, - status: enabled && denied.length === 0 ? "ready" : "blocked", + status: + enabled && !configError && denied.length === 0 ? "ready" : "blocked", queueAvailable: false, loopInvoked: false, runtime: process.execPath, @@ -88,9 +95,11 @@ export async function preflightEvolutionLoop(args: { 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.", + : configError + ? `Repair the review configuration before invoking the loop: ${configError}` + : enabled + ? null + : "Inspect the loop configuration and enable the intended scope before invoking the loop.", }; } ); diff --git a/src/reconciliation-config.ts b/src/reconciliation-config.ts index b7d363ba..35fa757a 100644 --- a/src/reconciliation-config.ts +++ b/src/reconciliation-config.ts @@ -285,6 +285,34 @@ export function parseReconciliationConfig( return { version: 1, sources }; } +export class ReconciliationConfigurationError extends Error {} + +export function selectReconciliationSources( + config: ReconciliationConfig, + sourceIds: string[] = [] +) { + const enabledSources = config.sources.filter( + (source) => source.enabled !== false + ); + const unknown = sourceIds.filter( + (id) => !enabledSources.some((source) => source.id === id) + ); + if (unknown.length > 0) { + throw new ReconciliationConfigurationError( + `Unknown or disabled reconciliation source ids: ${unknown.join(", ")}` + ); + } + const sources = enabledSources.filter( + (source) => sourceIds.length === 0 || sourceIds.includes(source.id) + ); + if (sources.length === 0) { + throw new ReconciliationConfigurationError( + "No enabled reconciliation sources matched the request" + ); + } + return { enabledSources, sources }; +} + export async function loadReconciliationConfig(args: { homeDir: string; rootDir: string; @@ -295,14 +323,18 @@ export async function loadReconciliationConfig(args: { facultAiReconciliationConfigPath(args.homeDir, args.rootDir); const file = Bun.file(path); if (!(await file.exists())) { - throw new Error( + throw new ReconciliationConfigurationError( `Reconciliation config not found: ${path}. Run fclt ai review init.` ); } - return { - config: parseReconciliationConfig(JSON.parse(await file.text())), - path, - }; + const text = await file.text(); + try { + return { config: parseReconciliationConfig(JSON.parse(text)), path }; + } catch (error) { + throw new ReconciliationConfigurationError( + `Invalid reconciliation config ${path}: ${error instanceof Error ? error.message : String(error)}` + ); + } } export async function initializeReconciliationConfig(args: { diff --git a/src/reconciliation-types.ts b/src/reconciliation-types.ts index 4600ae41..c2cc18d9 100644 --- a/src/reconciliation-types.ts +++ b/src/reconciliation-types.ts @@ -21,6 +21,7 @@ export type SourceFreshnessState = export type SourceFreshnessReason = | "cursor_advanced" + | "source_caught_up" | "within_threshold" | "threshold_exceeded" | "newer_repository_activity" diff --git a/src/reconciliation.test.ts b/src/reconciliation.test.ts index 939f4809..70682c78 100644 --- a/src/reconciliation.test.ts +++ b/src/reconciliation.test.ts @@ -2291,6 +2291,19 @@ describe("source reconciliation", () => { }); expect(first.coverageComplete).toBe(true); expect(first.freshness.state).toBe("current"); + const idle = await reconcileSources({ + ...fixture, + since: "2026-07-23", + until: "2026-08-30", + incremental: true, + persist: false, + }); + expect(idle.coverageComplete).toBe(true); + expect(idle.coverage[0]?.freshness).toMatchObject({ + state: "current", + reason: "source_caught_up", + alert: false, + }); await Bun.write(join(fixture.projectRoot, "outside.txt"), "new activity\n"); await runFixtureGit({ diff --git a/src/reconciliation.ts b/src/reconciliation.ts index e9e377d3..76845b84 100644 --- a/src/reconciliation.ts +++ b/src/reconciliation.ts @@ -30,6 +30,7 @@ import { import { DEFAULT_SOURCE_FRESHNESS_THRESHOLD_HOURS, loadReconciliationConfig, + selectReconciliationSources, } from "./reconciliation-config"; import type { AdapterScanResult, @@ -1140,6 +1141,21 @@ function sourceFreshness(args: { latestSourceAt, }; } + if ( + args.coverageState !== "stale" && + latestSourceAt && + Date.parse(latestSourceAt) <= Date.parse(cursorAt) + ) { + return { + state: "current", + reason: "source_caught_up", + checkedAt: args.checkedAt, + thresholdHours, + alert: false, + cursorAt, + latestSourceAt, + }; + } const cursorAgeHours = (Date.parse(args.until) - Date.parse(cursorAt)) / (60 * 60 * 1000); if (cursorAgeHours > thresholdHours) { @@ -1734,32 +1750,16 @@ export async function reconcileSources(args: { onStaleClaimRevalidated?: () => void | Promise; }): Promise { const { config } = await loadReconciliationConfig(args); - const enabledSources = config.sources.filter( - (source) => source.enabled !== false + const { enabledSources, sources } = selectReconciliationSources( + config, + args.sourceIds ); const enabledConfig: ReconciliationConfig = { version: 1, sources: enabledSources, }; - const unknownSourceIds = (args.sourceIds ?? []).filter( - (sourceId) => !enabledSources.some((source) => source.id === sourceId) - ); - if (unknownSourceIds.length > 0) { - throw new Error( - `Unknown or disabled reconciliation source ids: ${unknownSourceIds.join(", ")}` - ); - } - const selectedConfig: ReconciliationConfig = { - version: 1, - sources: enabledSources.filter( - (source) => !args.sourceIds?.length || args.sourceIds.includes(source.id) - ), - }; - const filteredCoverage = - selectedConfig.sources.length < enabledSources.length; - if (selectedConfig.sources.length === 0) { - throw new Error("No enabled reconciliation sources matched the request"); - } + const selectedConfig: ReconciliationConfig = { version: 1, sources }; + const filteredCoverage = sources.length < enabledSources.length; const requestedWindow = createWindow({ config: selectedConfig, rootDir: args.rootDir, From 5a381d9fb3f27627c830b3f86598c32bc814f0f4 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 14 Sep 2026 12:37:29 -0400 Subject: [PATCH 2/2] fix: scope source freshness to configured Git paths --- src/reconciliation-adapters.ts | 12 ++++--- src/reconciliation.test.ts | 58 ++++++++++++++++++++++++---------- 2 files changed, 49 insertions(+), 21 deletions(-) diff --git a/src/reconciliation-adapters.ts b/src/reconciliation-adapters.ts index 910bfcdf..a23c75c8 100644 --- a/src/reconciliation-adapters.ts +++ b/src/reconciliation-adapters.ts @@ -683,26 +683,28 @@ const gitAdapter: ReconciliationAdapter = { config, projectRoot, }); - const latestDefaultBranch = ( + const pathArgs = config.paths?.length ? ["--", ...config.paths] : []; + const revisionArgs = config.allBranches ? ["--all"] : [defaultBranch.ref]; + const latestSource = ( await runGit( [ "log", "-1", `--until=${context.window.until}`, "--format=%H%x1f%cI", - defaultBranch.ref, + ...revisionArgs, + ...pathArgs, ], projectRoot ) ).trim(); - const [, latestSourceAt] = latestDefaultBranch.split("\u001f"); - const pathArgs = config.paths?.length ? ["--", ...config.paths] : []; + const [, latestSourceAt] = latestSource.split("\u001f"); let output: string; try { output = await runGit( [ "log", - ...(config.allBranches ? ["--all"] : [defaultBranch.ref]), + ...revisionArgs, `--since=${context.window.since}`, `--until=${context.window.until}`, "--format=%x1e%H%x1f%cI%x1f%s%x1f%b%x00", diff --git a/src/reconciliation.test.ts b/src/reconciliation.test.ts index 70682c78..3ba4eefc 100644 --- a/src/reconciliation.test.ts +++ b/src/reconciliation.test.ts @@ -2245,7 +2245,7 @@ describe("source reconciliation", () => { ); }); - it("separates complete coverage from a cursor stale after newer repository activity", async () => { + it("keeps filtered Git sources current after unrelated activity", async () => { const fixture = await makeFixture(); for (const argv of [ ["init", "--quiet", "--initial-branch=main"], @@ -2334,22 +2334,48 @@ describe("source reconciliation", () => { state: "checked", recordsScanned: 0, freshness: { - state: "stale", - reason: "newer_repository_activity", - alert: true, + state: "current", + reason: "source_caught_up", + alert: false, cursorAt: "2026-07-23T18:12:45-04:00", - latestSourceAt: "2026-07-23T18:28:50-04:00", + latestSourceAt: "2026-07-23T18:12:45-04:00", }, }); expect(preview.freshness).toMatchObject({ - state: "stale", - staleSourceIds: ["git"], - alertSourceIds: ["git"], + state: "current", + staleSourceIds: [], + alertSourceIds: [], }); expect(await readFile(statePath, "utf8")).toBe(stateBefore); + await Bun.write( + join(fixture.projectRoot, "docs", "new.md"), + "New guidance.\n" + ); + await runFixtureGit({ + projectRoot: fixture.projectRoot, + argv: ["add", "docs"], + }); + await runFixtureGit({ + projectRoot: fixture.projectRoot, + argv: ["commit", "--quiet", "-m", "docs: new guidance"], + date: "2026-07-25T12:00:00Z", + }); + const advanced = await reconcileSources({ + ...fixture, + since: "2026-07-23", + until: "2026-07-27", + incremental: true, + persist: false, + }); + expect(advanced.coverage[0]?.recordsScanned).toBe(1); + expect(advanced.coverage[0]?.freshness).toMatchObject({ + state: "current", + cursorAt: "2026-07-25T12:00:00Z", + latestSourceAt: "2026-07-25T12:00:00Z", + }); }); - it("reports six stale Git cursors independently from aggregate coverage", async () => { + it("keeps six filtered Git sources current after unrelated activity", async () => { const fixture = await makeFixture(); for (const argv of [ ["init", "--quiet", "--initial-branch=main"], @@ -2423,12 +2449,12 @@ describe("source reconciliation", () => { ) ).toBe(true); expect( - review.coverage.every((entry) => entry.freshness.state === "stale") + review.coverage.every((entry) => entry.freshness.state === "current") ).toBe(true); expect(review.freshness).toMatchObject({ - state: "stale", - staleSourceIds: sourceIds, - alertSourceIds: sourceIds, + state: "current", + staleSourceIds: [], + alertSourceIds: [], }); }); @@ -2508,10 +2534,10 @@ describe("source reconciliation", () => { expect(review.coverage[0]).toMatchObject({ freshness: { - state: "stale", - reason: "newer_repository_activity", + state: "current", + reason: "source_caught_up", cursorAt: "2026-01-02T12:00:00Z", - latestSourceAt: "2026-01-04T12:00:00Z", + latestSourceAt: "2026-01-02T12:00:00Z", }, }); });