Judge each final answer for factual claims no tool result supports

Dotfiles repository for managing shell and tool configurations across macOS and Linux systems using Nix + Home Manager with Flakes.
~/.config/# Install Nix (using Determinate Systems installer)
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install
# Clone the repository
git clone https://github.com/kokatsu/dotfiles.git ~/dotfiles
cd ~/dotfiles
# Build and activate Home Manager configuration
DOTFILES_DIR="$PWD" nix run home-manager -- switch --flake . --impure
# User environment (packages / dotfiles) on both Linux and macOS.
# Set this to the actual repository path on each PC.
DOTFILES_DIR="$HOME/dotfiles" home-manager switch --flake "$HOME/dotfiles" --impure
# macOS system settings + Homebrew (quit Chrome first: casks get upgraded in place)
sudo HOSTNAME=$(hostname -s) DOTFILES_DIR="$HOME/dotfiles" darwin-rebuild switch --flake "$HOME/dotfiles" --impure
nix flake update
DOTFILES_DIR="$HOME/dotfiles" home-manager switch --flake "$HOME/dotfiles" --impure
nix develop # Enter development shell with every linter / formatter
just check # Format check, lint, typos, script tests, and flake evaluation
just fmt # Format everything (Nix, Lua, shell, TOML, YAML, JSON, TypeScript)
just # List all recipes
Lefthook runs a subset of these on commit and push (formatters, linters, gitleaks, commitlint); the script tests and flake evaluation only run through just check and CI.
.
├── flake.nix # Flake outputs and Home Manager/nix-darwin builders
├── flake.lock # Locked dependencies for reproducibility
├── justfile # Task runner: checks, formatters, tests
├── nix/
│ ├── home/ # Home Manager modules (packages, files, programs, services)
│ ├── darwin/ # macOS system and Homebrew configuration
│ └── overlays/ # Custom packages and upstream workarounds
├── bin/ # User scripts, linked to ~/.local/bin/scripts and on PATH
├── scripts/ # Test and helper scripts used by just and CI
├── karabiner-config/ # Karabiner-Elements rules written with karabiner.ts
└── .config/ # Dotfile sources linked by Home Manager
├── zsh/ # Zsh shell configuration
├── nvim/ # Neovim configuration
├── wezterm/ # WezTerm terminal configuration
├── claude/ # Claude Code settings, hooks, and skills
└── ... # Other tool configurations
Static application settings live in nix/home/programs/. Home Manager generates Git, Ghostty, ripgrep, Starship, Yazi, Biome, markdown-oxide, ov, Taplo, psql, Vim, Claude Code keybindings, and the Codex base configuration from Nix. Edit these modules, then run home-manager switch --flake . --impure to apply them.
Yazi plugins come from nixpkgs yaziPlugins where available; the remaining plugins and flavors are vendored under .config/yazi/. All are installed through programs.yazi; update them via nix flake update or in Git instead of using ya pkg. Yazi's init.lua and git-changes.sh also need home-manager switch --flake . --impure after editing to apply the changes. Lua, shell scripts, and repository tooling configurations remain under .config/. Claude Code's settings.json keeps its writable repository link. Codex receives a writable copy of its generated configuration and preserves the existing local state sections during activation.
MIT. Third-party notices are listed in THIRD_PARTY_LICENSES.md.
hooks/register.ts 166 lines1import type { EngineInterface, Register } from "claude-code";
2import { lineAppender } from "./append.ts";
3import { buildEvidence } from "./evidence.ts";
4
5const appendLine = lineAppender();
6
7const SYSTEM =
8 `You audit one assistant response for unsupported factual claims. The user message holds the evidence and then the response to judge. In the evidence, [user] is what the person typed, [tool ...] is a tool call with its output, and [context] is text the harness injected (environment, hook output, skill bodies, memory, instruction files).
9
10Flag a sentence only if it asserts a fact about code, files, specs, external services or tools, or the assistant's own past actions, and nothing in the evidence supports it. Support is [user] text, tool outputs, and [context] text that reports something checked or produced in this session (environment facts, hook output, skill bodies). Memory files (MEMORY.md and the memory notes it indexes) and instruction files (CLAUDE.md, AGENTS.md, rules) are context only: they tell you what the assistant was told, not what is true now, so a factual claim resting only on them is unsupported. This includes negative and capability claims (that something does not exist, cannot be done, or is not visible to the assistant) and claims about how Claude Code, its hooks and tools, or the assistant's own context work. An explanation or reason given in the response is not support. A search with zero results does not support a claim that something does not exist. Earlier tool outputs are truncated: if a claim concerns the subject of an earlier tool call and its support could lie in the truncated part, do not flag it.
11
12Do not flag: a claim whose own sentence labels it as unverified, inferred, or a guess in any language (e.g. 未確認, 未検証, 推定); a sentence that explicitly says it covers everything that follows, or names the sentences it covers (e.g. 以下はすべて未検証), exempts those sentences, but a heading alone or a label on an unrelated sentence does not; opinions, recommendations, and plans; restatements of what the user said; small talk.
13
14Your own knowledge is not support, even when you believe the claim is true; judge only by the evidence. Judge only evidential support; never request any other work. Reply with JSON only: {"ok": true} when nothing is flagged, otherwise {"ok": false, "reason": "<every flagged sentence, quoted, each followed by what is missing>"}, the reason written in the language of the response.`;
15
16type Verdict = { ok: boolean; reason?: string };
17
18function parseVerdict(text: string): Verdict | undefined {
19 const json = text.match(/\{[\s\S]*\}/)?.[0];
20 if (json === undefined) return undefined;
21 try {
22 const v = JSON.parse(json) as Verdict;
23 return typeof v.ok === "boolean" ? v : undefined;
24 } catch {
25 return undefined;
26 }
27}
28
29async function logPath($: EngineInterface, sessionId: string): Promise<string> {
30 return `${await $.env.get(
31 "HOME",
32 )}/.local/state/claim-verifier/${sessionId}.jsonl`;
33}
34
35async function judge(
36 $: EngineInterface,
37 sessionId: string,
38 prompt: string,
39 evidenceChars: number,
40 latestChars: number,
41): Promise<Verdict | undefined> {
42 const started = Date.now();
43 const judged = await $.model.complete({
44 model: "sonnet",
45 system: SYSTEM,
46 prompt,
47 maxTokens: 4096,
48 timeoutMs: 110_000,
49 });
50 const mineMs = Date.now() - started;
51 const verdict = judged.isAnswered ? parseVerdict(judged.text) : undefined;
52 if (verdict?.ok === false) {
53 // A log row is one line: a newline inside it is drawn as U+FFFD.
54 const lines = (verdict.reason ?? "").split("\n").filter((line) =>
55 line.trim() !== ""
56 );
57 for (const line of ["根拠を確認できなかった文:", ...lines]) $.ui.log(line);
58 }
59
60 const path = await logPath($, sessionId);
61 const entry = {
62 at: new Date().toISOString(),
63 evidenceChars,
64 latestChars,
65 mineMs,
66 usage: judged.usage,
67 mine: verdict ??
68 {
69 error: judged.isAnswered
70 ? `unparsable: ${judged.text.slice(0, 200)}`
71 : judged.reason,
72 },
73 };
74 await appendLine(
75 {
76 exists: (p) => $.fs.exists(p),
77 read: (p) => $.fs.read(p),
78 write: (p, text) => $.fs.write(p, text),
79 },
80 path,
81 JSON.stringify(entry),
82 );
83 return verdict;
84}
85
86export const register: Register = (on) => {
87 on("session.start", async ($, e, next) => {
88 await $.command.register({
89 name: "claim",
90 description: "Put a claim-verifier flag into the prompt box to send",
91 argumentHint: "[n: 1 is the latest flag]",
92 immediate: true,
93 });
94 return next(e);
95 });
96
97 // The flag goes into the prompt box, never into the command's output, which
98 // the model would read: the person decides what, if anything, is sent.
99 on("command.run", { command: "claim" }, async ($, e) => {
100 const n = e.args.trim() === "" ? 1 : Number(e.args.trim());
101 const path = await logPath($, await $.session.id());
102 const log = (await $.fs.exists(path)) ? await $.fs.read(path) : "";
103 const flags = log.split("\n").filter((line) => line !== "")
104 .map((line) => JSON.parse(line) as { mine?: Verdict })
105 .flatMap((entry) => entry.mine?.ok === false ? [entry.mine] : []);
106 const flag = Number.isInteger(n) && n >= 1 ? flags.at(-n) : undefined;
107 if (flag === undefined) {
108 $.ui.toast(
109 `claim-verifier: no flag #${
110 e.args.trim() || 1
111 } (${flags.length} in this session)`,
112 );
113 return {};
114 }
115 const filled = await $.prompt.fill({
116 text:
117 `claim-verifier が根拠を確認できなかった文です。確認してください。\n${
118 flag.reason ?? ""
119 }`,
120 mode: "insert",
121 });
122 if (!filled.isFilled) {
123 $.ui.toast(
124 `claim-verifier: the prompt box did not take the flag (${
125 filled.refusal ?? "refused"
126 })`,
127 );
128 }
129 return {};
130 });
131
132 on("classic.Stop", async ($, e, next) => {
133 const messages = await $.session.messages({ as: "api" });
134 const answer = e.last_assistant_message ??
135 messages.findLast((m) => m.role === "assistant")?.content
136 .flatMap((b) => b.type === "text" ? [String(b.text)] : []).join("\n") ??
137 "";
138 const { text: evidence, latestChars } = buildEvidence(messages);
139 const prompt = [evidence, "## Response to judge", answer].join("\n\n");
140
141 let verdict: Verdict | undefined;
142 try {
143 verdict = await judge(
144 $,
145 e.session_id,
146 prompt,
147 evidence.length,
148 latestChars,
149 );
150 } catch (error: unknown) {
151 $.ui.log(`claim-verifier: ${String(error)}`, { to: "debug" });
152 }
153 const result = await next(e);
154 // One send-back per turn: the rewrite is judged and logged, never blocked,
155 // so a judge that keeps flagging cannot hold the turn open.
156 if (verdict?.ok !== false || e.stop_hook_active) return result;
157 return {
158 ...result,
159 block:
160 `根拠を確認できなかった文があります。ツールで確認するか、未検証と明記したうえで、直前の回答全体を書き直してください。読み手はこの書き直しだけを読むので、指摘への返答ではなく、元の回答に代わる完全な回答にしてください。\n${
161 verdict.reason ?? ""
162 }`,
163 };
164 }).catch((_$, e, next) => next(e));
165};
166hooks/append.ts 20 lines1export type LineFile = {
2 exists: (path: string) => Promise<boolean>;
3 read: (path: string) => Promise<string>;
4 write: (path: string, text: string) => Promise<void>;
5};
6
7// `$.fs` has no append, so each line is a read-modify-write; chaining them
8// keeps judges that finish close together from overwriting each other.
9export function lineAppender() {
10 let last: Promise<void> = Promise.resolve();
11 return (file: LineFile, path: string, line: string): Promise<void> => {
12 const run = last.then(async () => {
13 const previous = (await file.exists(path)) ? await file.read(path) : "";
14 await file.write(path, previous + line + "\n");
15 });
16 last = run.catch(() => {});
17 return run;
18 };
19}
20hooks/evidence.ts 108 lines1// `ApiMessage` is not exported by 'claude-code', so the shape is restated here.
2export type Block = { type: string; [field: string]: unknown };
3export type Message = {
4 role: "user" | "assistant";
5 content: readonly Block[];
6};
7
8export const EARLIER_CHARS = 2000;
9
10const REMINDER = "<system-reminder>";
11const STOP_FEEDBACK = "Stop hook feedback:";
12
13function clip(text: string, limit: number | undefined): string {
14 if (limit === undefined || text.length <= limit) return text;
15 return `${text.slice(0, limit)}\n[... ${
16 text.length - limit
17 } more chars truncated]`;
18}
19
20function textOf(block: Block): string {
21 return typeof block.text === "string" ? block.text : "";
22}
23
24// The harness wraps everything it injects in <system-reminder>, so an unwrapped
25// text block in a message without tool results is what the person typed.
26function isPrompt(m: Message): boolean {
27 return m.role === "user" &&
28 !m.content.some((b) => b.type === "tool_result") &&
29 m.content.some((b) => {
30 const text = textOf(b).trimStart();
31 return b.type === "text" && text !== "" &&
32 !text.startsWith(REMINDER) && !text.startsWith(STOP_FEEDBACK);
33 });
34}
35
36export function latestTurnStart(messages: readonly Message[]): number {
37 for (let i = messages.length - 1; i >= 0; i--) {
38 const m = messages[i];
39 if (m !== undefined && isPrompt(m)) return i;
40 }
41 return 0;
42}
43
44function resultText(content: unknown): string {
45 if (typeof content === "string") return content;
46 if (!Array.isArray(content)) return "";
47 return content.map((b: Block) =>
48 b.type === "text" ? textOf(b) : `[${b.type}]`
49 )
50 .join("\n");
51}
52
53type ToolUse = { name: string; input: unknown };
54
55function render(
56 m: Message,
57 uses: ReadonlyMap<string, ToolUse>,
58 limit: number | undefined,
59): string[] {
60 if (m.role === "assistant") return [];
61 return m.content.flatMap((b) => {
62 if (b.type === "text") {
63 const text = textOf(b);
64 const trimmed = text.trimStart();
65 if (trimmed === "") return [];
66 const injected = trimmed.startsWith(REMINDER) ||
67 trimmed.startsWith(STOP_FEEDBACK);
68 return [`${injected ? "[context]" : "[user]"}\n${text}`];
69 }
70 if (b.type === "tool_result") {
71 const use = uses.get(String(b.tool_use_id));
72 return [[
73 `[tool ${use?.name ?? "unknown"}${b.is_error ? " (error)" : ""}]`,
74 `input: ${clip(JSON.stringify(use?.input ?? null), limit)}`,
75 `output:\n${clip(resultText(b.content), limit)}`,
76 ].join("\n")];
77 }
78 return [`[${b.type}]`];
79 });
80}
81
82export function buildEvidence(
83 messages: readonly Message[],
84): { text: string; latestChars: number } {
85 const uses = new Map<string, ToolUse>();
86 for (const m of messages) {
87 for (const b of m.content) {
88 if (b.type === "tool_use") {
89 uses.set(String(b.id), { name: String(b.name), input: b.input });
90 }
91 }
92 }
93 const start = latestTurnStart(messages);
94 const earlier = messages.slice(0, start).flatMap((m) =>
95 render(m, uses, EARLIER_CHARS)
96 );
97 const latest = messages.slice(start).flatMap((m) =>
98 render(m, uses, undefined)
99 ).join("\n\n");
100 const text = [
101 `## Earlier turns (tool inputs and outputs truncated to ${EARLIER_CHARS} chars each; [user] and [context] are whole)`,
102 ...earlier,
103 "## Latest turn (complete)",
104 latest,
105 ].join("\n\n");
106 return { text, latestChars: latest.length };
107}
108