Skip to content
Open
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
18 changes: 16 additions & 2 deletions src/products/gardener/engine/classifiers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ import type {
ClassifyInput,
ClassifyOutput,
} from "../comment.js";
import { collectTreeDigest, formatDigest } from "./tree-digest.js";
import {
collectTreeDigestDetailed,
emitDigestDiagnostics,
formatDigest,
} from "./tree-digest.js";
import { filterDiffNoise } from "./diff-filter.js";
import {
parseVerdictJson,
Expand All @@ -46,15 +50,25 @@ export interface AnthropicClassifierOptions {
model?: string;
/** Injected fetch for tests. Defaults to global fetch. */
fetchImpl?: typeof fetch;
/**
* Optional logging sink for diagnostics (digest budget warnings,
* etc.). Defaults to `process.stderr.write`.
*/
write?: (line: string) => void;
}

export function createAnthropicClassifier(
opts: AnthropicClassifierOptions,
): Classifier {
const model = opts.model?.trim() || DEFAULT_MODEL;
const doFetch = opts.fetchImpl ?? fetch;
const write =
opts.write ??
((line: string) => process.stderr.write(line + "\n"));
return async (input: ClassifyInput): Promise<ClassifyOutput> => {
const digest = formatDigest(collectTreeDigest(input.treeRoot));
const detailed = collectTreeDigestDetailed(input.treeRoot);
emitDigestDiagnostics(detailed, write);
const digest = formatDigest(detailed.entries);
const userPrompt = buildUserPrompt(input, digest);
try {
const res = await doFetch(ANTHROPIC_URL, {
Expand Down
20 changes: 18 additions & 2 deletions src/products/gardener/engine/classifiers/claude-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ import type {
ClassifyInput,
ClassifyOutput,
} from "../comment.js";
import { collectTreeDigest, formatDigest } from "./tree-digest.js";
import {
collectTreeDigestDetailed,
emitDigestDiagnostics,
formatDigest,
} from "./tree-digest.js";
import { filterDiffNoise } from "./diff-filter.js";
import {
parseVerdictJson,
Expand Down Expand Up @@ -63,6 +67,13 @@ export interface ClaudeCliClassifierOptions {
env?: NodeJS.ProcessEnv;
/** Injected spawner for tests. */
spawnImpl?: typeof spawn;
/**
* Optional logging sink for diagnostics (digest budget warnings,
* etc.). Defaults to `process.stderr.write`. The classifier never
* writes to stdout directly — stdout is reserved for the JSON
* verdict piped out to `gardener comment` / `sync`.
*/
write?: (line: string) => void;
}

export function buildClaudeCliEnvironment(
Expand All @@ -79,8 +90,13 @@ export function createClaudeCliClassifier(
const model = opts.model?.trim() || DEFAULT_MODEL;
const env = buildClaudeCliEnvironment(opts.env ?? process.env);
const doSpawn = opts.spawnImpl ?? spawn;
const write =
opts.write ??
((line: string) => process.stderr.write(line + "\n"));
return async (input: ClassifyInput): Promise<ClassifyOutput> => {
const digest = formatDigest(collectTreeDigest(input.treeRoot));
const detailed = collectTreeDigestDetailed(input.treeRoot);
emitDigestDiagnostics(detailed, write);
const digest = formatDigest(detailed.entries);
const prompt = buildPrompt(input, digest);
const { stdout, stderr, code, timedOut } = await runClaude(
doSpawn,
Expand Down
105 changes: 98 additions & 7 deletions src/products/gardener/engine/classifiers/tree-digest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,33 +22,86 @@ export interface TreeNodeEntry {
summary: string;
}

const DIGEST_BUDGET_BYTES = 100_000;
/**
* Total byte budget for the tree digest embedded in the classifier
* prompt. Raised from 100KB → 500KB (2026-04: Claude 4.5/4.6/4.7 give
* us a 200K-token context, so the old budget was severely
* over-conservative and was silently truncating nodes out of the
* classifier's view on larger trees; see #343).
*
* 500KB covers ≈ 2700 NODE.md entries at ~180 B each, which is
* comfortably beyond any realistic Context Tree. Still leaves headroom
* for PR body (uncapped) + diff (200KB cap) + system prompt (~1.5KB)
* under a 200K-token window (~800KB text).
*/
const DIGEST_BUDGET_BYTES = 500_000;
const PER_NODE_SUMMARY_CAP = 400;

/**
* Regex that matches the summary text gardener auto-writes when
* scaffolding `drift/<source-id>/.../NODE.md` placeholders during a
* sync. These placeholders carry no decisions — they exist only to let
* a proposal PR have a valid parent chain — so feeding them to the
* classifier wastes budget and clutters citations. See #343.
*/
const DRIFT_PLACEHOLDER_SUMMARY =
/^Auto-generated intermediate node for sync proposals\.?$/i;

const SKIP_DIRS = new Set([
".git",
"node_modules",
".first-tree",
".claude",
".agents",
// `.gardener-tree-cache` holds gardener's own per-sweep snapshot of
// the tree as it was at the last reconciled source commit. Those
// entries are stale duplicates of real NODE.md files; including
// them in the digest double-counts every node under each sourceId
// and pushes real nodes out of a tight budget. See #343.
".gardener-tree-cache",
"dist",
"build",
"tmp",
]);

export interface CollectTreeDigestResult {
entries: TreeNodeEntry[];
/** Nodes that matched on-disk but were filtered as noise before budget check. */
skippedAsNoise: number;
/** Nodes dropped because the budget was exhausted. */
truncatedCount: number;
/** True when we stopped walking the tree because DIGEST_BUDGET_BYTES filled up. */
budgetExhausted: boolean;
}

export function collectTreeDigest(treeRoot: string): TreeNodeEntry[] {
return collectTreeDigestDetailed(treeRoot).entries;
}

/**
* Same walk as {@link collectTreeDigest} but returns diagnostics the
* caller can surface (e.g. "tree digest truncated at N nodes — budget
* exhausted; consider raising DIGEST_BUDGET_BYTES"). Prefer this when
* running inside a classifier that can log a warning. Kept as a
* separate entry point so existing callers don't have to adopt the
* richer shape.
*/
export function collectTreeDigestDetailed(
treeRoot: string,
): CollectTreeDigestResult {
const out: TreeNodeEntry[] = [];
let bytes = 0;
let exhausted = false;
let budgetExhausted = false;
let skippedAsNoise = 0;
let truncatedCount = 0;
const walk = (dir: string): void => {
if (exhausted) return;
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return;
}
for (const name of entries) {
if (exhausted) return;
if (SKIP_DIRS.has(name)) continue;
const full = join(dir, name);
let st;
Expand All @@ -64,17 +117,24 @@ export function collectTreeDigest(treeRoot: string): TreeNodeEntry[] {
if (name !== "NODE.md") continue;
const entry = readNodeFile(full, treeRoot);
if (!entry) continue;
// Drop auto-generated drift-proposal placeholders before they
// eat budget. They carry no decision signal.
if (DRIFT_PLACEHOLDER_SUMMARY.test(entry.summary)) {
skippedAsNoise += 1;
continue;
}
const cost = entry.path.length + entry.summary.length + 4;
if (bytes + cost > DIGEST_BUDGET_BYTES) {
exhausted = true;
return;
budgetExhausted = true;
truncatedCount += 1;
continue;
}
bytes += cost;
out.push(entry);
}
};
walk(treeRoot);
return out;
return { entries: out, skippedAsNoise, truncatedCount, budgetExhausted };
}

function readNodeFile(
Expand Down Expand Up @@ -126,3 +186,34 @@ export function formatDigest(entries: TreeNodeEntry[]): string {
if (entries.length === 0) return "(no NODE.md files found)";
return entries.map((e) => `- \`${e.path}\` — ${e.summary}`).join("\n");
}

/**
* Surface tree-digest health info on the logging sink. Two things we
* want visible without flipping a debug flag:
*
* - noise filter actually removed nodes (worth knowing because it
* tells the operator their tree has ignorable auto-generated
* `drift/` placeholders; silent filtering would be confusing)
* - budget was exhausted (nodes silently dropped pre-#343); this is
* a correctness-affecting event — the classifier's verdict is
* judged against a truncated tree view and can cite the wrong
* nodes as "closest match."
*
* Shared between the claude-cli and anthropic-api classifiers so both
* speak the same warning vocabulary.
*/
export function emitDigestDiagnostics(
detailed: CollectTreeDigestResult,
write: (line: string) => void,
): void {
if (detailed.skippedAsNoise > 0) {
write(
`gardener: tree digest filtered ${detailed.skippedAsNoise} drift placeholder node(s) (auto-generated by prior sync)`,
);
}
if (detailed.budgetExhausted) {
write(
`gardener: tree digest budget exhausted — ${detailed.truncatedCount} node(s) dropped. Verdict may miss relevant tree context. Consider pruning the tree or raising DIGEST_BUDGET_BYTES.`,
);
}
}
Loading
Loading