|
@@ -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 |