From 7e9676eb743ec4b3d63be22985e35887e8adf468 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Tue, 28 Jul 2026 13:51:11 -0400 Subject: [PATCH 1/4] test: reproduce stale reconciliation queues --- src/evolution-loop.test.ts | 162 ++++++++++++++++++++++ src/reconciliation.test.ts | 270 +++++++++++++++++++++++++++++++++++++ 2 files changed, 432 insertions(+) diff --git a/src/evolution-loop.test.ts b/src/evolution-loop.test.ts index a5bedfa7..a532925a 100644 --- a/src/evolution-loop.test.ts +++ b/src/evolution-loop.test.ts @@ -81,6 +81,37 @@ async function makeProject(): Promise<{ return { homeDir, projectRoot, rootDir }; } +async function runProjectGit(args: { + projectRoot: string; + argv: string[]; + date?: string; +}): Promise { + const env: Record = {}; + for (const [name, value] of Object.entries(process.env)) { + if (value !== undefined && !name.startsWith("GIT_")) { + env[name] = value; + } + } + if (args.date) { + env.GIT_AUTHOR_DATE = args.date; + env.GIT_COMMITTER_DATE = args.date; + } + const proc = Bun.spawn({ + cmd: [Bun.which("git") ?? "/usr/bin/git", ...args.argv], + cwd: args.projectRoot, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stderr).text(), + ]); + if (exitCode !== 0) { + throw new Error(stderr); + } +} + afterEach(async () => { for (const root of temporaryRoots.splice(0)) { await rm(root, { recursive: true, force: true }); @@ -524,6 +555,91 @@ describe("evolution loop", () => { expect(quiet.delta.notifiable).toHaveLength(0); }); + it("alerts once for a stale cursor while keeping complete coverage", async () => { + const project = await makeProject(); + for (const argv of [ + ["init", "--quiet", "--initial-branch=main"], + ["config", "user.email", "fixture@example.invalid"], + ["config", "user.name", "Fixture"], + ]) { + await runProjectGit({ projectRoot: project.projectRoot, argv }); + } + await mkdir(join(project.projectRoot, "docs"), { recursive: true }); + await Bun.write( + join(project.projectRoot, "docs", "review.md"), + "Capability review cursor baseline.\n" + ); + await runProjectGit({ projectRoot: project.projectRoot, argv: ["add", "docs"] }); + await runProjectGit({ + projectRoot: project.projectRoot, + argv: ["commit", "--quiet", "-m", "docs: cursor baseline"], + date: "2026-07-23T18:12:45-04:00", + }); + await Bun.write( + join(project.rootDir, "reconciliation.json"), + `${JSON.stringify({ + version: 1, + sources: [ + { + id: "git", + type: "git", + paths: ["docs"], + defaultBranch: "main", + freshnessThresholdHours: 168, + }, + ], + })}\n` + ); + await enableEvolutionLoop({ ...project }); + await runEvolutionLoop({ + ...project, + since: "2026-07-23T00:00:00-04:00", + until: "2026-07-23T18:15:00-04:00", + now: () => new Date("2026-07-23T18:15:00-04:00"), + }); + + await Bun.write(join(project.projectRoot, "outside.txt"), "new activity\n"); + await runProjectGit({ + projectRoot: project.projectRoot, + argv: ["add", "outside.txt"], + }); + await runProjectGit({ + projectRoot: project.projectRoot, + argv: ["commit", "--quiet", "-m", "fix: newer activity"], + date: "2026-07-23T18:28:50-04:00", + }); + const stale = await runEvolutionLoop({ + ...project, + until: "2026-07-27T23:04:10Z", + now: () => new Date("2026-07-27T23:04:10Z"), + }); + const freshnessItem = stale.queue.find( + (item) => item.id === "freshness:git" + ); + expect(stale.coverageComplete).toBe(true); + expect(stale.status).toBe("complete"); + expect(stale.freshness.state).toBe("stale"); + expect(freshnessItem).toMatchObject({ + kind: "coverage", + state: "blocked", + sourceIds: ["git"], + }); + expect(stale.delta.notifiable).toContain("freshness:git"); + + const quiet = await runEvolutionLoop({ + ...project, + until: "2026-07-28T00:04:10Z", + now: () => new Date("2026-07-28T00:04:10Z"), + }); + expect(quiet.coverageComplete).toBe(true); + expect(quiet.freshness.state).toBe("stale"); + expect(quiet.delta.notifiable).not.toContain("freshness:git"); + expect(quiet.delta.unchangedSuppressed).toBeGreaterThan(0); + const artifact = await readFile(quiet.artifactPath, "utf8"); + expect(artifact).toContain("Freshness: stale"); + expect(artifact).toContain("newer_repository_activity"); + }); + it("does not report an existing signal-family writeback as a new mutation", async () => { const project = await makeProject(); await mkdir(join(project.rootDir, "instructions"), { recursive: true }); @@ -1990,6 +2106,52 @@ describe("evolution loop", () => { expect(closedItem?.requestedExternalAction).toBeUndefined(); }); + it("rejects an obsolete duplicate proposal without applying its target edit", async () => { + const project = await makeProject(); + const targetPath = join(project.rootDir, "instructions", "REVIEW.md"); + await mkdir(dirname(targetPath), { recursive: true }); + await Bun.write( + targetPath, + "# Review\n\nThe requested bounded review rule already exists.\n" + ); + const writeback = await addWriteback({ + ...project, + kind: "bad_default", + summary: "Add the bounded review rule that already exists.", + suggestedDestination: "@project/instructions/REVIEW.md", + evidence: [{ type: "test", ref: "duplicate-existing-rule" }], + }); + const [proposal] = await proposeEvolution({ + ...project, + writebackIds: [writeback.id], + }); + await draftProposal(proposal!.id, project); + const targetBefore = await readFile(targetPath, "utf8"); + + const rejected = await rejectProposal(proposal!.id, { + ...project, + reason: + "Rejected as duplicate/obsolete after exact proposal and target readback.", + }); + await enableEvolutionLoop({ ...project }); + const report = await runEvolutionLoop({ + ...project, + since: "2026-01-01", + until: "2026-01-03", + now: () => new Date("2026-01-03T00:00:00.000Z"), + }); + + expect(rejected.status).toBe("rejected"); + expect(await readFile(targetPath, "utf8")).toBe(targetBefore); + expect( + report.queue.find((item) => item.proposalId === proposal!.id) + ).toMatchObject({ + state: "resolved", + approvalRequired: false, + }); + expect(report.mutations.every((mutation) => !mutation.applied)).toBe(true); + }); + 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/reconciliation.test.ts b/src/reconciliation.test.ts index b9c2b218..0c0a9fff 100644 --- a/src/reconciliation.test.ts +++ b/src/reconciliation.test.ts @@ -212,6 +212,20 @@ describe("reconciliation config", () => { sources: [{ id: "git", type: "git", allBranches: "true" }], }) ).toThrow("allBranches must be a boolean"); + expect(() => + parseReconciliationConfig({ + version: 1, + sources: [ + { id: "git", type: "git", freshnessThresholdHours: 0 }, + ], + }) + ).toThrow("freshnessThresholdHours"); + expect(() => + parseReconciliationConfig({ + version: 1, + sources: [{ id: "git", type: "git", defaultBranch: "../main" }], + }) + ).toThrow("defaultBranch"); expect(() => parseReconciliationConfig({ version: 1, @@ -1456,6 +1470,59 @@ describe("source reconciliation", () => { expect(review.signals[0]?.disposition).toBe("resolve-watch"); }); + it("resolves linked-work families from bounded terminal status readback", async () => { + const fixture = await makeFixture(); + const linkedWork = [ + ["HACK-812", "done"], + ["HACK-924", "completed"], + ["HACK-934", "canceled"], + ["HACK-939", "obsolete"], + ["LNHACK-625", "duplicate"], + ] as const; + await Bun.write( + join(fixture.projectRoot, "issues.json"), + JSON.stringify( + evidenceExport( + linkedWork.map(([issue, status], index) => ({ + id: `status-${index + 1}`, + kind: "status-change", + observedAt: `2026-07-05T1${index}:00:00Z`, + title: `${issue} is ${status}`, + refs: [issue], + status, + })) + ) + ) + ); + await Bun.write( + join(fixture.rootDir, "reconciliation.json"), + JSON.stringify({ + version: 1, + sources: [ + { id: "linear-export", type: "evidence-export", path: "issues.json" }, + ], + }) + ); + + const review = await reconcileSources({ + ...fixture, + since: "2026-07-03", + until: "2026-07-10", + }); + + expect(review.coverageComplete).toBe(true); + expect(review.linkedWork).toEqual(linkedWork.map(([issue]) => issue)); + expect(review.signals).toHaveLength(linkedWork.length); + expect(review.signals.every((signal) => !signal.unresolved)).toBe(true); + expect(review.signals.every((signal) => signal.disposition === "resolve-watch")).toBe( + true + ); + expect(review.resolutionProofs).toHaveLength(linkedWork.length); + expect(review.resolutionProofs.map((proof) => proof.status).sort()).toEqual( + linkedWork.map(([, status]) => status).sort() + ); + }); + it("targets the project asset for apply-local dispositions", async () => { const fixture = await makeFixture(); const notesDir = join(fixture.projectRoot, "notes"); @@ -1667,6 +1734,209 @@ describe("source reconciliation", () => { }); }); + it("separates complete coverage from a cursor stale after newer repository activity", async () => { + const fixture = await makeFixture(); + for (const argv of [ + ["init", "--quiet", "--initial-branch=main"], + ["config", "user.email", "fixture@example.invalid"], + ["config", "user.name", "Fixture"], + ]) { + await runFixtureGit({ projectRoot: fixture.projectRoot, argv }); + } + await mkdir(join(fixture.projectRoot, "docs"), { recursive: true }); + await Bun.write( + join(fixture.projectRoot, "docs", "review.md"), + "Capability review HACK-939.\n" + ); + await runFixtureGit({ + projectRoot: fixture.projectRoot, + argv: ["add", "docs"], + }); + await runFixtureGit({ + projectRoot: fixture.projectRoot, + argv: ["commit", "--quiet", "-m", "docs: record HACK-939 review"], + date: "2026-07-23T18:12:45-04:00", + }); + await Bun.write( + join(fixture.rootDir, "reconciliation.json"), + JSON.stringify({ + version: 1, + sources: [ + { + id: "git", + type: "git", + paths: ["docs"], + defaultBranch: "main", + freshnessThresholdHours: 168, + }, + ], + }) + ); + const first = await reconcileSources({ + ...fixture, + since: "2026-07-23T00:00:00-04:00", + until: "2026-07-23T18:15:00-04:00", + incremental: true, + }); + expect(first.coverageComplete).toBe(true); + expect(first.freshness.state).toBe("current"); + + await Bun.write(join(fixture.projectRoot, "outside.txt"), "new activity\n"); + await runFixtureGit({ + projectRoot: fixture.projectRoot, + argv: ["add", "outside.txt"], + }); + await runFixtureGit({ + projectRoot: fixture.projectRoot, + argv: ["commit", "--quiet", "-m", "fix: newer repository activity"], + date: "2026-07-23T18:28:50-04:00", + }); + const statePath = facultAiReconciliationStatePath( + fixture.homeDir, + fixture.rootDir + ); + const stateBefore = await readFile(statePath, "utf8"); + const preview = await reconcileSources({ + ...fixture, + since: "2026-07-23T00:00:00-04:00", + until: "2026-07-27T23:04:10Z", + incremental: true, + persist: false, + }); + + expect(preview.coverageComplete).toBe(true); + expect(preview.degraded).toBe(false); + expect(preview.coverage[0]).toMatchObject({ + state: "checked", + recordsScanned: 0, + freshness: { + state: "stale", + reason: "newer_repository_activity", + alert: true, + cursorAt: "2026-07-23T18:12:45-04:00", + latestSourceAt: "2026-07-23T18:28:50-04:00", + }, + }); + expect(preview.freshness).toMatchObject({ + state: "stale", + staleSourceIds: ["git"], + alertSourceIds: ["git"], + }); + expect(await readFile(statePath, "utf8")).toBe(stateBefore); + }); + + it("checks exact Git evidence against the configured default branch", async () => { + const fixture = await makeFixture(); + for (const argv of [ + ["init", "--quiet", "--initial-branch=main"], + ["config", "user.email", "fixture@example.invalid"], + ["config", "user.name", "Fixture"], + ["commit", "--allow-empty", "--quiet", "-m", "chore: base"], + ["switch", "--quiet", "-c", "implementation"], + ]) { + await runFixtureGit({ projectRoot: fixture.projectRoot, argv }); + } + await mkdir(join(fixture.projectRoot, "docs"), { recursive: true }); + await Bun.write( + join(fixture.projectRoot, "docs", "setup.md"), + "Setup safety implementation for HACK-934.\n" + ); + await runFixtureGit({ + projectRoot: fixture.projectRoot, + argv: ["add", "docs"], + }); + await runFixtureGit({ + projectRoot: fixture.projectRoot, + argv: ["commit", "--quiet", "-m", "fix: complete HACK-934 setup safety"], + date: "2026-07-05T12:00:00Z", + }); + await Bun.write( + join(fixture.rootDir, "reconciliation.json"), + JSON.stringify({ + version: 1, + sources: [ + { + id: "git", + type: "git", + paths: ["docs"], + defaultBranch: "main", + }, + ], + }) + ); + const beforeMerge = await reconcileSources({ + ...fixture, + since: "2026-07-03", + until: "2026-07-10", + persist: false, + }); + expect(beforeMerge.linkedWork).not.toContain("HACK-934"); + + await runFixtureGit({ + projectRoot: fixture.projectRoot, + argv: ["switch", "--quiet", "main"], + }); + await runFixtureGit({ + projectRoot: fixture.projectRoot, + argv: ["merge", "--ff-only", "implementation"], + }); + await runFixtureGit({ + projectRoot: fixture.projectRoot, + argv: ["switch", "--quiet", "implementation"], + }); + const merged = await reconcileSources({ + ...fixture, + since: "2026-07-03", + until: "2026-07-10", + persist: false, + }); + + expect(merged.linkedWork).toContain("HACK-934"); + expect(merged.signals[0]).toMatchObject({ + disposition: "resolve-watch", + unresolved: false, + }); + expect(merged.evidence[0]?.provenance).toContainEqual( + expect.objectContaining({ + defaultBranch: "main", + onDefaultBranch: true, + terminal: true, + }) + ); + }); + + it("reports unavailable cursor freshness without changing coverage semantics", async () => { + const fixture = await makeFixture(); + await Bun.write( + join(fixture.rootDir, "reconciliation.json"), + JSON.stringify({ + version: 1, + sources: [{ id: "missing", type: "markdown", paths: ["missing.md"] }], + }) + ); + const review = await reconcileSources({ + ...fixture, + since: "2026-07-03", + until: "2026-07-10", + persist: false, + }); + + expect(review.coverageComplete).toBe(false); + expect(review.coverage[0]).toMatchObject({ + state: "unavailable", + freshness: { + state: "unknown", + reason: "source_unavailable", + alert: false, + }, + }); + expect(review.freshness).toMatchObject({ + state: "unknown", + unknownSourceIds: ["missing"], + alertSourceIds: [], + }); + }); + it("enforces the file scan cap across multiple patterns", async () => { const fixture = await makeFixture(); const logDir = join(fixture.projectRoot, "logs"); From 1629c790894cfd2fbf510e7ec9817192f86fa0a8 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Tue, 28 Jul 2026 20:35:29 -0400 Subject: [PATCH 2/4] fix: reconcile resolved evolution signals --- docs/writeback-evolution.md | 23 ++- src/activity-action.test.ts | 6 + src/activity-history.test.ts | 14 ++ src/activity.test.ts | 16 ++ src/activity.ts | 42 ++++- src/ai.ts | 3 + src/evolution-loop.test.ts | 5 +- src/evolution-loop.ts | 85 +++++++++- src/reconciliation-adapters.ts | 199 +++++++++++++++++++++--- src/reconciliation-config.ts | 69 ++++++++- src/reconciliation-types.ts | 52 +++++++ src/reconciliation.test.ts | 10 +- src/reconciliation.ts | 273 +++++++++++++++++++++++++++++++-- 13 files changed, 745 insertions(+), 52 deletions(-) diff --git a/docs/writeback-evolution.md b/docs/writeback-evolution.md index 03877d00..a53a45ae 100644 --- a/docs/writeback-evolution.md +++ b/docs/writeback-evolution.md @@ -76,7 +76,8 @@ credentials: "id": "git", "type": "git", "repository": "project", - "allBranches": true, + "defaultBranch": "main", + "freshnessThresholdHours": 168, "paths": [".ai", "AGENTS.md", "docs"] }, { @@ -120,7 +121,7 @@ the requested review window: "observedAt": "2026-07-08T14:30:00Z", "body": "Implementation completed", "refs": ["EXAMPLE-123"], - "terminal": true + "status": "done" } ] } @@ -133,6 +134,24 @@ user-owned exporter can produce that file from any external system. Missing exports, missing logs, stale sources, and adapter failures produce degraded coverage instead of a false empty result. +Coverage and cursor freshness are separate contracts. `coverageComplete` +means every configured source proved the requested window; it does not mean a +stored source cursor is current. JSON, persisted reports, and readable review +artifacts expose a typed `freshness` state independently for each source and +for the review overall. A cursor becomes stale only when its configured +`freshnessThresholdHours` is exceeded (168 hours by default) or a bounded Git +check finds newer activity on the configured default branch. Newer repository +activity is checked independently of `paths`, so a complete path-filtered scan +cannot mask a frozen Git cursor. An unchanged stale queue item is suppressed +after its first notification. + +Terminal evidence-export statuses (`done`, `completed`, `canceled`, +`obsolete`, `duplicate`, and their supported aliases) resolve a linked signal +family when the bounded export proves all linked work terminal. Git evidence +resolves a family only when the exact commit or equivalent patch is contained +on the configured default branch. Both paths preserve source provenance and +remain read-only; they do not close tracker work, edit Git, or apply proposals. + Configure file sources narrowly around append-only logs, dated runbooks, or research streams that represent review evidence. Date section headings as `## YYYY-MM-DD ...` so one file can prove which observations belong to the diff --git a/src/activity-action.test.ts b/src/activity-action.test.ts index 63ce8d00..527c3e23 100644 --- a/src/activity-action.test.ts +++ b/src/activity-action.test.ts @@ -94,6 +94,12 @@ function report(args: { generationAfter: 3, coverage: [], coverageComplete: true, + freshness: { + state: "current", + staleSourceIds: [], + unknownSourceIds: [], + alertSourceIds: [], + }, queue: [item], delta: { new: [item.id], diff --git a/src/activity-history.test.ts b/src/activity-history.test.ts index 54d33228..fa7a3a1f 100644 --- a/src/activity-history.test.ts +++ b/src/activity-history.test.ts @@ -149,10 +149,18 @@ function review(args: { }, coverageComplete: true, degraded: false, + freshness: { + state: "current", + staleSourceIds: [], + unknownSourceIds: [], + alertSourceIds: [], + }, coverage: [], decisions: [], evidence: [], signals: [], + resolutionProofs: [], + resolvedSignalFamilies: [], resolvedEvidenceKeys: [], unresolvedSignals: [], linkedWork: [], @@ -213,6 +221,12 @@ function report(args: { reviewId: `review-${args.index}`, coverage: [], coverageComplete: status === "complete", + freshness: { + state: status === "complete" ? "current" : "unknown", + staleSourceIds: [], + unknownSourceIds: [], + alertSourceIds: [], + }, queue: [], delta: { new: [], diff --git a/src/activity.test.ts b/src/activity.test.ts index 53d6777f..871f2aa9 100644 --- a/src/activity.test.ts +++ b/src/activity.test.ts @@ -59,9 +59,22 @@ function report(overrides?: Partial): EvolutionLoopReport { checkedAt: "2026-07-13T00:00:00.000Z", recordsScanned: 2, signalsDiscovered: 1, + freshness: { + state: "current", + reason: "cursor_advanced", + checkedAt: "2026-07-13T00:00:00.000Z", + thresholdHours: 168, + alert: false, + }, }, ], coverageComplete: true, + freshness: { + state: "current", + staleSourceIds: [], + unknownSourceIds: [], + alertSourceIds: [], + }, queue: [queueItem()], delta: { new: ["family:SF-stable"], @@ -95,6 +108,7 @@ function review(): ReconciliationReview { }, coverageComplete: true, degraded: false, + freshness: report().freshness, coverage: report().coverage, decisions: [], evidence: [], @@ -116,6 +130,8 @@ function review(): ReconciliationReview { unresolved: true, }, ], + resolutionProofs: [], + resolvedSignalFamilies: [], resolvedEvidenceKeys: [], unresolvedSignals: ["SIG-1"], linkedWork: ["TASK-1"], diff --git a/src/activity.ts b/src/activity.ts index b2ac667c..b8fbd304 100644 --- a/src/activity.ts +++ b/src/activity.ts @@ -178,10 +178,12 @@ export interface ActivityFeed { complete: boolean; checked: number; degraded: number; + freshness?: EvolutionLoopReport["freshness"]; sources: Array<{ id: string; label: string; state: SourceCoverage["state"]; + freshness?: SourceCoverage["freshness"]; detail?: string; }>; }; @@ -883,10 +885,12 @@ export function buildActivityFeed(args: { complete: args.report.coverageComplete, checked: args.report.coverage.length - degradedSources.length, degraded: degradedSources.length, + freshness: args.report.freshness, sources: args.report.coverage.map((entry) => ({ id: redactPortableActivityText(entry.sourceId), label: redactPortableActivityText(sourceLabel(entry.sourceId)), state: entry.state, + freshness: entry.freshness, detail: entry.unavailableReason ? redactPortableActivityText(entry.unavailableReason) : entry.staleReason @@ -963,6 +967,9 @@ export function renderActivityFeed(feed: ActivityFeed): string { `Activity — ${label}`, `Last review: ${feed.generatedAt} · ${feed.run.status}`, `Coverage: ${feed.coverage.checked}/${feed.coverage.sources.length} sources checked${feed.coverage.complete ? "" : " · incomplete"}`, + ...(feed.coverage.freshness + ? [`Freshness: ${feed.coverage.freshness.state}`] + : []), `Changes: ${feed.counts.new} new · ${feed.counts.changed} changed · ${feed.counts.resolved} resolved · ${feed.counts.unchangedSuppressed} unchanged suppressed`, "", ...(degraded.length @@ -1056,6 +1063,36 @@ function isNonNegativeInteger(value: unknown): value is number { return Number.isInteger(value) && Number(value) >= 0; } +function isReconciliationFreshness( + value: unknown +): value is EvolutionLoopReport["freshness"] { + return ( + isRecord(value) && + (value.state === "current" || + value.state === "stale" || + value.state === "unknown") && + isStringArray(value.staleSourceIds) && + isStringArray(value.unknownSourceIds) && + isStringArray(value.alertSourceIds) + ); +} + +function isSourceFreshness( + value: unknown +): value is SourceCoverage["freshness"] { + return ( + isRecord(value) && + (value.state === "current" || + value.state === "stale" || + value.state === "unknown" || + value.state === "not_applicable") && + typeof value.reason === "string" && + typeof value.checkedAt === "string" && + typeof value.thresholdHours === "number" && + typeof value.alert === "boolean" + ); +} + function isActivityItem(value: unknown): value is ActivityItem { if (!isRecord(value)) { return false; @@ -1140,13 +1177,16 @@ export function isActivityFeed(value: unknown): value is ActivityFeed { typeof value.coverage.complete === "boolean" && isNonNegativeInteger(value.coverage.checked) && isNonNegativeInteger(value.coverage.degraded) && + (value.coverage.freshness === undefined || + isReconciliationFreshness(value.coverage.freshness)) && Array.isArray(value.coverage.sources) && value.coverage.sources.every( (entry) => isRecord(entry) && typeof entry.id === "string" && typeof entry.label === "string" && - typeof entry.state === "string" + typeof entry.state === "string" && + (entry.freshness === undefined || isSourceFreshness(entry.freshness)) ) && isRecord(value.counts) && isNonNegativeInteger(value.counts.total) && diff --git a/src/ai.ts b/src/ai.ts index 73d6a33e..23cff63a 100644 --- a/src/ai.ts +++ b/src/ai.ts @@ -3081,6 +3081,8 @@ async function loopCommand(argv: string[]) { : [ `loop report: ${result.runId}`, `status: ${result.status}`, + `coverage: ${result.coverageComplete ? "complete" : "degraded"}`, + `freshness: ${result.freshness.state}`, `queue: ${result.queue.length}`, `notifiable: ${result.delta.notifiable.length}`, `artifact: ${result.artifactPath}`, @@ -3720,6 +3722,7 @@ async function reviewCommand(argv: string[]): Promise { : [ `review: ${result.reviewId}`, `coverage: ${result.coverageComplete ? "complete" : "degraded"}`, + `freshness: ${result.freshness.state}`, `signals: ${result.signals.length}`, `artifact: ${result.artifactPath}`, ].join("\n") diff --git a/src/evolution-loop.test.ts b/src/evolution-loop.test.ts index a532925a..a2e8ecab 100644 --- a/src/evolution-loop.test.ts +++ b/src/evolution-loop.test.ts @@ -569,7 +569,10 @@ describe("evolution loop", () => { join(project.projectRoot, "docs", "review.md"), "Capability review cursor baseline.\n" ); - await runProjectGit({ projectRoot: project.projectRoot, argv: ["add", "docs"] }); + await runProjectGit({ + projectRoot: project.projectRoot, + argv: ["add", "docs"], + }); await runProjectGit({ projectRoot: project.projectRoot, argv: ["commit", "--quiet", "-m", "docs: cursor baseline"], diff --git a/src/evolution-loop.ts b/src/evolution-loop.ts index 25633c21..a8b1d2ee 100644 --- a/src/evolution-loop.ts +++ b/src/evolution-loop.ts @@ -36,8 +36,10 @@ import { withFacultRootScope, } from "./paths"; import { reconcileSources, reconciliationStatus } from "./reconciliation"; +import { DEFAULT_SOURCE_FRESHNESS_THRESHOLD_HOURS } from "./reconciliation-config"; import type { CorrelatedSignal, + ReconciliationFreshness, ReconciliationReview, SourceCoverage, } from "./reconciliation-types"; @@ -140,6 +142,7 @@ interface EvolutionLoopState { lastSuccessfulScheduledConfigGeneration?: number; lastRunStatus?: "complete" | "degraded" | "failed"; lastCoverageComplete?: boolean; + lastFreshnessState?: ReconciliationFreshness["state"]; lastSuccessfulCoverageUntil?: string; lastReviewId?: string; lastReportPath?: string; @@ -176,6 +179,7 @@ export interface EvolutionLoopReport { reviewId?: string; coverage: SourceCoverage[]; coverageComplete: boolean; + freshness: ReconciliationFreshness; queue: LoopQueueItem[]; delta: { new: string[]; @@ -198,6 +202,12 @@ const ACTIVE_LOOP_PROPOSAL_STATUSES = new Set([ "accepted", "applied", ]); +const UNKNOWN_FRESHNESS: ReconciliationFreshness = { + state: "unknown", + staleSourceIds: [], + unknownSourceIds: [], + alertSourceIds: [], +}; function sha256(value: string): string { return createHash("sha256").update(value).digest("hex"); @@ -971,7 +981,32 @@ function rawQueue(args: { entry.unavailableReason ?? entry.staleReason ?? entry.state, ], })); - return [...signalItems, ...proposalItems, ...coverageItems]; + const freshnessItems = args.review.coverage + .filter((entry) => entry.freshness.state === "stale") + .map((entry) => ({ + id: `freshness:${entry.sourceId}`, + kind: "coverage" as const, + title: `${entry.sourceId} cursor freshness is stale`, + state: "blocked" as const, + linkedWork: [], + approvalRequired: false, + sourceIds: [entry.sourceId], + evidenceRefs: [ + entry.freshness.reason, + ...(entry.freshness.cursorAt + ? [`cursor:${entry.freshness.cursorAt}`] + : []), + ...(entry.freshness.latestSourceAt + ? [`latest:${entry.freshness.latestSourceAt}`] + : []), + ], + })); + return [ + ...signalItems, + ...proposalItems, + ...coverageItems, + ...freshnessItems, + ]; } function queueFingerprint( @@ -996,6 +1031,7 @@ function reconcileQueue(args: { generatedAt: string; coverageComplete: boolean; resolvedEvidenceKeys: string[]; + resolvedSignalFamilies: string[]; }): { queue: Record; fingerprints: Record; @@ -1007,6 +1043,7 @@ function reconcileQueue(args: { const changedIds: string[] = []; const resolvedIds: string[] = []; const resolvedEvidenceKeys = new Set(args.resolvedEvidenceKeys); + const resolvedSignalFamilies = new Set(args.resolvedSignalFamilies); let unchangedSuppressed = 0; for (const raw of args.current) { const prior = args.prior.queue[raw.id]; @@ -1067,7 +1104,8 @@ function reconcileQueue(args: { } const signalHasResolutionProof = prior.kind === "signal" && - prior.evidenceRefs.some((key) => resolvedEvidenceKeys.has(key)); + (prior.evidenceRefs.some((key) => resolvedEvidenceKeys.has(key)) || + Boolean(prior.familyId && resolvedSignalFamilies.has(prior.familyId))); if ( !args.coverageComplete || (prior.kind === "signal" && !signalHasResolutionProof) @@ -1378,7 +1416,7 @@ function renderReport(report: EvolutionLoopReport): string { ); const coverageRows = report.coverage.map( (entry) => - `| ${markdownCell(entry.sourceId)} | ${markdownCell(entry.state)} | ${entry.recordsScanned} | ${entry.signalsDiscovered} | ${markdownCell(entry.unavailableReason ?? entry.staleReason ?? "")} |` + `| ${markdownCell(entry.sourceId)} | ${markdownCell(entry.state)} | ${markdownCell(entry.freshness.state)} | ${markdownCell(entry.freshness.reason)} | ${entry.recordsScanned} | ${entry.signalsDiscovered} | ${markdownCell(entry.unavailableReason ?? entry.staleReason ?? "")} |` ); const attemptRows = report.attempts.map( (entry) => @@ -1399,6 +1437,7 @@ function renderReport(report: EvolutionLoopReport): string { `status: ${JSON.stringify(report.status)}`, `generatedAt: ${JSON.stringify(report.generatedAt)}`, `coverageComplete: ${report.coverageComplete}`, + `freshness: ${JSON.stringify(report.freshness.state)}`, "---", "", `# Evolution loop ${report.runId}`, @@ -1407,6 +1446,7 @@ function renderReport(report: EvolutionLoopReport): string { "", `- Run status: ${report.status}`, `- Coverage: ${report.coverageComplete ? "complete" : "incomplete"} (${report.activity?.coverage.checked ?? 0}/${report.coverage.length} sources checked)`, + `- Freshness: ${report.freshness.state}${report.freshness.staleSourceIds.length > 0 ? ` (${report.freshness.staleSourceIds.join(", ")})` : ""}`, `- Changes: ${report.delta.new.length} new, ${report.delta.changed.length} changed, ${report.delta.resolved.length} resolved`, `- Needs attention: ${activityAttention.length}`, "", @@ -1427,8 +1467,8 @@ function renderReport(report: EvolutionLoopReport): string { "", "## Source coverage", "", - "| Source | State | Records | Signals | Detail |", - "| --- | --- | ---: | ---: | --- |", + "| Source | Coverage | Freshness | Freshness reason | Records | Signals | Detail |", + "| --- | --- | --- | --- | ---: | ---: | --- |", ...coverageRows, "", "## Full current queue", @@ -1805,9 +1845,37 @@ async function latestEvolutionLoopReportScoped(args: { return null; } try { - return JSON.parse( + const report = JSON.parse( await readFile(state.lastReportPath, "utf8") ) as EvolutionLoopReport; + const coverage = report.coverage.map((entry) => + entry.freshness + ? entry + : { + ...entry, + freshness: { + state: "unknown" as const, + reason: "legacy_report" as const, + checkedAt: entry.checkedAt ?? report.generatedAt, + thresholdHours: DEFAULT_SOURCE_FRESHNESS_THRESHOLD_HOURS, + alert: false, + }, + } + ); + const unknownSourceIds = coverage + .filter((entry) => entry.freshness.state === "unknown") + .map((entry) => entry.sourceId) + .sort(); + return { + ...report, + coverage, + freshness: report.freshness ?? { + state: "unknown", + staleSourceIds: [], + unknownSourceIds, + alertSourceIds: [], + }, + }; } catch { return null; } @@ -1880,6 +1948,7 @@ async function persistFailedLoopRun(args: { reviewId: args.review?.reviewId, coverage: args.review?.coverage ?? [], coverageComplete: args.review?.coverageComplete ?? false, + freshness: args.review?.freshness ?? UNKNOWN_FRESHNESS, queue: Object.values(args.prior.queue).sort((left, right) => left.id.localeCompare(right.id) ), @@ -2106,6 +2175,7 @@ async function runEvolutionLoopScoped(args: { generatedAt, coverageComplete: review.coverageComplete, resolvedEvidenceKeys: review.resolvedEvidenceKeys ?? [], + resolvedSignalFamilies: review.resolvedSignalFamilies ?? [], }); const generationAfter = prior.generation + (args.dryRun ? 0 : 1); const runId = `LR-${sha256( @@ -2143,6 +2213,7 @@ async function runEvolutionLoopScoped(args: { reviewId: review.reviewId, coverage: review.coverage, coverageComplete: review.coverageComplete, + freshness: review.freshness, queue: Object.values(reconciledQueue.queue).sort((left, right) => left.id.localeCompare(right.id) ), @@ -2190,6 +2261,7 @@ async function runEvolutionLoopScoped(args: { : prior.lastSuccessfulScheduledConfigGeneration, lastRunStatus: review.coverageComplete ? "complete" : "degraded", lastCoverageComplete: review.coverageComplete, + lastFreshnessState: review.freshness.state, lastSuccessfulCoverageUntil: review.coverageComplete ? review.window.until : prior.lastSuccessfulCoverageUntil, @@ -2270,6 +2342,7 @@ async function runEvolutionLoopScoped(args: { ...nextState, lastRunStatus: "failed", lastCoverageComplete: false, + lastFreshnessState: "unknown", lastSuccessfulScheduledRunAt: prior.lastSuccessfulScheduledRunAt, lastSuccessfulScheduledConfigGeneration: diff --git a/src/reconciliation-adapters.ts b/src/reconciliation-adapters.ts index 7bd345a7..c6fd276d 100644 --- a/src/reconciliation-adapters.ts +++ b/src/reconciliation-adapters.ts @@ -193,14 +193,18 @@ function latestTimestamp(records: SourceRecord[]): string | undefined { function resultFromRecords( records: SourceRecord[], - staleReason?: string + latestSourceAt?: string ): AdapterScanResult { + const watermark = latestTimestamp(records); if (records.length > 0) { - return { state: "changed", records, watermark: latestTimestamp(records) }; + return { + state: "changed", + records, + watermark, + latestSourceAt: latestSourceAt ?? watermark, + }; } - return staleReason - ? { state: "stale", records, staleReason } - : { state: "checked", records }; + return { state: "checked", records, latestSourceAt }; } function record(args: { @@ -445,6 +449,101 @@ async function runGit(args: string[], cwd: string): Promise { return stdout; } +async function gitRefExists(ref: string, cwd: string): Promise { + const proc = Bun.spawn({ + cmd: [ + Bun.which("git") ?? "/usr/bin/git", + "rev-parse", + "--verify", + "--quiet", + `${ref}^{commit}`, + ], + cwd, + env: safeGitEnvironment(cwd), + stdout: "ignore", + stderr: "ignore", + }); + return (await proc.exited) === 0; +} + +async function configuredDefaultBranch(args: { + config: GitSourceConfig; + projectRoot: string; +}): Promise<{ display: string; ref: string }> { + if (args.config.defaultBranch) { + const candidates = args.config.defaultBranch.startsWith("refs/") + ? [args.config.defaultBranch] + : [ + `refs/heads/${args.config.defaultBranch}`, + `refs/remotes/origin/${args.config.defaultBranch}`, + ]; + for (const ref of unique(candidates)) { + if (await gitRefExists(ref, args.projectRoot)) { + return { display: args.config.defaultBranch, ref }; + } + } + throw new Error( + `Configured default Git branch is unavailable: ${args.config.defaultBranch}` + ); + } + for (const remote of ["origin", "upstream"]) { + try { + const ref = ( + await runGit( + ["symbolic-ref", "--quiet", "--short", `refs/remotes/${remote}/HEAD`], + args.projectRoot + ) + ).trim(); + if (ref && (await gitRefExists(ref, args.projectRoot))) { + return { display: ref, ref }; + } + } catch { + // Fall through to bounded local defaults. + } + } + for (const branch of ["main", "master"]) { + const ref = `refs/heads/${branch}`; + if (await gitRefExists(ref, args.projectRoot)) { + return { display: branch, ref }; + } + } + if (await gitRefExists("HEAD", args.projectRoot)) { + return { display: "HEAD", ref: "HEAD" }; + } + throw new Error("Git repository does not have any commits yet"); +} + +async function gitIsAncestor(args: { + commit: string; + ancestorOf: string; + projectRoot: string; +}): Promise { + const proc = Bun.spawn({ + cmd: [ + Bun.which("git") ?? "/usr/bin/git", + "merge-base", + "--is-ancestor", + args.commit, + args.ancestorOf, + ], + cwd: args.projectRoot, + env: safeGitEnvironment(args.projectRoot), + stdout: "ignore", + stderr: "pipe", + }); + const [exitCode, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stderr).text(), + ]); + if (exitCode === 0) { + return true; + } + if (exitCode === 1) { + return false; + } + throw new Error(stderr.trim() || "git merge-base containment check failed"); +} + function parseGitRecords(args: { context: ReconciliationAdapterContext; config: GitSourceConfig; @@ -511,13 +610,24 @@ const gitAdapter: ReconciliationAdapter = { if (isRepo !== "true") { throw new Error("Configured project is not a Git worktree"); } + const defaultBranch = await configuredDefaultBranch({ + config, + projectRoot, + }); + const latestDefaultBranch = ( + await runGit( + ["log", "-1", "--format=%H%x1f%cI", defaultBranch.ref], + projectRoot + ) + ).trim(); + const [, latestSourceAt] = latestDefaultBranch.split("\u001f"); const pathArgs = config.paths?.length ? ["--", ...config.paths] : []; let output: string; try { output = await runGit( [ "log", - ...(config.allBranches ? ["--all"] : []), + ...(config.allBranches ? ["--all"] : [defaultBranch.ref]), `--since=${context.window.since}`, `--until=${context.window.until}`, "--format=%x1e%H%x1f%cI%x1f%s%x1f%b%x00", @@ -548,16 +658,33 @@ const gitAdapter: ReconciliationAdapter = { ], projectRoot ); + const onDefaultBranch = await gitIsAncestor({ + commit: entry.commit, + ancestorOf: defaultBranch.ref, + projectRoot, + }); const { commit: _commit, ...base } = entry; - records.push({ ...base, dedupeKey: `git-patch:${sha256(patch)}` }); + records.push({ + ...base, + dedupeKey: `git-patch:${sha256(patch)}`, + provenance: { + ...base.provenance, + defaultBranch: defaultBranch.display, + onDefaultBranch, + terminal: onDefaultBranch, + }, + }); } - return resultFromRecords(records); + return resultFromRecords(records, latestSourceAt); } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (UNBORN_GIT_RE.test(message)) { + return resultFromRecords([]); + } return { state: "unavailable", records: [], - unavailableReason: - error instanceof Error ? error.message : String(error), + unavailableReason: message, }; } }, @@ -573,6 +700,7 @@ interface EvidenceExportEvent { body?: string; refs?: string[]; terminal?: boolean; + status?: string; sourceUri?: string; } @@ -597,6 +725,19 @@ const EVIDENCE_EVENT_KINDS = new Set([ "comment", "status-change", ]); +const TERMINAL_EVIDENCE_STATUSES = new Set([ + "done", + "completed", + "complete", + "canceled", + "cancelled", + "obsolete", + "duplicate", + "duplicated", + "resolved", + "closed", + "superseded", +]); function parseEvidenceExport(value: unknown): EvidenceExportEnvelope { if (!isPlainObject(value) || value.version !== 1) { @@ -648,8 +789,17 @@ function parseEvidenceExport(value: unknown): EvidenceExportEnvelope { if (!isPlainObject(entry)) { throw new Error(`Evidence export event ${index + 1} must be an object`); } - const { id, kind, observedAt, title, body, refs, terminal, sourceUri } = - entry; + const { + id, + kind, + observedAt, + title, + body, + refs, + terminal, + status, + sourceUri, + } = entry; if (typeof id !== "string" || !id || id.length > 500 || seenIds.has(id)) { throw new Error(`Evidence export event ${index + 1} has an invalid id`); } @@ -669,6 +819,7 @@ function parseEvidenceExport(value: unknown): EvidenceExportEnvelope { for (const [field, fieldValue] of [ ["title", title], ["body", body], + ["status", status], ["sourceUri", sourceUri], ] as const) { if ( @@ -698,6 +849,7 @@ function parseEvidenceExport(value: unknown): EvidenceExportEnvelope { body: body as string | undefined, refs: refs as string[] | undefined, terminal: terminal as boolean | undefined, + status: status as string | undefined, sourceUri: sourceUri as string | undefined, }; }); @@ -742,7 +894,7 @@ async function loadEvidenceExport(args: { function evidenceClassification( event: EvidenceExportEvent ): SignalClassification { - if (event.terminal) { + if (isTerminalEvidenceEvent(event)) { return "outcome-proof"; } const text = `${event.title ?? ""} ${event.body ?? ""}`.toLowerCase(); @@ -755,6 +907,14 @@ function evidenceClassification( return "implementation-only"; } +function isTerminalEvidenceEvent(event: EvidenceExportEvent): boolean { + return ( + event.terminal === true || + (typeof event.status === "string" && + TERMINAL_EVIDENCE_STATUSES.has(event.status.trim().toLowerCase())) + ); +} + const evidenceExportAdapter: ReconciliationAdapter = { type: "evidence-export", version: 1, @@ -783,6 +943,8 @@ const evidenceExportAdapter: ReconciliationAdapter = { producer: envelope.producer, generatedAt: envelope.generatedAt, kind: event.kind, + status: event.status ?? null, + terminal: isTerminalEvidenceEvent(event), sourceUri: safeEvidenceSourceUri(event.sourceUri), }, extraRefs: event.refs ?? [], @@ -1024,11 +1186,9 @@ function fileAdapter(type: "automation" | "markdown"): ReconciliationAdapter { ); } } - const staleReason = - paths.size > 0 && - Math.max(latestMtime, latestObserved) < - Date.parse(context.window.since) - ? "Configured files exist but none changed in the review window" + const latestSourceAt = + Math.max(latestMtime, latestObserved) > 0 + ? new Date(Math.max(latestMtime, latestObserved)).toISOString() : undefined; const cursor = sha256(contentDigests.sort().join("\n")); if (missingTimestamps > 0) { @@ -1045,6 +1205,7 @@ function fileAdapter(type: "automation" | "markdown"): ReconciliationAdapter { records, cursor, staleReason: `File scan truncated at the ${MAX_FILES}-file safety cap`, + latestSourceAt, }; } if (skippedFiles > 0) { @@ -1055,7 +1216,7 @@ function fileAdapter(type: "automation" | "markdown"): ReconciliationAdapter { unavailableReason: `${skippedFiles} configured file(s) could not be safely extracted`, }; } - return { ...resultFromRecords(records, staleReason), cursor }; + return { ...resultFromRecords(records, latestSourceAt), cursor }; } catch (error) { return { state: "unavailable", diff --git a/src/reconciliation-config.ts b/src/reconciliation-config.ts index d7d0b822..b7d363ba 100644 --- a/src/reconciliation-config.ts +++ b/src/reconciliation-config.ts @@ -14,6 +14,9 @@ import type { const SOURCE_ID_RE = /^[a-z0-9][a-z0-9._-]*$/i; const PATH_SEGMENT_RE = /[\\/]/; +const GIT_BRANCH_RE = /^[a-z0-9][a-z0-9._/-]*$/i; +export const DEFAULT_SOURCE_FRESHNESS_THRESHOLD_HOURS = 168; +const MAX_SOURCE_FRESHNESS_THRESHOLD_HOURS = 24 * 365; function isPlainObject(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); @@ -50,6 +53,26 @@ function stringArray(value: unknown, field: string): string[] { return value.map((entry) => String(entry).trim()); } +function parseFreshnessThreshold( + value: unknown, + sourceId: string +): number | undefined { + if (value === undefined) { + return undefined; + } + if ( + typeof value !== "number" || + !Number.isInteger(value) || + value < 1 || + value > MAX_SOURCE_FRESHNESS_THRESHOLD_HOURS + ) { + throw new Error( + `Reconciliation source ${sourceId} freshnessThresholdHours must be an integer between 1 and ${MAX_SOURCE_FRESHNESS_THRESHOLD_HOURS}` + ); + } + return value; +} + function parseSource(value: unknown): ReconciliationSourceConfig { if (!isPlainObject(value)) { throw new Error("Reconciliation sources must be objects"); @@ -59,10 +82,14 @@ function parseSource(value: unknown): ReconciliationSourceConfig { throw new Error(`Reconciliation source ${id} enabled must be boolean`); } const enabled = value.enabled !== false; + const freshnessThresholdHours = parseFreshnessThreshold( + value.freshnessThresholdHours, + id + ); if (value.type === "writebacks") { assertKnownFields( value, - ["id", "type", "enabled", "scope"], + ["id", "type", "enabled", "scope", "freshnessThresholdHours"], `Writeback source ${id}` ); if ( @@ -77,15 +104,30 @@ function parseSource(value: unknown): ReconciliationSourceConfig { type: "writebacks", enabled, scope: value.scope ?? "context", + freshnessThresholdHours, }; } if (value.type === "git") { assertKnownFields( value, - ["id", "type", "enabled", "repository", "paths", "allBranches"], + [ + "id", + "type", + "enabled", + "repository", + "paths", + "allBranches", + "defaultBranch", + "freshnessThresholdHours", + ], `Git source ${id}` ); - const source: GitSourceConfig = { id, type: "git", enabled }; + const source: GitSourceConfig = { + id, + type: "git", + enabled, + freshnessThresholdHours, + }; if (value.repository !== undefined && value.repository !== "project") { throw new Error(`Git source ${id} repository must be project`); } @@ -109,12 +151,27 @@ function parseSource(value: unknown): ReconciliationSourceConfig { } source.allBranches = value.allBranches; } + if (value.defaultBranch !== undefined) { + if ( + typeof value.defaultBranch !== "string" || + !GIT_BRANCH_RE.test(value.defaultBranch) || + value.defaultBranch.includes("..") || + value.defaultBranch.includes("@{") || + value.defaultBranch.endsWith("/") || + value.defaultBranch.includes("//") + ) { + throw new Error( + `Git source ${id} defaultBranch must be a safe Git branch or ref` + ); + } + source.defaultBranch = value.defaultBranch; + } return source; } if (value.type === "evidence-export") { assertKnownFields( value, - ["id", "type", "enabled", "path"], + ["id", "type", "enabled", "path", "freshnessThresholdHours"], `Evidence export source ${id}` ); if (typeof value.path !== "string" || !value.path.trim()) { @@ -127,6 +184,7 @@ function parseSource(value: unknown): ReconciliationSourceConfig { type: "evidence-export", enabled, path: value.path.trim(), + freshnessThresholdHours, }; if ( isAbsolute(source.path) || @@ -142,7 +200,7 @@ function parseSource(value: unknown): ReconciliationSourceConfig { if (value.type === "automation" || value.type === "markdown") { assertKnownFields( value, - ["id", "type", "enabled", "root", "paths"], + ["id", "type", "enabled", "root", "paths", "freshnessThresholdHours"], `${value.type} source ${id}` ); const root = @@ -158,6 +216,7 @@ function parseSource(value: unknown): ReconciliationSourceConfig { enabled, root, paths: stringArray(value.paths, `${value.type} source ${id} paths`), + freshnessThresholdHours, }; for (const path of source.paths) { if ( diff --git a/src/reconciliation-types.ts b/src/reconciliation-types.ts index 9aa2d8e8..d3bbbdb4 100644 --- a/src/reconciliation-types.ts +++ b/src/reconciliation-types.ts @@ -13,6 +13,38 @@ export type SourceCoverageState = | "changed" | "stale"; +export type SourceFreshnessState = + | "current" + | "stale" + | "unknown" + | "not_applicable"; + +export type SourceFreshnessReason = + | "cursor_advanced" + | "within_threshold" + | "threshold_exceeded" + | "newer_repository_activity" + | "source_unavailable" + | "legacy_report" + | "no_cursor"; + +export interface SourceFreshness { + state: SourceFreshnessState; + reason: SourceFreshnessReason; + checkedAt: string; + thresholdHours: number; + alert: boolean; + cursorAt?: string; + latestSourceAt?: string; +} + +export interface ReconciliationFreshness { + state: "current" | "stale" | "unknown"; + staleSourceIds: string[]; + unknownSourceIds: string[]; + alertSourceIds: string[]; +} + export type SignalClassification = | "implementation-only" | "capability-source" @@ -24,6 +56,7 @@ interface BaseSourceConfig { id: string; type: ReconciliationSourceType; enabled?: boolean; + freshnessThresholdHours?: number; } export interface WritebackSourceConfig extends BaseSourceConfig { @@ -36,6 +69,7 @@ export interface GitSourceConfig extends BaseSourceConfig { repository?: "project"; paths?: string[]; allBranches?: boolean; + defaultBranch?: string; } export interface EvidenceExportSourceConfig extends BaseSourceConfig { @@ -91,6 +125,7 @@ export interface AdapterScanResult { records: SourceRecord[]; watermark?: string; cursor?: string; + latestSourceAt?: string; unavailableReason?: string; staleReason?: string; } @@ -122,10 +157,22 @@ export interface SourceCoverage { cursorAfter?: string; recordsScanned: number; signalsDiscovered: number; + freshness: SourceFreshness; unavailableReason?: string; staleReason?: string; } +export interface ResolutionProof { + sourceId: string; + sourceType: ReconciliationSourceType; + sourceRecordId: string; + kind: "linked_work_terminal" | "default_branch_containment"; + issueRefs: string[]; + evidenceKey: string; + status?: string; + provenance: SourceRecord["provenance"]; +} + export interface ExtractionDecision { id: string; sourceId: string; @@ -180,11 +227,14 @@ export interface ReconciliationReview { window: ReconciliationWindow; coverageComplete: boolean; degraded: boolean; + freshness: ReconciliationFreshness; emptyReason?: string; coverage: SourceCoverage[]; decisions: ExtractionDecision[]; evidence: ReconciledEvidence[]; signals: CorrelatedSignal[]; + resolutionProofs: ResolutionProof[]; + resolvedSignalFamilies: string[]; resolvedEvidenceKeys: string[]; unresolvedSignals: string[]; linkedWork: string[]; @@ -204,6 +254,7 @@ export interface ReconciliationState { lastCheckedAt: string; coverageUntil?: string; coverageState: SourceCoverageState; + freshnessState?: SourceFreshnessState; } >; evidence: Record< @@ -246,6 +297,7 @@ export interface ReconciliationState { generatedAt: string; artifactPath: string; coverageComplete?: boolean; + freshnessState?: ReconciliationFreshness["state"]; evidenceKeys: string[]; signalIds: string[]; signalFamilyIds?: string[]; diff --git a/src/reconciliation.test.ts b/src/reconciliation.test.ts index 0c0a9fff..70d5d795 100644 --- a/src/reconciliation.test.ts +++ b/src/reconciliation.test.ts @@ -215,9 +215,7 @@ describe("reconciliation config", () => { expect(() => parseReconciliationConfig({ version: 1, - sources: [ - { id: "git", type: "git", freshnessThresholdHours: 0 }, - ], + sources: [{ id: "git", type: "git", freshnessThresholdHours: 0 }], }) ).toThrow("freshnessThresholdHours"); expect(() => @@ -1514,9 +1512,9 @@ describe("source reconciliation", () => { expect(review.linkedWork).toEqual(linkedWork.map(([issue]) => issue)); expect(review.signals).toHaveLength(linkedWork.length); expect(review.signals.every((signal) => !signal.unresolved)).toBe(true); - expect(review.signals.every((signal) => signal.disposition === "resolve-watch")).toBe( - true - ); + expect( + review.signals.every((signal) => signal.disposition === "resolve-watch") + ).toBe(true); expect(review.resolutionProofs).toHaveLength(linkedWork.length); expect(review.resolutionProofs.map((proof) => proof.status).sort()).toEqual( linkedWork.map(([, status]) => status).sort() diff --git a/src/reconciliation.ts b/src/reconciliation.ts index 6f36614a..0604ed72 100644 --- a/src/reconciliation.ts +++ b/src/reconciliation.ts @@ -15,18 +15,24 @@ import { projectRootFromAiRoot, } from "./paths"; import { reconciliationAdapterFor } from "./reconciliation-adapters"; -import { loadReconciliationConfig } from "./reconciliation-config"; +import { + DEFAULT_SOURCE_FRESHNESS_THRESHOLD_HOURS, + loadReconciliationConfig, +} from "./reconciliation-config"; import type { AdapterScanResult, CorrelatedSignal, ExtractionDecision, ReconciledEvidence, ReconciliationConfig, + ReconciliationFreshness, ReconciliationReview, ReconciliationState, ReconciliationWindow, + ResolutionProof, SignalClassification, SourceCoverage, + SourceFreshness, SourceRecord, } from "./reconciliation-types"; @@ -38,7 +44,7 @@ const CAPABILITY_RE = /\b(?:capability|writeback|evolution|instruction|skill|agent|runbook|reconciliation|feedback loop|verification)\b/i; const NOISE_RE = /\b(?:chore|format|typo|timestamp|heartbeat unchanged|no-op)\b/i; -const RECONCILIATION_ENGINE_VERSION = 5; +const RECONCILIATION_ENGINE_VERSION = 6; const STOP_WORD_RE = /\b(?:the|and|for|with|from|this|that|into|was|were|are|has|have)\b/g; const NON_ALPHANUMERIC_RE = /[^a-z0-9]+/g; @@ -318,6 +324,14 @@ function dispositionFor(args: { "Preserved the explicit disposition from the latest writeback state", }; } + if (args.records.some((record) => record.provenance.terminal === true)) { + return { + disposition: "resolve-watch", + target: args.issueRefs[0] ?? args.assetRefs[0], + rationale: + "Bounded current-source evidence proves the linked work is terminal or the exact implementation is on the default branch", + }; + } if (args.classifications.includes("capability-implementation")) { return OUTCOME_RE.test(args.records.map((record) => record.body).join(" ")) ? { @@ -479,6 +493,17 @@ function correlate(args: { ]); } } + for (const item of evidence) { + const matchingFamilyIds = Object.entries(args.state.families ?? {}) + .filter(([, family]) => + family.subjectKeys.some((key) => item.correlationKeys.includes(key)) + ) + .map(([familyId]) => familyId); + item.correlationKeys = unique([ + ...item.correlationKeys, + ...matchingFamilyIds.map((familyId) => `family:${familyId}`), + ]); + } const set = new DisjointSet(evidence.length); const keyOwner = new Map(); @@ -583,10 +608,205 @@ function dispositionCounts( return counts; } +function latestTimestampValue( + left: string | undefined, + right: string | undefined +): string | undefined { + if (!left) { + return right; + } + if (!right) { + return left; + } + return Date.parse(left) >= Date.parse(right) ? left : right; +} + +function sourceFreshness(args: { + checkedAt: string; + until: string; + coverageState: SourceCoverage["state"]; + thresholdHours?: number; + priorWatermark?: string; + result: AdapterScanResult; +}): SourceFreshness { + const thresholdHours = + args.thresholdHours ?? DEFAULT_SOURCE_FRESHNESS_THRESHOLD_HOURS; + if (args.coverageState === "unavailable") { + return { + state: "unknown", + reason: "source_unavailable", + checkedAt: args.checkedAt, + thresholdHours, + alert: false, + }; + } + const cursorAt = latestTimestampValue( + args.priorWatermark, + args.result.watermark + ); + const latestSourceAt = args.result.latestSourceAt; + if ( + latestSourceAt && + Date.parse(latestSourceAt) <= Date.parse(args.until) && + (!cursorAt || Date.parse(latestSourceAt) > Date.parse(cursorAt)) + ) { + return { + state: "stale", + reason: "newer_repository_activity", + checkedAt: args.checkedAt, + thresholdHours, + alert: true, + cursorAt, + latestSourceAt, + }; + } + if (!cursorAt) { + return { + state: "not_applicable", + reason: "no_cursor", + checkedAt: args.checkedAt, + thresholdHours, + alert: false, + latestSourceAt, + }; + } + const cursorAgeHours = + (Date.parse(args.until) - Date.parse(cursorAt)) / (60 * 60 * 1000); + if (cursorAgeHours > thresholdHours) { + return { + state: "stale", + reason: "threshold_exceeded", + checkedAt: args.checkedAt, + thresholdHours, + alert: true, + cursorAt, + latestSourceAt, + }; + } + const resultWatermark = args.result.watermark; + const cursorAdvanced = + Boolean(resultWatermark) && + (!args.priorWatermark || + Date.parse(resultWatermark as string) > Date.parse(args.priorWatermark)); + return { + state: "current", + reason: cursorAdvanced ? "cursor_advanced" : "within_threshold", + checkedAt: args.checkedAt, + thresholdHours, + alert: false, + cursorAt, + latestSourceAt, + }; +} + +function reconciliationFreshness( + coverage: SourceCoverage[] +): ReconciliationFreshness { + const staleSourceIds = coverage + .filter((entry) => entry.freshness.state === "stale") + .map((entry) => entry.sourceId) + .sort(); + const unknownSourceIds = coverage + .filter((entry) => entry.freshness.state === "unknown") + .map((entry) => entry.sourceId) + .sort(); + return { + state: + staleSourceIds.length > 0 + ? "stale" + : unknownSourceIds.length > 0 + ? "unknown" + : "current", + staleSourceIds, + unknownSourceIds, + alertSourceIds: coverage + .filter((entry) => entry.freshness.alert) + .map((entry) => entry.sourceId) + .sort(), + }; +} + +function normalizeReviewFreshness( + review: ReconciliationReview +): ReconciliationReview { + const coverage = review.coverage.map((entry) => + entry.freshness + ? entry + : { + ...entry, + freshness: { + state: "unknown" as const, + reason: "legacy_report" as const, + checkedAt: entry.checkedAt ?? review.generatedAt, + thresholdHours: DEFAULT_SOURCE_FRESHNESS_THRESHOLD_HOURS, + alert: false, + }, + } + ); + return { + ...review, + coverage, + freshness: review.freshness ?? reconciliationFreshness(coverage), + resolutionProofs: review.resolutionProofs ?? [], + resolvedSignalFamilies: review.resolvedSignalFamilies ?? [], + }; +} + +function resolutionProofs(records: SourceRecord[]): ResolutionProof[] { + return records + .filter((record) => record.provenance.terminal === true) + .map((record) => ({ + sourceId: record.sourceId, + sourceType: record.sourceType, + sourceRecordId: record.recordId, + kind: + record.sourceType === "git" && + record.provenance.onDefaultBranch === true + ? ("default_branch_containment" as const) + : ("linked_work_terminal" as const), + issueRefs: record.issueRefs, + evidenceKey: record.dedupeKey, + status: + typeof record.provenance.status === "string" + ? record.provenance.status + : undefined, + provenance: record.provenance, + })); +} + +function resolvedSignalFamilies(args: { + state: ReconciliationState; + proofs: ResolutionProof[]; +}): string[] { + const terminalIssueKeys = new Set( + args.proofs.flatMap((proof) => + proof.issueRefs.map((issueRef) => `issue:${issueRef}`) + ) + ); + const terminalEvidenceKeys = new Set( + args.proofs.map((proof) => proof.evidenceKey) + ); + return Object.entries(args.state.families ?? {}) + .filter(([, family]) => { + const linkedIssues = family.subjectKeys.filter((key) => + key.startsWith("issue:") + ); + const allLinkedWorkTerminal = + linkedIssues.length > 0 && + linkedIssues.every((key) => terminalIssueKeys.has(key)); + const exactEvidenceOnDefaultBranch = family.evidenceKeys.some((key) => + terminalEvidenceKeys.has(key) + ); + return allLinkedWorkTerminal || exactEvidenceOnDefaultBranch; + }) + .map(([familyId]) => familyId) + .sort(); +} + function renderReview(review: ReconciliationReview): string { const coverage = review.coverage.map( (entry) => - `| ${entry.sourceId} | ${entry.sourceType} | ${entry.state} | ${entry.recordsScanned} | ${entry.signalsDiscovered} | ${entry.unavailableReason ?? entry.staleReason ?? ""} |` + `| ${entry.sourceId} | ${entry.sourceType} | ${entry.state} | ${entry.freshness.state} | ${entry.freshness.reason} | ${entry.recordsScanned} | ${entry.signalsDiscovered} | ${entry.unavailableReason ?? entry.staleReason ?? ""} |` ); const signals = review.signals.flatMap((signal) => [ `### ${signal.id} — ${signal.title}`, @@ -618,6 +838,7 @@ function renderReview(review: ReconciliationReview): string { `since: "${review.window.since}"`, `until: "${review.window.until}"`, `coverageComplete: ${review.coverageComplete}`, + `freshness: "${review.freshness.state}"`, `degraded: ${review.degraded}`, "---", "", @@ -628,8 +849,8 @@ function renderReview(review: ReconciliationReview): string { "", "## Source coverage", "", - "| Source | Type | State | Records | Signals | Detail |", - "| --- | --- | --- | ---: | ---: | --- |", + "| Source | Type | Coverage | Freshness | Freshness reason | Records | Signals | Detail |", + "| --- | --- | --- | --- | --- | ---: | ---: | --- |", ...coverage, "", "## Signals and dispositions", @@ -664,7 +885,8 @@ function updateState(args: { if (!source) { continue; } - const advances = coverage.state !== "unavailable"; + const advances = + coverage.state === "checked" || coverage.state === "changed"; const resultWatermark = result?.watermark; const keepsPriorWatermark = Boolean( advances && @@ -696,6 +918,7 @@ function updateState(args: { coverageState: keepsPriorCoverage ? (prior?.coverageState ?? coverage.state) : coverage.state, + freshnessState: coverage.freshness.state, }; } for (const item of args.review.evidence) { @@ -763,6 +986,7 @@ function updateState(args: { generatedAt: args.review.generatedAt, artifactPath: args.review.artifactPath, coverageComplete: args.review.coverageComplete, + freshnessState: args.review.freshness.state, evidenceKeys: args.review.evidence.map((item) => item.dedupeKey), signalIds: args.review.signals.map((signal) => signal.id), signalFamilyIds: unique( @@ -905,17 +1129,32 @@ export async function reconcileSources(args: { ) : result.records; records.push(...reviewRecords); + const coverageState = + result.state === "changed" && reviewRecords.length === 0 + ? "checked" + : result.state; coverage.push({ sourceId: source.id, sourceType: source.type, - state: result.state, + state: coverageState, checkedAt, watermarkBefore: prior?.watermark, - watermarkAfter: result.watermark ?? prior?.watermark, + watermarkAfter: latestTimestampValue( + prior?.watermark, + result.watermark + ), cursorBefore: prior?.cursor, cursorAfter: result.cursor ?? prior?.cursor, - recordsScanned: result.records.length, + recordsScanned: reviewRecords.length, signalsDiscovered: 0, + freshness: sourceFreshness({ + checkedAt, + until: requestedWindow.until, + coverageState, + thresholdHours: source.freshnessThresholdHours, + priorWatermark: prior?.watermark, + result, + }), unavailableReason: result.unavailableReason, staleReason: result.staleReason, }); @@ -944,6 +1183,7 @@ export async function reconcileSources(args: { coverage.some( (entry) => entry.state === "unavailable" || entry.state === "stale" ); + const freshness = reconciliationFreshness(coverage); const reviewDir = facultAiReconciliationReviewDir( args.homeDir, args.rootDir @@ -957,6 +1197,7 @@ export async function reconcileSources(args: { : coverageComplete ? "Zero signals discovered after every configured source was checked for this review window." : "No signals are reported, but configured coverage is degraded; this is not a proven empty review."; + const proofs = resolutionProofs(records); const review: ReconciliationReview = { version: 1, reviewId: window.id, @@ -964,11 +1205,17 @@ export async function reconcileSources(args: { window, coverageComplete, degraded, + freshness, emptyReason, coverage, decisions, evidence: correlated.evidence, signals: correlated.signals, + resolutionProofs: proofs, + resolvedSignalFamilies: resolvedSignalFamilies({ + state, + proofs, + }), resolvedEvidenceKeys: unique( records .filter((record) => record.provenance.terminal === true) @@ -1019,6 +1266,7 @@ export async function reconciliationStatus(args: { sourceCount: number; lastReviewId?: string; coverageState?: "complete" | "degraded"; + freshnessState?: ReconciliationFreshness["state"]; }> { const statePath = facultAiReconciliationStatePath(args.homeDir, args.rootDir); const configPath = join(args.rootDir, "reconciliation.json"); @@ -1083,6 +1331,7 @@ export async function reconciliationStatus(args: { : lastReview ? "complete" : undefined, + freshnessState: lastReview?.[1].freshnessState, }; } catch (error) { return { @@ -1126,9 +1375,9 @@ export async function reconciliationReviewById(args: { `${args.reviewId}.json` ); try { - return JSON.parse( - await readFile(windowPath, "utf8") - ) as ReconciliationReview; + return normalizeReviewFreshness( + JSON.parse(await readFile(windowPath, "utf8")) as ReconciliationReview + ); } catch { return null; } From 9f3ce325fe4604c5a0f3fcef39f06fb8b6edec40 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Wed, 29 Jul 2026 06:25:51 -0400 Subject: [PATCH 3/4] fix: close stale reconciliation gaps --- src/evolution-loop.test.ts | 263 +++++++++++++++++++++++++++++++++ src/evolution-loop.ts | 13 +- src/reconciliation-adapters.ts | 16 +- src/reconciliation-types.ts | 1 + src/reconciliation.test.ts | 120 +++++++++++++++ src/reconciliation.ts | 135 ++++++++++++++--- 6 files changed, 518 insertions(+), 30 deletions(-) diff --git a/src/evolution-loop.test.ts b/src/evolution-loop.test.ts index a2e8ecab..e7b4bd5d 100644 --- a/src/evolution-loop.test.ts +++ b/src/evolution-loop.test.ts @@ -112,6 +112,113 @@ async function runProjectGit(args: { } } +async function verifyPersistedGitContainmentTransition( + strategy: "fast-forward" | "merge-commit" +) { + const project = await makeProject(); + for (const argv of [ + ["init", "--quiet", "--initial-branch=main"], + ["config", "user.email", "fixture@example.invalid"], + ["config", "user.name", "Fixture"], + ]) { + await runProjectGit({ projectRoot: project.projectRoot, argv }); + } + await runProjectGit({ + projectRoot: project.projectRoot, + argv: ["commit", "--allow-empty", "--quiet", "-m", "chore: base"], + date: "2026-01-01T12:00:00.000Z", + }); + await runProjectGit({ + projectRoot: project.projectRoot, + argv: ["switch", "--quiet", "-c", "feature"], + }); + const capabilityPath = join( + project.rootDir, + "instructions", + "RECONCILIATION.md" + ); + await mkdir(dirname(capabilityPath), { recursive: true }); + await Bun.write( + capabilityPath, + "# Reconciliation\n\nCapability implementation linked to HACK-1033.\n" + ); + await runProjectGit({ + projectRoot: project.projectRoot, + argv: ["add", ".ai/instructions"], + }); + await runProjectGit({ + projectRoot: project.projectRoot, + argv: ["commit", "--quiet", "-m", "feat: add HACK-1033 reconciliation"], + date: "2026-01-02T12:00:00.000Z", + }); + await Bun.write( + join(project.rootDir, "reconciliation.json"), + `${JSON.stringify({ + version: 1, + sources: [ + { + id: "git", + type: "git", + allBranches: true, + defaultBranch: "main", + paths: [".ai/instructions"], + }, + ], + })}\n` + ); + await enableEvolutionLoop({ + ...project, + now: () => new Date("2026-01-03T00:00:00.000Z"), + }); + const beforeMerge = await runEvolutionLoop({ + ...project, + since: "2026-01-01T00:00:00.000Z", + until: "2026-01-03T00:00:00.000Z", + now: () => new Date("2026-01-03T00:00:00.000Z"), + }); + const pending = beforeMerge.queue.find( + (item) => item.kind === "signal" && item.linkedWork.includes("HACK-1033") + ); + if (!pending) { + throw new Error("Expected the feature commit to create a pending signal"); + } + + await runProjectGit({ + projectRoot: project.projectRoot, + argv: ["switch", "--quiet", "main"], + }); + await runProjectGit({ + projectRoot: project.projectRoot, + argv: + strategy === "fast-forward" + ? ["merge", "--ff-only", "feature"] + : ["merge", "--no-ff", "--no-edit", "feature"], + date: "2026-01-04T12:00:00.000Z", + }); + const afterMerge = await runEvolutionLoop({ + ...project, + since: "2026-01-01T00:00:00.000Z", + until: "2026-01-05T00:00:00.000Z", + now: () => new Date("2026-01-05T00:00:00.000Z"), + }); + const resolved = afterMerge.queue.find((item) => item.id === pending.id); + + const reconciliationState = JSON.parse( + await readFile( + facultAiReconciliationStatePath(project.homeDir, project.rootDir), + "utf8" + ) + ); + + const quiet = await runEvolutionLoop({ + ...project, + since: "2026-01-01T00:00:00.000Z", + until: "2026-01-06T00:00:00.000Z", + now: () => new Date("2026-01-06T00:00:00.000Z"), + }); + return { afterMerge, pending, quiet, reconciliationState, resolved }; +} + afterEach(async () => { for (const root of temporaryRoots.splice(0)) { await rm(root, { recursive: true, force: true }); @@ -555,6 +662,162 @@ describe("evolution loop", () => { expect(quiet.delta.notifiable).toHaveLength(0); }); + for (const [strategy, label] of [ + ["fast-forward", "fast-forward merge"], + ["merge-commit", "merge commit"], + ] as const) { + it(`resolves persisted feature evidence after a ${label}`, async () => { + const result = await verifyPersistedGitContainmentTransition(strategy); + + expect(result.pending.state).not.toBe("resolved"); + expect(result.resolved).toMatchObject({ + state: "resolved", + linkedWork: expect.arrayContaining(["HACK-1033"]), + }); + expect(result.afterMerge.delta.resolved).toContain(result.pending.id); + expect( + Object.values(result.reconciliationState.evidence).some( + (entry) => + typeof entry === "object" && + entry !== null && + "defaultBranchContainment" in entry + ) + ).toBe(true); + expect( + result.quiet.queue.find((item) => item.id === result.pending.id)?.state + ).toBe("resolved"); + expect(result.quiet.delta.notifiable).not.toContain(result.pending.id); + }); + } + + it("keeps a multi-issue family open when only one linked issue is terminal", async () => { + const project = await makeProject(); + const evidencePath = join(project.projectRoot, "evidence.json"); + await Bun.write( + join(project.rootDir, "reconciliation.json"), + `${JSON.stringify({ + version: 1, + sources: [ + { + id: "work-export", + type: "evidence-export", + path: "evidence.json", + }, + ], + })}\n` + ); + const writeEvidence = async (args: { + generatedAt: string; + until: string; + events: unknown[]; + }): Promise => { + await Bun.write( + evidencePath, + `${JSON.stringify({ + version: 1, + producer: "multi-issue-fixture", + generatedAt: args.generatedAt, + coverage: { + since: "2026-01-01T00:00:00.000Z", + until: args.until, + complete: true, + }, + events: args.events, + })}\n` + ); + }; + await writeEvidence({ + generatedAt: "2026-01-03T00:00:00.000Z", + until: "2026-01-02T23:59:59.999Z", + events: [ + { + id: "family-open", + kind: "work-item", + observedAt: "2026-01-02T00:00:00.000Z", + title: "Capability implementation remains open", + refs: ["HACK-1101", "HACK-1102"], + }, + ], + }); + await enableEvolutionLoop({ + ...project, + now: () => new Date("2026-01-03T00:00:00.000Z"), + }); + const first = await runEvolutionLoop({ + ...project, + since: "2026-01-01T00:00:00.000Z", + until: "2026-01-02T23:59:59.999Z", + now: () => new Date("2026-01-03T00:00:00.000Z"), + }); + const family = first.queue.find( + (item) => + item.kind === "signal" && + item.linkedWork.includes("HACK-1101") && + item.linkedWork.includes("HACK-1102") + ); + expect(family).toMatchObject({ state: "open" }); + + await writeEvidence({ + generatedAt: "2026-01-05T00:00:00.000Z", + until: "2026-01-04T23:59:59.999Z", + events: [ + { + id: "hack-1101-done", + kind: "status-change", + observedAt: "2026-01-04T00:00:00.000Z", + title: "HACK-1101 is done", + refs: ["HACK-1101"], + status: "done", + }, + ], + }); + const partial = await runEvolutionLoop({ + ...project, + until: "2026-01-04T23:59:59.999Z", + now: () => new Date("2026-01-05T00:00:00.000Z"), + }); + const partialFamily = partial.queue.find((item) => item.id === family?.id); + expect(partialFamily).toMatchObject({ + state: "open", + linkedWork: ["HACK-1101", "HACK-1102"], + }); + expect(partial.delta.resolved).not.toContain(family?.id); + + await writeEvidence({ + generatedAt: "2026-01-07T00:00:00.000Z", + until: "2026-01-06T23:59:59.999Z", + events: [ + { + id: "hack-1101-confirmed", + kind: "status-change", + observedAt: "2026-01-06T00:00:00.000Z", + title: "HACK-1101 remains done", + refs: ["HACK-1101"], + status: "done", + }, + { + id: "hack-1102-done", + kind: "status-change", + observedAt: "2026-01-06T01:00:00.000Z", + title: "HACK-1102 is done", + refs: ["HACK-1102"], + status: "done", + }, + ], + }); + const terminal = await runEvolutionLoop({ + ...project, + until: "2026-01-06T23:59:59.999Z", + now: () => new Date("2026-01-07T00:00:00.000Z"), + }); + expect(terminal.queue.find((item) => item.id === family?.id)).toMatchObject( + { + state: "resolved", + linkedWork: ["HACK-1101", "HACK-1102"], + } + ); + }); + it("alerts once for a stale cursor while keeping complete coverage", async () => { const project = await makeProject(); for (const argv of [ diff --git a/src/evolution-loop.ts b/src/evolution-loop.ts index a8b1d2ee..c1306cf6 100644 --- a/src/evolution-loop.ts +++ b/src/evolution-loop.ts @@ -41,6 +41,7 @@ import type { CorrelatedSignal, ReconciliationFreshness, ReconciliationReview, + ResolutionProof, SourceCoverage, } from "./reconciliation-types"; import { @@ -1030,7 +1031,7 @@ function reconcileQueue(args: { prior: EvolutionLoopState; generatedAt: string; coverageComplete: boolean; - resolvedEvidenceKeys: string[]; + resolutionProofs: ResolutionProof[]; resolvedSignalFamilies: string[]; }): { queue: Record; @@ -1042,7 +1043,11 @@ function reconcileQueue(args: { const newIds: string[] = []; const changedIds: string[] = []; const resolvedIds: string[] = []; - const resolvedEvidenceKeys = new Set(args.resolvedEvidenceKeys); + const containedEvidenceKeys = new Set( + args.resolutionProofs + .filter((proof) => proof.kind === "default_branch_containment") + .map((proof) => proof.evidenceKey) + ); const resolvedSignalFamilies = new Set(args.resolvedSignalFamilies); let unchangedSuppressed = 0; for (const raw of args.current) { @@ -1104,7 +1109,7 @@ function reconcileQueue(args: { } const signalHasResolutionProof = prior.kind === "signal" && - (prior.evidenceRefs.some((key) => resolvedEvidenceKeys.has(key)) || + (prior.evidenceRefs.some((key) => containedEvidenceKeys.has(key)) || Boolean(prior.familyId && resolvedSignalFamilies.has(prior.familyId))); if ( !args.coverageComplete || @@ -2174,7 +2179,7 @@ async function runEvolutionLoopScoped(args: { prior, generatedAt, coverageComplete: review.coverageComplete, - resolvedEvidenceKeys: review.resolvedEvidenceKeys ?? [], + resolutionProofs: review.resolutionProofs ?? [], resolvedSignalFamilies: review.resolvedSignalFamilies ?? [], }); const generationAfter = prior.generation + (args.dryRun ? 0 : 1); diff --git a/src/reconciliation-adapters.ts b/src/reconciliation-adapters.ts index c6fd276d..6535981b 100644 --- a/src/reconciliation-adapters.ts +++ b/src/reconciliation-adapters.ts @@ -507,10 +507,12 @@ async function configuredDefaultBranch(args: { return { display: branch, ref }; } } - if (await gitRefExists("HEAD", args.projectRoot)) { - return { display: "HEAD", ref: "HEAD" }; + if (!(await gitRefExists("HEAD", args.projectRoot))) { + throw new Error("Git repository does not have any commits yet"); } - throw new Error("Git repository does not have any commits yet"); + throw new Error( + "Git default branch is unavailable; configure defaultBranch or provide a proven remote HEAD, main, or master" + ); } async function gitIsAncestor(args: { @@ -616,7 +618,13 @@ const gitAdapter: ReconciliationAdapter = { }); const latestDefaultBranch = ( await runGit( - ["log", "-1", "--format=%H%x1f%cI", defaultBranch.ref], + [ + "log", + "-1", + `--until=${context.window.until}`, + "--format=%H%x1f%cI", + defaultBranch.ref, + ], projectRoot ) ).trim(); diff --git a/src/reconciliation-types.ts b/src/reconciliation-types.ts index d3bbbdb4..0a1ebdf2 100644 --- a/src/reconciliation-types.ts +++ b/src/reconciliation-types.ts @@ -264,6 +264,7 @@ export interface ReconciliationState { lastSeenAt: string; sourceIds: string[]; reviewIds: string[]; + defaultBranchContainment?: Record; } >; decisions: Record< diff --git a/src/reconciliation.test.ts b/src/reconciliation.test.ts index 70d5d795..cf7de490 100644 --- a/src/reconciliation.test.ts +++ b/src/reconciliation.test.ts @@ -1732,6 +1732,42 @@ describe("source reconciliation", () => { }); }); + it("does not use a develop or feature HEAD as default-branch proof", async () => { + const fixture = await makeFixture(); + for (const argv of [ + ["init", "--quiet", "--initial-branch=develop"], + ["config", "user.email", "fixture@example.invalid"], + ["config", "user.name", "Fixture"], + ["commit", "--allow-empty", "--quiet", "-m", "chore: develop base"], + ["switch", "--quiet", "-c", "feature"], + ]) { + await runFixtureGit({ projectRoot: fixture.projectRoot, argv }); + } + await Bun.write( + join(fixture.rootDir, "reconciliation.json"), + JSON.stringify({ + version: 1, + sources: [{ id: "git", type: "git" }], + }) + ); + + const review = await reconcileSources({ + ...fixture, + since: "2026-07-03", + until: "2026-07-10", + persist: false, + }); + + expect(review.coverageComplete).toBe(false); + expect(review.coverage[0]).toMatchObject({ + state: "unavailable", + recordsScanned: 0, + }); + expect(review.coverage[0]?.unavailableReason).toContain( + "Git default branch is unavailable" + ); + }); + it("separates complete coverage from a cursor stale after newer repository activity", async () => { const fixture = await makeFixture(); for (const argv of [ @@ -1823,6 +1859,90 @@ describe("source reconciliation", () => { expect(await readFile(statePath, "utf8")).toBe(stateBefore); }); + it("bounds repository freshness activity to the review window", async () => { + const fixture = await makeFixture(); + for (const argv of [ + ["init", "--quiet", "--initial-branch=main"], + ["config", "user.email", "fixture@example.invalid"], + ["config", "user.name", "Fixture"], + ]) { + await runFixtureGit({ projectRoot: fixture.projectRoot, argv }); + } + await mkdir(join(fixture.projectRoot, "docs"), { recursive: true }); + await Bun.write( + join(fixture.projectRoot, "docs", "review.md"), + "Capability cursor baseline.\n" + ); + await runFixtureGit({ + projectRoot: fixture.projectRoot, + argv: ["add", "docs"], + }); + await runFixtureGit({ + projectRoot: fixture.projectRoot, + argv: ["commit", "--quiet", "-m", "docs: establish cursor"], + date: "2026-01-02T12:00:00.000Z", + }); + await Bun.write( + join(fixture.rootDir, "reconciliation.json"), + JSON.stringify({ + version: 1, + sources: [ + { + id: "git", + type: "git", + paths: ["docs"], + defaultBranch: "main", + freshnessThresholdHours: 168, + }, + ], + }) + ); + await reconcileSources({ + ...fixture, + since: "2026-01-01T00:00:00.000Z", + until: "2026-01-02T23:59:59.999Z", + incremental: true, + }); + + await Bun.write(join(fixture.projectRoot, "jan-4.txt"), "activity\n"); + await runFixtureGit({ + projectRoot: fixture.projectRoot, + argv: ["add", "jan-4.txt"], + }); + await runFixtureGit({ + projectRoot: fixture.projectRoot, + argv: ["commit", "--quiet", "-m", "chore: January 4 activity"], + date: "2026-01-04T12:00:00.000Z", + }); + await Bun.write(join(fixture.projectRoot, "jan-10.txt"), "later tip\n"); + await runFixtureGit({ + projectRoot: fixture.projectRoot, + argv: ["add", "jan-10.txt"], + }); + await runFixtureGit({ + projectRoot: fixture.projectRoot, + argv: ["commit", "--quiet", "-m", "chore: January 10 tip"], + date: "2026-01-10T12:00:00.000Z", + }); + + const review = await reconcileSources({ + ...fixture, + since: "2026-01-01T00:00:00.000Z", + until: "2026-01-05T23:59:59.999Z", + incremental: true, + persist: false, + }); + + expect(review.coverage[0]).toMatchObject({ + freshness: { + state: "stale", + reason: "newer_repository_activity", + cursorAt: "2026-01-02T12:00:00Z", + latestSourceAt: "2026-01-04T12:00:00Z", + }, + }); + }); + it("checks exact Git evidence against the configured default branch", async () => { const fixture = await makeFixture(); for (const argv of [ diff --git a/src/reconciliation.ts b/src/reconciliation.ts index 0604ed72..f0ec6ac9 100644 --- a/src/reconciliation.ts +++ b/src/reconciliation.ts @@ -324,7 +324,27 @@ function dispositionFor(args: { "Preserved the explicit disposition from the latest writeback state", }; } - if (args.records.some((record) => record.provenance.terminal === true)) { + const exactImplementationOnDefaultBranch = args.records.some( + (record) => + record.sourceType === "git" && record.provenance.onDefaultBranch === true + ); + const terminalIssueRefs = new Set( + args.records + .filter( + (record) => + record.provenance.terminal === true && + !( + record.sourceType === "git" && + record.provenance.onDefaultBranch === true + ) + ) + .flatMap((record) => record.issueRefs) + ); + const hasTerminalLinkedWork = terminalIssueRefs.size > 0; + const allLinkedWorkTerminal = + args.issueRefs.length > 0 && + args.issueRefs.every((issueRef) => terminalIssueRefs.has(issueRef)); + if (exactImplementationOnDefaultBranch || allLinkedWorkTerminal) { return { disposition: "resolve-watch", target: args.issueRefs[0] ?? args.assetRefs[0], @@ -332,6 +352,16 @@ function dispositionFor(args: { "Bounded current-source evidence proves the linked work is terminal or the exact implementation is on the default branch", }; } + if (hasTerminalLinkedWork) { + return { + disposition: "task", + target: args.issueRefs.find( + (issueRef) => !terminalIssueRefs.has(issueRef) + ), + rationale: + "Some linked work is terminal, but the full prior and current linked-work family is not terminal", + }; + } if (args.classifications.includes("capability-implementation")) { return OUTCOME_RE.test(args.records.map((record) => record.body).join(" ")) ? { @@ -529,15 +559,8 @@ function correlate(args: { ) .map((entry) => entry.record); const assetRefs = unique(items.flatMap((item) => item.assetRefs)); - const issueRefs = unique(items.flatMap((item) => item.issueRefs)); const writebackRefs = unique(items.flatMap((item) => item.writebackRefs)); const classifications = unique(items.map((item) => item.classification)); - const disposition = dispositionFor({ - records, - classifications, - assetRefs, - issueRefs, - }); const id = `SG-${sha256( items .map((item) => item.dedupeKey) @@ -555,6 +578,20 @@ function correlate(args: { leftId.localeCompare(rightId) ); const priorFamily = matchingFamilies[0]?.[0]; + const issueRefs = unique([ + ...items.flatMap((item) => item.issueRefs), + ...matchingFamilies.flatMap(([, family]) => + family.subjectKeys + .filter((key) => key.startsWith("issue:")) + .map((key) => key.slice("issue:".length)) + ), + ]); + const disposition = dispositionFor({ + records, + classifications, + assetRefs, + issueRefs, + }); const familySeed = subjectKeys[0] ?? items.map((item) => item.dedupeKey).sort()[0] ?? id; const familyId = priorFamily ?? `SF-${sha256(familySeed).slice(0, 16)}`; @@ -777,32 +814,64 @@ function resolutionProofs(records: SourceRecord[]): ResolutionProof[] { function resolvedSignalFamilies(args: { state: ReconciliationState; proofs: ResolutionProof[]; + signals: CorrelatedSignal[]; }): string[] { const terminalIssueKeys = new Set( - args.proofs.flatMap((proof) => - proof.issueRefs.map((issueRef) => `issue:${issueRef}`) - ) + args.proofs + .filter((proof) => proof.kind === "linked_work_terminal") + .flatMap((proof) => + proof.issueRefs.map((issueRef) => `issue:${issueRef}`) + ) ); - const terminalEvidenceKeys = new Set( - args.proofs.map((proof) => proof.evidenceKey) + const defaultBranchEvidenceKeys = new Set( + args.proofs + .filter((proof) => proof.kind === "default_branch_containment") + .map((proof) => proof.evidenceKey) ); return Object.entries(args.state.families ?? {}) - .filter(([, family]) => { - const linkedIssues = family.subjectKeys.filter((key) => - key.startsWith("issue:") + .flatMap(([familyId, family]) => { + const currentSignals = args.signals.filter( + (signal) => + signal.familyId === familyId || + signal.familyAliases?.includes(familyId) ); + const linkedIssues = unique([ + ...family.subjectKeys.filter((key) => key.startsWith("issue:")), + ...currentSignals.flatMap((signal) => + signal.issueRefs.map((issueRef) => `issue:${issueRef}`) + ), + ]); const allLinkedWorkTerminal = linkedIssues.length > 0 && linkedIssues.every((key) => terminalIssueKeys.has(key)); - const exactEvidenceOnDefaultBranch = family.evidenceKeys.some((key) => - terminalEvidenceKeys.has(key) - ); - return allLinkedWorkTerminal || exactEvidenceOnDefaultBranch; + const exactEvidenceOnDefaultBranch = [ + ...family.evidenceKeys, + ...currentSignals.flatMap((signal) => signal.evidenceKeys), + ].some((key) => defaultBranchEvidenceKeys.has(key)); + return allLinkedWorkTerminal || exactEvidenceOnDefaultBranch + ? [familyId] + : []; }) - .map(([familyId]) => familyId) .sort(); } +function isNewDefaultBranchContainment(args: { + record: SourceRecord; + state: ReconciliationState; +}): boolean { + if ( + args.record.sourceType !== "git" || + args.record.provenance.onDefaultBranch !== true + ) { + return false; + } + return !args.state.evidence[ + args.record.dedupeKey + ]?.defaultBranchContainment?.[args.record.sourceId]?.includes( + args.record.recordId + ); +} + function renderReview(review: ReconciliationReview): string { const coverage = review.coverage.map( (entry) => @@ -923,11 +992,29 @@ function updateState(args: { } for (const item of args.review.evidence) { const prior = next.evidence[item.dedupeKey]; + const defaultBranchContainment = structuredClone( + prior?.defaultBranchContainment ?? {} + ); + for (const proof of args.review.resolutionProofs) { + if ( + proof.evidenceKey !== item.dedupeKey || + proof.kind !== "default_branch_containment" + ) { + continue; + } + defaultBranchContainment[proof.sourceId] = unique([ + ...(defaultBranchContainment[proof.sourceId] ?? []), + proof.sourceRecordId, + ]); + } next.evidence[item.dedupeKey] = { firstSeenAt: prior?.firstSeenAt ?? args.review.generatedAt, lastSeenAt: args.review.generatedAt, sourceIds: unique([...(prior?.sourceIds ?? []), ...item.sourceIds]), reviewIds: unique([...(prior?.reviewIds ?? []), args.review.reviewId]), + ...(Object.keys(defaultBranchContainment).length > 0 + ? { defaultBranchContainment } + : {}), }; } for (const decision of args.review.decisions) { @@ -1124,7 +1211,10 @@ export async function reconcileSources(args: { !( prior?.watermark && Date.parse(record.observedAt) <= Date.parse(prior.watermark) && - state.evidence[record.dedupeKey]?.sourceIds.includes(source.id) + state.evidence[record.dedupeKey]?.sourceIds.includes( + source.id + ) && + !isNewDefaultBranchContainment({ record, state }) ) ) : result.records; @@ -1215,6 +1305,7 @@ export async function reconcileSources(args: { resolvedSignalFamilies: resolvedSignalFamilies({ state, proofs, + signals: correlated.signals, }), resolvedEvidenceKeys: unique( records From 9bda4bcbd1df6f2760423702644ff28fe202db9a Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Wed, 29 Jul 2026 14:13:36 -0400 Subject: [PATCH 4/4] fix: make reconciliation recovery durable --- src/evolution-loop.test.ts | 168 ++++++++++++++++++++++++++-- src/evolution-loop.ts | 3 +- src/paths.ts | 43 +++++++ src/projects.test.ts | 197 +++++++++++++++++++++++++++++++++ src/projects.ts | 110 +++++++++++++++++- src/reconciliation-adapters.ts | 19 ++++ src/reconciliation-types.ts | 10 ++ src/reconciliation.ts | 123 ++++++++++++++++++-- 8 files changed, 646 insertions(+), 27 deletions(-) diff --git a/src/evolution-loop.test.ts b/src/evolution-loop.test.ts index e7b4bd5d..8cea7cd8 100644 --- a/src/evolution-loop.test.ts +++ b/src/evolution-loop.test.ts @@ -39,12 +39,15 @@ import { facultAiActivityHistoryManifestPath, facultAiEvolutionLoopAuditPath, facultAiEvolutionLoopConfigPath, + facultAiEvolutionLoopLockPath, facultAiEvolutionLoopStatePath, facultAiProposalDir, + facultAiReconciliationLockPath, facultAiReconciliationStatePath, facultAiWritebackQueuePath, withFacultRootScope, } from "./paths"; +import { reconcileSources } from "./reconciliation"; const SIGNAL_FAMILY_ID_RE = /^SF-/; const COMPLETED_RUN_STATUS_RE = /^(complete|degraded)$/; @@ -197,7 +200,6 @@ async function verifyPersistedGitContainmentTransition( }); const afterMerge = await runEvolutionLoop({ ...project, - since: "2026-01-01T00:00:00.000Z", until: "2026-01-05T00:00:00.000Z", now: () => new Date("2026-01-05T00:00:00.000Z"), }); @@ -212,7 +214,6 @@ async function verifyPersistedGitContainmentTransition( const quiet = await runEvolutionLoop({ ...project, - since: "2026-01-01T00:00:00.000Z", until: "2026-01-06T00:00:00.000Z", now: () => new Date("2026-01-06T00:00:00.000Z"), }); @@ -818,6 +819,137 @@ describe("evolution loop", () => { ); }); + it("resolves a queued family from proof persisted before the loop commits", async () => { + const project = await makeProject(); + const evidencePath = join(project.projectRoot, "evidence.json"); + await Bun.write( + join(project.rootDir, "reconciliation.json"), + `${JSON.stringify({ + version: 1, + sources: [ + { + id: "work-export", + type: "evidence-export", + path: "evidence.json", + }, + ], + })}\n` + ); + const writeEvidence = async (args: { + events: unknown[]; + generatedAt: string; + since: string; + until: string; + }) => + await Bun.write( + evidencePath, + `${JSON.stringify({ + version: 1, + producer: "persisted-resolution-fixture", + generatedAt: args.generatedAt, + coverage: { + since: args.since, + until: args.until, + complete: true, + }, + events: args.events, + })}\n` + ); + + await writeEvidence({ + generatedAt: "2026-01-03T00:00:00.000Z", + since: "2026-01-01T00:00:00.000Z", + until: "2026-01-02T23:59:59.999Z", + events: [ + { + id: "hack-1033-open", + kind: "work-item", + observedAt: "2026-01-02T00:00:00.000Z", + title: "HACK-1033 reconciliation remains open", + refs: ["HACK-1033"], + }, + ], + }); + await enableEvolutionLoop({ ...project }); + const first = await runEvolutionLoop({ + ...project, + since: "2026-01-01", + until: "2026-01-02", + now: () => new Date("2026-01-03T00:00:00.000Z"), + }); + const pending = first.queue.find( + (item) => item.kind === "signal" && item.linkedWork.includes("HACK-1033") + ); + if (!pending?.familyId) { + throw new Error("Expected HACK-1033 to create a queued signal family"); + } + expect(pending.state).toBe("open"); + + await writeEvidence({ + generatedAt: "2026-01-05T00:00:00.000Z", + since: "2026-01-03T00:00:00.000Z", + until: "2026-01-04T23:59:59.999Z", + events: [ + { + id: "hack-1033-done", + kind: "status-change", + observedAt: "2026-01-04T00:00:00.000Z", + title: "HACK-1033 is done", + refs: ["HACK-1033"], + status: "done", + terminal: true, + }, + ], + }); + const directReview = await reconcileSources({ + ...project, + since: "2026-01-03", + until: "2026-01-04", + incremental: true, + }); + expect(directReview.resolvedSignalFamilies).toContain(pending.familyId); + const stateAfterDirectReview = JSON.parse( + await readFile( + facultAiReconciliationStatePath(project.homeDir, project.rootDir), + "utf8" + ) + ) as { resolutionProofs?: Record }; + expect( + Object.keys(stateAfterDirectReview.resolutionProofs ?? {}) + ).not.toHaveLength(0); + + await writeEvidence({ + generatedAt: "2026-01-06T00:00:00.000Z", + since: "2026-01-02T23:59:59.999Z", + until: "2026-01-05T23:59:59.999Z", + events: [], + }); + const recoveryPreview = await reconcileSources({ + ...project, + since: "2026-01-04T23:59:59.999Z", + until: "2026-01-05T23:59:59.999Z", + incremental: true, + persist: false, + }); + expect(recoveryPreview.resolvedSignalFamilies).toContain(pending.familyId); + const recovered = await runEvolutionLoop({ + ...project, + until: "2026-01-05T23:59:59.999Z", + now: () => new Date("2026-01-06T00:00:00.000Z"), + }); + expect(recovered.queue.find((item) => item.id === pending.id)?.state).toBe( + "resolved" + ); + expect(recovered.delta.resolved).toContain(pending.id); + + const quiet = await runEvolutionLoop({ + ...project, + until: "2026-01-06T23:59:59.999Z", + now: () => new Date("2026-01-07T00:00:00.000Z"), + }); + expect(quiet.delta.notifiable).not.toContain(pending.id); + }); + it("alerts once for a stale cursor while keeping complete coverage", async () => { const project = await makeProject(); for (const argv of [ @@ -1182,10 +1314,16 @@ describe("evolution loop", () => { await readdir(dirname(reconciliationStatePath), { recursive: true }) ).sort() ).toEqual(reconciliationEntriesBefore); - expect(await Bun.file(`${reconciliationStatePath}.lock`).exists()).toBe( - false - ); - expect(await Bun.file(`${statePath}.lock`).exists()).toBe(false); + expect( + await Bun.file( + facultAiReconciliationLockPath(project.homeDir, project.rootDir) + ).exists() + ).toBe(false); + expect( + await Bun.file( + facultAiEvolutionLoopLockPath(project.homeDir, project.rootDir) + ).exists() + ).toBe(false); expect(await Bun.file(preview.artifactPath).exists()).toBe(false); }); @@ -1686,11 +1824,10 @@ describe("evolution loop", () => { ...project, now: () => new Date("2026-01-03T00:00:00.000Z"), }); - const statePath = facultAiEvolutionLoopStatePath( + const lockPath = facultAiEvolutionLoopLockPath( project.homeDir, project.rootDir ); - const lockPath = `${statePath}.lock`; await Bun.write( lockPath, `${JSON.stringify({ pid: process.pid, startedAt: "2026-01-03T00:00:00.000Z" })}\n` @@ -1750,7 +1887,10 @@ describe("evolution loop", () => { ...project, now: () => new Date("2026-01-03T00:00:00.000Z"), }); - const lockPath = `${facultAiEvolutionLoopStatePath(project.homeDir, project.rootDir)}.lock`; + const lockPath = facultAiEvolutionLoopLockPath( + project.homeDir, + project.rootDir + ); await Bun.write( lockPath, `${JSON.stringify({ pid: 2_147_483_647, startedAt: "2026-01-01T00:00:00.000Z" })}\n` @@ -1879,7 +2019,10 @@ describe("evolution loop", () => { ...project, now: () => new Date("2026-01-03T00:00:00.000Z"), }); - const lockPath = `${facultAiEvolutionLoopStatePath(project.homeDir, project.rootDir)}.lock`; + const lockPath = facultAiEvolutionLoopLockPath( + project.homeDir, + project.rootDir + ); const takeoverPath = `${lockPath}.takeover`; const staleOwner = `${JSON.stringify({ pid: 2_147_483_647, @@ -1911,7 +2054,10 @@ describe("evolution loop", () => { it("does not reinterpret a non-file lock-path error as stale recovery", async () => { const project = await makeProject(); await enableEvolutionLoop({ ...project }); - const lockPath = `${facultAiEvolutionLoopStatePath(project.homeDir, project.rootDir)}.lock`; + const lockPath = facultAiEvolutionLoopLockPath( + project.homeDir, + project.rootDir + ); await expect( runEvolutionLoop({ diff --git a/src/evolution-loop.ts b/src/evolution-loop.ts index c1306cf6..daae7261 100644 --- a/src/evolution-loop.ts +++ b/src/evolution-loop.ts @@ -28,6 +28,7 @@ import { import { facultAiEvolutionLoopAuditPath, facultAiEvolutionLoopConfigPath, + facultAiEvolutionLoopLockPath, facultAiEvolutionLoopReportDir, facultAiEvolutionLoopStatePath, facultAiEvolutionReviewDir, @@ -2074,7 +2075,7 @@ async function runEvolutionLoopScoped(args: { }, updatedAt: now.toISOString(), }; - const lockPath = `${facultAiEvolutionLoopStatePath(args.homeDir, args.rootDir)}.lock`; + const lockPath = facultAiEvolutionLoopLockPath(args.homeDir, args.rootDir); const execute = async (): Promise => { if (!args.dryRun) { const lockedConfig = await loadConfig(args); diff --git a/src/paths.ts b/src/paths.ts index 01b97b0c..ea93ad09 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -629,6 +629,49 @@ export function facultAiRuntimeScopeDir( ); } +export function facultAiRuntimeCoordinationDir( + home: string = defaultHomeDir(), + rootDir?: string +): string { + const resolvedRoot = rootDir ?? facultRootDir(home); + return projectRootFromAiRoot(resolvedRoot, home) + ? join( + facultLocalStateRoot(home), + "projects", + "coordination", + executionMachineStateProjectKey(resolvedRoot, home) + ) + : join(facultLocalStateRoot(home), "global", "coordination"); +} + +export function facultAiReconciliationLockPath( + home: string = defaultHomeDir(), + rootDir?: string +): string { + const resolvedRoot = rootDir ?? facultRootDir(home); + if (!projectRootFromAiRoot(resolvedRoot, home)) { + return `${facultAiReconciliationStatePath(home, resolvedRoot)}.lock`; + } + return join( + facultAiRuntimeCoordinationDir(home, resolvedRoot), + "reconciliation.lock" + ); +} + +export function facultAiEvolutionLoopLockPath( + home: string = defaultHomeDir(), + rootDir?: string +): string { + const resolvedRoot = rootDir ?? facultRootDir(home); + if (!projectRootFromAiRoot(resolvedRoot, home)) { + return `${facultAiEvolutionLoopStatePath(home, resolvedRoot)}.lock`; + } + return join( + facultAiRuntimeCoordinationDir(home, resolvedRoot), + "evolution-loop.lock" + ); +} + export function facultAiJournalPath( home: string = defaultHomeDir(), rootDir?: string diff --git a/src/projects.test.ts b/src/projects.test.ts index f12ce932..8be969b3 100644 --- a/src/projects.test.ts +++ b/src/projects.test.ts @@ -19,10 +19,13 @@ import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { runFixtureGit } from "../test/git-fixture"; +import { enableEvolutionLoop, runEvolutionLoop } from "./evolution-loop"; import { + facultAiEvolutionLoopLockPath, facultAiEvolutionReviewDir, facultAiGraphPath, facultAiIndexPath, + facultAiReconciliationLockPath, facultAiReconciliationReviewDir, facultAiWritebackReviewDir, facultConfigPath, @@ -41,6 +44,7 @@ import { resolveRepositoryIdentity, rollbackProjectEnrollment, } from "./projects"; +import { reconcileSources } from "./reconciliation"; import { normalizeRepositoryRemote, repositoryPathComparisonKey, @@ -2236,6 +2240,163 @@ describe("project enrollment lifecycle", () => { } }); + it("refuses enrollment migration while reconciliation or evolution owns runtime state", async () => { + for (const writer of ["reconciliation", "evolution"] as const) { + const { root, home } = await makeFixture(); + const repo = join(root, `repo-${writer}`); + await createRepository({ path: repo, home }); + const aiRoot = join(repo, ".ai"); + const legacyDir = join( + facultLocalStateRoot(home), + "projects", + legacyMachineStateProjectKey(aiRoot, home) + ); + await mkdir(join(legacyDir, "journal"), { recursive: true }); + await writeFile( + join(legacyDir, "journal", "events.jsonl"), + `${writer} state\n`, + "utf8" + ); + await mkdir(aiRoot, { recursive: true }); + await writeFile( + join(aiRoot, "reconciliation.json"), + `${JSON.stringify({ + version: 1, + sources: [ + { + id: "runtime-export", + type: "evidence-export", + path: "evidence.json", + }, + ], + })}\n`, + "utf8" + ); + await writeFile( + join(repo, "evidence.json"), + `${JSON.stringify({ + version: 1, + producer: "migration-lock-fixture", + generatedAt: "2026-01-03T00:00:00.000Z", + coverage: { + since: "2026-01-01T00:00:00.000Z", + until: "2026-01-02T23:59:59.999Z", + complete: true, + }, + events: [], + })}\n`, + "utf8" + ); + if (writer === "evolution") { + await enableEvolutionLoop({ + homeDir: home, + rootDir: aiRoot, + now: () => new Date("2026-01-03T00:00:00.000Z"), + }); + } + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + expect(plan.stateMigrations.length).toBeGreaterThan(0); + + let markLocked: (() => void) | undefined; + const locked = new Promise((resolveLocked) => { + markLocked = resolveLocked; + }); + let releaseWriter: (() => void) | undefined; + const holdWriter = new Promise((resolveWriter) => { + releaseWriter = resolveWriter; + }); + const writerRun = + writer === "reconciliation" + ? reconcileSources({ + homeDir: home, + rootDir: aiRoot, + since: "2026-01-01", + until: "2026-01-02", + onLockAcquired: async () => { + markLocked?.(); + await holdWriter; + }, + }) + : runEvolutionLoop({ + homeDir: home, + rootDir: aiRoot, + since: "2026-01-01", + until: "2026-01-02", + now: () => new Date("2026-01-03T00:00:00.000Z"), + onLockAcquired: async () => { + markLocked?.(); + await holdWriter; + }, + }); + await locked; + + const expectedLockPath = + writer === "reconciliation" + ? facultAiReconciliationLockPath(home, aiRoot) + : facultAiEvolutionLoopLockPath(home, aiRoot); + expect(await Bun.file(expectedLockPath).exists()).toBe(true); + await expect( + applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + }) + ).rejects.toThrow("another writer holds"); + expect(await pathEntryExists(legacyDir)).toBe(true); + + releaseWriter?.(); + await writerRun; + const refreshed = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + await applyProjectEnrollment({ + plan: refreshed, + expectedPlanSha256: refreshed.planSha256, + homeDir: home, + }); + expect(await pathEntryExists(legacyDir)).toBe(false); + expect( + await Bun.file( + join(facultMachineStateDir(home, aiRoot), "journal", "events.jsonl") + ).exists() + ).toBe(true); + } + }); + + it("rejects legacy runtime lock files before planning a migration", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const aiRoot = join(repo, ".ai"); + const legacyDir = join( + facultLocalStateRoot(home), + "projects", + legacyMachineStateProjectKey(aiRoot, home) + ); + const oldLockPath = join( + legacyDir, + "ai", + "project", + "evolution", + "loop", + "state.json.lock" + ); + await mkdir(dirname(oldLockPath), { recursive: true }); + await writeFile(oldLockPath, '{"pid":123}\n', "utf8"); + + await expect( + planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }) + ).rejects.toThrow("Refusing to migrate active project runtime state"); + expect(await Bun.file(oldLockPath).exists()).toBe(true); + }); + it("migrates a legacy key created through an equivalent symlink spelling", async () => { const { root, home } = await makeFixture(); const repo = join(root, "repo"); @@ -3600,6 +3761,42 @@ describe("project enrollment lifecycle", () => { ).toHaveLength(1); }); + it("uses a bindable mutation socket when the platform temp path is long", async () => { + const { root, home } = await makeFixture(); + const repo = join(root, "repo"); + await createRepository({ path: repo, home }); + const plan = await planProjectEnrollment({ + projectRoot: repo, + homeDir: home, + }); + let endpoint: string | undefined; + + await applyProjectEnrollment({ + plan, + expectedPlanSha256: plan.planSha256, + homeDir: home, + beforeCanonicalWrite: async () => { + const owner = JSON.parse( + await readFile( + join( + facultLocalStateRoot(home), + "projects", + "mutation.lock", + "owner.json" + ), + "utf8" + ) + ) as { endpoint?: string }; + endpoint = owner.endpoint; + }, + }); + + expect(endpoint).toBeDefined(); + if (process.platform !== "win32") { + expect(Buffer.byteLength(endpoint!)).toBeLessThanOrEqual(96); + } + }); + it("refuses an older receipt after a newer enrollment of the same checkout", async () => { const { root, home } = await makeFixture(); const repo = join(root, "repo"); diff --git a/src/projects.ts b/src/projects.ts index 39c62918..7bafb1af 100644 --- a/src/projects.ts +++ b/src/projects.ts @@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto"; import { constants, createReadStream, type Stats } from "node:fs"; import { chmod, + type FileHandle, lstat, mkdir, open, @@ -32,8 +33,10 @@ import { resolveCliContextRoot } from "./cli-context"; import { buildIndexSnapshot } from "./index-builder"; import { executionMachineStateProjectKey, + facultAiEvolutionLoopLockPath, facultAiGraphPath, facultAiIndexPath, + facultAiReconciliationLockPath, facultLocalStateRoot, legacyMachineStateProjectKey, pathsMayCollide, @@ -65,6 +68,11 @@ const DISCOVERY_IGNORES = new Set([ ]); const PROJECT_SOURCES = new Set(["git", "guidance", "writebacks"]); const PROJECT_CADENCES = new Set(["on-demand", "weekly", "daily"]); +const MIGRATING_RUNTIME_LOCK_PATHS = new Set([ + "ai/project/evolution/loop/state.json.lock", + "ai/project/evolution/loop/state.json.lock.takeover", + "ai/project/reconciliation/state.json.lock", +]); const PROJECT_CONFIG_KEYS = [ "cadence", "guidance", @@ -126,6 +134,7 @@ const NON_DIGIT_RE = /[^0-9]/g; const PLAN_SHA_RE = /^[a-f0-9]{64}$/; const PROJECT_MUTATION_LOCK_ATTEMPTS = 500; const PROJECT_MUTATION_LOCK_RETRY_MS = 10; +const UNIX_SOCKET_PATH_MAX_BYTES = 96; const PROJECT_CANONICAL_FILE_MAX_BYTES = 1024 * 1024; const PROJECT_GUIDANCE_FILE_MAX_BYTES = 1024 * 1024; const PROJECT_RECEIPT_FILE_MAX_BYTES = 24 * 1024 * 1024; @@ -1731,6 +1740,20 @@ interface ProjectStateTree { sha256: string; } +function assertNoMigratingRuntimeLocks(args: { + path: string; + tree: ProjectStateTree; +}): void { + const activeLocks = args.tree.entries + .filter((entry) => MIGRATING_RUNTIME_LOCK_PATHS.has(entry.path)) + .map((entry) => entry.path); + if (activeLocks.length > 0) { + throw new Error( + `Refusing to migrate active project runtime state at ${args.path}: ${activeLocks.join(", ")}` + ); + } +} + async function hashProjectStateFile( pathValue: string, expectedSize: number @@ -1970,6 +1993,10 @@ async function planLegacyProjectStateMigrations(args: { } claimedDestinations.set(candidate.destination, candidate.source); const sourceTree = await inspectProjectStateTree(candidate.source); + assertNoMigratingRuntimeLocks({ + path: candidate.source, + tree: sourceTree, + }); const destination = await lstatIfExists(candidate.destination); if (!destination) { planned.push({ @@ -2060,6 +2087,10 @@ async function migrateLegacyProjectState(args: { ); } const sourceTree = await inspectProjectStateTree(candidate.source); + assertNoMigratingRuntimeLocks({ + path: candidate.source, + tree: sourceTree, + }); if (sourceTree.sha256 !== candidate.sourceTreeSha256) { throw new Error( `Reviewed legacy project state migration is stale: ${candidate.source}` @@ -3185,9 +3216,14 @@ function processIsAlive(pid: number): boolean { } function projectMutationLockEndpoint(ownerId: string): string { - return process.platform === "win32" - ? `\\\\.\\pipe\\fclt-project-mutation-${ownerId}` - : join(tmpdir(), `fclt-project-mutation-${ownerId}.sock`); + if (process.platform === "win32") { + return `\\\\.\\pipe\\fclt-project-mutation-${ownerId}`; + } + const socketName = `fclt-pm-${ownerId}.sock`; + const preferred = join(tmpdir(), socketName); + return Buffer.byteLength(preferred) <= UNIX_SOCKET_PATH_MAX_BYTES + ? preferred + : join("/tmp", socketName); } async function listenForProjectMutationLock( @@ -4248,6 +4284,63 @@ async function upsertRegistryEntry(args: { return registryEntryBefore; } +async function acquireProjectRuntimeMigrationLocks(args: { + aiRoot: string; + enabled: boolean; + homeDir: string; +}): Promise<() => Promise> { + if (!args.enabled) { + return () => Promise.resolve(); + } + const token = randomUUID(); + const acquired: Array<{ handle: FileHandle; path: string }> = []; + const release = async (): Promise => { + for (const entry of acquired.reverse()) { + await entry.handle.close(); + const owner = await readFile(entry.path, "utf8").catch(() => ""); + if (owner.includes(`"token":"${token}"`)) { + await rm(entry.path, { force: true }); + } + } + }; + try { + for (const path of [ + facultAiEvolutionLoopLockPath(args.homeDir, args.aiRoot), + facultAiReconciliationLockPath(args.homeDir, args.aiRoot), + ]) { + await mkdir(dirname(path), { recursive: true }); + let handle: FileHandle; + try { + handle = await open(path, "wx"); + } catch (error) { + if ( + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "EEXIST" + ) { + throw new Error( + `Project enrollment cannot migrate runtime state while another writer holds ${path}` + ); + } + throw error; + } + acquired.push({ handle, path }); + await handle.writeFile( + `${JSON.stringify({ + pid: process.pid, + token, + startedAt: new Date().toISOString(), + operation: "project-enrollment-state-migration", + })}\n` + ); + } + } catch (error) { + await release(); + throw error; + } + return release; +} + export async function applyProjectEnrollment(args: { plan: ProjectEnrollmentPlan; expectedPlanSha256: string; @@ -4317,6 +4410,11 @@ export async function applyProjectEnrollment(args: { } assertProjectRegistryMutationSupported(args.platform ?? process.platform); const homeDir = resolve(args.homeDir ?? process.env.HOME ?? homedir()); + const releaseRuntimeLocks = await acquireProjectRuntimeMigrationLocks({ + aiRoot: args.plan.aiRoot, + enabled: args.plan.stateMigrations.length > 0, + homeDir, + }); return await withProjectsMutationLock( homeDir, async () => { @@ -4564,8 +4662,8 @@ export async function applyProjectEnrollment(args: { throw error; } return { - version: 1, - applied: true, + version: 1 as const, + applied: true as const, repositoryId: args.plan.identity.id, changedPaths: args.plan.canonicalWrites.map((write) => write.path), generatedPaths: expectedGeneratedPaths, @@ -4575,7 +4673,7 @@ export async function applyProjectEnrollment(args: { }; }, args.mutationLockAttempts - ); + ).finally(releaseRuntimeLocks); } function isRecord(value: unknown): value is Record { diff --git a/src/reconciliation-adapters.ts b/src/reconciliation-adapters.ts index 6535981b..b4a7b816 100644 --- a/src/reconciliation-adapters.ts +++ b/src/reconciliation-adapters.ts @@ -515,6 +515,25 @@ async function configuredDefaultBranch(args: { ); } +export async function gitDefaultBranchContainment(args: { + commit: string; + config: GitSourceConfig; + projectRoot: string; +}): Promise<{ defaultBranch: string; onDefaultBranch: boolean }> { + const defaultBranch = await configuredDefaultBranch({ + config: args.config, + projectRoot: args.projectRoot, + }); + return { + defaultBranch: defaultBranch.display, + onDefaultBranch: await gitIsAncestor({ + commit: args.commit, + ancestorOf: defaultBranch.ref, + projectRoot: args.projectRoot, + }), + }; +} + async function gitIsAncestor(args: { commit: string; ancestorOf: string; diff --git a/src/reconciliation-types.ts b/src/reconciliation-types.ts index 0a1ebdf2..94288ebf 100644 --- a/src/reconciliation-types.ts +++ b/src/reconciliation-types.ts @@ -263,6 +263,7 @@ export interface ReconciliationState { firstSeenAt: string; lastSeenAt: string; sourceIds: string[]; + sourceRecordIds?: Record; reviewIds: string[]; defaultBranchContainment?: Record; } @@ -290,6 +291,15 @@ export interface ReconciliationState { signalIds: string[]; } >; + resolutionProofs?: Record< + string, + { + firstSeenAt: string; + lastSeenAt: string; + reviewIds: string[]; + proof: ResolutionProof; + } + >; reviews: Record< string, { diff --git a/src/reconciliation.ts b/src/reconciliation.ts index f0ec6ac9..03258148 100644 --- a/src/reconciliation.ts +++ b/src/reconciliation.ts @@ -10,11 +10,15 @@ import { import { dirname, join } from "node:path"; import type { WritebackDisposition } from "./ai"; import { + facultAiReconciliationLockPath, facultAiReconciliationReviewDir, facultAiReconciliationStatePath, projectRootFromAiRoot, } from "./paths"; -import { reconciliationAdapterFor } from "./reconciliation-adapters"; +import { + gitDefaultBranchContainment, + reconciliationAdapterFor, +} from "./reconciliation-adapters"; import { DEFAULT_SOURCE_FRESHNESS_THRESHOLD_HOURS, loadReconciliationConfig, @@ -71,6 +75,7 @@ function emptyState(): ReconciliationState { evidence: {}, decisions: {}, families: {}, + resolutionProofs: {}, reviews: {}, }; } @@ -85,6 +90,8 @@ function parseState(value: unknown): ReconciliationState { isPlainObject(value.evidence) && isPlainObject(value.decisions) && (value.families === undefined || isPlainObject(value.families)) && + (value.resolutionProofs === undefined || + isPlainObject(value.resolutionProofs)) && isPlainObject(value.reviews) ) ) { @@ -98,6 +105,11 @@ function parseState(value: unknown): ReconciliationState { families: isPlainObject(value.families) ? (value.families as NonNullable) : {}, + resolutionProofs: isPlainObject(value.resolutionProofs) + ? (value.resolutionProofs as NonNullable< + ReconciliationState["resolutionProofs"] + >) + : {}, reviews: value.reviews as ReconciliationState["reviews"], }; } @@ -123,18 +135,19 @@ async function atomicWrite(path: string, value: string): Promise { } async function withStateLock( - statePath: string, - fn: () => Promise + lockPath: string, + fn: () => Promise, + onLockAcquired?: () => void | Promise ): Promise { - const lockPath = `${statePath}.lock`; await mkdir(dirname(lockPath), { recursive: true }); let handle: FileHandle; try { handle = await open(lockPath, "wx"); } catch { - throw new Error(`Another reconciliation is already updating ${statePath}`); + throw new Error(`Another reconciliation is already updating ${lockPath}`); } try { + await onLockAcquired?.(); return await fn(); } finally { await handle.close(); @@ -816,15 +829,21 @@ function resolvedSignalFamilies(args: { proofs: ResolutionProof[]; signals: CorrelatedSignal[]; }): string[] { + const proofs = [ + ...Object.values(args.state.resolutionProofs ?? {}).map( + (entry) => entry.proof + ), + ...args.proofs, + ]; const terminalIssueKeys = new Set( - args.proofs + proofs .filter((proof) => proof.kind === "linked_work_terminal") .flatMap((proof) => proof.issueRefs.map((issueRef) => `issue:${issueRef}`) ) ); const defaultBranchEvidenceKeys = new Set( - args.proofs + proofs .filter((proof) => proof.kind === "default_branch_containment") .map((proof) => proof.evidenceKey) ); @@ -992,6 +1011,19 @@ function updateState(args: { } for (const item of args.review.evidence) { const prior = next.evidence[item.dedupeKey]; + const sourceRecordIds = structuredClone(prior?.sourceRecordIds ?? {}); + for (const sourceId of item.sourceIds) { + sourceRecordIds[sourceId] = unique([ + ...(sourceRecordIds[sourceId] ?? []), + ...args.review.decisions + .filter( + (decision) => + decision.dedupeKey === item.dedupeKey && + decision.sourceId === sourceId + ) + .map((decision) => decision.sourceRecordId), + ]); + } const defaultBranchContainment = structuredClone( prior?.defaultBranchContainment ?? {} ); @@ -1011,12 +1043,39 @@ function updateState(args: { firstSeenAt: prior?.firstSeenAt ?? args.review.generatedAt, lastSeenAt: args.review.generatedAt, sourceIds: unique([...(prior?.sourceIds ?? []), ...item.sourceIds]), + sourceRecordIds, reviewIds: unique([...(prior?.reviewIds ?? []), args.review.reviewId]), ...(Object.keys(defaultBranchContainment).length > 0 ? { defaultBranchContainment } : {}), }; } + const resolutionProofState = next.resolutionProofs ?? {}; + next.resolutionProofs = resolutionProofState; + for (const proof of args.review.resolutionProofs) { + const key = sha256( + `${proof.kind}\n${proof.sourceId}\n${proof.sourceRecordId}\n${proof.evidenceKey}` + ); + const prior = resolutionProofState[key]; + resolutionProofState[key] = { + firstSeenAt: prior?.firstSeenAt ?? args.review.generatedAt, + lastSeenAt: args.review.generatedAt, + reviewIds: unique([...(prior?.reviewIds ?? []), args.review.reviewId]), + proof, + }; + if (proof.kind !== "default_branch_containment") { + continue; + } + const evidence = next.evidence[proof.evidenceKey]; + if (!evidence) { + continue; + } + evidence.defaultBranchContainment ??= {}; + evidence.defaultBranchContainment[proof.sourceId] = unique([ + ...(evidence.defaultBranchContainment[proof.sourceId] ?? []), + proof.sourceRecordId, + ]); + } for (const decision of args.review.decisions) { next.decisions[decision.id] = { included: decision.included, @@ -1101,6 +1160,8 @@ export async function reconcileSources(args: { sourceIds?: string[]; incremental?: boolean; persist?: boolean; + /** @internal Adversarial test hook; production callers must not set this. */ + onLockAcquired?: () => void | Promise; }): Promise { const { config } = await loadReconciliationConfig(args); const enabledSources = config.sources.filter( @@ -1165,6 +1226,7 @@ export async function reconcileSources(args: { const checkedAt = new Date().toISOString(); const coverage: SourceCoverage[] = []; const records: SourceRecord[] = []; + const recheckedResolutionProofs: ResolutionProof[] = []; const adapterResults = new Map(); for (const source of selectedConfig.sources) { const priorState = state.sources[source.id]; @@ -1205,6 +1267,45 @@ export async function reconcileSources(args: { : result.watermark; } adapterResults.set(source.id, result); + if (source.type === "git" && projectRoot) { + const pendingCommits = Object.entries(state.evidence).flatMap( + ([evidenceKey, evidence]) => + (evidence.sourceRecordIds?.[source.id] ?? []) + .filter( + (recordId) => + !evidence.defaultBranchContainment?.[source.id]?.includes( + recordId + ) + ) + .map((recordId) => ({ evidenceKey, recordId })) + ); + for (const pending of pendingCommits) { + const containment = await gitDefaultBranchContainment({ + commit: pending.recordId, + config: source, + projectRoot, + }); + if (!containment.onDefaultBranch) { + continue; + } + recheckedResolutionProofs.push({ + sourceId: source.id, + sourceType: "git", + sourceRecordId: pending.recordId, + kind: "default_branch_containment", + issueRefs: [], + evidenceKey: pending.evidenceKey, + provenance: { + repository: projectRoot, + commit: pending.recordId, + defaultBranch: containment.defaultBranch, + onDefaultBranch: true, + terminal: true, + rechecked: true, + }, + }); + } + } const reviewRecords = args.incremental ? result.records.filter( (record) => @@ -1287,7 +1388,7 @@ export async function reconcileSources(args: { : coverageComplete ? "Zero signals discovered after every configured source was checked for this review window." : "No signals are reported, but configured coverage is degraded; this is not a proven empty review."; - const proofs = resolutionProofs(records); + const proofs = [...resolutionProofs(records), ...recheckedResolutionProofs]; const review: ReconciliationReview = { version: 1, reviewId: window.id, @@ -1341,7 +1442,11 @@ export async function reconcileSources(args: { }; return args.persist === false ? await execute() - : await withStateLock(statePath, execute); + : await withStateLock( + facultAiReconciliationLockPath(args.homeDir, args.rootDir), + execute, + args.onLockAcquired + ); } export async function reconciliationStatus(args: {