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
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,12 @@ User annotates content, provides feedback
Send Annotations → feedback sent to agent session
```

### Strict direct annotate results

Direct `plannotator annotate` invocations may add `--require-approval` and/or `--result-file <path>` only with `--gate --json`; both reject `--hook` and are not shared with OpenCode/Pi slash-command parsing. Legacy plaintext, JSON, hook, and exit behavior remains unchanged when neither strict option is present.

Strict decisions use one newline-terminated JSON record on stdout and, when requested, identical bytes in the result file. Exit codes follow the grep convention: approval exits `0`; with `--require-approval`, annotated and dismissed decisions are published before exiting `1` (negative human outcome); usage/startup/validation failures — bad flag combinations, strict flags outside `annotate --gate --json`, a missing `--result-file` parent, a pre-existing or dangling-symlink destination — exit `2` (the gate itself was misconfigured or could not start). Post-decision publication failures (destination appears between validation and publish, hard links unavailable, stdout write failure) also exit `2`: they deliver no decision record at all, so they present as environment errors — "the gate could not deliver a decision" — never as a reviewer outcome, and never as approval (still fail-closed, since only `0` means approved). Signal deaths keep `128+n`. Result paths resolve from the invocation working directory, require an existing parent and absent destination, and publish via a flushed/closed `0600` same-directory temporary file plus an atomic no-clobber hard link—never copy or overwrite fallback. Keep reviewed sources at stable project paths; unique result and diagnostic log files may use a narrow temporary directory. Explicit Close emits `dismissed`; missing results or process/browser failures are recovery cases, never approval.

## Archive Flow

```
Expand Down
217 changes: 217 additions & 0 deletions apps/hook/server/annotate-command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { describe, expect, test } from "bun:test";
import { completeAnnotateCommand } from "./annotate-command";
import type { AnnotateOutcome } from "./strict-annotate-result";

interface RunResult {
events: string[];
stdout: string[];
resultBytes: string[];
legacy: AnnotateOutcome[];
}

async function runCompletion(
outcome: AnnotateOutcome,
options: {
requireApproval?: boolean;
resultFile?: string;
resultWriter?: (path: string, serialized: string) => Promise<void>;
} = {},
): Promise<RunResult> {
const events: string[] = [];
const stdout: string[] = [];
const resultBytes: string[] = [];
const legacy: AnnotateOutcome[] = [];

await completeAnnotateCommand({
waitForDecision: async () => {
events.push("decision");
return outcome;
},
settleAfterDecision: async () => {
events.push("settle");
},
stopServer: () => {
events.push("stop");
},
requireApproval: options.requireApproval ?? false,
resultFile: options.resultFile,
writeResultFile:
options.resultWriter ??
(async (_path, serialized) => {
events.push("result");
resultBytes.push(`${serialized}\n`);
}),
writeStdout: async (bytes) => {
events.push("stdout");
stdout.push(bytes);
},
emitLegacyOutcome: (result) => {
events.push("legacy");
legacy.push(result);
},
exit: (code) => {
events.push(`exit:${code}`);
},
});

return { events, stdout, resultBytes, legacy };
}

describe("completeAnnotateCommand", () => {
test("publishes approved feedback to matching stdout and result bytes", async () => {
const result = await runCompletion(
{
approved: true,
feedback: "Keep the cache bounded.",
},
{
requireApproval: true,
resultFile: "/result.json",
},
);

const expected =
'{"decision":"approved","feedback":"Keep the cache bounded."}\n';
expect(result.resultBytes).toEqual([expected]);
expect(result.stdout).toEqual([expected]);
expect(result.events).toEqual([
"decision",
"settle",
"stop",
"result",
"stdout",
"exit:0",
]);
});

test("publishes annotated and dismissed decisions before nonzero exit", async () => {
const annotated = await runCompletion(
{ approved: false, exit: false, feedback: "revise" },
{ requireApproval: true, resultFile: "/annotated.json" },
);
const dismissed = await runCompletion(
{ exit: true, feedback: "" },
{ requireApproval: true, resultFile: "/dismissed.json" },
);

expect(annotated.stdout).toEqual([
'{"decision":"annotated","feedback":"revise"}\n',
]);
expect(annotated.resultBytes).toEqual(annotated.stdout);
expect(annotated.events.slice(-3)).toEqual([
"result",
"stdout",
"exit:1",
]);
expect(dismissed.stdout).toEqual([
'{"decision":"dismissed"}\n',
]);
expect(dismissed.resultBytes).toEqual(dismissed.stdout);
expect(dismissed.events.slice(-3)).toEqual([
"result",
"stdout",
"exit:1",
]);
});

test("delegates legacy output unchanged and exits zero", async () => {
const outcome = { exit: false, feedback: "legacy feedback" };
const result = await runCompletion(outcome);

expect(result.legacy).toEqual([outcome]);
expect(result.stdout).toEqual([]);
expect(result.resultBytes).toEqual([]);
expect(result.events.slice(-2)).toEqual(["legacy", "exit:0"]);
});

test("supports each strict option independently", async () => {
const resultFileOnly = await runCompletion(
{ approved: false, feedback: "revise" },
{ resultFile: "/result.json" },
);
const approvalOnly = await runCompletion(
{ exit: true, feedback: "" },
{ requireApproval: true },
);

expect(resultFileOnly.events.slice(-3)).toEqual([
"result",
"stdout",
"exit:0",
]);
expect(resultFileOnly.resultBytes).toEqual(resultFileOnly.stdout);
expect(approvalOnly.events.slice(-2)).toEqual(["stdout", "exit:1"]);
expect(approvalOnly.resultBytes).toEqual([]);
});

test("exits 2 without emitting stdout when result publication fails", async () => {
const events: string[] = [];
const errors: string[] = [];

await completeAnnotateCommand({
waitForDecision: async () => ({
approved: false,
feedback: "revise",
}),
settleAfterDecision: async () => {},
stopServer: () => {},
requireApproval: true,
resultFile: "/raced.json",
writeResultFile: async () => {
events.push("result");
throw new Error("destination appeared");
},
writeStdout: async () => {
events.push("stdout");
},
emitLegacyOutcome: () => {
events.push("legacy");
},
exit: (code) => {
events.push(`exit:${code}`);
},
logError: (message) => {
errors.push(message);
},
});

// No decision record was delivered, so this is an environment error
// (exit 2), never a reviewer outcome — and never approval.
expect(events).toEqual(["result", "exit:2"]);
expect(errors).toEqual(["destination appeared"]);
});

test("exits 2 when the stdout record cannot be written", async () => {
const events: string[] = [];
const errors: string[] = [];

await completeAnnotateCommand({
waitForDecision: async () => ({
approved: true,
feedback: "",
}),
settleAfterDecision: async () => {},
stopServer: () => {},
requireApproval: true,
writeResultFile: async () => {
events.push("result");
},
writeStdout: async () => {
events.push("stdout");
throw new Error("stdout closed");
},
emitLegacyOutcome: () => {
events.push("legacy");
},
exit: (code) => {
events.push(`exit:${code}`);
},
logError: (message) => {
errors.push(message);
},
});

expect(events).toEqual(["stdout", "exit:2"]);
expect(errors).toEqual(["stdout closed"]);
});
});
72 changes: 72 additions & 0 deletions apps/hook/server/annotate-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import {
annotateOutcomeExitCode,
serializeStrictAnnotateResult,
STRICT_GATE_ERROR_EXIT_CODE,
writeAnnotateResultFile,
type AnnotateOutcome,
} from "./strict-annotate-result";

export interface CompleteAnnotateCommandOptions {
waitForDecision: () => Promise<AnnotateOutcome>;
settleAfterDecision: () => Promise<void>;
stopServer: () => void;
requireApproval: boolean;
resultFile?: string;
writeResultFile?: (
resultFile: string,
serialized: string,
) => Promise<void>;
writeStdout?: (output: string) => Promise<void>;
emitLegacyOutcome: (result: AnnotateOutcome) => void;
exit?: (code: number) => void;
logError?: (message: string) => void;
}

export function writeStdout(output: string): Promise<void> {
return new Promise((resolve, reject) => {
process.stdout.write(output, (error) => {
if (error) reject(error);
else resolve();
});
});
}

export async function completeAnnotateCommand({
waitForDecision,
settleAfterDecision,
stopServer,
requireApproval,
resultFile,
writeResultFile = writeAnnotateResultFile,
writeStdout: outputWriter = writeStdout,
emitLegacyOutcome,
exit = process.exit,
logError = (message) => console.error(message),
}: CompleteAnnotateCommandOptions): Promise<void> {
const result = await waitForDecision();
await settleAfterDecision();
stopServer();

if (requireApproval || resultFile) {
const serialized = serializeStrictAnnotateResult(result);
try {
if (resultFile) {
await writeResultFile(resultFile, serialized);
}
await outputWriter(`${serialized}\n`);
} catch (error) {
// Publication failed: no decision record was delivered, so this is an
// environment error ("the gate could not deliver a decision"), not a
// reviewer outcome. Exit 2 — fail-closed, but distinct from exit 1's
// "gate ran and the reviewer did not approve".
logError(error instanceof Error ? error.message : String(error));
exit(STRICT_GATE_ERROR_EXIT_CODE);
return;
}
exit(annotateOutcomeExitCode(result, requireApproval));
return;
}

emitLegacyOutcome(result);
exit(0);
}
51 changes: 51 additions & 0 deletions apps/hook/server/annotate-output.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, test } from "bun:test";
import {
formatAnnotateOutcome,
supportsAnnotateApprovalNotes,
} from "./annotate-output";

describe("annotate stdout", () => {
test("preserves legacy plaintext output byte-for-byte", () => {
expect(formatAnnotateOutcome(
{ feedback: "", approved: true },
{ hook: false, json: false },
)).toBe("The user approved.");
expect(formatAnnotateOutcome(
{ feedback: "", exit: true },
{ hook: false, json: false },
)).toBeNull();
expect(formatAnnotateOutcome(
{ feedback: "Revise this.", approved: false },
{ hook: false, json: false },
)).toBe("Revise this.");
});

test("preserves legacy hook output byte-for-byte", () => {
expect(formatAnnotateOutcome(
{ feedback: "Keep the retry bounded.", approved: true },
{ hook: true, json: true },
)).toBeNull();
expect(formatAnnotateOutcome(
{ feedback: "Revise this." },
{ hook: true, json: false },
)).toBe('{"decision":"block","reason":"Revise this."}');
});

test("includes nonempty feedback only on direct JSON approval", () => {
expect(formatAnnotateOutcome(
{ feedback: "Keep the retry bounded.", approved: true },
{ hook: false, json: true },
)).toBe('{"decision":"approved","feedback":"Keep the retry bounded."}');
expect(formatAnnotateOutcome(
{ feedback: "", approved: true },
{ hook: false, json: true },
)).toBe('{"decision":"approved"}');
});

test("advertises approval notes only for gated direct JSON", () => {
expect(supportsAnnotateApprovalNotes({ gate: true, json: true, hook: false })).toBe(true);
expect(supportsAnnotateApprovalNotes({ gate: false, json: true, hook: false })).toBe(false);
expect(supportsAnnotateApprovalNotes({ gate: true, json: false, hook: false })).toBe(false);
expect(supportsAnnotateApprovalNotes({ gate: true, json: true, hook: true })).toBe(false);
});
});
Loading