diff --git a/.changeset/agent-context-auto-discovery.md b/.changeset/agent-context-auto-discovery.md new file mode 100644 index 000000000..d4cd8fff4 --- /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 597b37fde..57f5c2d91 100644 --- a/.gitignore +++ b/.gitignore @@ -42,8 +42,20 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json tmp .hunk/latest.json .hunk/config.toml +.hunk/agent-context.json +.hunk/review-state.json +.hunk/review-comments.json .pi/ autoresearch.jsonl autoresearch.ideas.md result + +# vscode extension package build + downloaded test editor +editors/vscode/out +editors/vscode/.vscode-test +# `vsce package` output — built on demand, never committed. +editors/vscode/*.vsix +# The end-to-end suite turns the fixture workspace into a real repo for the run. +editors/vscode/fixtures/workspace/.git +editors/vscode/fixtures/workspace/.hunk .video-work/ diff --git a/README.md b/README.md index 644197ea4..6c9ec7cf3 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,17 @@ Hunk is a review-first terminal diff viewer for agent-authored changesets, built [![MIT License](https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge)](LICENSE) - multi-file review stream with sidebar navigation +- per-file viewed progress with keyboard navigation and repo-local persistence - inline AI and agent annotations beside the code - split, stack, and responsive auto layouts - watch mode for auto-reloading file and Git-backed reviews - keyboard, mouse, pager, and Git difftool support +Press `v` to toggle the selected file viewed and `>` / `<` to jump between unviewed files. +Viewed files show a dimmed name and `✓` in the sidebar while the menu bar reports +`viewed n/m`. Repo-backed reviews persist this progress in `.hunk/review-state.json` and +automatically unview a file when its diff changes. +
@@ -44,22 +50,14 @@ brew install hunk > [!NOTE] > If you previously installed hunk via `modem-dev/tap`, be sure to uninstall it first with `brew uninstall modem-dev/tap/hunk`. -Or with [mise](https://mise.jdx.dev) (macOS and Linux): - -```bash -mise use -g hunk -``` - Requirements: +- Node.js 18+ - macOS, Linux, or Windows -- Node.js 18+ for the npm install; Homebrew, mise, and Nix ship a standalone binary - Git recommended for most workflows > Nix users can use the `default` package exported in `flake.nix` instead. See [nix/README.md](./nix/README.md) for details. -> Hunk also ships as a default tool in [Omarchy](https://omarchy.org), installed through mise. - ## Quick start ```bash @@ -144,6 +142,7 @@ tab_width = 4 # tab stops, 1-16 wrap_lines = false menu_bar = true agent_notes = false +agent_context = ".hunk/notes.json" # optional strict path; resolves against the repo root prompt_save_view_preferences = true transparent_background = false ``` @@ -152,6 +151,16 @@ Choose a built-in theme, `auto`, or a custom theme with `theme`. See [docs/themes.md](docs/themes.md) for automatic selection, custom theme tables, syntax scopes, and legacy syntax-table migration. +Bare `hunk diff` / `hunk diff ` / `hunk show` auto-load +`/.hunk/agent-context..json` when that file exists for the +**current review target** (working tree, staged, range expression, or show ref). +The path is best-effort and skipped when absent or malformed; when it loads, +agent notes are shown by default. Bare `.hunk/agent-context.json` is **not** +auto-loaded — use `--agent-context` or config if you still want that path. +Agents write the target-keyed conventional path (see `docs/agent-workflows.md`); +when `hunk review export --json` is available it also reports `agentContextPath`. Use `--no-agent-context` to disable loading, and Hunk keeps its own +`.hunk/` metadata out of untracked review noise. + `exclude_untracked` affects Git/Sapling working-tree `hunk diff` sessions only. `tab_width` controls source-code tab stops and can be overridden with `-x4` or `--tab-width 4`. `prompt_save_view_preferences = false` disables the quit prompt for saving changed view preferences. diff --git a/docs/agent-workflows.md b/docs/agent-workflows.md index 4dbcfa842..b0894a8e8 100644 --- a/docs/agent-workflows.md +++ b/docs/agent-workflows.md @@ -50,6 +50,7 @@ hunk session review --repo . --json - `list` shows the active Hunk windows - `get --repo .` confirms which live session matches the current repo - `review --json` returns the loaded file and hunk structure without dumping the full raw patch +- JSON session snapshots include `viewedFileCount` and `viewedFilePaths` for review progress Only add `--include-patch` when an agent truly needs raw unified diff text: @@ -79,6 +80,18 @@ Notes: - `--hunk` is 1-based - `--next-comment` and `--prev-comment` are handy when an agent is walking the user through existing notes +### Track viewed files + +Mark a file viewed after covering it, or clear the mark when it needs another pass: + +```bash +hunk session viewed --repo . --file src/App.tsx +hunk session viewed --repo . --file src/App.tsx --unset +``` + +The command updates the live window's sidebar and `viewed n/m` progress. Agents can read +`viewedFileCount` and `viewedFilePaths` from JSON session snapshots to choose the next file. + ### Add comments For one note, use `comment add`: @@ -132,6 +145,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 write notes to the **target-keyed** conventional path for that review: `/.hunk/agent-context..json`. Write the target-keyed conventional path for this review; prefer `agentContextPath` from `hunk review export --json` when that command is available. Do not hard-code bare `.hunk/agent-context.json` for auto-discovery. Bare `hunk diff`, `hunk show`, and range reviews auto-load only the keyed file for **that** target when it exists; watch-mode reloads the same path. + +Precedence is `--no-agent-context` > `--agent-context ` > config `agent_context` > keyed conventional path. Config and explicit paths are strict opt-ins (and may still name a legacy bare file). The conventional keyed sidecar is best-effort and silently skipped when absent or malformed. + +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 222d30ea3..382eec94f 100644 --- a/skills/hunk-review/SKILL.md +++ b/skills/hunk-review/SKILL.md @@ -148,6 +148,14 @@ Before writing markup, run `hunk markup guide` once — it has copy-paste patter hunk session reload --repo . -- diff --exclude-untracked ``` +## Agent context sidecars + +At the end of a meaningful changeset, write notes to the **target-keyed** conventional path so bare `hunk diff` / range / show auto-load them with zero flags. + +Write `.hunk/agent-context..json` for the current review target (same args as the review command). When `hunk review export --json` is available, prefer its `agentContextPath` field. Never hard-code bare `.hunk/agent-context.json` for auto-discovery. + +Use range-based annotations with `oldRange` / `newRange`; the file order in the sidecar drives sidebar and review order. Explicit `--agent-context ` still loads any path, including a legacy bare name. + ## 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 5b1e807e7..b94ae98c2 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 517ded911..5ea082179 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. */ @@ -83,20 +88,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") { @@ -112,6 +105,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/agentContextPath.test.ts b/src/core/agentContextPath.test.ts new file mode 100644 index 000000000..a80aa4e00 --- /dev/null +++ b/src/core/agentContextPath.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import type { CliInput } from "./types"; +import { + agentContextTargetId, + canonicalizeAgentContextTarget, + conventionalAgentContextPath, + HUNK_DIR_NAME, + normalizeAgentContextPathspecs, +} from "./paths"; + +function vcs(overrides: Partial> = {}): CliInput { + return { + kind: "vcs", + staged: false, + options: {}, + ...overrides, + }; +} + +describe("agent-context target identity", () => { + test("same inputs produce the same target id", () => { + const left = agentContextTargetId(vcs({ range: "main...HEAD" })); + const right = agentContextTargetId(vcs({ range: "main...HEAD" })); + expect(left).toBe(right); + expect(left).toMatch(/^[0-9a-f]{12}$/); + }); + + test("working-tree id differs from a range id", () => { + const workingTree = agentContextTargetId(vcs()); + const range = agentContextTargetId(vcs({ range: "main...HEAD" })); + expect(workingTree).not.toBe(range); + }); + + test("staged differs from working-tree", () => { + expect(agentContextTargetId(vcs({ staged: true }))).not.toBe(agentContextTargetId(vcs())); + }); + + test("pathspec order does not change the id", () => { + const a = agentContextTargetId(vcs({ pathspecs: ["src/b.ts", "src/a.ts"] })); + const b = agentContextTargetId(vcs({ pathspecs: ["src/a.ts", "src/b.ts"] })); + expect(a).toBe(b); + }); + + test("show and stash-show ids differ from working-tree", () => { + const show: CliInput = { kind: "show", ref: "HEAD", options: {} }; + const stash: CliInput = { kind: "stash-show", ref: "stash@{0}", options: {} }; + const workingTree = agentContextTargetId(vcs()); + expect(agentContextTargetId(show)).not.toBe(workingTree); + expect(agentContextTargetId(stash)).not.toBe(workingTree); + expect(agentContextTargetId(show)).not.toBe(agentContextTargetId(stash)); + }); + + test("file and patch inputs have no auto-discovery id", () => { + expect( + agentContextTargetId({ kind: "diff", left: "a.ts", right: "b.ts", options: {} }), + ).toBeNull(); + expect(agentContextTargetId({ kind: "patch", file: "p.patch", options: {} })).toBeNull(); + expect(canonicalizeAgentContextTarget({ kind: "patch", options: {} })).toBeNull(); + }); + + test("conventional path embeds the target id under .hunk/", () => { + const input = vcs({ range: "main...HEAD" }); + const id = agentContextTargetId(input); + const path = conventionalAgentContextPath("/repo", input); + expect(path).toBe(join("/repo", HUNK_DIR_NAME, `agent-context.${id}.json`)); + expect(path).not.toContain("agent-context.json"); + expect(path?.endsWith(`agent-context.${id}.json`)).toBe(true); + }); + + test("normalizeAgentContextPathspecs trims empties and sorts", () => { + expect(normalizeAgentContextPathspecs([" b ", "", "a"])).toEqual(["a", "b"]); + expect(normalizeAgentContextPathspecs(undefined)).toEqual([]); + }); +}); diff --git a/src/core/cli.ts b/src/core/cli.ts index 24347c8ef..cf6f05fe8 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -272,7 +272,8 @@ function buildCommonOptions( mode: options.mode, cursorLine: options.cursorLine, 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, experimental: @@ -398,6 +399,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", " --line-numbers / --no-line-numbers show or hide line numbers", " -x, --tab-width tab stop width: 1-16 (default: 4)", diff --git a/src/core/config.test.ts b/src/core/config.test.ts index 2dc1f6a2d..2fdde632e 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, readFileSync, 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 { diffPersistedViewPreferences, @@ -48,6 +48,14 @@ function createPatchPagerInput(overrides: Partial = {}): Cl }; } +function createVcsInput(overrides: Partial = {}): CliInput { + return { + kind: "vcs", + staged: false, + options: overrides, + }; +} + afterEach(() => { cleanupTempDirs(); }); @@ -159,6 +167,232 @@ describe("config persistence", () => { }); describe("config resolution", () => { + test("auto-discovers the target-keyed 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 }, + }); + + const path = resolved.input.options.agentContext; + expect(path).toMatch( + new RegExp( + `${repo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/\\.hunk/agent-context\\.[0-9a-f]{12}\\.json$`, + ), + ); + expect(path).not.toBe(join(repo, ".hunk", "agent-context.json")); + expect(resolved.input.options.agentContextOptional).toBe(true); + }); + + test("conventional discovery for a range does not use the working-tree sidecar path", () => { + const home = createTempDir("hunk-config-home-"); + const repo = createTempDir("hunk-config-repo-"); + createRepo(repo); + + const workingTree = resolveConfiguredCliInput(createVcsInput(), { + cwd: repo, + env: { HOME: home }, + }); + const range = resolveConfiguredCliInput( + { kind: "vcs", staged: false, range: "main...HEAD", options: {} }, + { cwd: repo, env: { HOME: home } }, + ); + + expect(workingTree.input.options.agentContext).not.toBe(range.input.options.agentContext); + expect(range.input.options.agentContext).toContain("agent-context."); + expect(range.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(first.input.options.agentContext); + expect(second.input.options.agentContext).toMatch(/agent-context\.[0-9a-f]{12}\.json$/); + expect(second.input.options.agentContextOptional).toBe(true); + }); + + test("does not point conventional discovery at bare agent-context.json", () => { + const home = createTempDir("hunk-config-home-"); + const repo = createTempDir("hunk-config-repo-"); + createRepo(repo); + mkdirSync(join(repo, ".hunk"), { recursive: true }); + writeFileSync(join(repo, ".hunk", "agent-context.json"), "{}\n"); + + const resolved = resolveConfiguredCliInput(createVcsInput(), { + cwd: repo, + env: { HOME: home }, + }); + expect(resolved.input.options.agentContext).not.toBe(join(repo, ".hunk", "agent-context.json")); + expect(resolved.input.options.agentContext).toMatch(/agent-context\.[0-9a-f]{12}\.json$/); + }); + + test("optional load of the resolved keyed path succeeds when the file exists", async () => { + const { loadAgentContext } = await import("./agent"); + const home = createTempDir("hunk-config-home-"); + const repo = createTempDir("hunk-config-repo-"); + createRepo(repo); + + const resolved = resolveConfiguredCliInput(createVcsInput(), { + cwd: repo, + env: { HOME: home }, + }); + const keyedPath = resolved.input.options.agentContext; + expect(typeof keyedPath).toBe("string"); + mkdirSync(join(repo, ".hunk"), { recursive: true }); + writeFileSync( + keyedPath!, + JSON.stringify({ + version: 1, + summary: "keyed notes", + files: [ + { + path: "ghost.ts", + annotations: [{ summary: "note on a missing file", newRange: [1, 1] }], + }, + ], + }), + ); + + const context = await loadAgentContext(keyedPath, { optional: true }); + expect(context?.summary).toBe("keyed notes"); + expect( + await loadAgentContext(join(repo, ".hunk", "agent-context.deadbeefcafe.json"), { + optional: true, + }), + ).toBeNull(); + }); + 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-"); @@ -1015,7 +1249,7 @@ describe("config resolution", () => { kind: "diff", left: before, right: after, - options: {}, + options: { noAgentContext: true }, }, { cwd: repo, env: { HOME: home } }, ); @@ -1062,7 +1296,7 @@ describe("config resolution", () => { kind: "diff", left: before, right: after, - options: {}, + options: { noAgentContext: true }, }, { cwd: repo, env: { HOME: home } }, ); @@ -1098,7 +1332,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 2f09f31ee..dfde57cb9 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -13,7 +13,7 @@ import { resolveThemeBase, } from "./customThemes"; import { LEGACY_CUSTOM_SYNTAX_COLOR_KEYS, resolveSyntaxScopeOverrides } from "./legacySyntaxScopes"; -import { resolveGlobalConfigPath } from "./paths"; +import { conventionalAgentContextPath, HUNK_DIR_NAME, resolveGlobalConfigPath } from "./paths"; import { LEGACY_CUSTOM_SYNTAX_NOTICES, type StartupNotice } from "./startupNotice"; import { DEFAULT_TAB_WIDTH, validateTabWidth } from "./tabWidth"; import { detectVcs, findVcsRepoRootCandidate, getDefaultVcsAdapter } from "./vcs"; @@ -245,6 +245,16 @@ export const CONFIG_REFERENCE_OPTIONS: readonly ConfigReferenceOption[] = [ runtimeDefault: DEFAULT_THEME_ID, description: "Select the active color theme.", }, + { + key: "agent_context", + property: "agentContext", + type: "string", + accepted: "a path to an agent-context JSON sidecar", + defaultValue: + "`.hunk/agent-context..json` for the current review target when present", + description: + "Point at an agent-rationale sidecar. Relative paths resolve against the repo root. A configured path is a strict opt-in that outranks auto-discovery of the target-keyed conventional file. Bare `.hunk/agent-context.json` is never auto-loaded; pass it here or via `--agent-context` if you still want that path.", + }, { key: "watch", property: "watch", @@ -824,6 +834,7 @@ function normalizeConfigReferenceValue(property: keyof CommonOptions, value: unk case "vcs": return normalizeVcsMode(value); case "theme": + case "agentContext": return normalizeString(value); case "tabWidth": return normalizeTabWidth(value); @@ -876,6 +887,7 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti vcs: overrides.vcs ?? base.vcs, theme: overrides.theme ?? base.theme, agentContext: overrides.agentContext ?? base.agentContext, + agentContextOptional: overrides.agentContextOptional ?? base.agentContextOptional, pager: overrides.pager ?? base.pager, watch: overrides.watch ?? base.watch, experimental: overrides.experimental ?? base.experimental, @@ -1034,7 +1046,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 resolvedCustomThemes: NamedCustomThemeConfig[] = []; let usesLegacyCustomSyntax = false; @@ -1046,7 +1058,11 @@ export function resolveConfiguredCliInput( let resolvedOptions: CommonOptions = { ...buildDefaultConfigPreferences(cwd), - agentContext: input.options.agentContext, + // Do not seed from CLI agentContext here: re-resolution would treat a conventional + // path as an explicit strict path. Seeding stays empty until the resolution block below. + agentContext: undefined, + // Leave agentNotes unresolved so loadAppBootstrap can default from whether a sidecar loads. + agentNotes: undefined, pager: input.options.pager ?? false, experimental: false, ...(input.options.pager ? { menuBar: false } : {}), @@ -1085,10 +1101,42 @@ export function resolveConfiguredCliInput( } explicitVcsId = input.options.vcs ?? explicitVcsId; + + // 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 = join(repoRoot ?? cwd, configAgentContext); + } else if (repoRoot) { + // Keyed conventional path only — bare agent-context.json is never auto-discovered + // (modem-dev/hunk#540). Watch tracks create/rewrite/delete of this file. + const keyedPath = conventionalAgentContextPath(repoRoot, input); + if (keyedPath) { + resolvedAgentContext = keyedPath; + 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, experimental: input.options.experimental ?? false, @@ -1101,7 +1149,10 @@ 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. + agentNotes: resolvedOptions.agentNotes, copyDecorations: resolvedOptions.copyDecorations ?? DEFAULT_VIEW_PREFERENCES.copyDecorations, promptSaveViewPreferences: resolvedOptions.promptSaveViewPreferences ?? true, transparentBackground: resolvedOptions.transparentBackground ?? false, diff --git a/src/core/paths.ts b/src/core/paths.ts index ce52be544..f531f35ef 100644 --- a/src/core/paths.ts +++ b/src/core/paths.ts @@ -1,5 +1,37 @@ +import { createHash } from "node:crypto"; import fs from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; +import type { CliInput } from "./types"; + +/** Name of hunk's repo-local metadata directory. */ +export const HUNK_DIR_NAME = ".hunk"; +/** + * Legacy bare agent-context filename. + * + * Still valid as an *explicit* or config path. Auto-discovery never uses this + * name alone — see `conventionalAgentContextPath` and SPEC REQ-AGENT-001. + */ +export const AGENT_CONTEXT_FILENAME = "agent-context.json"; +/** Hex length of the review-target id embedded in the conventional sidecar name. */ +export const AGENT_CONTEXT_TARGET_ID_LENGTH = 12; +/** Conventional per-repo review-state filename inside `.hunk/`. */ +export const REVIEW_STATE_FILENAME = "review-state.json"; +/** + * Conventional per-repo review-comment filename inside `.hunk/`. + * + * Separate from `REVIEW_STATE_FILENAME` on purpose: viewed state is derived and resets on + * any doubt, while comments are authored and are never discarded. One file cannot carry + * both policies. + */ +export const REVIEW_COMMENTS_FILENAME = "review-comments.json"; +/** + * Conventional per-repo review-focus filename inside `.hunk/`. + * + * Where an agent points its human partner. Derived like `REVIEW_STATE_FILENAME` and unlike + * `REVIEW_COMMENTS_FILENAME`: a corrupt focus resets to none, because nothing here is + * authored — it says what to look at, never what is true about the code. + */ +export const REVIEW_FOCUS_FILENAME = "review-focus.json"; /** * Skills Hunk ships, in the order `hunk skill path` lists them. @@ -72,6 +104,96 @@ export function resolveCanonicalPath(path: string) { } } +/** 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}/`); +} + +/** + * Normalize pathspecs the way discovery hashes them: trim, drop empties, sort. + * + * Sorting is required so `-- path/a path/b` and `-- path/b path/a` name one sidecar. + */ +export function normalizeAgentContextPathspecs(pathspecs: readonly string[] | undefined): string[] { + if (!pathspecs || pathspecs.length === 0) { + return []; + } + + return [...pathspecs] + .map((pathspec) => pathspec.trim()) + .filter((pathspec) => pathspec.length > 0) + .sort(); +} + +/** + * Canonical string for the review target a CLI invocation asked for. + * + * Intent only — kind, expression/ref, and pathspecs — never resolved commit SHAs and never + * patch bytes. Same string for the same human command; branch tips may move without + * renaming the sidecar (matching review-focus target policy). + * + * Returns null for non-repo inputs (file/patch/difftool): those never auto-discover. + */ +export function canonicalizeAgentContextTarget(input: CliInput): string | null { + const pathspecs = normalizeAgentContextPathspecs( + "pathspecs" in input ? input.pathspecs : undefined, + ); + const pathspecKey = pathspecs.join("\0"); + + if (input.kind === "vcs") { + if (input.staged) { + return ["staged", pathspecKey].join("\0"); + } + if (input.range !== undefined && input.range.length > 0) { + return ["range", input.range, pathspecKey].join("\0"); + } + return ["working-tree", pathspecKey].join("\0"); + } + + if (input.kind === "show") { + return ["show", input.ref ?? "", pathspecKey].join("\0"); + } + + if (input.kind === "stash-show") { + return ["stash-show", input.ref ?? ""].join("\0"); + } + + return null; +} + +/** + * Short stable id for one review target, embedded in the conventional sidecar filename. + * + * Users never type this; Hunk derives it from the same CLI args that open the review. + */ +export function agentContextTargetId(input: CliInput): string | null { + const canonical = canonicalizeAgentContextTarget(input); + if (canonical === null) { + return null; + } + + return createHash("sha256") + .update(canonical) + .digest("hex") + .slice(0, AGENT_CONTEXT_TARGET_ID_LENGTH); +} + +/** + * Absolute path of the conventional auto-discovery sidecar for this review target. + * + * Form: `/.hunk/agent-context..json`. Null when the input is not a + * repo-backed review target (no auto-discovery). + */ +export function conventionalAgentContextPath(repoRoot: string, input: CliInput): string | null { + const targetId = agentContextTargetId(input); + if (targetId === null) { + return null; + } + + return join(repoRoot, HUNK_DIR_NAME, `agent-context.${targetId}.json`); +} + /** 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/types.ts b/src/core/types.ts index 8ebcd95de..f27c470de 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -91,6 +91,14 @@ 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` path for this review target, not an + * explicit user or config path. + */ + agentContextOptional?: boolean; pager?: boolean; watch?: boolean; /** Enable launch-scoped experimental review features. */ diff --git a/src/core/vcs/git.test.ts b/src/core/vcs/git.test.ts index 837b82c33..09edb131c 100644 --- a/src/core/vcs/git.test.ts +++ b/src/core/vcs/git.test.ts @@ -8,6 +8,7 @@ import { buildGitStashShowArgs, buildGitStatusArgs, listGitIgnoredDirectoryRoots, + listGitUntrackedFiles, parseGitIgnoredDirectoryRoots, resolveGitDiffEndpoints, parseGitNumstat, @@ -139,6 +140,28 @@ describe("git command helpers", () => { ).toEqual([resolve(repoRoot, "dependencies"), resolve(repoRoot, "build/nested")]); }); + 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/vcs/git.ts b/src/core/vcs/git.ts index 38f2fcc88..63138551c 100644 --- a/src/core/vcs/git.ts +++ b/src/core/vcs/git.ts @@ -8,6 +8,7 @@ import { } from "../../extension-api/types"; import { LARGE_DIFF_FILE_MAX_BYTES, LARGE_DIFF_FILE_MAX_LINES } from "./largeFile"; import { escapeUntrackedPatchPath } from "../patch/normalize"; +import { isHunkMetadataRelativePath } from "../paths"; import { normalizePathForOS } from "../../lib/osPath"; /** @@ -712,8 +713,11 @@ export function listGitUntrackedFiles( const normalizedRepoRoot = repoRoot ?? resolveGitRepoRoot(input, { cwd, gitExecutable, preventOptionalLocks }); - 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/vcs/sapling.ts b/src/core/vcs/sapling.ts index a5c26a609..d48e5aaf4 100644 --- a/src/core/vcs/sapling.ts +++ b/src/core/vcs/sapling.ts @@ -6,6 +6,7 @@ import { type ExtensionVcsShowInput, } from "../../extension-api/types"; import { normalizePathForOS } from "../../lib/osPath"; +import { isHunkMetadataRelativePath } from "../paths"; export type SlBackedInput = ExtensionVcsDiffInput | ExtensionVcsShowInput; @@ -263,8 +264,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/hunk-review/skillDocument.ts b/src/hunk-review/skillDocument.ts index 424b1a835..f37f0efa4 100644 --- a/src/hunk-review/skillDocument.ts +++ b/src/hunk-review/skillDocument.ts @@ -197,6 +197,16 @@ const GUIDING_SECTION = [ "- Don't comment on every hunk -- highlight what the user wouldn't spot themselves", ]; +const AGENT_CONTEXT_SECTION = [ + "## Agent context sidecars", + "", + "At the end of a meaningful changeset, write notes to the **target-keyed** conventional path so bare `hunk diff` / range / show auto-load them with zero flags.", + "", + "Write `.hunk/agent-context..json` for the current review target (same args as the review command). When `hunk review export --json` is available, prefer its `agentContextPath` field. Never hard-code bare `.hunk/agent-context.json` for auto-discovery.", + "", + "Use range-based annotations with `oldRange` / `newRange`; the file order in the sidecar drives sidebar and review order. Explicit `--agent-context ` still loads any path, including a legacy bare name.", +]; + /** Render the "Common errors" section from the shared agent error catalog. */ function commonErrorsSection() { return [ @@ -219,6 +229,7 @@ export function renderHunkReviewSkill() { COMMENTS_SECTION, STML_SECTION, NEW_FILES_SECTION, + AGENT_CONTEXT_SECTION, GUIDING_SECTION, commonErrorsSection(), ]; diff --git a/test/pty/notes.test.ts b/test/pty/notes.test.ts index dc4e0ecb2..8596713d4 100644 --- a/test/pty/notes.test.ts +++ b/test/pty/notes.test.ts @@ -36,26 +36,25 @@ 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."); - - 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."); - expect(withNotes).not.toContain("STML ACTIVE"); + const shownByDefault = await session.waitForText(/Adds bonus export\./, { timeout: 5_000 }); + expect(shownByDefault).toContain("Highlights the follow-up addition for review."); + expect(shownByDefault).not.toContain("STML ACTIVE"); 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(); } @@ -80,7 +79,8 @@ describe("PTY notes", () => { try { await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000 }); - await session.press("a"); + // An explicit --agent-context shows notes at launch, so the markup body is already on + // screen; pressing "a" here would hide it. const withMarkup = await session.waitForText(/STML ACTIVE/, { timeout: 5_000 }); expect(withMarkup).not.toContain("Highlights the follow-up addition for review."); diff --git a/website/public/docs/hunk-review-skill.md b/website/public/docs/hunk-review-skill.md index 222d30ea3..eec249aa9 100644 --- a/website/public/docs/hunk-review-skill.md +++ b/website/public/docs/hunk-review-skill.md @@ -18,9 +18,10 @@ If no session exists, ask the user to launch Hunk in their terminal first. 4. hunk session review --repo . --include-patch --json # opt into raw diff text only when needed 5. hunk session context --repo . # check current focus when needed 6. hunk session navigate ... # move to the right place -7. hunk session reload -- # swap contents if needed -8. hunk session comment add ... # leave one review note -9. hunk session comment apply ... # apply many agent notes in one stdin batch +7. hunk session viewed --repo . --file # mark a file covered +8. hunk session reload -- # swap contents if needed +9. hunk session comment add ... # leave one review note +10. hunk session comment apply ... # apply many agent notes in one stdin batch ``` ## Session selection @@ -80,6 +81,24 @@ hunk session navigate --repo . --prev-comment - `--new-line` / `--old-line` are 1-based line numbers on that diff side - Use either `--next-comment` or `--prev-comment`, not both +### Viewed files + +After covering a file, mark it viewed so the user and other agents can track review progress: + +```bash +hunk session viewed ( | --repo ) --file [--unset] [--json] +``` + +Examples: + +```bash +hunk session viewed --repo . --file src/App.tsx +hunk session viewed --repo . --file src/App.tsx --unset +``` + +Use `--unset` when the file needs another pass. JSON session snapshots expose +`viewedFileCount` and `viewedFilePaths` for choosing the next unviewed file. + ### Reload Swaps the live session's contents. Pass a Hunk review command after `--`: @@ -148,6 +167,14 @@ Before writing markup, run `hunk markup guide` once — it has copy-paste patter hunk session reload --repo . -- diff --exclude-untracked ``` +## Agent context sidecars + +At the end of a meaningful changeset, write notes to the **target-keyed** conventional path so bare `hunk diff` / range / show auto-load them with zero flags. + +Write `.hunk/agent-context..json` for the current review target (same args as the review command). When `hunk review export --json` is available, prefer its `agentContextPath` field. Never hard-code bare `.hunk/agent-context.json` for auto-discovery. + +Use range-based annotations with `oldRange` / `newRange`; the file order in the sidecar drives sidebar and review order. Explicit `--agent-context ` still loads any path, including a legacy bare name. + ## 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/website/src/content/docs/docs/reference/cli.md b/website/src/content/docs/docs/reference/cli.md index 28959b1fc..a0209ad17 100644 --- a/website/src/content/docs/docs/reference/cli.md +++ b/website/src/content/docs/docs/reference/cli.md @@ -22,6 +22,7 @@ This reference is generated from the command metadata used by Hunk itself. Run ` | `--cursor-line