Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/agent-context-auto-discovery.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
27 changes: 18 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<table>
<tr>
<td width="60%" align="center">
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
```
Expand All @@ -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 <range>` / `hunk show` auto-load
`<repoRoot>/.hunk/agent-context.<targetId>.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.
Expand Down
21 changes: 21 additions & 0 deletions docs/agent-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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`:
Expand Down Expand Up @@ -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: `<repoRoot>/.hunk/agent-context.<targetId>.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 <path>` > 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
Expand Down
8 changes: 8 additions & 0 deletions skills/hunk-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<targetId>.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 <path>` 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.
Expand Down
35 changes: 35 additions & 0 deletions src/core/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
47 changes: 33 additions & 14 deletions src/core/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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<AgentContext | null> {
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<string, unknown>;

if (!parsed || typeof parsed !== "object") {
Expand All @@ -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<AgentContext | null> {
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,
Expand Down
75 changes: 75 additions & 0 deletions src/core/agentContextPath.test.ts
Original file line number Diff line number Diff line change
@@ -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<Extract<CliInput, { kind: "vcs" }>> = {}): 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([]);
});
});
4 changes: 3 additions & 1 deletion src/core/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -398,6 +399,7 @@ function renderCliHelp() {
" --mode <mode> layout mode: auto, split, stack",
" --watch auto-reload when the current diff input changes",
" --agent-context <path> 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 <columns> tab stop width: 1-16 (default: 4)",
Expand Down
Loading