Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/legal-glasses-guess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Require reviews to opt into experimental STML agent-note rendering with `--experimental`. Normal reviews now use plain-text note fallbacks and reject live STML comments, while opted-in live sessions advertise the `stml` capability to agents.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ A good generic prompt is:
Load the Hunk skill and use it for this review. Run `hunk skill path` to get the skill path.
```

For the full live-session and `--agent-context` workflow guide, see [docs/agent-workflows.md](docs/agent-workflows.md).
For the full live-session and `--agent-context` workflow guide, see [docs/agent-workflows.md](docs/agent-workflows.md). Experimental rich STML note bodies require starting the review with `--experimental`; plain agent notes remain the default.

## Feature comparison

Expand Down
11 changes: 11 additions & 0 deletions docs/agent-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,17 @@ hunk patch change.patch --agent-context notes.json

For a compact real example, see [`examples/3-agent-review-demo/agent-context.json`](../examples/3-agent-review-demo/agent-context.json).

## Opt into experimental rich notes

STML note bodies are experimental and disabled by default. Start a new review with `--experimental` to render sidecar `markup` fields and accept live comments that carry markup:

```bash
hunk --experimental diff --agent-context notes.json
# Equivalent: hunk diff --experimental --agent-context notes.json
```

Normal reviews keep using each annotation's required plain-text `summary` fallback. Opted-in live sessions list `stml` in `hunk session context --json` under `experimentalFeatures`; reload commands cannot change the launch opt-in.

## Practical defaults

- start with `hunk session review --repo . --json`
Expand Down
10 changes: 6 additions & 4 deletions skills/hunk-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ hunk session reload --session-path /path/to/live-window --source /path/to/other-
### Comments

```bash
hunk session comment add --repo . --file README.md --new-line 103 --summary "Tighten this wording" [--rationale "..."] [--markup "<stml>"] [--author "agent"] [--focus]
hunk session comment add --repo . --file README.md --new-line 103 --summary "Tighten this wording" [--rationale "..."] [--author "agent"] [--focus]
printf '%s\n' '{"comments":[{"filePath":"README.md","newLine":103,"summary":"Tighten this wording"}]}' | hunk session comment apply --repo . --stdin [--focus]
hunk session comment list --repo . [--file README.md] [--type live|all|ai|agent|user]
hunk session comment rm --repo . <comment-id>
Expand All @@ -112,11 +112,13 @@ hunk session comment clear --repo . --yes [--file README.md]
- `comment list` and `comment clear` accept optional `--file`
- Quote `--summary` and `--rationale` defensively in the shell

### Rich markup notes (STML)
### Experimental rich markup notes (STML)

`--markup` (or a `markup` field on apply items) renders the note body as STML — a small HTML-like markup for terminal UI (boxes, rows, gauges, badges, lists, code). Keep `--summary` a real sentence: it is the fallback and the `comment list` text.
Only use STML when `hunk session context --json` lists `stml` in `experimentalFeatures`. The user opts into that experience by launching the review with `--experimental`; do not ask a normal session to render markup.

Before writing markup, run `hunk markup guide` once — it has copy-paste patterns and the width rules. `hunk session context --json` reports `noteMarkupWidth` (the live render width); preview with `hunk markup render - --width <that>`. Comment responses echo `markupWidth` and return `markupNotes` when markup degraded — fix what they flag.
For an opted-in session, `--markup` (or a `markup` field on apply items) renders the note body as STML — a small HTML-like markup for terminal UI (boxes, rows, gauges, badges, lists, code). Keep `--summary` a real sentence: it is the fallback and the `comment list` text.

Before writing markup, run `hunk markup guide` once — it has copy-paste patterns and the width rules. The session context also reports `noteMarkupWidth` (the live render width); preview with `hunk markup render - --width <that>`. Comment responses echo `markupWidth` and return `markupNotes` when markup degraded — fix what they flag.

## New files in working-tree reviews

Expand Down
19 changes: 19 additions & 0 deletions src/core/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ 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("--experimental");
expect(parsed.text).toContain("experimental STML");
expect(parsed.text).toContain("Git diff options:");
expect(parsed.text).toContain("Notes:");
expect(parsed.text).toContain(
Expand Down Expand Up @@ -104,6 +106,7 @@ describe("parseCli", () => {
"--agent-notes",
"--transparent-bg",
"--watch",
"--experimental",
]);

expect(parsed).toMatchObject({
Expand All @@ -115,6 +118,7 @@ describe("parseCli", () => {
theme: "github-light-default",
agentContext: "notes.json",
watch: true,
experimental: true,
lineNumbers: false,
tabWidth: 4,
wrapLines: true,
Expand All @@ -125,6 +129,15 @@ describe("parseCli", () => {
});
});

test("accepts --experimental before the review command", async () => {
const parsed = await parseCli(["bun", "hunk", "--experimental", "diff"]);

expect(parsed).toMatchObject({
kind: "vcs",
options: { experimental: true },
});
});

test("parses transparent background toggles", async () => {
const transparent = await parseCli(["bun", "hunk", "diff", "--transparent-bg"]);
const opaque = await parseCli(["bun", "hunk", "diff", "--no-transparent-bg"]);
Expand Down Expand Up @@ -1198,6 +1211,12 @@ describe("parseCli argument validation", () => {
).rejects.toThrow("Specify either <session-id> or --repo <path>, not both.");
});

test("rejects --experimental for non-review commands", async () => {
await expect(parseCli(["bun", "hunk", "--experimental", "session", "list"])).rejects.toThrow(
"must be used with a Hunk review command",
);
});

test("rejects unknown top-level, skill, daemon, stash, and comment subcommands", async () => {
await expect(parseCli(["bun", "hunk", "skill", "bogus"])).rejects.toThrow(
"Only `hunk skill path` is supported.",
Expand Down
25 changes: 20 additions & 5 deletions src/core/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ function buildCommonOptions(
agentContext?: string;
pager?: boolean;
watch?: boolean;
experimental?: boolean;
transparentBackground?: boolean;
tabWidth?: number;
},
Expand All @@ -76,6 +77,7 @@ function buildCommonOptions(
agentContext: options.agentContext,
pager: options.pager ? true : undefined,
watch: options.watch ? true : undefined,
experimental: options.experimental || argv[2] === "--experimental" ? true : undefined,
excludeUntracked: resolveBooleanFlag(argv, "--exclude-untracked", "--no-exclude-untracked"),
lineNumbers: resolveBooleanFlag(argv, "--line-numbers", "--no-line-numbers"),
tabWidth: options.tabWidth,
Expand All @@ -93,6 +95,7 @@ function applyCommonOptions(command: Command) {
.option("--theme <theme>", "named theme override")
.option("--agent-context <path>", "JSON sidecar with agent rationale")
.option("--pager", "use pager-style chrome and controls")
.option("--experimental", "enable experimental features (currently STML agent-note markup)")
.option("--line-numbers", "show line numbers")
.option("--no-line-numbers", "hide line numbers")
.option("-x, --tab-width <columns>", "tab stop width: 1-16", parseTabWidth)
Expand Down Expand Up @@ -149,14 +152,15 @@ function renderCliHelp() {
" hunk pager general Git pager wrapper with diff detection",
" hunk difftool <left> <right> [path] review Git difftool file pairs",
" hunk session <subcommand> inspect or control a live Hunk session",
" hunk markup render (<file> | -) preview STML note markup as terminal text",
" hunk markup guide print the STML authoring guide for agents",
" hunk markup render (<file> | -) preview experimental STML note markup",
" hunk markup guide print the experimental STML authoring guide",
" hunk skill path print the bundled Hunk review skill path",
" hunk daemon serve run the local Hunk session daemon",
"",
"Global options:",
" -h, --help show help",
" -v, --version show version",
" --experimental enable experimental review features (currently STML)",
"",
"Common review options:",
" --mode <mode> layout mode: auto, split, stack",
Expand Down Expand Up @@ -937,7 +941,7 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
.option("--old-line <n>", "1-based line number on the old side", parsePositiveInt)
.option("--new-line <n>", "1-based line number on the new side", parsePositiveInt)
.option("--rationale <text>", "optional longer explanation")
.option("--markup <stml>", "optional STML markup rendered as the note body")
.option("--markup <stml>", "experimental STML body (target session must opt in)")
.option("--author <name>", "optional author label")
.option("--focus", "add the note and focus the viewport on it")
.option("--json", "emit structured JSON");
Expand Down Expand Up @@ -1243,6 +1247,8 @@ const MARKUP_HELP = [
" hunk markup render (<file> | -) [--width <n>] [--color <auto|always|never>] [--theme <id>] [--json]",
" hunk markup guide",
"",
"Experimental STML authoring tools.",
"",
"render preview STML markup as terminal text without launching the TUI;",
" render notes (unknown tags, layout degradations) go to stderr",
" --width <n> layout width in columns (default 56, the note reference width)",
Expand Down Expand Up @@ -1272,7 +1278,7 @@ async function parseMarkupCommand(tokens: string[]): Promise<ParsedCliInput> {

if (subcommand === "render") {
const command = new Command("markup render")
.description("preview STML markup as terminal text")
.description("preview experimental STML markup as terminal text")
.argument("[file]", "markup file path, or - for stdin", "-")
.option("--width <n>", "layout width in columns", parsePositiveInt)
.option("--color <mode>", "auto, always, or never", "auto")
Expand Down Expand Up @@ -1438,7 +1444,9 @@ async function parseStashCommand(tokens: string[], argv: string[]): Promise<Pars

/** Parse CLI arguments into one normalized input shape for the app loader layer. */
export async function parseCli(argv: string[]): Promise<ParsedCliInput> {
const args = argv.slice(2);
const rawArgs = argv.slice(2);
const prefixedExperimental = rawArgs[0] === "--experimental";
const args = prefixedExperimental ? rawArgs.slice(1) : rawArgs;
const [commandName, ...rest] = args;

if (!commandName || commandName === "help" || commandName === "--help" || commandName === "-h") {
Expand All @@ -1449,6 +1457,13 @@ export async function parseCli(argv: string[]): Promise<ParsedCliInput> {
return { kind: "help", text: renderCliVersion() };
}

if (
prefixedExperimental &&
!["diff", "show", "patch", "pager", "difftool", "stash"].includes(commandName)
) {
throw new Error("`--experimental` must be used with a Hunk review command.");
}

switch (commandName) {
case "diff":
return parseDiffCommand(rest, argv);
Expand Down
16 changes: 16 additions & 0 deletions src/core/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,22 @@ describe("config resolution", () => {
).toThrow('Expected a [custom_theme] table when config selects theme = "custom".');
});

test("requires experimental features to be enabled by the launch CLI", () => {
const home = createTempDir("hunk-config-experimental-");
mkdirSync(join(home, ".config", "hunk"), { recursive: true });
writeFileSync(join(home, ".config", "hunk", "config.toml"), "experimental = true\n");

const normal = resolveConfiguredCliInput(createPatchPagerInput(), {
env: { HOME: home },
});
const optedIn = resolveConfiguredCliInput(createPatchPagerInput({ experimental: true }), {
env: { HOME: home },
});

expect(normal.input.options.experimental).toBe(false);
expect(optedIn.input.options.experimental).toBe(true);
});

test("accepts transparent background config and CLI overrides", () => {
const home = createTempDir("hunk-config-home-");
mkdirSync(join(home, ".config", "hunk"), { recursive: true });
Expand Down
3 changes: 3 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,7 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti
agentContext: overrides.agentContext ?? base.agentContext,
pager: overrides.pager ?? base.pager,
watch: overrides.watch ?? base.watch,
experimental: overrides.experimental ?? base.experimental,
excludeUntracked: overrides.excludeUntracked ?? base.excludeUntracked,
lineNumbers: overrides.lineNumbers ?? base.lineNumbers,
tabWidth: overrides.tabWidth ?? base.tabWidth,
Expand Down Expand Up @@ -525,6 +526,7 @@ export function resolveConfiguredCliInput(
agentContext: input.options.agentContext,
pager: input.options.pager ?? false,
watch: input.options.watch ?? false,
experimental: false,
excludeUntracked: false,
lineNumbers: DEFAULT_VIEW_PREFERENCES.showLineNumbers,
tabWidth: DEFAULT_TAB_WIDTH,
Expand Down Expand Up @@ -559,6 +561,7 @@ export function resolveConfiguredCliInput(
agentContext: input.options.agentContext,
pager: input.options.pager ?? false,
watch: input.options.watch ?? resolvedOptions.watch ?? false,
experimental: input.options.experimental ?? false,
excludeUntracked: resolvedOptions.excludeUntracked ?? false,
theme: resolvedOptions.theme,
vcs: resolvedOptions.vcs ?? getDefaultVcsAdapter().id,
Expand Down
38 changes: 38 additions & 0 deletions src/core/experimental.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test";
import { createTestAgentFileContext, createTestDiffFile } from "../../test/helpers/diff-helpers";
import { resolveExperimentalDiffFiles, resolveExperimentalFeatures } from "./experimental";

describe("experimental review features", () => {
test("normal reviews derive plain-text note fallbacks without mutating sidecar data", () => {
const agent = createTestAgentFileContext("example.ts", {
annotations: [
{
newRange: [1, 1],
summary: "Updated the answer.",
markup: "<badge>42</badge>",
},
],
});
const files = [createTestDiffFile({ agent })];

const resolved = resolveExperimentalDiffFiles(files, {});

expect(resolved[0]?.agent?.annotations[0]?.summary).toBe("Updated the answer.");
expect(resolved[0]?.agent?.annotations[0]?.markup).toBeUndefined();
expect(files[0]?.agent?.annotations[0]?.markup).toBe("<badge>42</badge>");
});

test("experimental reviews preserve STML and advertise the feature", () => {
const files = [
createTestDiffFile({
agent: createTestAgentFileContext("example.ts", {
annotations: [{ summary: "Updated", markup: "<badge>42</badge>" }],
}),
}),
];

expect(resolveExperimentalDiffFiles(files, { experimental: true })).toBe(files);
expect(resolveExperimentalFeatures({ experimental: true })).toEqual(["stml"]);
expect(resolveExperimentalFeatures({})).toEqual([]);
});
});
60 changes: 60 additions & 0 deletions src/core/experimental.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { AgentContext, AgentFileContext, CommonOptions, DiffFile } from "./types";

export const EXPERIMENTAL_FEATURES = ["stml"] as const;
export type ExperimentalFeature = (typeof EXPERIMENTAL_FEATURES)[number];

/** Resolve the experimental features enabled for one review launch. */
export function resolveExperimentalFeatures(
options: Pick<CommonOptions, "experimental">,
): ExperimentalFeature[] {
return options.experimental ? [...EXPERIMENTAL_FEATURES] : [];
}

/** Return whether one experimental feature is enabled for this review launch. */
export function experimentalFeatureEnabled(
options: Pick<CommonOptions, "experimental">,
feature: ExperimentalFeature,
) {
return options.experimental === true && EXPERIMENTAL_FEATURES.includes(feature);
}

/** Remove disabled STML bodies from one file while preserving plain-text fallbacks. */
function resolveExperimentalAgentFileContext(file: AgentFileContext): AgentFileContext {
return {
...file,
annotations: file.annotations.map((annotation) => {
const resolved = { ...annotation };
delete resolved.markup;
return resolved;
}),
};
}

/** Remove disabled STML bodies while preserving their required plain-text fallbacks. */
export function resolveExperimentalAgentContext(
context: AgentContext | null,
options: Pick<CommonOptions, "experimental">,
): AgentContext | null {
if (!context || experimentalFeatureEnabled(options, "stml")) {
return context;
}

return {
...context,
files: context.files.map(resolveExperimentalAgentFileContext),
};
}

/** Derive review files whose annotation bodies match the launch's enabled features. */
export function resolveExperimentalDiffFiles(
files: DiffFile[],
options: Pick<CommonOptions, "experimental">,
): DiffFile[] {
if (experimentalFeatureEnabled(options, "stml")) {
return files;
}

return files.map((file) =>
file.agent ? { ...file, agent: resolveExperimentalAgentFileContext(file.agent) } : file,
);
}
2 changes: 2 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ export interface CommonOptions {
agentContext?: string;
pager?: boolean;
watch?: boolean;
/** Enable launch-scoped experimental review features. */
experimental?: boolean;
excludeUntracked?: boolean;
lineNumbers?: boolean;
tabWidth?: number;
Expand Down
17 changes: 16 additions & 1 deletion src/hunk-session/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,13 +355,28 @@ describe("Hunk session CLI formatters", () => {
"Old range: -",
"New range: -",
"Agent notes visible: yes",
"Note markup width: -",
"Live comments: 2",
"",
].join("\n"),
);
});

test("context output exposes STML geometry only for opted-in sessions", () => {
const normal = createTestSelectedSessionContext({
experimentalFeatures: [],
noteMarkupWidth: undefined,
});
const experimental = createTestSelectedSessionContext({
experimentalFeatures: ["stml"],
noteMarkupWidth: 72,
});

expect(formatContextOutput(normal)).not.toContain("markup");
expect(formatContextOutput(normal)).not.toContain("Experimental features");
expect(formatContextOutput(experimental)).toContain("Experimental features: stml\n");
expect(formatContextOutput(experimental)).toContain("Note markup width: 72\n");
});

test("review output keeps file order, hunk headers, and no-selection fallbacks", () => {
const firstFile = createTestSessionReviewFile({
id: "file-1",
Expand Down
Loading