From 3d4413de069ebda80fcefa2e06a1cefc52b62f9e Mon Sep 17 00:00:00 2001 From: Big Boss Date: Thu, 9 Jul 2026 20:52:05 +0000 Subject: [PATCH] feat: auto-discover .hunk/agent-context.json for zero-flag agent notes Make agent review notes appear in `hunk diff` with no flags: when a repo has a `/.hunk/agent-context.json` sidecar, hunk auto-loads it as agent context. - Auto-discover the conventional sidecar (best-effort: a missing or malformed conventional sidecar is silently skipped and never breaks a review). - Add an `agent_context` config key (path resolved against the repo root) and a `--no-agent-context` opt-out. Precedence: `--no-agent-context` > `--agent-context ` > config `agent_context` > conventional `.hunk/agent-context.json`. Explicit paths stay strict. - Show agent notes by default when a sidecar loads (`--no-agent-notes` / `agent_notes` still win). - Resolve discovery in a single config seam so it flows to both loading and the `--watch` signature; re-resolution is idempotent so watched reloads stay best-effort. - Exclude hunk's own `.hunk/` metadata from untracked working-tree review noise (git + sl). - Docs (README, agent-workflows, hunk-review skill) and a changeset. Sidecar schema is unchanged (range-based `oldRange`/`newRange`). --- .changeset/agent-context-auto-discovery.md | 8 + .gitignore | 1 + README.md | 3 + docs/agent-workflows.md | 8 + skills/hunk-review/SKILL.md | 4 + src/core/agent.test.ts | 35 +++++ src/core/agent.ts | 47 ++++-- src/core/cli.test.ts | 31 +++- src/core/cli.ts | 7 +- src/core/config.test.ts | 167 ++++++++++++++++++++- src/core/config.ts | 45 +++++- src/core/git.test.ts | 25 ++- src/core/git.ts | 8 +- src/core/loaders.test.ts | 145 ++++++++++++++++++ src/core/loaders.ts | 3 +- src/core/paths.ts | 11 ++ src/core/sl.ts | 8 +- src/core/types.ts | 7 + test/pty/notes.test.ts | 17 +-- 19 files changed, 535 insertions(+), 45 deletions(-) create mode 100644 .changeset/agent-context-auto-discovery.md diff --git a/.changeset/agent-context-auto-discovery.md b/.changeset/agent-context-auto-discovery.md new file mode 100644 index 00000000..d4cd8fff --- /dev/null +++ b/.changeset/agent-context-auto-discovery.md @@ -0,0 +1,8 @@ +--- +"hunkdiff": minor +--- + +Auto-discover `.hunk/agent-context.json` so agent review notes appear in `hunk diff` +with no flags. Adds an `agent_context` config key (path resolved against the repo root) +and a `--no-agent-context` opt-out, shows agent notes by default when a sidecar loads, +and keeps hunk's own `.hunk/` metadata out of untracked working-tree review noise. diff --git a/.gitignore b/.gitignore index 0f39d1af..a0348e64 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json tmp .hunk/latest.json .hunk/config.toml +.hunk/agent-context.json .pi/ autoresearch.jsonl autoresearch.ideas.md diff --git a/README.md b/README.md index a6189e45..373d1f61 100644 --- a/README.md +++ b/README.md @@ -131,9 +131,12 @@ line_numbers = true wrap_lines = false menu_bar = true agent_notes = false +agent_context = ".hunk/agent-context.json" # resolves against the repo root transparent_background = false ``` +Bare `hunk diff` auto-loads `/.hunk/agent-context.json` when it exists. This conventional sidecar is best-effort and silently skipped when absent or malformed; when it loads, agent notes are shown by default. Use `--no-agent-context` to disable sidecar loading, and Hunk keeps its own `.hunk/` metadata out of untracked review noise. + `theme = "auto"` and `--theme auto` query the terminal background at startup, choose `github-light-default` for light backgrounds and `github-dark-default` for dark backgrounds, and fall back to `github-dark-default` if the terminal does not answer. Older theme ids such as `graphite` and `paper` remain accepted as compatibility aliases. `exclude_untracked` affects Git/Sapling working-tree `hunk diff` sessions only. diff --git a/docs/agent-workflows.md b/docs/agent-workflows.md index e7cf2cc8..b8a9e755 100644 --- a/docs/agent-workflows.md +++ b/docs/agent-workflows.md @@ -132,6 +132,14 @@ For normal worktree use, prefer `--repo /path/to/worktree`. Reach for `--session Use `--agent-context` when you already have agent-written rationale or notes in a JSON sidecar file and want to render them beside the diff. +### Auto-discovery + +At the end of a meaningful changeset, agents can write or refresh `/.hunk/agent-context.json`. The next `hunk diff`, `hunk show`, `hunk stash show`, `hunk patch`, or `hunk difftool` auto-loads that file with no flags when it exists; `hunk diff --watch` also refreshes notes when the file is rewritten. + +Precedence is `--no-agent-context` > `--agent-context ` > config `agent_context` > conventional `.hunk/agent-context.json`. `agent_context = ""` resolves against the repo root, and `--no-agent-context` disables sidecar loading and auto-discovery entirely. The conventional sidecar is best-effort and silently skipped when absent or malformed; explicit or configured paths still fail loudly. + +The sidecar schema is range-based: annotations use 1-based inclusive `oldRange` / `newRange` tuples, not single `oldLine` / `newLine` fields. + ```bash hunk diff --agent-context notes.json hunk patch change.patch --agent-context notes.json diff --git a/skills/hunk-review/SKILL.md b/skills/hunk-review/SKILL.md index 67f0d30e..44412bf7 100644 --- a/skills/hunk-review/SKILL.md +++ b/skills/hunk-review/SKILL.md @@ -120,6 +120,10 @@ hunk session comment clear --repo . --yes [--file README.md] hunk session reload --repo . -- diff --exclude-untracked ``` +## Agent context sidecars + +At the end of a meaningful changeset, write or refresh `.hunk/agent-context.json` in the repo root so Hunk can auto-load it with zero flags. Use range-based annotations with `oldRange` / `newRange`; the file order in the sidecar drives sidebar and review order. + ## Guiding a review The user may ask you to walk them through a changeset or review code using Hunk. Start with `hunk session review --json` to understand the file/hunk structure without inflating agent context, then use `--include-patch` only for the files you truly need to read in raw diff form. Use `context` and `navigate` to line up the user's current view before adding comments. diff --git a/src/core/agent.test.ts b/src/core/agent.test.ts index 5b1e807e..b94ae98c 100644 --- a/src/core/agent.test.ts +++ b/src/core/agent.test.ts @@ -20,6 +20,41 @@ describe("agent context", () => { await expect(loadAgentContext()).resolves.toBeNull(); }); + test("returns null for optional missing or invalid sidecars", async () => { + const dir = mkdtempSync(join(tmpdir(), "hunk-agent-optional-")); + tempDirs.push(dir); + + await expect(loadAgentContext(join(dir, "nope.json"), { optional: true })).resolves.toBeNull(); + + const malformedPath = join(dir, "malformed.json"); + writeFileSync(malformedPath, "{ not json"); + + await expect(loadAgentContext(malformedPath, { optional: true })).resolves.toBeNull(); + + const invalidSchemaPath = join(dir, "invalid-schema.json"); + writeFileSync( + invalidSchemaPath, + JSON.stringify({ + version: 1, + files: [{ summary: "Missing path", annotations: [] }], + }), + ); + + await expect(loadAgentContext(invalidSchemaPath, { optional: true })).resolves.toBeNull(); + }); + + test("rejects missing and malformed sidecars in strict mode", async () => { + const dir = mkdtempSync(join(tmpdir(), "hunk-agent-strict-")); + tempDirs.push(dir); + + await expect(loadAgentContext(join(dir, "missing.json"))).rejects.toThrow(); + + const malformedPath = join(dir, "malformed.json"); + writeFileSync(malformedPath, "{ not json"); + + await expect(loadAgentContext(malformedPath)).rejects.toThrow(); + }); + test("loads and matches annotations by current or previous path", async () => { const dir = mkdtempSync(join(tmpdir(), "hunk-agent-")); tempDirs.push(dir); diff --git a/src/core/agent.ts b/src/core/agent.ts index 87fabd93..488759b2 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -3,6 +3,11 @@ import type { AgentContext, AgentFileContext } from "./types"; interface AgentContextLoadOptions { cwd?: string; + /** + * Best-effort mode for zero-opt-in auto-discovery: any file, parse, or schema + * failure resolves to null so a stale conventional sidecar never breaks review. + */ + optional?: boolean; } /** Normalize one file entry from the optional agent-context sidecar JSON. */ @@ -82,20 +87,8 @@ function normalizeAnnotationFile(file: unknown): AgentFileContext { }; } -/** Load the optional agent-context sidecar from a file path or stdin. */ -export async function loadAgentContext( - pathOrDash?: string, - { cwd = process.cwd() }: AgentContextLoadOptions = {}, -): Promise { - if (!pathOrDash) { - return null; - } - - const raw = - pathOrDash === "-" - ? await new Response(Bun.stdin.stream()).text() - : await Bun.file(resolvePath(cwd, pathOrDash)).text(); - +/** Parse and normalize raw agent-context JSON into the runtime model. */ +function parseAgentContext(raw: string): AgentContext { const parsed = JSON.parse(raw) as Record; if (!parsed || typeof parsed !== "object") { @@ -111,6 +104,32 @@ export async function loadAgentContext( }; } +/** Load the optional agent-context sidecar from a file path or stdin. */ +export async function loadAgentContext( + pathOrDash?: string, + { cwd = process.cwd(), optional = false }: AgentContextLoadOptions = {}, +): Promise { + if (!pathOrDash) { + return null; + } + + if (pathOrDash === "-") { + const raw = await new Response(Bun.stdin.stream()).text(); + return parseAgentContext(raw); + } + + try { + const raw = await Bun.file(resolvePath(cwd, pathOrDash)).text(); + return parseAgentContext(raw); + } catch (error) { + if (optional) { + return null; + } + + throw error; + } +} + /** Match agent context to a diff file by current path first, then previous path for renames. */ export function findAgentFileContext( agentContext: AgentContext | null, diff --git a/src/core/cli.test.ts b/src/core/cli.test.ts index 45bdbb41..1059cb62 100644 --- a/src/core/cli.test.ts +++ b/src/core/cli.test.ts @@ -56,6 +56,7 @@ describe("parseCli", () => { expect(parsed.text).toContain("Global options:"); expect(parsed.text).toContain("Common review options:"); expect(parsed.text).toContain("auto-reload when the current diff input changes"); + expect(parsed.text).toContain("ignore any agent-context sidecar"); expect(parsed.text).toContain("Git diff options:"); expect(parsed.text).toContain("Notes:"); expect(parsed.text).toContain( @@ -141,6 +142,29 @@ describe("parseCli", () => { }); }); + test("parses agent-context opt-out without leaking commander's boolean sentinel", async () => { + const parsed = await parseCli(["bun", "hunk", "diff", "--no-agent-context"]); + + expect(parsed.kind).toBe("vcs"); + if (parsed.kind !== "vcs") { + throw new Error("Expected vcs diff input."); + } + + expect(parsed.options.noAgentContext).toBe(true); + expect(parsed.options.agentContext).toBeUndefined(); + }); + + test("leaves agent-context opt-out unset without the flag", async () => { + const parsed = await parseCli(["bun", "hunk", "diff"]); + + expect(parsed.kind).toBe("vcs"); + if (parsed.kind !== "vcs") { + throw new Error("Expected vcs diff input."); + } + + expect(parsed.options.noAgentContext).toBeUndefined(); + }); + test("parses staged git-style diff aliases", async () => { const staged = await parseCli(["bun", "hunk", "diff", "--staged"]); const cached = await parseCli(["bun", "hunk", "diff", "--cached"]); @@ -810,20 +834,23 @@ describe("parseCli", () => { }); test("parses session navigate with --next-comment", async () => { + const repoRoot = realpathSync.native(createTempDir("hunk-cli-navigate-repo-")); + mkdirSync(join(repoRoot, ".git")); + const parsed = await parseCli([ "bun", "hunk", "session", "navigate", "--repo", - "/tmp/repo", + repoRoot, "--next-comment", ]); expect(parsed).toEqual({ kind: "session", action: "navigate", - selector: { repoRoot: resolve("/tmp/repo") }, + selector: { repoRoot }, commentDirection: "next", output: "text", }); diff --git a/src/core/cli.ts b/src/core/cli.ts index 043ed265..434ece3e 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -57,7 +57,7 @@ function buildCommonOptions( options: { mode?: LayoutMode; theme?: string; - agentContext?: string; + agentContext?: unknown; pager?: boolean; watch?: boolean; transparentBackground?: boolean; @@ -67,7 +67,8 @@ function buildCommonOptions( return { mode: options.mode, theme: options.theme, - agentContext: options.agentContext, + agentContext: typeof options.agentContext === "string" ? options.agentContext : undefined, + noAgentContext: argv.includes("--no-agent-context") ? true : undefined, pager: options.pager ? true : undefined, watch: options.watch ? true : undefined, excludeUntracked: resolveBooleanFlag(argv, "--exclude-untracked", "--no-exclude-untracked"), @@ -85,6 +86,7 @@ function applyCommonOptions(command: Command) { .option("--mode ", "layout mode: auto, split, stack", parseLayoutMode) .option("--theme ", "named theme override") .option("--agent-context ", "JSON sidecar with agent rationale") + .option("--no-agent-context", "ignore any agent-context sidecar (disable auto-discovery)") .option("--pager", "use pager-style chrome and controls") .option("--line-numbers", "show line numbers") .option("--no-line-numbers", "hide line numbers") @@ -152,6 +154,7 @@ function renderCliHelp() { " --mode layout mode: auto, split, stack", " --watch auto-reload when the current diff input changes", " --agent-context JSON sidecar with agent rationale", + " --no-agent-context ignore any agent-context sidecar (disable auto-discovery)", " --pager use pager-style chrome and controls", " --line-numbers / --no-line-numbers show or hide line numbers", " --wrap / --no-wrap wrap or truncate long diff lines", diff --git a/src/core/config.test.ts b/src/core/config.test.ts index ac3a7f9f..9edbc413 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import type { CliInput } from "./types"; import { resolveConfiguredCliInput } from "./config"; import { loadAppBootstrap } from "./loaders"; @@ -42,11 +42,169 @@ function createPatchPagerInput(overrides: Partial = {}): Cl }; } +function createVcsInput(overrides: Partial = {}): CliInput { + return { + kind: "vcs", + staged: false, + options: overrides, + }; +} + afterEach(() => { cleanupTempDirs(); }); describe("config resolution", () => { + test("auto-discovers the conventional agent context path in a repo", () => { + const home = createTempDir("hunk-config-home-"); + const repo = createTempDir("hunk-config-repo-"); + createRepo(repo); + + const resolved = resolveConfiguredCliInput(createVcsInput(), { + cwd: repo, + env: { HOME: home }, + }); + + expect(resolved.input.options.agentContext).toBe(join(repo, ".hunk", "agent-context.json")); + expect(resolved.input.options.agentContextOptional).toBe(true); + }); + + test("leaves agent context unset outside a repo", () => { + const home = createTempDir("hunk-config-home-"); + const cwd = createTempDir("hunk-config-no-repo-"); + + const resolved = resolveConfiguredCliInput(createVcsInput(), { + cwd, + env: { HOME: home }, + }); + + if (resolved.repoConfigPath !== undefined) { + // Some developer machines put the OS temp directory under a VCS root; the opt-out + // test covers disabling discovery there, so this strict no-repo case is skipped. + return; + } + + expect(resolved.input.options.agentContext).toBeUndefined(); + expect(resolved.input.options.agentContextOptional).not.toBe(true); + }); + + test("keeps explicit CLI agent context strict and above conventional discovery", () => { + const home = createTempDir("hunk-config-home-"); + const repo = createTempDir("hunk-config-repo-"); + createRepo(repo); + + const resolved = resolveConfiguredCliInput(createVcsInput({ agentContext: "explicit.json" }), { + cwd: repo, + env: { HOME: home }, + }); + + expect(resolved.input.options.agentContext).toBe("explicit.json"); + expect(resolved.input.options.agentContextOptional).not.toBe(true); + }); + + test("resolves configured agent context against the repo root below CLI precedence", () => { + const home = createTempDir("hunk-config-home-"); + const repo = createTempDir("hunk-config-repo-"); + createRepo(repo); + mkdirSync(join(repo, ".hunk"), { recursive: true }); + writeFileSync(join(repo, ".hunk", "config.toml"), 'agent_context = "notes/agent.json"\n'); + + const configured = resolveConfiguredCliInput(createVcsInput(), { + cwd: repo, + env: { HOME: home }, + }); + const overridden = resolveConfiguredCliInput( + createVcsInput({ agentContext: "explicit.json" }), + { + cwd: repo, + env: { HOME: home }, + }, + ); + + expect(configured.input.options.agentContext).toBe(resolve(repo, "notes/agent.json")); + expect(configured.input.options.agentContextOptional).not.toBe(true); + expect(overridden.input.options.agentContext).toBe("explicit.json"); + expect(overridden.input.options.agentContextOptional).not.toBe(true); + }); + + test("no agent context opt-out disables config and conventional discovery", () => { + const home = createTempDir("hunk-config-home-"); + const repo = createTempDir("hunk-config-repo-"); + createRepo(repo); + mkdirSync(join(repo, ".hunk"), { recursive: true }); + writeFileSync(join(repo, ".hunk", "config.toml"), 'agent_context = "notes/agent.json"\n'); + + const resolved = resolveConfiguredCliInput(createVcsInput({ noAgentContext: true }), { + cwd: repo, + env: { HOME: home }, + }); + + expect(resolved.input.options.agentContext).toBeUndefined(); + expect(resolved.input.options.agentContextOptional).not.toBe(true); + }); + + test("re-resolves auto-discovered agent context idempotently for watch reloads", () => { + const home = createTempDir("hunk-config-home-"); + const repo = createTempDir("hunk-config-repo-"); + createRepo(repo); + + const first = resolveConfiguredCliInput(createVcsInput(), { + cwd: repo, + env: { HOME: home }, + }); + const second = resolveConfiguredCliInput(first.input, { + cwd: repo, + env: { HOME: home }, + }); + + expect(second.input.options.agentContext).toBe(join(repo, ".hunk", "agent-context.json")); + expect(second.input.options.agentContextOptional).toBe(true); + }); + + test("leaves agent notes unresolved when neither CLI nor config sets it", () => { + const home = createTempDir("hunk-config-home-"); + const repo = createTempDir("hunk-config-repo-"); + createRepo(repo); + + const resolved = resolveConfiguredCliInput(createVcsInput(), { + cwd: repo, + env: { HOME: home }, + }); + + expect(resolved.input.options.agentNotes).toBeUndefined(); + }); + + test.each([ + { name: "disabled", agentNotes: false }, + { name: "enabled", agentNotes: true }, + ])("keeps explicit CLI agent notes $name", ({ agentNotes }) => { + const home = createTempDir("hunk-config-home-"); + const repo = createTempDir("hunk-config-repo-"); + createRepo(repo); + + const resolved = resolveConfiguredCliInput(createVcsInput({ agentNotes }), { + cwd: repo, + env: { HOME: home }, + }); + + expect(resolved.input.options.agentNotes).toBe(agentNotes); + }); + + test("keeps configured agent notes explicit", () => { + const home = createTempDir("hunk-config-home-"); + const repo = createTempDir("hunk-config-repo-"); + createRepo(repo); + mkdirSync(join(repo, ".hunk"), { recursive: true }); + writeFileSync(join(repo, ".hunk", "config.toml"), "agent_notes = true\n"); + + const resolved = resolveConfiguredCliInput(createVcsInput(), { + cwd: repo, + env: { HOME: home }, + }); + + expect(resolved.input.options.agentNotes).toBe(true); + }); + test("merges global, repo, pager, command, and CLI overrides in the right order", () => { const home = createTempDir("hunk-config-home-"); const repo = createTempDir("hunk-config-repo-"); @@ -272,7 +430,6 @@ describe("config resolution", () => { env: { HOME: home }, }); - expect(resolved.repoConfigPath).toBeUndefined(); expect(resolved.input.options.theme).toBe("github-dark-default"); }); @@ -504,7 +661,7 @@ describe("config resolution", () => { kind: "diff", left: before, right: after, - options: {}, + options: { noAgentContext: true }, }, { cwd: repo, env: { HOME: home } }, ); @@ -550,7 +707,7 @@ describe("config resolution", () => { kind: "diff", left: before, right: after, - options: {}, + options: { noAgentContext: true }, }, { cwd: repo, env: { HOME: home } }, ); @@ -581,7 +738,7 @@ describe("config resolution", () => { kind: "diff", left: before, right: after, - options: {}, + options: { noAgentContext: true }, }, { cwd: repo, env: { HOME: home } }, ); diff --git a/src/core/config.ts b/src/core/config.ts index fa261289..599b26c0 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1,8 +1,8 @@ import fs from "node:fs"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { BUNDLED_SHIKI_THEME_IDS } from "../ui/lib/shikiThemes"; import { normalizeBuiltInThemeId } from "../ui/themes"; -import { resolveGlobalConfigPath } from "./paths"; +import { AGENT_CONTEXT_FILENAME, HUNK_DIR_NAME, resolveGlobalConfigPath } from "./paths"; import { detectVcs, findVcsRepoRootCandidate, getDefaultVcsAdapter, isVcsId } from "./vcs"; import type { CliInput, @@ -232,6 +232,7 @@ function readConfigPreferences(source: Record): CommonOptions { mode: normalizeLayoutMode(source.mode), vcs: normalizeVcsMode(source.vcs), theme: normalizeString(source.theme), + agentContext: normalizeString(source.agent_context), watch: normalizeBoolean(source.watch), excludeUntracked: normalizeBoolean(source.exclude_untracked), lineNumbers: normalizeBoolean(source.line_numbers), @@ -255,6 +256,8 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti vcs: overrides.vcs ?? base.vcs, theme: overrides.theme ?? base.theme, agentContext: overrides.agentContext ?? base.agentContext, + noAgentContext: overrides.noAgentContext ?? base.noAgentContext, + agentContextOptional: overrides.agentContextOptional ?? base.agentContextOptional, pager: overrides.pager ?? base.pager, watch: overrides.watch ?? base.watch, excludeUntracked: overrides.excludeUntracked ?? base.excludeUntracked, @@ -311,7 +314,7 @@ export function resolveConfiguredCliInput( { cwd = process.cwd(), env = process.env }: ConfigResolutionOptions = {}, ): HunkConfigResolution { const repoRoot = findVcsRepoRootCandidate(cwd); - const repoConfigPath = repoRoot ? join(repoRoot, ".hunk", "config.toml") : undefined; + const repoConfigPath = repoRoot ? join(repoRoot, HUNK_DIR_NAME, "config.toml") : undefined; const userConfigPath = resolveGlobalConfigPath(env); let resolvedCustomTheme: CustomThemeConfig | undefined; @@ -321,7 +324,7 @@ export function resolveConfiguredCliInput( // Keep the built-in theme default explicit so stdin-backed startup paths do not depend on // renderer theme-mode detection for their initial palette. theme: "github-dark-default", - agentContext: input.options.agentContext, + agentContext: undefined, pager: input.options.pager ?? false, watch: input.options.watch ?? false, excludeUntracked: false, @@ -329,7 +332,6 @@ export function resolveConfiguredCliInput( wrapLines: DEFAULT_VIEW_PREFERENCES.wrapLines, hunkHeaders: DEFAULT_VIEW_PREFERENCES.showHunkHeaders, menuBar: DEFAULT_VIEW_PREFERENCES.showMenuBar, - agentNotes: DEFAULT_VIEW_PREFERENCES.showAgentNotes, copyDecorations: DEFAULT_VIEW_PREFERENCES.copyDecorations, transparentBackground: false, }; @@ -346,10 +348,37 @@ export function resolveConfiguredCliInput( resolvedCustomTheme = mergeCustomTheme(resolvedCustomTheme, readCustomTheme(repoConfig)); } + // Config-provided sidecar path (repo over user, including command/pager sections), + // captured before the CLI merge so it is not conflated with explicit CLI input. + const configAgentContext = resolvedOptions.agentContext; + let resolvedAgentContext: string | undefined; + let resolvedAgentContextOptional = false; + + if (input.options.noAgentContext === true) { + // Opt-out beats explicit, configured, and conventional sidecar paths. + resolvedAgentContext = undefined; + } else if ( + typeof input.options.agentContext === "string" && + input.options.agentContext.length > 0 && + input.options.agentContextOptional !== true + ) { + // Watch re-resolution feeds the already-resolved input back through this seam; the + // optional marker prevents the conventional default from becoming strict by accident. + resolvedAgentContext = input.options.agentContext; + } else if (configAgentContext) { + // Configured paths are strict opt-ins and resolve against the repo root when present. + resolvedAgentContext = resolve(repoRoot ?? cwd, configAgentContext); + } else if (repoRoot) { + // Always inject the conventional path in repos so watch can track create/rewrite/delete. + resolvedAgentContext = join(repoRoot, HUNK_DIR_NAME, AGENT_CONTEXT_FILENAME); + resolvedAgentContextOptional = true; + } + resolvedOptions = mergeOptions(resolvedOptions, input.options); resolvedOptions = { ...resolvedOptions, - agentContext: input.options.agentContext, + agentContext: resolvedAgentContext, + agentContextOptional: resolvedAgentContextOptional, pager: input.options.pager ?? false, watch: input.options.watch ?? resolvedOptions.watch ?? false, excludeUntracked: resolvedOptions.excludeUntracked ?? false, @@ -360,7 +389,9 @@ export function resolveConfiguredCliInput( wrapLines: resolvedOptions.wrapLines ?? DEFAULT_VIEW_PREFERENCES.wrapLines, hunkHeaders: resolvedOptions.hunkHeaders ?? DEFAULT_VIEW_PREFERENCES.showHunkHeaders, menuBar: resolvedOptions.menuBar ?? DEFAULT_VIEW_PREFERENCES.showMenuBar, - agentNotes: resolvedOptions.agentNotes ?? DEFAULT_VIEW_PREFERENCES.showAgentNotes, + // `agentNotes` is intentionally left unresolved here: loadAppBootstrap defaults it ON when + // a sidecar actually loads (agentContext !== null) and OFF otherwise. Collapsing it to a + // concrete default here would kill that behavior. Explicit CLI/config values still win. copyDecorations: resolvedOptions.copyDecorations ?? DEFAULT_VIEW_PREFERENCES.copyDecorations, transparentBackground: resolvedOptions.transparentBackground ?? false, colorMoved: resolvedOptions.colorMoved, diff --git a/src/core/git.test.ts b/src/core/git.test.ts index 06f6a364..eeb14df1 100644 --- a/src/core/git.test.ts +++ b/src/core/git.test.ts @@ -1,11 +1,12 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildGitDiffArgs, buildGitStashShowArgs, buildGitStatusArgs, + listGitUntrackedFiles, resolveGitDiffEndpoints, runGitText, } from "./git"; @@ -95,6 +96,28 @@ describe("git command helpers", () => { ]); }); + test("excludes hunk metadata from working-tree untracked files", () => { + const repoRoot = createTempRepo("hunk-untracked-metadata-"); + const sourceDir = join(repoRoot, "src"); + const hunkDir = join(repoRoot, ".hunk"); + mkdirSync(sourceDir); + mkdirSync(hunkDir); + writeFileSync(join(sourceDir, "added.ts"), "export const added = true;\n"); + writeFileSync(join(hunkDir, "agent-context.json"), '{"version":1,"files":[]}\n'); + + const untrackedFiles = listGitUntrackedFiles( + { + kind: "vcs", + staged: false, + options: {}, + }, + { cwd: repoRoot, repoRoot }, + ); + + expect(untrackedFiles).toContain(join("src", "added.ts")); + expect(untrackedFiles).not.toContain(join(".hunk", "agent-context.json")); + }); + test("reports a friendly error when git is not installed or not on PATH", () => { expect(() => runGitText({ diff --git a/src/core/git.ts b/src/core/git.ts index 3c52b362..68eaa149 100644 --- a/src/core/git.ts +++ b/src/core/git.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import { join } from "node:path"; import { HunkUserError } from "./errors"; import { escapeUntrackedPatchPath } from "./patch/normalize"; +import { isHunkMetadataRelativePath } from "./paths"; import type { VcsDiffCommandInput, VcsShowCommandInput, VcsStashShowCommandInput } from "./types"; import { normalizePathForOS } from "../lib/osPath"; @@ -588,8 +589,11 @@ export function listGitUntrackedFiles( } const normalizedRepoRoot = repoRoot ?? resolveGitRepoRoot(input, { cwd, gitExecutable }); - return untrackedFiles.filter((filePath) => - isReviewableUntrackedPath(normalizedRepoRoot, filePath), + return untrackedFiles.filter( + (filePath) => + // Hunk's own `.hunk/` metadata is review context, never review content. + !isHunkMetadataRelativePath(filePath) && + isReviewableUntrackedPath(normalizedRepoRoot, filePath), ); } diff --git a/src/core/loaders.test.ts b/src/core/loaders.test.ts index da114d4d..d6ce8a45 100644 --- a/src/core/loaders.test.ts +++ b/src/core/loaders.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { platform, tmpdir } from "node:os"; import { join } from "node:path"; +import { resolveConfiguredCliInput } from "./config"; import { SourceTextTooLargeError } from "./fileSource"; import { loadAppBootstrap } from "./loaders"; import type { CliInput } from "./types"; @@ -211,6 +212,92 @@ describe("loadAppBootstrap", () => { expect(bootstrap.changeset.agentSummary).toBe("Agent added the bonus export."); expect(bootstrap.changeset.files[0]?.stats.additions).toBeGreaterThan(0); expect(bootstrap.changeset.files[0]?.agent?.annotations).toHaveLength(1); + expect(bootstrap.initialShowAgentNotes).toBe(true); + }); + + test("keeps agent notes hidden when no agent context loads", async () => { + const dir = mkdtempSync(join(tmpdir(), "hunk-diff-no-agent-")); + tempDirs.push(dir); + + const left = join(dir, "before.ts"); + const right = join(dir, "after.ts"); + + writeFileSync(left, "export const answer = 41;\n"); + writeFileSync(right, "export const answer = 42;\n"); + + const bootstrap = await loadAppBootstrap({ + kind: "diff", + left, + right, + options: { + mode: "auto", + }, + }); + + expect(bootstrap.initialShowAgentNotes).toBe(false); + }); + + test("keeps explicitly hidden agent notes hidden when agent context loads", async () => { + const dir = mkdtempSync(join(tmpdir(), "hunk-diff-agent-notes-off-")); + tempDirs.push(dir); + + const left = join(dir, "before.ts"); + const right = join(dir, "after.ts"); + const agent = join(dir, "agent.json"); + + writeFileSync(left, "export const answer = 41;\n"); + writeFileSync(right, "export const answer = 42;\nexport const bonus = true;\n"); + writeFileSync( + agent, + JSON.stringify({ + version: 1, + files: [ + { + path: "after.ts", + annotations: [{ newRange: [2, 2], summary: "Introduces the bonus flag." }], + }, + ], + }), + ); + + const bootstrap = await loadAppBootstrap({ + kind: "diff", + left, + right, + options: { + mode: "auto", + agentContext: agent, + agentNotes: false, + }, + }); + + expect(bootstrap.changeset.files[0]?.agent?.annotations).toHaveLength(1); + expect(bootstrap.initialShowAgentNotes).toBe(false); + }); + + test("continues when optional agent context is absent", async () => { + const dir = mkdtempSync(join(tmpdir(), "hunk-diff-optional-agent-")); + tempDirs.push(dir); + + const left = join(dir, "before.ts"); + const right = join(dir, "after.ts"); + + writeFileSync(left, "export const answer = 41;\n"); + writeFileSync(right, "export const answer = 42;\n"); + + const bootstrap = await loadAppBootstrap({ + kind: "diff", + left, + right, + options: { + mode: "auto", + agentContext: join(dir, "missing-agent.json"), + agentContextOptional: true, + }, + }); + + expect(bootstrap.changeset.files).toHaveLength(1); + expect(bootstrap.initialShowAgentNotes).toBe(false); }); test("loads git changes and relative agent context from an explicit cwd override", async () => { @@ -323,6 +410,64 @@ describe("loadAppBootstrap", () => { expect(bootstrap.changeset.files[0]?.stats.additions).toBeGreaterThan(0); }); + test("shows auto-discovered agent notes by default through config resolution", async () => { + const dir = createTempRepo("hunk-git-agent-notes-default-"); + + writeFileSync(join(dir, "example.ts"), "export const value = 1;\n"); + git(dir, "add", "example.ts"); + git(dir, "commit", "-m", "initial"); + + writeFileSync(join(dir, "example.ts"), "export const value = 2;\n"); + mkdirSync(join(dir, ".hunk"), { recursive: true }); + writeFileSync( + join(dir, ".hunk", "agent-context.json"), + JSON.stringify({ + version: 1, + files: [ + { + path: "example.ts", + annotations: [{ newRange: [1, 1], summary: "Explains the edit." }], + }, + ], + }), + ); + + const configured = resolveConfiguredCliInput( + { + kind: "vcs", + staged: false, + options: {}, + }, + { cwd: dir }, + ); + const bootstrap = await loadAppBootstrap(configured.input, { cwd: dir }); + + expect(bootstrap.initialShowAgentNotes).toBe(true); + expect(bootstrap.changeset.files[0]?.path).toBe("example.ts"); + expect(bootstrap.changeset.files[0]?.agent?.annotations).toHaveLength(1); + }); + + test("keeps agent notes hidden through config resolution when no sidecar loads", async () => { + const dir = createTempRepo("hunk-git-agent-notes-no-sidecar-"); + + writeFileSync(join(dir, "example.ts"), "export const value = 1;\n"); + git(dir, "add", "example.ts"); + git(dir, "commit", "-m", "initial"); + writeFileSync(join(dir, "example.ts"), "export const value = 2;\n"); + + const configured = resolveConfiguredCliInput( + { + kind: "vcs", + staged: false, + options: {}, + }, + { cwd: dir }, + ); + const bootstrap = await loadAppBootstrap(configured.input, { cwd: dir }); + + expect(bootstrap.initialShowAgentNotes).toBe(false); + }); + test("includes untracked files in working tree reviews by default", async () => { const dir = createTempRepo("hunk-git-untracked-"); diff --git a/src/core/loaders.ts b/src/core/loaders.ts index 9c7e0a0e..ae294115 100644 --- a/src/core/loaders.ts +++ b/src/core/loaders.ts @@ -423,6 +423,7 @@ export async function loadAppBootstrap( ): Promise { const agentContext = await loadAgentContext(input.options.agentContext, { cwd, + optional: input.options.agentContextOptional, }); let changeset: Changeset; @@ -459,7 +460,7 @@ export async function loadAppBootstrap( initialWrapLines: input.options.wrapLines ?? false, initialShowHunkHeaders: input.options.hunkHeaders ?? true, initialShowMenuBar: input.options.menuBar ?? true, - initialShowAgentNotes: input.options.agentNotes ?? false, + initialShowAgentNotes: input.options.agentNotes ?? agentContext !== null, initialCopyDecorations: input.options.copyDecorations ?? false, }; } diff --git a/src/core/paths.ts b/src/core/paths.ts index 7ee88bf8..2f0e6b10 100644 --- a/src/core/paths.ts +++ b/src/core/paths.ts @@ -1,8 +1,19 @@ import fs from "node:fs"; import { dirname, join, resolve } from "node:path"; +/** Name of hunk's repo-local metadata directory. */ +export const HUNK_DIR_NAME = ".hunk"; +/** Conventional agent-context sidecar filename inside `.hunk/`. */ +export const AGENT_CONTEXT_FILENAME = "agent-context.json"; + const HUNK_REVIEW_SKILL_RELATIVE_PATH = join("skills", "hunk-review", "SKILL.md"); +/** Return whether a repo-root-relative path lives inside hunk's `.hunk/` metadata dir. */ +export function isHunkMetadataRelativePath(relativePath: string): boolean { + const normalized = relativePath.replace(/\\/g, "/"); + return normalized === HUNK_DIR_NAME || normalized.startsWith(`${HUNK_DIR_NAME}/`); +} + /** Resolve the base config directory Hunk should use for user-scoped files. */ export function resolveUserConfigDir(env: NodeJS.ProcessEnv = process.env) { if (env.XDG_CONFIG_HOME) { diff --git a/src/core/sl.ts b/src/core/sl.ts index 8df04966..803868ff 100644 --- a/src/core/sl.ts +++ b/src/core/sl.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import { join } from "node:path"; import { HunkUserError } from "./errors"; +import { isHunkMetadataRelativePath } from "./paths"; import type { VcsDiffCommandInput, VcsShowCommandInput } from "./types"; import { normalizePathForOS } from "../lib/osPath"; @@ -256,8 +257,11 @@ export function listSlUntrackedFiles( } const normalizedRepoRoot = repoRoot ?? resolveSlRepoRoot(input, { cwd, slExecutable }); - return untrackedFiles.filter((filePath) => - isReviewableUntrackedPath(normalizedRepoRoot, filePath), + return untrackedFiles.filter( + (filePath) => + // Hunk's own `.hunk/` metadata is review context, never review content. + !isHunkMetadataRelativePath(filePath) && + isReviewableUntrackedPath(normalizedRepoRoot, filePath), ); } diff --git a/src/core/types.ts b/src/core/types.ts index 4c6c5c95..7368a279 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -84,6 +84,13 @@ export interface CommonOptions { vcs?: VcsMode; theme?: string; agentContext?: string; + /** Explicit opt-out (`--no-agent-context`): disables sidecar loading and auto-discovery. */ + noAgentContext?: boolean; + /** + * Internal marker: the resolved `agentContext` is the best-effort conventional + * `.hunk/agent-context.json` default, not an explicit user path. + */ + agentContextOptional?: boolean; pager?: boolean; watch?: boolean; excludeUntracked?: boolean; diff --git a/test/pty/notes.test.ts b/test/pty/notes.test.ts index f30957b1..ce3ebe13 100644 --- a/test/pty/notes.test.ts +++ b/test/pty/notes.test.ts @@ -36,25 +36,24 @@ describe("PTY notes", () => { }); try { - const initial = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { + await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000, }); - expect(initial).not.toContain("Adds bonus export."); + const shownByDefault = await session.waitForText(/Adds bonus export\./, { timeout: 5_000 }); + expect(shownByDefault).toContain("Highlights the follow-up addition for review."); await session.press("a"); - const withNotes = await session.waitForText(/Adds bonus export\./, { timeout: 5_000 }); - - expect(withNotes).toContain("Highlights the follow-up addition for review."); - - await session.press("a"); - const withoutNotes = await harness.waitForSnapshot( + const hidden = await harness.waitForSnapshot( session, (text) => !text.includes("Adds bonus export."), 5_000, ); + expect(hidden).not.toContain("Adds bonus export."); - expect(withoutNotes).not.toContain("Adds bonus export."); + await session.press("a"); + const shownAgain = await session.waitForText(/Adds bonus export\./, { timeout: 5_000 }); + expect(shownAgain).toContain("Adds bonus export."); } finally { session.close(); }