diff --git a/.gitignore b/.gitignore index 4798dea..53aba8a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules/ dist/ +!plugins/omo/components/*/dist/ +!plugins/omo/components/*/dist/** .env .env.* *.tgz diff --git a/plugins/omo/components/comment-checker/.gitignore b/plugins/omo/components/comment-checker/.gitignore index af76037..bcc2871 100644 --- a/plugins/omo/components/comment-checker/.gitignore +++ b/plugins/omo/components/comment-checker/.gitignore @@ -5,3 +5,5 @@ node_modules/ .env.* coverage/ .vitest/ +!dist/ +!dist/** diff --git a/plugins/omo/components/comment-checker/dist/apply-patch.d.ts b/plugins/omo/components/comment-checker/dist/apply-patch.d.ts new file mode 100644 index 0000000..d899af3 --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/apply-patch.d.ts @@ -0,0 +1,7 @@ +import type { CommentCheckRequest } from "./types.js"; +export declare function extractApplyPatchRequests(event: { + details?: unknown; + input: Record; + toolName: string; +}): CommentCheckRequest[]; +export declare function parseApplyPatchRequests(patch: string, sourceToolName?: string): CommentCheckRequest[]; diff --git a/plugins/omo/components/comment-checker/dist/apply-patch.js b/plugins/omo/components/comment-checker/dist/apply-patch.js new file mode 100644 index 0000000..8eac82b --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/apply-patch.js @@ -0,0 +1,173 @@ +import { getString, isRecord } from "./record.js"; +export function extractApplyPatchRequests(event) { + 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); +} +export function parseApplyPatchRequests(patch, sourceToolName = "apply_patch") { + const requests = []; + let current; + const flush = () => { + 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 extractApplyPatchMetadataRequests(details, sourceToolName) { + const metadataFiles = getApplyPatchMetadataFiles(details); + if (metadataFiles.length === 0) + return []; + const requests = []; + 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) { + 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) { + if (!Array.isArray(value)) + return []; + const files = []; + 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; +} +function makeAccumulator(operation, filePath) { + return { + operation, + filePath, + oldLines: [], + newLines: [], + }; +} +function joinPatchLines(lines) { + return lines.length === 0 ? "" : `${lines.join("\n")}\n`; +} diff --git a/plugins/omo/components/comment-checker/dist/cli.d.ts b/plugins/omo/components/comment-checker/dist/cli.d.ts new file mode 100644 index 0000000..b798801 --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/cli.d.ts @@ -0,0 +1,2 @@ +#!/usr/bin/env node +export {}; diff --git a/plugins/omo/components/comment-checker/dist/cli.js b/plugins/omo/components/comment-checker/dist/cli.js new file mode 100644 index 0000000..38a642a --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/cli.js @@ -0,0 +1,10 @@ +#!/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; +} diff --git a/plugins/omo/components/comment-checker/dist/codex-hook.d.ts b/plugins/omo/components/comment-checker/dist/codex-hook.d.ts new file mode 100644 index 0000000..79ec554 --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/codex-hook.d.ts @@ -0,0 +1,22 @@ +import { type CommentCheckRequest } from "./core.js"; +import { type CommentCheckerRunner } 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; + tool_response: unknown; + tool_use_id: string; +}; +export type CodexHookOptions = { + run?: CommentCheckerRunner; +}; +export declare function extractCodexCommentCheckRequests(input: CodexPostToolUseInput): CommentCheckRequest[]; +export declare function runCommentCheckerPostToolUse(input: CodexPostToolUseInput, options?: CodexHookOptions): Promise; +export declare function runCodexHookCli(): Promise; +export declare function parseCodexPostToolUseInput(input: string): CodexPostToolUseInput | undefined; diff --git a/plugins/omo/components/comment-checker/dist/codex-hook.js b/plugins/omo/components/comment-checker/dist/codex-hook.js new file mode 100644 index 0000000..04ab3f0 --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/codex-hook.js @@ -0,0 +1,165 @@ +import { readFileSync } from "node:fs"; +import { stdin as processStdin, stdout as processStdout } from "node:process"; +import { extractCommentCheckRequests, isRecord, toHookInput, } from "./core.js"; +import { runCommentChecker } from "./runner.js"; +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", +]; +export function extractCodexCommentCheckRequests(input) { + return extractCommentCheckRequests(toToolResultLike(input)); +} +export async function runCommentCheckerPostToolUse(input, options = {}) { + const requests = extractCodexCommentCheckRequests(input); + if (requests.length === 0) + return ""; + const runner = options.run ?? runCommentChecker; + const warnings = []; + 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() { + 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) { + let parsed; + try { + parsed = JSON.parse(input); + } + catch { + return undefined; + } + return isCodexPostToolUseInput(parsed) ? parsed : undefined; +} +function toToolResultLike(input) { + 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, toolInput) { + if (toolName === "apply_patch" && typeof toolInput["command"] === "string") { + return { + ...toolInput, + input: toolInput["command"], + patch: toolInput["command"], + }; + } + return toolInput; +} +function normalizeToolResponse(toolResponse) { + 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) { + return isRecord(toolResponse) && toolResponse["is_error"] === true; +} +function formatWarnings(warnings) { + return warnings + .map((warning) => `comment-checker found issues in ${warning.filePath}:\n${warning.message}`) + .join("\n\n"); +} +function normalizeHookText(value) { + return value.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim(); +} +function hookFeedbackLimit(transcriptPath) { + return isContextPressureTranscript(transcriptPath) + ? CONTEXT_PRESSURE_MAX_HOOK_FEEDBACK_CHARS + : DEFAULT_MAX_HOOK_FEEDBACK_CHARS; +} +function isContextPressureTranscript(transcriptPath) { + if (transcriptPath === null) + return false; + try { + return hasContextPressureMarker(readFileSync(transcriptPath, "utf8")); + } + catch (error) { + if (error instanceof Error) + return false; + throw error; + } +} +function hasContextPressureMarker(text) { + const normalizedText = text.toLowerCase(); + return CONTEXT_PRESSURE_MARKERS.some((marker) => normalizedText.includes(marker)); +} +function limitHookText(text, maxChars) { + 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) { + 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() { + return new Promise((resolve, reject) => { + let data = ""; + processStdin.setEncoding("utf-8"); + processStdin.on("data", (chunk) => { + data += chunk; + }); + processStdin.once("error", reject); + processStdin.once("end", () => { + resolve(data); + }); + }); +} diff --git a/plugins/omo/components/comment-checker/dist/core-values.d.ts b/plugins/omo/components/comment-checker/dist/core-values.d.ts new file mode 100644 index 0000000..b7f0867 --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/core-values.d.ts @@ -0,0 +1 @@ +export { getString, isRecord } from "./record.js"; diff --git a/plugins/omo/components/comment-checker/dist/core-values.js b/plugins/omo/components/comment-checker/dist/core-values.js new file mode 100644 index 0000000..b7f0867 --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/core-values.js @@ -0,0 +1 @@ +export { getString, isRecord } from "./record.js"; diff --git a/plugins/omo/components/comment-checker/dist/core.d.ts b/plugins/omo/components/comment-checker/dist/core.d.ts new file mode 100644 index 0000000..eb4fedd --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/core.d.ts @@ -0,0 +1,5 @@ +export { parseApplyPatchRequests } from "./apply-patch.js"; +export { toHookInput } from "./hook-input.js"; +export { isRecord } from "./record.js"; +export { extractCommentCheckRequests, isToolFailureOutput } from "./request-extractor.js"; +export type { CheckerEdit, CheckerToolInput, CheckerToolName, CommentCheckerHookInput, CommentCheckRequest, ImageContent, TextContent, ToolResultContent, ToolResultLike, } from "./types.js"; diff --git a/plugins/omo/components/comment-checker/dist/core.js b/plugins/omo/components/comment-checker/dist/core.js new file mode 100644 index 0000000..2ecc05f --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/core.js @@ -0,0 +1,4 @@ +export { parseApplyPatchRequests } from "./apply-patch.js"; +export { toHookInput } from "./hook-input.js"; +export { isRecord } from "./record.js"; +export { extractCommentCheckRequests, isToolFailureOutput } from "./request-extractor.js"; diff --git a/plugins/omo/components/comment-checker/dist/hook-input.d.ts b/plugins/omo/components/comment-checker/dist/hook-input.d.ts new file mode 100644 index 0000000..5093bf0 --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/hook-input.d.ts @@ -0,0 +1,6 @@ +import type { CommentCheckerHookInput, CommentCheckRequest } from "./types.js"; +export declare function toHookInput(request: CommentCheckRequest, context: { + readonly sessionId: string; + readonly cwd: string; + readonly transcriptPath?: string; +}): CommentCheckerHookInput; diff --git a/plugins/omo/components/comment-checker/dist/hook-input.js b/plugins/omo/components/comment-checker/dist/hook-input.js new file mode 100644 index 0000000..e12802a --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/hook-input.js @@ -0,0 +1,10 @@ +export function toHookInput(request, context) { + return { + session_id: context.sessionId, + tool_name: request.toolName, + transcript_path: context.transcriptPath ?? "", + cwd: context.cwd, + hook_event_name: "PostToolUse", + tool_input: request.toolInput, + }; +} diff --git a/plugins/omo/components/comment-checker/dist/record.d.ts b/plugins/omo/components/comment-checker/dist/record.d.ts new file mode 100644 index 0000000..581ffb6 --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/record.d.ts @@ -0,0 +1,2 @@ +export declare function getString(input: Record, keys: readonly string[]): string | undefined; +export declare function isRecord(value: unknown): value is Record; diff --git a/plugins/omo/components/comment-checker/dist/record.js b/plugins/omo/components/comment-checker/dist/record.js new file mode 100644 index 0000000..259e383 --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/record.js @@ -0,0 +1,11 @@ +export function getString(input, keys) { + for (const key of keys) { + const value = input[key]; + if (typeof value === "string") + return value; + } + return undefined; +} +export function isRecord(value) { + return typeof value === "object" && value !== null; +} diff --git a/plugins/omo/components/comment-checker/dist/request-extractor.d.ts b/plugins/omo/components/comment-checker/dist/request-extractor.d.ts new file mode 100644 index 0000000..ece1a33 --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/request-extractor.d.ts @@ -0,0 +1,3 @@ +import type { CommentCheckRequest, ToolResultLike } from "./types.js"; +export declare function extractCommentCheckRequests(event: ToolResultLike): CommentCheckRequest[]; +export declare function isToolFailureOutput(text: string): boolean; diff --git a/plugins/omo/components/comment-checker/dist/request-extractor.js b/plugins/omo/components/comment-checker/dist/request-extractor.js new file mode 100644 index 0000000..2c20f6a --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/request-extractor.js @@ -0,0 +1,104 @@ +import { extractApplyPatchRequests } from "./apply-patch.js"; +import { getString, isRecord } from "./record.js"; +export function extractCommentCheckRequests(event) { + 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 isToolFailureOutput(text) { + const lower = text.trim().toLowerCase(); + return (lower.startsWith("error") || + lower.includes("error:") || + lower.includes("failed to") || + lower.includes("could not")); +} +function extractWriteRequest(event) { + 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) { + 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 []; + return [ + { + sourceToolName: event.toolName, + toolName: "Edit", + filePath, + toolInput: { + file_path: filePath, + old_string: oldString, + new_string: newString, + }, + }, + ]; +} +function extractMultiEditRequest(event) { + 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 getEdits(value) { + if (!Array.isArray(value)) + return []; + const edits = []; + 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) { + if (!content) + return ""; + return content + .filter((block) => block.type === "text") + .map((block) => block.text) + .join("\n"); +} diff --git a/plugins/omo/components/comment-checker/dist/runner.d.ts b/plugins/omo/components/comment-checker/dist/runner.d.ts new file mode 100644 index 0000000..285c5ca --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/runner.d.ts @@ -0,0 +1,26 @@ +import type { CommentCheckerHookInput } from "./core.js"; +export type ProcessResult = { + exitCode: number | null; + stdout: string; + stderr: string; +}; +export declare const MAX_PROCESS_OUTPUT_BYTES: number; +export type ProcessExecutor = (command: string, args: string[], stdin: string) => Promise; +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; +export declare function runCommentChecker(input: CommentCheckerHookInput, options?: RunCommentCheckerOptions): Promise; +export declare function resolveCommentCheckerBinary(): string | undefined; +export declare function spawnProcess(command: string, args: string[], stdin: string, maxOutputBytes?: number): Promise; diff --git a/plugins/omo/components/comment-checker/dist/runner.js b/plugins/omo/components/comment-checker/dist/runner.js new file mode 100644 index 0000000..bc5f354 --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/runner.js @@ -0,0 +1,144 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +export const MAX_PROCESS_OUTPUT_BYTES = 64 * 1024; +export async function runCommentChecker(input, options = {}) { + 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() { + 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() { + try { + const require = createRequire(import.meta.url); + const packageExports = 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) { + 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) { + return isRecord(value) && typeof value["getBinaryPath"] === "function"; +} +function isRecord(value) { + return typeof value === "object" && value !== null; +} +function appendOutput(output, chunk, maxOutputBytes) { + 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, streamName, maxOutputBytes) { + if (!output.truncated) + return output.text; + return `${output.text}\n[${streamName} truncated after ${maxOutputBytes} bytes]`; +} +export function spawnProcess(command, args, stdin, maxOutputBytes = MAX_PROCESS_OUTPUT_BYTES) { + 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 = { text: "", bytes: 0, truncated: false }; + const stderr = { text: "", bytes: 0, truncated: false }; + proc.stdout.setEncoding("utf-8"); + proc.stderr.setEncoding("utf-8"); + proc.stdout.on("data", (chunk) => { + appendOutput(stdout, chunk, outputByteLimit); + }); + proc.stderr.on("data", (chunk) => { + 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); + }); +} diff --git a/plugins/omo/components/comment-checker/dist/types.d.ts b/plugins/omo/components/comment-checker/dist/types.d.ts new file mode 100644 index 0000000..95358fd --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/types.d.ts @@ -0,0 +1,43 @@ +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; + content?: ToolResultContent[]; + isError?: boolean; + details?: unknown; +}; diff --git a/plugins/omo/components/comment-checker/dist/types.js b/plugins/omo/components/comment-checker/dist/types.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/plugins/omo/components/comment-checker/dist/types.js @@ -0,0 +1 @@ +export {}; diff --git a/plugins/omo/components/git-bash/dist/cli.d.ts b/plugins/omo/components/git-bash/dist/cli.d.ts new file mode 100644 index 0000000..b798801 --- /dev/null +++ b/plugins/omo/components/git-bash/dist/cli.d.ts @@ -0,0 +1,2 @@ +#!/usr/bin/env node +export {}; diff --git a/plugins/omo/components/git-bash/dist/cli.js b/plugins/omo/components/git-bash/dist/cli.js new file mode 100644 index 0000000..d59f08f --- /dev/null +++ b/plugins/omo/components/git-bash/dist/cli.js @@ -0,0 +1,29 @@ +#!/usr/bin/env node +import { runGitBashHookCli } from "./codex-hook.js"; +const TOP_LEVEL_HELP = "Usage:\n omo-git-bash-hook hook pre-tool-use\n omo-git-bash-hook hook post-compact\n omo-git-bash-hook help | --help | -h\n"; +async function main() { + const argv = process.argv.slice(2); + const command = argv[0]; + if (command === undefined || command === "help" || command === "--help" || command === "-h") { + process.stdout.write(TOP_LEVEL_HELP); + return 0; + } + if (command === "hook" && argv[1] === "pre-tool-use") { + await runGitBashHookCli(process.stdin, process.stdout, "pre-tool-use"); + return 0; + } + if (command === "hook" && argv[1] === "post-compact") { + await runGitBashHookCli(process.stdin, process.stdout, "post-compact"); + return 0; + } + process.stderr.write(`[omo-git-bash-hook] unknown command: ${argv.join(" ")}\n${TOP_LEVEL_HELP}`); + return 1; +} +main() + .then((code) => { + process.exit(code); +}) + .catch((error) => { + process.stderr.write(`[omo-git-bash-hook] ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/plugins/omo/components/git-bash/dist/codex-hook.d.ts b/plugins/omo/components/git-bash/dist/codex-hook.d.ts new file mode 100644 index 0000000..e05e11f --- /dev/null +++ b/plugins/omo/components/git-bash/dist/codex-hook.d.ts @@ -0,0 +1,28 @@ +export interface PreToolUsePayload { + readonly cwd: string; + readonly hook_event_name: "PreToolUse"; + readonly model: string; + readonly permission_mode: string; + readonly session_id: string; + readonly tool_input: unknown; + readonly tool_name: string; + readonly tool_use_id: string; + readonly transcript_path: string | null; + readonly turn_id: string; +} +export interface GitBashHookOptions { + readonly env?: NodeJS.ProcessEnv; + readonly platform?: NodeJS.Platform | string; + readonly pluginDataRoot?: string; +} +export interface PostCompactPayload { + readonly hook_event_name: "PostCompact"; + readonly session_id: string; + readonly transcript_path?: string | null; + readonly trigger?: string; +} +export declare function parsePreToolUsePayload(raw: string): PreToolUsePayload | null; +export declare function parsePostCompactPayload(raw: string): PostCompactPayload | null; +export declare function applyGitBashPreToolUseReminder(payload: PreToolUsePayload, options?: GitBashHookOptions): string; +export declare function applyGitBashPostCompactReset(payload: PostCompactPayload, options?: GitBashHookOptions): string; +export declare function runGitBashHookCli(stdin: NodeJS.ReadableStream, stdout: NodeJS.WritableStream, eventName?: "pre-tool-use" | "post-compact", options?: GitBashHookOptions): Promise; diff --git a/plugins/omo/components/git-bash/dist/codex-hook.js b/plugins/omo/components/git-bash/dist/codex-hook.js new file mode 100644 index 0000000..fc1c75e --- /dev/null +++ b/plugins/omo/components/git-bash/dist/codex-hook.js @@ -0,0 +1,137 @@ +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +const BASH_TOOL_NAME = "Bash"; +const REMINDER = "On Windows, prefer the OMO git_bash MCP for shell commands before using built-in exec_command. Use exec_command only when git_bash is unavailable or for non-shell operations."; +export function parsePreToolUsePayload(raw) { + if (raw.trim().length === 0) + return null; + try { + const parsed = JSON.parse(raw); + return isPreToolUsePayload(parsed) ? parsed : null; + } + catch (error) { + if (error instanceof SyntaxError) + return null; + return null; + } +} +export function parsePostCompactPayload(raw) { + if (raw.trim().length === 0) + return null; + try { + const parsed = JSON.parse(raw); + return isPostCompactPayload(parsed) ? parsed : null; + } + catch (error) { + if (error instanceof SyntaxError) + return null; + return null; + } +} +export function applyGitBashPreToolUseReminder(payload, options = {}) { + if (payload.hook_event_name !== "PreToolUse") + return ""; + if (payload.tool_name !== BASH_TOOL_NAME) + return ""; + if (!isWindowsHost(options)) + return ""; + const markerPath = reminderMarkerPath(payload.session_id, options.pluginDataRoot); + if (hasReminderMarker(markerPath)) + return ""; + mkdirSync(dirname(markerPath), { recursive: true }); + writeFileSync(markerPath, `${new Date().toISOString()}\n`); + const output = { + hookSpecificOutput: { + hookEventName: "PreToolUse", + additionalContext: REMINDER, + }, + }; + return `${JSON.stringify(output)}\n`; +} +export function applyGitBashPostCompactReset(payload, options = {}) { + if (payload.hook_event_name !== "PostCompact") + return ""; + rmSync(reminderMarkerPath(payload.session_id, options.pluginDataRoot), { force: true }); + return ""; +} +export async function runGitBashHookCli(stdin, stdout, eventName = "pre-tool-use", options = {}) { + try { + const raw = await readAll(stdin); + const output = eventName === "post-compact" ? postCompactOutput(raw, options) : preToolUseOutput(raw, options); + if (output.length > 0) + stdout.write(output); + } + catch (error) { + if (error instanceof Error) + return; + return; + } +} +function preToolUseOutput(raw, options) { + const payload = parsePreToolUsePayload(raw); + if (payload === null) + return ""; + return applyGitBashPreToolUseReminder(payload, options); +} +function postCompactOutput(raw, options) { + const payload = parsePostCompactPayload(raw); + if (payload === null) + return ""; + return applyGitBashPostCompactReset(payload, options); +} +function isWindowsHost(options) { + const platform = options.platform ?? process.platform; + if (platform === "win32") + return true; + const env = options.env ?? process.env; + return env["OS"] === "Windows_NT" || env["ComSpec"] !== undefined || env["SystemRoot"] !== undefined; +} +function hasReminderMarker(path) { + return existsSync(path); +} +function reminderMarkerPath(sessionId, pluginDataRoot) { + const root = pluginDataRoot ?? process.env["PLUGIN_DATA"] ?? join(homedir(), ".codex", "omo-git-bash"); + return join(root, "git-bash-reminder", `${safePathSegment(sessionId)}.seen`); +} +function safePathSegment(value) { + return value.replace(/[^A-Za-z0-9._-]/g, "_"); +} +function isPreToolUsePayload(value) { + if (!isRecord(value)) + return false; + return (value["hook_event_name"] === "PreToolUse" && + typeof value["cwd"] === "string" && + typeof value["model"] === "string" && + typeof value["permission_mode"] === "string" && + typeof value["session_id"] === "string" && + typeof value["tool_name"] === "string" && + typeof value["tool_use_id"] === "string" && + (value["transcript_path"] === null || typeof value["transcript_path"] === "string") && + typeof value["turn_id"] === "string" && + Object.hasOwn(value, "tool_input")); +} +function isPostCompactPayload(value) { + if (!isRecord(value)) + return false; + return (value["hook_event_name"] === "PostCompact" && + typeof value["session_id"] === "string" && + (value["transcript_path"] === undefined || + value["transcript_path"] === null || + typeof value["transcript_path"] === "string") && + (value["trigger"] === undefined || typeof value["trigger"] === "string")); +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function readAll(stdin) { + return new Promise((resolve, reject) => { + let data = ""; + stdin.setEncoding("utf8"); + stdin.on("data", (chunk) => { + data += chunk instanceof Buffer ? chunk.toString() : String(chunk); + }); + stdin.once("error", reject); + stdin.once("end", () => resolve(data)); + }); +} diff --git a/plugins/omo/components/git-bash/dist/index.d.ts b/plugins/omo/components/git-bash/dist/index.d.ts new file mode 100644 index 0000000..45220c8 --- /dev/null +++ b/plugins/omo/components/git-bash/dist/index.d.ts @@ -0,0 +1 @@ +export { applyGitBashPostCompactReset, applyGitBashPreToolUseReminder, parsePostCompactPayload, parsePreToolUsePayload, runGitBashHookCli, type GitBashHookOptions, type PostCompactPayload, type PreToolUsePayload, } from "./codex-hook.js"; diff --git a/plugins/omo/components/git-bash/dist/index.js b/plugins/omo/components/git-bash/dist/index.js new file mode 100644 index 0000000..b28782a --- /dev/null +++ b/plugins/omo/components/git-bash/dist/index.js @@ -0,0 +1 @@ +export { applyGitBashPostCompactReset, applyGitBashPreToolUseReminder, parsePostCompactPayload, parsePreToolUsePayload, runGitBashHookCli, } from "./codex-hook.js"; diff --git a/plugins/omo/components/lsp/.gitignore b/plugins/omo/components/lsp/.gitignore index 1bf09f1..99be6a3 100644 --- a/plugins/omo/components/lsp/.gitignore +++ b/plugins/omo/components/lsp/.gitignore @@ -4,3 +4,5 @@ node_modules/ .DS_Store coverage/ .vitest/ +!dist/ +!dist/** diff --git a/plugins/omo/components/lsp/dist/cli.d.ts b/plugins/omo/components/lsp/dist/cli.d.ts new file mode 100644 index 0000000..b798801 --- /dev/null +++ b/plugins/omo/components/lsp/dist/cli.d.ts @@ -0,0 +1,2 @@ +#!/usr/bin/env node +export {}; diff --git a/plugins/omo/components/lsp/dist/cli.js b/plugins/omo/components/lsp/dist/cli.js new file mode 100644 index 0000000..6938721 --- /dev/null +++ b/plugins/omo/components/lsp/dist/cli.js @@ -0,0 +1,42 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import { argv, execPath, stderr } from "node:process"; +import { runPostCompactHookCli, runPostToolUseHookCli } from "./codex-hook-cli.js"; +const require = createRequire(import.meta.url); +const PACKAGE_LSP_MCP_CLI = "@code-yeongyu/lsp-tools-mcp/dist/cli.js"; +async function main() { + const [command = "mcp", subcommand = ""] = argv.slice(2); + if (command === "hook" && subcommand === "post-tool-use") { + await runPostToolUseHookCli(); + return; + } + if (command === "hook" && subcommand === "post-compact") { + await runPostCompactHookCli(); + return; + } + if (command === "mcp") { + await runPackageLspMcpCli(); + return; + } + stderr.write("Usage: omo-lsp [mcp | hook post-tool-use | hook post-compact]\n"); + process.exitCode = 2; +} +main().catch((error) => { + stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`); + process.exitCode = 1; +}); +async function runPackageLspMcpCli() { + const cliPath = require.resolve(PACKAGE_LSP_MCP_CLI); + const child = spawn(execPath, [cliPath, "mcp"], { stdio: "inherit" }); + await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => { + if (code !== null && code !== 0) + process.exitCode = code; + if (code === null && signal !== null) + process.exitCode = 1; + resolve(); + }); + }); +} diff --git a/plugins/omo/components/lsp/dist/codex-hook-cli.d.ts b/plugins/omo/components/lsp/dist/codex-hook-cli.d.ts new file mode 100644 index 0000000..d7d9876 --- /dev/null +++ b/plugins/omo/components/lsp/dist/codex-hook-cli.d.ts @@ -0,0 +1,2 @@ +export declare function runPostToolUseHookCli(stdin?: NodeJS.ReadStream): Promise; +export declare function runPostCompactHookCli(stdin?: NodeJS.ReadStream): Promise; diff --git a/plugins/omo/components/lsp/dist/codex-hook-cli.js b/plugins/omo/components/lsp/dist/codex-hook-cli.js new file mode 100644 index 0000000..50032f7 --- /dev/null +++ b/plugins/omo/components/lsp/dist/codex-hook-cli.js @@ -0,0 +1,39 @@ +import { stdin as processStdin } from "node:process"; +import { disposeLspBackend, isRecord, runLspPostCompactHook, runLspPostToolUseHook } from "./codex-hook.js"; +export async function runPostToolUseHookCli(stdin = processStdin) { + await runHookCli((input) => runLspPostToolUseHook(input), stdin); +} +export async function runPostCompactHookCli(stdin = processStdin) { + await runHookCli((input) => runLspPostCompactHook(input), stdin); +} +async function runHookCli(runHook, stdin) { + try { + const raw = await readStdin(stdin); + if (!raw.trim()) + return; + let parsed; + try { + parsed = JSON.parse(raw); + } + catch (error) { + if (error instanceof SyntaxError) + return; + throw error; + } + const input = isRecord(parsed) ? parsed : {}; + const output = await runHook(input); + if (output) + process.stdout.write(output); + } + finally { + await disposeLspBackend(); + } +} +async function readStdin(stdin) { + stdin.setEncoding("utf8"); + let raw = ""; + for await (const chunk of stdin) { + raw += chunk; + } + return raw; +} diff --git a/plugins/omo/components/lsp/dist/codex-hook.d.ts b/plugins/omo/components/lsp/dist/codex-hook.d.ts new file mode 100644 index 0000000..de6f774 --- /dev/null +++ b/plugins/omo/components/lsp/dist/codex-hook.d.ts @@ -0,0 +1,17 @@ +export { extractMutatedFilePaths } from "./mutated-file-paths.js"; +export type DiagnosticsRunner = (filePath: string) => Promise; +export interface CodexPostToolUseInput { + session_id?: unknown; + tool_name?: unknown; + tool_input?: unknown; + tool_response?: unknown; + transcript_path?: unknown; +} +export interface CodexPostCompactInput { + session_id?: unknown; +} +export declare function runLspDiagnosticsText(filePath: string): Promise; +export declare function disposeLspBackend(): Promise; +export declare function runLspPostToolUseHook(input: CodexPostToolUseInput, runDiagnostics?: DiagnosticsRunner): Promise; +export declare function runLspPostCompactHook(input: CodexPostCompactInput): Promise; +export declare function isRecord(value: unknown): value is Record; diff --git a/plugins/omo/components/lsp/dist/codex-hook.js b/plugins/omo/components/lsp/dist/codex-hook.js new file mode 100644 index 0000000..334b6c2 --- /dev/null +++ b/plugins/omo/components/lsp/dist/codex-hook.js @@ -0,0 +1,208 @@ +import { readFileSync } from "node:fs"; +import { isUnavailableLspDiagnostics, markLspSessionCompacted, recordLspDiagnosticsObservations, sessionIdFrom, shouldSkipUnavailableLspDiagnostics, } from "./lsp-session-state.js"; +import { extractMutatedFilePaths } from "./mutated-file-paths.js"; +export { extractMutatedFilePaths } from "./mutated-file-paths.js"; +const CLEAN_DIAGNOSTICS_TEXT = "No diagnostics found"; +const UNSUPPORTED_EXTENSION_TEXT = "No LSP server configured for extension:"; +const DIAGNOSTIC_START_PATTERN = /(?:error|warning|information|hint)\[[^\]\r\n]+\] \(\d+\) at \d+:\d+:/g; +const DIAGNOSTIC_CHUNK_PATTERN = /^(?:error|warning|information|hint)\[[^\]\r\n]+\] \(\d+\) at \d+:\d+:/; +const DEFAULT_MAX_HOOK_FEEDBACK_CHARS = 8000; +const CONTEXT_PRESSURE_MAX_HOOK_FEEDBACK_CHARS = 1200; +const MAX_CONCURRENT_DIAGNOSTICS = 4; +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", +]; +let lspToolsModulePromise; +let lspManagerModulePromise; +export async function runLspDiagnosticsText(filePath) { + const lspTools = await loadLspToolsModule(); + if (lspTools === null) + return `${UNSUPPORTED_EXTENSION_TEXT} LSP backend unavailable`; + const result = await lspTools.executeLspDiagnostics({ filePath, severity: "error" }); + return result.content.map((block) => block.text).join("\n"); +} +export async function disposeLspBackend() { + const lspManager = await loadLspManagerModule(); + if (lspManager === null) + return; + await lspManager.disposeDefaultLspManager(); +} +export async function runLspPostToolUseHook(input, runDiagnostics = runLspDiagnosticsText) { + const sessionId = sessionIdFrom(input); + const filePaths = extractMutatedFilePaths(input).filter((filePath) => !shouldSkipUnavailableLspDiagnostics(filePath, sessionId)); + if (filePaths.length === 0) + return ""; + const blocks = []; + const observations = []; + for (const { filePath, diagnostics } of await collectDiagnostics(filePaths, runDiagnostics)) { + const unavailable = isUnavailableLspDiagnostics(diagnostics); + observations.push({ filePath, unavailable }); + if (isCleanDiagnostics(diagnostics)) + continue; + if (unavailable) + continue; + blocks.push({ filePath, diagnostics }); + } + recordLspDiagnosticsObservations(sessionId, observations); + if (blocks.length === 0) + return ""; + const rawReason = blocks.map(formatDiagnosticBlock).join("\n\n"); + const reason = limitHookText(rawReason, hookFeedbackLimit(input.transcript_path)); + const output = { + decision: "block", + reason, + hookSpecificOutput: { + hookEventName: "PostToolUse", + additionalContext: reason, + }, + }; + return `${JSON.stringify(output)}\n`; +} +export async function runLspPostCompactHook(input) { + markLspSessionCompacted(sessionIdFrom(input)); + return ""; +} +async function collectDiagnostics(filePaths, runDiagnostics) { + const results = []; + let nextIndex = 0; + const workerCount = Math.min(MAX_CONCURRENT_DIAGNOSTICS, filePaths.length); + async function worker() { + for (;;) { + const index = nextIndex; + nextIndex += 1; + const filePath = filePaths[index]; + if (filePath === undefined) + return; + results[index] = { filePath, diagnostics: await collectFileDiagnostics(filePath, runDiagnostics) }; + } + } + await Promise.all(Array.from({ length: workerCount }, () => worker())); + return results; +} +async function collectFileDiagnostics(filePath, runDiagnostics) { + try { + return (await runDiagnostics(filePath)).trim(); + } + catch (error) { + return formatDiagnosticsError(error); + } +} +function formatDiagnosticsError(error) { + if (error instanceof Error) { + const message = error.message.trim(); + if (message.length > 0) + return message; + } + return String(error).trim(); +} +async function loadLspToolsModule() { + lspToolsModulePromise ??= import("@code-yeongyu/lsp-tools-mcp/dist/tools.js").catch((error) => { + if (isMissingLspBackendError(error)) + return null; + throw error; + }); + return lspToolsModulePromise; +} +async function loadLspManagerModule() { + lspManagerModulePromise ??= import("@code-yeongyu/lsp-tools-mcp/dist/lsp/manager.js").catch((error) => { + if (isMissingLspBackendError(error)) + return null; + throw error; + }); + return lspManagerModulePromise; +} +function isMissingLspBackendError(error) { + return (error instanceof Error && + "code" in error && + error.code === "ERR_MODULE_NOT_FOUND" && + error.message.includes("@code-yeongyu/lsp-tools-mcp")); +} +function formatDiagnosticBlock({ filePath, diagnostics }) { + return `LSP diagnostics after editing ${filePath}:\n\n${formatDiagnosticsForDisplay(diagnostics)}`; +} +function formatDiagnosticsForDisplay(diagnostics) { + const chunks = splitDiagnosticChunks(diagnostics); + if (!chunks.some(isDiagnosticChunk)) + return chunks.join("\n").trim(); + return chunks.map(formatDiagnosticChunk).join("\n"); +} +function splitDiagnosticChunks(diagnostics) { + const normalized = diagnostics.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim(); + if (normalized.length === 0) + return []; + const matches = Array.from(normalized.matchAll(DIAGNOSTIC_START_PATTERN)); + const firstMatch = matches[0]; + if (firstMatch?.index === undefined) + return [normalized]; + const chunks = []; + const leadingText = normalized.slice(0, firstMatch.index).trim(); + if (leadingText.length > 0) + chunks.push(leadingText); + for (const [index, match] of matches.entries()) { + if (match.index === undefined) + continue; + const nextMatch = matches[index + 1]; + const end = nextMatch?.index ?? normalized.length; + const chunk = normalized.slice(match.index, end).trim(); + if (chunk.length > 0) + chunks.push(chunk); + } + return chunks; +} +function formatDiagnosticChunk(chunk) { + const lines = chunk.split("\n"); + const firstLine = lines[0]; + if (firstLine === undefined) + return ""; + if (!isDiagnosticChunk(firstLine)) + return chunk; + const followingLines = lines.slice(1).map((line) => ` ${line}`); + return [`- ${firstLine}`, ...followingLines].join("\n"); +} +function isDiagnosticChunk(chunk) { + return DIAGNOSTIC_CHUNK_PATTERN.test(chunk); +} +function hookFeedbackLimit(transcriptPath) { + return isContextPressureTranscript(transcriptPath) + ? CONTEXT_PRESSURE_MAX_HOOK_FEEDBACK_CHARS + : DEFAULT_MAX_HOOK_FEEDBACK_CHARS; +} +function isContextPressureTranscript(transcriptPath) { + if (typeof transcriptPath !== "string") + return false; + try { + return hasContextPressureMarker(readFileSync(transcriptPath, "utf8")); + } + catch (error) { + if (error instanceof Error) + return false; + throw error; + } +} +function hasContextPressureMarker(text) { + const normalizedText = text.toLowerCase(); + return CONTEXT_PRESSURE_MARKERS.some((marker) => normalizedText.includes(marker)); +} +function limitHookText(text, maxChars) { + 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 isCleanDiagnostics(diagnostics) { + return (diagnostics.length === 0 || + diagnostics === CLEAN_DIAGNOSTICS_TEXT || + diagnostics.startsWith(UNSUPPORTED_EXTENSION_TEXT)); +} +export function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/plugins/omo/components/lsp/dist/lsp-session-state.d.ts b/plugins/omo/components/lsp/dist/lsp-session-state.d.ts new file mode 100644 index 0000000..b77e971 --- /dev/null +++ b/plugins/omo/components/lsp/dist/lsp-session-state.d.ts @@ -0,0 +1,11 @@ +export interface DiagnosticsObservation { + readonly filePath: string; + readonly unavailable: boolean; +} +export declare function sessionIdFrom(input: { + readonly session_id?: unknown; +}): string | undefined; +export declare function shouldSkipUnavailableLspDiagnostics(filePath: string, sessionId: string | undefined): boolean; +export declare function recordLspDiagnosticsObservations(sessionId: string | undefined, observations: readonly DiagnosticsObservation[]): void; +export declare function markLspSessionCompacted(sessionId: string | undefined): void; +export declare function isUnavailableLspDiagnostics(diagnostics: string): boolean; diff --git a/plugins/omo/components/lsp/dist/lsp-session-state.js b/plugins/omo/components/lsp/dist/lsp-session-state.js new file mode 100644 index 0000000..50fc381 --- /dev/null +++ b/plugins/omo/components/lsp/dist/lsp-session-state.js @@ -0,0 +1,92 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, extname, join } from "node:path"; +export function sessionIdFrom(input) { + return typeof input.session_id === "string" && input.session_id.length > 0 ? input.session_id : undefined; +} +export function shouldSkipUnavailableLspDiagnostics(filePath, sessionId) { + if (sessionId === undefined) + return false; + const state = readSessionState(sessionStatePath(sessionId)); + const extension = extensionKey(filePath); + return (extension !== undefined && + state.postCompactProbePending !== true && + state.unavailableExtensions.includes(extension)); +} +export function recordLspDiagnosticsObservations(sessionId, observations) { + if (sessionId === undefined || observations.length === 0) + return; + const state = readSessionState(sessionStatePath(sessionId)); + const unavailableExtensions = new Set(state.unavailableExtensions); + for (const observation of observations) { + const extension = extensionKey(observation.filePath); + if (extension === undefined) + continue; + if (observation.unavailable) { + unavailableExtensions.add(extension); + } + else { + unavailableExtensions.delete(extension); + } + } + writeSessionState(sessionStatePath(sessionId), { unavailableExtensions: [...unavailableExtensions].sort() }); +} +export function markLspSessionCompacted(sessionId) { + if (sessionId === undefined) + return; + const state = readSessionState(sessionStatePath(sessionId)); + if (state.unavailableExtensions.length === 0) + return; + writeSessionState(sessionStatePath(sessionId), { + unavailableExtensions: state.unavailableExtensions, + postCompactProbePending: true, + }); +} +export function isUnavailableLspDiagnostics(diagnostics) { + const normalized = diagnostics.trim(); + return (normalized.includes("LSP request timeout (method: initialize)") || + normalized.includes("LSP server is still initializing") || + normalized.includes("NOT INSTALLED") || + normalized.includes("Command not found:")); +} +function sessionStatePath(sessionId) { + const root = process.env["PLUGIN_DATA"] ?? join(homedir(), ".codex", "codex-lsp"); + return join(root, "sessions", `${safePathSegment(sessionId)}.json`); +} +function readSessionState(path) { + try { + const parsed = JSON.parse(readFileSync(path, "utf8")); + if (isLspSessionState(parsed)) + return parsed; + return emptyState(); + } + catch (error) { + if (error instanceof SyntaxError || (isRecord(error) && error["code"] === "ENOENT")) + return emptyState(); + throw error; + } +} +function writeSessionState(path, state) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(state)}\n`); +} +function emptyState() { + return { unavailableExtensions: [] }; +} +function extensionKey(filePath) { + const extension = extname(filePath).toLowerCase(); + return extension.length === 0 ? undefined : extension; +} +function safePathSegment(value) { + return value.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120) || "unknown-session"; +} +function isLspSessionState(value) { + if (!isRecord(value) || !Array.isArray(value["unavailableExtensions"])) + return false; + const postCompactProbePending = value["postCompactProbePending"]; + return (value["unavailableExtensions"].every((item) => typeof item === "string") && + (postCompactProbePending === undefined || typeof postCompactProbePending === "boolean")); +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/plugins/omo/components/lsp/dist/mutated-file-paths.d.ts b/plugins/omo/components/lsp/dist/mutated-file-paths.d.ts new file mode 100644 index 0000000..28e1c6c --- /dev/null +++ b/plugins/omo/components/lsp/dist/mutated-file-paths.d.ts @@ -0,0 +1,6 @@ +export interface MutatedFileInput { + readonly tool_name?: unknown; + readonly tool_input?: unknown; + readonly tool_response?: unknown; +} +export declare function extractMutatedFilePaths(input: MutatedFileInput): string[]; diff --git a/plugins/omo/components/lsp/dist/mutated-file-paths.js b/plugins/omo/components/lsp/dist/mutated-file-paths.js new file mode 100644 index 0000000..4c76c13 --- /dev/null +++ b/plugins/omo/components/lsp/dist/mutated-file-paths.js @@ -0,0 +1,79 @@ +const MUTATION_TOOL_NAMES = new Set(["apply_patch", "write", "edit", "multiedit", "multi_edit"]); +export function extractMutatedFilePaths(input) { + if (!isMutationTool(input.tool_name)) + return []; + if (isFailedToolResponse(input.tool_response)) + return []; + const toolInput = isRecord(input.tool_input) ? input.tool_input : {}; + const paths = new Set(); + addStringValue(paths, toolInput["path"]); + addStringValue(paths, toolInput["filePath"]); + addStringValue(paths, toolInput["file_path"]); + addStringArray(paths, toolInput["paths"]); + addStringArray(paths, toolInput["filePaths"]); + addStringArray(paths, toolInput["file_paths"]); + addPatchPayloads(paths, toolInput); + addPatchFiles(paths, toolInput["files"]); + addPatchFiles(paths, toolInput["changes"]); + return [...paths]; +} +function isMutationTool(value) { + if (typeof value !== "string") + return false; + return MUTATION_TOOL_NAMES.has(value.toLowerCase()); +} +function isFailedToolResponse(value) { + if (!isRecord(value)) + return false; + return (value["isError"] === true || value["is_error"] === true || value["error"] === true || value["status"] === "error"); +} +function addStringValue(paths, value) { + if (typeof value === "string" && value.length > 0) { + paths.add(value); + } +} +function addStringArray(paths, value) { + if (!Array.isArray(value)) + return; + for (const item of value) { + addStringValue(paths, item); + } +} +function addPatchPayloads(paths, input) { + addPatchInput(paths, input["input"]); + addPatchInput(paths, input["patch"]); + addPatchInput(paths, input["command"]); +} +function addPatchInput(paths, value) { + if (typeof value !== "string") + return; + for (const line of value.split("\n")) { + const path = extractPatchHeaderPath(line); + if (path !== undefined) + paths.add(path); + } +} +function extractPatchHeaderPath(line) { + const prefixes = ["*** Add File: ", "*** Update File: ", "*** Move to: "]; + for (const prefix of prefixes) { + if (line.startsWith(prefix)) + return line.slice(prefix.length).trim(); + } + return undefined; +} +function addPatchFiles(paths, value) { + if (!Array.isArray(value)) + return; + for (const item of value) { + if (!isRecord(item)) + continue; + addStringValue(paths, item["path"]); + addStringValue(paths, item["filePath"]); + addStringValue(paths, item["file_path"]); + addStringValue(paths, item["movePath"]); + addStringValue(paths, item["move_path"]); + } +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/plugins/omo/components/lsp/package.json b/plugins/omo/components/lsp/package.json index b6a9af5..04b35b7 100644 --- a/plugins/omo/components/lsp/package.json +++ b/plugins/omo/components/lsp/package.json @@ -37,16 +37,12 @@ ], "scripts": { "bootstrap": "node scripts/build-lsp-tools.mjs", - "prebuild": "node scripts/build-lsp-tools.mjs", "build": "node scripts/clean-dist.mjs && tsc -p tsconfig.build.json", - "pretest": "node scripts/build-lsp-tools.mjs", "test": "node scripts/test.mjs", "test:watch": "vitest", - "pretypecheck": "node scripts/build-lsp-tools.mjs", "typecheck": "tsc --noEmit", "lint": "biome check src test", "lint:fix": "biome check --write src test", - "precheck": "node scripts/build-lsp-tools.mjs", "check": "tsc --noEmit && biome check src test && tsc -p tsconfig.build.json" }, "dependencies": { diff --git a/plugins/omo/components/lsp/src/codex-hook-cli.ts b/plugins/omo/components/lsp/src/codex-hook-cli.ts index f99cd3e..40ee642 100644 --- a/plugins/omo/components/lsp/src/codex-hook-cli.ts +++ b/plugins/omo/components/lsp/src/codex-hook-cli.ts @@ -1,8 +1,6 @@ import { stdin as processStdin } from "node:process"; -import { disposeDefaultLspManager } from "@code-yeongyu/lsp-tools-mcp/dist/lsp/manager.js"; - -import { isRecord, runLspPostCompactHook, runLspPostToolUseHook } from "./codex-hook.js"; +import { disposeLspBackend, isRecord, runLspPostCompactHook, runLspPostToolUseHook } from "./codex-hook.js"; export async function runPostToolUseHookCli(stdin: NodeJS.ReadStream = processStdin): Promise { await runHookCli((input) => runLspPostToolUseHook(input), stdin); @@ -30,7 +28,7 @@ async function runHookCli( const output = await runHook(input); if (output) process.stdout.write(output); } finally { - await disposeDefaultLspManager(); + await disposeLspBackend(); } } diff --git a/plugins/omo/components/lsp/src/codex-hook.ts b/plugins/omo/components/lsp/src/codex-hook.ts index 138c2d1..5473704 100644 --- a/plugins/omo/components/lsp/src/codex-hook.ts +++ b/plugins/omo/components/lsp/src/codex-hook.ts @@ -1,7 +1,5 @@ import { readFileSync } from "node:fs"; -import { executeLspDiagnostics } from "@code-yeongyu/lsp-tools-mcp/dist/tools.js"; - import { isUnavailableLspDiagnostics, markLspSessionCompacted, @@ -58,11 +56,25 @@ const CONTEXT_PRESSURE_MARKERS = [ "long threads and multiple compactions", ] as const; +type LspToolsModule = typeof import("@code-yeongyu/lsp-tools-mcp/dist/tools.js"); +type LspManagerModule = typeof import("@code-yeongyu/lsp-tools-mcp/dist/lsp/manager.js"); + +let lspToolsModulePromise: Promise | undefined; +let lspManagerModulePromise: Promise | undefined; + export async function runLspDiagnosticsText(filePath: string): Promise { - const result = await executeLspDiagnostics({ filePath, severity: "error" }); + const lspTools = await loadLspToolsModule(); + if (lspTools === null) return `${UNSUPPORTED_EXTENSION_TEXT} LSP backend unavailable`; + const result = await lspTools.executeLspDiagnostics({ filePath, severity: "error" }); return result.content.map((block) => block.text).join("\n"); } +export async function disposeLspBackend(): Promise { + const lspManager = await loadLspManagerModule(); + if (lspManager === null) return; + await lspManager.disposeDefaultLspManager(); +} + export async function runLspPostToolUseHook( input: CodexPostToolUseInput, runDiagnostics: DiagnosticsRunner = runLspDiagnosticsText, @@ -140,6 +152,31 @@ function formatDiagnosticsError(error: unknown): string { return String(error).trim(); } +async function loadLspToolsModule(): Promise { + lspToolsModulePromise ??= import("@code-yeongyu/lsp-tools-mcp/dist/tools.js").catch((error: unknown) => { + if (isMissingLspBackendError(error)) return null; + throw error; + }); + return lspToolsModulePromise; +} + +async function loadLspManagerModule(): Promise { + lspManagerModulePromise ??= import("@code-yeongyu/lsp-tools-mcp/dist/lsp/manager.js").catch((error: unknown) => { + if (isMissingLspBackendError(error)) return null; + throw error; + }); + return lspManagerModulePromise; +} + +function isMissingLspBackendError(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + error.code === "ERR_MODULE_NOT_FOUND" && + error.message.includes("@code-yeongyu/lsp-tools-mcp") + ); +} + function formatDiagnosticBlock({ filePath, diagnostics }: DiagnosticBlock): string { return `LSP diagnostics after editing ${filePath}:\n\n${formatDiagnosticsForDisplay(diagnostics)}`; } diff --git a/plugins/omo/components/lsp/src/lsp-tools-mcp.d.ts b/plugins/omo/components/lsp/src/lsp-tools-mcp.d.ts new file mode 100644 index 0000000..1f835af --- /dev/null +++ b/plugins/omo/components/lsp/src/lsp-tools-mcp.d.ts @@ -0,0 +1,10 @@ +declare module "@code-yeongyu/lsp-tools-mcp/dist/tools.js" { + export function executeLspDiagnostics(input: { + readonly filePath: string; + readonly severity: "error"; + }): Promise<{ readonly content: readonly { readonly text: string }[] }>; +} + +declare module "@code-yeongyu/lsp-tools-mcp/dist/lsp/manager.js" { + export function disposeDefaultLspManager(): Promise; +} diff --git a/plugins/omo/components/lsp/test/package-smoke.test.ts b/plugins/omo/components/lsp/test/package-smoke.test.ts index 72c0247..f9ebf0d 100644 --- a/plugins/omo/components/lsp/test/package-smoke.test.ts +++ b/plugins/omo/components/lsp/test/package-smoke.test.ts @@ -84,7 +84,8 @@ describe("plugin package metadata", () => { expect(cliSource).not.toContain("./lazy-lsp-mcp.js"); expect(cliSource).toContain("@code-yeongyu/lsp-tools-mcp/dist/cli.js"); expect(cliSource).not.toContain("../../../../../lsp-tools-mcp/dist/cli.js"); - expect(codexHookCliSource).toContain("@code-yeongyu/lsp-tools-mcp/dist/lsp/manager.js"); + expect(codexHookCliSource).toContain("disposeLspBackend"); + expect(codexHookSource).toContain("@code-yeongyu/lsp-tools-mcp/dist/lsp/manager.js"); expect(codexHookSource).toContain("@code-yeongyu/lsp-tools-mcp/dist/tools.js"); expect(codexHookCliSource).not.toContain("../../../../../lsp-tools-mcp/dist/lsp/manager.js"); expect(codexHookSource).not.toContain("../../../../../lsp-tools-mcp/dist/tools.js"); diff --git a/plugins/omo/components/rules/dist/cli.d.ts b/plugins/omo/components/rules/dist/cli.d.ts new file mode 100644 index 0000000..b798801 --- /dev/null +++ b/plugins/omo/components/rules/dist/cli.d.ts @@ -0,0 +1,2 @@ +#!/usr/bin/env node +export {}; diff --git a/plugins/omo/components/rules/dist/cli.js b/plugins/omo/components/rules/dist/cli.js new file mode 100644 index 0000000..2afd0ab --- /dev/null +++ b/plugins/omo/components/rules/dist/cli.js @@ -0,0 +1,118 @@ +#!/usr/bin/env node +import { stdin as processStdin, stdout as processStdout } from "node:process"; +import { runPostCompactHook, runPostToolUseHook, runSessionStartHook, runUserPromptSubmitHook, } from "./codex-hook.js"; +const command = process.argv[2]; +const subcommand = process.argv[3]; +if (command === "hook" && subcommand === "session-start") { + await runHookCli("SessionStart"); +} +else if (command === "hook" && subcommand === "user-prompt-submit") { + await runHookCli("UserPromptSubmit"); +} +else if (command === "hook" && subcommand === "post-tool-use") { + await runHookCli("PostToolUse"); +} +else if (command === "hook" && subcommand === "post-compact") { + await runHookCli("PostCompact"); +} +else { + process.stderr.write("Usage: omo-rules hook [session-start|user-prompt-submit|post-tool-use|post-compact]\n"); + process.exitCode = 1; +} +async function runHookCli(eventName) { + const raw = await readStdin(); + if (raw.trim().length === 0) + return; + const parsed = parseHookInput(raw); + if (!parsed) + return; + const pluginDataRoot = process.env["PLUGIN_DATA"]; + const options = pluginDataRoot === undefined ? {} : { pluginDataRoot }; + const output = await runHook(eventName, parsed, options); + if (output.length > 0) { + processStdout.write(output); + } +} +async function runHook(eventName, parsed, options) { + switch (eventName) { + case "SessionStart": + return isCodexSessionStartInput(parsed) ? await runSessionStartHook(parsed, options) : ""; + case "UserPromptSubmit": + return isCodexUserPromptSubmitInput(parsed) ? await runUserPromptSubmitHook(parsed, options) : ""; + case "PostToolUse": + return isCodexPostToolUseInput(parsed) ? await runPostToolUseHook(parsed, options) : ""; + case "PostCompact": + return isCodexPostCompactInput(parsed) ? await runPostCompactHook(parsed, options) : ""; + } +} +function parseHookInput(raw) { + try { + const parsed = JSON.parse(raw); + return parsed; + } + catch { + return undefined; + } +} +function isCodexSessionStartInput(value) { + return (isRecord(value) && + value["hook_event_name"] === "SessionStart" && + typeof value["session_id"] === "string" && + isStringOrNull(value["transcript_path"]) && + typeof value["cwd"] === "string" && + typeof value["model"] === "string" && + typeof value["permission_mode"] === "string" && + typeof value["source"] === "string"); +} +function isCodexUserPromptSubmitInput(value) { + return (isRecord(value) && + value["hook_event_name"] === "UserPromptSubmit" && + typeof value["session_id"] === "string" && + typeof value["turn_id"] === "string" && + isStringOrNull(value["transcript_path"]) && + typeof value["cwd"] === "string" && + typeof value["model"] === "string" && + typeof value["permission_mode"] === "string" && + typeof value["prompt"] === "string"); +} +function isCodexPostToolUseInput(value) { + return (isRecord(value) && + value["hook_event_name"] === "PostToolUse" && + typeof value["session_id"] === "string" && + typeof value["turn_id"] === "string" && + isStringOrNull(value["transcript_path"]) && + typeof value["cwd"] === "string" && + typeof value["model"] === "string" && + typeof value["permission_mode"] === "string" && + typeof value["tool_name"] === "string" && + typeof value["tool_use_id"] === "string"); +} +function isCodexPostCompactInput(value) { + return (isRecord(value) && + value["hook_event_name"] === "PostCompact" && + typeof value["session_id"] === "string" && + typeof value["turn_id"] === "string" && + isStringOrNull(value["transcript_path"]) && + typeof value["cwd"] === "string" && + typeof value["model"] === "string" && + (value["trigger"] === "manual" || value["trigger"] === "auto")); +} +function isStringOrNull(value) { + return typeof value === "string" || value === null; +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function readStdin() { + return new Promise((resolve, reject) => { + let data = ""; + processStdin.setEncoding("utf8"); + processStdin.on("data", (chunk) => { + data += chunk; + }); + processStdin.once("error", reject); + processStdin.once("end", () => { + resolve(data); + }); + }); +} diff --git a/plugins/omo/components/rules/dist/codex-hook-options.d.ts b/plugins/omo/components/rules/dist/codex-hook-options.d.ts new file mode 100644 index 0000000..96545a0 --- /dev/null +++ b/plugins/omo/components/rules/dist/codex-hook-options.d.ts @@ -0,0 +1,5 @@ +export interface CodexRulesHookOptions { + env?: NodeJS.ProcessEnv; + pluginDataRoot?: string; + platform?: NodeJS.Platform; +} diff --git a/plugins/omo/components/rules/dist/codex-hook-options.js b/plugins/omo/components/rules/dist/codex-hook-options.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/plugins/omo/components/rules/dist/codex-hook-options.js @@ -0,0 +1 @@ +export {}; diff --git a/plugins/omo/components/rules/dist/codex-hook.d.ts b/plugins/omo/components/rules/dist/codex-hook.d.ts new file mode 100644 index 0000000..74474f9 --- /dev/null +++ b/plugins/omo/components/rules/dist/codex-hook.d.ts @@ -0,0 +1,47 @@ +import type { CodexRulesHookOptions } from "./codex-hook-options.js"; +export type { CodexRulesHookOptions } from "./codex-hook-options.js"; +export type CodexSessionStartInput = { + session_id: string; + transcript_path: string | null; + cwd: string; + hook_event_name: "SessionStart"; + model: string; + permission_mode: string; + source: "startup" | "resume" | "clear" | "compact"; +}; +export type CodexUserPromptSubmitInput = { + session_id: string; + turn_id: string; + transcript_path: string | null; + cwd: string; + hook_event_name: "UserPromptSubmit"; + model: string; + permission_mode: string; + prompt: string; +}; +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: unknown; + tool_response: unknown; + tool_use_id: string; +}; +export type CodexPostCompactInput = { + session_id: string; + turn_id: string; + transcript_path: string | null; + cwd: string; + hook_event_name: "PostCompact"; + model: string; + trigger: "manual" | "auto"; +}; +export declare function runSessionStartHook(input: CodexSessionStartInput, options?: CodexRulesHookOptions): Promise; +export declare function runPostCompactHook(input: CodexPostCompactInput, options?: CodexRulesHookOptions): Promise; +export declare function runUserPromptSubmitHook(input: CodexUserPromptSubmitInput, options?: CodexRulesHookOptions): Promise; +export declare function runPostToolUseHook(input: CodexPostToolUseInput, options?: CodexRulesHookOptions): Promise; diff --git a/plugins/omo/components/rules/dist/codex-hook.js b/plugins/omo/components/rules/dist/codex-hook.js new file mode 100644 index 0000000..eab9a27 --- /dev/null +++ b/plugins/omo/components/rules/dist/codex-hook.js @@ -0,0 +1,125 @@ +import { configFromEnvironment } from "./config.js"; +import { hasContextPressureMarker, transcriptHasContextPressureMarker } from "./context-pressure.js"; +import { createHookDebugTimer } from "./debug-log.js"; +import { fingerprintDynamicTargets } from "./dynamic-target-fingerprints.js"; +import { formatAdditionalContextOutput } from "./hook-output.js"; +import { displayPath, uniqueStrings } from "./path-utils.js"; +import { claimPostCompactPending, clearSessionState, hasPostCompactPending, hydrateEngineState, isPostCompactRecoveryInProgress, markSessionCompacted, persistEngineState, sessionCachePath, } from "./persistent-cache.js"; +import { withPostCompactBudget } from "./post-compact-budget.js"; +import { claimedPostCompactKind, shouldSkipPostCompactClaim } from "./post-compact-claim.js"; +import { createRulesEngine } from "./rules-engine-factory.js"; +import { runStaticInjection } from "./static-injection.js"; +import { extractCodexToolPaths } from "./tool-paths.js"; +import { filterRulesAlreadyInTranscript } from "./transcript-rule-filter.js"; +export async function runSessionStartHook(input, options = {}) { + const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot); + if (input.source === "clear") { + clearSessionState(cachePath); + } + else if (input.source !== "resume" && input.source !== "compact" && !hasPostCompactPending(cachePath)) { + clearSessionState(cachePath); + } + const postCompactClaim = input.source === "clear" ? "not-pending" : claimPostCompactPending(cachePath, "static"); + const completedPostCompactKind = claimedPostCompactKind(postCompactClaim, "static") ?? + (input.source === "compact" && postCompactClaim === "not-pending" ? "static" : undefined); + if (shouldSkipPostCompactClaim(postCompactClaim, input.source === "compact" && isPostCompactRecoveryInProgress(cachePath, "static"))) { + return ""; + } + const transcriptPath = input.source === "clear" ? null : input.transcript_path; + return runStaticInjection(input.cwd, transcriptPath, "SessionStart", cachePath, options, completedPostCompactKind, { latestCompactedReplacementOnly: completedPostCompactKind !== undefined }, input.model); +} +export async function runPostCompactHook(input, options = {}) { + markSessionCompacted(sessionCachePath(input.session_id, options.pluginDataRoot)); + return ""; +} +export async function runUserPromptSubmitHook(input, options = {}) { + if (hasContextPressureMarker(input.prompt)) { + return ""; + } + const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot); + const postCompactClaim = claimPostCompactPending(cachePath, "static"); + if (postCompactClaim === "not-pending" && transcriptHasContextPressureMarker(input.transcript_path)) { + return ""; + } + const completedPostCompactKind = claimedPostCompactKind(postCompactClaim, "static"); + if (shouldSkipPostCompactClaim(postCompactClaim, isPostCompactRecoveryInProgress(cachePath, "static"))) { + return ""; + } + return runStaticInjection(input.cwd, input.transcript_path, "UserPromptSubmit", cachePath, options, completedPostCompactKind, { latestCompactedReplacementOnly: completedPostCompactKind !== undefined }, input.model); +} +export async function runPostToolUseHook(input, options = {}) { + const debugTimer = createHookDebugTimer("PostToolUse"); + const config = configFromEnvironment(options.env); + debugTimer.lap("config", { disabled: config.disabled, mode: config.mode }); + if (config.disabled || config.mode === "off" || config.mode === "static") { + debugTimer.done({ outputBytes: 0, reason: "disabled" }); + return ""; + } + const targetPaths = extractCodexToolPaths(input, input.cwd); + debugTimer.lap("extract", { + targets: targetPaths.length, + uniqueTargets: uniqueStrings(targetPaths).length, + tool: input.tool_name, + }); + const firstTargetPath = targetPaths[0]; + if (firstTargetPath === undefined) { + debugTimer.done({ outputBytes: 0, reason: "no-target" }); + return ""; + } + const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot); + const postCompactClaim = claimPostCompactPending(cachePath, "dynamic"); + if (postCompactClaim === "not-pending" && transcriptHasContextPressureMarker(input.transcript_path)) { + debugTimer.done({ outputBytes: 0, reason: "context-pressure-transcript" }); + return ""; + } + const completedPostCompactKind = claimedPostCompactKind(postCompactClaim, "dynamic"); + if (shouldSkipPostCompactClaim(postCompactClaim, isPostCompactRecoveryInProgress(cachePath, "dynamic"))) { + debugTimer.done({ outputBytes: 0, reason: "post-compact-recovery-in-progress" }); + return ""; + } + const engine = createRulesEngine(options, completedPostCompactKind !== undefined + ? withPostCompactBudget(config, { model: input.model, transcriptPath: input.transcript_path }) + : config); + hydrateEngineState(engine, cachePath); + debugTimer.lap("hydrate", { + dynamicDedupScopes: engine.state.dynamicDedup.size, + dynamicTargetFingerprints: engine.state.dynamicTargetFingerprints.size, + staticDedup: engine.state.staticDedup.size, + }); + const dynamicTargetFingerprints = fingerprintDynamicTargets(input.cwd, targetPaths, config); + debugTimer.lap("fingerprint", { fingerprints: dynamicTargetFingerprints.length }); + const pendingTargetFingerprints = dynamicTargetFingerprints.filter((target) => engine.state.dynamicTargetFingerprints.get(target.cacheKey) !== target.fingerprint); + debugTimer.lap("pending", { pending: pendingTargetFingerprints.length }); + if (pendingTargetFingerprints.length === 0) { + persistEngineState(engine, cachePath, completedPostCompactKind); + debugTimer.lap("persist", { reason: "no-pending" }); + debugTimer.done({ outputBytes: 0, reason: "no-pending" }); + return ""; + } + const loaded = engine.loadDynamicRules(input.cwd, pendingTargetFingerprints.map((target) => target.targetPath)); + debugTimer.lap("load", { diagnostics: loaded.diagnostics.length, loadedRules: loaded.rules.length }); + const rules = filterRulesAlreadyInTranscript(loaded.rules.filter((rule) => !engine.isStaticInjected(rule) && !engine.isDynamicInjected(rule)), input.transcript_path, (rule) => { + engine.markDynamicInjected(rule); + }, { latestCompactedReplacementOnly: completedPostCompactKind !== undefined }); + debugTimer.lap("filter", { rules: rules.length }); + for (const target of pendingTargetFingerprints) { + engine.state.dynamicTargetFingerprints.set(target.cacheKey, target.fingerprint); + } + if (rules.length === 0) { + persistEngineState(engine, cachePath, completedPostCompactKind); + debugTimer.lap("persist", { reason: "no-rules" }); + debugTimer.done({ outputBytes: 0, reason: "no-rules" }); + return ""; + } + const firstPendingTargetPath = pendingTargetFingerprints[0]?.targetPath ?? firstTargetPath; + const block = engine.formatDynamic(rules, displayPath(input.cwd, firstPendingTargetPath)); + debugTimer.lap("format", { blockChars: block.length, rules: rules.length }); + for (const rule of rules) { + engine.markDynamicInjected(rule); + } + persistEngineState(engine, cachePath, completedPostCompactKind); + debugTimer.lap("persist", { reason: "emit" }); + const output = formatAdditionalContextOutput("PostToolUse", block); + debugTimer.done({ outputBytes: Buffer.byteLength(output), reason: "emit" }); + return output; +} diff --git a/plugins/omo/components/rules/dist/config.d.ts b/plugins/omo/components/rules/dist/config.d.ts new file mode 100644 index 0000000..ef07d08 --- /dev/null +++ b/plugins/omo/components/rules/dist/config.d.ts @@ -0,0 +1,2 @@ +import type { PiRulesConfig } from "./rules/types.js"; +export declare function configFromEnvironment(env?: NodeJS.ProcessEnv): PiRulesConfig; diff --git a/plugins/omo/components/rules/dist/config.js b/plugins/omo/components/rules/dist/config.js new file mode 100644 index 0000000..9b9c9d4 --- /dev/null +++ b/plugins/omo/components/rules/dist/config.js @@ -0,0 +1,89 @@ +import { SOURCE_PRIORITY } from "./rules/constants.js"; +import { defaultConfig } from "./rules/engine.js"; +export function configFromEnvironment(env = process.env) { + const config = defaultConfig(); + const disableBundledRules = isTruthy(firstEnv(env, "CODEX_RULES_DISABLE_BUNDLED", "PI_RULES_DISABLE_BUNDLED")); + config.disabled = isTruthy(firstEnv(env, "CODEX_RULES_DISABLED", "PI_RULES_DISABLED")); + config.mode = parseMode(firstEnv(env, "CODEX_RULES_MODE", "PI_RULES_MODE")) ?? config.mode; + config.maxRuleChars = + parsePositiveInteger(firstEnv(env, "CODEX_RULES_MAX_RULE_CHARS", "PI_RULES_MAX_RULE_CHARS")) ?? + config.maxRuleChars; + config.maxResultChars = + parsePositiveInteger(firstEnv(env, "CODEX_RULES_MAX_RESULT_CHARS", "PI_RULES_MAX_RESULT_CHARS")) ?? + config.maxResultChars; + config.postCompactMaxRuleChars = + parsePositiveInteger(firstEnv(env, "CODEX_RULES_POST_COMPACT_MAX_RULE_CHARS", "PI_RULES_POST_COMPACT_MAX_RULE_CHARS")) ?? config.postCompactMaxRuleChars; + config.postCompactMaxResultChars = + parsePositiveInteger(firstEnv(env, "CODEX_RULES_POST_COMPACT_MAX_RESULT_CHARS", "PI_RULES_POST_COMPACT_MAX_RESULT_CHARS")) ?? config.postCompactMaxResultChars; + config.enabledSources = parseEnabledSources(firstEnv(env, "CODEX_RULES_ENABLED_SOURCES", "PI_RULES_ENABLED_SOURCES"), disableBundledRules); + return config; +} +function firstEnv(env, ...names) { + for (const name of names) { + const value = env[name]; + if (typeof value === "string" && value.trim().length > 0) { + return value; + } + } + return undefined; +} +function isTruthy(value) { + if (value === undefined) + return false; + return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase()); +} +function parseMode(value) { + if (value === undefined) + return undefined; + const normalized = value.trim().toLowerCase(); + switch (normalized) { + case "static": + case "dynamic": + case "both": + case "off": + return normalized; + default: + return undefined; + } +} +function parsePositiveInteger(value) { + if (value === undefined) + return undefined; + const parsed = Number.parseInt(value.trim(), 10); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; +} +function parseEnabledSources(value, disableBundledRules) { + if (value === undefined || value.trim().toLowerCase() === "auto") { + return disableBundledRules ? sourcesWithoutBundledRules() : "auto"; + } + const sources = []; + for (const rawSource of value.split(",")) { + const source = toRuleSource(rawSource.trim()); + if (source === null) { + continue; + } + sources.push(source); + } + const enabledSources = disableBundledRules ? sources.filter((source) => source !== "plugin-bundled") : sources; + return enabledSources; +} +function sourcesWithoutBundledRules() { + return [...SOURCE_PRIORITY.keys()].filter((source) => source !== "plugin-bundled"); +} +function toRuleSource(value) { + switch (value) { + case ".omo/rules": + case ".claude/rules": + case ".cursor/rules": + case ".github/instructions": + case ".github/copilot-instructions.md": + case "CONTEXT.md": + case "plugin-bundled": + case "~/.omo/rules": + case "~/.opencode/rules": + case "~/.claude/rules": + return value; + default: + return null; + } +} diff --git a/plugins/omo/components/rules/dist/context-pressure.d.ts b/plugins/omo/components/rules/dist/context-pressure.d.ts new file mode 100644 index 0000000..ca60911 --- /dev/null +++ b/plugins/omo/components/rules/dist/context-pressure.d.ts @@ -0,0 +1,2 @@ +export declare function hasContextPressureMarker(text: string): boolean; +export declare function transcriptHasContextPressureMarker(transcriptPath: string | null | undefined): boolean; diff --git a/plugins/omo/components/rules/dist/context-pressure.js b/plugins/omo/components/rules/dist/context-pressure.js new file mode 100644 index 0000000..0102909 --- /dev/null +++ b/plugins/omo/components/rules/dist/context-pressure.js @@ -0,0 +1,26 @@ +import { readFileSync } from "node:fs"; +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", +]; +export function hasContextPressureMarker(text) { + const normalizedText = text.toLowerCase(); + return CONTEXT_PRESSURE_MARKERS.some((marker) => normalizedText.includes(marker)); +} +export function transcriptHasContextPressureMarker(transcriptPath) { + if (transcriptPath === undefined || transcriptPath === null) + return false; + try { + return hasContextPressureMarker(readFileSync(transcriptPath, "utf8")); + } + catch (error) { + if (error instanceof Error) + return false; + throw error; + } +} diff --git a/plugins/omo/components/rules/dist/debug-log.d.ts b/plugins/omo/components/rules/dist/debug-log.d.ts new file mode 100644 index 0000000..7b98a50 --- /dev/null +++ b/plugins/omo/components/rules/dist/debug-log.d.ts @@ -0,0 +1,8 @@ +type DebugFieldValue = boolean | number | string | null; +type DebugFields = Record; +export interface HookDebugTimer { + lap(phase: string, fields?: DebugFields): void; + done(fields?: DebugFields): void; +} +export declare function createHookDebugTimer(hookName: string): HookDebugTimer; +export {}; diff --git a/plugins/omo/components/rules/dist/debug-log.js b/plugins/omo/components/rules/dist/debug-log.js new file mode 100644 index 0000000..b8ea751 --- /dev/null +++ b/plugins/omo/components/rules/dist/debug-log.js @@ -0,0 +1,36 @@ +import { performance } from "node:perf_hooks"; +import { debuglog } from "node:util"; +const debug = debuglog("codex-rules"); +const noopTimer = { + lap: () => { }, + done: () => { }, +}; +export function createHookDebugTimer(hookName) { + if (!debug.enabled) { + return noopTimer; + } + const startMs = performance.now(); + let lastMs = startMs; + return { + lap: (phase, fields = {}) => { + const nowMs = performance.now(); + writeDebugLine(hookName, phase, nowMs - lastMs, nowMs - startMs, fields); + lastMs = nowMs; + }, + done: (fields = {}) => { + const nowMs = performance.now(); + writeDebugLine(hookName, "done", nowMs - lastMs, nowMs - startMs, fields); + lastMs = nowMs; + }, + }; +} +function writeDebugLine(hookName, phase, durationMs, totalMs, fields) { + debug("%s phase=%s ms=%s total_ms=%s%s", hookName, phase, durationMs.toFixed(3), totalMs.toFixed(3), formatFields(fields)); +} +function formatFields(fields) { + const entries = Object.entries(fields); + if (entries.length === 0) { + return ""; + } + return ` ${entries.map(([key, value]) => `${key}=${String(value)}`).join(" ")}`; +} diff --git a/plugins/omo/components/rules/dist/dynamic-target-fingerprints.d.ts b/plugins/omo/components/rules/dist/dynamic-target-fingerprints.d.ts new file mode 100644 index 0000000..5e9a40a --- /dev/null +++ b/plugins/omo/components/rules/dist/dynamic-target-fingerprints.d.ts @@ -0,0 +1,7 @@ +import type { PiRulesConfig } from "./rules/types.js"; +export interface DynamicTargetFingerprint { + targetPath: string; + cacheKey: string; + fingerprint: string; +} +export declare function fingerprintDynamicTargets(cwd: string, targetPaths: ReadonlyArray, config: PiRulesConfig): DynamicTargetFingerprint[]; diff --git a/plugins/omo/components/rules/dist/dynamic-target-fingerprints.js b/plugins/omo/components/rules/dist/dynamic-target-fingerprints.js new file mode 100644 index 0000000..6d17cf4 --- /dev/null +++ b/plugins/omo/components/rules/dist/dynamic-target-fingerprints.js @@ -0,0 +1,65 @@ +import { statSync } from "node:fs"; +import { resolve } from "node:path"; +import { isSameOrChildPath, toPosixPath, uniqueStrings } from "./path-utils.js"; +import { createRuleDiscoveryCache, findRuleCandidates } from "./rules/finder.js"; +import { hashContent } from "./rules/matcher.js"; +import { sortCandidates } from "./rules/ordering.js"; +import { findProjectRoot } from "./rules/project-root.js"; +import { disabledSourcesFromConfig } from "./rules/sources.js"; +export function fingerprintDynamicTargets(cwd, targetPaths, config) { + const disabledSources = disabledSourcesFromConfig(config); + const discoveryCache = createRuleDiscoveryCache(); + const cwdProjectRoot = findProjectRoot(cwd); + const fingerprints = []; + for (const targetPath of uniqueStrings(targetPaths)) { + const projectRoot = cwdProjectRoot !== null && isSameOrChildPath(targetPath, cwdProjectRoot) + ? cwdProjectRoot + : findProjectRoot(targetPath); + const findOptions = { + projectRoot, + targetFile: targetPath, + cache: discoveryCache, + }; + if (disabledSources !== undefined) { + findOptions.disabledSources = disabledSources; + } + const candidates = findRuleCandidates(findOptions); + const candidateFingerprint = sortCandidates(candidates).map(fingerprintCandidate).join("\u0001"); + const cacheKey = dynamicTargetCacheKey(targetPath); + fingerprints.push({ + targetPath, + cacheKey, + fingerprint: hashContent([ + "v1", + config.enabledSources === "auto" ? "auto" : config.enabledSources.join(","), + projectRoot ?? "", + cacheKey, + candidateFingerprint, + ].join("\u0000")), + }); + } + return fingerprints; +} +function fingerprintCandidate(candidate) { + return [ + candidate.realPath, + candidate.relativePath, + candidate.source, + candidate.isGlobal ? "global" : "project", + candidate.isSingleFile ? "single" : "multi", + String(candidate.distance), + fileFingerprint(candidate.path), + ].join("\u0000"); +} +function fileFingerprint(filePath) { + try { + const stats = statSync(filePath, { bigint: true }); + return `${stats.mtimeNs}:${stats.ctimeNs}:${stats.size}`; + } + catch { + return "missing"; + } +} +function dynamicTargetCacheKey(targetPath) { + return toPosixPath(resolve(targetPath)); +} diff --git a/plugins/omo/components/rules/dist/hook-output.d.ts b/plugins/omo/components/rules/dist/hook-output.d.ts new file mode 100644 index 0000000..d6f800e --- /dev/null +++ b/plugins/omo/components/rules/dist/hook-output.d.ts @@ -0,0 +1,2 @@ +export type ContextInjectionHookEventName = "SessionStart" | "UserPromptSubmit" | "PostToolUse"; +export declare function formatAdditionalContextOutput(eventName: ContextInjectionHookEventName, additionalContext: string): string; diff --git a/plugins/omo/components/rules/dist/hook-output.js b/plugins/omo/components/rules/dist/hook-output.js new file mode 100644 index 0000000..7552862 --- /dev/null +++ b/plugins/omo/components/rules/dist/hook-output.js @@ -0,0 +1,24 @@ +const MAX_ADDITIONAL_CONTEXT_CHARS = 32_000; +export function formatAdditionalContextOutput(eventName, additionalContext) { + const normalizedContext = limitAdditionalContext(normalizeAdditionalContext(additionalContext)); + if (normalizedContext.length === 0) + return ""; + return `${JSON.stringify({ + hookSpecificOutput: { + hookEventName: eventName, + additionalContext: normalizedContext, + }, + })}\n`; +} +function normalizeAdditionalContext(additionalContext) { + return additionalContext.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim(); +} +function limitAdditionalContext(additionalContext) { + if (additionalContext.length <= MAX_ADDITIONAL_CONTEXT_CHARS) + return additionalContext; + const marker = `\n\n[Truncated hook additional context to ${MAX_ADDITIONAL_CONTEXT_CHARS} chars to avoid Codex context overflow.]`; + if (marker.length >= MAX_ADDITIONAL_CONTEXT_CHARS) + return marker.slice(0, MAX_ADDITIONAL_CONTEXT_CHARS); + const head = additionalContext.slice(0, MAX_ADDITIONAL_CONTEXT_CHARS - marker.length).replace(/[ \t\r\n]+$/, ""); + return `${head}${marker}`; +} diff --git a/plugins/omo/components/rules/dist/path-utils.d.ts b/plugins/omo/components/rules/dist/path-utils.d.ts new file mode 100644 index 0000000..6d9ed33 --- /dev/null +++ b/plugins/omo/components/rules/dist/path-utils.d.ts @@ -0,0 +1,4 @@ +export declare function displayPath(cwd: string, filePath: string): string; +export declare function isSameOrChildPath(childPath: string, parentPath: string): boolean; +export declare function toPosixPath(path: string): string; +export declare function uniqueStrings(values: ReadonlyArray): string[]; diff --git a/plugins/omo/components/rules/dist/path-utils.js b/plugins/omo/components/rules/dist/path-utils.js new file mode 100644 index 0000000..9f2a5f7 --- /dev/null +++ b/plugins/omo/components/rules/dist/path-utils.js @@ -0,0 +1,24 @@ +import { isAbsolute, relative, resolve } from "node:path"; +export function displayPath(cwd, filePath) { + const rel = isAbsolute(filePath) ? relative(cwd, filePath) : filePath; + return toPosixPath(rel); +} +export function isSameOrChildPath(childPath, parentPath) { + const childRelativePath = relative(parentPath, resolve(childPath)); + return childRelativePath === "" || (!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath)); +} +export function toPosixPath(path) { + return path.replaceAll("\\", "/"); +} +export function uniqueStrings(values) { + const uniqueValues = []; + const seenValues = new Set(); + for (const value of values) { + if (seenValues.has(value)) { + continue; + } + seenValues.add(value); + uniqueValues.push(value); + } + return uniqueValues; +} diff --git a/plugins/omo/components/rules/dist/persistent-cache.d.ts b/plugins/omo/components/rules/dist/persistent-cache.d.ts new file mode 100644 index 0000000..c48aa79 --- /dev/null +++ b/plugins/omo/components/rules/dist/persistent-cache.d.ts @@ -0,0 +1,13 @@ +import { type PostCompactPendingKind } from "./post-compact-state.js"; +import type { Engine } from "./rules/engine.js"; +export type PostCompactClaimResult = "claimed" | "not-pending" | "contended"; +export declare function hydrateEngineState(engine: Engine, cachePath: string): void; +export declare function persistEngineState(engine: Engine, cachePath: string, completedPostCompactKind?: PostCompactPendingKind): void; +export declare function clearSessionState(cachePath: string): void; +export declare function markSessionCompacted(cachePath: string): void; +export declare function hasPostCompactPending(cachePath: string): boolean; +export declare function isPostCompactPending(cachePath: string, kind: PostCompactPendingKind): boolean; +export declare function claimPostCompactPending(cachePath: string, kind: PostCompactPendingKind): PostCompactClaimResult; +export declare function isPostCompactRecoveryInProgress(cachePath: string, kind: PostCompactPendingKind): boolean; +export declare function completePostCompactRecovery(cachePath: string, kind: PostCompactPendingKind): void; +export declare function sessionCachePath(sessionId: string, pluginDataRoot: string | undefined): string; diff --git a/plugins/omo/components/rules/dist/persistent-cache.js b/plugins/omo/components/rules/dist/persistent-cache.js new file mode 100644 index 0000000..2ec8209 --- /dev/null +++ b/plugins/omo/components/rules/dist/persistent-cache.js @@ -0,0 +1,169 @@ +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { postCompactKindState, postCompactPendingKinds, postCompactRecoveringKinds, } from "./post-compact-state.js"; +import { SESSION_STATE_LOCK_CONTENDED, withSessionStateLock } from "./session-state-lock.js"; +export function hydrateEngineState(engine, cachePath) { + const state = readSessionState(cachePath); + engine.state.staticDedup.clear(); + engine.state.dynamicDedup.clear(); + engine.state.dynamicTargetFingerprints.clear(); + for (const key of state.staticDedup) { + engine.state.staticDedup.add(key); + } + for (const [scope, keys] of Object.entries(state.dynamicDedup)) { + engine.state.dynamicDedup.set(scope, new Set(keys)); + } + for (const [targetKey, fingerprint] of Object.entries(state.dynamicTargetFingerprints ?? {})) { + engine.state.dynamicTargetFingerprints.set(targetKey, fingerprint); + } +} +export function persistEngineState(engine, cachePath, completedPostCompactKind) { + const currentState = readSessionState(cachePath); + const dynamicDedup = {}; + for (const [scope, keys] of engine.state.dynamicDedup.entries()) { + dynamicDedup[scope] = [...keys]; + } + const postCompactPending = nextPostCompactPending(currentState, completedPostCompactKind); + const postCompactRecovering = nextPostCompactRecovering(currentState, completedPostCompactKind); + writeSessionState(cachePath, { + staticDedup: [...engine.state.staticDedup], + dynamicDedup, + dynamicTargetFingerprints: Object.fromEntries(engine.state.dynamicTargetFingerprints.entries()), + ...(postCompactPending === undefined ? {} : { postCompactPending }), + ...(postCompactRecovering === undefined ? {} : { postCompactRecovering }), + }); +} +export function clearSessionState(cachePath) { + rmSync(cachePath, { force: true }); +} +export function markSessionCompacted(cachePath) { + const state = readSessionState(cachePath); + writeSessionState(cachePath, { + staticDedup: state.staticDedup, + dynamicDedup: state.dynamicDedup, + ...(state.dynamicTargetFingerprints === undefined + ? {} + : { dynamicTargetFingerprints: state.dynamicTargetFingerprints }), + postCompactPending: { static: true, dynamic: true }, + }); +} +export function hasPostCompactPending(cachePath) { + const state = readSessionState(cachePath); + return postCompactPendingKinds(state).size > 0 || postCompactRecoveringKinds(state).size > 0; +} +export function isPostCompactPending(cachePath, kind) { + return postCompactPendingKinds(readSessionState(cachePath)).has(kind); +} +export function claimPostCompactPending(cachePath, kind) { + const result = withSessionStateLock(cachePath, () => { + const state = readSessionState(cachePath); + const pendingKinds = postCompactPendingKinds(state); + if (!pendingKinds.has(kind)) { + return "not-pending"; + } + pendingKinds.delete(kind); + const recoveringKinds = postCompactRecoveringKinds(state); + recoveringKinds.add(kind); + writeSessionState(cachePath, stateWithPostCompactKinds(state, pendingKinds, recoveringKinds)); + return "claimed"; + }); + return result === SESSION_STATE_LOCK_CONTENDED ? "contended" : result; +} +export function isPostCompactRecoveryInProgress(cachePath, kind) { + return postCompactRecoveringKinds(readSessionState(cachePath)).has(kind); +} +export function completePostCompactRecovery(cachePath, kind) { + withSessionStateLock(cachePath, () => { + const state = readSessionState(cachePath); + const pendingKinds = postCompactPendingKinds(state); + const recoveringKinds = postCompactRecoveringKinds(state); + recoveringKinds.delete(kind); + writeSessionState(cachePath, stateWithPostCompactKinds(state, pendingKinds, recoveringKinds)); + }); +} +export function sessionCachePath(sessionId, pluginDataRoot) { + const root = pluginDataRoot ?? process.env["PLUGIN_DATA"] ?? join(homedir(), ".codex", "codex-rules"); + return join(root, "sessions", `${safePathSegment(sessionId)}.json`); +} +function readSessionState(cachePath) { + try { + const parsed = JSON.parse(readFileSync(cachePath, "utf8")); + if (!isSerializedSessionState(parsed)) + return emptyState(); + return parsed; + } + catch { + return emptyState(); + } +} +function writeSessionState(cachePath, state) { + mkdirSync(dirname(cachePath), { recursive: true }); + writeFileSync(cachePath, `${JSON.stringify(state)}\n`); +} +function emptyState() { + return { staticDedup: [], dynamicDedup: {}, dynamicTargetFingerprints: {} }; +} +function nextPostCompactPending(state, completedKind) { + const pendingKinds = postCompactPendingKinds(state); + if (completedKind !== undefined) { + pendingKinds.delete(completedKind); + } + if (pendingKinds.size === 0) { + return undefined; + } + return { + ...(pendingKinds.has("static") ? { static: true } : {}), + ...(pendingKinds.has("dynamic") ? { dynamic: true } : {}), + }; +} +function nextPostCompactRecovering(state, completedKind) { + const recoveringKinds = postCompactRecoveringKinds(state); + if (completedKind !== undefined) { + recoveringKinds.delete(completedKind); + } + return postCompactKindState(recoveringKinds); +} +function stateWithPostCompactKinds(state, pendingKinds, recoveringKinds) { + const postCompactPending = postCompactKindState(pendingKinds); + const postCompactRecovering = postCompactKindState(recoveringKinds); + return { + staticDedup: state.staticDedup, + dynamicDedup: state.dynamicDedup, + ...(state.dynamicTargetFingerprints === undefined + ? {} + : { dynamicTargetFingerprints: state.dynamicTargetFingerprints }), + ...(postCompactPending === undefined ? {} : { postCompactPending }), + ...(postCompactRecovering === undefined ? {} : { postCompactRecovering }), + }; +} +function safePathSegment(value) { + return value.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120) || "unknown-session"; +} +function isSerializedSessionState(value) { + if (!isRecord(value) || !Array.isArray(value["staticDedup"]) || !isRecord(value["dynamicDedup"])) { + return false; + } + const staticDedup = value["staticDedup"]; + const dynamicDedup = value["dynamicDedup"]; + const dynamicTargetFingerprints = value["dynamicTargetFingerprints"]; + const postCompactPending = value["postCompactPending"]; + const postCompactRecovering = value["postCompactRecovering"]; + const compacted = value["compacted"]; + return (staticDedup.every((item) => typeof item === "string") && + Object.values(dynamicDedup).every((item) => Array.isArray(item) && item.every((nestedItem) => typeof nestedItem === "string")) && + (dynamicTargetFingerprints === undefined || + (isRecord(dynamicTargetFingerprints) && + Object.entries(dynamicTargetFingerprints).every(([targetKey, fingerprint]) => typeof targetKey === "string" && typeof fingerprint === "string"))) && + (postCompactPending === undefined || isPostCompactPendingState(postCompactPending)) && + (postCompactRecovering === undefined || isPostCompactPendingState(postCompactRecovering)) && + (compacted === undefined || typeof compacted === "boolean")); +} +function isPostCompactPendingState(value) { + return (isRecord(value) && + (value["static"] === undefined || typeof value["static"] === "boolean") && + (value["dynamic"] === undefined || typeof value["dynamic"] === "boolean")); +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/plugins/omo/components/rules/dist/post-compact-budget.d.ts b/plugins/omo/components/rules/dist/post-compact-budget.d.ts new file mode 100644 index 0000000..eeffa01 --- /dev/null +++ b/plugins/omo/components/rules/dist/post-compact-budget.d.ts @@ -0,0 +1,6 @@ +import type { PiRulesConfig } from "./rules/types.js"; +export interface PostCompactBudgetContext { + readonly model: string; + readonly transcriptPath: string | null; +} +export declare function withPostCompactBudget(config: PiRulesConfig, context?: PostCompactBudgetContext): PiRulesConfig; diff --git a/plugins/omo/components/rules/dist/post-compact-budget.js b/plugins/omo/components/rules/dist/post-compact-budget.js new file mode 100644 index 0000000..ddc21f8 --- /dev/null +++ b/plugins/omo/components/rules/dist/post-compact-budget.js @@ -0,0 +1,74 @@ +import { hasContextPressureMarker } from "./context-pressure.js"; +import { readTranscriptSearchText } from "./transcript-search.js"; +const DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT = 95; +const ESTIMATED_TRANSCRIPT_CHARS_PER_TOKEN = 3; +const PROJECTED_INJECTION_CHARS_PER_TOKEN = 2; +const POST_COMPACT_RESERVED_CONTEXT_PERCENT = 5; +const POST_COMPACT_MIN_RESERVED_TOKENS = 8_000; +const POST_COMPACT_MIN_GUIDE_CHARS = 500; +const FALLBACK_CONTEXT_WINDOW_TOKENS = 200_000; +const MODEL_CONTEXT_BUDGETS = [ + { slug: "gpt-5.5", contextWindowTokens: 272_000, effectivePercent: DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT }, + { slug: "gpt-5.4-mini", contextWindowTokens: 272_000, effectivePercent: DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT }, + { + slug: "codex-auto-review", + contextWindowTokens: 272_000, + effectivePercent: DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT, + }, +]; +export function withPostCompactBudget(config, context) { + const postCompactMaxResultChars = dynamicPostCompactMaxResultChars(context) ?? config.postCompactMaxResultChars; + const maxResultChars = Math.min(config.maxResultChars, config.postCompactMaxResultChars, postCompactMaxResultChars); + const maxRuleChars = Math.min(config.maxRuleChars, config.postCompactMaxRuleChars, maxResultChars); + return { + ...config, + maxRuleChars, + maxResultChars, + }; +} +function dynamicPostCompactMaxResultChars(context) { + if (context === undefined || context.transcriptPath === null) { + return undefined; + } + const transcript = estimateTranscript(context.transcriptPath); + if (transcript === undefined) { + return undefined; + } + if (hasContextPressureMarker(transcript.text)) { + return POST_COMPACT_MIN_GUIDE_CHARS; + } + const modelBudget = modelContextBudgetFor(context.model) ?? fallbackModelContextBudget(); + const effectiveContextWindow = Math.floor((modelBudget.contextWindowTokens * modelBudget.effectivePercent) / 100); + const reservedTokens = Math.max(POST_COMPACT_MIN_RESERVED_TOKENS, Math.floor((effectiveContextWindow * POST_COMPACT_RESERVED_CONTEXT_PERCENT) / 100)); + const injectableTokens = Math.max(0, effectiveContextWindow - reservedTokens - transcript.tokens); + return Math.max(POST_COMPACT_MIN_GUIDE_CHARS, Math.floor(injectableTokens * PROJECTED_INJECTION_CHARS_PER_TOKEN)); +} +function modelContextBudgetFor(model) { + const normalizedModel = model.trim().toLowerCase(); + for (const budget of MODEL_CONTEXT_BUDGETS) { + if (normalizedModel === budget.slug || + normalizedModel.endsWith(`.${budget.slug}`) || + normalizedModel.endsWith(`/${budget.slug}`)) { + return budget; + } + } + return undefined; +} +function fallbackModelContextBudget() { + return { + slug: "unknown", + contextWindowTokens: FALLBACK_CONTEXT_WINDOW_TOKENS, + effectivePercent: DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT, + }; +} +function estimateTranscript(transcriptPath) { + const transcriptText = readTranscriptSearchText(transcriptPath, { latestCompactedReplacementOnly: true }) ?? + readTranscriptSearchText(transcriptPath); + if (transcriptText === null) { + return undefined; + } + return { + text: transcriptText, + tokens: Math.ceil(Buffer.byteLength(transcriptText, "utf8") / ESTIMATED_TRANSCRIPT_CHARS_PER_TOKEN), + }; +} diff --git a/plugins/omo/components/rules/dist/post-compact-claim.d.ts b/plugins/omo/components/rules/dist/post-compact-claim.d.ts new file mode 100644 index 0000000..101b277 --- /dev/null +++ b/plugins/omo/components/rules/dist/post-compact-claim.d.ts @@ -0,0 +1,4 @@ +import type { PostCompactClaimResult } from "./persistent-cache.js"; +import type { PostCompactPendingKind } from "./post-compact-state.js"; +export declare function claimedPostCompactKind(result: PostCompactClaimResult, kind: T): T | undefined; +export declare function shouldSkipPostCompactClaim(result: PostCompactClaimResult, recoveryInProgress: boolean): boolean; diff --git a/plugins/omo/components/rules/dist/post-compact-claim.js b/plugins/omo/components/rules/dist/post-compact-claim.js new file mode 100644 index 0000000..b936f73 --- /dev/null +++ b/plugins/omo/components/rules/dist/post-compact-claim.js @@ -0,0 +1,6 @@ +export function claimedPostCompactKind(result, kind) { + return result === "claimed" ? kind : undefined; +} +export function shouldSkipPostCompactClaim(result, recoveryInProgress) { + return result === "contended" || (result === "not-pending" && recoveryInProgress); +} diff --git a/plugins/omo/components/rules/dist/post-compact-state.d.ts b/plugins/omo/components/rules/dist/post-compact-state.d.ts new file mode 100644 index 0000000..5a20a81 --- /dev/null +++ b/plugins/omo/components/rules/dist/post-compact-state.d.ts @@ -0,0 +1,13 @@ +export type PostCompactPendingKind = "static" | "dynamic"; +export interface PostCompactPendingState { + static?: boolean; + dynamic?: boolean; +} +export interface PostCompactStateFields { + readonly postCompactPending?: PostCompactPendingState; + readonly postCompactRecovering?: PostCompactPendingState; + readonly compacted?: boolean; +} +export declare function postCompactKindState(kinds: ReadonlySet): PostCompactPendingState | undefined; +export declare function postCompactPendingKinds(state: PostCompactStateFields): Set; +export declare function postCompactRecoveringKinds(state: PostCompactStateFields): Set; diff --git a/plugins/omo/components/rules/dist/post-compact-state.js b/plugins/omo/components/rules/dist/post-compact-state.js new file mode 100644 index 0000000..6953252 --- /dev/null +++ b/plugins/omo/components/rules/dist/post-compact-state.js @@ -0,0 +1,29 @@ +export function postCompactKindState(kinds) { + if (kinds.size === 0) { + return undefined; + } + return { + ...(kinds.has("static") ? { static: true } : {}), + ...(kinds.has("dynamic") ? { dynamic: true } : {}), + }; +} +export function postCompactPendingKinds(state) { + const pendingKinds = new Set(); + if (state.compacted === true || state.postCompactPending?.static === true) { + pendingKinds.add("static"); + } + if (state.compacted === true || state.postCompactPending?.dynamic === true) { + pendingKinds.add("dynamic"); + } + return pendingKinds; +} +export function postCompactRecoveringKinds(state) { + const recoveringKinds = new Set(); + if (state.postCompactRecovering?.static === true) { + recoveringKinds.add("static"); + } + if (state.postCompactRecovering?.dynamic === true) { + recoveringKinds.add("dynamic"); + } + return recoveringKinds; +} diff --git a/plugins/omo/components/rules/dist/rules-engine-factory.d.ts b/plugins/omo/components/rules/dist/rules-engine-factory.d.ts new file mode 100644 index 0000000..aef9fb9 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules-engine-factory.d.ts @@ -0,0 +1,6 @@ +interface RulesEngineFactoryOptions { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; +} +export declare function createRulesEngine(options: RulesEngineFactoryOptions, config?: import("./rules/types.js").PiRulesConfig): import("./rules/engine-types.js").Engine; +export {}; diff --git a/plugins/omo/components/rules/dist/rules-engine-factory.js b/plugins/omo/components/rules/dist/rules-engine-factory.js new file mode 100644 index 0000000..bebcce0 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules-engine-factory.js @@ -0,0 +1,20 @@ +import { readFileSync } from "node:fs"; +import { configFromEnvironment } from "./config.js"; +import { createEngine } from "./rules/engine.js"; +import { findRuleCandidates } from "./rules/finder.js"; +import { findProjectRoot } from "./rules/project-root.js"; +export function createRulesEngine(options, config = configFromEnvironment(options.env)) { + const platform = options.platform ?? process.platform; + return createEngine(config, { + findCandidates: (finderOptions) => findRuleCandidates({ ...finderOptions, platform }), + findProjectRoot, + readFile: (path) => { + try { + return readFileSync(path, "utf8"); + } + catch { + return null; + } + }, + }); +} diff --git a/plugins/omo/components/rules/dist/rules/cache.d.ts b/plugins/omo/components/rules/dist/rules/cache.d.ts new file mode 100644 index 0000000..03c922e --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/cache.d.ts @@ -0,0 +1,9 @@ +import type { LoadedRule, SessionState } from "./types.js"; +export declare function createSessionState(cwd?: string): SessionState; +export declare function staticDedupKey(cwd: string, rulePath: string, contentHash: string): string; +export declare function dynamicDedupKey(rulePath: string, contentHash: string): string; +export declare function markStaticInjected(state: SessionState, rule: LoadedRule): boolean; +export declare function markDynamicInjected(state: SessionState, rule: LoadedRule): boolean; +export declare function isStaticInjected(state: SessionState, rule: LoadedRule): boolean; +export declare function isDynamicInjected(state: SessionState, rule: LoadedRule): boolean; +export declare function clearSession(state: SessionState): void; diff --git a/plugins/omo/components/rules/dist/rules/cache.js b/plugins/omo/components/rules/dist/rules/cache.js new file mode 100644 index 0000000..459b78c --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/cache.js @@ -0,0 +1,51 @@ +const DYNAMIC_SESSION_KEY = "__pi-rules-session__"; +export function createSessionState(cwd) { + return { + cwd, + staticDedup: new Set(), + dynamicDedup: new Map(), + dynamicTargetFingerprints: new Map(), + loadedRules: [], + diagnostics: [], + }; +} +export function staticDedupKey(cwd, rulePath, contentHash) { + return `${cwd}::${rulePath}::${contentHash}`; +} +export function dynamicDedupKey(rulePath, contentHash) { + return `${rulePath}::${contentHash}`; +} +export function markStaticInjected(state, rule) { + const key = staticDedupKey(state.cwd ?? "", rule.realPath, rule.contentHash); + if (state.staticDedup.has(key)) { + return false; + } + state.staticDedup.add(key); + return true; +} +export function markDynamicInjected(state, rule) { + let keys = state.dynamicDedup.get(DYNAMIC_SESSION_KEY); + if (keys === undefined) { + keys = new Set(); + state.dynamicDedup.set(DYNAMIC_SESSION_KEY, keys); + } + const key = dynamicDedupKey(rule.realPath, rule.contentHash); + if (keys.has(key)) { + return false; + } + keys.add(key); + return true; +} +export function isStaticInjected(state, rule) { + return state.staticDedup.has(staticDedupKey(state.cwd ?? "", rule.realPath, rule.contentHash)); +} +export function isDynamicInjected(state, rule) { + return state.dynamicDedup.get(DYNAMIC_SESSION_KEY)?.has(dynamicDedupKey(rule.realPath, rule.contentHash)) === true; +} +export function clearSession(state) { + state.staticDedup.clear(); + state.dynamicDedup.clear(); + state.dynamicTargetFingerprints.clear(); + state.loadedRules.length = 0; + state.diagnostics.length = 0; +} diff --git a/plugins/omo/components/rules/dist/rules/constants.d.ts b/plugins/omo/components/rules/dist/rules/constants.d.ts new file mode 100644 index 0000000..d6d2927 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/constants.d.ts @@ -0,0 +1,58 @@ +import type { RuleSource } from "./types.js"; +/** + * Project root marker files / directories used by `findProjectRoot`. + * Walks UP from cwd until any of these is found in the directory. + */ +export declare const PROJECT_MARKERS: readonly string[]; +/** + * Project rule subdirectories. First tuple element is the parent dir under + * the project root, second is the subdir scanned recursively. + */ +export declare const PROJECT_RULE_SUBDIRS: ReadonlyArray; +/** + * Single-file project rules (always apply, frontmatter optional). + */ +export declare const PROJECT_SINGLE_FILES: readonly string[]; +/** + * User-home rule directories. + */ +export declare const USER_HOME_RULE_SUBDIRS: readonly string[]; +/** + * User-home single-file rules. The first one to exist wins per "first-match" semantics. + */ +export declare const USER_HOME_SINGLE_FILES: readonly string[]; +/** + * Bundled plugin rule directory relative to the rules component root. + */ +export declare const BUNDLED_RULE_SUBDIR = "bundled-rules"; +/** + * File extensions accepted as rule files in scanned directories. + */ +export declare const RULE_FILE_EXTENSIONS: readonly string[]; +/** + * Per-rule source priority for deterministic ordering. Lower = earlier. + */ +export declare const SOURCE_PRIORITY: ReadonlyMap; +/** + * Distance value assigned to global / user-home rules. + */ +export declare const GLOBAL_DISTANCE = 9999; +/** + * Per-rule body character cap (default). + */ +export declare const DEFAULT_MAX_RULE_CHARS = 12000; +export declare const DEFAULT_MAX_SCAN_FILES = 1000; +/** + * Total injected chars per tool result (default). + */ +export declare const DEFAULT_MAX_RESULT_CHARS = 40000; +export declare const DEFAULT_POST_COMPACT_MAX_RULE_CHARS = 3500; +export declare const DEFAULT_POST_COMPACT_MAX_RESULT_CHARS = 4000; +/** + * Truncation marker template. `{path}` is replaced with the relative path. + */ +export declare const TRUNCATION_NOTICE = "\n\n[Truncated. Full: {path}]"; +/** + * Directories excluded by the recursive scanner regardless of glob settings. + */ +export declare const SCANNER_EXCLUDED_DIRS: readonly string[]; diff --git a/plugins/omo/components/rules/dist/rules/constants.js b/plugins/omo/components/rules/dist/rules/constants.js new file mode 100644 index 0000000..1b5fd51 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/constants.js @@ -0,0 +1,89 @@ +/** + * Project root marker files / directories used by `findProjectRoot`. + * Walks UP from cwd until any of these is found in the directory. + */ +export const PROJECT_MARKERS = [ + ".git", + "pnpm-workspace.yaml", + "package.json", + "pyproject.toml", + "Cargo.toml", + "go.mod", + ".venv", +]; +/** + * Project rule subdirectories. First tuple element is the parent dir under + * the project root, second is the subdir scanned recursively. + */ +export const PROJECT_RULE_SUBDIRS = [ + [".omo", "rules"], + [".claude", "rules"], + [".cursor", "rules"], + [".github", "instructions"], +]; +/** + * Single-file project rules (always apply, frontmatter optional). + */ +export const PROJECT_SINGLE_FILES = [".github/copilot-instructions.md", "CONTEXT.md"]; +/** + * User-home rule directories. + */ +export const USER_HOME_RULE_SUBDIRS = [".omo/rules", ".opencode/rules", ".claude/rules"]; +/** + * User-home single-file rules. The first one to exist wins per "first-match" semantics. + */ +export const USER_HOME_SINGLE_FILES = []; +/** + * Bundled plugin rule directory relative to the rules component root. + */ +export const BUNDLED_RULE_SUBDIR = "bundled-rules"; +/** + * File extensions accepted as rule files in scanned directories. + */ +export const RULE_FILE_EXTENSIONS = [".md", ".mdc"]; +/** + * Per-rule source priority for deterministic ordering. Lower = earlier. + */ +export const SOURCE_PRIORITY = new Map([ + [".omo/rules", 0], + [".claude/rules", 1], + [".cursor/rules", 2], + [".github/instructions", 3], + [".github/copilot-instructions.md", 4], + ["CONTEXT.md", 7], + ["~/.omo/rules", 100], + ["~/.opencode/rules", 101], + ["~/.claude/rules", 102], + ["plugin-bundled", 200], +]); +/** + * Distance value assigned to global / user-home rules. + */ +export const GLOBAL_DISTANCE = 9999; +/** + * Per-rule body character cap (default). + */ +export const DEFAULT_MAX_RULE_CHARS = 12000; +export const DEFAULT_MAX_SCAN_FILES = 1000; +/** + * Total injected chars per tool result (default). + */ +export const DEFAULT_MAX_RESULT_CHARS = 40000; +export const DEFAULT_POST_COMPACT_MAX_RULE_CHARS = 3500; +export const DEFAULT_POST_COMPACT_MAX_RESULT_CHARS = 4000; +/** + * Truncation marker template. `{path}` is replaced with the relative path. + */ +export const TRUNCATION_NOTICE = "\n\n[Truncated. Full: {path}]"; +/** + * Directories excluded by the recursive scanner regardless of glob settings. + */ +export const SCANNER_EXCLUDED_DIRS = [ + "node_modules", + ".git", + "dist", + "build", + ".turbo", + ".next", + "coverage", +]; diff --git a/plugins/omo/components/rules/dist/rules/engine-dynamic-cache.d.ts b/plugins/omo/components/rules/dist/rules/engine-dynamic-cache.d.ts new file mode 100644 index 0000000..4d43aad --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/engine-dynamic-cache.d.ts @@ -0,0 +1,5 @@ +import type { CandidateDiscoveryCache, DynamicMatchCache, EngineDeps } from "./engine-types.js"; +import type { matchRule } from "./matcher.js"; +import type { LoadedRule, MatchReason, RuleCandidate } from "./types.js"; +export declare function matchDynamicRuleCached(cache: DynamicMatchCache, projectRoot: string | null, targetFile: string, candidate: RuleCandidate, loadedRule: LoadedRule, matchRuleImpl: typeof matchRule): MatchReason | null; +export declare function findSortedCandidatesCached(cache: CandidateDiscoveryCache, findCandidates: EngineDeps["findCandidates"], options: Parameters[0]): RuleCandidate[]; diff --git a/plugins/omo/components/rules/dist/rules/engine-dynamic-cache.js b/plugins/omo/components/rules/dist/rules/engine-dynamic-cache.js new file mode 100644 index 0000000..748a64a --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/engine-dynamic-cache.js @@ -0,0 +1,60 @@ +import { dirname, resolve } from "node:path"; +import { pathBasesForTarget, toPosixPath } from "./engine-paths.js"; +import { sortCandidates } from "./ordering.js"; +const MAX_DYNAMIC_MATCH_CACHE_ENTRIES = 4096; +export function matchDynamicRuleCached(cache, projectRoot, targetFile, candidate, loadedRule, matchRuleImpl) { + const cacheKey = dynamicMatchCacheKey(projectRoot, targetFile, candidate, loadedRule.contentHash); + if (cache.has(cacheKey)) { + const cachedReason = cache.get(cacheKey) ?? null; + cache.delete(cacheKey); + cache.set(cacheKey, cachedReason); + return cachedReason; + } + const matchResult = matchRuleImpl({ + frontmatter: loadedRule.frontmatter, + isSingleFile: candidate.isSingleFile, + pathBases: pathBasesForTarget(projectRoot, targetFile, candidate), + }); + const reason = matchResult.matched ? matchResult.reason : null; + setDynamicMatchCacheEntry(cache, cacheKey, reason); + return reason; +} +export function findSortedCandidatesCached(cache, findCandidates, options) { + const cacheKey = candidateDiscoveryCacheKey(options); + const cached = cache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + const candidates = sortCandidates(findCandidates(options)); + cache.set(cacheKey, candidates); + return candidates; +} +function setDynamicMatchCacheEntry(cache, cacheKey, reason) { + if (cache.size >= MAX_DYNAMIC_MATCH_CACHE_ENTRIES) { + const oldestCacheKey = cache.keys().next().value; + if (oldestCacheKey !== undefined) { + cache.delete(oldestCacheKey); + } + } + cache.set(cacheKey, reason); +} +function dynamicMatchCacheKey(projectRoot, targetFile, candidate, contentHash) { + return [ + projectRoot ?? "", + toPosixPath(resolve(targetFile)), + candidate.realPath, + candidate.relativePath, + candidate.source, + candidate.isGlobal ? "global" : "project", + candidate.isSingleFile ? "single" : "multi", + String(candidate.distance), + contentHash, + ].join("\0"); +} +function candidateDiscoveryCacheKey(options) { + return [ + options.projectRoot ?? "", + options.targetFile === null ? "" : dirname(resolve(options.targetFile)), + ...[...(options.disabledSources ?? [])].sort(), + ].join("\0"); +} diff --git a/plugins/omo/components/rules/dist/rules/engine-dynamic-loader.d.ts b/plugins/omo/components/rules/dist/rules/engine-dynamic-loader.d.ts new file mode 100644 index 0000000..ca69227 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/engine-dynamic-loader.d.ts @@ -0,0 +1,6 @@ +import type { DynamicMatchCache, EngineDeps } from "./engine-types.js"; +import type { LoadedRule, PiRulesConfig, RuleDiagnostic } from "./types.js"; +export declare function loadDynamicCandidates(config: PiRulesConfig, deps: EngineDeps, cwd: string, targetPaths: ReadonlyArray, dynamicMatchCache: DynamicMatchCache): { + rules: LoadedRule[]; + diagnostics: RuleDiagnostic[]; +}; diff --git a/plugins/omo/components/rules/dist/rules/engine-dynamic-loader.js b/plugins/omo/components/rules/dist/rules/engine-dynamic-loader.js new file mode 100644 index 0000000..761c3a5 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/engine-dynamic-loader.js @@ -0,0 +1,61 @@ +import { findSortedCandidatesCached, matchDynamicRuleCached } from "./engine-dynamic-cache.js"; +import { loadCandidate, ruleDedupKey } from "./engine-loader.js"; +import { isSameOrChildPath } from "./engine-paths.js"; +import { createRuleDiscoveryCache } from "./finder.js"; +import { matchRule } from "./matcher.js"; +import { sortCandidates } from "./ordering.js"; +import { disabledSourcesFromConfig } from "./sources.js"; +export function loadDynamicCandidates(config, deps, cwd, targetPaths, dynamicMatchCache) { + const rules = []; + const diagnostics = []; + const seenRules = new Set(); + const loadedRuleContent = new Map(); + const projectMembership = new Map(); + const disabledSources = disabledSourcesFromConfig(config); + const discoveryCache = createRuleDiscoveryCache(); + const candidateDiscoveryCache = new Map(); + const cwdProjectRoot = deps.findProjectRoot(cwd); + for (const targetFile of uniqueStrings(targetPaths)) { + const projectRoot = cwdProjectRoot !== null && isSameOrChildPath(targetFile, cwdProjectRoot) + ? cwdProjectRoot + : deps.findProjectRoot(targetFile); + const findOptions = { + projectRoot, + targetFile, + cache: discoveryCache, + }; + if (disabledSources !== undefined) { + findOptions.disabledSources = disabledSources; + } + const candidates = findSortedCandidatesCached(candidateDiscoveryCache, deps.findCandidates, findOptions); + for (const candidate of candidates) { + const loadedRule = loadCandidate(candidate, deps, diagnostics, projectRoot, loadedRuleContent, projectMembership); + if (loadedRule === null) { + continue; + } + const matchReason = matchDynamicRuleCached(dynamicMatchCache, projectRoot, targetFile, candidate, loadedRule, deps.matchRule ?? matchRule); + if (matchReason === null) { + continue; + } + const dedupKey = ruleDedupKey(loadedRule); + if (seenRules.has(dedupKey)) { + continue; + } + seenRules.add(dedupKey); + rules.push({ ...loadedRule, matchReason }); + } + } + return { rules: sortCandidates(rules), diagnostics }; +} +function uniqueStrings(values) { + const uniqueValues = []; + const seenValues = new Set(); + for (const value of values) { + if (seenValues.has(value)) { + continue; + } + seenValues.add(value); + uniqueValues.push(value); + } + return uniqueValues; +} diff --git a/plugins/omo/components/rules/dist/rules/engine-loader.d.ts b/plugins/omo/components/rules/dist/rules/engine-loader.d.ts new file mode 100644 index 0000000..bfd3a10 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/engine-loader.d.ts @@ -0,0 +1,7 @@ +import type { CandidateProjectMembership, EngineDeps, LoadedRuleContent } from "./engine-types.js"; +import type { LoadedRule, MatchReason, RuleCandidate, RuleDiagnostic } from "./types.js"; +export declare function loadCandidate(candidate: RuleCandidate, deps: EngineDeps, diagnostics: RuleDiagnostic[], projectRoot: string | null, loadedRuleContent?: Map, projectMembership?: CandidateProjectMembership): (LoadedRule & { + matchReason: MatchReason; +}) | null; +export declare function ruleDedupKey(rule: LoadedRule): string; +export declare function staticMatchReason(rule: LoadedRule): MatchReason | null; diff --git a/plugins/omo/components/rules/dist/rules/engine-loader.js b/plugins/omo/components/rules/dist/rules/engine-loader.js new file mode 100644 index 0000000..0e288d0 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/engine-loader.js @@ -0,0 +1,60 @@ +import { isCandidateWithinProjectCached } from "./engine-paths.js"; +import { hashContent } from "./matcher.js"; +import { parseRule } from "./parser.js"; +export function loadCandidate(candidate, deps, diagnostics, projectRoot, loadedRuleContent, projectMembership) { + if (!isCandidateWithinProjectCached(candidate, projectRoot, projectMembership)) { + diagnostics.push({ + severity: "warning", + source: candidate.path, + message: "Rule file resolves outside project root", + }); + return null; + } + const cachedContent = loadedRuleContent?.get(candidate.realPath); + if (cachedContent !== undefined) { + return loadedRuleFromContent(candidate, cachedContent, diagnostics); + } + const content = deps.readFile(candidate.path); + if (content === null) { + loadedRuleContent?.set(candidate.realPath, null); + diagnostics.push({ severity: "warning", source: candidate.path, message: "Unable to read rule file" }); + return null; + } + const parsed = parseRule(content); + const loadedContent = { + frontmatter: parsed.frontmatter, + body: parsed.body, + contentHash: hashContent(content), + ...(parsed.diagnostic === undefined ? {} : { diagnostic: parsed.diagnostic }), + }; + loadedRuleContent?.set(candidate.realPath, loadedContent); + return loadedRuleFromContent(candidate, loadedContent, diagnostics); +} +export function ruleDedupKey(rule) { + return `${rule.realPath}::${rule.contentHash}`; +} +export function staticMatchReason(rule) { + if (rule.frontmatter.alwaysApply === true) { + return "alwaysApply"; + } + if (rule.isSingleFile) { + return "single-file"; + } + return null; +} +function loadedRuleFromContent(candidate, content, diagnostics) { + if (content === null) { + diagnostics.push({ severity: "warning", source: candidate.path, message: "Unable to read rule file" }); + return null; + } + if (content.diagnostic !== undefined) { + diagnostics.push({ severity: "warning", source: candidate.path, message: content.diagnostic }); + } + return { + ...candidate, + frontmatter: content.frontmatter, + body: content.body, + contentHash: content.contentHash, + matchReason: { kind: "no-match" }, + }; +} diff --git a/plugins/omo/components/rules/dist/rules/engine-paths.d.ts b/plugins/omo/components/rules/dist/rules/engine-paths.d.ts new file mode 100644 index 0000000..85e49ec --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/engine-paths.d.ts @@ -0,0 +1,11 @@ +import type { CandidateProjectMembership } from "./engine-types.js"; +import type { RuleCandidate } from "./types.js"; +export declare function isCandidateWithinProjectCached(candidate: RuleCandidate, projectRoot: string | null, projectMembership: CandidateProjectMembership | undefined): boolean; +export declare function isSameOrChildPath(childPath: string, parentPath: string): boolean; +export declare function isRootSingleFile(candidate: RuleCandidate): boolean; +export declare function pathBasesForTarget(projectRoot: string | null, targetFile: string, candidate: RuleCandidate): { + projectRelative: string; + scopeRelative?: string; + basename: string; +}; +export declare function toPosixPath(path: string): string; diff --git a/plugins/omo/components/rules/dist/rules/engine-paths.js b/plugins/omo/components/rules/dist/rules/engine-paths.js new file mode 100644 index 0000000..bc26db3 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/engine-paths.js @@ -0,0 +1,75 @@ +import { realpathSync } from "node:fs"; +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { PROJECT_SINGLE_FILES } from "./constants.js"; +const ROOT_SINGLE_FILE_SOURCES = new Set(PROJECT_SINGLE_FILES.filter((source) => !source.includes("/"))); +export function isCandidateWithinProjectCached(candidate, projectRoot, projectMembership) { + if (projectMembership === undefined) { + return isCandidateWithinProject(candidate, projectRoot); + } + const cacheKey = `${projectRoot ?? ""}\0${candidate.realPath}`; + const cached = projectMembership.get(cacheKey); + if (cached !== undefined) { + return cached; + } + const isWithinProject = isCandidateWithinProject(candidate, projectRoot); + projectMembership.set(cacheKey, isWithinProject); + return isWithinProject; +} +export function isSameOrChildPath(childPath, parentPath) { + const childRelativePath = relative(parentPath, resolve(childPath)); + return childRelativePath === "" || (!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath)); +} +export function isRootSingleFile(candidate) { + return candidate.distance === 0 && candidate.isSingleFile && ROOT_SINGLE_FILE_SOURCES.has(candidate.source); +} +export function pathBasesForTarget(projectRoot, targetFile, candidate) { + const targetBasename = basename(targetFile); + if (projectRoot === null) { + return { projectRelative: targetBasename, basename: targetBasename }; + } + const projectRelative = toPosixPath(relative(projectRoot, targetFile)); + const scopeDirectory = scopeDirectoryForCandidate(projectRoot, candidate); + if (scopeDirectory === null) { + return { projectRelative, basename: targetBasename }; + } + return { + projectRelative, + scopeRelative: toPosixPath(relative(scopeDirectory, targetFile)), + basename: targetBasename, + }; +} +export function toPosixPath(path) { + return path.replaceAll("\\", "/"); +} +function isCandidateWithinProject(candidate, projectRoot) { + if (candidate.isGlobal) { + return true; + } + if (projectRoot === null) { + return false; + } + const relativeRealPath = relative(realPathOrResolved(projectRoot), realPathOrResolved(candidate.realPath)); + return relativeRealPath === "" || (!relativeRealPath.startsWith("..") && !isAbsolute(relativeRealPath)); +} +function realPathOrResolved(path) { + try { + return realpathSync.native(path); + } + catch { + return resolve(path); + } +} +function scopeDirectoryForCandidate(projectRoot, candidate) { + if (candidate.isGlobal) { + return null; + } + if (candidate.isSingleFile) { + return dirname(candidate.path); + } + const sourceIndex = candidate.relativePath.indexOf(candidate.source); + if (sourceIndex === -1) { + return projectRoot; + } + const scopeRelativeDirectory = candidate.relativePath.slice(0, sourceIndex).replace(/\/$/, ""); + return scopeRelativeDirectory.length === 0 ? projectRoot : join(projectRoot, scopeRelativeDirectory); +} diff --git a/plugins/omo/components/rules/dist/rules/engine-static-loader.d.ts b/plugins/omo/components/rules/dist/rules/engine-static-loader.d.ts new file mode 100644 index 0000000..63e2884 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/engine-static-loader.d.ts @@ -0,0 +1,6 @@ +import type { EngineDeps } from "./engine-types.js"; +import type { LoadedRule, RuleCandidate, RuleDiagnostic } from "./types.js"; +export declare function loadStaticCandidates(candidates: ReadonlyArray, deps: EngineDeps, projectRoot: string | null): { + rules: LoadedRule[]; + diagnostics: RuleDiagnostic[]; +}; diff --git a/plugins/omo/components/rules/dist/rules/engine-static-loader.js b/plugins/omo/components/rules/dist/rules/engine-static-loader.js new file mode 100644 index 0000000..3643734 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/engine-static-loader.js @@ -0,0 +1,29 @@ +import { loadCandidate, staticMatchReason } from "./engine-loader.js"; +import { isRootSingleFile } from "./engine-paths.js"; +import { sortCandidates } from "./ordering.js"; +export function loadStaticCandidates(candidates, deps, projectRoot) { + const rules = []; + const diagnostics = []; + let rootSingleFileSelected = false; + for (const candidate of sortCandidates(candidates)) { + if (isDedupedRootSingleFile(candidate, rootSingleFileSelected)) { + continue; + } + const loadedRule = loadCandidate(candidate, deps, diagnostics, projectRoot); + if (loadedRule === null) { + continue; + } + const matchReason = staticMatchReason(loadedRule); + if (matchReason === null) { + continue; + } + if (isRootSingleFile(candidate)) { + rootSingleFileSelected = true; + } + rules.push({ ...loadedRule, matchReason }); + } + return { rules: sortCandidates(rules), diagnostics }; +} +function isDedupedRootSingleFile(candidate, rootSingleFileSelected) { + return rootSingleFileSelected && isRootSingleFile(candidate); +} diff --git a/plugins/omo/components/rules/dist/rules/engine-types.d.ts b/plugins/omo/components/rules/dist/rules/engine-types.d.ts new file mode 100644 index 0000000..f6c4772 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/engine-types.d.ts @@ -0,0 +1,44 @@ +import type { RuleDiscoveryCache } from "./finder.js"; +import type { matchRule } from "./matcher.js"; +import type { LoadedRule, MatchReason, PiRulesConfig, RuleCandidate, RuleDiagnostic, SessionState } from "./types.js"; +export interface LoadedRuleContent { + frontmatter: LoadedRule["frontmatter"]; + body: string; + contentHash: string; + diagnostic?: string; +} +export type CandidateProjectMembership = Map; +export type CandidateDiscoveryCache = Map; +export type DynamicMatchCache = Map; +export interface EngineDeps { + findCandidates: (options: { + projectRoot: string | null; + targetFile: string | null; + homeDir?: string; + disabledSources?: ReadonlySet; + skipUserHome?: boolean; + cache?: RuleDiscoveryCache; + }) => RuleCandidate[]; + readFile: (path: string) => string | null; + findProjectRoot: (startPath: string) => string | null; + matchRule?: typeof matchRule; +} +export interface Engine { + state: SessionState; + config: PiRulesConfig; + loadStaticRules(cwd: string): { + rules: LoadedRule[]; + diagnostics: RuleDiagnostic[]; + }; + loadDynamicRules(cwd: string, targetPaths: ReadonlyArray): { + rules: LoadedRule[]; + diagnostics: RuleDiagnostic[]; + }; + formatStatic(rules: ReadonlyArray): string; + formatDynamic(rules: ReadonlyArray, target: string): string; + resetSession(cwd?: string): void; + isStaticInjected(rule: LoadedRule): boolean; + isDynamicInjected(rule: LoadedRule): boolean; + markStaticInjected(rule: LoadedRule): boolean; + markDynamicInjected(rule: LoadedRule): boolean; +} diff --git a/plugins/omo/components/rules/dist/rules/engine-types.js b/plugins/omo/components/rules/dist/rules/engine-types.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/engine-types.js @@ -0,0 +1 @@ +export {}; diff --git a/plugins/omo/components/rules/dist/rules/engine.d.ts b/plugins/omo/components/rules/dist/rules/engine.d.ts new file mode 100644 index 0000000..1a6a371 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/engine.d.ts @@ -0,0 +1,5 @@ +import type { Engine, EngineDeps } from "./engine-types.js"; +import type { PiRulesConfig } from "./types.js"; +export type { Engine, EngineDeps } from "./engine-types.js"; +export declare function defaultConfig(): PiRulesConfig; +export declare function createEngine(config: PiRulesConfig, deps: EngineDeps): Engine; diff --git a/plugins/omo/components/rules/dist/rules/engine.js b/plugins/omo/components/rules/dist/rules/engine.js new file mode 100644 index 0000000..fa01165 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/engine.js @@ -0,0 +1,81 @@ +import { clearSession, createSessionState, isDynamicInjected as isDynamicInjectedInState, isStaticInjected as isStaticInjectedInState, markDynamicInjected as markDynamicInjectedInState, markStaticInjected as markStaticInjectedInState, } from "./cache.js"; +import { DEFAULT_MAX_RESULT_CHARS, DEFAULT_MAX_RULE_CHARS, DEFAULT_POST_COMPACT_MAX_RESULT_CHARS, DEFAULT_POST_COMPACT_MAX_RULE_CHARS, } from "./constants.js"; +import { loadDynamicCandidates } from "./engine-dynamic-loader.js"; +import { loadStaticCandidates } from "./engine-static-loader.js"; +import { formatDynamicBlock, formatStaticBlock } from "./formatter.js"; +import { disabledSourcesFromConfig } from "./sources.js"; +export function defaultConfig() { + return { + disabled: false, + mode: "both", + maxRuleChars: DEFAULT_MAX_RULE_CHARS, + maxResultChars: DEFAULT_MAX_RESULT_CHARS, + postCompactMaxRuleChars: DEFAULT_POST_COMPACT_MAX_RULE_CHARS, + postCompactMaxResultChars: DEFAULT_POST_COMPACT_MAX_RESULT_CHARS, + enabledSources: "auto", + }; +} +export function createEngine(config, deps) { + const state = createSessionState(); + const dynamicMatchCache = new Map(); + function loadStaticRules(cwd) { + state.cwd = cwd; + if (config.disabled || config.mode === "off" || config.mode === "dynamic") { + return emptyLoadResult(state); + } + const projectRoot = deps.findProjectRoot(cwd); + const findOptions = { + projectRoot, + targetFile: null, + }; + const disabledSources = disabledSourcesFromConfig(config); + if (disabledSources !== undefined) { + findOptions.disabledSources = disabledSources; + } + const candidates = deps.findCandidates(findOptions); + const result = loadStaticCandidates(candidates, deps, projectRoot); + storeLastLoad(state, result.rules, result.diagnostics); + return result; + } + function loadDynamicRules(cwd, targetPaths) { + state.cwd = cwd; + if (config.disabled || config.mode === "off" || config.mode === "static" || targetPaths.length === 0) { + return emptyLoadResult(state); + } + const result = loadDynamicCandidates(config, deps, cwd, targetPaths, dynamicMatchCache); + storeLastLoad(state, result.rules, result.diagnostics); + return result; + } + return { + state, + config, + loadStaticRules, + loadDynamicRules, + formatStatic: (rules) => formatStaticBlock(rules, { maxRuleChars: config.maxRuleChars, maxResultChars: config.maxResultChars }), + formatDynamic: (rules, target) => formatDynamicBlock(rules, target, { + maxRuleChars: config.maxRuleChars, + maxResultChars: config.maxResultChars, + }), + resetSession: (cwd) => { + clearSession(state); + dynamicMatchCache.clear(); + if (cwd !== undefined) { + state.cwd = cwd; + } + }, + isStaticInjected: (rule) => isStaticInjectedInState(state, rule), + isDynamicInjected: (rule) => isDynamicInjectedInState(state, rule), + markStaticInjected: (rule) => markStaticInjectedInState(state, rule), + markDynamicInjected: (rule) => markDynamicInjectedInState(state, rule), + }; +} +function storeLastLoad(state, rules, diagnostics) { + state.loadedRules.length = 0; + state.loadedRules.push(...rules); + state.diagnostics.length = 0; + state.diagnostics.push(...diagnostics); +} +function emptyLoadResult(state) { + storeLastLoad(state, [], []); + return { rules: [], diagnostics: [] }; +} diff --git a/plugins/omo/components/rules/dist/rules/errors.d.ts b/plugins/omo/components/rules/dist/rules/errors.d.ts new file mode 100644 index 0000000..cc98d46 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/errors.d.ts @@ -0,0 +1,6 @@ +export declare class UnsupportedRuleSourceError extends Error { + constructor(message: string); +} +export declare class RuleFrontmatterParseError extends Error { + constructor(message: string); +} diff --git a/plugins/omo/components/rules/dist/rules/errors.js b/plugins/omo/components/rules/dist/rules/errors.js new file mode 100644 index 0000000..0fb83e8 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/errors.js @@ -0,0 +1,12 @@ +export class UnsupportedRuleSourceError extends Error { + constructor(message) { + super(message); + this.name = "UnsupportedRuleSourceError"; + } +} +export class RuleFrontmatterParseError extends Error { + constructor(message) { + super(message); + this.name = "RuleFrontmatterParseError"; + } +} diff --git a/plugins/omo/components/rules/dist/rules/finder-cache.d.ts b/plugins/omo/components/rules/dist/rules/finder-cache.d.ts new file mode 100644 index 0000000..33e716c --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/finder-cache.d.ts @@ -0,0 +1,14 @@ +import { scanRuleFiles } from "./scanner.js"; +type ScannedRuleFiles = ReturnType; +interface SingleFileInfo { + readonly path: string; + readonly realPath: string; +} +export interface RuleDiscoveryCache { + readonly scannedRuleFiles: Map; + readonly singleFileInfo: Map; +} +export declare function createRuleDiscoveryCache(): RuleDiscoveryCache; +export declare function scanRuleFilesCached(rootDir: string, cache: RuleDiscoveryCache | undefined): ScannedRuleFiles; +export declare function singleFileInfoCached(filePath: string, cache: RuleDiscoveryCache | undefined): SingleFileInfo | null; +export {}; diff --git a/plugins/omo/components/rules/dist/rules/finder-cache.js b/plugins/omo/components/rules/dist/rules/finder-cache.js new file mode 100644 index 0000000..3964f6f --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/finder-cache.js @@ -0,0 +1,51 @@ +import { existsSync, realpathSync, statSync } from "node:fs"; +import { scanRuleFiles } from "./scanner.js"; +export function createRuleDiscoveryCache() { + return { scannedRuleFiles: new Map(), singleFileInfo: new Map() }; +} +export function scanRuleFilesCached(rootDir, cache) { + if (cache === undefined) { + return scanRuleFiles({ rootDir }); + } + const cached = cache.scannedRuleFiles.get(rootDir); + if (cached !== undefined) { + return cached; + } + const scannedFiles = scanRuleFiles({ rootDir }); + cache.scannedRuleFiles.set(rootDir, scannedFiles); + return scannedFiles; +} +export function singleFileInfoCached(filePath, cache) { + if (cache === undefined) { + return readSingleFileInfo(filePath); + } + const cached = cache.singleFileInfo.get(filePath); + if (cached !== undefined) { + return cached; + } + const fileInfo = readSingleFileInfo(filePath); + cache.singleFileInfo.set(filePath, fileInfo); + return fileInfo; +} +function readSingleFileInfo(filePath) { + if (!existsSync(filePath)) { + return null; + } + try { + if (!statSync(filePath).isFile()) { + return null; + } + return { path: filePath, realPath: resolveRealPath(filePath) }; + } + catch { + return null; + } +} +function resolveRealPath(filePath) { + try { + return realpathSync.native(filePath); + } + catch { + return filePath; + } +} diff --git a/plugins/omo/components/rules/dist/rules/finder-paths.d.ts b/plugins/omo/components/rules/dist/rules/finder-paths.d.ts new file mode 100644 index 0000000..e112d76 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/finder-paths.d.ts @@ -0,0 +1,6 @@ +export interface WalkDirectory { + readonly directory: string; + readonly distance: number; +} +export declare function getWalkDirectories(projectRoot: string, targetFile: string | null): WalkDirectory[]; +export declare function toRelativePath(rootDirectory: string, filePath: string): string; diff --git a/plugins/omo/components/rules/dist/rules/finder-paths.js b/plugins/omo/components/rules/dist/rules/finder-paths.js new file mode 100644 index 0000000..93c7ae2 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/finder-paths.js @@ -0,0 +1,33 @@ +import { dirname, posix, relative, resolve } from "node:path"; +export function getWalkDirectories(projectRoot, targetFile) { + if (targetFile === null) { + return [{ directory: projectRoot, distance: 0 }]; + } + const startDirectory = dirname(resolve(targetFile)); + if (!isSameOrChildPath(startDirectory, projectRoot)) { + return [{ directory: projectRoot, distance: 0 }]; + } + const walkDirectories = []; + let currentDirectory = startDirectory; + let distance = 0; + while (true) { + walkDirectories.push({ directory: currentDirectory, distance }); + if (currentDirectory === projectRoot) { + break; + } + const parentDirectory = dirname(currentDirectory); + if (parentDirectory === currentDirectory) { + break; + } + currentDirectory = parentDirectory; + distance += 1; + } + return walkDirectories; +} +export function toRelativePath(rootDirectory, filePath) { + return posix.normalize(relative(rootDirectory, filePath).replace(/\\/g, "/")); +} +function isSameOrChildPath(childPath, parentPath) { + const childRelativePath = relative(parentPath, childPath); + return childRelativePath === "" || (!childRelativePath.startsWith("..") && !childRelativePath.startsWith("/")); +} diff --git a/plugins/omo/components/rules/dist/rules/finder-sources.d.ts b/plugins/omo/components/rules/dist/rules/finder-sources.d.ts new file mode 100644 index 0000000..b845c3c --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/finder-sources.d.ts @@ -0,0 +1,5 @@ +import type { RuleSource } from "./types.js"; +export declare function toProjectRuleSource(parentDirectory: string, subDirectory: string): RuleSource; +export declare function toProjectSingleFileSource(ruleFile: string): RuleSource; +export declare function toUserHomeRuleSource(ruleSubdir: string): RuleSource; +export declare function toUserHomeSingleFileSource(ruleFile: string): RuleSource; diff --git a/plugins/omo/components/rules/dist/rules/finder-sources.js b/plugins/omo/components/rules/dist/rules/finder-sources.js new file mode 100644 index 0000000..e798b7b --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/finder-sources.js @@ -0,0 +1,40 @@ +import { UnsupportedRuleSourceError } from "./errors.js"; +export function toProjectRuleSource(parentDirectory, subDirectory) { + const source = `${parentDirectory}/${subDirectory}`; + switch (source) { + case ".omo/rules": + case ".claude/rules": + case ".cursor/rules": + case ".github/instructions": + return source; + default: + throw new UnsupportedRuleSourceError(`Unsupported project rule source: ${source}`); + } +} +export function toProjectSingleFileSource(ruleFile) { + switch (ruleFile) { + case ".github/copilot-instructions.md": + case "CONTEXT.md": + return ruleFile; + default: + throw new UnsupportedRuleSourceError(`Unsupported project single-file source: ${ruleFile}`); + } +} +export function toUserHomeRuleSource(ruleSubdir) { + const source = `~/${ruleSubdir}`; + switch (source) { + case "~/.omo/rules": + case "~/.opencode/rules": + case "~/.claude/rules": + return source; + default: + throw new UnsupportedRuleSourceError(`Unsupported user-home rule source: ${source}`); + } +} +export function toUserHomeSingleFileSource(ruleFile) { + const source = `~/${ruleFile}`; + switch (source) { + default: + throw new UnsupportedRuleSourceError(`Unsupported user-home single-file source: ${source}`); + } +} diff --git a/plugins/omo/components/rules/dist/rules/finder.d.ts b/plugins/omo/components/rules/dist/rules/finder.d.ts new file mode 100644 index 0000000..bd1c5f7 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/finder.d.ts @@ -0,0 +1,28 @@ +import { type RuleDiscoveryCache } from "./finder-cache.js"; +import type { RuleCandidate } from "./types.js"; +export type { RuleDiscoveryCache } from "./finder-cache.js"; +export { createRuleDiscoveryCache } from "./finder-cache.js"; +export interface FinderOptions { + /** Project root absolute path (use findProjectRoot to get this). */ + projectRoot: string | null; + /** Target file path (used for distance calculation in dynamic injection mode). null for static mode. */ + targetFile: string | null; + /** User home directory (default: os.homedir()). Injectable for tests. */ + homeDir?: string; + /** Set of disabled sources to omit from discovery. Empty by default. */ + disabledSources?: ReadonlySet; + /** Whether to skip user-home rules. Default: false. */ + skipUserHome?: boolean; + /** Plugin root directory. Defaults to PLUGIN_ROOT env or this package root. */ + pluginRoot?: string; + platform?: NodeJS.Platform; + cache?: RuleDiscoveryCache; +} +interface PluginBundledFinderOptions { + readonly disabledSources?: ReadonlySet; + readonly cache?: RuleDiscoveryCache; + readonly pluginRoot?: string; + readonly platform?: NodeJS.Platform; +} +export declare function findRuleCandidates(options: FinderOptions): RuleCandidate[]; +export declare function findPluginBundledCandidates(options?: PluginBundledFinderOptions): RuleCandidate[]; diff --git a/plugins/omo/components/rules/dist/rules/finder.js b/plugins/omo/components/rules/dist/rules/finder.js new file mode 100644 index 0000000..7b1a768 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/finder.js @@ -0,0 +1,146 @@ +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { BUNDLED_RULE_SUBDIR, GLOBAL_DISTANCE, PROJECT_RULE_SUBDIRS, PROJECT_SINGLE_FILES, USER_HOME_RULE_SUBDIRS, USER_HOME_SINGLE_FILES, } from "./constants.js"; +import { scanRuleFilesCached, singleFileInfoCached } from "./finder-cache.js"; +import { getWalkDirectories, toRelativePath } from "./finder-paths.js"; +import { toProjectRuleSource, toProjectSingleFileSource, toUserHomeRuleSource, toUserHomeSingleFileSource, } from "./finder-sources.js"; +import { resolvePluginRulesRoot } from "./plugin-root.js"; +export { createRuleDiscoveryCache } from "./finder-cache.js"; +const WINDOWS_GIT_BASH_BUNDLED_RULE_PATH = "bundled-rules/windows-git-bash.md"; +export function findRuleCandidates(options) { + const skipUserHome = options.skipUserHome ?? false; + const disabledSources = options.disabledSources ?? new Set(); + const candidates = []; + const homeDirectory = resolve(options.homeDir ?? homedir()); + if (options.projectRoot !== null) { + candidates.push(...findProjectCandidates(options.projectRoot, options.targetFile, disabledSources, options.cache)); + } + const pluginBundledOptions = { + disabledSources, + ...(options.cache === undefined ? {} : { cache: options.cache }), + ...(options.pluginRoot === undefined ? {} : { pluginRoot: options.pluginRoot }), + ...(options.platform === undefined ? {} : { platform: options.platform }), + }; + candidates.push(...findPluginBundledCandidates(pluginBundledOptions)); + if (!skipUserHome) { + candidates.push(...findUserHomeCandidates(homeDirectory, disabledSources, options.cache)); + } + return candidates; +} +export function findPluginBundledCandidates(options = {}) { + if (options.disabledSources?.has("plugin-bundled") === true) { + return []; + } + const pluginRoot = resolvePluginRulesRoot(options.pluginRoot); + const ruleDirectory = join(pluginRoot, BUNDLED_RULE_SUBDIR); + const platform = options.platform ?? process.platform; + const candidates = []; + for (const scannedFile of scanRuleFilesCached(ruleDirectory, options.cache)) { + const candidate = { + path: scannedFile.path, + realPath: scannedFile.realPath, + source: "plugin-bundled", + distance: GLOBAL_DISTANCE, + isGlobal: true, + isSingleFile: false, + relativePath: toRelativePath(pluginRoot, scannedFile.path), + }; + if (isPluginBundledCandidateEnabled(candidate, platform)) { + candidates.push(candidate); + } + } + return candidates; +} +function isPluginBundledCandidateEnabled(candidate, platform) { + return candidate.relativePath !== WINDOWS_GIT_BASH_BUNDLED_RULE_PATH || platform === "win32"; +} +function findProjectCandidates(projectRoot, targetFile, disabledSources, cache) { + const rootDirectory = resolve(projectRoot); + const walkDirectories = getWalkDirectories(rootDirectory, targetFile); + const candidates = []; + for (const walkDirectory of walkDirectories) { + for (const [parentDirectory, subDirectory] of PROJECT_RULE_SUBDIRS) { + const source = toProjectRuleSource(parentDirectory, subDirectory); + if (disabledSources.has(source)) { + continue; + } + const ruleDirectory = join(walkDirectory.directory, parentDirectory, subDirectory); + for (const scannedFile of scanRuleFilesCached(ruleDirectory, cache)) { + candidates.push({ + path: scannedFile.path, + realPath: scannedFile.realPath, + source, + distance: targetFile === null ? 0 : walkDirectory.distance, + isGlobal: false, + isSingleFile: false, + relativePath: toRelativePath(rootDirectory, scannedFile.path), + }); + } + } + } + for (const walkDirectory of walkDirectories) { + for (const ruleFile of PROJECT_SINGLE_FILES) { + const source = toProjectSingleFileSource(ruleFile); + if (disabledSources.has(source)) { + continue; + } + const filePath = join(walkDirectory.directory, ruleFile); + const fileInfo = singleFileInfoCached(filePath, cache); + if (fileInfo === null) { + continue; + } + candidates.push({ + path: fileInfo.path, + realPath: fileInfo.realPath, + source, + distance: targetFile === null ? 0 : walkDirectory.distance, + isGlobal: false, + isSingleFile: true, + relativePath: toRelativePath(rootDirectory, filePath), + }); + } + } + return candidates; +} +function findUserHomeCandidates(homeDirectory, disabledSources, cache) { + const candidates = []; + for (const ruleSubdir of USER_HOME_RULE_SUBDIRS) { + const source = toUserHomeRuleSource(ruleSubdir); + if (disabledSources.has(source)) { + continue; + } + const ruleDirectory = join(homeDirectory, ruleSubdir); + for (const scannedFile of scanRuleFilesCached(ruleDirectory, cache)) { + candidates.push({ + path: scannedFile.path, + realPath: scannedFile.realPath, + source, + distance: GLOBAL_DISTANCE, + isGlobal: true, + isSingleFile: false, + relativePath: toRelativePath(homeDirectory, scannedFile.path), + }); + } + } + for (const ruleFile of USER_HOME_SINGLE_FILES) { + const source = toUserHomeSingleFileSource(ruleFile); + if (disabledSources.has(source)) { + continue; + } + const filePath = join(homeDirectory, ruleFile); + const fileInfo = singleFileInfoCached(filePath, cache); + if (fileInfo === null) { + continue; + } + candidates.push({ + path: fileInfo.path, + realPath: fileInfo.realPath, + source, + distance: GLOBAL_DISTANCE, + isGlobal: true, + isSingleFile: true, + relativePath: toRelativePath(homeDirectory, filePath), + }); + } + return candidates; +} diff --git a/plugins/omo/components/rules/dist/rules/formatter.d.ts b/plugins/omo/components/rules/dist/rules/formatter.d.ts new file mode 100644 index 0000000..b4bd384 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/formatter.d.ts @@ -0,0 +1,7 @@ +import type { LoadedRule } from "./types.js"; +export interface FormatOptions { + maxRuleChars: number; + maxResultChars: number; +} +export declare function formatStaticBlock(rules: ReadonlyArray, options: FormatOptions): string; +export declare function formatDynamicBlock(rules: ReadonlyArray, targetRelativePath: string, options: FormatOptions): string; diff --git a/plugins/omo/components/rules/dist/rules/formatter.js b/plugins/omo/components/rules/dist/rules/formatter.js new file mode 100644 index 0000000..3171fcc --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/formatter.js @@ -0,0 +1,112 @@ +import { truncateBudget, truncateRule } from "./truncator.js"; +function formatRule(rule) { + const body = normalizeRuleBody(rule.body); + if (body.length === 0) { + return `Instructions from: ${rule.path}`; + } + return `Instructions from: ${rule.path}\n\n${body}`; +} +function truncateRules(rules, options) { + const perRuleNormalized = rules.map((rule) => ({ + path: rule.path, + relativePath: rule.relativePath, + body: normalizeRuleBody(rule.body), + source: rule.source, + })); + const perRuleResultChars = Math.floor(options.maxResultChars / Math.max(1, perRuleNormalized.length)); + const perRuleBudgeted = perRuleNormalized.map((rule) => ({ + path: rule.path, + relativePath: rule.relativePath, + body: rule.source === "plugin-bundled" + ? truncateRule(rule.body, { maxChars: perRuleResultChars, relativePath: rule.relativePath }).body + : truncateRule(rule.body, { + maxChars: Math.min(options.maxRuleChars, perRuleResultChars), + relativePath: rule.relativePath, + }).body, + })); + const budgetedRules = truncateBudget({ + rules: perRuleBudgeted.map((rule) => ({ body: rule.body, relativePath: rule.relativePath })), + maxResultChars: options.maxResultChars, + }); + const truncatedRules = []; + for (let index = 0; index < budgetedRules.length; index += 1) { + const sourceRule = perRuleBudgeted[index]; + const budgetedRule = budgetedRules[index]; + if (sourceRule === undefined || budgetedRule === undefined) { + continue; + } + truncatedRules.push({ + path: sourceRule.path, + relativePath: budgetedRule.relativePath, + body: budgetedRule.body, + }); + } + return truncatedRules; +} +export function formatStaticBlock(rules, options) { + if (rules.length === 0) { + return ""; + } + if (options.maxResultChars <= 0) { + return ""; + } + const orderedRules = orderStaticRules(uniqueRulesByBody(rules)); + return ["## Project Instructions", "", truncateRules(orderedRules, options).map(formatRule).join("\n\n")].join("\n"); +} +function orderStaticRules(rules) { + const hephaestusRules = []; + const otherRules = []; + for (const rule of rules) { + if (isHephaestusRule(rule)) { + hephaestusRules.push(rule); + continue; + } + otherRules.push(rule); + } + return [...hephaestusRules, ...otherRules]; +} +function isHephaestusRule(rule) { + return displayFilename(rule).toLowerCase() === "hephaestus.md"; +} +function displayFilename(rule) { + const normalizedPath = rule.relativePath.length > 0 ? rule.relativePath : rule.path; + const segments = normalizedPath + .replace(/\\/g, "/") + .split("/") + .filter((segment) => segment.length > 0); + return segments.at(-1) ?? normalizedPath; +} +function uniqueRulesByBody(rules) { + const uniqueRules = []; + const seenBodies = new Set(); + const userDescriptions = new Set(); + for (const rule of rules) { + const descriptionKey = rule.frontmatter.description?.trim(); + if (rule.source === "plugin-bundled" && descriptionKey !== undefined && userDescriptions.has(descriptionKey)) { + continue; + } + const bodyKey = normalizeRuleBody(rule.body); + if (seenBodies.has(bodyKey)) { + continue; + } + seenBodies.add(bodyKey); + if (descriptionKey !== undefined && rule.source !== "plugin-bundled") { + userDescriptions.add(descriptionKey); + } + uniqueRules.push(rule); + } + return uniqueRules; +} +export function formatDynamicBlock(rules, targetRelativePath, options) { + if (rules.length === 0) { + return ""; + } + return [ + `Additional project instructions matched for ${targetRelativePath}:`, + "", + truncateRules(rules, options).map(formatRule).join("\n\n"), + ].join("\n"); +} +function normalizeRuleBody(body) { + return body.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim(); +} diff --git a/plugins/omo/components/rules/dist/rules/matcher.d.ts b/plugins/omo/components/rules/dist/rules/matcher.d.ts new file mode 100644 index 0000000..1aee611 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/matcher.d.ts @@ -0,0 +1,18 @@ +import type { MatchReason, RuleFrontmatter } from "./types.js"; +export interface MatcherInput { + frontmatter: RuleFrontmatter; + isSingleFile: boolean; + /** Path bases to try matching against (POSIX-normalized). */ + pathBases: { + projectRelative: string; + scopeRelative?: string; + basename: string; + }; +} +export interface MatchResult { + matched: boolean; + reason: MatchReason; +} +export declare function matchRule(input: MatcherInput): MatchResult; +export declare function normalizeGlobs(frontmatter: RuleFrontmatter): string[]; +export declare function hashContent(body: string): string; diff --git a/plugins/omo/components/rules/dist/rules/matcher.js b/plugins/omo/components/rules/dist/rules/matcher.js new file mode 100644 index 0000000..e9c4438 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/matcher.js @@ -0,0 +1,93 @@ +import { createHash } from "node:crypto"; +import picomatch from "picomatch"; +const compiledPatternSets = new Map(); +export function matchRule(input) { + if (input.isSingleFile) { + return { matched: true, reason: "single-file" }; + } + if (input.frontmatter.alwaysApply === true) { + return { matched: true, reason: "alwaysApply" }; + } + const patterns = normalizeGlobs(input.frontmatter); + if (patterns.length === 0) { + return noMatch(); + } + const pathBases = normalizedPathBases(input.pathBases); + const { positivePatterns, negativeMatchers } = compiledPatternSetFor(patterns); + for (const { pattern, isMatch } of positivePatterns) { + for (const pathBase of pathBases) { + if (!isMatch(pathBase)) { + continue; + } + if (isExcluded(pathBase, negativeMatchers)) { + return noMatch(); + } + return { matched: true, reason: { kind: "glob", pattern } }; + } + } + return noMatch(); +} +export function normalizeGlobs(frontmatter) { + const patterns = [ + ...normalizePatternList(frontmatter.globs), + ...normalizePatternList(frontmatter.paths), + ...normalizePatternList(frontmatter.applyTo), + ]; + return [...new Set(patterns.map(normalizePath))]; +} +export function hashContent(body) { + return createHash("sha256").update(body).digest("hex"); +} +function normalizePatternList(patterns) { + if (patterns === undefined) { + return []; + } + return Array.isArray(patterns) ? patterns : [patterns]; +} +function normalizePath(path) { + return path.replaceAll("\\", "/"); +} +function normalizedPathBases(pathBases) { + const normalizedBases = [normalizePath(pathBases.projectRelative)]; + if (pathBases.scopeRelative !== undefined) { + normalizedBases.push(normalizePath(pathBases.scopeRelative)); + } + normalizedBases.push(normalizePath(pathBases.basename)); + return normalizedBases; +} +function compiledPatternSetFor(patterns) { + const cacheKey = JSON.stringify(patterns); + const cached = compiledPatternSets.get(cacheKey); + if (cached !== undefined) { + return cached; + } + const compiled = compilePatternSet(patterns); + compiledPatternSets.set(cacheKey, compiled); + return compiled; +} +function compilePatternSet(patterns) { + const positivePatterns = []; + const negativeMatchers = []; + for (const pattern of patterns) { + if (pattern.startsWith("!")) { + negativeMatchers.push(createGlobMatcher(pattern.slice(1))); + continue; + } + positivePatterns.push({ pattern, isMatch: createGlobMatcher(pattern) }); + } + return { positivePatterns, negativeMatchers }; +} +function createGlobMatcher(pattern) { + return picomatch(normalizePath(pattern), { bash: true, dot: true }); +} +function isExcluded(pathBase, negativeMatchers) { + for (const isMatch of negativeMatchers) { + if (isMatch(pathBase)) { + return true; + } + } + return false; +} +function noMatch() { + return { matched: false, reason: { kind: "no-match" } }; +} diff --git a/plugins/omo/components/rules/dist/rules/ordering.d.ts b/plugins/omo/components/rules/dist/rules/ordering.d.ts new file mode 100644 index 0000000..f30d972 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/ordering.d.ts @@ -0,0 +1,3 @@ +import type { RuleCandidate } from "./types.js"; +export declare function sortCandidates(candidates: ReadonlyArray): T[]; +export declare function compareCandidates(a: RuleCandidate, b: RuleCandidate): number; diff --git a/plugins/omo/components/rules/dist/rules/ordering.js b/plugins/omo/components/rules/dist/rules/ordering.js new file mode 100644 index 0000000..5308e48 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/ordering.js @@ -0,0 +1,27 @@ +import { SOURCE_PRIORITY } from "./constants.js"; +export function sortCandidates(candidates) { + return candidates + .map((candidate, index) => ({ candidate, index })) + .sort((left, right) => compareCandidates(left.candidate, right.candidate) || left.index - right.index) + .map(({ candidate }) => candidate); +} +export function compareCandidates(a, b) { + return (compareBoolean(a.isGlobal, b.isGlobal) || + compareNumber(a.distance, b.distance) || + compareNumber(SOURCE_PRIORITY.get(a.source) ?? Infinity, SOURCE_PRIORITY.get(b.source) ?? Infinity) || + compareString(a.relativePath, b.relativePath) || + compareString(a.realPath, b.realPath)); +} +function compareBoolean(a, b) { + return Number(a) - Number(b); +} +function compareNumber(a, b) { + return a - b; +} +function compareString(a, b) { + if (a < b) + return -1; + if (a > b) + return 1; + return 0; +} diff --git a/plugins/omo/components/rules/dist/rules/parser-frontmatter.d.ts b/plugins/omo/components/rules/dist/rules/parser-frontmatter.d.ts new file mode 100644 index 0000000..1f08b8c --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/parser-frontmatter.d.ts @@ -0,0 +1,7 @@ +export type ClosingDelimiter = { + readonly start: number; + readonly bodyStart: number; +}; +export declare function stripBom(content: string): string; +export declare function getOpeningDelimiterLength(content: string): number; +export declare function findClosingDelimiter(content: string, openingLength: number): ClosingDelimiter | null; diff --git a/plugins/omo/components/rules/dist/rules/parser-frontmatter.js b/plugins/omo/components/rules/dist/rules/parser-frontmatter.js new file mode 100644 index 0000000..f3bc671 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/parser-frontmatter.js @@ -0,0 +1,30 @@ +const FRONTMATTER_OPENING = "---\n"; +const FRONTMATTER_OPENING_CRLF = "---\r\n"; +export function stripBom(content) { + return content.startsWith("\uFEFF") ? content.slice(1) : content; +} +export function getOpeningDelimiterLength(content) { + if (content.startsWith(FRONTMATTER_OPENING_CRLF)) + return FRONTMATTER_OPENING_CRLF.length; + if (content.startsWith(FRONTMATTER_OPENING)) + return FRONTMATTER_OPENING.length; + return 0; +} +export function findClosingDelimiter(content, openingLength) { + let lineStart = openingLength; + while (lineStart <= content.length) { + const nextNewline = content.indexOf("\n", lineStart); + const lineEnd = nextNewline === -1 ? content.length : nextNewline; + const line = content.slice(lineStart, lineEnd).replace(/\r$/, ""); + if (line === "---") { + return { + start: lineStart, + bodyStart: nextNewline === -1 ? content.length : nextNewline + 1, + }; + } + if (nextNewline === -1) + break; + lineStart = nextNewline + 1; + } + return null; +} diff --git a/plugins/omo/components/rules/dist/rules/parser-yaml.d.ts b/plugins/omo/components/rules/dist/rules/parser-yaml.d.ts new file mode 100644 index 0000000..3c65d9a --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/parser-yaml.d.ts @@ -0,0 +1,2 @@ +import type { RuleFrontmatter } from "./types.js"; +export declare function parseYamlFrontmatter(yamlContent: string): RuleFrontmatter; diff --git a/plugins/omo/components/rules/dist/rules/parser-yaml.js b/plugins/omo/components/rules/dist/rules/parser-yaml.js new file mode 100644 index 0000000..01150ac --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/parser-yaml.js @@ -0,0 +1,237 @@ +import { RuleFrontmatterParseError } from "./errors.js"; +export function parseYamlFrontmatter(yamlContent) { + const lines = yamlContent.replace(/\r\n/g, "\n").split("\n"); + const frontmatter = {}; + const globValues = []; + const seenGlobs = new Set(); + let lineIndex = 0; + while (lineIndex < lines.length) { + const rawLine = lines[lineIndex]; + if (rawLine === undefined) + break; + const line = stripComment(rawLine).trim(); + if (line.length === 0) { + lineIndex += 1; + continue; + } + const colonIndex = line.indexOf(":"); + if (colonIndex === -1) { + throw new RuleFrontmatterParseError(`Expected key-value pair on line ${lineIndex + 1}`); + } + const key = line.slice(0, colonIndex).trim(); + const rawValue = line.slice(colonIndex + 1).trim(); + if (key === "description") { + frontmatter.description = parseStringValue(rawValue); + lineIndex += 1; + continue; + } + if (key === "alwaysApply") { + frontmatter.alwaysApply = parseBooleanValue(rawValue, lineIndex + 1); + lineIndex += 1; + continue; + } + if (key === "globs" || key === "paths" || key === "applyTo") { + const parsed = parseGlobValue(rawValue, lines, lineIndex); + for (const glob of parsed.values) { + if (!seenGlobs.has(glob)) { + seenGlobs.add(glob); + globValues.push(glob); + } + } + lineIndex += parsed.consumed; + continue; + } + lineIndex += 1; + } + const singleGlob = globValues[0]; + if (globValues.length === 1 && singleGlob !== undefined) { + frontmatter.globs = singleGlob; + } + else if (globValues.length > 1) { + frontmatter.globs = globValues; + } + return frontmatter; +} +function parseBooleanValue(value, lineNumber) { + if (value === "true") + return true; + if (value === "false") + return false; + throw new RuleFrontmatterParseError(`Expected boolean on line ${lineNumber}`); +} +function parseGlobValue(rawValue, lines, lineIndex) { + if (rawValue.startsWith("[")) { + return { values: parseInlineArray(rawValue), consumed: 1 }; + } + if (rawValue.length === 0) { + return parseMultilineArray(lines, lineIndex); + } + const quotedScalar = isQuotedScalar(rawValue); + const value = parseStringValue(rawValue); + if (!quotedScalar && value.includes(",")) { + return { + values: value + .split(",") + .map((item) => item.trim()) + .filter(Boolean), + consumed: 1, + }; + } + return { values: [value], consumed: 1 }; +} +function isQuotedScalar(value) { + return value.startsWith('"') || value.startsWith("'"); +} +function parseMultilineArray(lines, lineIndex) { + const values = []; + let consumed = 1; + for (let index = lineIndex + 1; index < lines.length; index += 1) { + const rawLine = lines[index]; + if (rawLine === undefined) + break; + const lineWithoutComment = stripComment(rawLine); + if (lineWithoutComment.trim().length === 0) { + consumed += 1; + continue; + } + const arrayItem = lineWithoutComment.match(/^\s+-\s*(.*)$/); + if (arrayItem === null) + break; + values.push(parseStringValue(arrayItem[1] ?? "")); + consumed += 1; + } + return { values: values.filter(Boolean), consumed }; +} +function parseInlineArray(value) { + const closingBracketIndex = findClosingBracket(value); + if (closingBracketIndex === -1) { + throw new RuleFrontmatterParseError("Unclosed inline array"); + } + const trailing = value.slice(closingBracketIndex + 1).trim(); + if (trailing.length > 0) { + throw new RuleFrontmatterParseError("Unexpected content after inline array"); + } + const content = value.slice(1, closingBracketIndex).trim(); + if (content.length === 0) + return []; + return splitCommaSeparated(content).map(parseStringValue).filter(Boolean); +} +function findClosingBracket(value) { + let quote = null; + let escaped = false; + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + if (character === undefined) + continue; + if (escaped) { + escaped = false; + continue; + } + if (quote !== null && character === "\\") { + escaped = true; + continue; + } + if (character === '"' || character === "'") { + if (quote === null) + quote = character; + else if (quote === character) + quote = null; + continue; + } + if (quote === null && character === "]") + return index; + } + return -1; +} +function splitCommaSeparated(value) { + const values = []; + let current = ""; + let quote = null; + let escaped = false; + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + if (character === undefined) + continue; + if (escaped) { + current += character; + escaped = false; + continue; + } + if (quote !== null && character === "\\") { + current += character; + escaped = true; + continue; + } + if (character === '"' || character === "'") { + if (quote === null) + quote = character; + else if (quote === character) + quote = null; + current += character; + continue; + } + if (quote === null && character === ",") { + values.push(current.trim()); + current = ""; + continue; + } + current += character; + } + if (quote !== null) { + throw new RuleFrontmatterParseError("Unclosed quoted value"); + } + values.push(current.trim()); + return values.filter(Boolean); +} +function parseStringValue(value) { + if (value.length === 0) + return ""; + if (value.startsWith('"')) + return parseJsonString(value); + if (value.startsWith("'") && value.endsWith("'")) + return value.slice(1, -1); + if (value.startsWith("'")) + throw new RuleFrontmatterParseError("Unclosed quoted value"); + return value; +} +function parseJsonString(value) { + try { + const parsedValue = JSON.parse(value); + if (typeof parsedValue !== "string") { + throw new RuleFrontmatterParseError("Expected JSON-quoted string"); + } + return parsedValue; + } + catch (error) { + if (error instanceof RuleFrontmatterParseError) + throw error; + throw new RuleFrontmatterParseError("Invalid JSON-quoted string"); + } +} +function stripComment(line) { + let quote = null; + let escaped = false; + for (let index = 0; index < line.length; index += 1) { + const character = line[index]; + if (character === undefined) + continue; + if (escaped) { + escaped = false; + continue; + } + if (quote !== null && character === "\\") { + escaped = true; + continue; + } + if (character === '"' || character === "'") { + if (quote === null) + quote = character; + else if (quote === character) + quote = null; + continue; + } + if (quote === null && character === "#") + return line.slice(0, index); + } + return line; +} diff --git a/plugins/omo/components/rules/dist/rules/parser.d.ts b/plugins/omo/components/rules/dist/rules/parser.d.ts new file mode 100644 index 0000000..cb945a5 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/parser.d.ts @@ -0,0 +1,3 @@ +import type { ParsedRule } from "./types.js"; +/** Parse markdown rule content and extract the supported YAML frontmatter subset. */ +export declare function parseRule(content: string): ParsedRule; diff --git a/plugins/omo/components/rules/dist/rules/parser.js b/plugins/omo/components/rules/dist/rules/parser.js new file mode 100644 index 0000000..3a1e24b --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/parser.js @@ -0,0 +1,31 @@ +import { findClosingDelimiter, getOpeningDelimiterLength, stripBom } from "./parser-frontmatter.js"; +import { parseYamlFrontmatter } from "./parser-yaml.js"; +/** Parse markdown rule content and extract the supported YAML frontmatter subset. */ +export function parseRule(content) { + const normalizedContent = stripBom(content); + const openingLength = getOpeningDelimiterLength(normalizedContent); + if (openingLength === 0) { + return { frontmatter: {}, body: normalizedContent }; + } + const closingDelimiter = findClosingDelimiter(normalizedContent, openingLength); + if (closingDelimiter === null) { + return { + frontmatter: {}, + body: normalizedContent, + diagnostic: "Missing closing frontmatter delimiter", + }; + } + const yamlContent = normalizedContent.slice(openingLength, closingDelimiter.start); + const body = normalizedContent.slice(closingDelimiter.bodyStart); + try { + return { frontmatter: parseYamlFrontmatter(yamlContent), body }; + } + catch (error) { + const message = error instanceof Error ? error.message : "Invalid YAML frontmatter"; + return { + frontmatter: {}, + body: normalizedContent, + diagnostic: `Malformed frontmatter: ${message}`, + }; + } +} diff --git a/plugins/omo/components/rules/dist/rules/plugin-root.d.ts b/plugins/omo/components/rules/dist/rules/plugin-root.d.ts new file mode 100644 index 0000000..7a83c2f --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/plugin-root.d.ts @@ -0,0 +1 @@ +export declare function resolvePluginRulesRoot(pluginRoot: string | undefined, moduleUrl?: string): string; diff --git a/plugins/omo/components/rules/dist/rules/plugin-root.js b/plugins/omo/components/rules/dist/rules/plugin-root.js new file mode 100644 index 0000000..4b15e32 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/plugin-root.js @@ -0,0 +1,48 @@ +import { statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +const PLUGIN_MANIFEST_PATH = join(".codex-plugin", "plugin.json"); +export function resolvePluginRulesRoot(pluginRoot, moduleUrl = import.meta.url) { + const configuredRoot = pluginRoot ?? process.env["PLUGIN_ROOT"]; + if (configuredRoot !== undefined && configuredRoot.trim().length > 0) { + return resolveRulesComponentRoot(resolve(configuredRoot)); + } + const discoveredRoot = findNearestPluginRoot(dirname(fileURLToPath(moduleUrl))); + if (discoveredRoot !== null) { + return resolveRulesComponentRoot(discoveredRoot); + } + return fileURLToPath(new URL("../../..", moduleUrl)); +} +function findNearestPluginRoot(startDirectory) { + let currentDirectory = resolve(startDirectory); + while (true) { + if (isFile(join(currentDirectory, PLUGIN_MANIFEST_PATH))) { + return currentDirectory; + } + const parentDirectory = dirname(currentDirectory); + if (parentDirectory === currentDirectory) { + return null; + } + currentDirectory = parentDirectory; + } +} +function resolveRulesComponentRoot(pluginRoot) { + const componentRoot = join(pluginRoot, "components", "rules"); + return isDirectory(componentRoot) ? componentRoot : pluginRoot; +} +function isFile(path) { + try { + return statSync(path).isFile(); + } + catch { + return false; + } +} +function isDirectory(path) { + try { + return statSync(path).isDirectory(); + } + catch { + return false; + } +} diff --git a/plugins/omo/components/rules/dist/rules/project-root.d.ts b/plugins/omo/components/rules/dist/rules/project-root.d.ts new file mode 100644 index 0000000..079a718 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/project-root.d.ts @@ -0,0 +1 @@ +export declare function findProjectRoot(startPath: string, markers?: ReadonlyArray): string | null; diff --git a/plugins/omo/components/rules/dist/rules/project-root.js b/plugins/omo/components/rules/dist/rules/project-root.js new file mode 100644 index 0000000..d9b4b5d --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/project-root.js @@ -0,0 +1,23 @@ +import { existsSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { PROJECT_MARKERS } from "./constants.js"; +export function findProjectRoot(startPath, markers = PROJECT_MARKERS) { + const resolvedStartPath = resolve(startPath); + if (!existsSync(resolvedStartPath)) { + return null; + } + const startStats = statSync(resolvedStartPath); + let currentDirectory = startStats.isDirectory() ? resolvedStartPath : dirname(resolvedStartPath); + const filesystemRoot = resolve("/"); + while (true) { + for (const marker of markers) { + if (existsSync(join(currentDirectory, marker))) { + return currentDirectory; + } + } + if (currentDirectory === filesystemRoot) { + return null; + } + currentDirectory = dirname(currentDirectory); + } +} diff --git a/plugins/omo/components/rules/dist/rules/scanner.d.ts b/plugins/omo/components/rules/dist/rules/scanner.d.ts new file mode 100644 index 0000000..7a66748 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/scanner.d.ts @@ -0,0 +1,14 @@ +export interface ScanOptions { + rootDir: string; + excludedDirs?: ReadonlyArray; + /** Maximum recursion depth. Default: 10 */ + maxDepth?: number; + maxFiles?: number; +} +export interface ScannedFile { + /** Absolute path as encountered (may be a symlink). */ + path: string; + /** Real (resolved) path; same as path if not a symlink. */ + realPath: string; +} +export declare function scanRuleFiles(options: ScanOptions): ScannedFile[]; diff --git a/plugins/omo/components/rules/dist/rules/scanner.js b/plugins/omo/components/rules/dist/rules/scanner.js new file mode 100644 index 0000000..6d35993 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/scanner.js @@ -0,0 +1,111 @@ +import { existsSync, lstatSync, readdirSync, realpathSync, statSync } from "node:fs"; +import { isAbsolute, join, resolve } from "node:path"; +import { DEFAULT_MAX_SCAN_FILES, RULE_FILE_EXTENSIONS, SCANNER_EXCLUDED_DIRS } from "./constants.js"; +export function scanRuleFiles(options) { + const rootPath = toAbsolutePath(options.rootDir); + if (!existsSync(rootPath)) { + return []; + } + let rootStats; + try { + rootStats = statSync(rootPath); + } + catch { + return []; + } + if (!rootStats.isDirectory()) { + return []; + } + const results = []; + const visitedDirectories = new Set(); + const excludedDirs = new Set(options.excludedDirs ?? SCANNER_EXCLUDED_DIRS); + const maxDepth = options.maxDepth ?? 10; + const maxFiles = normalizeMaxFiles(options.maxFiles); + scanDirectory(rootPath, 0, maxDepth, maxFiles, excludedDirs, visitedDirectories, results); + return results; +} +function normalizeMaxFiles(maxFiles) { + const value = maxFiles ?? DEFAULT_MAX_SCAN_FILES; + if (!Number.isFinite(value) || value < 0) + return DEFAULT_MAX_SCAN_FILES; + return Math.floor(value); +} +function toAbsolutePath(filePath) { + return isAbsolute(filePath) ? filePath : resolve(filePath); +} +function scanDirectory(directoryPath, depth, maxDepth, maxFiles, excludedDirs, visitedDirectories, results) { + if (results.length >= maxFiles) { + return; + } + let realDirectoryPath; + try { + realDirectoryPath = realpathSync.native(directoryPath); + } + catch { + return; + } + if (visitedDirectories.has(realDirectoryPath)) { + return; + } + visitedDirectories.add(realDirectoryPath); + let entries; + try { + entries = readdirSync(directoryPath, { withFileTypes: true }).sort((leftEntry, rightEntry) => leftEntry.name.localeCompare(rightEntry.name)); + } + catch { + return; + } + for (const entry of entries) { + if (results.length >= maxFiles) { + return; + } + const entryPath = join(directoryPath, entry.name); + if (entry.isDirectory()) { + if (!excludedDirs.has(entry.name) && depth < maxDepth) { + scanDirectory(entryPath, depth + 1, maxDepth, maxFiles, excludedDirs, visitedDirectories, results); + } + continue; + } + if (entry.isSymbolicLink()) { + scanSymbolicLink(entryPath, entry.name, depth, maxDepth, maxFiles, excludedDirs, visitedDirectories, results); + continue; + } + if (entry.isFile() && isRuleFile(entry.name)) { + results.push({ path: entryPath, realPath: resolveRealPath(entryPath) }); + } + } +} +function scanSymbolicLink(linkPath, linkName, depth, maxDepth, maxFiles, excludedDirs, visitedDirectories, results) { + if (results.length >= maxFiles) { + return; + } + let targetStats; + try { + targetStats = statSync(linkPath); + } + catch { + return; + } + if (targetStats.isDirectory()) { + if (!excludedDirs.has(linkName) && depth < maxDepth) { + scanDirectory(linkPath, depth + 1, maxDepth, maxFiles, excludedDirs, visitedDirectories, results); + } + return; + } + if (targetStats.isFile() && isRuleFile(linkName)) { + results.push({ path: linkPath, realPath: resolveRealPath(linkPath) }); + } +} +function isRuleFile(fileName) { + return RULE_FILE_EXTENSIONS.some((extension) => fileName.endsWith(extension)); +} +function resolveRealPath(filePath) { + try { + const realPath = realpathSync.native(filePath); + const fileStats = lstatSync(filePath); + return fileStats.isSymbolicLink() ? realPath : filePath; + } + catch { + return filePath; + } +} diff --git a/plugins/omo/components/rules/dist/rules/sources.d.ts b/plugins/omo/components/rules/dist/rules/sources.d.ts new file mode 100644 index 0000000..58e1226 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/sources.d.ts @@ -0,0 +1,3 @@ +import type { PiRulesConfig } from "./types.js"; +export declare const DEFAULT_AUTO_DISABLED_SOURCES: readonly string[]; +export declare function disabledSourcesFromConfig(config: PiRulesConfig): ReadonlySet | undefined; diff --git a/plugins/omo/components/rules/dist/rules/sources.js b/plugins/omo/components/rules/dist/rules/sources.js new file mode 100644 index 0000000..489e22a --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/sources.js @@ -0,0 +1,9 @@ +import { SOURCE_PRIORITY } from "./constants.js"; +export const DEFAULT_AUTO_DISABLED_SOURCES = ["AGENTS.md", "~/.claude/rules", "~/.claude/CLAUDE.md"]; +export function disabledSourcesFromConfig(config) { + if (config.enabledSources === "auto") { + return new Set(DEFAULT_AUTO_DISABLED_SOURCES); + } + const enabledSources = new Set(config.enabledSources); + return new Set([...SOURCE_PRIORITY.keys()].filter((source) => !enabledSources.has(source))); +} diff --git a/plugins/omo/components/rules/dist/rules/truncator.d.ts b/plugins/omo/components/rules/dist/rules/truncator.d.ts new file mode 100644 index 0000000..86a2634 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/truncator.d.ts @@ -0,0 +1,17 @@ +import type { TruncationResult } from "./types.js"; +type BudgetRule = { + body: string; + relativePath: string; +}; +type BudgetResult = BudgetRule & { + truncated: boolean; +}; +export declare function truncateRule(body: string, options: { + maxChars: number; + relativePath: string; +}): TruncationResult; +export declare function truncateBudget(input: { + rules: ReadonlyArray; + maxResultChars: number; +}): BudgetResult[]; +export {}; diff --git a/plugins/omo/components/rules/dist/rules/truncator.js b/plugins/omo/components/rules/dist/rules/truncator.js new file mode 100644 index 0000000..7ee0f78 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/truncator.js @@ -0,0 +1,45 @@ +import { TRUNCATION_NOTICE } from "./constants.js"; +function truncationNotice(relativePath) { + return TRUNCATION_NOTICE.replace("{path}", relativePath); +} +function safeSliceEnd(body, end) { + if (end <= 0) { + return 0; + } + const lastCodeUnit = body.charCodeAt(end - 1); + if (lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff) { + return end - 1; + } + return end; +} +export function truncateRule(body, options) { + if (body.length <= options.maxChars) { + return { body, truncated: false, originalLength: body.length }; + } + const notice = truncationNotice(options.relativePath); + if (options.maxChars < notice.length) { + return { body: notice, truncated: true, originalLength: body.length }; + } + const sliceEnd = safeSliceEnd(body, options.maxChars - notice.length); + return { body: `${body.slice(0, sliceEnd)}${notice}`, truncated: true, originalLength: body.length }; +} +export function truncateBudget(input) { + const results = []; + let remainingBudget = input.maxResultChars; + for (const rule of input.rules) { + if (remainingBudget >= rule.body.length) { + results.push({ body: rule.body, truncated: false, relativePath: rule.relativePath }); + remainingBudget -= rule.body.length; + continue; + } + const notice = truncationNotice(rule.relativePath); + if (remainingBudget <= notice.length) { + break; + } + const sliceEnd = safeSliceEnd(rule.body, remainingBudget - notice.length); + const body = `${rule.body.slice(0, sliceEnd)}${notice}`; + results.push({ body, truncated: true, relativePath: rule.relativePath }); + remainingBudget -= body.length; + } + return results; +} diff --git a/plugins/omo/components/rules/dist/rules/types.d.ts b/plugins/omo/components/rules/dist/rules/types.d.ts new file mode 100644 index 0000000..d7b2528 --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/types.d.ts @@ -0,0 +1,122 @@ +/** + * Public types for pi-rules. + * + * These types are stable contracts between modules. The frontmatter type + * mirrors omo's `RuleMetadata` plus Claude (`paths`) and Copilot (`applyTo`) + * aliases that are normalized into `globs` internally. + */ +/** + * YAML frontmatter parsed from a rule markdown file. + * `paths` (Claude alias) and `applyTo` (Copilot alias) are normalized into + * `globs` by the parser before any matcher sees this struct. + */ +export interface RuleFrontmatter { + description?: string; + globs?: string | string[]; + paths?: string | string[]; + applyTo?: string | string[]; + alwaysApply?: boolean; +} +/** + * Result of parsing a rule markdown file. + * `body` excludes the frontmatter delimiters and the YAML payload. + */ +export interface ParsedRule { + frontmatter: RuleFrontmatter; + body: string; + /** + * Diagnostic message if frontmatter parsing failed but the body was salvaged. + * Empty when parsing succeeded. + */ + diagnostic?: string; +} +/** + * A discovered rule file candidate before parsing/matching. + * + * `path` is the absolute path as discovered (possibly via symlink). + * `realPath` is the canonical resolved path used for dedup. + * `source` identifies which discovery source produced this candidate. + */ +export interface RuleCandidate { + path: string; + realPath: string; + source: RuleSource; + /** + * Distance from the target file directory to the directory containing this rule. + * 0 = same directory, 9999 = global/user-home rule. + */ + distance: number; + isGlobal: boolean; + /** + * True when this candidate is a SINGLE-FILE rule like + * `.github/copilot-instructions.md` (frontmatter optional, applies always). + */ + isSingleFile: boolean; + /** + * Path relative to project root, POSIX-normalized. Used for matcher and display. + * Empty string for user-home global rules. + */ + relativePath: string; +} +/** + * A fully-loaded rule ready for injection. + */ +export interface LoadedRule extends RuleCandidate { + frontmatter: RuleFrontmatter; + body: string; + contentHash: string; + matchReason: MatchReason; +} +/** + * Source identifier for rule files. Used for deterministic ordering and display. + */ +export type RuleSource = ".omo/rules" | ".claude/rules" | ".cursor/rules" | ".github/instructions" | ".github/copilot-instructions.md" | "CONTEXT.md" | "plugin-bundled" | "~/.omo/rules" | "~/.opencode/rules" | "~/.claude/rules"; +/** + * Why a candidate matched the target file. Surfaced in the injection block so + * the model can attribute its behavior to a specific rule. + */ +export type MatchReason = "alwaysApply" | "single-file" | { + kind: "glob"; + pattern: string; +} | { + kind: "no-match"; +}; +/** + * Truncation result. + */ +export interface TruncationResult { + body: string; + truncated: boolean; + originalLength: number; +} +/** + * Configuration knobs resolved from env vars and package.json. + */ +export interface PiRulesConfig { + disabled: boolean; + mode: "static" | "dynamic" | "both" | "off"; + maxRuleChars: number; + maxResultChars: number; + postCompactMaxRuleChars: number; + postCompactMaxResultChars: number; + enabledSources: RuleSource[] | "auto"; +} +/** + * Per-session in-memory dedup state. + * + * `staticDedup` keys are `{cwd}::{rulePath}::{contentHash}` strings. + * `dynamicDedup` stores session-scoped `{rulePath}::{contentHash}` strings. + */ +export interface SessionState { + cwd: string | undefined; + staticDedup: Set; + dynamicDedup: Map>; + dynamicTargetFingerprints: Map; + loadedRules: LoadedRule[]; + diagnostics: RuleDiagnostic[]; +} +export interface RuleDiagnostic { + severity: "warning" | "error"; + source: string; + message: string; +} diff --git a/plugins/omo/components/rules/dist/rules/types.js b/plugins/omo/components/rules/dist/rules/types.js new file mode 100644 index 0000000..4244d3b --- /dev/null +++ b/plugins/omo/components/rules/dist/rules/types.js @@ -0,0 +1,8 @@ +/** + * Public types for pi-rules. + * + * These types are stable contracts between modules. The frontmatter type + * mirrors omo's `RuleMetadata` plus Claude (`paths`) and Copilot (`applyTo`) + * aliases that are normalized into `globs` internally. + */ +export {}; diff --git a/plugins/omo/components/rules/dist/session-state-lock.d.ts b/plugins/omo/components/rules/dist/session-state-lock.d.ts new file mode 100644 index 0000000..02113cd --- /dev/null +++ b/plugins/omo/components/rules/dist/session-state-lock.d.ts @@ -0,0 +1,3 @@ +export declare const SESSION_STATE_LOCK_CONTENDED: unique symbol; +export type SessionStateLockResult = T | typeof SESSION_STATE_LOCK_CONTENDED; +export declare function withSessionStateLock(cachePath: string, callback: () => T): SessionStateLockResult; diff --git a/plugins/omo/components/rules/dist/session-state-lock.js b/plugins/omo/components/rules/dist/session-state-lock.js new file mode 100644 index 0000000..e8fe879 --- /dev/null +++ b/plugins/omo/components/rules/dist/session-state-lock.js @@ -0,0 +1,41 @@ +import { mkdirSync, rmSync } from "node:fs"; +import { dirname } from "node:path"; +export const SESSION_STATE_LOCK_CONTENDED = Symbol("session-state-lock-contended"); +const LOCK_RETRY_COUNT = 20; +const LOCK_RETRY_DELAY_MS = 5; +const LOCK_SLEEP_VIEW = new Int32Array(new SharedArrayBuffer(4)); +export function withSessionStateLock(cachePath, callback) { + const lockPath = `${cachePath}.lock`; + mkdirSync(dirname(cachePath), { recursive: true }); + for (let attempt = 0; attempt < LOCK_RETRY_COUNT; attempt += 1) { + try { + mkdirSync(lockPath); + try { + return callback(); + } + finally { + rmSync(lockPath, { recursive: true, force: true }); + } + } + catch (error) { + if (errorCode(error) === "EEXIST") { + sleepSync(LOCK_RETRY_DELAY_MS); + continue; + } + throw error; + } + } + return SESSION_STATE_LOCK_CONTENDED; +} +function errorCode(error) { + if (!isRecord(error)) { + return undefined; + } + return Reflect.get(error, "code"); +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function sleepSync(milliseconds) { + Atomics.wait(LOCK_SLEEP_VIEW, 0, 0, milliseconds); +} diff --git a/plugins/omo/components/rules/dist/sparkshell-awareness.d.ts b/plugins/omo/components/rules/dist/sparkshell-awareness.d.ts new file mode 100644 index 0000000..4a6b65c --- /dev/null +++ b/plugins/omo/components/rules/dist/sparkshell-awareness.d.ts @@ -0,0 +1,5 @@ +type RuntimeEnv = Readonly>; +export declare const SPARKSHELL_AWARENESS_DEDUP_KEY = "__omo_sparkshell_awareness__"; +export declare function isCodexAppServerActive(env?: RuntimeEnv): boolean; +export declare function getSparkShellRuntimeAwareness(env?: RuntimeEnv): string; +export {}; diff --git a/plugins/omo/components/rules/dist/sparkshell-awareness.js b/plugins/omo/components/rules/dist/sparkshell-awareness.js new file mode 100644 index 0000000..c547da7 --- /dev/null +++ b/plugins/omo/components/rules/dist/sparkshell-awareness.js @@ -0,0 +1,45 @@ +const SPARKSHELL_AWARENESS_MARKER = "## Sparkshell Runtime"; +export const SPARKSHELL_AWARENESS_DEDUP_KEY = "__omo_sparkshell_awareness__"; +export function isCodexAppServerActive(env = process.env) { + const originator = env["CODEX_INTERNAL_ORIGINATOR_OVERRIDE"]?.toLowerCase() ?? ""; + const bundleIdentifier = env["__CFBundleIdentifier"]?.toLowerCase() ?? ""; + const shellActive = isTruthy(env["CODEX_SHELL"]); + return (shellActive && + (originator.includes("codex desktop") || + originator.includes("codex app") || + bundleIdentifier === "com.openai.codex")); +} +function isSparkShellAppServerConfigured(env = process.env) { + const codexSocketPath = env["CODEX_APP_SERVER_SOCKET"]?.trim() ?? ""; + const omoSocketPath = env["OMO_SPARKSHELL_APP_SERVER_SOCKET"]?.trim() ?? ""; + return codexSocketPath.length > 0 || omoSocketPath.length > 0; +} +export function getSparkShellRuntimeAwareness(env = process.env) { + const override = env["OMO_SPARKSHELL_AWARENESS"] ?? env["LAZYCODEX_SPARKSHELL_AWARENESS"]; + if (isFalsy(override)) { + return ""; + } + if (!isTruthy(override) && !isCodexAppServerActive(env) && !isSparkShellAppServerConfigured(env)) { + return ""; + } + return [ + SPARKSHELL_AWARENESS_MARKER, + "", + "- Prefer `omo sparkshell ` for repo inspection, CLI smoke tests, git/history checks, and bounded verification before falling back to raw shell commands.", + "- Use `omo sparkshell --shell ''` only when shell metacharacters are required.", + "- Use `omo sparkshell --tmux-pane --tail-lines 400` to inspect an existing tmux pane. Tail lines must stay between 100 and 1000.", + "- When no native sidecar or appserver is available, Sparkshell silently falls back to raw command execution. `OMO_SPARKSHELL_BIN` selects a native sidecar path.", + ].join("\n"); +} +function isTruthy(value) { + if (value === undefined) { + return false; + } + return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase()); +} +function isFalsy(value) { + if (value === undefined) { + return false; + } + return ["0", "false", "no", "off"].includes(value.trim().toLowerCase()); +} diff --git a/plugins/omo/components/rules/dist/static-injection.d.ts b/plugins/omo/components/rules/dist/static-injection.d.ts new file mode 100644 index 0000000..32af80e --- /dev/null +++ b/plugins/omo/components/rules/dist/static-injection.d.ts @@ -0,0 +1,3 @@ +import type { CodexRulesHookOptions } from "./codex-hook-options.js"; +import type { TranscriptSearchOptions } from "./transcript-search.js"; +export declare function runStaticInjection(cwd: string, transcriptPath: string | null, eventName: "SessionStart" | "UserPromptSubmit", cachePath: string, options: CodexRulesHookOptions, completedPostCompactChannel?: "static", transcriptSearchOptions?: TranscriptSearchOptions, model?: string): string; diff --git a/plugins/omo/components/rules/dist/static-injection.js b/plugins/omo/components/rules/dist/static-injection.js new file mode 100644 index 0000000..49f3d85 --- /dev/null +++ b/plugins/omo/components/rules/dist/static-injection.js @@ -0,0 +1,45 @@ +import { configFromEnvironment } from "./config.js"; +import { formatAdditionalContextOutput } from "./hook-output.js"; +import { completePostCompactRecovery, hydrateEngineState, persistEngineState } from "./persistent-cache.js"; +import { withPostCompactBudget } from "./post-compact-budget.js"; +import { createRulesEngine } from "./rules-engine-factory.js"; +import { getSparkShellRuntimeAwareness, SPARKSHELL_AWARENESS_DEDUP_KEY } from "./sparkshell-awareness.js"; +import { filterRulesAlreadyInTranscript } from "./transcript-rule-filter.js"; +export function runStaticInjection(cwd, transcriptPath, eventName, cachePath, options, completedPostCompactChannel, transcriptSearchOptions = {}, model) { + const config = configFromEnvironment(options.env); + if (config.disabled || config.mode === "off" || config.mode === "dynamic") { + if (completedPostCompactChannel !== undefined) { + completePostCompactRecovery(cachePath, completedPostCompactChannel); + } + return ""; + } + const effectiveConfig = completedPostCompactChannel === undefined + ? config + : withPostCompactBudget(config, { model: model ?? "", transcriptPath }); + const engine = createRulesEngine(options, effectiveConfig); + hydrateEngineState(engine, cachePath); + engine.state.cwd = cwd; + const loaded = engine.loadStaticRules(cwd); + const rules = filterRulesAlreadyInTranscript(loaded.rules.filter((rule) => !engine.isStaticInjected(rule)), transcriptPath, (rule) => { + engine.markStaticInjected(rule); + }, transcriptSearchOptions); + const sparkshellAwareness = engine.state.staticDedup.has(SPARKSHELL_AWARENESS_DEDUP_KEY) + ? "" + : getSparkShellRuntimeAwareness(options.env); + if (rules.length === 0 && sparkshellAwareness.length === 0) { + persistEngineState(engine, cachePath, completedPostCompactChannel); + return ""; + } + const block = engine.formatStatic(rules); + for (const rule of rules) { + engine.markStaticInjected(rule); + } + if (sparkshellAwareness.length > 0) { + engine.state.staticDedup.add(SPARKSHELL_AWARENESS_DEDUP_KEY); + } + persistEngineState(engine, cachePath, completedPostCompactChannel); + return formatAdditionalContextOutput(eventName, combineStaticContext(block, sparkshellAwareness)); +} +function combineStaticContext(...blocks) { + return blocks.filter((block) => block.trim().length > 0).join("\n\n"); +} diff --git a/plugins/omo/components/rules/dist/tool-paths.d.ts b/plugins/omo/components/rules/dist/tool-paths.d.ts new file mode 100644 index 0000000..c4aa2f5 --- /dev/null +++ b/plugins/omo/components/rules/dist/tool-paths.d.ts @@ -0,0 +1,6 @@ +export interface CodexPostToolUseLike { + tool_name: string; + tool_input: unknown; + tool_response: unknown; +} +export declare function extractCodexToolPaths(input: CodexPostToolUseLike, cwd: string): string[]; diff --git a/plugins/omo/components/rules/dist/tool-paths.js b/plugins/omo/components/rules/dist/tool-paths.js new file mode 100644 index 0000000..2cd742e --- /dev/null +++ b/plugins/omo/components/rules/dist/tool-paths.js @@ -0,0 +1,168 @@ +import { existsSync, statSync } from "node:fs"; +import { isAbsolute, resolve } from "node:path"; +const COMMAND_TOOL_NAMES = new Set(["bash", "shell_command", "exec_command"]); +const TRACKED_TOOL_NAMES = new Set([ + "read", + "read_file", + "mcp__filesystem__read_file", + "mcp__filesystem__read_multiple_files", + "mcp__filesystem__write_file", + "mcp__filesystem__edit_file", + "write", + "edit", + "multiedit", + "multi_edit", + "apply_patch", + "bash", + "shell_command", + "exec_command", +]); +export function extractCodexToolPaths(input, cwd) { + const toolName = input.tool_name.toLowerCase(); + if (!TRACKED_TOOL_NAMES.has(toolName) || isFailedToolResponse(input.tool_response)) { + return []; + } + const paths = new Set(); + const toolInput = isRecord(input.tool_input) ? input.tool_input : {}; + addCommonPathFields(paths, toolInput, cwd); + addPatchPayloadPaths(paths, toolInput, cwd); + addPatchRecordPaths(paths, toolInput["files"], cwd); + addPatchRecordPaths(paths, toolInput["changes"], cwd); + if (COMMAND_TOOL_NAMES.has(toolName)) { + const command = stringProperty(toolInput, "command") ?? stringProperty(toolInput, "cmd"); + const workdir = stringProperty(toolInput, "workdir") ?? stringProperty(toolInput, "cwd"); + addCommandPaths(paths, command, workdir === undefined ? cwd : resolvePath(cwd, workdir)); + } + return [...paths]; +} +function addCommonPathFields(paths, input, cwd) { + for (const key of ["path", "filePath", "file_path", "target", "targetPath", "target_path"]) { + addPath(paths, input[key], cwd, false); + } + for (const key of ["paths", "filePaths", "file_paths"]) { + addPathArray(paths, input[key], cwd, false); + } +} +function addPatchPayloadPaths(paths, input, cwd) { + for (const key of ["input", "patch", "command", "cmd"]) { + const value = input[key]; + if (typeof value === "string") { + addPatchHeaderPaths(paths, value, cwd); + } + } +} +function addPatchHeaderPaths(paths, patch, cwd) { + for (const line of patch.split("\n")) { + for (const prefix of ["*** Add File: ", "*** Update File: ", "*** Move to: "]) { + if (line.startsWith(prefix)) { + addPath(paths, line.slice(prefix.length).trim(), cwd, false); + } + } + } +} +function addPatchRecordPaths(paths, value, cwd) { + if (!Array.isArray(value)) + return; + for (const item of value) { + if (typeof item === "string") { + addPath(paths, item, cwd, false); + continue; + } + if (!isRecord(item)) + continue; + addCommonPathFields(paths, item, cwd); + for (const key of ["movePath", "move_path", "to", "from"]) { + addPath(paths, item[key], cwd, false); + } + } +} +function addCommandPaths(paths, command, cwd) { + if (command === undefined) + return; + for (const token of tokenizeShell(command)) { + if (token.length === 0 || token.startsWith("-") || token.includes("*")) { + continue; + } + addPath(paths, token, cwd, true); + } +} +function addPathArray(paths, value, cwd, mustExist) { + if (!Array.isArray(value)) + return; + for (const item of value) { + addPath(paths, item, cwd, mustExist); + } +} +function addPath(paths, value, cwd, mustExist) { + if (typeof value !== "string" || value.length === 0 || looksLikeUrl(value)) { + return; + } + const path = resolvePath(cwd, value); + if (mustExist && !isExistingFile(path)) { + return; + } + paths.add(path); +} +function resolvePath(cwd, filePath) { + return isAbsolute(filePath) ? filePath : resolve(cwd, filePath); +} +function isExistingFile(filePath) { + try { + return existsSync(filePath) && statSync(filePath).isFile(); + } + catch { + return false; + } +} +function looksLikeUrl(value) { + return /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(value); +} +function stringProperty(value, key) { + const property = value[key]; + return typeof property === "string" && property.length > 0 ? property : undefined; +} +function tokenizeShell(command) { + const tokens = []; + let current = ""; + let quote = null; + let escaped = false; + for (const character of command) { + if (escaped) { + current += character; + escaped = false; + continue; + } + if (character === "\\") { + escaped = true; + continue; + } + if ((character === "'" || character === '"') && quote === null) { + quote = character; + continue; + } + if (quote === character) { + quote = null; + continue; + } + if (quote === null && /\s/.test(character)) { + if (current.length > 0) { + tokens.push(current); + current = ""; + } + continue; + } + current += character; + } + if (current.length > 0) { + tokens.push(current); + } + return tokens; +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function isFailedToolResponse(value) { + if (!isRecord(value)) + return false; + return (value["isError"] === true || value["is_error"] === true || value["error"] === true || value["status"] === "error"); +} diff --git a/plugins/omo/components/rules/dist/transcript-rule-filter.d.ts b/plugins/omo/components/rules/dist/transcript-rule-filter.d.ts new file mode 100644 index 0000000..a084798 --- /dev/null +++ b/plugins/omo/components/rules/dist/transcript-rule-filter.d.ts @@ -0,0 +1,3 @@ +import type { LoadedRule } from "./rules/types.js"; +import type { TranscriptSearchOptions } from "./transcript-search.js"; +export declare function filterRulesAlreadyInTranscript(rules: ReadonlyArray, transcriptPath: string | null, markInjected: (rule: LoadedRule) => void, options?: TranscriptSearchOptions): LoadedRule[]; diff --git a/plugins/omo/components/rules/dist/transcript-rule-filter.js b/plugins/omo/components/rules/dist/transcript-rule-filter.js new file mode 100644 index 0000000..52ae293 --- /dev/null +++ b/plugins/omo/components/rules/dist/transcript-rule-filter.js @@ -0,0 +1,46 @@ +import { readTranscriptSearchText } from "./transcript-search.js"; +export function filterRulesAlreadyInTranscript(rules, transcriptPath, markInjected, options = {}) { + if (rules.length === 0 || transcriptPath === null) { + return [...rules]; + } + const transcriptText = readTranscriptSearchText(transcriptPath, options); + if (transcriptText === null) { + return [...rules]; + } + const pendingRules = []; + for (const rule of rules) { + if (isRuleAlreadyInTranscript(rule, transcriptText)) { + markInjected(rule); + continue; + } + pendingRules.push(rule); + } + return pendingRules; +} +function isRuleAlreadyInTranscript(rule, transcriptText) { + const staticReferenceNeedles = [ + `- [${displayFilename(rule)}]{${rule.path}}`, + `- [${displayFilename(rule)}]{${rule.realPath}}`, + ]; + if (staticReferenceNeedles.some((needle) => transcriptText.includes(needle))) { + return true; + } + const bodyNeedle = rule.body.trim().slice(0, 2_000); + if (bodyNeedle.length === 0 || !transcriptText.includes(bodyNeedle)) { + return false; + } + const markers = [ + `Instructions from: ${rule.path}`, + `Instructions from: ${rule.realPath}`, + rule.relativePath.length === 0 ? null : rule.relativePath, + ].filter((marker) => marker !== null); + return markers.some((marker) => transcriptText.includes(marker)); +} +function displayFilename(rule) { + const normalizedPath = rule.relativePath.length > 0 ? rule.relativePath : rule.path; + const segments = normalizedPath + .replace(/\\/g, "/") + .split("/") + .filter((segment) => segment.length > 0); + return segments.at(-1) ?? normalizedPath; +} diff --git a/plugins/omo/components/rules/dist/transcript-search.d.ts b/plugins/omo/components/rules/dist/transcript-search.d.ts new file mode 100644 index 0000000..c88f13c --- /dev/null +++ b/plugins/omo/components/rules/dist/transcript-search.d.ts @@ -0,0 +1,4 @@ +export interface TranscriptSearchOptions { + readonly latestCompactedReplacementOnly?: boolean; +} +export declare function readTranscriptSearchText(transcriptPath: string, options?: TranscriptSearchOptions): string | null; diff --git a/plugins/omo/components/rules/dist/transcript-search.js b/plugins/omo/components/rules/dist/transcript-search.js new file mode 100644 index 0000000..379338d --- /dev/null +++ b/plugins/omo/components/rules/dist/transcript-search.js @@ -0,0 +1,91 @@ +import { readFileSync } from "node:fs"; +export function readTranscriptSearchText(transcriptPath, options = {}) { + try { + const rawTranscript = readFileSync(transcriptPath, "utf8"); + if (options.latestCompactedReplacementOnly === true) { + return latestCompactedReplacementSearchText(rawTranscript); + } + return [rawTranscript, ...collectJsonLineStrings(rawTranscript)].join("\n"); + } + catch (error) { + if (!(error instanceof Error)) { + throw error; + } + return null; + } +} +function latestCompactedReplacementSearchText(rawTranscript) { + const lines = rawTranscript.split(/\r?\n/); + let latestCompactedLineIndex = -1; + let replacementHistory = null; + for (const [index, line] of lines.entries()) { + const parsed = parseJsonLine(line); + if (!isRecord(parsed) || parsed["type"] !== "compacted") { + continue; + } + const payload = parsed["payload"]; + if (!isRecord(payload)) { + continue; + } + const candidateReplacementHistory = payload["replacement_history"]; + if (!Array.isArray(candidateReplacementHistory)) { + continue; + } + latestCompactedLineIndex = index; + replacementHistory = candidateReplacementHistory; + } + if (replacementHistory === null) { + return null; + } + const values = []; + collectStrings(replacementHistory, values); + const laterTranscript = lines.slice(latestCompactedLineIndex + 1).join("\n"); + values.push(laterTranscript, ...collectJsonLineStrings(laterTranscript)); + return values.join("\n"); +} +function collectJsonLineStrings(rawTranscript) { + const values = []; + for (const line of rawTranscript.split(/\r?\n/)) { + const parsed = parseJsonLine(line); + if (parsed !== null) { + collectStrings(parsed, values); + } + } + return values; +} +function parseJsonLine(line) { + if (line.trim().length === 0) { + return null; + } + try { + const parsed = JSON.parse(line); + return parsed; + } + catch (error) { + if (!(error instanceof Error)) { + throw error; + } + return null; + } +} +function collectStrings(value, output) { + if (typeof value === "string") { + output.push(value); + return; + } + if (Array.isArray(value)) { + for (const item of value) { + collectStrings(item, output); + } + return; + } + if (!isRecord(value)) { + return; + } + for (const item of Object.values(value)) { + collectStrings(item, output); + } +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/plugins/omo/components/start-work-continuation/.gitignore b/plugins/omo/components/start-work-continuation/.gitignore index 746087d..a7ece8c 100644 --- a/plugins/omo/components/start-work-continuation/.gitignore +++ b/plugins/omo/components/start-work-continuation/.gitignore @@ -1,3 +1,5 @@ dist/ +!dist/ +!dist/** node_modules/ *.log diff --git a/plugins/omo/components/start-work-continuation/dist/boulder-reader.d.ts b/plugins/omo/components/start-work-continuation/dist/boulder-reader.d.ts new file mode 100644 index 0000000..836f1ad --- /dev/null +++ b/plugins/omo/components/start-work-continuation/dist/boulder-reader.d.ts @@ -0,0 +1,16 @@ +import type { ReadonlyFileSystem } from "./types.js"; +export type PlanChecklist = { + readonly remaining: number; + readonly total: number; + readonly nextTaskLabel: string | null; +}; +export type ContinuationState = { + readonly planName: string; + readonly planPath: string; + readonly boulderPath: string; + readonly ledgerPath: string; + readonly worktreePath: string | null; + readonly checklist: PlanChecklist; +}; +export declare function parsePlanChecklist(markdown: string): PlanChecklist; +export declare function readContinuationState(cwd: string, sessionId: string, fs: ReadonlyFileSystem): ContinuationState | null; diff --git a/plugins/omo/components/start-work-continuation/dist/boulder-reader.js b/plugins/omo/components/start-work-continuation/dist/boulder-reader.js new file mode 100644 index 0000000..778870a --- /dev/null +++ b/plugins/omo/components/start-work-continuation/dist/boulder-reader.js @@ -0,0 +1,146 @@ +import { isAbsolute, join, resolve } from "node:path"; +const CHECKBOX_PATTERN = /^- \[[ xX]\] /; +const UNCHECKED_PATTERN = /^- \[ \] /; +const TODO_HEADING = "TODOs"; +const FINAL_VERIFICATION_HEADING = "Final Verification Wave"; +export function parsePlanChecklist(markdown) { + const lines = markdown.split(/\r?\n/); + const hasCountedSections = lines.some(hasCountedSectionHeading); + let remaining = 0; + let total = 0; + let nextTaskLabel = null; + let isCountedSection = !hasCountedSections; + for (const line of lines) { + const heading = parseLevelTwoHeading(line); + if (heading !== null) + isCountedSection = isCountedHeading(heading); + if (!isCountedSection) + continue; + if (!CHECKBOX_PATTERN.test(line)) + continue; + total += 1; + if (!UNCHECKED_PATTERN.test(line)) + continue; + remaining += 1; + if (nextTaskLabel === null) + nextTaskLabel = line.slice("- [ ] ".length); + } + return { remaining, total, nextTaskLabel }; +} +function hasCountedSectionHeading(line) { + const heading = parseLevelTwoHeading(line); + return heading !== null && isCountedHeading(heading); +} +export function readContinuationState(cwd, sessionId, fs) { + const boulderPath = join(cwd, ".omo", "boulder.json"); + const boulderText = readTextFile(fs, boulderPath); + if (boulderText === null) + return null; + const parsed = parseJsonObject(boulderText); + if (parsed === null) + return null; + const work = findMatchingWork(parsed, `codex:${sessionId}`); + if (work === null) + return null; + const planPath = resolvePlanPath(cwd, work.activePlan); + const planText = readTextFile(fs, planPath); + if (planText === null) + return null; + const checklist = parsePlanChecklist(planText); + if (checklist.remaining === 0) + return null; + return { + planName: work.planName, + planPath, + boulderPath, + ledgerPath: join(cwd, ".omo", "start-work", "ledger.jsonl"), + worktreePath: work.worktreePath, + checklist, + }; +} +function findMatchingWork(state, prefixedSessionId) { + const worksValue = state["works"]; + const candidates = isRecord(worksValue) ? Object.values(worksValue) : [state]; + for (const candidate of candidates) { + const work = parseBoulderWork(candidate); + if (work === null) + continue; + if (!isContinuableStatus(work.status)) + continue; + if (work.sessionIds.includes(prefixedSessionId)) + return work; + } + return null; +} +function parseBoulderWork(value) { + if (!isRecord(value)) + return null; + const activePlan = value["active_plan"]; + const planName = value["plan_name"]; + const status = parseWorkStatus(value["status"]); + const sessionIds = value["session_ids"]; + const worktreePath = value["worktree_path"]; + if (typeof activePlan !== "string") + return null; + if (typeof planName !== "string") + return null; + if (status === null) + return null; + if (!isStringArray(sessionIds)) + return null; + return { + activePlan, + planName, + status, + sessionIds, + worktreePath: typeof worktreePath === "string" ? worktreePath : null, + }; +} +function parseWorkStatus(value) { + if (value === "active" || value === "completed" || value === "paused" || value === "abandoned") + return value; + return null; +} +function isContinuableStatus(status) { + return status === "active" || status === "paused"; +} +function parseLevelTwoHeading(line) { + if (!line.startsWith("## ")) + return null; + if (line.startsWith("### ")) + return null; + return line.slice("## ".length).trim(); +} +function isCountedHeading(heading) { + return heading === TODO_HEADING || heading === FINAL_VERIFICATION_HEADING; +} +function resolvePlanPath(cwd, activePlan) { + return isAbsolute(activePlan) ? activePlan : resolve(cwd, activePlan); +} +function readTextFile(fs, path) { + try { + return fs.readFileSync(path, "utf8"); + } + catch (error) { + if (error instanceof Error) + return null; + throw error; + } +} +function parseJsonObject(json) { + try { + const parsed = JSON.parse(json); + return isRecord(parsed) ? parsed : null; + } + catch (error) { + if (error instanceof SyntaxError) + return null; + throw error; + } +} +function isStringArray(value) { + return Array.isArray(value) && value.every((item) => typeof item === "string"); +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/plugins/omo/components/start-work-continuation/dist/cli.d.ts b/plugins/omo/components/start-work-continuation/dist/cli.d.ts new file mode 100644 index 0000000..b798801 --- /dev/null +++ b/plugins/omo/components/start-work-continuation/dist/cli.d.ts @@ -0,0 +1,2 @@ +#!/usr/bin/env node +export {}; diff --git a/plugins/omo/components/start-work-continuation/dist/cli.js b/plugins/omo/components/start-work-continuation/dist/cli.js new file mode 100644 index 0000000..5280a77 --- /dev/null +++ b/plugins/omo/components/start-work-continuation/dist/cli.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import { stdin as processStdin, stdout as processStdout } from "node:process"; +import { runStopHook } from "./codex-hook.js"; +const nodeFileSystem = { + readFileSync(path, encoding) { + return readFileSync(path, encoding); + }, +}; +const command = process.argv[2]; +const subcommand = process.argv[3]; +if (command === "hook" && (subcommand === "stop" || subcommand === "subagent-stop")) { + await runHookCli(); +} +else { + process.stderr.write("Usage: omo-start-work-continuation hook \n"); + process.exitCode = 1; +} +async function runHookCli() { + const raw = await readStdin(); + if (raw.trim().length === 0) + return; + const parsed = parseHookInput(raw); + const output = runStopHook(parsed, nodeFileSystem); + if (output.length > 0) + processStdout.write(output); +} +function parseHookInput(raw) { + try { + const parsed = JSON.parse(raw); + return parsed; + } + catch (error) { + if (error instanceof SyntaxError) + return undefined; + throw error; + } +} +function readStdin() { + return new Promise((resolve) => { + let data = ""; + processStdin.setEncoding("utf8"); + processStdin.on("data", (chunk) => { + data += chunk; + }); + processStdin.once("error", () => resolve(data)); + processStdin.once("end", () => resolve(data)); + }); +} diff --git a/plugins/omo/components/start-work-continuation/dist/codex-hook.d.ts b/plugins/omo/components/start-work-continuation/dist/codex-hook.d.ts new file mode 100644 index 0000000..8b43585 --- /dev/null +++ b/plugins/omo/components/start-work-continuation/dist/codex-hook.d.ts @@ -0,0 +1,2 @@ +import type { ReadonlyFileSystem } from "./types.js"; +export declare function runStopHook(input: unknown, fs: ReadonlyFileSystem): string; diff --git a/plugins/omo/components/start-work-continuation/dist/codex-hook.js b/plugins/omo/components/start-work-continuation/dist/codex-hook.js new file mode 100644 index 0000000..0a7b5a8 --- /dev/null +++ b/plugins/omo/components/start-work-continuation/dist/codex-hook.js @@ -0,0 +1,80 @@ +import { readContinuationState } from "./boulder-reader.js"; +import { START_WORK_CONTINUATION_DIRECTIVE } from "./directive.js"; +export function runStopHook(input, fs) { + if (!isStopInput(input)) + return ""; + if (input.stop_hook_active) + return ""; + if (transcriptHasContextPressureMarker(input.transcript_path, fs)) + return ""; + const state = readContinuationState(input.cwd, input.session_id, fs); + if (state === null) + return ""; + return JSON.stringify({ + decision: "block", + reason: renderDirective(state, input.session_id), + }); +} +function renderDirective(state, sessionId) { + const lineBreak = String.fromCharCode(10); + const worktreeBlock = state.worktreePath === null + ? "" + : `${lineBreak}- Worktree: \`${state.worktreePath}\` (all edits, tests, and commands run inside this directory)`; + const replacements = { + PLAN_NAME: state.planName, + PLAN_PATH: state.planPath, + BOULDER_PATH: state.boulderPath, + REMAINING_COUNT: String(state.checklist.remaining), + TOTAL_COUNT: String(state.checklist.total), + NEXT_TASK_LABEL: state.checklist.nextTaskLabel ?? "", + WORKTREE_BLOCK: worktreeBlock, + LEDGER_PATH: state.ledgerPath, + SESSION_ID: sessionId, + }; + let rendered = START_WORK_CONTINUATION_DIRECTIVE; + for (const [placeholder, value] of Object.entries(replacements)) { + rendered = rendered.replaceAll(`{{${placeholder}}}`, value); + } + return rendered; +} +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", +]; +function transcriptHasContextPressureMarker(transcriptPath, fs) { + try { + const transcript = fs.readFileSync(transcriptPath, "utf8").toLowerCase(); + return CONTEXT_PRESSURE_MARKERS.some((marker) => transcript.includes(marker)); + } + catch (error) { + if (error instanceof Error) + return false; + throw error; + } +} +function isStopInput(value) { + return (isRecord(value) && + isStopHookEventName(value["hook_event_name"]) && + typeof value["session_id"] === "string" && + typeof value["turn_id"] === "string" && + typeof value["transcript_path"] === "string" && + typeof value["cwd"] === "string" && + typeof value["model"] === "string" && + typeof value["permission_mode"] === "string" && + typeof value["stop_hook_active"] === "boolean" && + optionalString(value["last_assistant_message"])); +} +function isStopHookEventName(value) { + return value === "Stop" || value === "SubagentStop"; +} +function optionalString(value) { + return value === undefined || typeof value === "string"; +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/plugins/omo/components/start-work-continuation/dist/directive.d.ts b/plugins/omo/components/start-work-continuation/dist/directive.d.ts new file mode 100644 index 0000000..21bb6eb --- /dev/null +++ b/plugins/omo/components/start-work-continuation/dist/directive.d.ts @@ -0,0 +1 @@ +export declare const START_WORK_CONTINUATION_DIRECTIVE: string; diff --git a/plugins/omo/components/start-work-continuation/dist/directive.js b/plugins/omo/components/start-work-continuation/dist/directive.js new file mode 100644 index 0000000..2e968cf --- /dev/null +++ b/plugins/omo/components/start-work-continuation/dist/directive.js @@ -0,0 +1,2 @@ +import { readFileSync } from "node:fs"; +export const START_WORK_CONTINUATION_DIRECTIVE = readFileSync(new URL("../directive.md", import.meta.url), "utf8"); diff --git a/plugins/omo/components/start-work-continuation/dist/index.d.ts b/plugins/omo/components/start-work-continuation/dist/index.d.ts new file mode 100644 index 0000000..fd51acd --- /dev/null +++ b/plugins/omo/components/start-work-continuation/dist/index.d.ts @@ -0,0 +1,5 @@ +export type { ContinuationState, PlanChecklist } from "./boulder-reader.js"; +export { parsePlanChecklist, readContinuationState } from "./boulder-reader.js"; +export { runStopHook } from "./codex-hook.js"; +export { START_WORK_CONTINUATION_DIRECTIVE } from "./directive.js"; +export type { ReadonlyFileSystem, StopHookEventName, StopHookOutput, StopInput } from "./types.js"; diff --git a/plugins/omo/components/start-work-continuation/dist/index.js b/plugins/omo/components/start-work-continuation/dist/index.js new file mode 100644 index 0000000..061ae60 --- /dev/null +++ b/plugins/omo/components/start-work-continuation/dist/index.js @@ -0,0 +1,3 @@ +export { parsePlanChecklist, readContinuationState } from "./boulder-reader.js"; +export { runStopHook } from "./codex-hook.js"; +export { START_WORK_CONTINUATION_DIRECTIVE } from "./directive.js"; diff --git a/plugins/omo/components/start-work-continuation/dist/types.d.ts b/plugins/omo/components/start-work-continuation/dist/types.d.ts new file mode 100644 index 0000000..8bbccd9 --- /dev/null +++ b/plugins/omo/components/start-work-continuation/dist/types.d.ts @@ -0,0 +1,20 @@ +export declare const STOP_HOOK_EVENTS: readonly ["Stop", "SubagentStop"]; +export type StopHookEventName = (typeof STOP_HOOK_EVENTS)[number]; +export type StopInput = { + readonly hook_event_name: StopHookEventName; + readonly session_id: string; + readonly turn_id: string; + readonly transcript_path: string; + readonly cwd: string; + readonly model: string; + readonly permission_mode: string; + readonly stop_hook_active: boolean; + readonly last_assistant_message?: string; +}; +export type StopHookOutput = { + readonly decision: "block"; + readonly reason: string; +}; +export type ReadonlyFileSystem = { + readFileSync(path: string, encoding: "utf8"): string; +}; diff --git a/plugins/omo/components/start-work-continuation/dist/types.js b/plugins/omo/components/start-work-continuation/dist/types.js new file mode 100644 index 0000000..d70340a --- /dev/null +++ b/plugins/omo/components/start-work-continuation/dist/types.js @@ -0,0 +1 @@ +export const STOP_HOOK_EVENTS = ["Stop", "SubagentStop"]; diff --git a/plugins/omo/components/telemetry/dist/atomic-write.d.ts b/plugins/omo/components/telemetry/dist/atomic-write.d.ts new file mode 100644 index 0000000..ae1b009 --- /dev/null +++ b/plugins/omo/components/telemetry/dist/atomic-write.d.ts @@ -0,0 +1 @@ +export declare function writeFileAtomically(filePath: string, content: string): void; diff --git a/plugins/omo/components/telemetry/dist/atomic-write.js b/plugins/omo/components/telemetry/dist/atomic-write.js new file mode 100644 index 0000000..9f19268 --- /dev/null +++ b/plugins/omo/components/telemetry/dist/atomic-write.js @@ -0,0 +1,18 @@ +import { renameSync, unlinkSync, writeFileSync } from "node:fs"; +export function writeFileAtomically(filePath, content) { + const tempPath = `${filePath}.tmp`; + writeFileSync(tempPath, content, "utf-8"); + try { + renameSync(tempPath, filePath); + } + catch (error) { + const isPermissionError = error instanceof Error && + (error.message.includes("EPERM") || error.message.includes("EACCES")); + if (process.platform === "win32" && isPermissionError) { + unlinkSync(filePath); + renameSync(tempPath, filePath); + return; + } + throw error; + } +} diff --git a/plugins/omo/components/telemetry/dist/cli.d.ts b/plugins/omo/components/telemetry/dist/cli.d.ts new file mode 100644 index 0000000..b798801 --- /dev/null +++ b/plugins/omo/components/telemetry/dist/cli.d.ts @@ -0,0 +1,2 @@ +#!/usr/bin/env node +export {}; diff --git a/plugins/omo/components/telemetry/dist/cli.js b/plugins/omo/components/telemetry/dist/cli.js new file mode 100644 index 0000000..e5c4dca --- /dev/null +++ b/plugins/omo/components/telemetry/dist/cli.js @@ -0,0 +1,62 @@ +#!/usr/bin/env node +import { stdin as processStdin, stdout as processStdout } from "node:process"; +import { runSessionStartHook } from "./codex-hook.js"; +const command = process.argv[2]; +const subcommand = process.argv[3]; +if (command === "hook" && subcommand === "session-start") { + await runHookCli(); +} +else { + process.stderr.write("Usage: omo-telemetry hook session-start\n"); + process.exitCode = 1; +} +async function runHookCli() { + const raw = await readStdin(); + if (raw.trim().length === 0) + return; + const parsed = parseHookInput(raw); + if (!isCodexSessionStartInput(parsed)) + return; + const output = await runSessionStartHook(parsed); + if (output.length > 0) { + processStdout.write(output); + } +} +function parseHookInput(raw) { + try { + const parsed = JSON.parse(raw); + return parsed; + } + catch { + return undefined; + } +} +function isCodexSessionStartInput(value) { + return (isRecord(value) && + value["hook_event_name"] === "SessionStart" && + typeof value["session_id"] === "string" && + isStringOrNull(value["transcript_path"]) && + typeof value["cwd"] === "string" && + typeof value["model"] === "string" && + typeof value["permission_mode"] === "string" && + typeof value["source"] === "string"); +} +function isStringOrNull(value) { + return typeof value === "string" || value === null; +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function readStdin() { + return new Promise((resolve, reject) => { + let data = ""; + processStdin.setEncoding("utf8"); + processStdin.on("data", (chunk) => { + data += chunk; + }); + processStdin.once("error", reject); + processStdin.once("end", () => { + resolve(data); + }); + }); +} diff --git a/plugins/omo/components/telemetry/dist/codex-hook.d.ts b/plugins/omo/components/telemetry/dist/codex-hook.d.ts new file mode 100644 index 0000000..33e8a1b --- /dev/null +++ b/plugins/omo/components/telemetry/dist/codex-hook.d.ts @@ -0,0 +1,15 @@ +import { type PostHogClient } from "./posthog.js"; +export type CodexSessionStartInput = { + session_id: string; + transcript_path: string | null; + cwd: string; + hook_event_name: "SessionStart"; + model: string; + permission_mode: string; + source: "startup" | "resume" | "clear"; +}; +export type CodexTelemetryHookOptions = { + createClient?: () => PostHogClient | Promise; + getDistinctId?: () => string; +}; +export declare function runSessionStartHook(_input: CodexSessionStartInput, options?: CodexTelemetryHookOptions): Promise; diff --git a/plugins/omo/components/telemetry/dist/codex-hook.js b/plugins/omo/components/telemetry/dist/codex-hook.js new file mode 100644 index 0000000..90ef700 --- /dev/null +++ b/plugins/omo/components/telemetry/dist/codex-hook.js @@ -0,0 +1,42 @@ +import { writeTelemetryDiagnostic, } from "./diagnostics.js"; +import { createPluginPostHog, getPostHogDistinctId, } from "./posthog.js"; +const SESSION_START_REASON = "session_start"; +function writeHookDiagnostic(event, error, errorKind) { + writeTelemetryDiagnostic({ + event, + source: "plugin", + error, + errorKind, + }); +} +export async function runSessionStartHook(_input, options = {}) { + const createClient = options.createClient ?? createPluginPostHog; + const getDistinctId = options.getDistinctId ?? getPostHogDistinctId; + let client; + try { + client = await createClient(); + } + catch (error) { + writeHookDiagnostic("telemetry_posthog_init_failed", error, error instanceof Error ? "error" : "non_error"); + return ""; + } + try { + client.trackActive(getDistinctId(), SESSION_START_REASON); + } + catch (error) { + writeHookDiagnostic("telemetry_capture_failed", error, error instanceof Error ? "error" : "non_error"); + await safeShutdown(client); + return ""; + } + await safeShutdown(client); + return ""; +} +async function safeShutdown(client) { + try { + await client.shutdown(); + } + catch (error) { + writeHookDiagnostic("telemetry_shutdown_failed", error, error instanceof Error ? "error" : "non_error"); + return; + } +} diff --git a/plugins/omo/components/telemetry/dist/data-path.d.ts b/plugins/omo/components/telemetry/dist/data-path.d.ts new file mode 100644 index 0000000..7781d54 --- /dev/null +++ b/plugins/omo/components/telemetry/dist/data-path.d.ts @@ -0,0 +1,10 @@ +import os from "node:os"; +type OsProvider = Pick; +export declare function getOsProvider(): OsProvider; +/** @internal test-only */ +export declare function __setOsProviderForTesting(provider: OsProvider): void; +/** @internal test-only */ +export declare function __resetOsProviderForTesting(): void; +export declare function getDataDir(): string; +export declare function getActivityStateDir(): string; +export {}; diff --git a/plugins/omo/components/telemetry/dist/data-path.js b/plugins/omo/components/telemetry/dist/data-path.js new file mode 100644 index 0000000..dcfffb3 --- /dev/null +++ b/plugins/omo/components/telemetry/dist/data-path.js @@ -0,0 +1,35 @@ +import { accessSync, constants, mkdirSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { CACHE_DIR_NAME } from "./product-identity.js"; +let osProviderOverride = null; +export function getOsProvider() { + return osProviderOverride ?? os; +} +/** @internal test-only */ +export function __setOsProviderForTesting(provider) { + osProviderOverride = provider; +} +/** @internal test-only */ +export function __resetOsProviderForTesting() { + osProviderOverride = null; +} +function resolveWritableDirectory(preferredDir, fallbackSuffix) { + try { + mkdirSync(preferredDir, { recursive: true }); + accessSync(preferredDir, constants.W_OK); + return preferredDir; + } + catch { + const fallbackDir = path.join(getOsProvider().tmpdir(), fallbackSuffix); + mkdirSync(fallbackDir, { recursive: true }); + return fallbackDir; + } +} +export function getDataDir() { + const preferredDataDir = process.env["XDG_DATA_HOME"] ?? path.join(getOsProvider().homedir(), ".local", "share"); + return resolveWritableDirectory(preferredDataDir, "omo-codex-data"); +} +export function getActivityStateDir() { + return path.join(getDataDir(), CACHE_DIR_NAME); +} diff --git a/plugins/omo/components/telemetry/dist/diagnostics.d.ts b/plugins/omo/components/telemetry/dist/diagnostics.d.ts new file mode 100644 index 0000000..10f6dff --- /dev/null +++ b/plugins/omo/components/telemetry/dist/diagnostics.d.ts @@ -0,0 +1,12 @@ +export type TelemetryDiagnosticEvent = "telemetry_activity_state_read_failed" | "telemetry_activity_state_write_failed" | "telemetry_capture_failed" | "telemetry_cpu_info_unavailable" | "telemetry_posthog_import_failed" | "telemetry_posthog_init_failed" | "telemetry_shutdown_failed"; +export type TelemetryDiagnosticSource = "cli" | "install" | "plugin" | "shared"; +export type TelemetryDiagnosticErrorKind = "error" | "non_error"; +export type TelemetryDiagnosticInput = { + readonly event: TelemetryDiagnosticEvent; + readonly source: TelemetryDiagnosticSource; + readonly error?: unknown; + readonly errorKind?: TelemetryDiagnosticErrorKind; +}; +export declare function getTelemetryDiagnosticsFilePath(): string; +export declare function writeTelemetryDiagnostic(input: TelemetryDiagnosticInput, now?: Date): void; +export declare function cleanupTelemetryDiagnostics(now?: Date): void; diff --git a/plugins/omo/components/telemetry/dist/diagnostics.js b/plugins/omo/components/telemetry/dist/diagnostics.js new file mode 100644 index 0000000..2ac9029 --- /dev/null +++ b/plugins/omo/components/telemetry/dist/diagnostics.js @@ -0,0 +1,108 @@ +import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { writeFileAtomically } from "./atomic-write.js"; +import { getActivityStateDir } from "./data-path.js"; +const DIAGNOSTICS_FILE_NAME = "telemetry-diagnostics.jsonl"; +const DIAGNOSTICS_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; +const DIAGNOSTICS_MAX_BYTES = 256 * 1024; +export function getTelemetryDiagnosticsFilePath() { + return join(getActivityStateDir(), DIAGNOSTICS_FILE_NAME); +} +export function writeTelemetryDiagnostic(input, now = new Date()) { + try { + cleanupTelemetryDiagnostics(now); + mkdirSync(getActivityStateDir(), { recursive: true }); + appendFileSync(getTelemetryDiagnosticsFilePath(), `${JSON.stringify(toDiagnosticRecord(input, now))}\n`, "utf-8"); + } + catch { + return; + } +} +export function cleanupTelemetryDiagnostics(now = new Date()) { + const diagnosticsFilePath = getTelemetryDiagnosticsFilePath(); + if (!existsSync(diagnosticsFilePath)) { + return; + } + try { + const cutoffMs = now.getTime() - DIAGNOSTICS_RETENTION_MS; + const retainedLines = trimToMaxBytes(readFileSync(diagnosticsFilePath, "utf-8") + .split("\n") + .filter((line) => shouldRetainLine(line, cutoffMs))); + writeFileAtomically(diagnosticsFilePath, retainedLines.length === 0 ? "" : `${retainedLines.join("\n")}\n`); + } + catch { + return; + } +} +function toDiagnosticRecord(input, now) { + return { + timestamp: now.toISOString(), + event: input.event, + source: input.source, + ...serializeError(input.error, input.errorKind), + }; +} +function serializeError(error, errorKind) { + if (error instanceof Error) { + return { + error_kind: errorKind ?? "error", + error_name: error.name, + error_message: error.message, + }; + } + if (error === undefined) { + return {}; + } + return { + error_kind: errorKind ?? "non_error", + error_name: typeof error, + error_message: String(error), + }; +} +function shouldRetainLine(line, cutoffMs) { + if (line.length === 0) { + return false; + } + const parsed = parseDiagnosticLine(line); + if (parsed === null) { + return false; + } + const timestamp = parsed["timestamp"]; + if (typeof timestamp !== "string") { + return false; + } + const timestampMs = Date.parse(timestamp); + return Number.isFinite(timestampMs) && timestampMs >= cutoffMs; +} +function parseDiagnosticLine(line) { + try { + const parsed = JSON.parse(line); + if (!isRecord(parsed)) { + return null; + } + return parsed; + } + catch { + return null; + } +} +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} +function trimToMaxBytes(lines) { + const retained = []; + let totalBytes = 0; + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index]; + if (line === undefined) { + continue; + } + const lineBytes = Buffer.byteLength(`${line}\n`, "utf-8"); + if (totalBytes + lineBytes > DIAGNOSTICS_MAX_BYTES) { + break; + } + retained.unshift(line); + totalBytes += lineBytes; + } + return retained; +} diff --git a/plugins/omo/components/telemetry/dist/env-flags.d.ts b/plugins/omo/components/telemetry/dist/env-flags.d.ts new file mode 100644 index 0000000..b3889c7 --- /dev/null +++ b/plugins/omo/components/telemetry/dist/env-flags.d.ts @@ -0,0 +1,4 @@ +export declare function shouldDisablePostHog(): boolean; +export declare function getPostHogApiKey(): string; +export declare function hasPostHogApiKey(): boolean; +export declare function getPostHogHost(): string; diff --git a/plugins/omo/components/telemetry/dist/env-flags.js b/plugins/omo/components/telemetry/dist/env-flags.js new file mode 100644 index 0000000..a446da4 --- /dev/null +++ b/plugins/omo/components/telemetry/dist/env-flags.js @@ -0,0 +1,31 @@ +import { DEFAULT_POSTHOG_API_KEY, DEFAULT_POSTHOG_HOST, } from "./product-identity.js"; +function normalizeEnvValue(value) { + return value?.trim().toLowerCase(); +} +function isDisableFlag(value) { + const normalized = normalizeEnvValue(value); + return normalized === "1" || normalized === "true"; +} +function isTelemetryOptOutFlag(value) { + const normalized = normalizeEnvValue(value); + return normalized === "0" || normalized === "false" || normalized === "no"; +} +export function shouldDisablePostHog() { + return (isDisableFlag(process.env["OMO_DISABLE_POSTHOG"]) || + isTelemetryOptOutFlag(process.env["OMO_SEND_ANONYMOUS_TELEMETRY"]) || + isDisableFlag(process.env["OMO_CODEX_DISABLE_POSTHOG"]) || + isTelemetryOptOutFlag(process.env["OMO_CODEX_SEND_ANONYMOUS_TELEMETRY"])); +} +export function getPostHogApiKey() { + const explicit = process.env["POSTHOG_API_KEY"]; + if (explicit === undefined) { + return DEFAULT_POSTHOG_API_KEY; + } + return explicit.trim(); +} +export function hasPostHogApiKey() { + return getPostHogApiKey().length > 0; +} +export function getPostHogHost() { + return process.env["POSTHOG_HOST"]?.trim() || DEFAULT_POSTHOG_HOST; +} diff --git a/plugins/omo/components/telemetry/dist/posthog-activity-state.d.ts b/plugins/omo/components/telemetry/dist/posthog-activity-state.d.ts new file mode 100644 index 0000000..9addfdb --- /dev/null +++ b/plugins/omo/components/telemetry/dist/posthog-activity-state.d.ts @@ -0,0 +1,8 @@ +export type PostHogActivityState = { + readonly lastActiveDayUTC?: string; +}; +export type PostHogActivityCaptureState = { + readonly dayUTC: string; + readonly captureDaily: boolean; +}; +export declare function getPostHogActivityCaptureState(now?: Date): PostHogActivityCaptureState; diff --git a/plugins/omo/components/telemetry/dist/posthog-activity-state.js b/plugins/omo/components/telemetry/dist/posthog-activity-state.js new file mode 100644 index 0000000..29543a4 --- /dev/null +++ b/plugins/omo/components/telemetry/dist/posthog-activity-state.js @@ -0,0 +1,68 @@ +import { existsSync, mkdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { writeFileAtomically } from "./atomic-write.js"; +import { getActivityStateDir } from "./data-path.js"; +import { writeTelemetryDiagnostic } from "./diagnostics.js"; +const POSTHOG_ACTIVITY_STATE_FILE = "posthog-activity.json"; +function getPostHogActivityStateFilePath() { + return join(getActivityStateDir(), POSTHOG_ACTIVITY_STATE_FILE); +} +function getUtcDayString(date) { + return date.toISOString().slice(0, 10); +} +function isPostHogActivityState(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} +function writeActivityStateDiagnostic(event, error, errorKind) { + writeTelemetryDiagnostic({ + event, + source: "shared", + error, + errorKind, + }); +} +function readPostHogActivityState() { + const stateFilePath = getPostHogActivityStateFilePath(); + if (!existsSync(stateFilePath)) { + return {}; + } + try { + const stateContent = readFileSync(stateFilePath, "utf-8"); + const stateJson = JSON.parse(stateContent); + if (!isPostHogActivityState(stateJson)) { + return {}; + } + return stateJson; + } + catch (error) { + writeActivityStateDiagnostic("telemetry_activity_state_read_failed", error, error instanceof Error ? "error" : "non_error"); + return {}; + } +} +function writePostHogActivityState(nextState) { + const stateDir = getActivityStateDir(); + const stateFilePath = getPostHogActivityStateFilePath(); + try { + mkdirSync(stateDir, { recursive: true }); + writeFileAtomically(stateFilePath, `${JSON.stringify(nextState, null, 2)}\n`); + } + catch (error) { + writeActivityStateDiagnostic("telemetry_activity_state_write_failed", error, error instanceof Error ? "error" : "non_error"); + return; + } +} +export function getPostHogActivityCaptureState(now = new Date()) { + const state = readPostHogActivityState(); + const dayUTC = getUtcDayString(now); + const captureDaily = state.lastActiveDayUTC !== dayUTC; + if (captureDaily) { + writePostHogActivityState({ + ...state, + lastActiveDayUTC: dayUTC, + }); + } + return { + dayUTC, + captureDaily, + }; +} diff --git a/plugins/omo/components/telemetry/dist/posthog.d.ts b/plugins/omo/components/telemetry/dist/posthog.d.ts new file mode 100644 index 0000000..22051c6 --- /dev/null +++ b/plugins/omo/components/telemetry/dist/posthog.d.ts @@ -0,0 +1,21 @@ +import os from "node:os"; +import { getPostHogActivityCaptureState } from "./posthog-activity-state.js"; +import { DEFAULT_POSTHOG_API_KEY, DEFAULT_POSTHOG_HOST } from "./product-identity.js"; +export { DEFAULT_POSTHOG_API_KEY, DEFAULT_POSTHOG_HOST }; +export type PostHogActivityReason = "session_start"; +export type PostHogClient = { + trackActive: (distinctId: string, reason: PostHogActivityReason) => void; + shutdown: () => Promise; +}; +type OsProvider = Pick; +type ActivityStateProvider = typeof getPostHogActivityCaptureState; +export declare function createPluginPostHog(): Promise; +export declare function getPostHogDistinctId(): string; +/** @internal test-only */ +export declare function __setOsProviderForTesting(provider: OsProvider): void; +/** @internal test-only */ +export declare function __resetOsProviderForTesting(): void; +/** @internal test-only */ +export declare function __setActivityStateProviderForTesting(provider: ActivityStateProvider): void; +/** @internal test-only */ +export declare function __resetActivityStateProviderForTesting(): void; diff --git a/plugins/omo/components/telemetry/dist/posthog.js b/plugins/omo/components/telemetry/dist/posthog.js new file mode 100644 index 0000000..3a8073c --- /dev/null +++ b/plugins/omo/components/telemetry/dist/posthog.js @@ -0,0 +1,133 @@ +import { createHash } from "node:crypto"; +import os from "node:os"; +import { writeTelemetryDiagnostic, } from "./diagnostics.js"; +import { getPostHogApiKey, getPostHogHost, hasPostHogApiKey, shouldDisablePostHog } from "./env-flags.js"; +import { getPostHogActivityCaptureState } from "./posthog-activity-state.js"; +import { DEFAULT_POSTHOG_API_KEY, DEFAULT_POSTHOG_HOST, EVENT_NAME, getComponentVersion, PACKAGE_NAME, PRODUCT_NAME, } from "./product-identity.js"; +export { DEFAULT_POSTHOG_API_KEY, DEFAULT_POSTHOG_HOST }; +let osProviderOverride = null; +let activityStateProviderOverride = null; +const NO_OP_POSTHOG = { + trackActive: () => undefined, + shutdown: async () => undefined, +}; +function resolveOsProvider() { + return osProviderOverride ?? os; +} +function resolveActivityStateProvider() { + return activityStateProviderOverride ?? getPostHogActivityCaptureState; +} +function writePostHogDiagnostic(event, source, error, errorKind) { + writeTelemetryDiagnostic({ event, source, error, errorKind }); +} +function getSafeCpuInfo() { + try { + const cpuInfo = resolveOsProvider().cpus(); + return { + count: cpuInfo.length, + model: cpuInfo[0]?.model, + }; + } + catch (error) { + writePostHogDiagnostic("telemetry_cpu_info_unavailable", "plugin", error, error instanceof Error ? "error" : "non_error"); + return { + count: 0, + model: undefined, + }; + } +} +function getSharedProperties() { + const osProvider = resolveOsProvider(); + const cpuInfo = getSafeCpuInfo(); + return { + platform: "omo-codex", + product_name: PRODUCT_NAME, + package_name: PACKAGE_NAME, + package_version: getComponentVersion(), + runtime: "node", + runtime_version: process.version, + source: "plugin", + $os: osProvider.platform(), + $os_version: osProvider.release(), + os_arch: osProvider.arch(), + os_type: osProvider.type(), + cpu_count: cpuInfo.count, + cpu_model: cpuInfo.model, + total_memory_gb: Math.round(osProvider.totalmem() / 1024 / 1024 / 1024), + locale: Intl.DateTimeFormat().resolvedOptions().locale, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + shell: process.env["SHELL"], + ci: Boolean(process.env["CI"]), + terminal: process.env["TERM_PROGRAM"], + }; +} +export async function createPluginPostHog() { + if (shouldDisablePostHog() || !hasPostHogApiKey()) { + return NO_OP_POSTHOG; + } + let PostHogClientConstructor; + try { + const module = await import("posthog-node"); + PostHogClientConstructor = module.PostHog; + } + catch (error) { + writePostHogDiagnostic("telemetry_posthog_import_failed", "plugin", error, error instanceof Error ? "error" : "non_error"); + return NO_OP_POSTHOG; + } + let client; + try { + client = new PostHogClientConstructor(getPostHogApiKey(), { + enableExceptionAutocapture: false, + enableLocalEvaluation: false, + strictLocalEvaluation: true, + disableRemoteConfig: true, + flushAt: 1, + flushInterval: 0, + host: getPostHogHost(), + disableGeoip: false, + }); + } + catch (error) { + writePostHogDiagnostic("telemetry_posthog_init_failed", "plugin", error, error instanceof Error ? "error" : "non_error"); + return NO_OP_POSTHOG; + } + const sharedProperties = getSharedProperties(); + return { + trackActive: (distinctId, reason) => { + const activityState = resolveActivityStateProvider()(); + if (!activityState.captureDaily) { + return; + } + client.capture({ + distinctId, + event: EVENT_NAME, + properties: { + ...sharedProperties, + $process_person_profile: false, + day_utc: activityState.dayUTC, + reason, + }, + }); + }, + shutdown: async () => client.shutdown(), + }; +} +export function getPostHogDistinctId() { + return createHash("sha256").update(`omo-codex:${resolveOsProvider().hostname()}`).digest("hex"); +} +/** @internal test-only */ +export function __setOsProviderForTesting(provider) { + osProviderOverride = provider; +} +/** @internal test-only */ +export function __resetOsProviderForTesting() { + osProviderOverride = null; +} +/** @internal test-only */ +export function __setActivityStateProviderForTesting(provider) { + activityStateProviderOverride = provider; +} +/** @internal test-only */ +export function __resetActivityStateProviderForTesting() { + activityStateProviderOverride = null; +} diff --git a/plugins/omo/components/telemetry/dist/product-identity.d.ts b/plugins/omo/components/telemetry/dist/product-identity.d.ts new file mode 100644 index 0000000..66a529a --- /dev/null +++ b/plugins/omo/components/telemetry/dist/product-identity.d.ts @@ -0,0 +1,8 @@ +export declare const PRODUCT_NAME = "omo-codex"; +export declare const PACKAGE_NAME = "@oh-my-opencode/omo-codex"; +export declare const CACHE_DIR_NAME = "omo-codex"; +export declare const EVENT_NAME = "omo_codex_daily_active"; +export declare const LEGACY_PARENT_PACKAGE = "oh-my-opencode"; +export declare const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"; +export declare const DEFAULT_POSTHOG_API_KEY = "phc_CFJhj5HyvA62QPhvyaUCtaq23aUfznnijg5VaaGkNk74"; +export declare function getComponentVersion(): string; diff --git a/plugins/omo/components/telemetry/dist/product-identity.js b/plugins/omo/components/telemetry/dist/product-identity.js new file mode 100644 index 0000000..079a348 --- /dev/null +++ b/plugins/omo/components/telemetry/dist/product-identity.js @@ -0,0 +1,29 @@ +import { readFileSync } from "node:fs"; +export const PRODUCT_NAME = "omo-codex"; +export const PACKAGE_NAME = "@oh-my-opencode/omo-codex"; +export const CACHE_DIR_NAME = "omo-codex"; +export const EVENT_NAME = "omo_codex_daily_active"; +export const LEGACY_PARENT_PACKAGE = "oh-my-opencode"; +export const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"; +export const DEFAULT_POSTHOG_API_KEY = "phc_CFJhj5HyvA62QPhvyaUCtaq23aUfznnijg5VaaGkNk74"; +function isComponentPackageManifest(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} +function readComponentVersionFromManifest() { + try { + const manifestUrl = new URL("../package.json", import.meta.url); + const manifestText = readFileSync(manifestUrl, "utf-8"); + const parsed = JSON.parse(manifestText); + if (isComponentPackageManifest(parsed) && typeof parsed.version === "string") { + return parsed.version; + } + } + catch { + return "0.0.0"; + } + return "0.0.0"; +} +const COMPONENT_VERSION_CACHE = readComponentVersionFromManifest(); +export function getComponentVersion() { + return COMPONENT_VERSION_CACHE; +} diff --git a/plugins/omo/components/ultrawork/dist/cli.d.ts b/plugins/omo/components/ultrawork/dist/cli.d.ts new file mode 100644 index 0000000..b798801 --- /dev/null +++ b/plugins/omo/components/ultrawork/dist/cli.d.ts @@ -0,0 +1,2 @@ +#!/usr/bin/env node +export {}; diff --git a/plugins/omo/components/ultrawork/dist/cli.js b/plugins/omo/components/ultrawork/dist/cli.js new file mode 100644 index 0000000..22e2e47 --- /dev/null +++ b/plugins/omo/components/ultrawork/dist/cli.js @@ -0,0 +1,48 @@ +#!/usr/bin/env node +import { stdin as processStdin, stdout as processStdout } from "node:process"; +import { runUserPromptSubmitHook } from "./codex-hook.js"; +const command = process.argv[2]; +const subcommand = process.argv[3]; +if (command === "hook" && subcommand === "user-prompt-submit") { + await runHookCli(); +} +else { + process.stderr.write("Usage: omo-ultrawork hook user-prompt-submit\n"); + process.exitCode = 1; +} +async function runHookCli() { + const raw = await readStdin(); + if (raw.trim().length === 0) + return; + const parsed = parseHookInput(raw); + const output = runUserPromptSubmitHook(parsed); + if (output.length > 0) { + processStdout.write(output); + } +} +function parseHookInput(raw) { + try { + const parsed = JSON.parse(raw); + return parsed; + } + catch (error) { + if (error instanceof SyntaxError) + return undefined; + throw error; + } +} +function readStdin() { + return new Promise((resolve) => { + let data = ""; + processStdin.setEncoding("utf8"); + processStdin.on("data", (chunk) => { + data += chunk; + }); + processStdin.once("error", () => { + resolve(data); + }); + processStdin.once("end", () => { + resolve(data); + }); + }); +} diff --git a/plugins/omo/components/ultrawork/dist/codex-hook.d.ts b/plugins/omo/components/ultrawork/dist/codex-hook.d.ts new file mode 100644 index 0000000..f0683d0 --- /dev/null +++ b/plugins/omo/components/ultrawork/dist/codex-hook.d.ts @@ -0,0 +1,7 @@ +export type CodexUserPromptSubmitInput = { + readonly hook_event_name: "UserPromptSubmit"; + readonly prompt: string; + readonly transcript_path?: string | null; +}; +export declare function runUserPromptSubmitHook(input: unknown): string; +export declare function isUltraworkPrompt(prompt: string): boolean; diff --git a/plugins/omo/components/ultrawork/dist/codex-hook.js b/plugins/omo/components/ultrawork/dist/codex-hook.js new file mode 100644 index 0000000..463c288 --- /dev/null +++ b/plugins/omo/components/ultrawork/dist/codex-hook.js @@ -0,0 +1,122 @@ +import { readFileSync } from "node:fs"; +import { ULTRAWORK_DIRECTIVE } from "./directive.js"; +const ULTRAWORK_PATTERN = /\b(?:ultrawork|ulw)\b/i; +const ULTRAWORK_DIRECTIVE_MARKER = ""; +const TRANSCRIPT_SEARCH_BYTES = 512_000; +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", +]; +export function runUserPromptSubmitHook(input) { + if (!isCodexUserPromptSubmitInput(input)) + return ""; + if (isContextPressureRecoveryPrompt(input.prompt)) + return ""; + if (hasUltraworkDirectiveAlreadyInTranscript(input.transcript_path)) + return ""; + if (isContextPressureTranscript(input.transcript_path)) + return ""; + return isUltraworkPrompt(input.prompt) ? formatAdditionalContextOutput(ULTRAWORK_DIRECTIVE) : ""; +} +function hasUltraworkDirectiveAlreadyInTranscript(transcriptPath) { + if (transcriptPath === undefined || transcriptPath === null) + return false; + try { + const rawTranscript = readTranscriptTail(transcriptPath); + for (const line of rawTranscript.split(/\r?\n/)) { + const parsed = parseJsonLine(line); + if (parsed === null) { + continue; + } + if (!isRecord(parsed)) { + continue; + } + const hookSpecificOutput = parsed["hookSpecificOutput"]; + if (!isRecord(hookSpecificOutput)) { + continue; + } + if (hookSpecificOutput["hookEventName"] !== "UserPromptSubmit") { + continue; + } + if (typeof hookSpecificOutput["additionalContext"] === "string" && + hookSpecificOutput["additionalContext"].includes(ULTRAWORK_DIRECTIVE_MARKER)) { + return true; + } + } + } + catch (error) { + if (error instanceof Error) + return false; + throw error; + } + return false; +} +function readTranscriptTail(transcriptPath) { + const rawTranscript = readFileSync(transcriptPath); + return rawTranscript.subarray(Math.max(0, rawTranscript.byteLength - TRANSCRIPT_SEARCH_BYTES)).toString("utf8"); +} +export function isUltraworkPrompt(prompt) { + return ULTRAWORK_PATTERN.test(prompt); +} +function isContextPressureRecoveryPrompt(prompt) { + const normalizedPrompt = prompt.toLowerCase(); + return CONTEXT_PRESSURE_MARKERS.some((marker) => normalizedPrompt.includes(marker)); +} +function isContextPressureTranscript(transcriptPath) { + if (transcriptPath === undefined || transcriptPath === null) + return false; + try { + return isContextPressureRecoveryPrompt(readFileSync(transcriptPath, "utf8")); + } + catch (error) { + if (error instanceof Error) + return false; + throw error; + } +} +function formatAdditionalContextOutput(additionalContext) { + const normalizedContext = normalizeAdditionalContext(additionalContext); + if (normalizedContext.length === 0) + return ""; + const output = { + hookSpecificOutput: { + hookEventName: "UserPromptSubmit", + additionalContext: normalizedContext, + }, + }; + return `${JSON.stringify(output)}\n`; +} +function normalizeAdditionalContext(additionalContext) { + return additionalContext.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim(); +} +function parseJsonLine(line) { + if (line.trim().length === 0) { + return null; + } + try { + const parsed = JSON.parse(line); + return parsed; + } + catch (error) { + if (error instanceof Error) { + return null; + } + throw error; + } +} +function isCodexUserPromptSubmitInput(value) { + return (isRecord(value) && + value["hook_event_name"] === "UserPromptSubmit" && + typeof value["prompt"] === "string" && + (value["transcript_path"] === undefined || + value["transcript_path"] === null || + typeof value["transcript_path"] === "string")); +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/plugins/omo/components/ultrawork/dist/directive.d.ts b/plugins/omo/components/ultrawork/dist/directive.d.ts new file mode 100644 index 0000000..18d2306 --- /dev/null +++ b/plugins/omo/components/ultrawork/dist/directive.d.ts @@ -0,0 +1 @@ +export declare const ULTRAWORK_DIRECTIVE: string; diff --git a/plugins/omo/components/ultrawork/dist/directive.js b/plugins/omo/components/ultrawork/dist/directive.js new file mode 100644 index 0000000..a5da6cb --- /dev/null +++ b/plugins/omo/components/ultrawork/dist/directive.js @@ -0,0 +1,2 @@ +import { readFileSync } from "node:fs"; +export const ULTRAWORK_DIRECTIVE = readFileSync(new URL("../directive.md", import.meta.url), "utf8"); diff --git a/plugins/omo/components/ulw-loop/.gitignore b/plugins/omo/components/ulw-loop/.gitignore index e5f7145..ab4dec1 100644 --- a/plugins/omo/components/ulw-loop/.gitignore +++ b/plugins/omo/components/ulw-loop/.gitignore @@ -1,5 +1,7 @@ node_modules/ dist/ +!dist/ +!dist/** *.log .DS_Store coverage/ diff --git a/plugins/omo/components/ulw-loop/dist/checkpoint.d.ts b/plugins/omo/components/ulw-loop/dist/checkpoint.d.ts new file mode 100644 index 0000000..c604b27 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/checkpoint.d.ts @@ -0,0 +1,16 @@ +import { type UlwLoopScope } from "./paths.js"; +import type { UlwLoopAggregateCompletion, UlwLoopItem, UlwLoopLedgerEntry, UlwLoopPlan } from "./types.js"; +export interface CheckpointUlwLoopArgs { + readonly goalId: string; + readonly status: "complete" | "failed" | "blocked"; + readonly evidence: string; + readonly codexGoalJson?: string; + readonly qualityGateJson?: string; +} +export interface CheckpointUlwLoopResult { + readonly plan: UlwLoopPlan; + readonly goal: UlwLoopItem; + readonly ledgerEntry: UlwLoopLedgerEntry; + readonly aggregateCompletion?: UlwLoopAggregateCompletion; +} +export declare function checkpointUlwLoop(repoRoot: string, args: CheckpointUlwLoopArgs, scope?: UlwLoopScope): Promise; diff --git a/plugins/omo/components/ulw-loop/dist/checkpoint.js b/plugins/omo/components/ulw-loop/dist/checkpoint.js new file mode 100644 index 0000000..98fa0b5 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/checkpoint.js @@ -0,0 +1,200 @@ +// biome-ignore-all format: keep checkpoint orchestration below the pure LOC budget. +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { formatCodexGoalReconciliation, readCodexGoalSnapshotInput, reconcileCodexGoalSnapshot } from "./codex-goal-snapshot.js"; +import { requireAllCriteriaPass } from "./evidence.js"; +import { codexGoalMode, compatibleCodexObjectives, expectedCodexObjective, isFinalRunCompletionCandidate } from "./goal-status.js"; +import { ulwLoopBriefPath } from "./paths.js"; +import { appendLedger, readUlwLoopPlan, withUlwLoopMutationLock, writePlan } from "./plan-io.js"; +import { classifyExternalAuthorizationBlocker, clearGoalBlockerFields, sameBlockerOccurrences, validateQualityGate } from "./quality-gate.js"; +import { iso, ULW_LOOP_DIR, ULW_LOOP_GOALS, ULW_LOOP_LEDGER, UlwLoopError } from "./types.js"; +function ulwLoopFail(message, code) { throw new UlwLoopError(message, code); } +function normalizeObjective(value) { return value.replace(/\s+/g, " ").trim(); } +function nonEmptyEvidence(value) { const trimmed = value.trim(); return trimmed || ulwLoopFail("Evidence must be a non-empty string.", "ulw_loop_evidence_required"); } +function findGoal(plan, goalId) { const goal = plan.goals.find((candidate) => candidate.id === goalId); return goal ?? ulwLoopFail(`Unknown ulw-loop id: ${goalId}.`, "ulw_loop_goal_not_found"); } +function textMentionsUlwLoopPlanArtifact(value) { + const normalized = (value ?? "").toLowerCase(); + return normalized.includes(ULW_LOOP_DIR.toLowerCase()) || normalized.includes(ULW_LOOP_GOALS.toLowerCase()) || normalized.includes(ULW_LOOP_LEDGER.toLowerCase()); +} +function textMentionsGoalId(value, goalId) { return (value ?? "").toLowerCase().includes(goalId.toLowerCase()); } +function textHasCompletionValidationEvidence(value) { + const normalized = (value ?? "").toLowerCase(); + const done = /\b(?:planned work|implementation|deliverables?|scope|task|work)\b/.test(normalized) && /\b(?:done|complete|completed|finished|shipped)\b/.test(normalized); + const verified = /\b(?:validation|verification|tests?|build|lint|review|quality gate|code-review)\b/.test(normalized) && /\b(?:passed|complete|completed|clean|green|approve|approved|clear)\b/.test(normalized); + return done && verified; +} +async function snapshotObjectiveMapsToUlwLoopPlan(repoRoot, snapshotObjective, scope) { + const actual = normalizeObjective(snapshotObjective).toLowerCase(); + if (textMentionsUlwLoopPlanArtifact(actual)) + return true; + if (actual.length < 24 || !existsSync(ulwLoopBriefPath(repoRoot, scope))) + return false; + try { + const brief = normalizeObjective(await readFile(ulwLoopBriefPath(repoRoot, scope), "utf8")).toLowerCase(); + return brief.length >= 24 && (brief.includes(actual) || actual.includes(brief)); + } + catch (error) { + if (error instanceof Error) + return false; + throw error; + } +} +async function canReconcileCompletedTaskScopedAggregateSnapshot(repoRoot, plan, goal, snapshotObjective, evidence, scope) { + if (codexGoalMode(plan) !== "aggregate") + return false; + if (goal.status !== "in_progress" || plan.activeGoalId !== goal.id) + return false; + if (isFinalRunCompletionCandidate(plan, goal)) + return snapshotObjectiveMapsToUlwLoopPlan(repoRoot, snapshotObjective, scope); + if (!textMentionsUlwLoopPlanArtifact(evidence) || !textMentionsGoalId(evidence, goal.id)) + return false; + if (!textHasCompletionValidationEvidence(evidence)) + return false; + return snapshotObjectiveMapsToUlwLoopPlan(repoRoot, snapshotObjective, scope); +} +async function canReconcileActiveFinalTaskScopedAggregateSnapshot(repoRoot, plan, goal, snapshotObjective, evidence, scope) { + if (codexGoalMode(plan) !== "aggregate") + return false; + if (goal.status !== "in_progress" || plan.activeGoalId !== goal.id) + return false; + if (!isFinalRunCompletionCandidate(plan, goal)) + return false; + if (!textHasCompletionValidationEvidence(evidence)) + return false; + return snapshotObjectiveMapsToUlwLoopPlan(repoRoot, snapshotObjective, scope); +} +function buildCompletedLegacyGoalRemediation(goal) { + return [ + "If get_goal returns a different completed legacy/thread objective, do not repeat --status complete in this thread.", + `Record a non-terminal blocker with: omo ulw-loop checkpoint --goal-id ${goal.id} --status blocked --evidence "" --codex-goal-json "".`, + "Then continue only from a Codex goal context with no active/completed conflicting goal, in the same repo/worktree, and create the intended goal there.", + ].join(" "); +} +function buildTaskScopedAggregateReconciliationHint(goal, final) { + if (final) { + return ` Final task-scoped aggregate reconciliation requires the checkpoint goal to be the active in-progress final OMO goal and the completed get_goal objective to map to the ulw-loop brief or artifact. ${buildCompletedLegacyGoalRemediation(goal)}`; + } + return ` Completed task-scoped aggregate reconciliation requires the checkpoint goal to be the active in-progress OMO goal, evidence that names that active OMO goal id, names .omo/ulw-loop/goals.json or ledger.jsonl, includes completed implementation plus validation/review evidence, and a get_goal objective that maps to the ulw-loop brief/artifact. ${buildCompletedLegacyGoalRemediation(goal)}`; +} +async function readJsonInput(raw, repoRoot) { + if (raw === undefined || raw.trim() === "") + return undefined; + const trimmed = raw.trim(); + try { + return JSON.parse(trimmed); + } + catch (error) { + if (!(error instanceof SyntaxError)) + throw error; + } + const path = resolve(repoRoot, trimmed); + if (!existsSync(path)) + return ulwLoopFail("Quality gate JSON is neither valid JSON nor a readable path.", "ulw_loop_json_input_invalid"); + try { + return JSON.parse(await readFile(path, "utf8")); + } + catch (error) { + return ulwLoopFail(`Quality gate path does not contain valid JSON${error instanceof Error ? `: ${error.message}` : "."}`, "ulw_loop_json_input_invalid"); + } +} +function makeAggregateCompletion(now, evidence, codexGoal) { + return { status: "complete", completedAt: now, evidence, codexGoal }; +} +function applyBlockedOrFailed(goal, plan, status, evidence, now) { + const signature = classifyExternalAuthorizationBlocker(evidence); + const occurrences = signature === null ? 0 : sameBlockerOccurrences(plan, signature) + 1; + const needsDecision = signature !== null && occurrences >= 3; + goal.status = needsDecision ? "needs_user_decision" : status; + goal.updatedAt = now; + if (status === "failed" || needsDecision) { + goal.failedAt = now; + goal.failureReason = evidence; + } + if (status === "blocked" || needsDecision) + goal.blockedReason = evidence; + if (signature !== null) { + goal.blockerSignature = signature; + goal.blockerOccurrenceCount = occurrences; + goal.requiredExternalDecision = `Resolve external authorization: ${signature}`; + } + if (needsDecision) + goal.nonRetriable = true; + if (plan.activeGoalId === goal.id) + delete plan.activeGoalId; +} +function ledgerKind(status, goal, aggregateCompletion) { + if (aggregateCompletion !== undefined) + return "aggregate_completed"; + if (status === "complete") + return "goal_completed"; + if (goal.status === "needs_user_decision") + return "goal_needs_user_decision"; + return status === "blocked" ? "goal_blocked" : "goal_failed"; +} +function buildLedger(now, args, goal, qualityGate, codexGoal, aggregateCompletion) { + const entry = { at: now, kind: ledgerKind(args.status, goal, aggregateCompletion), goalId: goal.id, status: goal.status, evidence: args.evidence }; + if (codexGoal !== undefined) + entry.codexGoal = codexGoal; + if (qualityGate !== undefined) + entry.qualityGate = qualityGate; + if (goal.blockerSignature !== undefined) + entry.blockerSignature = goal.blockerSignature; + if (goal.blockerOccurrenceCount !== undefined) + entry.blockerOccurrenceCount = goal.blockerOccurrenceCount; + if (goal.requiredExternalDecision !== undefined) + entry.requiredExternalDecision = goal.requiredExternalDecision; + return entry; +} +export async function checkpointUlwLoop(repoRoot, args, scope) { + return withUlwLoopMutationLock(repoRoot, scope, async () => { + const plan = await readUlwLoopPlan(repoRoot, scope); + const goal = findGoal(plan, args.goalId); + if (args.status === "complete") + requireAllCriteriaPass(goal); + const evidence = nonEmptyEvidence(args.evidence); + const now = iso(); + let aggregateCompletion; + let qualityGate; + let codexGoal; + if (args.status === "complete") { + const aggregate = codexGoalMode(plan) === "aggregate"; + const final = isFinalRunCompletionCandidate(plan, goal); + const snapshot = await readCodexGoalSnapshotInput(args.codexGoalJson, repoRoot); + const reconciliation = reconcileCodexGoalSnapshot(snapshot, { expectedObjective: expectedCodexObjective(plan, goal), ...(aggregate ? { acceptedObjectives: compatibleCodexObjectives(plan) } : {}), allowedStatuses: aggregate ? (final ? ["complete"] : ["active"]) : ["complete"], requireSnapshot: true, requireComplete: !aggregate || final }); + codexGoal = reconciliation.snapshot.raw; + if (!reconciliation.ok) { + const objective = snapshot?.objective; + const mismatchedTaskObjective = snapshot?.available === true && objective !== undefined && normalizeObjective(objective) !== normalizeObjective(expectedCodexObjective(plan, goal)); + const completedTaskScoped = mismatchedTaskObjective && snapshot.status === "complete" && await canReconcileCompletedTaskScopedAggregateSnapshot(repoRoot, plan, goal, objective, evidence, scope); + const activeFinalTaskScoped = mismatchedTaskObjective && snapshot.status === "active" && await canReconcileActiveFinalTaskScopedAggregateSnapshot(repoRoot, plan, goal, objective, evidence, scope); + const taskScoped = completedTaskScoped || activeFinalTaskScoped; + if (!taskScoped) + throw new UlwLoopError(`${formatCodexGoalReconciliation(reconciliation)}${aggregate && snapshot?.status === "complete" && objective !== undefined ? buildTaskScopedAggregateReconciliationHint(goal, final) : ""}`, "ulw_loop_codex_snapshot_mismatch"); + aggregateCompletion = makeAggregateCompletion(now, evidence, codexGoal); + } + if (final) + aggregateCompletion = makeAggregateCompletion(now, evidence, codexGoal); + if (final || aggregateCompletion !== undefined) + qualityGate = validateQualityGate(await readJsonInput(args.qualityGateJson, repoRoot)); + goal.status = "complete"; + goal.completedAt = now; + goal.evidence = evidence; + delete goal.failedAt; + delete goal.failureReason; + clearGoalBlockerFields(goal); + if (plan.activeGoalId === goal.id) + delete plan.activeGoalId; + } + else + applyBlockedOrFailed(goal, plan, args.status, evidence, now); + goal.updatedAt = now; + if (aggregateCompletion !== undefined) + plan.aggregateCompletion = aggregateCompletion; + plan.updatedAt = now; + await writePlan(repoRoot, plan, scope); + const ledgerEntry = buildLedger(now, args, goal, qualityGate, codexGoal, aggregateCompletion); + await appendLedger(repoRoot, ledgerEntry, scope); + return aggregateCompletion === undefined ? { plan, goal, ledgerEntry } : { plan, goal, ledgerEntry, aggregateCompletion }; + }); +} diff --git a/plugins/omo/components/ulw-loop/dist/cli-arg-parser.d.ts b/plugins/omo/components/ulw-loop/dist/cli-arg-parser.d.ts new file mode 100644 index 0000000..ee898de --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/cli-arg-parser.d.ts @@ -0,0 +1,17 @@ +type RecordEvidenceCliArgs = { + readonly goalId: string; + readonly criterionId: string; + readonly status: "pass" | "fail" | "blocked"; + readonly evidence: string; + readonly notes?: string; +}; +export declare function hasFlag(argv: readonly string[], flag: string): boolean; +export declare function readValue(argv: readonly string[], flag: string): string | undefined; +export declare function readRepeated(argv: readonly string[], flag: string): string[]; +export declare function parseGoalArg(argv: readonly string[]): string | undefined; +export declare function readStdin(): Promise; +export declare function positionalText(argv: readonly string[]): string; +export declare function readJsonInput(value: string | undefined): Promise; +export declare function parseCodexGoalJson(value: string | undefined): Promise; +export declare function parseRecordEvidenceArgs(argv: readonly string[]): RecordEvidenceCliArgs; +export {}; diff --git a/plugins/omo/components/ulw-loop/dist/cli-arg-parser.js b/plugins/omo/components/ulw-loop/dist/cli-arg-parser.js new file mode 100644 index 0000000..6e1eeb3 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/cli-arg-parser.js @@ -0,0 +1,97 @@ +// biome-ignore-all format: keep this module under the mandated pure LOC budget. +import { readFile } from "node:fs/promises"; +import { UlwLoopError } from "./types.js"; +const VALUE_FLAGS = new Set("--brief --brief-file --session-id --codex-goal-mode --goal --goal-id --criterion-id --status --evidence --notes --codex-goal-json --quality-gate-json --kind --rationale --title --objective --target-goal-id --source --after-json --directive-json --directive-file --idempotency-key".split(" ")); +const SUBCOMMANDS = new Set("create-goals status complete-goals criteria record-evidence checkpoint steer add-goal record-review-blockers".split(" ")); +export function hasFlag(argv, flag) { return argv.includes(flag); } +export function readValue(argv, flag) { + const index = argv.indexOf(flag); + if (index >= 0) { + const next = argv[index + 1]; + return next === undefined || next.startsWith("--") ? undefined : next; + } + const prefix = `${flag}=`; + return argv.find((arg) => arg.startsWith(prefix))?.slice(prefix.length); +} +export function readRepeated(argv, flag) { + const values = []; + const prefix = `${flag}=`; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const next = argv[index + 1]; + if (arg === flag && next !== undefined && !next.startsWith("--")) { + values.push(next); + index += 1; + } + else if (arg?.startsWith(prefix)) + values.push(arg.slice(prefix.length)); + } + return values; +} +export function parseGoalArg(argv) { return readValue(argv, "--goal-id") ?? readValue(argv, "--goal"); } +export async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +} +export function positionalText(argv) { + const words = []; + for (let index = SUBCOMMANDS.has(argv[0] ?? "") ? 1 : 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === undefined) + continue; + if (VALUE_FLAGS.has(arg)) { + index += 1; + continue; + } + if (arg.startsWith("--")) + continue; + words.push(arg); + } + return words.join(" ").trim(); +} +function looksLikeJson(value) { const trimmed = value.trim(); return trimmed.startsWith("{") || trimmed.startsWith("["); } +export async function readJsonInput(value) { + if (value === undefined) + return undefined; + try { + return JSON.parse(looksLikeJson(value) ? value : await readFile(value, "utf8")); + } + catch (error) { + const message = error instanceof Error ? error.message : "unknown error"; + throw new UlwLoopError(`Invalid JSON input: ${message}`, "ULW_LOOP_JSON_INPUT_INVALID", { cause: error }); + } +} +export async function parseCodexGoalJson(value) { + if (value === undefined) + return undefined; + const raw = looksLikeJson(value) ? value : await readFile(value, "utf8"); + try { + JSON.parse(raw); + return raw; + } + catch (error) { + const message = error instanceof Error ? error.message : "unknown error"; + throw new UlwLoopError(`Invalid --codex-goal-json: ${message}`, "ULW_LOOP_CODEX_GOAL_JSON_INVALID", { cause: error }); + } +} +function required(argv, flag, code) { + const value = readValue(argv, flag)?.trim(); + if (value) + return value; + throw new UlwLoopError(`Missing ${flag}.`, code, { details: { flag } }); +} +function evidenceStatus(value) { + switch (value) { + case "pass": return "pass"; + case "fail": return "fail"; + case "blocked": return "blocked"; + default: throw new UlwLoopError("Invalid --status; expected pass, fail, or blocked.", "ULW_LOOP_EVIDENCE_STATUS_INVALID", { details: { status: value } }); + } +} +export function parseRecordEvidenceArgs(argv) { + const result = { goalId: required(argv, "--goal-id", "ULW_LOOP_GOAL_ID_REQUIRED"), criterionId: required(argv, "--criterion-id", "ULW_LOOP_CRITERION_ID_REQUIRED"), status: evidenceStatus(required(argv, "--status", "ULW_LOOP_EVIDENCE_STATUS_REQUIRED")), evidence: required(argv, "--evidence", "ULW_LOOP_EVIDENCE_REQUIRED") }; + const notes = readValue(argv, "--notes")?.trim(); + return notes ? { ...result, notes } : result; +} diff --git a/plugins/omo/components/ulw-loop/dist/cli-commands.d.ts b/plugins/omo/components/ulw-loop/dist/cli-commands.d.ts new file mode 100644 index 0000000..ba876bf --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/cli-commands.d.ts @@ -0,0 +1 @@ +export declare function ulwLoopCommand(argv: readonly string[]): Promise; diff --git a/plugins/omo/components/ulw-loop/dist/cli-commands.js b/plugins/omo/components/ulw-loop/dist/cli-commands.js new file mode 100644 index 0000000..6396a0c --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/cli-commands.js @@ -0,0 +1,175 @@ +// biome-ignore-all format: keep cli-commands dispatcher under the 200 pure LOC budget. +import { readFile } from "node:fs/promises"; +import { checkpointUlwLoop } from "./checkpoint.js"; +import { hasFlag, parseCodexGoalJson, parseRecordEvidenceArgs, positionalText, readStdin, readValue } from "./cli-arg-parser.js"; +import { blockedDecisionHandoff, normalizeCodexGoalMode, printJson, printStatus, ULW_LOOP_HELP } from "./cli-output.js"; +import { parseSteeringProposal, printSteerResult } from "./cli-steering.js"; +import { buildCodexGoalInstruction } from "./codex-goal-instruction.js"; +import { recordEvidence } from "./evidence.js"; +import { resolveUlwLoopSessionIdFromEnv } from "./paths.js"; +import { addUlwLoopGoal, createUlwLoopPlan, startNextUlwLoop, summarizeUlwLoopPlan } from "./plan-crud.js"; +import { readUlwLoopPlan } from "./plan-io.js"; +import { recordFinalReviewBlockers } from "./review-blockers.js"; +import { steerUlwLoop } from "./steering.js"; +import { UlwLoopError } from "./types.js"; +export async function ulwLoopCommand(argv) { + const command = argv[0] ?? "help"; + const rest = argv.slice(1); + const repoRoot = process.cwd(); + const json = hasFlag(rest, "--json"); + const scope = commandScope(rest); + try { + switch (command) { + case "help": + case "--help": + case "-h": + process.stdout.write(`${ULW_LOOP_HELP}\n`); + return 0; + case "create-goals": return await createGoals(repoRoot, rest, json, scope); + case "status": return await status(repoRoot, json, scope); + case "complete-goals": return await completeGoals(repoRoot, rest, json, scope); + case "checkpoint": return await checkpoint(repoRoot, rest, json, scope); + case "steer": return await steer(repoRoot, rest, json, scope); + case "add-goal": return await addGoal(repoRoot, rest, json, scope); + case "criteria": return await criteria(repoRoot, rest, json, scope); + case "record-evidence": return await captureEvidence(repoRoot, rest, json, scope); + case "record-review-blockers": return await reviewBlockers(repoRoot, rest, json, scope); + default: + process.stdout.write(`${ULW_LOOP_HELP}\n`); + return 1; + } + } + catch (error) { + if (error instanceof UlwLoopError) + process.stderr.write(`[ulw-loop] ${error.message}\n`); + else if (error instanceof Error) + process.stderr.write(`[ulw-loop] unexpected: ${error.message}\n`); + else + process.stderr.write("[ulw-loop] unknown error\n"); + return 1; + } +} +function commandScope(argv) { + const sessionId = readValue(argv, "--session-id") ?? resolveUlwLoopSessionIdFromEnv(); + return sessionId === null ? undefined : { sessionId }; +} +async function createGoals(repoRoot, argv, json, scope) { + const briefFile = readValue(argv, "--brief-file"); + const brief = readValue(argv, "--brief") ?? (briefFile === undefined ? undefined : await readFile(briefFile, "utf8")) ?? (hasFlag(argv, "--from-stdin") ? await readStdin() : undefined) ?? positionalText(argv); + if (!brief.trim()) + throw new UlwLoopError("Missing brief text. Pass --brief, --brief-file, --from-stdin, or positional text.", "ULW_LOOP_BRIEF_REQUIRED"); + const plan = await createUlwLoopPlan(repoRoot, { brief, codexGoalMode: normalizeCodexGoalMode(readValue(argv, "--codex-goal-mode")), force: hasFlag(argv, "--force") }, scope); + if (json) + printJson({ ok: true, plan, summary: summarizeUlwLoopPlan(plan) }); + else + process.stdout.write(`ulw-loop plan created: ${plan.goals.length} goal(s)\nbrief: ${plan.briefPath}\ngoals: ${plan.goalsPath}\nledger: ${plan.ledgerPath}\n`); + return 0; +} +async function status(repoRoot, json, scope) { + const plan = await readUlwLoopPlan(repoRoot, scope); + if (json) + printJson({ ok: true, plan, summary: summarizeUlwLoopPlan(plan) }); + else + printStatus(plan); + return 0; +} +async function completeGoals(repoRoot, argv, json, scope) { + const result = await startNextUlwLoop(repoRoot, { retryFailed: hasFlag(argv, "--retry-failed") }, scope); + if ("done" in result) { + const handoff = blockedDecisionHandoff(result.plan); + if (json) + printJson({ ok: true, done: true, blocked: handoff.length > 0, handoff, summary: summarizeUlwLoopPlan(result.plan), plan: result.plan }); + else + process.stdout.write(`${handoff || "ulw-loop: all goals complete"}\n`); + return 0; + } + const instruction = buildCodexGoalInstruction({ plan: result.plan, goal: result.goal }); + if (json) + printJson({ ok: true, resumed: result.resumed, goal: result.goal, instruction, plan: result.plan }); + else + process.stdout.write(`${instruction.text}\n`); + return 0; +} +async function checkpoint(repoRoot, argv, json, scope) { + const goalId = required(argv, "--goal-id"); + const statusValue = checkpointStatus(required(argv, "--status")); + const evidence = required(argv, "--evidence"); + const codexGoalJson = await parseCodexGoalJson(statusValue === "complete" ? required(argv, "--codex-goal-json") : readValue(argv, "--codex-goal-json")); + if (statusValue === "complete" && codexGoalJson === undefined) + throw new UlwLoopError("Missing --codex-goal-json.", "ULW_LOOP_CODEX_GOAL_JSON_REQUIRED"); + const qualityGateJson = readValue(argv, "--quality-gate-json"); + const args = { + goalId, + status: statusValue, + evidence, + ...(codexGoalJson === undefined ? {} : { codexGoalJson }), + ...(qualityGateJson === undefined ? {} : { qualityGateJson }), + }; + const result = await checkpointUlwLoop(repoRoot, args, scope); + if (json) + printJson({ ok: true, ...result, summary: summarizeUlwLoopPlan(result.plan) }); + else + process.stdout.write(`ulw-loop checkpoint: ${result.goal.id} -> ${result.goal.status}\n`); + return 0; +} +async function steer(repoRoot, argv, json, scope) { + const proposal = await parseSteeringProposal(argv); + const result = await steerUlwLoop(repoRoot, proposal, scope); + printSteerResult(result, json); + return result.accepted ? 0 : 1; +} +async function addGoal(repoRoot, argv, json, scope) { + const result = await addUlwLoopGoal(repoRoot, { title: required(argv, "--title"), objective: required(argv, "--objective") }, scope); + if (json) + printJson({ ok: true, plan: result.plan, goal: result.goal, summary: summarizeUlwLoopPlan(result.plan) }); + else { + process.stdout.write(`ulw-loop added goal: ${result.goal.id}\n`); + printStatus(result.plan); + } + return 0; +} +async function criteria(repoRoot, argv, json, scope) { + const goalId = required(argv, "--goal-id"); + const goal = findGoal(await readUlwLoopPlan(repoRoot, scope), goalId); + if (json) + printJson({ ok: true, goalId: goal.id, criteria: goal.successCriteria }); + else + process.stdout.write(`criteria for ${goal.id}:\n${goal.successCriteria.map((c) => `- ${c.id} [${c.status}] (${c.userModel}) ${c.scenario} evidence: ${c.capturedEvidence ?? "pending"}`).join("\n")}\n`); + return 0; +} +async function captureEvidence(repoRoot, argv, json, scope) { + const result = await recordEvidence(repoRoot, parseRecordEvidenceArgs(argv), scope); + if (json) + printJson({ ok: true, ...result, summary: summarizeUlwLoopPlan(result.plan) }); + else + process.stdout.write(`ulw-loop evidence recorded: ${result.goal.id}/${result.criterion.id} -> ${result.criterion.status}\n`); + return 0; +} +async function reviewBlockers(repoRoot, argv, json, scope) { + const codexGoalJson = await parseCodexGoalJson(required(argv, "--codex-goal-json")); + if (codexGoalJson === undefined) + throw new UlwLoopError("Missing --codex-goal-json.", "ULW_LOOP_CODEX_GOAL_JSON_REQUIRED"); + const result = await recordFinalReviewBlockers(repoRoot, { goalId: required(argv, "--goal-id"), title: required(argv, "--title"), objective: required(argv, "--objective"), evidence: required(argv, "--evidence"), codexGoalJson }, scope); + if (json) + printJson({ ok: true, plan: result.plan, blockedGoal: result.blockedGoal, goal: result.newGoal, ledgerEntries: result.ledgerEntries, summary: summarizeUlwLoopPlan(result.plan) }); + else + process.stdout.write(`ulw-loop final review blockers recorded: ${result.blockedGoal.id} -> review_blocked; added ${result.newGoal.id}\n`); + return 0; +} +function required(argv, flag) { + const value = readValue(argv, flag)?.trim(); + if (value) + return value; + throw new UlwLoopError(`Missing ${flag}.`, "ULW_LOOP_ARGUMENT_MISSING", { details: { flag } }); +} +function checkpointStatus(value) { + if (value === "complete" || value === "failed" || value === "blocked") + return value; + throw new UlwLoopError("Missing or invalid --status; expected complete, failed, or blocked.", "ULW_LOOP_STATUS_INVALID", { details: { status: value } }); +} +function findGoal(plan, goalId) { + const goal = plan.goals.find((candidate) => candidate.id === goalId); + if (goal !== undefined) + return goal; + throw new UlwLoopError(`Unknown ulw-loop id: ${goalId}.`, "ULW_LOOP_GOAL_NOT_FOUND", { details: { goalId } }); +} diff --git a/plugins/omo/components/ulw-loop/dist/cli-output.d.ts b/plugins/omo/components/ulw-loop/dist/cli-output.d.ts new file mode 100644 index 0000000..f6e08f0 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/cli-output.d.ts @@ -0,0 +1,6 @@ +import type { UlwLoopCodexGoalMode, UlwLoopPlan } from "./types.js"; +export declare const ULW_LOOP_HELP = "Usage:\n omo ulw-loop create-goals --brief \"...\" [--brief-file ] [--from-stdin] [--codex-goal-mode aggregate|per_story] [--force] [--json]\n omo ulw-loop status [--json]\n omo ulw-loop complete-goals [--retry-failed] [--json]\n omo ulw-loop criteria --goal-id [--json]\n omo ulw-loop record-evidence --goal-id --criterion-id --status pass|fail|blocked --evidence \"...\" [--notes \"...\"] [--json]\n omo ulw-loop checkpoint --goal-id --status complete|failed|blocked --evidence \"...\" --codex-goal-json <...> [--quality-gate-json <...>] [--json]\n omo ulw-loop steer --kind ... --evidence \"...\" --rationale \"...\" [--json]\n omo ulw-loop add-goal --title \"...\" --objective \"...\" [--json]\n omo ulw-loop record-review-blockers --goal-id --title \"...\" --objective \"...\" --evidence \"...\" --codex-goal-json <...> [--json]\n\nAll subcommands accept [--session-id ] to isolate state under .omo/ulw-loop//; without it, Codex session env is used when present."; +export declare function printJson(value: unknown): void; +export declare function printStatus(plan: UlwLoopPlan): void; +export declare function blockedDecisionHandoff(plan: UlwLoopPlan): string; +export declare function normalizeCodexGoalMode(value: string | undefined): UlwLoopCodexGoalMode; diff --git a/plugins/omo/components/ulw-loop/dist/cli-output.js b/plugins/omo/components/ulw-loop/dist/cli-output.js new file mode 100644 index 0000000..ddf5ac4 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/cli-output.js @@ -0,0 +1,55 @@ +import { UlwLoopError } from "./types.js"; +export const ULW_LOOP_HELP = `Usage: + omo ulw-loop create-goals --brief "..." [--brief-file ] [--from-stdin] [--codex-goal-mode aggregate|per_story] [--force] [--json] + omo ulw-loop status [--json] + omo ulw-loop complete-goals [--retry-failed] [--json] + omo ulw-loop criteria --goal-id [--json] + omo ulw-loop record-evidence --goal-id --criterion-id --status pass|fail|blocked --evidence "..." [--notes "..."] [--json] + omo ulw-loop checkpoint --goal-id --status complete|failed|blocked --evidence "..." --codex-goal-json <...> [--quality-gate-json <...>] [--json] + omo ulw-loop steer --kind ... --evidence "..." --rationale "..." [--json] + omo ulw-loop add-goal --title "..." --objective "..." [--json] + omo ulw-loop record-review-blockers --goal-id --title "..." --objective "..." --evidence "..." --codex-goal-json <...> [--json] + +All subcommands accept [--session-id ] to isolate state under .omo/ulw-loop//; without it, Codex session env is used when present.`; +export function printJson(value) { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} +function criteriaCounts(goal) { + let pass = 0; + for (const criterion of goal.successCriteria) + if (criterion.status === "pass") + pass += 1; + return { pass, total: goal.successCriteria.length }; +} +export function printStatus(plan) { + let totalCriteria = 0; + let passCriteria = 0; + const lines = ["ulw-loop status", "", "goals:"]; + for (const goal of plan.goals) { + const counts = criteriaCounts(goal); + totalCriteria += counts.total; + passCriteria += counts.pass; + const marker = goal.id === plan.activeGoalId ? "*" : "-"; + lines.push(`${marker} ${goal.id} [${goal.status}] ${goal.title} (criteria: ${counts.pass}/${counts.total})`); + } + lines.push("", "summary:", `total goals: ${plan.goals.length}`, `criteria: ${passCriteria}/${totalCriteria} pass`); + process.stdout.write(`${lines.join("\n")}\n`); +} +export function blockedDecisionHandoff(plan) { + const blocked = plan.goals.find((goal) => goal.status === "needs_user_decision" && goal.nonRetriable); + if (blocked === undefined) + return ""; + return [ + "ulw-loop: blocked on repeated external authorization; no retryable failed goals remain.", + `Goal: ${blocked.id} - ${blocked.title}`, + `Required external decision: ${blocked.requiredExternalDecision ?? "provide the missing authorization or choose a different unblock path"}.`, + "Do not run complete-goals --retry-failed again until external state changes or the user authorizes an unblock path.", + ].join("\n"); +} +export function normalizeCodexGoalMode(value) { + if (value === undefined) + return "aggregate"; + if (value === "aggregate" || value === "per_story") + return value; + throw new UlwLoopError("Invalid --codex-goal-mode; expected aggregate or per_story.", "ULW_LOOP_CODEX_GOAL_MODE_INVALID", { details: { value } }); +} diff --git a/plugins/omo/components/ulw-loop/dist/cli-steering.d.ts b/plugins/omo/components/ulw-loop/dist/cli-steering.d.ts new file mode 100644 index 0000000..13cc108 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/cli-steering.d.ts @@ -0,0 +1,12 @@ +import type { SteerUlwLoopResult, UlwLoopSteeringMutationKind, UlwLoopSteeringProposal, UlwLoopSteeringSource, UlwLoopSuccessCriterionUserModel } from "./types.js"; +export type CliSteeringProposal = UlwLoopSteeringProposal & { + readonly goalId?: string; + readonly scenario?: string; + readonly expectedEvidence?: string; + readonly userModel?: UlwLoopSuccessCriterionUserModel; +}; +export declare function parseSteeringKind(argv: readonly string[]): UlwLoopSteeringMutationKind; +export declare function parseSteeringSource(argv: readonly string[]): UlwLoopSteeringSource; +export declare function parseSteeringProposal(argv: readonly string[]): Promise; +export declare function normalizeSteeringProposal(proposal: CliSteeringProposal): CliSteeringProposal; +export declare function printSteerResult(result: SteerUlwLoopResult, json: boolean): void; diff --git a/plugins/omo/components/ulw-loop/dist/cli-steering.js b/plugins/omo/components/ulw-loop/dist/cli-steering.js new file mode 100644 index 0000000..9eae536 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/cli-steering.js @@ -0,0 +1,145 @@ +// biome-ignore-all format: keep this module under the mandated pure LOC budget. +import { parseGoalArg, readJsonInput, readValue } from "./cli-arg-parser.js"; +import { printJson, printStatus } from "./cli-output.js"; +import { ULW_LOOP_STEERING_MUTATION_KINDS, ULW_LOOP_SUCCESS_CRITERION_USER_MODELS, UlwLoopError } from "./types.js"; +const SOURCES = ["user_prompt_submit", "finding", "cli"]; +function isKind(value) { return value !== undefined && ULW_LOOP_STEERING_MUTATION_KINDS.some((kind) => kind === value); } +function isSource(value) { return value !== undefined && SOURCES.some((source) => source === value); } +function isModel(value) { return ULW_LOOP_SUCCESS_CRITERION_USER_MODELS.some((model) => model === value); } +function fail(message, code, details) { throw new UlwLoopError(message, code, { details }); } +function text(value, field) { if (value === undefined) + return undefined; const trimmed = value.trim(); if (trimmed.length > 0) + return trimmed; return fail(`Empty ${field}.`, "ULW_LOOP_STEERING_FIELD_EMPTY", { field }); } +function required(argv, flag) { const value = text(readValue(argv, flag), flag); return value ?? fail(`Missing ${flag}.`, "ULW_LOOP_STEERING_FIELD_REQUIRED", { flag }); } +function requiredGoal(argv) { const value = text(parseGoalArg(argv), "--goal-id"); return value ?? fail("Missing --goal-id.", "ULW_LOOP_GOAL_ID_REQUIRED", { flag: "--goal-id" }); } +function readObject(value, key) { return Object.entries(value).find(([name]) => name === key)?.[1]; } +function isPlain(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } +function objectText(value, key) { const candidate = readObject(value, key); return typeof candidate === "string" ? candidate : undefined; } +export function parseSteeringKind(argv) { + const value = readValue(argv, "--kind"); + if (isKind(value)) + return value; + return value === undefined ? fail("Missing --kind.", "ULW_LOOP_STEERING_KIND_REQUIRED", { flag: "--kind" }) : fail(`Invalid --kind: ${value}.`, "ULW_LOOP_STEERING_KIND_INVALID", { value, expected: ULW_LOOP_STEERING_MUTATION_KINDS }); +} +export function parseSteeringSource(argv) { + const value = readValue(argv, "--source"); + if (value === undefined) + return "cli"; + return isSource(value) ? value : fail(`Invalid --source: ${value}.`, "ULW_LOOP_STEERING_SOURCE_INVALID", { value, expected: SOURCES }); +} +function child(value) { + if (!isPlain(value)) + return null; + const title = text(objectText(value, "title"), "title"); + const objective = text(objectText(value, "objective"), "objective"); + if (title === undefined || objective === undefined) + return null; + return { title, objective }; +} +async function children(argv, flag, needed) { + const input = needed ? required(argv, flag) : text(readValue(argv, flag), flag); + if (input === undefined) + return []; + const raw = await readJsonInput(input); + if (!Array.isArray(raw)) + return fail(`${flag} must be a JSON array.`, "ULW_LOOP_STEERING_JSON_ARRAY_REQUIRED", { flag }); + const parsed = []; + for (const item of raw) { + const next = child(item); + if (next === null) + return fail(`${flag} entries require title/objective.`, "ULW_LOOP_STEERING_CHILD_INVALID", { flag }); + parsed.push(next); + } + return parsed; +} +async function stringArray(argv, flag) { + const raw = await readJsonInput(required(argv, flag)); + if (!Array.isArray(raw)) + return fail(`${flag} must be a JSON array.`, "ULW_LOOP_STEERING_JSON_ARRAY_REQUIRED", { flag }); + const values = []; + for (const item of raw) { + if (typeof item !== "string") + return fail(`${flag} entries must be strings.`, "ULW_LOOP_STEERING_STRING_ARRAY_REQUIRED", { flag }); + values.push(text(item, flag) ?? ""); + } + return values; +} +function model(value) { const trimmed = text(value, "--user-model"); if (trimmed === undefined) + return undefined; return isModel(trimmed) ? trimmed : fail(`Invalid --user-model: ${trimmed}.`, "ULW_LOOP_STEERING_USER_MODEL_INVALID", { value: trimmed, expected: ULW_LOOP_SUCCESS_CRITERION_USER_MODELS }); } +function neverKind(kind) { return fail(`Unsupported steering kind: ${String(kind)}.`, "ULW_LOOP_STEERING_KIND_UNSUPPORTED", { kind }); } +export async function parseSteeringProposal(argv) { + const kind = parseSteeringKind(argv); + const source = parseSteeringSource(argv); + const base = { kind, source, evidence: required(argv, "--evidence"), rationale: required(argv, "--rationale") }; + switch (kind) { + case "add_subgoal": return normalizeSteeringProposal({ ...base, title: required(argv, "--title"), objective: required(argv, "--objective") }); + case "split_subgoal": { + const goalId = requiredGoal(argv); + return normalizeSteeringProposal({ ...base, goalId, targetGoalId: goalId, childGoals: await children(argv, "--children", true) }); + } + case "reorder_pending": return normalizeSteeringProposal({ ...base, pendingOrder: await stringArray(argv, "--order") }); + case "revise_pending_wording": { + const goalId = requiredGoal(argv); + const revisedTitle = readValue(argv, "--title"); + const revisedObjective = readValue(argv, "--objective"); + if (revisedTitle === undefined && revisedObjective === undefined) + return fail("revise_pending_wording requires --title or --objective.", "ULW_LOOP_STEERING_UPDATE_REQUIRED", { kind }); + return normalizeSteeringProposal({ ...base, goalId, targetGoalId: goalId, ...(revisedTitle === undefined ? {} : { revisedTitle }), ...(revisedObjective === undefined ? {} : { revisedObjective }) }); + } + case "revise_criterion": { + const goalId = requiredGoal(argv); + const criterionId = required(argv, "--criterion-id"); + const scenario = readValue(argv, "--scenario"); + const expectedEvidence = readValue(argv, "--expected-evidence"); + const userModel = model(readValue(argv, "--user-model")); + if (scenario === undefined && expectedEvidence === undefined && userModel === undefined) + return fail("revise_criterion requires scenario, expected-evidence, or user-model.", "ULW_LOOP_STEERING_UPDATE_REQUIRED", { kind }); + return normalizeSteeringProposal({ ...base, goalId, targetGoalId: goalId, criterionId, ...(scenario === undefined ? {} : { scenario }), ...(expectedEvidence === undefined ? {} : { expectedEvidence }), ...(userModel === undefined ? {} : { userModel }) }); + } + case "annotate_ledger": return normalizeSteeringProposal(base); + case "mark_blocked_superseded": { + const goalId = requiredGoal(argv); + const childGoals = await children(argv, "--replacements", false); + return normalizeSteeringProposal({ ...base, goalId, targetGoalId: goalId, ...(childGoals.length === 0 ? {} : { childGoals }) }); + } + default: return neverKind(kind); + } +} +function normalizedChildren(values) { if (values === undefined) + return undefined; return values.map((item) => ({ title: text(item.title, "child.title") ?? "", objective: text(item.objective, "child.objective") ?? "" })); } +function normalizedStrings(values, field) { if (values === undefined) + return undefined; return values.map((value) => text(value, field) ?? ""); } +export function normalizeSteeringProposal(proposal) { + const evidence = text(proposal.evidence, "evidence") ?? ""; + const rationale = text(proposal.rationale, "rationale") ?? ""; + const goalId = text(proposal.goalId, "goalId"); + const targetGoalId = text(proposal.targetGoalId, "targetGoalId"); + const targetGoalIds = normalizedStrings(proposal.targetGoalIds, "targetGoalIds"); + const criterionId = text(proposal.criterionId, "criterionId"); + const title = text(proposal.title, "title"); + const objective = text(proposal.objective, "objective"); + const revisedTitle = text(proposal.revisedTitle, "revisedTitle"); + const revisedObjective = text(proposal.revisedObjective, "revisedObjective"); + const blockedReason = text(proposal.blockedReason, "blockedReason"); + const directiveText = text(proposal.directiveText, "directiveText"); + const promptSignature = text(proposal.promptSignature, "promptSignature"); + const idempotencyKey = text(proposal.idempotencyKey, "idempotencyKey"); + const scenario = text(proposal.scenario, "scenario"); + const expectedEvidence = text(proposal.expectedEvidence, "expectedEvidence"); + const childGoals = normalizedChildren(proposal.childGoals); + const pendingOrder = normalizedStrings(proposal.pendingOrder, "pendingOrder"); + return { kind: proposal.kind, source: proposal.source, evidence, rationale, ...(goalId === undefined ? {} : { goalId }), ...(targetGoalId === undefined ? {} : { targetGoalId }), ...(targetGoalIds === undefined ? {} : { targetGoalIds }), ...(criterionId === undefined ? {} : { criterionId }), ...(title === undefined ? {} : { title }), ...(objective === undefined ? {} : { objective }), ...(childGoals === undefined ? {} : { childGoals }), ...(revisedTitle === undefined ? {} : { revisedTitle }), ...(revisedObjective === undefined ? {} : { revisedObjective }), ...(pendingOrder === undefined ? {} : { pendingOrder }), ...(blockedReason === undefined ? {} : { blockedReason }), ...(proposal.after === undefined ? {} : { after: proposal.after }), ...(directiveText === undefined ? {} : { directiveText }), ...(promptSignature === undefined ? {} : { promptSignature }), ...(idempotencyKey === undefined ? {} : { idempotencyKey }), ...(proposal.now === undefined ? {} : { now: proposal.now }), ...(scenario === undefined ? {} : { scenario }), ...(expectedEvidence === undefined ? {} : { expectedEvidence }), ...(proposal.userModel === undefined ? {} : { userModel: proposal.userModel }) }; +} +export function printSteerResult(result, json) { + if (json) { + printJson({ ok: result.accepted, accepted: result.accepted, rejectedReasons: result.rejectedReasons, deduped: result.deduped, audit: result.audit, plan: result.plan }); + return; + } + const outcome = result.deduped ? "deduped" : result.accepted ? "accepted" : "rejected"; + process.stdout.write(`ulw-loop steer: ${outcome} ${result.audit.kind}\n`); + if (result.rejectedReasons.length > 0) + process.stdout.write(`rejected: ${result.rejectedReasons.join("; ")}\n`); + if (result.audit.idempotencyKey !== undefined) + process.stdout.write(`idempotency-key: ${result.audit.idempotencyKey}\n`); + printStatus(result.plan); +} diff --git a/plugins/omo/components/ulw-loop/dist/cli.d.ts b/plugins/omo/components/ulw-loop/dist/cli.d.ts new file mode 100644 index 0000000..b798801 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/cli.d.ts @@ -0,0 +1,2 @@ +#!/usr/bin/env node +export {}; diff --git a/plugins/omo/components/ulw-loop/dist/cli.js b/plugins/omo/components/ulw-loop/dist/cli.js new file mode 100644 index 0000000..475b8a6 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/cli.js @@ -0,0 +1,37 @@ +#!/usr/bin/env node +import { ulwLoopCommand } from "./cli-commands.js"; +import { runPreToolUseGoalBudgetGuardCli, runUlwLoopHookCli } from "./codex-hook.js"; +const TOP_LEVEL_HELP = "Usage:\n omo ulw-loop [args]\n omo hook user-prompt-submit (Codex UserPromptSubmit hook)\n omo help | --help | -h (this message)\n\nRun `omo ulw-loop help` for ulw-loop subcommands.\n"; +async function main() { + const argv = process.argv.slice(2); + const command = argv[0]; + if (command === undefined || command === "help" || command === "--help" || command === "-h") { + process.stdout.write(TOP_LEVEL_HELP); + return 0; + } + if (command === "ulw-loop") + return ulwLoopCommand(argv.slice(1)); + if (command === "hook") { + const sub = argv[1]; + if (sub === "user-prompt-submit") { + await runUlwLoopHookCli(process.stdin, process.stdout); + return 0; + } + if (sub === "pre-tool-use") { + await runPreToolUseGoalBudgetGuardCli(process.stdin, process.stdout); + return 0; + } + process.stderr.write(`[omo] unknown hook subcommand: ${sub ?? "(none)"}\n`); + return 1; + } + process.stderr.write(`[omo] unknown command: ${command}\n${TOP_LEVEL_HELP}`); + return 1; +} +main() + .then((code) => { + process.exit(code); +}) + .catch((error) => { + process.stderr.write(`[omo] ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/plugins/omo/components/ulw-loop/dist/codex-goal-instruction.d.ts b/plugins/omo/components/ulw-loop/dist/codex-goal-instruction.d.ts new file mode 100644 index 0000000..01f5388 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/codex-goal-instruction.d.ts @@ -0,0 +1,13 @@ +import type { UlwLoopItem, UlwLoopPlan } from "./types.js"; +export interface CodexCreateGoalPayload { + readonly objective: string; +} +export interface UlwLoopGoalInstruction { + readonly text: string; + readonly json: CodexCreateGoalPayload; +} +export declare function buildCodexGoalInstruction(args: { + readonly plan: UlwLoopPlan; + readonly goal: UlwLoopItem; + readonly isFinal?: boolean; +}): UlwLoopGoalInstruction; diff --git a/plugins/omo/components/ulw-loop/dist/codex-goal-instruction.js b/plugins/omo/components/ulw-loop/dist/codex-goal-instruction.js new file mode 100644 index 0000000..0c50640 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/codex-goal-instruction.js @@ -0,0 +1,100 @@ +import { codexGoalMode, expectedCodexObjective, isFinalRunCompletionCandidate } from "./goal-status.js"; +export function buildCodexGoalInstruction(args) { + const mode = codexGoalMode(args.plan); + const createGoal = buildCreateGoalPayload(args.plan, args.goal); + const isFinal = args.isFinal ?? isFinalRunCompletionCandidate(args.plan, args.goal); + return { text: buildText(mode, args.plan, args.goal, createGoal, isFinal), json: createGoal }; +} +function buildCreateGoalPayload(plan, goal) { + return { objective: expectedCodexObjective(plan, goal) }; +} +function buildText(mode, plan, goal, createGoal, isFinal) { + return joinLines([ + mode === "aggregate" ? "UlwLoop aggregate-goal handoff" : "UlwLoop active-goal handoff", + `Mode: ${mode}`, + `Plan: ${plan.goalsPath}`, + `Ledger: ${plan.ledgerPath}`, + `Goal: ${goal.id} — ${goal.title}`, + "", + ...activeGoalLines(goal), + "", + ...successCriteriaLines(goal.successCriteria), + "", + "Codex goal integration constraints:", + "- Use the create_goal payload exactly as rendered: objective only.", + "- Goals are unlimited. Do not add numeric limits.", + ...modeConstraintLines(mode, isFinal), + finalSection(plan, goal, isFinal, mode === "aggregate"), + ...checkpointLines(plan, mode), + "", + "create_goal payload:", + JSON.stringify(createGoal, null, 2), + ]); +} +function modeConstraintLines(mode, isFinal) { + if (mode === "per_story") { + return [ + "- First call get_goal. If no active goal exists, call create_goal with the payload below.", + "- If a different active Codex goal exists, finish/checkpoint that goal before starting this ulw-loop.", + "- Work only this goal until its completion audit passes.", + ]; + } + return [ + "- Codex goal = the whole omo ulw-loop run; OMO G001/G002/etc. = ledger stories.", + "- First call get_goal. If no active goal exists, call create_goal with the aggregate payload below.", + "- If get_goal reports the same aggregate objective as active, continue this OMO story without creating a new Codex goal.", + "- If a different active or incomplete Codex goal exists, finish/checkpoint that goal before starting this ulw-loop.", + isFinal + ? "- This is the final story; update_goal is allowed only after the mandatory quality gate passes." + : "- This is not the final story: do not call update_goal yet; the aggregate Codex goal must remain active while later OMO stories remain.", + ]; +} +function checkpointLines(plan, mode) { + const failureLine = `- If blocked or failed, checkpoint with --status failed and the failure evidence; rerun complete-goals${sessionOption(plan)} --retry-failed to resume.`; + if (mode === "per_story") + return [failureLine]; + return [ + "- Checkpoint this OMO story with a fresh get_goal snapshot whose objective matches the aggregate payload.", + failureLine, + ]; +} +function activeGoalLines(goal) { + return ["Active goal:", `- id: ${goal.id}`, `- title: ${goal.title}`, `- objective: ${goal.objective}`]; +} +function successCriteriaLines(criteria) { + if (criteria.length === 0) + return ["Success criteria:", "- No success criteria recorded for this goal."]; + return ["Success criteria:", ...criteria.map(formatCriterionLine)]; +} +function formatCriterionLine(criterion) { + const remainingWork = criterion.status === "pending" ? " remaining work:" : ""; + return `-${remainingWork} [${criterion.id}] (${criterion.userModel}) ${criterion.scenario} — expect: ${criterion.expectedEvidence} — status: ${criterion.status}`; +} +function finalSection(plan, goal, isFinal, aggregate) { + if (!isFinal) + return "- This is not the final ulw-loop story; do not run the final ai-slop-cleaner/code-review gate yet."; + const option = sessionOption(plan); + const blockerCommand = `omo ulw-loop record-review-blockers${option} --goal-id ${goal.id} --title "Resolve final code-review blockers" --objective "" --evidence "" --codex-goal-json ""`; + const checkpointCommand = `omo ulw-loop checkpoint${option} --goal-id ${goal.id} --status complete --evidence "" --codex-goal-json "" --quality-gate-json ""`; + return joinLines([ + "Final story — run mandatory quality gate before update_goal:", + "- Run ai-slop-cleaner on changed files even when it is a no-op, rerun verification, then run the code review (spawn_agent(agent_type=\"codex-ultrawork-reviewer\", fork_turns=\"none\", ...); fall back to agent_type=\"worker\" with a scoped reviewer assignment if unavailable).", + "- If the final review is not APPROVE with architect status CLEAR, do not call update_goal. Record blocker work first:", + ` ${blockerCommand}`, + aggregate + ? '- If the final review is clean, call update_goal({status: "complete"}), call get_goal again, then checkpoint the aggregate story:' + : '- If the final review is clean, call update_goal({status: "complete"}), call get_goal again, then checkpoint:', + ` ${checkpointCommand}`, + ]); +} +function sessionOption(plan) { + const prefix = ".omo/ulw-loop/"; + const suffix = "/goals.json"; + if (!plan.goalsPath.startsWith(prefix) || !plan.goalsPath.endsWith(suffix)) + return ""; + const sessionId = plan.goalsPath.slice(prefix.length, -suffix.length); + return sessionId.length === 0 ? "" : ` --session-id ${sessionId}`; +} +function joinLines(lines) { + return lines.join("\n"); +} diff --git a/plugins/omo/components/ulw-loop/dist/codex-goal-snapshot.d.ts b/plugins/omo/components/ulw-loop/dist/codex-goal-snapshot.d.ts new file mode 100644 index 0000000..2ee79e2 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/codex-goal-snapshot.d.ts @@ -0,0 +1,26 @@ +export type CodexGoalSnapshotStatus = "active" | "complete" | "cancelled" | "failed" | "unknown"; +export interface CodexGoalSnapshot { + available: boolean; + objective?: string; + status?: CodexGoalSnapshotStatus; + raw: unknown; +} +export interface CodexGoalReconciliation { + ok: boolean; + snapshot: CodexGoalSnapshot; + warnings: string[]; + errors: string[]; +} +export interface ReconcileCodexGoalOptions { + expectedObjective: string; + acceptedObjectives?: readonly string[]; + allowedStatuses?: readonly CodexGoalSnapshotStatus[]; + requireSnapshot?: boolean; + requireComplete?: boolean; +} +export declare class CodexGoalSnapshotError extends Error { +} +export declare function parseCodexGoalSnapshot(value: unknown): CodexGoalSnapshot; +export declare function readCodexGoalSnapshotInput(raw: string | undefined, cwd?: string): Promise; +export declare function reconcileCodexGoalSnapshot(snapshot: CodexGoalSnapshot | null | undefined, options: ReconcileCodexGoalOptions): CodexGoalReconciliation; +export declare function formatCodexGoalReconciliation(reconciliation: CodexGoalReconciliation): string; diff --git a/plugins/omo/components/ulw-loop/dist/codex-goal-snapshot.js b/plugins/omo/components/ulw-loop/dist/codex-goal-snapshot.js new file mode 100644 index 0000000..a630f22 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/codex-goal-snapshot.js @@ -0,0 +1,97 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +export class CodexGoalSnapshotError extends Error { +} +function safeObject(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; +} +function safeString(value) { + return typeof value === "string" ? value.trim() : ""; +} +function normalizeStatus(value) { + const status = safeString(value).toLowerCase(); + if (status === "complete" || status === "completed" || status === "done") + return "complete"; + if (status === "cancelled" || status === "canceled") + return "cancelled"; + if (status === "failed" || status === "failure") + return "failed"; + if (status === "active" || status === "in_progress" || status === "pending" || status === "running") + return "active"; + return "unknown"; +} +function normalizeObjective(value) { + return value.replace(/\s+/g, " ").trim(); +} +export function parseCodexGoalSnapshot(value) { + const root = safeObject(value); + const goalValue = Object.hasOwn(root, "goal") ? root["goal"] : value; + if (goalValue === null || goalValue === undefined || goalValue === false) { + return { available: false, raw: value }; + } + const goal = safeObject(goalValue); + const objective = safeString(goal["objective"] ?? goal["goal"] ?? goal["description"] ?? root["objective"]); + const status = normalizeStatus(goal["status"] ?? root["status"]); + return { + available: Boolean(objective || status !== "unknown"), + ...(objective ? { objective } : {}), + status, + raw: value, + }; +} +export async function readCodexGoalSnapshotInput(raw, cwd = process.cwd()) { + if (!raw?.trim()) + return null; + const trimmed = raw.trim(); + try { + return parseCodexGoalSnapshot(JSON.parse(trimmed)); + } + catch { + const path = resolve(cwd, trimmed); + if (!existsSync(path)) { + throw new CodexGoalSnapshotError(`Codex goal snapshot is neither valid JSON nor a readable path: ${trimmed}`); + } + try { + return parseCodexGoalSnapshot(JSON.parse(await readFile(path, "utf-8"))); + } + catch (error) { + throw new CodexGoalSnapshotError(`Codex goal snapshot path does not contain valid JSON: ${trimmed}${error instanceof Error ? ` (${error.message})` : ""}`); + } + } +} +export function reconcileCodexGoalSnapshot(snapshot, options) { + const effectiveSnapshot = snapshot ?? { available: false, raw: null }; + const errors = []; + const warnings = []; + if (!effectiveSnapshot.available) { + const message = "Codex goal snapshot is absent or reports no active goal; call get_goal and pass its JSON with --codex-goal-json."; + if (options.requireSnapshot) + errors.push(message); + else + warnings.push(message); + return { ok: errors.length === 0, snapshot: effectiveSnapshot, warnings, errors }; + } + const expected = normalizeObjective(options.expectedObjective); + const accepted = new Set([expected, ...(options.acceptedObjectives ?? []).map((objective) => normalizeObjective(objective))].filter(Boolean)); + const actual = normalizeObjective(effectiveSnapshot.objective ?? ""); + if (!actual) { + errors.push("Codex goal snapshot is missing objective text."); + } + else if (!accepted.has(actual)) { + errors.push(`Codex goal objective mismatch: expected "${expected}", got "${actual}".`); + } + const allowed = options.allowedStatuses ?? (options.requireComplete ? ["complete"] : ["active", "complete"]); + const actualStatus = effectiveSnapshot.status ?? "unknown"; + if (!allowed.includes(actualStatus)) { + errors.push(`Codex goal status mismatch: expected ${allowed.join(" or ")}, got ${actualStatus}.`); + } + if (options.requireComplete && actualStatus !== "complete") { + errors.push('Codex goal is not complete; call update_goal({status: "complete"}) only after the objective is actually complete, then pass the fresh get_goal JSON.'); + } + return { ok: errors.length === 0, snapshot: effectiveSnapshot, warnings, errors }; +} +export function formatCodexGoalReconciliation(reconciliation) { + const parts = [...reconciliation.errors, ...reconciliation.warnings]; + return parts.join(" "); +} diff --git a/plugins/omo/components/ulw-loop/dist/codex-hook.d.ts b/plugins/omo/components/ulw-loop/dist/codex-hook.d.ts new file mode 100644 index 0000000..f2b0333 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/codex-hook.d.ts @@ -0,0 +1,28 @@ +export interface UserPromptSubmitPayload { + readonly cwd: string; + readonly hook_event_name: "UserPromptSubmit"; + readonly model?: string; + readonly permission_mode?: string; + readonly prompt: string; + readonly session_id: string; + readonly transcript_path?: string; + readonly turn_id?: string; +} +export interface PreToolUsePayload { + readonly cwd: string; + readonly hook_event_name: "PreToolUse"; + readonly model: string; + readonly permission_mode: string; + readonly session_id: string; + readonly tool_input: unknown; + readonly tool_name: string; + readonly tool_use_id: string; + readonly transcript_path: string | null; + readonly turn_id: string; +} +export declare function parseUserPromptSubmitPayload(raw: string): UserPromptSubmitPayload | null; +export declare function parsePreToolUsePayload(raw: string): PreToolUsePayload | null; +export declare function applyUserPromptUlwLoopSteering(payload: UserPromptSubmitPayload): Promise; +export declare function applyPreToolUseGoalBudgetGuard(payload: PreToolUsePayload): string; +export declare function runUlwLoopHookCli(stdin: NodeJS.ReadableStream, stdout: NodeJS.WritableStream): Promise; +export declare function runPreToolUseGoalBudgetGuardCli(stdin: NodeJS.ReadableStream, stdout: NodeJS.WritableStream): Promise; diff --git a/plugins/omo/components/ulw-loop/dist/codex-hook.js b/plugins/omo/components/ulw-loop/dist/codex-hook.js new file mode 100644 index 0000000..70d6916 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/codex-hook.js @@ -0,0 +1,145 @@ +import { parseUlwLoopSteeringDirective, steerUlwLoop } from "./steering.js"; +const CREATE_GOAL_TOOL_NAME = "create_goal"; +const CREATE_GOAL_PAYLOAD_WARNING = "Use create_goal with objective only. Omit token_budget so the goal stays unlimited, and put lifecycle status changes on update_goal."; +export function parseUserPromptSubmitPayload(raw) { + if (raw.trim().length === 0) + return null; + try { + const parsed = JSON.parse(raw); + return isUserPromptSubmitPayload(parsed) ? parsed : null; + } + catch (error) { + if (error instanceof SyntaxError) + return null; + return null; + } +} +export function parsePreToolUsePayload(raw) { + if (raw.trim().length === 0) + return null; + try { + const parsed = JSON.parse(raw); + return isPreToolUsePayload(parsed) ? parsed : null; + } + catch (error) { + if (error instanceof SyntaxError) + return null; + return null; + } +} +export async function applyUserPromptUlwLoopSteering(payload) { + try { + if (payload.hook_event_name !== "UserPromptSubmit") + return ""; + const proposal = parseUlwLoopSteeringDirective(payload.prompt); + if (proposal === null) + return ""; + const result = await steerUlwLoop(payload.cwd, proposal, payloadScope(payload)); + if (!result.accepted) + return ""; + return JSON.stringify({ + status: "accepted", + kind: result.audit.kind, + source: result.audit.source, + deduped: result.deduped, + }); + } + catch (error) { + if (error instanceof Error) + return ""; + return ""; + } +} +function payloadScope(payload) { + return { sessionId: payload.session_id }; +} +export function applyPreToolUseGoalBudgetGuard(payload) { + if (payload.hook_event_name !== "PreToolUse") + return ""; + if (payload.tool_name !== CREATE_GOAL_TOOL_NAME) + return ""; + if (!hasInvalidCreateGoalInput(payload.tool_input)) + return ""; + const output = { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: CREATE_GOAL_PAYLOAD_WARNING, + additionalContext: CREATE_GOAL_PAYLOAD_WARNING, + }, + }; + return `${JSON.stringify(output)}\n`; +} +export async function runUlwLoopHookCli(stdin, stdout) { + try { + const payload = parseUserPromptSubmitPayload(await readAll(stdin)); + if (payload === null) + return; + const output = await applyUserPromptUlwLoopSteering(payload); + if (output.length > 0) + stdout.write(output); + } + catch (error) { + if (error instanceof Error) + return; + return; + } +} +export async function runPreToolUseGoalBudgetGuardCli(stdin, stdout) { + try { + const payload = parsePreToolUsePayload(await readAll(stdin)); + if (payload === null) + return; + const output = applyPreToolUseGoalBudgetGuard(payload); + if (output.length > 0) + stdout.write(output); + } + catch (error) { + if (error instanceof Error) + return; + return; + } +} +function isUserPromptSubmitPayload(value) { + if (!isRecord(value)) + return false; + return (value["hook_event_name"] === "UserPromptSubmit" && + typeof value["cwd"] === "string" && + typeof value["prompt"] === "string" && + typeof value["session_id"] === "string" && + ["model", "permission_mode", "transcript_path", "turn_id"].every((key) => optionalString(value[key]))); +} +function isPreToolUsePayload(value) { + if (!isRecord(value)) + return false; + return (value["hook_event_name"] === "PreToolUse" && + typeof value["cwd"] === "string" && + typeof value["model"] === "string" && + typeof value["permission_mode"] === "string" && + typeof value["session_id"] === "string" && + typeof value["tool_name"] === "string" && + typeof value["tool_use_id"] === "string" && + (value["transcript_path"] === null || typeof value["transcript_path"] === "string") && + typeof value["turn_id"] === "string" && + Object.hasOwn(value, "tool_input")); +} +function hasInvalidCreateGoalInput(value) { + return isRecord(value) && Object.keys(value).some((key) => key !== "objective"); +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function optionalString(value) { + return value === undefined || typeof value === "string"; +} +function readAll(stdin) { + return new Promise((resolve, reject) => { + let data = ""; + stdin.setEncoding("utf8"); + stdin.on("data", (chunk) => { + data += chunk instanceof Buffer ? chunk.toString() : String(chunk); + }); + stdin.once("error", reject); + stdin.once("end", () => resolve(data)); + }); +} diff --git a/plugins/omo/components/ulw-loop/dist/command-types.d.ts b/plugins/omo/components/ulw-loop/dist/command-types.d.ts new file mode 100644 index 0000000..5f6970b --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/command-types.d.ts @@ -0,0 +1,34 @@ +import type { UlwLoopCodexGoalMode, UlwLoopStatus } from "./constants.js"; +export interface CreateUlwLoopOptions { + brief: string; + goals?: readonly { + readonly title?: string; + readonly objective: string; + }[]; + codexGoalMode?: UlwLoopCodexGoalMode; + now?: Date; + force?: boolean; +} +export interface StartNextOptions { + now?: Date; + retryFailed?: boolean; +} +export interface CheckpointOptions { + goalId: string; + status: Extract | "blocked"; + evidence?: string; + codexGoal?: unknown; + qualityGate?: unknown; + allowActiveFinalCodexGoal?: boolean; + now?: Date; +} +export interface AddUlwLoopGoalOptions { + title: string; + objective: string; + evidence?: string; + now?: Date; +} +export interface RecordFinalReviewBlockersOptions extends AddUlwLoopGoalOptions { + goalId: string; + codexGoal?: unknown; +} diff --git a/plugins/omo/components/ulw-loop/dist/command-types.js b/plugins/omo/components/ulw-loop/dist/command-types.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/command-types.js @@ -0,0 +1 @@ +export {}; diff --git a/plugins/omo/components/ulw-loop/dist/constants.d.ts b/plugins/omo/components/ulw-loop/dist/constants.d.ts new file mode 100644 index 0000000..99909fd --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/constants.d.ts @@ -0,0 +1,16 @@ +export declare const ULW_LOOP_DIR = ".omo/ulw-loop"; +export declare const ULW_LOOP_BRIEF = "brief.md"; +export declare const ULW_LOOP_GOALS = "goals.json"; +export declare const ULW_LOOP_LEDGER = "ledger.jsonl"; +export type UlwLoopStatus = "pending" | "in_progress" | "complete" | "failed" | "blocked" | "review_blocked" | "needs_user_decision"; +export type UlwLoopCodexGoalMode = "aggregate" | "per_story"; +export type UlwLoopSteeringStatus = "superseded" | "blocked"; +export declare const ULW_LOOP_STEERING_MUTATION_KINDS: readonly ["add_subgoal", "split_subgoal", "reorder_pending", "revise_pending_wording", "revise_criterion", "annotate_ledger", "mark_blocked_superseded"]; +export type UlwLoopSteeringMutationKind = (typeof ULW_LOOP_STEERING_MUTATION_KINDS)[number]; +export type UlwLoopSteeringSource = "user_prompt_submit" | "finding" | "cli"; +export declare const ULW_LOOP_SUCCESS_CRITERION_USER_MODELS: readonly ["happy", "edge", "regression", "adversarial"]; +export type UlwLoopSuccessCriterionUserModel = (typeof ULW_LOOP_SUCCESS_CRITERION_USER_MODELS)[number]; +export declare const ULW_LOOP_CRITERION_STATUSES: readonly ["pending", "pass", "fail", "blocked"]; +export type UlwLoopCriterionStatus = (typeof ULW_LOOP_CRITERION_STATUSES)[number]; +export declare const ULW_LOOP_LEDGER_EVENT_KINDS: readonly ["plan_created", "goal_started", "goal_resumed", "goal_completed", "goal_blocked", "goal_failed", "goal_needs_user_decision", "goal_retried", "aggregate_completed", "aggregate_objective_migrated", "goal_added", "steering_accepted", "steering_rejected", "final_review_failed", "goal_review_blocked", "evidence_captured", "criterion_failed", "criterion_blocked", "criteria_revised"]; +export type UlwLoopLedgerEventKind = (typeof ULW_LOOP_LEDGER_EVENT_KINDS)[number]; diff --git a/plugins/omo/components/ulw-loop/dist/constants.js b/plugins/omo/components/ulw-loop/dist/constants.js new file mode 100644 index 0000000..022e31c --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/constants.js @@ -0,0 +1,41 @@ +export const ULW_LOOP_DIR = ".omo/ulw-loop"; +export const ULW_LOOP_BRIEF = "brief.md"; +export const ULW_LOOP_GOALS = "goals.json"; +export const ULW_LOOP_LEDGER = "ledger.jsonl"; +export const ULW_LOOP_STEERING_MUTATION_KINDS = [ + "add_subgoal", + "split_subgoal", + "reorder_pending", + "revise_pending_wording", + "revise_criterion", + "annotate_ledger", + "mark_blocked_superseded", +]; +export const ULW_LOOP_SUCCESS_CRITERION_USER_MODELS = [ + "happy", + "edge", + "regression", + "adversarial", +]; +export const ULW_LOOP_CRITERION_STATUSES = ["pending", "pass", "fail", "blocked"]; +export const ULW_LOOP_LEDGER_EVENT_KINDS = [ + "plan_created", + "goal_started", + "goal_resumed", + "goal_completed", + "goal_blocked", + "goal_failed", + "goal_needs_user_decision", + "goal_retried", + "aggregate_completed", + "aggregate_objective_migrated", + "goal_added", + "steering_accepted", + "steering_rejected", + "final_review_failed", + "goal_review_blocked", + "evidence_captured", + "criterion_failed", + "criterion_blocked", + "criteria_revised", +]; diff --git a/plugins/omo/components/ulw-loop/dist/domain-types.d.ts b/plugins/omo/components/ulw-loop/dist/domain-types.d.ts new file mode 100644 index 0000000..bfa805d --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/domain-types.d.ts @@ -0,0 +1,95 @@ +import type { UlwLoopCodexGoalMode, UlwLoopCriterionStatus, UlwLoopLedgerEventKind, UlwLoopStatus, UlwLoopSteeringMutationKind, UlwLoopSteeringStatus, UlwLoopSuccessCriterionUserModel } from "./constants.js"; +import type { UlwLoopSteeringAudit } from "./steering-types.js"; +export interface UlwLoopSuccessCriterion { + readonly id: string; + readonly scenario: string; + readonly userModel: UlwLoopSuccessCriterionUserModel; + readonly expectedEvidence: string; + capturedEvidence: string | null; + status: UlwLoopCriterionStatus; + capturedAt?: string; + notes?: string; +} +export interface UlwLoopItem { + id: string; + title: string; + objective: string; + status: UlwLoopStatus; + successCriteria: UlwLoopSuccessCriterion[]; + attempt: number; + createdAt: string; + updatedAt: string; + startedAt?: string; + completedAt?: string; + failedAt?: string; + reviewBlockedAt?: string; + evidence?: string; + failureReason?: string; + steeringStatus?: UlwLoopSteeringStatus; + supersededBy?: string[]; + supersedes?: string[]; + blockedReason?: string; + blockerSignature?: string; + blockerOccurrenceCount?: number; + requiredExternalDecision?: string; + nonRetriable?: boolean; + steeringEvidence?: string; + steeringRationale?: string; +} +export interface UlwLoopAggregateCompletion { + status: "complete"; + completedAt: string; + evidence: string; + codexGoal?: unknown; +} +export interface UlwLoopPlan { + version: 1; + createdAt: string; + updatedAt: string; + briefPath: string; + goalsPath: string; + ledgerPath: string; + codexGoalMode?: UlwLoopCodexGoalMode; + codexObjective?: string; + codexObjectiveAliases?: string[]; + aggregateCompletion?: UlwLoopAggregateCompletion; + activeGoalId?: string; + goals: UlwLoopItem[]; +} +export interface UlwLoopQualityGate { + aiSlopCleaner: { + status: "passed"; + evidence: string; + }; + verification: { + status: "passed"; + commands: string[]; + evidence: string; + }; + codeReview: { + recommendation: "APPROVE"; + architectStatus: "CLEAR"; + evidence: string; + }; +} +export interface UlwLoopLedgerEntry { + at: string; + kind: UlwLoopLedgerEventKind; + goalId?: string; + criterionId?: string; + status?: UlwLoopStatus; + criterionStatus?: UlwLoopCriterionStatus; + message?: string; + codexGoal?: unknown; + evidence?: string; + capturedEvidence?: string; + qualityGate?: UlwLoopQualityGate; + steering?: UlwLoopSteeringAudit; + before?: unknown; + after?: unknown; + mutationKind?: UlwLoopSteeringMutationKind; + idempotencyKey?: string; + blockerSignature?: string; + blockerOccurrenceCount?: number; + requiredExternalDecision?: string; +} diff --git a/plugins/omo/components/ulw-loop/dist/domain-types.js b/plugins/omo/components/ulw-loop/dist/domain-types.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/domain-types.js @@ -0,0 +1 @@ +export {}; diff --git a/plugins/omo/components/ulw-loop/dist/evidence.d.ts b/plugins/omo/components/ulw-loop/dist/evidence.d.ts new file mode 100644 index 0000000..5d25273 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/evidence.d.ts @@ -0,0 +1,31 @@ +import type { UlwLoopScope } from "./paths.js"; +import type { UlwLoopItem, UlwLoopLedgerEntry, UlwLoopPlan, UlwLoopSuccessCriterion } from "./types.js"; +type EvidenceStatus = "pass" | "fail" | "blocked"; +type RecordEvidenceArgs = { + readonly goalId: string; + readonly criterionId: string; + readonly status: EvidenceStatus; + readonly evidence: string; + readonly notes?: string; +}; +export declare function recordEvidence(repoRoot: string, args: RecordEvidenceArgs, scope?: UlwLoopScope): Promise<{ + plan: UlwLoopPlan; + goal: UlwLoopItem; + criterion: UlwLoopSuccessCriterion; + ledgerEntry: UlwLoopLedgerEntry; +}>; +export declare function markCriteriaPendingResetForGoal(repoRoot: string, goalId: string, scope?: UlwLoopScope): Promise<{ + plan: UlwLoopPlan; + resetCount: number; +}>; +export declare function criteriaSummary(plan: UlwLoopPlan): { + totalCriteria: number; + passCount: number; + pendingCount: number; + failCount: number; + blockedCount: number; + goalsWithUnresolvedCriteria: string[]; +}; +export declare function unresolvedCriteriaOf(goal: UlwLoopItem): UlwLoopSuccessCriterion[]; +export declare function requireAllCriteriaPass(goal: UlwLoopItem): void; +export {}; diff --git a/plugins/omo/components/ulw-loop/dist/evidence.js b/plugins/omo/components/ulw-loop/dist/evidence.js new file mode 100644 index 0000000..6cd75be --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/evidence.js @@ -0,0 +1,119 @@ +// biome-ignore-all format: keep this module under the mandated pure LOC budget. +import { hasAllCriteriaPass } from "./goal-status.js"; +import { appendLedger, readUlwLoopPlan, withUlwLoopMutationLock, writePlan } from "./plan-io.js"; +import { iso, UlwLoopError } from "./types.js"; +function ulwLoopFail(message, code, details) { throw new UlwLoopError(message, code, { details }); } +function ledgerKind(status) { + switch (status) { + case "pass": + return "evidence_captured"; + case "fail": + return "criterion_failed"; + case "blocked": + return "criterion_blocked"; + default: + return ulwLoopFail("Invalid criterion status.", "ULW_LOOP_CRITERION_STATUS_INVALID", { status }); + } +} +function findGoal(plan, goalId) { + const goal = plan.goals.find((candidate) => candidate.id === goalId); + return goal ?? ulwLoopFail(`UlwLoop goal not found: ${goalId}.`, "ULW_LOOP_GOAL_NOT_FOUND", { goalId }); +} +function findCriterion(goal, criterionId) { + const criterion = goal.successCriteria.find((candidate) => candidate.id === criterionId); + return criterion ?? ulwLoopFail(`Success criterion not found: ${criterionId}.`, "ULW_LOOP_CRITERION_NOT_FOUND", { goalId: goal.id, criterionId }); +} +function nonEmptyEvidence(evidence) { const trimmed = evidence.trim(); return trimmed || ulwLoopFail("Evidence must be a non-empty string.", "ULW_LOOP_EVIDENCE_REQUIRED", {}); } +export async function recordEvidence(repoRoot, args, scope) { + return withUlwLoopMutationLock(repoRoot, scope, async () => { + const plan = await readUlwLoopPlan(repoRoot, scope); + const goal = findGoal(plan, args.goalId); + const criterion = findCriterion(goal, args.criterionId); + const evidence = nonEmptyEvidence(args.evidence); + const kind = ledgerKind(args.status); + const prevStatus = criterion.status; + const capturedAt = iso(); + criterion.status = args.status; + criterion.capturedEvidence = evidence; + criterion.capturedAt = capturedAt; + if (args.notes !== undefined) + criterion.notes = args.notes; + goal.updatedAt = capturedAt; + plan.updatedAt = capturedAt; + await writePlan(repoRoot, plan, scope); + const ledgerEntry = { + at: capturedAt, + kind, + goalId: goal.id, + criterionId: criterion.id, + criterionStatus: args.status, + evidence, + capturedEvidence: evidence, + before: { status: prevStatus }, + after: { goalId: goal.id, criterionId: criterion.id, status: args.status, evidence, capturedAt, prevStatus }, + }; + await appendLedger(repoRoot, ledgerEntry, scope); + return { plan, goal, criterion, ledgerEntry }; + }); +} +export async function markCriteriaPendingResetForGoal(repoRoot, goalId, scope) { + return withUlwLoopMutationLock(repoRoot, scope, async () => { + const plan = await readUlwLoopPlan(repoRoot, scope); + const goal = findGoal(plan, goalId); + const now = iso(); + const before = goal.successCriteria.map((criterion) => ({ id: criterion.id, status: criterion.status, capturedEvidence: criterion.capturedEvidence, capturedAt: criterion.capturedAt ?? null })); + for (const criterion of goal.successCriteria) { + criterion.status = "pending"; + criterion.capturedEvidence = null; + delete criterion.capturedAt; + delete criterion.notes; + } + goal.updatedAt = now; + plan.updatedAt = now; + await writePlan(repoRoot, plan, scope); + await appendLedger(repoRoot, { at: now, kind: "criteria_revised", goalId, message: `Reset ${goal.successCriteria.length} criteria to pending.`, before, after: { resetCount: goal.successCriteria.length } }, scope); + return { plan, resetCount: goal.successCriteria.length }; + }); +} +export function criteriaSummary(plan) { + let totalCriteria = 0; + let passCount = 0; + let pendingCount = 0; + let failCount = 0; + let blockedCount = 0; + const goalsWithUnresolvedCriteria = []; + for (const goal of plan.goals) { + let unresolved = false; + for (const criterion of goal.successCriteria) { + totalCriteria += 1; + if (criterion.status !== "pass") + unresolved = true; + switch (criterion.status) { + case "pass": + passCount += 1; + break; + case "pending": + pendingCount += 1; + break; + case "fail": + failCount += 1; + break; + case "blocked": + blockedCount += 1; + break; + default: ulwLoopFail("Invalid criterion status.", "ULW_LOOP_CRITERION_STATUS_INVALID", { status: criterion.status }); + } + } + if (unresolved) + goalsWithUnresolvedCriteria.push(goal.id); + } + return { totalCriteria, passCount, pendingCount, failCount, blockedCount, goalsWithUnresolvedCriteria }; +} +export function unresolvedCriteriaOf(goal) { return goal.successCriteria.filter((criterion) => criterion.status !== "pass"); } +export function requireAllCriteriaPass(goal) { + if (hasAllCriteriaPass(goal)) + return; + throw new UlwLoopError(`Goal ${goal.id} has unresolved success criteria.`, "ulw_loop_criteria_not_all_pass", { + details: { goalId: goal.id, unresolved: unresolvedCriteriaOf(goal).map((criterion) => ({ id: criterion.id, status: criterion.status })) }, + }); +} diff --git a/plugins/omo/components/ulw-loop/dist/goal-status.d.ts b/plugins/omo/components/ulw-loop/dist/goal-status.d.ts new file mode 100644 index 0000000..3f18043 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/goal-status.d.ts @@ -0,0 +1,12 @@ +import { type UlwLoopScope } from "./paths.js"; +import type { UlwLoopCodexGoalMode, UlwLoopItem, UlwLoopPlan, UlwLoopSuccessCriterion } from "./types.js"; +export declare const ULW_LOOP_AGGREGATE_CODEX_OBJECTIVE: string; +export declare function aggregateCodexObjectiveForScope(scope?: UlwLoopScope): string; +export declare function codexGoalMode(plan: UlwLoopPlan): UlwLoopCodexGoalMode; +export declare function isUlwLoopDone(plan: UlwLoopPlan): boolean; +export declare function isFinalRunCompletionCandidate(plan: UlwLoopPlan, goal: UlwLoopItem): boolean; +export declare function aggregateCodexObjective(plan: UlwLoopPlan): string; +export declare function expectedCodexObjective(plan: UlwLoopPlan, goal: UlwLoopItem): string; +export declare function compatibleCodexObjectives(plan: UlwLoopPlan): readonly string[]; +export declare function hasAllCriteriaPass(goal: UlwLoopItem): boolean; +export declare function firstUnresolvedCriterion(goal: UlwLoopItem): UlwLoopSuccessCriterion | undefined; diff --git a/plugins/omo/components/ulw-loop/dist/goal-status.js b/plugins/omo/components/ulw-loop/dist/goal-status.js new file mode 100644 index 0000000..e8b5708 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/goal-status.js @@ -0,0 +1,69 @@ +import { ulwLoopGoalsRelativePath, ulwLoopLedgerRelativePath } from "./paths.js"; +export const ULW_LOOP_AGGREGATE_CODEX_OBJECTIVE = aggregateCodexObjectiveForScope(); +export function aggregateCodexObjectiveForScope(scope) { + return `Complete the durable ulw-loop plan in ${ulwLoopGoalsRelativePath(scope)}, including later accepted/appended stories, under the original brief constraints; use ${ulwLoopLedgerRelativePath(scope)} as the audit trail.`; +} +export function codexGoalMode(plan) { + return plan.codexGoalMode ?? "per_story"; +} +function isResolvedStatus(status) { + return status === "complete"; +} +function isSupersededResolved(goal, plan) { + if (goal.steeringStatus !== "superseded") + return false; + const replacements = goal.supersededBy ?? []; + if (replacements.length === 0) + return false; + return replacements.every((id) => { + const replacement = plan.goals.find((candidate) => candidate.id === id); + return replacement !== undefined && isResolvedStatus(replacement.status); + }); +} +function isCompletionBlocking(goal, plan) { + if (goal.steeringStatus === "superseded") + return !isSupersededResolved(goal, plan); + if (goal.steeringStatus === "blocked") + return true; + return !isResolvedStatus(goal.status); +} +function isCompletionBlockingForFinalCandidate(candidate, finalCandidate, plan) { + if (candidate.id === finalCandidate.id) + return false; + if (candidate.steeringStatus === "superseded") { + const replacements = candidate.supersededBy ?? []; + if (replacements.length === 0) + return true; + return !replacements.every((id) => { + if (id === finalCandidate.id) + return true; + const replacement = plan.goals.find((goal) => goal.id === id); + return replacement !== undefined && isResolvedStatus(replacement.status); + }); + } + return isCompletionBlocking(candidate, plan); +} +export function isUlwLoopDone(plan) { + if (plan.aggregateCompletion?.status === "complete") + return true; + return plan.goals.every((goal) => !isCompletionBlocking(goal, plan)); +} +export function isFinalRunCompletionCandidate(plan, goal) { + return (isCompletionBlocking(goal, plan) && + plan.goals.every((candidate) => !isCompletionBlockingForFinalCandidate(candidate, goal, plan))); +} +export function aggregateCodexObjective(plan) { + return plan.codexObjective ?? ULW_LOOP_AGGREGATE_CODEX_OBJECTIVE; +} +export function expectedCodexObjective(plan, goal) { + return codexGoalMode(plan) === "aggregate" ? aggregateCodexObjective(plan) : goal.objective; +} +export function compatibleCodexObjectives(plan) { + return [aggregateCodexObjective(plan), ...(plan.codexObjectiveAliases ?? [])]; +} +export function hasAllCriteriaPass(goal) { + return goal.successCriteria.length > 0 && goal.successCriteria.every((criterion) => criterion.status === "pass"); +} +export function firstUnresolvedCriterion(goal) { + return goal.successCriteria.find((criterion) => criterion.status !== "pass"); +} diff --git a/plugins/omo/components/ulw-loop/dist/paths.d.ts b/plugins/omo/components/ulw-loop/dist/paths.d.ts new file mode 100644 index 0000000..7c28841 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/paths.d.ts @@ -0,0 +1,16 @@ +export interface UlwLoopScope { + readonly sessionId?: string | null; +} +type EnvMap = Readonly>; +export declare function normalizeUlwLoopSessionId(sessionId: string | null | undefined): string | null; +export declare function resolveUlwLoopSessionIdFromEnv(env?: EnvMap): string | null; +export declare function ulwLoopRelativeDir(scope?: UlwLoopScope): string; +export declare function ulwLoopDir(repoRoot: string, scope?: UlwLoopScope): string; +export declare function ulwLoopBriefRelativePath(scope?: UlwLoopScope): string; +export declare function ulwLoopGoalsRelativePath(scope?: UlwLoopScope): string; +export declare function ulwLoopLedgerRelativePath(scope?: UlwLoopScope): string; +export declare function ulwLoopBriefPath(repoRoot: string, scope?: UlwLoopScope): string; +export declare function ulwLoopGoalsPath(repoRoot: string, scope?: UlwLoopScope): string; +export declare function ulwLoopLedgerPath(repoRoot: string, scope?: UlwLoopScope): string; +export declare function repoRelative(absolutePath: string, repoRoot: string): string; +export {}; diff --git a/plugins/omo/components/ulw-loop/dist/paths.js b/plugins/omo/components/ulw-loop/dist/paths.js new file mode 100644 index 0000000..6ad99ac --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/paths.js @@ -0,0 +1,59 @@ +import { join } from "node:path"; +import { ULW_LOOP_BRIEF, ULW_LOOP_DIR, ULW_LOOP_GOALS, ULW_LOOP_LEDGER } from "./types.js"; +const SESSION_ENV_KEYS = ["OMO_ULW_LOOP_SESSION_ID", "CODEX_SESSION_ID", "CODEX_THREAD_ID"]; +export function normalizeUlwLoopSessionId(sessionId) { + const trimmed = sessionId?.trim(); + if (!trimmed) + return null; + const pathSegments = trimmed + .split(/[\\/]+/) + .filter((segment) => segment.length > 0 && segment !== "." && segment !== ".."); + const candidate = (pathSegments.length > 0 ? pathSegments.join("-") : trimmed) + .replace(/[^A-Za-z0-9._-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^\.+/, "") + .replace(/^[.-]+|[.-]+$/g, ""); + return candidate.length > 0 ? candidate : null; +} +export function resolveUlwLoopSessionIdFromEnv(env = process.env) { + for (const key of SESSION_ENV_KEYS) { + const normalized = normalizeUlwLoopSessionId(env[key]); + if (normalized !== null) + return normalized; + } + return null; +} +export function ulwLoopRelativeDir(scope) { + const sessionId = normalizeUlwLoopSessionId(scope?.sessionId); + return sessionId === null ? ULW_LOOP_DIR : `${ULW_LOOP_DIR}/${sessionId}`; +} +export function ulwLoopDir(repoRoot, scope) { + return join(repoRoot, ulwLoopRelativeDir(scope)); +} +export function ulwLoopBriefRelativePath(scope) { + return `${ulwLoopRelativeDir(scope)}/${ULW_LOOP_BRIEF}`; +} +export function ulwLoopGoalsRelativePath(scope) { + return `${ulwLoopRelativeDir(scope)}/${ULW_LOOP_GOALS}`; +} +export function ulwLoopLedgerRelativePath(scope) { + return `${ulwLoopRelativeDir(scope)}/${ULW_LOOP_LEDGER}`; +} +export function ulwLoopBriefPath(repoRoot, scope) { + return join(ulwLoopDir(repoRoot, scope), ULW_LOOP_BRIEF); +} +export function ulwLoopGoalsPath(repoRoot, scope) { + return join(ulwLoopDir(repoRoot, scope), ULW_LOOP_GOALS); +} +export function ulwLoopLedgerPath(repoRoot, scope) { + return join(ulwLoopDir(repoRoot, scope), ULW_LOOP_LEDGER); +} +export function repoRelative(absolutePath, repoRoot) { + const slashPrefix = `${repoRoot}/`; + const backslashPrefix = `${repoRoot}\\`; + if (absolutePath.startsWith(slashPrefix)) + return absolutePath.slice(slashPrefix.length).split("\\").join("/"); + if (absolutePath.startsWith(backslashPrefix)) + return absolutePath.slice(backslashPrefix.length).split("\\").join("/"); + return absolutePath.split("\\").join("/"); +} diff --git a/plugins/omo/components/ulw-loop/dist/plan-crud.d.ts b/plugins/omo/components/ulw-loop/dist/plan-crud.d.ts new file mode 100644 index 0000000..b89e031 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/plan-crud.d.ts @@ -0,0 +1,48 @@ +import { type UlwLoopScope } from "./paths.js"; +import type { UlwLoopCodexGoalMode, UlwLoopItem, UlwLoopPlan, UlwLoopSuccessCriterion } from "./types.js"; +export type UlwLoopPlanSummary = { + readonly total: number; + readonly pending: number; + readonly in_progress: number; + readonly complete: number; + readonly failed: number; + readonly blocked: number; + readonly review_blocked: number; + readonly needs_user_decision: number; + readonly superseded: number; + readonly criteria: { + readonly total: number; + readonly pass: number; + readonly pending: number; + readonly fail: number; + readonly blocked: number; + }; +}; +export declare function seedDefaultSuccessCriteria(goalIndex: number, objective: string): UlwLoopSuccessCriterion[]; +export declare function deriveGoalCandidates(brief: string): Array<{ + title: string; + objective: string; +}>; +export declare function createUlwLoopPlan(repoRoot: string, args: { + brief: string; + codexGoalMode?: UlwLoopCodexGoalMode; + force?: boolean; +}, scope?: UlwLoopScope): Promise; +export declare function addUlwLoopGoal(repoRoot: string, args: { + title: string; + objective: string; +}, scope?: UlwLoopScope): Promise<{ + plan: UlwLoopPlan; + goal: UlwLoopItem; +}>; +export declare function startNextUlwLoop(repoRoot: string, args?: { + retryFailed?: boolean; +}, scope?: UlwLoopScope): Promise<{ + plan: UlwLoopPlan; + goal: UlwLoopItem; + resumed: boolean; +} | { + done: true; + plan: UlwLoopPlan; +}>; +export declare function summarizeUlwLoopPlan(plan: UlwLoopPlan): UlwLoopPlanSummary; diff --git a/plugins/omo/components/ulw-loop/dist/plan-crud.js b/plugins/omo/components/ulw-loop/dist/plan-crud.js new file mode 100644 index 0000000..73d6a9c --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/plan-crud.js @@ -0,0 +1,119 @@ +// biome-ignore-all format: keep this port under the mandated pure LOC budget. +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { aggregateCodexObjectiveForScope, isUlwLoopDone } from "./goal-status.js"; +import { ulwLoopBriefPath, ulwLoopBriefRelativePath, ulwLoopDir, ulwLoopGoalsPath, ulwLoopGoalsRelativePath, ulwLoopLedgerPath, ulwLoopLedgerRelativePath } from "./paths.js"; +import { appendLedger, readUlwLoopPlan, withUlwLoopMutationLock, writePlan } from "./plan-io.js"; +import { iso, UlwLoopError } from "./types.js"; +function cleanLine(line) { return line.replace(/^\s*(?:[-*+]\s+|\d+[.)]\s+)/, "").trim(); } +function normalizeObjective(value) { return value.replace(/\s+/g, " ").trim(); } +function titleFromObjective(objective, fallback) { const firstLine = objective.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? fallback; return firstLine.length > 72 ? `${firstLine.slice(0, 69).trimEnd()}...` : firstLine; } +function normalizeGoalId(title, index) { const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 36).replace(/-+$/g, ""); return `G${String(index + 1).padStart(3, "0")}${slug ? `-${slug}` : ""}`; } +function assertNonEmpty(value, label) { const trimmed = value?.trim(); if (!trimmed) + throw new UlwLoopError(`Missing ${label}.`, "ULW_LOOP_ARGUMENT_MISSING"); return trimmed; } +function truncateObjective(objective) { return objective.length > 80 ? `${objective.slice(0, 77).trimEnd()}...` : objective; } +export function seedDefaultSuccessCriteria(goalIndex, objective) { + const subject = truncateObjective(normalizeObjective(objective) || `Goal ${goalIndex + 1}`); + const rows = [ + ["C001", "happy", `happy path for: ${subject}`, `Replace via revise_criterion with observable happy-path proof for goal ${goalIndex + 1}.`], + ["C002", "edge", "edge case (boundary/empty/malformed)", `Replace via revise_criterion with boundary or malformed-input proof for: ${subject}.`], + ["C003", "regression", "regression: adjacent surface still works", `Replace via revise_criterion with regression proof for neighboring behavior after: ${subject}.`], + ]; + return rows.map(([id, userModel, scenario, expectedEvidence]) => ({ id, scenario, userModel, expectedEvidence, capturedEvidence: null, status: "pending" })); +} +export function deriveGoalCandidates(brief) { + const bulletGoals = brief.split(/\r?\n/).map((line) => ({ original: line, cleaned: normalizeObjective(cleanLine(line)) })).filter(({ cleaned }) => cleaned.length > 0 && cleaned.length <= 1200).filter(({ original, cleaned }, index, all) => /^\s*(?:[-*+]\s+|\d+[.)]\s+)/.test(original) && all.findIndex((candidate) => candidate.cleaned === cleaned) === index).map(({ cleaned }) => cleaned); + const paragraphs = brief.split(/\n\s*\n/).map(normalizeObjective).filter((paragraph) => paragraph.length > 0 && !paragraph.startsWith("#")); + const selected = (bulletGoals.length > 0 ? bulletGoals : paragraphs).length > 0 ? (bulletGoals.length > 0 ? bulletGoals : paragraphs) : ["Complete the requested project objective."]; + return selected.map((objective, index) => ({ title: titleFromObjective(objective, `Goal ${index + 1}`), objective })); +} +function makeGoal(title, objective, index, now) { + const cleanTitle = assertNonEmpty(title, "title"); + const cleanObjective = assertNonEmpty(objective, "objective"); + return { id: normalizeGoalId(cleanTitle, index), title: cleanTitle, objective: cleanObjective, status: "pending", successCriteria: seedDefaultSuccessCriteria(index, cleanObjective), attempt: 0, createdAt: now, updatedAt: now }; +} +function appendGoalToPlan(plan, title, objective, now) { + const goal = makeGoal(title, objective, plan.goals.length, now); + plan.goals.push(goal); + plan.updatedAt = now; + return goal; +} +function isScheduleEligible(goal) { return goal.steeringStatus !== "superseded" && goal.steeringStatus !== "blocked"; } +function clearGoalBlockerFields(goal) { + for (const key of ["blockedReason", "blockerSignature", "blockerOccurrenceCount", "requiredExternalDecision", "nonRetriable", "failedAt", "failureReason"]) + delete goal[key]; +} +export async function createUlwLoopPlan(repoRoot, args, scope) { + return withUlwLoopMutationLock(repoRoot, scope, async () => { + if (!args.force && existsSync(ulwLoopGoalsPath(repoRoot, scope))) { + const existing = await readUlwLoopPlan(repoRoot, scope); + if (isUlwLoopDone(existing)) + throw completedPlanExistsError(scope); + throw new UlwLoopError(`Refusing to overwrite existing ${ulwLoopGoalsRelativePath(scope)}; pass --force to recreate it.`, "ULW_LOOP_PLAN_EXISTS"); + } + const now = iso(); + const goals = deriveGoalCandidates(args.brief).map((goal, index) => makeGoal(goal.title, goal.objective, index, now)); + const plan = { version: 1, createdAt: now, updatedAt: now, briefPath: ulwLoopBriefRelativePath(scope), goalsPath: ulwLoopGoalsRelativePath(scope), ledgerPath: ulwLoopLedgerRelativePath(scope), codexGoalMode: args.codexGoalMode ?? "aggregate", goals }; + if (plan.codexGoalMode === "aggregate") + plan.codexObjective = aggregateCodexObjectiveForScope(scope); + await mkdir(ulwLoopDir(repoRoot, scope), { recursive: true }); + await writeFile(ulwLoopBriefPath(repoRoot, scope), args.brief.endsWith("\n") ? args.brief : `${args.brief}\n`, "utf8"); + await writePlan(repoRoot, plan, scope); + await writeFile(ulwLoopLedgerPath(repoRoot, scope), "", "utf8"); + await appendLedger(repoRoot, { at: now, kind: "plan_created", message: `${goals.length} goal(s) created` }, scope); + return plan; + }); +} +function completedPlanExistsError(scope) { + return new UlwLoopError([ + `Existing ulw-loop aggregate is already complete at ${ulwLoopGoalsRelativePath(scope)}.`, + "Start a new run with `omo ulw-loop create-goals --session-id ...` to isolate fresh state.", + "Use --force only when you intentionally want to overwrite the completed evidence.", + ].join(" "), "ULW_LOOP_PLAN_EXISTS_COMPLETE"); +} +export async function addUlwLoopGoal(repoRoot, args, scope) { + return withUlwLoopMutationLock(repoRoot, scope, async () => { + const plan = await readUlwLoopPlan(repoRoot, scope); + const now = iso(); + const goal = appendGoalToPlan(plan, args.title, args.objective, now); + await writePlan(repoRoot, plan, scope); + await appendLedger(repoRoot, { at: now, kind: "goal_added", goalId: goal.id, status: goal.status, message: goal.title }, scope); + return { plan, goal }; + }); +} +export async function startNextUlwLoop(repoRoot, args = {}, scope) { + return withUlwLoopMutationLock(repoRoot, scope, async () => { + const plan = await readUlwLoopPlan(repoRoot, scope); + const now = iso(); + if (plan.aggregateCompletion?.status === "complete") + return { done: true, plan }; + const existing = plan.goals.find((goal) => goal.status === "in_progress" && isScheduleEligible(goal)); + if (existing) { + await appendLedger(repoRoot, { at: now, kind: "goal_resumed", goalId: existing.id, status: existing.status, message: "Resuming active ulw-loop" }, scope); + return { plan, goal: existing, resumed: true }; + } + let next = plan.goals.find((goal) => goal.status === "pending" && isScheduleEligible(goal)); + if (!next && args.retryFailed) { + next = plan.goals.find((goal) => goal.status === "failed" && !goal.nonRetriable && isScheduleEligible(goal)); + if (next) + await appendLedger(repoRoot, { at: now, kind: "goal_retried", goalId: next.id, status: "pending", ...(next.failureReason ? { message: next.failureReason } : {}) }, scope); + } + if (!next) + return { done: true, plan }; + next.status = "in_progress"; + next.attempt += 1; + next.startedAt = now; + clearGoalBlockerFields(next); + next.updatedAt = now; + plan.activeGoalId = next.id; + plan.updatedAt = now; + await writePlan(repoRoot, plan, scope); + await appendLedger(repoRoot, { at: now, kind: "goal_started", goalId: next.id, status: next.status, message: `Attempt ${next.attempt}` }, scope); + return { plan, goal: next, resumed: false }; + }); +} +export function summarizeUlwLoopPlan(plan) { + const countStatus = (status) => plan.goals.filter((goal) => goal.status === status).length; + const countCriteria = (status) => plan.goals.reduce((sum, goal) => sum + goal.successCriteria.filter((criterion) => criterion.status === status).length, 0); + return { total: plan.goals.length, pending: countStatus("pending"), in_progress: countStatus("in_progress"), complete: countStatus("complete"), failed: countStatus("failed"), blocked: countStatus("blocked"), review_blocked: countStatus("review_blocked"), needs_user_decision: countStatus("needs_user_decision"), superseded: plan.goals.filter((goal) => goal.steeringStatus === "superseded").length, criteria: { total: plan.goals.reduce((sum, goal) => sum + goal.successCriteria.length, 0), pass: countCriteria("pass"), pending: countCriteria("pending"), fail: countCriteria("fail"), blocked: countCriteria("blocked") } }; +} diff --git a/plugins/omo/components/ulw-loop/dist/plan-io.d.ts b/plugins/omo/components/ulw-loop/dist/plan-io.d.ts new file mode 100644 index 0000000..070fa6a --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/plan-io.d.ts @@ -0,0 +1,8 @@ +import { type UlwLoopScope } from "./paths.js"; +import type { UlwLoopLedgerEntry, UlwLoopPlan } from "./types.js"; +export declare function withUlwLoopMutationLock(repoRoot: string, fn: () => Promise): Promise; +export declare function withUlwLoopMutationLock(repoRoot: string, scope: UlwLoopScope | undefined, fn: () => Promise): Promise; +export declare function readUlwLoopPlan(repoRoot: string, scope?: UlwLoopScope): Promise; +export declare function writePlan(repoRoot: string, plan: UlwLoopPlan, scope?: UlwLoopScope): Promise; +export declare function appendLedger(repoRoot: string, entry: UlwLoopLedgerEntry, scope?: UlwLoopScope): Promise; +export declare function readSteeringLedgerEntries(repoRoot: string, scope?: UlwLoopScope): Promise; diff --git a/plugins/omo/components/ulw-loop/dist/plan-io.js b/plugins/omo/components/ulw-loop/dist/plan-io.js new file mode 100644 index 0000000..eed44f7 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/plan-io.js @@ -0,0 +1,89 @@ +import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { aggregateCodexObjectiveForScope } from "./goal-status.js"; +import { repoRelative, ulwLoopDir, ulwLoopGoalsPath, ulwLoopLedgerPath, ulwLoopRelativeDir, } from "./paths.js"; +import { iso, ULW_LOOP_DIR, ULW_LOOP_GOALS, ULW_LOOP_LEDGER, UlwLoopError } from "./types.js"; +const LEGACY_OBJECTIVE_PREFIX = `Complete all ulw-loop stories in ${ULW_LOOP_DIR}/${ULW_LOOP_GOALS}: `; +const LEGACY_OBJECTIVE = `Complete all ulw-loop stories listed in ${ULW_LOOP_DIR}/${ULW_LOOP_GOALS}. Use ${ULW_LOOP_DIR}/${ULW_LOOP_LEDGER} as the durable audit trail.`; +const locks = new Map(); +function hasCode(error, code) { + return error instanceof Error && "code" in error && error.code === code; +} +function isLegacyEnumeratedAggregateObjective(objective) { + return objective === LEGACY_OBJECTIVE || Boolean(objective?.startsWith(LEGACY_OBJECTIVE_PREFIX)); +} +function isSteeringKind(value) { + return value === "steering_accepted" || value === "steering_rejected" || value === "criteria_revised"; +} +export async function withUlwLoopMutationLock(repoRoot, scopeOrFn, maybeFn) { + const scope = typeof scopeOrFn === "function" ? undefined : scopeOrFn; + const fn = typeof scopeOrFn === "function" ? scopeOrFn : maybeFn; + if (fn === undefined) + throw new UlwLoopError("Missing ulw-loop mutation body.", "ULW_LOOP_LOCK_BODY_MISSING"); + const lockKey = `${repoRoot}\0${ulwLoopRelativeDir(scope)}`; + const prior = locks.get(lockKey) ?? Promise.resolve(); + const run = prior.then(fn, fn); + locks.set(lockKey, run.catch(() => undefined)); + return run; +} +export async function readUlwLoopPlan(repoRoot, scope) { + const path = ulwLoopGoalsPath(repoRoot, scope); + let raw; + try { + raw = await readFile(path, "utf8"); + } + catch (error) { + if (!hasCode(error, "ENOENT")) + throw error; + throw new UlwLoopError(`No ulw-loop plan found at ${repoRelative(path, repoRoot)}. Run \`omo ulw-loop create-goals ...\` first.`, "ULW_LOOP_PLAN_MISSING", { cause: error }); + } + const parsed = JSON.parse(raw); + if (parsed.version !== 1 || !Array.isArray(parsed.goals)) { + throw new UlwLoopError(`Invalid ulw-loop plan at ${repoRelative(path, repoRoot)}.`, "ULW_LOOP_PLAN_INVALID"); + } + const previousObjective = parsed.codexObjective; + if ((parsed.codexGoalMode ?? "per_story") === "aggregate" && + isLegacyEnumeratedAggregateObjective(previousObjective)) { + const now = iso(); + parsed.codexObjective = aggregateCodexObjectiveForScope(scope); + parsed.codexObjectiveAliases = [...new Set([...(parsed.codexObjectiveAliases ?? []), previousObjective])]; + parsed.updatedAt = now; + await writePlan(repoRoot, parsed, scope); + await appendLedger(repoRoot, { + at: now, + kind: "aggregate_objective_migrated", + message: "Migrated legacy enumerated aggregate Codex objective to the stable pointer objective.", + before: { codexObjective: previousObjective }, + after: { codexObjective: parsed.codexObjective }, + }, scope); + } + return parsed; +} +export async function writePlan(repoRoot, plan, scope) { + await mkdir(ulwLoopDir(repoRoot, scope), { recursive: true }); + const path = ulwLoopGoalsPath(repoRoot, scope); + const tmpPath = `${path}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tmpPath, `${JSON.stringify(plan, null, 2)}\n`, "utf8"); + await rename(tmpPath, path); +} +export async function appendLedger(repoRoot, entry, scope) { + await mkdir(ulwLoopDir(repoRoot, scope), { recursive: true }); + await appendFile(ulwLoopLedgerPath(repoRoot, scope), `${JSON.stringify(entry)}\n`, "utf8"); +} +export async function readSteeringLedgerEntries(repoRoot, scope) { + let raw; + try { + raw = await readFile(ulwLoopLedgerPath(repoRoot, scope), "utf8"); + } + catch (error) { + if (hasCode(error, "ENOENT")) + return []; + throw error; + } + const entries = []; + for (const line of raw.split(/\r?\n/).filter(Boolean)) { + const entry = JSON.parse(line); + if (isSteeringKind(entry.kind)) + entries.push(entry); + } + return entries; +} diff --git a/plugins/omo/components/ulw-loop/dist/quality-gate.d.ts b/plugins/omo/components/ulw-loop/dist/quality-gate.d.ts new file mode 100644 index 0000000..838d18b --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/quality-gate.d.ts @@ -0,0 +1,6 @@ +import type { UlwLoopItem, UlwLoopPlan, UlwLoopQualityGate } from "./types.js"; +export declare function validateQualityGate(input: unknown): UlwLoopQualityGate; +export declare function normalizeBlockerEvidence(evidence: string): string; +export declare function classifyExternalAuthorizationBlocker(evidence: string): string | null; +export declare function sameBlockerOccurrences(plan: UlwLoopPlan, signature: string): number; +export declare function clearGoalBlockerFields(goal: UlwLoopItem): void; diff --git a/plugins/omo/components/ulw-loop/dist/quality-gate.js b/plugins/omo/components/ulw-loop/dist/quality-gate.js new file mode 100644 index 0000000..3e5da31 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/quality-gate.js @@ -0,0 +1,123 @@ +import { UlwLoopError } from "./types.js"; +const BLOCKER_FIELD_KEYS = "blocker blockerSignature blockerEvidence blockerOccurrences blockedAt".split(" "); +const URL_PATTERN = /https?:\/\/\S+/g; +const PUNCTUATION_PATTERN = /[`"'()[\]{}:,;]/g; +const WHITESPACE_PATTERN = /\s+/g; +const AUTH_PATTERN = /\b(auth\w*|credential\w*|token|permission\w*|scope\w*|access|unauthorized|forbidden|401|403)\b/; +const MISSING_PATTERN = /\b(unset|missing|required|requires|without|omit\w*|not set|not available|no read packages|read packages)\b/; +const GHCR_PATTERN = /\b(ghcr|github container registry|read packages|imagepullsecret|package api|anonymous|container image)\b/; +const GHCR_401_PATTERN = /\b(401|unauthorized|anonymous pull|authentication required)\b/; +const GHCR_403_PATTERN = /\b(403|forbidden|read packages|package api)\b/; +const UNCONDITIONAL_APPROVAL_PATTERN = /\bUNCONDITIONAL\s+APPROVAL\b/i; +function invalid(message, field) { + throw new UlwLoopError(message, "ULW_LOOP_QUALITY_GATE_INVALID", { details: { field } }); +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function section(value, field) { + return isRecord(value) ? value : invalid(`Final quality gate is missing ${field} evidence.`, field); +} +function nonEmptyString(value, field) { + return typeof value === "string" && value.trim() !== "" + ? value + : invalid(`Final quality gate requires non-empty ${field}.`, field); +} +function numberField(value, field) { + return typeof value === "number" && Number.isFinite(value) + ? value + : invalid(`Final quality gate requires numeric ${field}.`, field); +} +function stringArray(value, field) { + if (!Array.isArray(value) || value.length === 0) + return invalid(`Final quality gate requires ${field}.`, field); + return value.map((item) => nonEmptyString(item, field)); +} +function normalizeReviewerField({ value, field, expectedValue, evidenceApproved, }) { + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed === "") { + if (evidenceApproved) + return expectedValue; + invalid(`${field} must be ${expectedValue} or codeReview.evidence should include UNCONDITIONAL APPROVAL.`, field); + } + if (trimmed === expectedValue) + return expectedValue; + invalid(`${field} must be ${expectedValue}.`, field); + } + if (value === undefined) { + if (evidenceApproved) + return expectedValue; + invalid(`${field} must be ${expectedValue} or codeReview.evidence should include UNCONDITIONAL APPROVAL.`, field); + } + invalid(`${field} must be ${expectedValue}.`, field); +} +export function validateQualityGate(input) { + const gate = section(input, "qualityGate"); + const cleaner = section(gate["aiSlopCleaner"], "aiSlopCleaner"); + const verification = section(gate["verification"], "verification"); + const review = section(gate["codeReview"], "codeReview"); + const coverage = section(gate["criteriaCoverage"], "criteriaCoverage"); + if (cleaner["status"] !== "passed") + invalid("aiSlopCleaner.status must be passed.", "aiSlopCleaner.status"); + if (verification["status"] !== "passed") + invalid("verification.status must be passed.", "verification.status"); + const totalCriteria = numberField(coverage["totalCriteria"], "criteriaCoverage.totalCriteria"); + const passCount = numberField(coverage["passCount"], "criteriaCoverage.passCount"); + if (passCount < totalCriteria) + invalid("criteriaCoverage.passCount must cover totalCriteria.", "criteriaCoverage.passCount"); + const commands = stringArray(verification["commands"], "verification.commands"); + const covered = stringArray(coverage["adversarialClassesCovered"], "criteriaCoverage.adversarialClassesCovered"); + const cleanerEvidence = nonEmptyString(cleaner["evidence"], "aiSlopCleaner.evidence"); + const verificationEvidence = nonEmptyString(verification["evidence"], "verification.evidence"); + const reviewEvidence = nonEmptyString(review["evidence"], "codeReview.evidence"); + const approvalEvidence = UNCONDITIONAL_APPROVAL_PATTERN.test(reviewEvidence); + const recommendation = normalizeReviewerField({ + value: review["recommendation"], + field: "codeReview.recommendation", + expectedValue: "APPROVE", + evidenceApproved: approvalEvidence, + }); + const architectStatus = normalizeReviewerField({ + value: review["architectStatus"], + field: "codeReview.architectStatus", + expectedValue: "CLEAR", + evidenceApproved: approvalEvidence, + }); + const result = { + aiSlopCleaner: { status: "passed", evidence: cleanerEvidence }, + verification: { status: "passed", commands, evidence: verificationEvidence }, + codeReview: { recommendation, architectStatus, evidence: reviewEvidence }, + }; + Object.assign(result, { criteriaCoverage: { totalCriteria, passCount, adversarialClassesCovered: covered } }); + return result; +} +export function normalizeBlockerEvidence(evidence) { + const withoutUrls = evidence.toLowerCase().replace(URL_PATTERN, " "); + const withoutPunctuation = withoutUrls.replace(PUNCTUATION_PATTERN, " "); + return withoutPunctuation.replace(WHITESPACE_PATTERN, " ").trim(); +} +export function classifyExternalAuthorizationBlocker(evidence) { + const normalized = normalizeBlockerEvidence(evidence); + if (!normalized || !AUTH_PATTERN.test(normalized) || !MISSING_PATTERN.test(normalized)) + return null; + if (!GHCR_PATTERN.test(normalized)) + return "EXTERNAL_AUTHORIZATION_REQUIRED"; + const status401 = GHCR_401_PATTERN.test(normalized) ? "HTTP_401_ANONYMOUS" : null; + const status403 = GHCR_403_PATTERN.test(normalized) ? "HTTP_403_NO_READ_PACKAGES" : null; + const status = [status401, status403].filter((part) => part !== null).join("+"); + return `GHCR_PULL_ACCESS:${status || "AUTHORIZATION_REQUIRED"}:GHCR_VISIBILITY_OR_CREDENTIAL_REQUIRED`; +} +function nestedBlockerSignature(goal) { + const blocker = Reflect.get(goal, "blocker"); + const signature = isRecord(blocker) ? blocker["signature"] : null; + return typeof signature === "string" ? signature : null; +} +export function sameBlockerOccurrences(plan, signature) { + return plan.goals.filter((goal) => goal.blockerSignature === signature || nestedBlockerSignature(goal) === signature) + .length; +} +export function clearGoalBlockerFields(goal) { + for (const key of BLOCKER_FIELD_KEYS) + Reflect.deleteProperty(goal, key); +} diff --git a/plugins/omo/components/ulw-loop/dist/review-blockers.d.ts b/plugins/omo/components/ulw-loop/dist/review-blockers.d.ts new file mode 100644 index 0000000..bbf59a3 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/review-blockers.d.ts @@ -0,0 +1,16 @@ +import type { UlwLoopScope } from "./paths.js"; +import type { UlwLoopItem, UlwLoopLedgerEntry, UlwLoopPlan } from "./types.js"; +export interface RecordFinalReviewBlockersArgs { + readonly goalId: string; + readonly title: string; + readonly objective: string; + readonly evidence: string; + readonly codexGoalJson: string; +} +export interface RecordFinalReviewBlockersResult { + readonly plan: UlwLoopPlan; + readonly blockedGoal: UlwLoopItem; + readonly newGoal: UlwLoopItem; + readonly ledgerEntries: UlwLoopLedgerEntry[]; +} +export declare function recordFinalReviewBlockers(repoRoot: string, args: RecordFinalReviewBlockersArgs, scope?: UlwLoopScope): Promise; diff --git a/plugins/omo/components/ulw-loop/dist/review-blockers.js b/plugins/omo/components/ulw-loop/dist/review-blockers.js new file mode 100644 index 0000000..2ea4a46 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/review-blockers.js @@ -0,0 +1,70 @@ +// biome-ignore-all format: compact port must stay within the requested pure LOC budget. +import { readCodexGoalSnapshotInput, reconcileCodexGoalSnapshot } from "./codex-goal-snapshot.js"; +import { codexGoalMode, compatibleCodexObjectives, expectedCodexObjective, isFinalRunCompletionCandidate } from "./goal-status.js"; +import { seedDefaultSuccessCriteria } from "./plan-crud.js"; +import { appendLedger, readUlwLoopPlan, withUlwLoopMutationLock, writePlan } from "./plan-io.js"; +import { iso, UlwLoopError } from "./types.js"; +const BLOCKER_FIELDS = "blockedReason blockerSignature blockerOccurrenceCount requiredExternalDecision nonRetriable failedAt failureReason completedAt blocker blockerEvidence blockerOccurrences blockedAt".split(" "); +function ulwLoopError(message, code) { + throw new UlwLoopError(message, code); +} +function nextGoalId(plan) { + const max = plan.goals.reduce((current, goal) => { + const digits = /^G(\d+)/u.exec(goal.id)?.[1]; + return digits === undefined ? current : Math.max(current, Number(digits)); + }, 0); + return `G${String(max + 1).padStart(3, "0")}`; +} +function appendBlockerGoal(plan, args, now) { + const index = plan.goals.length; + const goal = { + id: nextGoalId(plan), + title: args.title, + objective: args.objective, + status: "pending", + successCriteria: seedDefaultSuccessCriteria(index, args.objective), + attempt: 0, + createdAt: now, + updatedAt: now, + }; + plan.goals.push(goal); + return goal; +} +export async function recordFinalReviewBlockers(repoRoot, args, scope) { + return withUlwLoopMutationLock(repoRoot, scope, async () => { + const plan = await readUlwLoopPlan(repoRoot, scope); + const goal = plan.goals.find((candidate) => candidate.id === args.goalId); + if (goal === undefined) + ulwLoopError(`Unknown ulw-loop id: ${args.goalId}`, "ulw_loop_goal_not_found"); + if (goal.status !== "in_progress") + ulwLoopError(`${goal.id} is ${goal.status}.`, "ulw_loop_goal_not_in_progress"); + if (!isFinalRunCompletionCandidate(plan, goal)) + ulwLoopError(`${goal.id} is not final.`, "ulw_loop_not_final_story"); + const snapshot = await readCodexGoalSnapshotInput(args.codexGoalJson, repoRoot); + const aggregate = codexGoalMode(plan) === "aggregate"; + const reconciliation = reconcileCodexGoalSnapshot(snapshot, { expectedObjective: expectedCodexObjective(plan, goal), ...(aggregate ? { acceptedObjectives: compatibleCodexObjectives(plan) } : {}), allowedStatuses: ["active"], requireSnapshot: true, requireComplete: false }); + if (!reconciliation.ok) + ulwLoopError(reconciliation.errors.join(" "), "ulw_loop_codex_snapshot_mismatch"); + const now = iso(); + for (const field of BLOCKER_FIELDS) + Reflect.deleteProperty(goal, field); + goal.status = "review_blocked"; + goal.reviewBlockedAt = now; + goal.evidence = args.evidence; + goal.updatedAt = now; + if (plan.activeGoalId === goal.id) + delete plan.activeGoalId; + const newGoal = appendBlockerGoal(plan, args, now); + plan.updatedAt = now; + const codexGoal = reconciliation.snapshot.raw; + const blockedEntry = { at: now, kind: "goal_review_blocked", goalId: goal.id, status: goal.status, evidence: args.evidence, codexGoal }; + const addedEntry = { at: now, kind: "goal_added", goalId: newGoal.id, status: newGoal.status, evidence: args.evidence, message: newGoal.title }; + const summaryEntry = { at: now, kind: "goal_review_blocked", goalId: goal.id, status: goal.status, evidence: args.evidence, codexGoal, message: `Review blockers recorded; appended ${newGoal.id}.` }; + Reflect.set(summaryEntry, "kind", "blocker_recorded"); + const ledgerEntries = [blockedEntry, addedEntry, summaryEntry]; + await writePlan(repoRoot, plan, scope); + for (const entry of ledgerEntries) + await appendLedger(repoRoot, entry, scope); + return { plan, blockedGoal: goal, newGoal, ledgerEntries }; + }); +} diff --git a/plugins/omo/components/ulw-loop/dist/runtime.d.ts b/plugins/omo/components/ulw-loop/dist/runtime.d.ts new file mode 100644 index 0000000..41c9223 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/runtime.d.ts @@ -0,0 +1,10 @@ +export interface UlwLoopErrorOptions { + readonly cause?: unknown; + readonly details?: Record; +} +export declare class UlwLoopError extends Error { + readonly code: string; + readonly details?: Record; + constructor(message: string, code: string, opts?: UlwLoopErrorOptions); +} +export declare function iso(): string; diff --git a/plugins/omo/components/ulw-loop/dist/runtime.js b/plugins/omo/components/ulw-loop/dist/runtime.js new file mode 100644 index 0000000..811e8d1 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/runtime.js @@ -0,0 +1,13 @@ +export class UlwLoopError extends Error { + constructor(message, code, opts) { + super(message, opts?.cause === undefined ? undefined : { cause: opts.cause }); + this.name = "UlwLoopError"; + this.code = code; + if (opts?.details !== undefined) { + this.details = opts.details; + } + } +} +export function iso() { + return new Date().toISOString(); +} diff --git a/plugins/omo/components/ulw-loop/dist/steering-types.d.ts b/plugins/omo/components/ulw-loop/dist/steering-types.d.ts new file mode 100644 index 0000000..84533aa --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/steering-types.d.ts @@ -0,0 +1,63 @@ +import type { UlwLoopSteeringMutationKind, UlwLoopSteeringSource } from "./constants.js"; +import type { UlwLoopPlan } from "./domain-types.js"; +export interface UlwLoopSteeringInvariantResult { + accepted: boolean; + structuralInvariantAccepted: boolean; + evidenceBackedNecessity: boolean; + noEasierCompletion: boolean; + rejectedReasons: string[]; + reasons?: string[]; +} +export interface UlwLoopSteeringChildGoal { + title: string; + objective: string; +} +export interface UlwLoopSteeringAfterPayload { + title?: string; + objective?: string; + pendingGoalIds?: string[]; + children?: UlwLoopSteeringChildGoal[]; +} +export interface UlwLoopSteeringProposal { + kind: UlwLoopSteeringMutationKind; + source: UlwLoopSteeringSource; + targetGoalId?: string; + targetGoalIds?: string[]; + criterionId?: string; + evidence: string; + rationale: string; + title?: string; + objective?: string; + childGoals?: UlwLoopSteeringChildGoal[]; + revisedTitle?: string; + revisedObjective?: string; + pendingOrder?: string[]; + blockedReason?: string; + after?: UlwLoopSteeringAfterPayload; + directiveText?: string; + promptSignature?: string; + idempotencyKey?: string; + now?: Date; +} +export interface UlwLoopSteeringAudit { + kind: UlwLoopSteeringMutationKind; + source: UlwLoopSteeringSource; + targetGoalIds: string[]; + criterionId?: string; + before?: unknown; + after?: unknown; + evidence: string; + rationale: string; + invariant: UlwLoopSteeringInvariantResult; + directiveText?: string; + promptSignature?: string; + idempotencyKey?: string; + deduped?: boolean; +} +export interface SteerUlwLoopResult { + plan: UlwLoopPlan; + accepted: boolean; + audit: UlwLoopSteeringAudit; + rejectedReasons: string[]; + deduped: boolean; +} diff --git a/plugins/omo/components/ulw-loop/dist/steering-types.js b/plugins/omo/components/ulw-loop/dist/steering-types.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/steering-types.js @@ -0,0 +1 @@ +export {}; diff --git a/plugins/omo/components/ulw-loop/dist/steering.d.ts b/plugins/omo/components/ulw-loop/dist/steering.d.ts new file mode 100644 index 0000000..01a8a86 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/steering.d.ts @@ -0,0 +1,6 @@ +import type { UlwLoopScope } from "./paths.js"; +import type { SteerUlwLoopResult, UlwLoopPlan, UlwLoopSteeringAudit, UlwLoopSteeringProposal } from "./types.js"; +export declare function validateUlwLoopSteeringProposal(plan: UlwLoopPlan, proposal: unknown): UlwLoopSteeringAudit; +export declare function applySteeringMutation(plan: UlwLoopPlan, proposal: UlwLoopSteeringProposal, audit: UlwLoopSteeringAudit): UlwLoopPlan; +export declare function parseUlwLoopSteeringDirective(text: string): UlwLoopSteeringProposal | null; +export declare function steerUlwLoop(repoRoot: string, proposal: UlwLoopSteeringProposal, scope?: UlwLoopScope): Promise; diff --git a/plugins/omo/components/ulw-loop/dist/steering.js b/plugins/omo/components/ulw-loop/dist/steering.js new file mode 100644 index 0000000..8c5d75b --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/steering.js @@ -0,0 +1,292 @@ +// biome-ignore-all format: compact steering module must stay below the 240 pure-LOC budget +import { isUlwLoopDone } from "./goal-status.js"; +import { seedDefaultSuccessCriteria } from "./plan-crud.js"; +import { appendLedger, readSteeringLedgerEntries, readUlwLoopPlan, withUlwLoopMutationLock, writePlan } from "./plan-io.js"; +import { iso, ULW_LOOP_STEERING_MUTATION_KINDS, ULW_LOOP_SUCCESS_CRITERION_USER_MODELS } from "./types.js"; +const SOURCES = ["user_prompt_submit", "finding", "cli"]; +const PROTECTED = new Set(["aggregateCompletion", "codexObjective", "codexObjectiveAliases", "originalConstraints", "qualityGate", "status", "completedAt", "completionStatus"]); +const isObject = (value) => typeof value === "object" && value !== null; +const isPlain = (value) => isObject(value) && !Array.isArray(value); +const read = (value, key) => Object.entries(value).find(([name]) => name === key)?.[1]; +const isText = (value) => typeof value === "string" && value.trim().length > 0; +const text = (value, key) => { + const candidate = read(value, key); + return isText(candidate) ? candidate.trim() : undefined; +}; +const isKind = (value) => typeof value === "string" && ULW_LOOP_STEERING_MUTATION_KINDS.some((kind) => kind === value); +const isSource = (value) => typeof value === "string" && SOURCES.some((source) => source === value); +const isModel = (value) => typeof value === "string" && ULW_LOOP_SUCCESS_CRITERION_USER_MODELS.some((model) => model === value); +const texts = (value, key) => { + const candidate = read(value, key); + return Array.isArray(candidate) && candidate.every((item) => typeof item === "string") ? candidate : []; +}; +function targets(proposal) { + const many = texts(proposal, "targetGoalIds"); + const one = text(proposal, "targetGoalId") ?? text(proposal, "goalId"); + return many.length > 0 ? many : one === undefined ? [] : [one]; +} +const after = (proposal) => { + const candidate = read(proposal, "after"); + return isPlain(candidate) ? candidate : undefined; +}; +const revised = (proposal, direct, nested) => text(proposal, direct) ?? text(after(proposal) ?? proposal, nested); +function child(value) { + if (!isPlain(value)) + return null; + const title = text(value, "title"); + const objective = text(value, "objective"); + if (title === undefined || objective === undefined) + return null; + return { title, objective }; +} +function childValues(proposal) { + const direct = read(proposal, "childGoals"); + if (Array.isArray(direct) && direct.length > 0) + return direct; + const nested = after(proposal); + const fromAfter = nested === undefined ? undefined : read(nested, "children"); + return Array.isArray(fromAfter) ? fromAfter : []; +} +const children = (proposal) => childValues(proposal).map(child).filter((item) => item !== null); +const pendingOrder = (proposal) => { + const direct = texts(proposal, "pendingOrder"); + return direct.length > 0 ? direct : texts(after(proposal) ?? proposal, "pendingGoalIds"); +}; +function hasProtected(value) { + if (!isObject(value)) + return false; + for (const [key, childValue] of Object.entries(value)) + if (PROTECTED.has(key) || key.toLowerCase().includes("complete") || hasProtected(childValue)) + return true; + return false; +} +function allText(value) { + if (typeof value === "string") + return value; + return isObject(value) ? Object.values(value).map(allText).filter(Boolean).join("\n") : ""; +} +function weakens(value) { + const valueText = allText(value).toLowerCase(); + return /\b(skip|bypass|weaken|remove|omit|auto[-\s]?complete|mark complete|complete faster)\b/.test(valueText) && /\b(test|tests|verification|review|quality gate|complete|completion)\b/.test(valueText); +} +function auditFor(proposal, reasons) { + const object = isPlain(proposal) ? proposal : undefined; + const kindRaw = object === undefined ? undefined : read(object, "kind"); + const sourceRaw = object === undefined ? undefined : read(object, "source"); + const evidence = object === undefined ? "" : (text(object, "evidence") ?? ""); + const rationale = object === undefined ? "" : (text(object, "rationale") ?? ""); + const audit = { kind: isKind(kindRaw) ? kindRaw : "annotate_ledger", source: isSource(sourceRaw) ? sourceRaw : "cli", targetGoalIds: object === undefined ? [] : targets(object), evidence, rationale, invariant: { accepted: reasons.length === 0, structuralInvariantAccepted: reasons.length === 0, evidenceBackedNecessity: evidence.length > 0 && rationale.length > 0, noEasierCompletion: !weakens(proposal), rejectedReasons: reasons, reasons } }; + if (object === undefined) + return audit; + const criterionId = text(object, "criterionId"); + const directiveText = text(object, "directiveText"); + const promptSignature = text(object, "promptSignature"); + const idempotencyKey = text(object, "idempotencyKey"); + if (criterionId !== undefined) + audit.criterionId = criterionId; + if (directiveText !== undefined) + audit.directiveText = directiveText; + if (promptSignature !== undefined) + audit.promptSignature = promptSignature; + if (idempotencyKey !== undefined) + audit.idempotencyKey = idempotencyKey; + return audit; +} +export function validateUlwLoopSteeringProposal(plan, proposal) { + const reasons = []; + if (!isPlain(proposal)) + reasons.push("proposal must be an object"); + const object = isPlain(proposal) ? proposal : {}; + const kind = read(object, "kind"); + if (!isKind(kind)) + reasons.push(`invalid kind: ${String(kind)}`); + if (!isSource(read(object, "source"))) + reasons.push(`invalid source: ${String(read(object, "source"))}`); + if (text(object, "evidence") === undefined) + reasons.push("missing evidence"); + if (text(object, "rationale") === undefined) + reasons.push("missing rationale"); + if (hasProtected(proposal)) + reasons.push("protected payload"); + if (weakens(proposal)) + reasons.push("weakened completion"); + if (isUlwLoopDone(plan)) + reasons.push("plan already complete"); + if (isKind(kind)) + validateKind(plan, object, kind, reasons); + return auditFor(proposal, reasons); +} +function goal(plan, id) { + return id === undefined ? undefined : plan.goals.find((item) => item.id === id); +} +function validateKind(plan, proposal, kind, reasons) { + const target = goal(plan, targets(proposal)[0]); + if (kind === "add_subgoal" && (text(proposal, "title") === undefined || text(proposal, "objective") === undefined)) + reasons.push("add_subgoal requires title/objective"); + if ((kind === "split_subgoal" || kind === "revise_pending_wording" || kind === "mark_blocked_superseded") && target === undefined) + reasons.push(`${kind} requires target`); + if ((kind === "split_subgoal" || kind === "revise_pending_wording") && target !== undefined && target.status !== "pending") + reasons.push(`${kind} requires pending target`); + const rawChildren = childValues(proposal); + if (kind === "split_subgoal" && rawChildren.length === 0) + reasons.push("split_subgoal requires children"); + if ((kind === "split_subgoal" || kind === "mark_blocked_superseded") && rawChildren.some((item) => child(item) === null)) + reasons.push(`${kind} children require title/objective`); + if (kind === "reorder_pending") + validateOrder(plan, proposal, reasons); + if (kind === "revise_pending_wording" && revised(proposal, "revisedTitle", "title") === undefined && revised(proposal, "revisedObjective", "objective") === undefined) + reasons.push("revise_pending_wording requires update"); + if (kind === "revise_criterion") + validateCriterion(plan, proposal, reasons); +} +function validateOrder(plan, proposal, reasons) { + const requested = pendingOrder(proposal); + const pending = plan.goals.filter((item) => item.status === "pending" && item.steeringStatus === undefined).map((item) => item.id); + if (requested.length === 0) + reasons.push("reorder_pending requires ids"); + if (new Set(requested).size !== requested.length) + reasons.push("duplicate pending id"); + if (requested.some((id) => !pending.includes(id))) + reasons.push("unknown pending id"); +} +function validateCriterion(plan, proposal, reasons) { + const target = goal(plan, targets(proposal)[0]); + const criterionId = text(proposal, "criterionId"); + if (target === undefined) + reasons.push("revise_criterion requires goalId"); + else if (criterionId === undefined || target.successCriteria.every((item) => item.id !== criterionId)) + reasons.push("revise_criterion requires criterionId"); + const model = read(proposal, "userModel"); + if (read(proposal, "scenario") === undefined && read(proposal, "expectedEvidence") === undefined && model === undefined) + reasons.push("revise_criterion requires update"); + if (model !== undefined && !isModel(model)) + reasons.push("invalid userModel"); +} +function nextId(plan, offset) { + const max = plan.goals.reduce((current, item) => { + const digits = /^G(\d+)(?:-|$)/u.exec(item.id)?.[1]; + return digits === undefined ? current : Math.max(current, Number(digits)); + }, 0); + return `G${String(max + offset).padStart(3, "0")}`; +} +function makeGoal(plan, childGoal, evidence, now, offset) { + const id = nextId(plan, offset); + const digits = /^G(\d+)/u.exec(id)?.[1]; + const goalIndex = digits === undefined ? plan.goals.length + offset - 1 : Number(digits) - 1; + return { id, title: childGoal.title, objective: childGoal.objective, status: "pending", successCriteria: seedDefaultSuccessCriteria(goalIndex, childGoal.objective), attempt: 0, createdAt: now, updatedAt: now, evidence }; +} +export function applySteeringMutation(plan, proposal, audit) { + const next = structuredClone(plan); + if (!audit.invariant.accepted) + return next; + const now = proposal.now?.toISOString() ?? iso(); + if (proposal.kind === "add_subgoal") + next.goals.push(makeGoal(next, { title: proposal.title ?? "", objective: proposal.objective ?? "" }, proposal.evidence, now, 1)); + if (proposal.kind === "reorder_pending") { + const order = pendingOrder(proposal); + next.goals = [...order.map((id) => goal(next, id)).filter((item) => item !== undefined), ...next.goals.filter((item) => !order.includes(item.id))]; + } + if (proposal.kind === "revise_pending_wording") + reviseWording(next, proposal, now); + if (proposal.kind === "split_subgoal" || proposal.kind === "mark_blocked_superseded") + splitOrBlock(next, proposal, now); + if (proposal.kind === "revise_criterion") + reviseCriterion(next, proposal, now); + if (proposal.kind !== "annotate_ledger") + next.updatedAt = now; + return next; +} +function reviseWording(plan, proposal, now) { + const target = goal(plan, targets(proposal)[0]); + if (target === undefined) + return; + target.title = revised(proposal, "revisedTitle", "title") ?? target.title; + target.objective = revised(proposal, "revisedObjective", "objective") ?? target.objective; + target.steeringEvidence = proposal.evidence; + target.steeringRationale = proposal.rationale; + target.updatedAt = now; +} +function splitOrBlock(plan, proposal, now) { + const target = goal(plan, targets(proposal)[0]); + if (target === undefined) + return; + const replacements = children(proposal).map((item, index) => makeGoal(plan, item, proposal.evidence, now, index + 1)); + target.steeringEvidence = proposal.evidence; + target.steeringRationale = proposal.rationale; + target.updatedAt = now; + if (replacements.length === 0) { + target.status = "blocked"; + target.steeringStatus = "blocked"; + target.blockedReason = proposal.blockedReason ?? proposal.rationale; + } + else { + target.steeringStatus = "superseded"; + target.supersededBy = replacements.map((item) => item.id); + for (const item of replacements) + item.supersedes = [target.id]; + plan.goals.splice(plan.goals.indexOf(target) + 1, 0, ...replacements); + } + if (plan.activeGoalId === target.id) + delete plan.activeGoalId; +} +function reviseCriterion(plan, proposal, now) { + const target = goal(plan, targets(proposal)[0]); + const index = target?.successCriteria.findIndex((item) => item.id === proposal.criterionId) ?? -1; + const current = target?.successCriteria[index]; + if (target === undefined || current === undefined) + return; + const model = read(proposal, "userModel"); + target.successCriteria[index] = { ...current, scenario: text(proposal, "scenario") ?? current.scenario, expectedEvidence: text(proposal, "expectedEvidence") ?? current.expectedEvidence, userModel: isModel(model) ? model : current.userModel }; + target.updatedAt = now; +} +function isProposal(value) { + return isPlain(value) && isKind(read(value, "kind")) && isSource(read(value, "source")) && isText(read(value, "evidence")) && isText(read(value, "rationale")); +} +export function parseUlwLoopSteeringDirective(text) { + const match = /(?:^|\s)(?:OMO_ULW_LOOP_STEER|omo\.ulw-loop\.steer|omo ulw-loop steer):\s*([\s\S]+)$/u.exec(text); + if (match?.[1] === undefined) + return null; + try { + const parsed = JSON.parse(match[1].trim()); + return isProposal(parsed) ? parsed : null; + } + catch (error) { + if (error instanceof SyntaxError) + return null; + throw error; + } +} +export async function steerUlwLoop(repoRoot, proposal, scope) { + return withUlwLoopMutationLock(repoRoot, scope, async () => { + const plan = await readUlwLoopPlan(repoRoot, scope); + const key = proposal.idempotencyKey ?? proposal.promptSignature; + const prior = key === undefined ? undefined : (await readSteeringLedgerEntries(repoRoot, scope)).find((entry) => entry.steering?.invariant.accepted === true && (entry.idempotencyKey === key || entry.steering.idempotencyKey === key || entry.steering.promptSignature === key)); + if (prior?.steering !== undefined) + return { plan, accepted: true, audit: { ...prior.steering, deduped: true }, rejectedReasons: [], deduped: true }; + const audit = validateUlwLoopSteeringProposal(plan, proposal); + const accepted = audit.invariant.accepted; + const next = accepted ? applySteeringMutation(plan, proposal, audit) : plan; + const finalAudit = { ...audit, before: plan }; + if (accepted) + finalAudit.after = next; + if (accepted) + await writePlan(repoRoot, next, scope); + await appendLedger(repoRoot, ledgerEntry(proposal, finalAudit, proposal.now?.toISOString() ?? iso()), scope); + return { plan: next, accepted, audit: finalAudit, rejectedReasons: audit.invariant.rejectedReasons, deduped: false }; + }); +} +function ledgerEntry(proposal, audit, at) { + const entry = { at, kind: audit.invariant.accepted ? (proposal.kind === "revise_criterion" ? "criteria_revised" : "steering_accepted") : "steering_rejected", evidence: proposal.evidence, message: proposal.rationale, steering: audit, mutationKind: proposal.kind }; + const goalId = audit.targetGoalIds[0]; + if (goalId !== undefined) + entry.goalId = goalId; + if (proposal.criterionId !== undefined) + entry.criterionId = proposal.criterionId; + if (proposal.idempotencyKey !== undefined) + entry.idempotencyKey = proposal.idempotencyKey; + if (audit.before !== undefined) + entry.before = audit.before; + if (audit.after !== undefined) + entry.after = audit.after; + return entry; +} diff --git a/plugins/omo/components/ulw-loop/dist/types.d.ts b/plugins/omo/components/ulw-loop/dist/types.d.ts new file mode 100644 index 0000000..b639dd0 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/types.d.ts @@ -0,0 +1,5 @@ +export * from "./command-types.js"; +export * from "./constants.js"; +export * from "./domain-types.js"; +export * from "./runtime.js"; +export * from "./steering-types.js"; diff --git a/plugins/omo/components/ulw-loop/dist/types.js b/plugins/omo/components/ulw-loop/dist/types.js new file mode 100644 index 0000000..b639dd0 --- /dev/null +++ b/plugins/omo/components/ulw-loop/dist/types.js @@ -0,0 +1,5 @@ +export * from "./command-types.js"; +export * from "./constants.js"; +export * from "./domain-types.js"; +export * from "./runtime.js"; +export * from "./steering-types.js"; diff --git a/plugins/omo/package.json b/plugins/omo/package.json index b997a24..445c393 100644 --- a/plugins/omo/package.json +++ b/plugins/omo/package.json @@ -19,7 +19,7 @@ "@oh-my-opencode/shared-skills": "file:../../shared-skills" }, "scripts": { - "build": "node scripts/sync-hook-status-messages.mjs && node scripts/build-bundled-mcp-runtimes.mjs && node scripts/sync-skills.mjs && node ../scripts/sync-telemetry-component.mjs && node scripts/build-components.mjs", + "build": "node scripts/sync-hook-status-messages.mjs && node scripts/build-bundled-mcp-runtimes.mjs && node scripts/sync-skills.mjs && node scripts/sync-telemetry-component.mjs && node scripts/build-components.mjs", "check": "npm run build && npm test", "sync:hooks": "node scripts/sync-hook-status-messages.mjs", "sync:skills": "node scripts/sync-skills.mjs", diff --git a/plugins/omo/scripts/build-bundled-mcp-runtimes.mjs b/plugins/omo/scripts/build-bundled-mcp-runtimes.mjs index b3ee50c..ddef5b2 100644 --- a/plugins/omo/scripts/build-bundled-mcp-runtimes.mjs +++ b/plugins/omo/scripts/build-bundled-mcp-runtimes.mjs @@ -36,8 +36,11 @@ function buildRuntime(runtime) { } if (!existsSync(join(runtime.packageRoot, "package.json"))) { - assertBundledDist(runtime); - console.log(`Using bundled ${runtime.label} dist`); + if (hasBundledDist(runtime)) { + console.log(`Using bundled ${runtime.label} dist`); + return; + } + console.warn(`Skipping optional ${runtime.label}; package source and bundled dist are absent.`); return; } @@ -53,13 +56,3 @@ function buildRuntime(runtime) { function hasBundledDist(runtime) { return runtime.requiredOutputs.every((output) => existsSync(join(runtime.packageRoot, output))); } - -function assertBundledDist(runtime) { - const missingOutputs = runtime.requiredOutputs.filter((output) => !existsSync(join(runtime.packageRoot, output))); - if (missingOutputs.length === 0) return; - console.error(`Missing bundled ${runtime.label} outputs:`); - for (const output of missingOutputs) { - console.error(` ${join(runtime.packageRoot, output)}`); - } - process.exit(1); -} diff --git a/plugins/omo/scripts/sync-skills.mjs b/plugins/omo/scripts/sync-skills.mjs index 6b8f441..6065d41 100644 --- a/plugins/omo/scripts/sync-skills.mjs +++ b/plugins/omo/scripts/sync-skills.mjs @@ -2,10 +2,8 @@ import { cp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { sharedSkillsRootPath } from "@oh-my-opencode/shared-skills"; const root = dirname(dirname(fileURLToPath(import.meta.url))); -const sharedSkillsRoot = sharedSkillsRootPath(); const skillsRoot = join(root, "skills"); const sourceTestFilePattern = /\.test\.ts$/; const skillSources = [ @@ -150,6 +148,12 @@ async function adaptSkillForCodex(skillName) { } async function syncSkills() { + const sharedSkillsRoot = await resolveSharedSkillsRoot(); + if (sharedSkillsRoot === null) { + console.warn("Skipping shared skill sync; @oh-my-opencode/shared-skills is unavailable."); + return; + } + await rm(skillsRoot, { recursive: true, force: true }); await mkdir(skillsRoot, { recursive: true }); @@ -173,6 +177,25 @@ async function syncSkills() { } } +async function resolveSharedSkillsRoot() { + try { + const { sharedSkillsRootPath } = await import("@oh-my-opencode/shared-skills"); + return sharedSkillsRootPath(); + } catch (error) { + if (isMissingSharedSkillsError(error)) return null; + throw error; + } +} + +function isMissingSharedSkillsError(error) { + return ( + error instanceof Error && + "code" in error && + error.code === "ERR_MODULE_NOT_FOUND" && + error.message.includes("@oh-my-opencode/shared-skills") + ); +} + if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { await syncSkills(); } diff --git a/plugins/omo/scripts/sync-telemetry-component.mjs b/plugins/omo/scripts/sync-telemetry-component.mjs new file mode 100644 index 0000000..690c356 --- /dev/null +++ b/plugins/omo/scripts/sync-telemetry-component.mjs @@ -0,0 +1,20 @@ +#!/usr/bin/env node +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const sourceSyncScript = join(root, "..", "scripts", "sync-telemetry-component.mjs"); + +if (!existsSync(sourceSyncScript)) { + console.warn("Skipping telemetry component sync; source sync script is unavailable."); + process.exit(0); +} + +const result = spawnSync(process.execPath, [sourceSyncScript], { + cwd: root, + stdio: "inherit", +}); +if (result.error !== undefined) throw result.error; +process.exit(result.status ?? 1); diff --git a/plugins/omo/test/aggregate-build.test.mjs b/plugins/omo/test/aggregate-build.test.mjs index e2c7b42..bbd284c 100644 --- a/plugins/omo/test/aggregate-build.test.mjs +++ b/plugins/omo/test/aggregate-build.test.mjs @@ -9,7 +9,7 @@ test("#given aggregate plugin build script #when inspected #then hook status and // given const packageText = await readFile(join(root, "package.json"), "utf8"); const packageJson = await readJson("package.json"); - const telemetrySyncScript = await readFile(join(root, "..", "scripts", "sync-telemetry-component.mjs"), "utf8"); + const telemetrySyncScript = await readFile(join(root, "scripts", "sync-telemetry-component.mjs"), "utf8"); // when const buildScript = packageJson.scripts.build; @@ -18,21 +18,25 @@ test("#given aggregate plugin build script #when inspected #then hook status and // then assert.equal( buildScript, - "node scripts/sync-hook-status-messages.mjs && node scripts/build-bundled-mcp-runtimes.mjs && node scripts/sync-skills.mjs && node ../scripts/sync-telemetry-component.mjs && node scripts/build-components.mjs", + "node scripts/sync-hook-status-messages.mjs && node scripts/build-bundled-mcp-runtimes.mjs && node scripts/sync-skills.mjs && node scripts/sync-telemetry-component.mjs && node scripts/build-components.mjs", ); assert.equal(testScript, "node --test test/*.test.mjs"); assert(packageJson.workspaces.includes("components/ultrawork")); - assert.match(telemetrySyncScript, /syncTelemetryComponent/); + assert.match(telemetrySyncScript, /sync-telemetry-component\.mjs/); + assert.match(telemetrySyncScript, /Skipping telemetry component sync/); assert.doesNotMatch(packageText, /\bpython3?\b|ultrawork-detector\.py/); }); -test("#given omo-codex package build script #when inspected #then delegates to the aggregate plugin package", async () => { +test("#given marketplace package build scripts #when inspected #then aggregate plugin build is self-contained", async () => { // given - const packageJson = JSON.parse(await readFile(join(root, "..", "package.json"), "utf8")); + const packageJson = await readJson("package.json"); + const rootPackageJson = JSON.parse(await readFile(join(root, "..", "..", "package.json"), "utf8")); // when - const buildPluginScript = packageJson.scripts["build:plugin"]; + const aggregateBuildScript = packageJson.scripts.build; + const rootBuildPluginScript = rootPackageJson.scripts?.["build:plugin"]; // then - assert.equal(buildPluginScript, "bun run --cwd plugin build"); + assert.match(aggregateBuildScript, /node scripts\/build-components\.mjs/); + assert.equal(rootBuildPluginScript, undefined); }); diff --git a/plugins/omo/test/aggregate-hooks.test.mjs b/plugins/omo/test/aggregate-hooks.test.mjs index e9ad3f2..006eed9 100644 --- a/plugins/omo/test/aggregate-hooks.test.mjs +++ b/plugins/omo/test/aggregate-hooks.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; +import { readFile, stat } from "node:fs/promises"; import { join } from "node:path"; import test from "node:test"; @@ -83,6 +83,32 @@ test("#given aggregate hook commands #when inspected #then commands stay Node-ba assert(commands.every((command) => !command.includes("\\"))); }); +test("#given aggregate hook commands #when installed from the marketplace snapshot #then every referenced local CLI exists", async () => { + // given + const hooks = await readJson("hooks/hooks.json"); + + // when + const missingTargets = []; + for (const { handler } of collectCommandHooks(hooks, "hooks/hooks.json")) { + const command = handler.command; + const match = /"\$\{PLUGIN_ROOT\}\/([^"]+)"/.exec(command); + if (match === null) continue; + const target = match[1]; + try { + await stat(join(root, target)); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + missingTargets.push(target); + continue; + } + throw error; + } + } + + // then + assert.deepEqual(missingTargets, []); +}); + test("#given component hook commands #when inspected #then standalone packages expose Codex status messages", async () => { // given const componentHooks = await readComponentHookManifests(); diff --git a/plugins/omo/test/install-time-build-runtime.test.mjs b/plugins/omo/test/install-time-build-runtime.test.mjs index f080a9a..5d00c64 100644 --- a/plugins/omo/test/install-time-build-runtime.test.mjs +++ b/plugins/omo/test/install-time-build-runtime.test.mjs @@ -32,3 +32,12 @@ test("#given aggregate build scripts #when inspected #then npm subprocesses reso assert.match(installTimeBuildScripts, /shell: process\.platform === "win32"/); assert.doesNotMatch(installTimeBuildScripts, /npm\.cmd/); }); + +test("#given marketplace repo lacks optional MCP source packages #when aggregate build runs #then it can still build hook CLIs", async () => { + // given + const buildBundledMcpRuntimesScript = await readFile(join(root, "scripts", "build-bundled-mcp-runtimes.mjs"), "utf8"); + + // when / then + assert.match(buildBundledMcpRuntimesScript, /Skipping optional/); + assert.doesNotMatch(buildBundledMcpRuntimesScript, /process\.exit\(1\)/); +});