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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 7 additions & 3 deletions src/agent-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type AgentMapOptions = {
provider: Provider | null;
providerOptions: ProviderOptions;
inventory?: PathFilters;
changedFiles?: ReadonlySet<string>;
onProgress?: (event: string, fields: Record<string, string | number | boolean>) => void;
};

Expand Down Expand Up @@ -156,6 +157,7 @@ export async function mapWithSource(
options.providerOptions,
inventory,
options.inventory,
options.changedFiles,
);
options.onProgress?.("agent-done", {
features: agent.features.length,
Expand Down Expand Up @@ -208,6 +210,7 @@ async function agentMap(
providerOptions: ProviderOptions,
inventory: RepoInventory,
filters: PathFilters | undefined,
changedFiles: ReadonlySet<string> | undefined,
): Promise<MapResult> {
const prompt = buildAgentMapPrompt(project, {
manifests: inventory.manifests,
Expand All @@ -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(
Expand Down
17 changes: 16 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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);
},
Expand All @@ -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", {
Expand All @@ -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",
Expand All @@ -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",
};
}
Expand Down Expand Up @@ -394,6 +405,10 @@ function parseMapSource(flags: Record<string, string | boolean>): "heuristic" |
);
}

function hasIncrementalFlags(flags: Record<string, string | boolean>): boolean {
return stringFlag(flags, "since") !== undefined || flags["includeDirty"] === true;
}

// eslint-disable-next-line no-underscore-dangle
export const __testing = {
...reviewTesting,
Expand Down
11 changes: 10 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
38 changes: 38 additions & 0 deletions src/mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export type MapProgressEvent = {
export type MapOptions = {
onProgress?: (event: MapProgressEvent) => void;
filters?: PathFilters;
changedFiles?: ReadonlySet<string>;
};

const featureMappers: FeatureMapper[] = [
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -314,3 +321,34 @@ function statusForChangedFeature(status: FeatureRecord["status"]): FeatureRecord
}
return status;
}

function seedTouchesChangedFiles(seed: FeatureSeed, changedFiles: ReadonlySet<string>): 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<string>,
): 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;
}
34 changes: 34 additions & 0 deletions src/workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down