diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa9a87..938358c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Fixed revalidation prompts to compact historical and feature metadata and hard-cap metadata lists even when configured file limits are high, preventing provider input overflows, thanks @pai-scaffolde. - Added an opt-in Claude host auth context that preserves the default-deny environment, uses Claude Code safe mode, validates auth through doctor, and reports redacted OAuth failure signals, thanks @grantjayy. +- Fixed agent-mode mapping to respect the `--since` and `--include-dirty` diff filters, ensuring unchanged features are preserved and not remapped by the provider. ## 0.7.0 - 2026-06-15 diff --git a/src/agent-mapper.ts b/src/agent-mapper.ts index c8d2fcf..260d1ca 100644 --- a/src/agent-mapper.ts +++ b/src/agent-mapper.ts @@ -35,6 +35,7 @@ type AgentMapOptions = { provider: Provider | null; providerOptions: ProviderOptions; inventory?: PathFilters; + changedFiles?: ReadonlySet; onProgress?: (event: string, fields: Record) => void; }; @@ -156,6 +157,7 @@ export async function mapWithSource( options.providerOptions, inventory, options.inventory, + options.changedFiles, ); options.onProgress?.("agent-done", { features: agent.features.length, @@ -208,6 +210,7 @@ async function agentMap( providerOptions: ProviderOptions, inventory: RepoInventory, filters: PathFilters | undefined, + changedFiles: ReadonlySet | undefined, ): Promise { const prompt = buildAgentMapPrompt(project, { manifests: inventory.manifests, @@ -224,9 +227,10 @@ async function agentMap( const mappedSeeds = dedupeFeatureSeeds( seeds.filter((seed): seed is FeatureSeed => seed !== null), ); - return filters === undefined - ? mapFeatureSeeds(root, project, existing, mappedSeeds) - : mapFeatureSeeds(root, project, existing, mappedSeeds, { filters }); + return mapFeatureSeeds(root, project, existing, mappedSeeds, { + ...(filters !== undefined ? { filters } : {}), + ...(changedFiles !== undefined ? { changedFiles } : {}), + }); } async function toSeed( diff --git a/src/app.ts b/src/app.ts index 474f9a6..de6bd25 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,13 +1,14 @@ import { appendFile } from "node:fs/promises"; import { loadConfig, parseReasoningEffort, resolveStateDir } from "./config.js"; import { applyProviderFlags, providerOptions, stringFlag } from "./command-support.js"; +import { changedFiles } from "./command-selection.js"; import { loadProjectState, type AppContext } from "./app-context.js"; import { detectProject } from "./detect.js"; import { ClawpatchError } from "./errors.js"; import { nowIso, writeJson } from "./fs.js"; import { discoverGit } from "./git.js"; import { mapWithSource } from "./agent-mapper.js"; -import { mapFeatures } from "./mapper.js"; +import { featureTouchesChangedFiles, mapFeatures } from "./mapper.js"; import { emitProgress } from "./progress.js"; import { providerByName } from "./provider.js"; import { @@ -79,13 +80,17 @@ export async function mapCommand( const provider = source === "heuristic" ? null : providerByName(config.provider.name); const existing = await readFeatures(loaded.paths); const filters = { include: config.include, exclude: config.exclude }; + const incremental = hasIncrementalFlags(flags); + const diffFiles = incremental ? await changedFiles(loaded.root, flags) : undefined; emitProgress(context, "map", "start", { source, existing: existing.length, dryRun: flags["dryRun"] === true, + ...(diffFiles !== undefined ? { incremental: true, changedFiles: diffFiles.size } : {}), }); const heuristic = await mapFeatures(loaded.root, loaded.project, existing, { filters, + ...(diffFiles !== undefined ? { changedFiles: diffFiles } : {}), onProgress: (event) => { emitProgress(context, "map", event.event, { mapper: event.mapper, @@ -107,6 +112,7 @@ export async function mapCommand( provider, providerOptions: providerOptions(config), inventory: filters, + ...(diffFiles !== undefined ? { changedFiles: diffFiles } : {}), onProgress: (event, fields) => { emitProgress(context, "map", event, fields); }, @@ -127,6 +133,7 @@ export async function mapCommand( source: result.decision.source, usedAgent: result.decision.usedAgent, reason: result.decision.reason, + ...(diffFiles !== undefined ? { incremental: true, changedFiles: diffFiles.size } : {}), }; } emitProgress(context, "map", "write-start", { @@ -137,6 +144,9 @@ export async function mapCommand( } for (const feature of existing) { if (!activeFeatureIds.has(feature.featureId)) { + if (diffFiles !== undefined && !featureTouchesChangedFiles(feature, diffFiles)) { + continue; + } await writeFeature(loaded.paths, { ...feature, status: "skipped", @@ -158,6 +168,7 @@ export async function mapCommand( source: result.decision.source, usedAgent: result.decision.usedAgent, reason: result.decision.reason, + ...(diffFiles !== undefined ? { incremental: true, changedFiles: diffFiles.size } : {}), next: "clawpatch review --limit 3", }; } @@ -394,6 +405,10 @@ function parseMapSource(flags: Record): "heuristic" | ); } +function hasIncrementalFlags(flags: Record): boolean { + return stringFlag(flags, "since") !== undefined || flags["includeDirty"] === true; +} + // eslint-disable-next-line no-underscore-dangle export const __testing = { ...reviewTesting, diff --git a/src/cli.ts b/src/cli.ts index 0310bec..68e6972 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -131,7 +131,16 @@ type CommandSpec = { const commandSpecs = { init: { flags: ["force"], usage: ["clawpatch init [flags]"], run: initCommand }, map: { - flags: ["dryRun", "source", "provider", "model", "reasoningEffort", "skipGitRepoCheck"], + flags: [ + "dryRun", + "source", + "provider", + "model", + "reasoningEffort", + "skipGitRepoCheck", + "since", + "includeDirty", + ], usage: ["clawpatch map [flags]"], run: mapCommand, }, diff --git a/src/mapper.ts b/src/mapper.ts index 0e1e946..bee9555 100644 --- a/src/mapper.ts +++ b/src/mapper.ts @@ -45,6 +45,7 @@ export type MapProgressEvent = { export type MapOptions = { onProgress?: (event: MapProgressEvent) => void; filters?: PathFilters; + changedFiles?: ReadonlySet; }; const featureMappers: FeatureMapper[] = [ @@ -95,6 +96,12 @@ export async function mapFeatureSeeds( if (seed === null) { continue; } + if ( + options.changedFiles !== undefined && + !seedTouchesChangedFiles(seed, options.changedFiles) + ) { + continue; + } const identity = featureIdentity(seed, existingById); const featureId = identity.featureId; const previous = existingById.get(featureId); @@ -314,3 +321,34 @@ function statusForChangedFeature(status: FeatureRecord["status"]): FeatureRecord } return status; } + +function seedTouchesChangedFiles(seed: FeatureSeed, changedFiles: ReadonlySet): boolean { + if (changedFiles.has(seed.entryPath)) { + return true; + } + if (seed.ownedFiles !== undefined) { + for (const file of seed.ownedFiles) { + if (changedFiles.has(file.path)) { + return true; + } + } + } + return false; +} + +export function featureTouchesChangedFiles( + feature: FeatureRecord, + changedFiles: ReadonlySet, +): boolean { + for (const entrypoint of feature.entrypoints) { + if (changedFiles.has(entrypoint.path)) { + return true; + } + } + for (const file of feature.ownedFiles) { + if (changedFiles.has(file.path)) { + return true; + } + } + return false; +} diff --git a/src/workflow.test.ts b/src/workflow.test.ts index 65144c4..eef5394 100644 --- a/src/workflow.test.ts +++ b/src/workflow.test.ts @@ -1833,6 +1833,40 @@ describe("workflow", () => { expect(agentFeature?.tests).toEqual([{ path: "agent/worker.test.custom", command: null }]); }); + it("applies --since changed files filter to agent-mapped features", async () => { + const root = await sinceFixture("clawpatch-agent-map-since-"); + await writeFixture(root, "agent/worker.custom", "worker source\n"); + await writeFixture(root, "agent/scheduler.custom", "scheduler source\n"); + await runCommand("git add agent/worker.custom agent/scheduler.custom", root); + await runCommand("git commit -m 'initial custom files'", root); + + const context = await makeContext(testOptions(root)); + await initCommand(context, {}); + await mapCommand(context, { source: "agent", provider: "mock" }); + + await writeFixture(root, "agent/worker.custom", "changed worker source\n"); + await runCommand("git add agent/worker.custom", root); + await runCommand("git commit -m 'update worker'", root); + + const mapped = (await mapCommand(context, { + source: "agent", + provider: "mock", + since: "HEAD~1", + })) as any; + + expect(mapped.incremental).toBe(true); + expect(mapped.changedFiles).toBe(1); + + const updatedFeatures = await readFeatures(statePaths(join(root, ".clawpatch"))); + const updatedScheduler = updatedFeatures.find((f) => + f.ownedFiles.some((file) => file.path === "agent/scheduler.custom"), + ); + + // The scheduler feature should be completely untouched (same updatedAt timestamp and NOT skipped) + expect(updatedScheduler).toBeDefined(); + expect(updatedScheduler?.status).not.toBe("skipped"); + }); + it("builds agent mapper inventory from git-visible files and config filters", async () => { const root = await fixtureRoot("clawpatch-agent-map-git-inventory-"); await writeFixture(