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.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
tmp
.hunk/latest.json
.hunk/config.toml
.hunk/agent-context.json
.pi/
autoresearch.jsonl
autoresearch.ideas.md
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,12 @@ line_numbers = true
wrap_lines = false
menu_bar = true
agent_notes = false
agent_context = ".hunk/agent-context.json" # resolves against the repo root
transparent_background = false
```

Bare `hunk diff` auto-loads `<repoRoot>/.hunk/agent-context.json` when it exists. This conventional sidecar is best-effort and silently skipped when absent or malformed; when it loads, agent notes are shown by default. Use `--no-agent-context` to disable sidecar loading, and Hunk keeps its own `.hunk/` metadata out of untracked review noise.

`theme = "auto"` and `--theme auto` query the terminal background at startup, choose `github-light-default` for light backgrounds and `github-dark-default` for dark backgrounds, and fall back to `github-dark-default` if the terminal does not answer.
Older theme ids such as `graphite` and `paper` remain accepted as compatibility aliases.
`exclude_untracked` affects Git/Sapling working-tree `hunk diff` sessions only.
Expand Down
8 changes: 8 additions & 0 deletions docs/agent-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,14 @@ For normal worktree use, prefer `--repo /path/to/worktree`. Reach for `--session

Use `--agent-context` when you already have agent-written rationale or notes in a JSON sidecar file and want to render them beside the diff.

### Auto-discovery

At the end of a meaningful changeset, agents can write or refresh `<repoRoot>/.hunk/agent-context.json`. The next `hunk diff`, `hunk show`, `hunk stash show`, `hunk patch`, or `hunk difftool` auto-loads that file with no flags when it exists; `hunk diff --watch` also refreshes notes when the file is rewritten.

Precedence is `--no-agent-context` > `--agent-context <path>` > config `agent_context` > conventional `.hunk/agent-context.json`. `agent_context = "<path>"` resolves against the repo root, and `--no-agent-context` disables sidecar loading and auto-discovery entirely. The conventional sidecar is best-effort and silently skipped when absent or malformed; explicit or configured paths still fail loudly.

The sidecar schema is range-based: annotations use 1-based inclusive `oldRange` / `newRange` tuples, not single `oldLine` / `newLine` fields.

```bash
hunk diff --agent-context notes.json
hunk patch change.patch --agent-context notes.json
Expand Down
4 changes: 4 additions & 0 deletions skills/hunk-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ hunk session comment clear --repo . --yes [--file README.md]
hunk session reload --repo . -- diff --exclude-untracked
```

## Agent context sidecars

At the end of a meaningful changeset, write or refresh `.hunk/agent-context.json` in the repo root so Hunk can auto-load it with zero flags. Use range-based annotations with `oldRange` / `newRange`; the file order in the sidecar drives sidebar and review order.

## Guiding a review

The user may ask you to walk them through a changeset or review code using Hunk. Start with `hunk session review --json` to understand the file/hunk structure without inflating agent context, then use `--include-patch` only for the files you truly need to read in raw diff form. Use `context` and `navigate` to line up the user's current view before adding comments.
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 @@ -82,20 +87,8 @@ function normalizeAnnotationFile(file: unknown): AgentFileContext {
};
}

/** Load the optional agent-context sidecar from a file path or stdin. */
export async function loadAgentContext(
pathOrDash?: string,
{ cwd = process.cwd() }: AgentContextLoadOptions = {},
): Promise<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 @@ -111,6 +104,32 @@ export async function loadAgentContext(
};
}

/** Load the optional agent-context sidecar from a file path or stdin. */
export async function loadAgentContext(
pathOrDash?: string,
{ cwd = process.cwd(), optional = false }: AgentContextLoadOptions = {},
): Promise<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
31 changes: 29 additions & 2 deletions src/core/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ describe("parseCli", () => {
expect(parsed.text).toContain("Global options:");
expect(parsed.text).toContain("Common review options:");
expect(parsed.text).toContain("auto-reload when the current diff input changes");
expect(parsed.text).toContain("ignore any agent-context sidecar");
expect(parsed.text).toContain("Git diff options:");
expect(parsed.text).toContain("Notes:");
expect(parsed.text).toContain(
Expand Down Expand Up @@ -141,6 +142,29 @@ describe("parseCli", () => {
});
});

test("parses agent-context opt-out without leaking commander's boolean sentinel", async () => {
const parsed = await parseCli(["bun", "hunk", "diff", "--no-agent-context"]);

expect(parsed.kind).toBe("vcs");
if (parsed.kind !== "vcs") {
throw new Error("Expected vcs diff input.");
}

expect(parsed.options.noAgentContext).toBe(true);
expect(parsed.options.agentContext).toBeUndefined();
});

test("leaves agent-context opt-out unset without the flag", async () => {
const parsed = await parseCli(["bun", "hunk", "diff"]);

expect(parsed.kind).toBe("vcs");
if (parsed.kind !== "vcs") {
throw new Error("Expected vcs diff input.");
}

expect(parsed.options.noAgentContext).toBeUndefined();
});

test("parses staged git-style diff aliases", async () => {
const staged = await parseCli(["bun", "hunk", "diff", "--staged"]);
const cached = await parseCli(["bun", "hunk", "diff", "--cached"]);
Expand Down Expand Up @@ -810,20 +834,23 @@ describe("parseCli", () => {
});

test("parses session navigate with --next-comment", async () => {
const repoRoot = realpathSync.native(createTempDir("hunk-cli-navigate-repo-"));
mkdirSync(join(repoRoot, ".git"));

const parsed = await parseCli([
"bun",
"hunk",
"session",
"navigate",
"--repo",
"/tmp/repo",
repoRoot,
"--next-comment",
]);

expect(parsed).toEqual({
kind: "session",
action: "navigate",
selector: { repoRoot: resolve("/tmp/repo") },
selector: { repoRoot },
commentDirection: "next",
output: "text",
});
Expand Down
7 changes: 5 additions & 2 deletions src/core/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ function buildCommonOptions(
options: {
mode?: LayoutMode;
theme?: string;
agentContext?: string;
agentContext?: unknown;
pager?: boolean;
watch?: boolean;
transparentBackground?: boolean;
Expand All @@ -67,7 +67,8 @@ function buildCommonOptions(
return {
mode: options.mode,
theme: options.theme,
agentContext: options.agentContext,
agentContext: typeof options.agentContext === "string" ? options.agentContext : undefined,
noAgentContext: argv.includes("--no-agent-context") ? true : undefined,
pager: options.pager ? true : undefined,
watch: options.watch ? true : undefined,
excludeUntracked: resolveBooleanFlag(argv, "--exclude-untracked", "--no-exclude-untracked"),
Expand All @@ -85,6 +86,7 @@ function applyCommonOptions(command: Command) {
.option("--mode <mode>", "layout mode: auto, split, stack", parseLayoutMode)
.option("--theme <theme>", "named theme override")
.option("--agent-context <path>", "JSON sidecar with agent rationale")
.option("--no-agent-context", "ignore any agent-context sidecar (disable auto-discovery)")
.option("--pager", "use pager-style chrome and controls")
.option("--line-numbers", "show line numbers")
.option("--no-line-numbers", "hide line numbers")
Expand Down Expand Up @@ -152,6 +154,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 and controls",
" --line-numbers / --no-line-numbers show or hide line numbers",
" --wrap / --no-wrap wrap or truncate long diff lines",
Expand Down
Loading