feat(omo-codex): batch 35 (4 files)

This commit is contained in:
YeonGyu-Kim
2026-05-30 19:12:06 +09:00
parent 77d3e32d5c
commit c576eae679
4 changed files with 773 additions and 0 deletions
@@ -0,0 +1,12 @@
#!/usr/bin/env node
import { runCodexHookCli } from "./codex-hook.js";
const [command, subcommand] = process.argv.slice(2);
if (command === "hook" && subcommand === "post-tool-use") {
await runCodexHookCli();
} else {
process.stderr.write("Usage: omo-comment-checker hook post-tool-use\n");
process.exitCode = 2;
}
@@ -0,0 +1,205 @@
import { readFileSync } from "node:fs";
import { stdin as processStdin, stdout as processStdout } from "node:process";
import {
type CommentCheckRequest,
extractCommentCheckRequests,
isRecord,
type ToolResultContent,
type ToolResultLike,
toHookInput,
} from "./core.js";
import { type CommentCheckerRunner, runCommentChecker } from "./runner.js";
export type CodexPostToolUseInput = {
session_id: string;
turn_id: string;
transcript_path: string | null;
cwd: string;
hook_event_name: "PostToolUse";
model: string;
permission_mode: string;
tool_name: string;
tool_input: Record<string, unknown>;
tool_response: unknown;
tool_use_id: string;
};
export type CodexHookOptions = {
run?: CommentCheckerRunner;
};
const DEFAULT_MAX_HOOK_FEEDBACK_CHARS = 8000;
const CONTEXT_PRESSURE_MAX_HOOK_FEEDBACK_CHARS = 1200;
const CONTEXT_PRESSURE_MARKERS = [
"context compacted",
"context_length_exceeded",
"skill descriptions were shortened",
"context_too_large",
"codex ran out of room in the model's context window",
"your input exceeds the context window",
"long threads and multiple compactions",
] as const;
export function extractCodexCommentCheckRequests(input: CodexPostToolUseInput): CommentCheckRequest[] {
return extractCommentCheckRequests(toToolResultLike(input));
}
export async function runCommentCheckerPostToolUse(
input: CodexPostToolUseInput,
options: CodexHookOptions = {},
): Promise<string> {
const requests = extractCodexCommentCheckRequests(input);
if (requests.length === 0) return "";
const runner = options.run ?? runCommentChecker;
const warnings: Array<{ filePath: string; message: string }> = [];
for (const request of requests) {
const context = {
sessionId: input.session_id,
cwd: input.cwd,
...(input.transcript_path === null ? {} : { transcriptPath: input.transcript_path }),
};
const result = await runner(toHookInput(request, context));
if (result.status === "missing" || result.status === "pass") continue;
if (result.status === "error") continue;
const message = normalizeHookText(result.message);
if (message.length > 0) {
warnings.push({ filePath: request.filePath, message });
}
}
if (warnings.length === 0) return "";
return JSON.stringify({
decision: "block",
reason: limitHookText(formatWarnings(warnings), hookFeedbackLimit(input.transcript_path)),
});
}
export async function runCodexHookCli(): Promise<void> {
const input = await readStdin();
if (input.trim().length === 0) return;
const parsed = parseCodexPostToolUseInput(input);
if (!parsed) return;
const output = await runCommentCheckerPostToolUse(parsed);
if (output.length > 0) {
processStdout.write(output);
processStdout.write("\n");
}
}
export function parseCodexPostToolUseInput(input: string): CodexPostToolUseInput | undefined {
let parsed: unknown;
try {
parsed = JSON.parse(input);
} catch {
return undefined;
}
return isCodexPostToolUseInput(parsed) ? parsed : undefined;
}
function toToolResultLike(input: CodexPostToolUseInput): ToolResultLike {
return {
toolName: input.tool_name,
input: normalizeToolInput(input.tool_name, input.tool_input),
content: normalizeToolResponse(input.tool_response),
isError: isErrorResponse(input.tool_response),
details: isRecord(input.tool_response) ? input.tool_response : undefined,
};
}
function normalizeToolInput(toolName: string, toolInput: Record<string, unknown>): Record<string, unknown> {
if (toolName === "apply_patch" && typeof toolInput["command"] === "string") {
return {
...toolInput,
input: toolInput["command"],
patch: toolInput["command"],
};
}
return toolInput;
}
function normalizeToolResponse(toolResponse: unknown): ToolResultContent[] {
if (typeof toolResponse === "string") {
return [{ type: "text", text: toolResponse }];
}
if (isRecord(toolResponse) && typeof toolResponse["text"] === "string") {
return [{ type: "text", text: toolResponse["text"] }];
}
return [];
}
function isErrorResponse(toolResponse: unknown): boolean {
return isRecord(toolResponse) && toolResponse["is_error"] === true;
}
function formatWarnings(warnings: Array<{ filePath: string; message: string }>): string {
return warnings
.map((warning) => `comment-checker found issues in ${warning.filePath}:\n${warning.message}`)
.join("\n\n");
}
function normalizeHookText(value: string): string {
return value.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim();
}
function hookFeedbackLimit(transcriptPath: string | null): number {
return isContextPressureTranscript(transcriptPath)
? CONTEXT_PRESSURE_MAX_HOOK_FEEDBACK_CHARS
: DEFAULT_MAX_HOOK_FEEDBACK_CHARS;
}
function isContextPressureTranscript(transcriptPath: string | null): boolean {
if (transcriptPath === null) return false;
try {
return hasContextPressureMarker(readFileSync(transcriptPath, "utf8"));
} catch (error) {
if (error instanceof Error) return false;
throw error;
}
}
function hasContextPressureMarker(text: string): boolean {
const normalizedText = text.toLowerCase();
return CONTEXT_PRESSURE_MARKERS.some((marker) => normalizedText.includes(marker));
}
function limitHookText(text: string, maxChars: number): string {
if (text.length <= maxChars) return text;
const marker = `\n\n[Truncated hook output to ${maxChars} chars to avoid Codex context overflow.]`;
if (marker.length >= maxChars) return marker.slice(0, maxChars);
const head = text.slice(0, maxChars - marker.length).replace(/[ \t\r\n]+$/, "");
return `${head}${marker}`;
}
function isCodexPostToolUseInput(value: unknown): value is CodexPostToolUseInput {
return (
isRecord(value) &&
value["hook_event_name"] === "PostToolUse" &&
typeof value["session_id"] === "string" &&
typeof value["turn_id"] === "string" &&
(typeof value["transcript_path"] === "string" || value["transcript_path"] === null) &&
typeof value["cwd"] === "string" &&
typeof value["model"] === "string" &&
typeof value["permission_mode"] === "string" &&
typeof value["tool_name"] === "string" &&
isRecord(value["tool_input"]) &&
typeof value["tool_use_id"] === "string"
);
}
function readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
let data = "";
processStdin.setEncoding("utf-8");
processStdin.on("data", (chunk: string) => {
data += chunk;
});
processStdin.once("error", reject);
processStdin.once("end", () => {
resolve(data);
});
});
}
@@ -0,0 +1,361 @@
export type TextContent = {
type: "text";
text: string;
};
export type ImageContent = {
type: "image";
data: string;
mimeType: string;
};
export type CheckerToolName = "Write" | "Edit" | "MultiEdit";
export type CheckerEdit = {
old_string: string;
new_string: string;
};
export type CheckerToolInput = {
file_path: string;
content?: string;
old_string?: string;
new_string?: string;
edits?: CheckerEdit[];
};
export type CommentCheckRequest = {
sourceToolName: string;
toolName: CheckerToolName;
filePath: string;
toolInput: CheckerToolInput;
};
export type CommentCheckerHookInput = {
session_id: string;
tool_name: CheckerToolName;
transcript_path: string;
cwd: string;
hook_event_name: "PostToolUse";
tool_input: CheckerToolInput;
};
export type ToolResultContent = TextContent | ImageContent;
export type ToolResultLike = {
toolName: string;
input: Record<string, unknown>;
content?: ToolResultContent[];
isError?: boolean;
details?: unknown;
};
type ApplyPatchAccumulator = {
operation: "add" | "delete" | "update";
filePath: string;
movePath?: string;
oldLines: string[];
newLines: string[];
};
type ApplyPatchFileMetadata = {
filePath: string;
movePath?: string;
before: string;
after: string;
type?: string;
};
export function extractCommentCheckRequests(event: ToolResultLike): CommentCheckRequest[] {
if (event.isError) return [];
if (isToolFailureOutput(getContentText(event.content))) return [];
const toolName = event.toolName.toLowerCase();
if (toolName === "write") return extractWriteRequest(event);
if (toolName === "edit") return extractEditRequest(event);
if (toolName === "multiedit" || toolName === "multi_edit") return extractMultiEditRequest(event);
if (toolName === "apply_patch") return extractApplyPatchRequests(event);
return [];
}
export function toHookInput(
request: CommentCheckRequest,
context: {
sessionId: string;
cwd: string;
transcriptPath?: string;
},
): CommentCheckerHookInput {
return {
session_id: context.sessionId,
tool_name: request.toolName,
transcript_path: context.transcriptPath ?? "",
cwd: context.cwd,
hook_event_name: "PostToolUse",
tool_input: request.toolInput,
};
}
export function isToolFailureOutput(text: string): boolean {
const lower = text.trim().toLowerCase();
return (
lower.startsWith("error") ||
lower.includes("error:") ||
lower.includes("failed to") ||
lower.includes("could not")
);
}
function extractWriteRequest(event: ToolResultLike): CommentCheckRequest[] {
const filePath = getString(event.input, ["filePath", "file_path", "path"]);
const content = getString(event.input, ["content"]);
if (!filePath || content === undefined) return [];
return [
{
sourceToolName: event.toolName,
toolName: "Write",
filePath,
toolInput: {
file_path: filePath,
content,
},
},
];
}
function extractEditRequest(event: ToolResultLike): CommentCheckRequest[] {
const filePath = getString(event.input, ["filePath", "file_path", "path"]);
const oldString = getString(event.input, ["oldString", "old_string"]);
const newString = getString(event.input, ["newString", "new_string"]);
if (!filePath || oldString === undefined || newString === undefined) return [];
const toolInput: CheckerToolInput = { file_path: filePath };
toolInput.old_string = oldString;
toolInput.new_string = newString;
return [
{
sourceToolName: event.toolName,
toolName: "Edit",
filePath,
toolInput,
},
];
}
function extractMultiEditRequest(event: ToolResultLike): CommentCheckRequest[] {
const filePath = getString(event.input, ["filePath", "file_path", "path"]);
const edits = getEdits(event.input["edits"]);
if (!filePath || edits.length === 0) return [];
return [
{
sourceToolName: event.toolName,
toolName: "MultiEdit",
filePath,
toolInput: {
file_path: filePath,
edits,
},
},
];
}
function extractApplyPatchRequests(event: ToolResultLike): CommentCheckRequest[] {
const metadataRequests = extractApplyPatchMetadataRequests(event.details, event.toolName);
if (metadataRequests.length > 0) return metadataRequests;
const patch = getString(event.input, ["input", "patch", "command"]);
if (!patch) return [];
return parseApplyPatchRequests(patch, event.toolName);
}
function extractApplyPatchMetadataRequests(details: unknown, sourceToolName: string): CommentCheckRequest[] {
const metadataFiles = getApplyPatchMetadataFiles(details);
if (metadataFiles.length === 0) return [];
const requests: CommentCheckRequest[] = [];
for (const file of metadataFiles) {
if (file.type === "delete") continue;
const filePath = file.movePath ?? file.filePath;
if (file.before.length === 0) {
requests.push({
sourceToolName,
toolName: "Write",
filePath,
toolInput: {
file_path: filePath,
content: file.after,
},
});
continue;
}
requests.push({
sourceToolName,
toolName: "Edit",
filePath,
toolInput: {
file_path: filePath,
old_string: file.before,
new_string: file.after,
},
});
}
return requests;
}
function getApplyPatchMetadataFiles(details: unknown): ApplyPatchFileMetadata[] {
if (!isRecord(details)) return [];
const direct = readApplyPatchMetadataFiles(details["files"]);
if (direct.length > 0) return direct;
const resultDetails = details["result"];
const result = isRecord(resultDetails) ? readApplyPatchMetadataFiles(resultDetails["files"]) : [];
if (result.length > 0) return result;
const metadataDetails = details["metadata"];
const metadata = isRecord(metadataDetails) ? readApplyPatchMetadataFiles(metadataDetails["files"]) : [];
return metadata;
}
function readApplyPatchMetadataFiles(value: unknown): ApplyPatchFileMetadata[] {
if (!Array.isArray(value)) return [];
const files: ApplyPatchFileMetadata[] = [];
for (const item of value) {
if (!isRecord(item)) continue;
const filePath = getString(item, ["filePath", "file_path", "path"]);
const movePath = getString(item, ["movePath", "move_path"]);
const before = getString(item, ["before", "old", "oldString", "old_string"]);
const after = getString(item, ["after", "new", "newString", "new_string"]);
const type = getString(item, ["type", "operation"]);
if (!filePath || before === undefined || after === undefined) continue;
files.push({
filePath,
before,
after,
...(movePath === undefined ? {} : { movePath }),
...(type === undefined ? {} : { type }),
});
}
return files;
}
export function parseApplyPatchRequests(patch: string, sourceToolName = "apply_patch"): CommentCheckRequest[] {
const requests: CommentCheckRequest[] = [];
let current: ApplyPatchAccumulator | undefined;
const flush = (): void => {
if (!current) return;
if (current.operation === "add") {
const content = joinPatchLines(current.newLines);
if (content.length > 0) {
requests.push({
sourceToolName,
toolName: "Write",
filePath: current.filePath,
toolInput: {
file_path: current.filePath,
content,
},
});
}
}
if (current.operation === "update") {
const newString = joinPatchLines(current.newLines);
if (newString.length > 0) {
const filePath = current.movePath ?? current.filePath;
requests.push({
sourceToolName,
toolName: "Edit",
filePath,
toolInput: {
file_path: filePath,
old_string: joinPatchLines(current.oldLines),
new_string: newString,
},
});
}
}
current = undefined;
};
for (const line of patch.split(/\r?\n/)) {
if (line === "*** Begin Patch" || line === "*** End Patch") continue;
if (line.startsWith("*** Add File: ")) {
flush();
current = makeAccumulator("add", line.slice("*** Add File: ".length).trim());
continue;
}
if (line.startsWith("*** Update File: ")) {
flush();
current = makeAccumulator("update", line.slice("*** Update File: ".length).trim());
continue;
}
if (line.startsWith("*** Delete File: ")) {
flush();
current = makeAccumulator("delete", line.slice("*** Delete File: ".length).trim());
continue;
}
if (line.startsWith("*** Move to: ")) {
if (current?.operation === "update") current.movePath = line.slice("*** Move to: ".length).trim();
continue;
}
if (!current) continue;
if (line.startsWith("@@")) continue;
if (current.operation === "add") {
if (line.startsWith("+")) current.newLines.push(line.slice(1));
continue;
}
if (current.operation === "update") {
if (line.startsWith("+")) current.newLines.push(line.slice(1));
if (line.startsWith("-")) current.oldLines.push(line.slice(1));
}
}
flush();
return requests;
}
function makeAccumulator(operation: ApplyPatchAccumulator["operation"], filePath: string): ApplyPatchAccumulator {
return {
operation,
filePath,
oldLines: [],
newLines: [],
};
}
function getEdits(value: unknown): CheckerEdit[] {
if (!Array.isArray(value)) return [];
const edits: CheckerEdit[] = [];
for (const item of value) {
if (!isRecord(item)) continue;
const oldString = getString(item, ["oldString", "old_string"]);
const newString = getString(item, ["newString", "new_string"]);
if (oldString === undefined || newString === undefined) continue;
edits.push({
old_string: oldString,
new_string: newString,
});
}
return edits;
}
function getContentText(content: ToolResultContent[] | undefined): string {
if (!content) return "";
return content
.filter((block): block is TextContent => block.type === "text")
.map((block) => block.text)
.join("\n");
}
function getString(input: Record<string, unknown>, keys: string[]): string | undefined {
for (const key of keys) {
const value = input[key];
if (typeof value === "string") return value;
}
return undefined;
}
function joinPatchLines(lines: string[]): string {
return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -0,0 +1,195 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import type { CommentCheckerHookInput } from "./core.js";
export type ProcessResult = {
exitCode: number | null;
stdout: string;
stderr: string;
};
export const MAX_PROCESS_OUTPUT_BYTES = 64 * 1024;
export type ProcessExecutor = (command: string, args: string[], stdin: string) => Promise<ProcessResult>;
export type RunCommentCheckerOptions = {
binaryPath?: string;
customPrompt?: string;
resolveBinary?: () => string | undefined;
executor?: ProcessExecutor;
};
export type CommentCheckerRunResult = {
status: "pass" | "warning" | "error" | "missing";
message: string;
binaryPath?: string;
exitCode?: number | null;
stdout?: string;
stderr?: string;
};
export type CommentCheckerRunner = (input: CommentCheckerHookInput) => Promise<CommentCheckerRunResult>;
export async function runCommentChecker(
input: CommentCheckerHookInput,
options: RunCommentCheckerOptions = {},
): Promise<CommentCheckerRunResult> {
const binaryPath =
options.binaryPath ?? (options.resolveBinary ? options.resolveBinary() : resolveCommentCheckerBinary());
if (!binaryPath) {
return {
status: "missing",
message: "comment-checker binary not found. Run npm install for the codex-comment-checker plugin.",
};
}
const args = ["check"];
if (options.customPrompt) {
args.push("--prompt", options.customPrompt);
}
const executor = options.executor ?? spawnProcess;
const result = await executor(binaryPath, args, JSON.stringify(input));
const message = result.stderr || result.stdout;
if (result.exitCode === 0) {
return {
status: "pass",
message: "",
binaryPath,
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
};
}
if (result.exitCode === 2) {
return {
status: "warning",
message,
binaryPath,
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
};
}
return {
status: "error",
message,
binaryPath,
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
};
}
export function resolveCommentCheckerBinary(): string | undefined {
const binaryName = process.platform === "win32" ? "comment-checker.exe" : "comment-checker";
const fromPackageApi = resolvePackageApiBinary();
if (fromPackageApi) return fromPackageApi;
const fromPackage = resolvePackageBinary(binaryName);
if (fromPackage) return fromPackage;
return undefined;
}
function resolvePackageApiBinary(): string | undefined {
try {
const require = createRequire(import.meta.url);
const packageExports: unknown = require("@code-yeongyu/comment-checker");
if (!isCommentCheckerPackage(packageExports)) return undefined;
const binaryPath = packageExports.getBinaryPath();
return existsSync(binaryPath) ? binaryPath : undefined;
} catch {
return undefined;
}
}
function resolvePackageBinary(binaryName: string): string | undefined {
try {
const require = createRequire(import.meta.url);
const packagePath = require.resolve("@code-yeongyu/comment-checker/package.json");
const binaryPath = join(dirname(packagePath), "bin", binaryName);
return existsSync(binaryPath) ? binaryPath : undefined;
} catch {
return undefined;
}
}
function isCommentCheckerPackage(value: unknown): value is { getBinaryPath: () => string } {
return isRecord(value) && typeof value["getBinaryPath"] === "function";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
interface OutputAccumulator {
text: string;
bytes: number;
truncated: boolean;
}
function appendOutput(output: OutputAccumulator, chunk: string, maxOutputBytes: number): void {
if (output.truncated) return;
const remainingBytes = maxOutputBytes - output.bytes;
const chunkBytes = Buffer.byteLength(chunk, "utf8");
if (chunkBytes <= remainingBytes) {
output.text += chunk;
output.bytes += chunkBytes;
return;
}
if (remainingBytes > 0) {
output.text += Buffer.from(chunk, "utf8").subarray(0, remainingBytes).toString("utf8");
output.bytes += remainingBytes;
}
output.truncated = true;
}
function formatOutput(output: OutputAccumulator, streamName: "stdout" | "stderr", maxOutputBytes: number): string {
if (!output.truncated) return output.text;
return `${output.text}\n[${streamName} truncated after ${maxOutputBytes} bytes]`;
}
export function spawnProcess(
command: string,
args: string[],
stdin: string,
maxOutputBytes: number = MAX_PROCESS_OUTPUT_BYTES,
): Promise<ProcessResult> {
return new Promise((resolve) => {
const outputByteLimit = Number.isFinite(maxOutputBytes) && maxOutputBytes > 0 ? Math.floor(maxOutputBytes) : 0;
const proc = spawn(command, args, {
stdio: ["pipe", "pipe", "pipe"],
});
const stdout: OutputAccumulator = { text: "", bytes: 0, truncated: false };
const stderr: OutputAccumulator = { text: "", bytes: 0, truncated: false };
proc.stdout.setEncoding("utf-8");
proc.stderr.setEncoding("utf-8");
proc.stdout.on("data", (chunk: string) => {
appendOutput(stdout, chunk, outputByteLimit);
});
proc.stderr.on("data", (chunk: string) => {
appendOutput(stderr, chunk, outputByteLimit);
});
proc.once("error", (error) => {
appendOutput(stderr, error.message, outputByteLimit);
resolve({
exitCode: null,
stdout: formatOutput(stdout, "stdout", outputByteLimit),
stderr: formatOutput(stderr, "stderr", outputByteLimit),
});
});
proc.once("close", (exitCode) => {
resolve({
exitCode,
stdout: formatOutput(stdout, "stdout", outputByteLimit),
stderr: formatOutput(stderr, "stderr", outputByteLimit),
});
});
proc.stdin.end(stdin);
});
}