feat(forensics): opt-in duplicate detection before issue creation (#2105)

* feat(forensics): opt-in duplicate detection before issue creation

Adds forensics_dedup preference (default: false) that instructs the
forensics agent to search existing issues and PRs before filing.
First-time users see an opt-in notice explaining the token cost.

Fixes #2096

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* ci: retrigger checks

* fix(build): summary must be string[] not string in showNextAction

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-23 11:53:34 -04:00 committed by GitHub
parent 75d2ea7fb7
commit d83000d05d
5 changed files with 146 additions and 0 deletions

View file

@ -30,6 +30,9 @@ import { loadPrompt } from "./prompt-loader.js";
import { gsdRoot } from "./paths.js";
import { formatDuration } from "../shared/format-utils.js";
import { getAutoWorktreePath } from "./auto-worktree.js";
import { loadEffectiveGSDPreferences, loadGlobalGSDPreferences, getGlobalGSDPreferencesPath } from "./preferences.js";
import { showNextAction } from "../shared/tui.js";
import { ensurePreferencesFile, serializePreferencesToFrontmatter } from "./commands-prefs-wizard.js";
// ─── Types ────────────────────────────────────────────────────────────────────
@ -67,6 +70,71 @@ interface ForensicReport {
recentUnits: { type: string; id: string; cost: number; duration: number; model: string; finishedAt: number }[];
}
// ─── Duplicate Detection ──────────────────────────────────────────────────────
const DEDUP_PROMPT_SECTION = `
## Duplicate Detection (REQUIRED before issue creation)
Before offering to create a GitHub issue, you MUST search for existing issues and PRs that may already address this bug. This step uses the user's AI tokens for analysis.
### Search Steps
1. **Search closed issues** for similar keywords from your diagnosis:
\`\`\`
gh issue list --repo gsd-build/gsd-2 --state closed --search "<keywords from root cause>" --limit 20
\`\`\`
2. **Search open PRs** that might contain the fix:
\`\`\`
gh pr list --repo gsd-build/gsd-2 --state open --search "<keywords>" --limit 10
\`\`\`
3. **Search merged PRs** that may have already fixed this:
\`\`\`
gh pr list --repo gsd-build/gsd-2 --state merged --search "<keywords>" --limit 10
\`\`\`
### Analysis
For each result, compare it against your root-cause diagnosis:
- Does the issue describe the same code path or file?
- Does the PR modify the same file:line you identified?
- Is the symptom description semantically similar even if keywords differ?
### Present Findings
If you find potential matches, present them to the user:
1. **"Already fixed by PR #X — skip issue creation"** when a merged PR or closed issue clearly addresses the same root cause. Explain why you believe it matches.
2. **"Add my findings to existing issue #Y"** when an open issue exists for the same bug. Use \`gh issue comment #Y --repo gsd-build/gsd-2\` to add forensic evidence.
3. **"Create new issue anyway"** when existing results do not cover this specific failure.
Only proceed to issue creation if no matches were found OR the user explicitly chooses "Create new issue anyway".
`;
async function writeForensicsDedupPref(ctx: ExtensionCommandContext, enabled: boolean): Promise<void> {
const prefsPath = getGlobalGSDPreferencesPath();
await ensurePreferencesFile(prefsPath, ctx, "global");
const existing = loadGlobalGSDPreferences();
const prefs: Record<string, unknown> = existing?.preferences ? { ...existing.preferences } : {};
prefs.version = prefs.version || 1;
prefs.forensics_dedup = enabled;
const frontmatter = serializePreferencesToFrontmatter(prefs);
const raw = existsSync(prefsPath) ? readFileSync(prefsPath, "utf-8") : "";
let body = "\n# GSD Skill Preferences\n\nSee `~/.gsd/agent/extensions/gsd/docs/preferences-reference.md` for full field documentation and examples.\n";
const start = raw.startsWith("---\n") ? 4 : raw.startsWith("---\r\n") ? 5 : -1;
if (start !== -1) {
const closingIdx = raw.indexOf("\n---", start);
if (closingIdx !== -1) {
const after = raw.slice(closingIdx + 4);
if (after.trim()) body = after;
}
}
writeFileSync(prefsPath, `---\n${frontmatter}---${body}`, "utf-8");
}
// ─── Entry Point ──────────────────────────────────────────────────────────────
export async function handleForensics(
@ -98,6 +166,29 @@ export async function handleForensics(
return;
}
// ─── Duplicate detection opt-in ─────────────────────────────────────────────
const effectivePrefs = loadEffectiveGSDPreferences()?.preferences;
let dedupEnabled = effectivePrefs?.forensics_dedup === true;
if (effectivePrefs?.forensics_dedup === undefined) {
const choice = await showNextAction(ctx, {
title: "Duplicate detection available",
summary: ["Before filing a GitHub issue, forensics can search existing issues and PRs to avoid duplicates.", "This uses additional AI tokens for analysis."],
actions: [
{ id: "enable", label: "Enable duplicate detection", description: "Search issues/PRs before filing (recommended)", recommended: true },
{ id: "skip", label: "Skip for now", description: "File without checking for duplicates" },
],
notYetMessage: "You can enable this later via preferences (forensics_dedup: true).",
});
if (choice === "enable") {
await writeForensicsDedupPref(ctx, true);
dedupEnabled = true;
}
}
const dedupSection = dedupEnabled ? DEDUP_PROMPT_SECTION : "";
ctx.ui.notify("Building forensic report...", "info");
const report = await buildForensicReport(basePath);
@ -117,6 +208,7 @@ export async function handleForensics(
problemDescription,
forensicData,
gsdSourceDir,
dedupSection,
});
ctx.ui.notify(`Forensic report saved: ${relative(basePath, savedPath)}`, "info");

View file

@ -89,6 +89,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set<string>([
"reactive_execution",
"github",
"service_tier",
"forensics_dedup",
]);
/** Canonical list of all dispatch unit types. */
@ -223,6 +224,8 @@ export interface GSDPreferences {
github?: GitHubSyncConfig;
/** OpenAI service tier preference. "priority" = 2x cost, faster. "flex" = 0.5x cost, slower. Only affects gpt-5.4 models. */
service_tier?: "priority" | "flex";
/** Opt-in: search existing issues and PRs before filing from /gsd forensics. Uses additional AI tokens. */
forensics_dedup?: boolean;
}
export interface LoadedGSDPreferences {

View file

@ -341,6 +341,7 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr
? { ...(base.github ?? {}), ...(override.github ?? {}) } as import("../github-sync/types.js").GitHubSyncConfig
: undefined,
service_tier: override.service_tier ?? base.service_tier,
forensics_dedup: override.forensics_dedup ?? base.forensics_dedup,
};
}

View file

@ -101,6 +101,8 @@ Explain your findings:
- **Code snippet** — the problematic code and what it should do instead
- **Recovery** — what the user can do right now to get unstuck
{{dedupSection}}
Then **offer GitHub issue creation**: "Would you like me to create a GitHub issue for this on gsd-build/gsd-2?"
**CRITICAL: The `github_issues` tool ONLY targets the current user's repository — it has no `repo` parameter. You MUST use `gh issue create --repo gsd-build/gsd-2` via the `bash` tool to file on the correct repo. Do NOT use the `github_issues` tool for this.**

View file

@ -0,0 +1,48 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const gsdDir = join(__dirname, "..");
describe("forensics dedup (#2096)", () => {
it("forensics_dedup is in KNOWN_PREFERENCE_KEYS", () => {
const source = readFileSync(join(gsdDir, "preferences-types.ts"), "utf-8");
assert.ok(source.includes('"forensics_dedup"'),
"KNOWN_PREFERENCE_KEYS must contain forensics_dedup");
assert.ok(source.includes("forensics_dedup?: boolean"),
"GSDPreferences must declare forensics_dedup as optional boolean");
});
it("forensics prompt contains {{dedupSection}} placeholder", () => {
const prompt = readFileSync(join(gsdDir, "prompts", "forensics.md"), "utf-8");
assert.ok(prompt.includes("{{dedupSection}}"),
"forensics.md must contain {{dedupSection}} placeholder");
});
it("DEDUP_PROMPT_SECTION contains required search commands", async () => {
const source = readFileSync(join(gsdDir, "forensics.ts"), "utf-8");
assert.ok(source.includes("DEDUP_PROMPT_SECTION"), "forensics.ts must define DEDUP_PROMPT_SECTION");
assert.ok(source.includes("gh issue list --repo gsd-build/gsd-2 --state closed"));
assert.ok(source.includes("gh pr list --repo gsd-build/gsd-2 --state open"));
assert.ok(source.includes("gh pr list --repo gsd-build/gsd-2 --state merged"));
});
it("handleForensics checks forensics_dedup preference", () => {
const source = readFileSync(join(gsdDir, "forensics.ts"), "utf-8");
assert.ok(source.includes("forensics_dedup"),
"handleForensics must reference forensics_dedup preference");
assert.ok(source.includes("dedupSection"),
"handleForensics must pass dedupSection to loadPrompt");
});
it("first-time opt-in shows when preference is undefined", () => {
const source = readFileSync(join(gsdDir, "forensics.ts"), "utf-8");
assert.ok(source.includes("=== undefined"),
"first-time detection must check for undefined (not false)");
assert.ok(source.includes("Duplicate detection available") || source.includes("duplicate detection"),
"opt-in notice must mention duplicate detection");
});
});