diff --git a/packages/omo-codex/README.md b/packages/omo-codex/README.md index 1b7369085..cff6da33c 100644 --- a/packages/omo-codex/README.md +++ b/packages/omo-codex/README.md @@ -17,7 +17,7 @@ Codex harness adapter for **oh-my-openagent**. Brings the OMO experience (rules - `rules` (TypeScript) - injects `AGENTS.md` / `CLAUDE.md` / `.omo/rules/**` into context via `SessionStart`, `UserPromptSubmit`, `PostToolUse`, `PostCompact`. - `comment-checker` (TypeScript) - runs `@code-yeongyu/comment-checker` after `apply_patch` / `edit` / `write` tool use. - `lsp` (TypeScript + LSP MCP) - exposes LSP diagnostics, navigation, symbols, rename via MCP + post-edit hooks. -- `ultrawork` (Python) - keyword detector (`ulw` / `ultrawork`) that injects the full ultrawork directive, plus a `SessionStart` hook that syncs bundled agent TOML files into `CODEX_HOME/agents`. +- `ultrawork` (TypeScript) - keyword detector (`ulw` / `ultrawork`) that injects the full ultrawork directive; bundled agent TOML files are installed into `CODEX_HOME/agents`. - `ultragoal` (TypeScript) - durable multi-goal orchestration backed by `.omo/ultragoal/` evidence audit. ## Install diff --git a/packages/omo-codex/plugin/components/ultrawork/AGENTS.md b/packages/omo-codex/plugin/components/ultrawork/AGENTS.md index 9bc71b7f4..90f3a2d1f 100644 --- a/packages/omo-codex/plugin/components/ultrawork/AGENTS.md +++ b/packages/omo-codex/plugin/components/ultrawork/AGENTS.md @@ -5,13 +5,17 @@ Conventions for human contributors and AI agents working on this repository. ## Style - Terse technical prose. No emojis in commits, issues, PR comments, or code. -- Python: PEP 484 type hints. Hook script stays in standard-library only — no pip dependencies. -- Tabs for indentation in JSON and Markdown tables. Spaces (4) for Python. +- TypeScript strict mode. No `any`, no `@ts-ignore`, no `@ts-expect-error`, no enums, no non-null assertions. +- ESM modules with `.js` suffix in runtime import paths. +- Runtime is Node only because Codex launches plugin hooks with Node. +- Tabs for indentation in JSON, TypeScript, and Markdown tables. - Double quotes for JSON strings. ## Layout -- `hooks/ultrawork-detector.py` — pure stdlib `UserPromptSubmit` hook. Reads JSON on stdin, writes the directive to stdout when the keyword matches, exits 0 otherwise. +- `src/cli.ts` — `UserPromptSubmit` hook CLI. Reads JSON on stdin, writes the directive to stdout when the keyword matches, exits 0 otherwise. +- `src/codex-hook.ts` — pure detector/hook behavior. +- `directive.md` — bundled ultrawork directive text. - `agents/*.toml` — bundled Codex agent role files. Installed into `CODEX_HOME/agents/` by `src/cli/install-codex/link-cached-plugin-agents.ts` at install time (symlink on Unix, copy on Windows). No runtime `SessionStart` hook is involved. - `hooks/hooks.json` — registers the prompt-detector hook only. - `.codex-plugin/plugin.json` — Codex plugin manifest. Marketplace metadata lives here, not in `package.json`. @@ -20,17 +24,18 @@ Conventions for human contributors and AI agents working on this repository. - Never let the hook block a turn — exit code is always 0. - Never make a network call from the hook. -- Keep the directive in `ULTRAWORK_DIRECTIVE` self-contained inside the Python file. The prompt hook is a single artifact a reviewer can read top-to-bottom. +- Keep the directive in `directive.md`. Do not inline it into TypeScript files. - Keep bundled agent role prompts concise and model-specific; measure prompt length when changing them. -- When editing `ULTRAWORK_DIRECTIVE`, apply the `prompt-engineering` skill's entropy gate: every edit must reduce uncertainty per token. Re-measure character count before committing. +- When editing `directive.md`, apply the `prompt-engineering` skill's entropy gate: every edit must reduce uncertainty per token. Re-measure character count before committing. ## Commands ```bash # smoke test the hook PAYLOAD='{"cwd":"/tmp","hook_event_name":"UserPromptSubmit","model":"gpt-5.5","permission_mode":"default","session_id":"x","transcript_path":"","turn_id":"y","prompt":"please ultrawork"}' -echo "$PAYLOAD" | python3 hooks/ultrawork-detector.py | head -3 +npm run build +echo "$PAYLOAD" | node dist/cli.js hook user-prompt-submit | head -3 # pattern boundary check (must be empty) -echo '{"hook_event_name":"UserPromptSubmit","prompt":"refactor ulw_helper.ts"}' | python3 hooks/ultrawork-detector.py | wc -c +echo '{"hook_event_name":"UserPromptSubmit","prompt":"refactor ulw_helper.ts"}' | node dist/cli.js hook user-prompt-submit | wc -c ``` diff --git a/packages/omo-codex/plugin/components/ultrawork/CHANGELOG.md b/packages/omo-codex/plugin/components/ultrawork/CHANGELOG.md index 0153a0252..6bd02aa9e 100644 --- a/packages/omo-codex/plugin/components/ultrawork/CHANGELOG.md +++ b/packages/omo-codex/plugin/components/ultrawork/CHANGELOG.md @@ -2,12 +2,13 @@ ## Unreleased +- Runtime hook migrated from `python3 hooks/ultrawork-detector.py` to the component-standard TypeScript build output `node dist/cli.js hook user-prompt-submit`, removing the Codex runtime dependency on Python. - New top-level **`# Manual-QA channels`** section explicitly enumerates the four real-usage channels the agent MUST verify through: (1) HTTP call, (2) tmux, (3) Browser use, (4) Computer use — each with concrete commands and the artifact to capture. Auxiliary surfaces (CLI stdout / DB diff / parsed config dump) only count for genuinely CLI- or data-shaped criteria. - Goal section now shouts **TESTS ALONE NEVER PROVE DONE**: a green test suite is supporting evidence, never completion proof. Every criterion needs its own real-usage scenario, built fresh and run through one of the four channels, every time. - Bootstrap criterion item 2 and execution step 4 collapse onto the new channel table to remove triple-enumeration of the same surfaces (single source of truth, less drift). - Execution loop step 4 (**SURFACE-AS-SCENARIO**) runs the chosen channel scenario; step 5 (**CLEANUP, PAIRED**) tears down server PIDs, `tmux` sessions, browser / Playwright contexts, containers, bound ports, temp files / dirs, QA-only env vars and records a one-line receipt. Missing receipt → criterion stays in_progress. Leftover state from QA = NOT done (Stop rule). -- Regression tests in `hooks/ultrawork-hooks.test.mjs` now pin: the four channel labels (`HTTP call`, `tmux`, `Browser use`, `Computer use`), `TESTS ALONE NEVER PROVE DONE`, `every criterion needs its own real-usage scenario`, the `# Manual-QA channels` heading, plus SURFACE-AS-SCENARIO + CLEANUP + leftover-state stop rule. -- Directive size: 11,005 chars across 232 lines. +- Regression tests in `test/codex-hook.test.ts` now pin: the four channel labels (`HTTP call`, `tmux`, `Browser use`, `Computer use`), `TESTS ALONE NEVER PROVE DONE`, `every criterion needs its own real-usage scenario`, the `# Manual-QA channels` heading, plus SURFACE-AS-SCENARIO + CLEANUP + leftover-state stop rule. +- Directive size: 10,951 chars across 231 lines. ### Pre-cleanup unreleased entries (folded above) @@ -19,6 +20,6 @@ Initial release. -- Codex `UserPromptSubmit` hook (`hooks/ultrawork-detector.py`) that detects `ultrawork` / `ulw` (word-bounded, case-insensitive) in the user prompt and injects the ultrawork orchestration directive. +- Codex `UserPromptSubmit` hook that detects `ultrawork` / `ulw` (word-bounded, case-insensitive) in the user prompt and injects the ultrawork orchestration directive. - Directive enforces: goal + binding success criteria with manual-QA scenarios + evidence, durable `/tmp` notepad lifecycle, obsessive atomic todos, scenario-driven execution loop, and a GPT-5.2 xhigh verification gate with no "false positive" escape hatch. - Directive size: 5,775 chars across 143 lines. diff --git a/packages/omo-codex/plugin/components/ultrawork/README.md b/packages/omo-codex/plugin/components/ultrawork/README.md index 8a4a022b7..7d805f756 100644 --- a/packages/omo-codex/plugin/components/ultrawork/README.md +++ b/packages/omo-codex/plugin/components/ultrawork/README.md @@ -2,7 +2,7 @@ Codex plugin that injects a compact orchestration directive (the **ultrawork** prompt) when the user prompt contains `ultrawork` or `ulw` (word-bounded, case-insensitive). -Bundled Codex agent role TOMLs in `agents/` are installed into `CODEX_HOME/agents/` by the omo-codex installer (`linkCachedPluginAgents`, in `src/cli/install-codex/link-cached-plugin-agents.ts`). Install-time linking uses symlinks on Linux / macOS and file copies on Windows. There is no Python `SessionStart` hook anymore. +Bundled Codex agent role TOMLs in `agents/` are installed into `CODEX_HOME/agents/` by the omo-codex installer (`linkCachedPluginAgents`, in `src/cli/install-codex/link-cached-plugin-agents.ts`). Install-time linking uses symlinks on Linux / macOS and file copies on Windows. There is no runtime Python hook. ## What the injected directive enforces @@ -15,7 +15,7 @@ Bundled Codex agent role TOMLs in `agents/` are installed into `CODEX_HOME/agent | Obsessive atomic todos | Every action — even one-line edits, `ls`, single test runs — becomes a todo. Format: `path: for — verify by `. One in_progress at a time, mark completed immediately. | | GPT-5.2 xhigh verification gate | Triggered automatically on user-requested rigor, 3+ files, 20+ turns, 30+ minutes, or refactor/migration/perf/security work. Use the bundled `codex-ultrawork-reviewer` agent role when available. Reviewer verdict is **binding** — no "false positive", no minimising, no arguing. Loop until **unconditional** approval. "Looks good but…" = REJECTION. | -The directive is currently 11,005 chars / 232 lines and follows the GPT-5.5 prompting structure (Role / Goal / Manual-QA channels / Bootstrap / Execution loop / Verification gate / Commits / Constraints / Output / Stop rules). +The directive is currently 10,951 chars / 231 lines and follows the GPT-5.5 prompting structure (Role / Goal / Manual-QA channels / Bootstrap / Execution loop / Verification gate / Commits / Constraints / Output / Stop rules). ## Install (via this marketplace) @@ -30,7 +30,7 @@ The installer copies the plugin into `~/.codex/plugins/cache/sisyphuslabs/omo/0. `hooks/hooks.json` registers a `UserPromptSubmit` hook running: ``` -python3 ${PLUGIN_ROOT}/hooks/ultrawork-detector.py +node ${PLUGIN_ROOT}/dist/cli.js hook user-prompt-submit ``` Codex passes the prompt payload on stdin. When the pattern `\b(?:ultrawork|ulw)\b` (case-insensitive) matches, the hook writes the directive to stdout — Codex injects non-JSON stdout as `additional_context` for the next turn. Otherwise the hook writes nothing and exits 0. Malformed input also exits 0 to never block the turn. @@ -41,7 +41,8 @@ Bundled agent role TOMLs in `agents/` ship to `CODEX_HOME/agents/` at install ti ```bash PAYLOAD='{"cwd":"/tmp","hook_event_name":"UserPromptSubmit","model":"gpt-5.5","permission_mode":"default","session_id":"x","transcript_path":"","turn_id":"y","prompt":"please ultrawork"}' -echo "$PAYLOAD" | python3 hooks/ultrawork-detector.py | head -3 +npm run build +echo "$PAYLOAD" | node dist/cli.js hook user-prompt-submit | head -3 ``` Expect `` ... directive body. diff --git a/packages/omo-codex/plugin/components/ultrawork/biome.json b/packages/omo-codex/plugin/components/ultrawork/biome.json new file mode 100644 index 000000000..5aa1a0dcc --- /dev/null +++ b/packages/omo-codex/plugin/components/ultrawork/biome.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { + "noDefaultExport": "error", + "noEnum": "error", + "noNonNullAssertion": "error", + "useImportType": "error", + "useConst": "error", + "useNodejsImportProtocol": "off" + }, + "complexity": { + "useLiteralKeys": "off" + }, + "suspicious": { + "noExplicitAny": "error", + "noTsIgnore": "error", + "noControlCharactersInRegex": "off", + "noEmptyInterface": "off" + } + } + }, + "formatter": { + "enabled": true, + "formatWithErrors": false, + "indentStyle": "tab", + "indentWidth": 3, + "lineWidth": 120 + }, + "files": { + "includes": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts", "!**/node_modules/**/*", "!**/dist/**/*"] + }, + "overrides": [ + { + "includes": ["vitest.config.ts"], + "linter": { + "rules": { + "style": { + "noDefaultExport": "off" + } + } + } + } + ] +} diff --git a/packages/omo-codex/plugin/components/ultrawork/hooks/ultrawork-detector.py b/packages/omo-codex/plugin/components/ultrawork/directive.md old mode 100755 new mode 100644 similarity index 85% rename from packages/omo-codex/plugin/components/ultrawork/hooks/ultrawork-detector.py rename to packages/omo-codex/plugin/components/ultrawork/directive.md index 585c50b5c..8945f2d8c --- a/packages/omo-codex/plugin/components/ultrawork/hooks/ultrawork-detector.py +++ b/packages/omo-codex/plugin/components/ultrawork/directive.md @@ -1,28 +1,4 @@ -#!/usr/bin/env python3 -"""Codex UserPromptSubmit hook: inject ultrawork directive on `ulw`/`ultrawork`. - -Contract (required for codex hooks runtime): - stdin: JSON {cwd, hook_event_name="UserPromptSubmit", model, - permission_mode, prompt, session_id, transcript_path, turn_id} - stdout: when the user prompt matches the ultrawork keyword, the directive - text below; otherwise empty. Non-JSON stdout is treated by codex as - `additional_context` and injected into the model's turn context. - exit: 0 always (this hook never blocks the turn). -""" - -from __future__ import annotations - -import json -import re -import sys -from typing import cast - - -# `\b(?:ultrawork|ulw)\b` — word-bounded match excludes paths and identifiers. -ULTRAWORK_PATTERN = re.compile(r"\b(?:ultrawork|ulw)\b", re.IGNORECASE) - - -ULTRAWORK_DIRECTIVE = """ + **MANDATORY**: First user-visible line this turn MUST be exactly: `ULTRAWORK MODE ENABLED!` @@ -252,42 +228,4 @@ message + present for approval. - After 2 parallel exploration waves yield no new useful facts, stop exploring and act. -""" - - -def _load_payload() -> dict[str, object] | None: - try: - raw = sys.stdin.read() - except (OSError, ValueError): - return None - if not raw.strip(): - return None - try: - parsed = cast(object, json.loads(raw)) - except json.JSONDecodeError: - return None - if not isinstance(parsed, dict): - return None - values = cast(dict[object, object], parsed) - return {str(k): v for k, v in values.items()} - - -def _should_inject(payload: dict[str, object]) -> bool: - if payload.get("hook_event_name") != "UserPromptSubmit": - return False - prompt = payload.get("prompt") - if not isinstance(prompt, str) or not prompt: - return False - return ULTRAWORK_PATTERN.search(prompt) is not None - - -def main() -> None: - payload = _load_payload() - if payload is not None and _should_inject(payload): - _ = sys.stdout.write(ULTRAWORK_DIRECTIVE) - _ = sys.stdout.flush() - sys.exit(0) - - -if __name__ == "__main__": - main() + diff --git a/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json b/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json index af24adb7c..897315d53 100644 --- a/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +++ b/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "python3 \"${PLUGIN_ROOT}/hooks/ultrawork-detector.py\"", + "command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook user-prompt-submit", "timeout": 5 } ] diff --git a/packages/omo-codex/plugin/components/ultrawork/hooks/ultrawork-hooks.test.mjs b/packages/omo-codex/plugin/components/ultrawork/hooks/ultrawork-hooks.test.mjs deleted file mode 100644 index dc3328915..000000000 --- a/packages/omo-codex/plugin/components/ultrawork/hooks/ultrawork-hooks.test.mjs +++ /dev/null @@ -1,169 +0,0 @@ -import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; -import { readFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import test from "node:test"; - -const hookDir = dirname(fileURLToPath(import.meta.url)); -const pluginRoot = dirname(hookDir); -const detectorPath = join(hookDir, "ultrawork-detector.py"); - -async function runPython(scriptPath, input, env = {}) { - return new Promise((resolve, reject) => { - const child = spawn("python3", [scriptPath], { - env: { - ...process.env, - ...env, - }, - }); - let stdout = ""; - let stderr = ""; - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - child.stdout.on("data", (chunk) => { - stdout += chunk; - }); - child.stderr.on("data", (chunk) => { - stderr += chunk; - }); - child.once("error", reject); - child.once("close", (code, signal) => { - resolve({ code, signal, stdout, stderr }); - }); - child.stdin.end(input); - }); -} - -test("#given ultrawork prompt #when detector runs #then emits directive", async () => { - const payload = JSON.stringify({ - hook_event_name: "UserPromptSubmit", - prompt: "please ulw this change", - }); - - const result = await runPython(detectorPath, payload); - - assert.equal(result.code, 0); - assert.equal(result.signal, null); - assert.equal(result.stderr, ""); - assert.match(result.stdout, /^/); - assert.match(result.stdout, /First user-visible line this turn MUST be exactly:/); -}); - -test("#given ultrawork prompt #when detector runs #then directive keeps goal budget unlimited", async () => { - const payload = JSON.stringify({ - hook_event_name: "UserPromptSubmit", - prompt: "please ultrawork this change", - }); - - const result = await runPython(detectorPath, payload); - - assert.equal(result.code, 0); - assert.equal(result.signal, null); - assert.equal(result.stderr, ""); - assert.match(result.stdout, /Goals are\s+unlimited/); - assert.match(result.stdout, /exactly `objective` and `status` fields/); - assert.doesNotMatch(result.stdout, /token[_-]?budget/i); - assert.doesNotMatch(result.stdout, /200000/i); -}); - -test("#given ultrawork prompt #when detector runs #then directive mandates manual-QA-as-scenario for http/tmux/computer-use", async () => { - const payload = JSON.stringify({ - hook_event_name: "UserPromptSubmit", - prompt: "please ultrawork", - }); - - const result = await runPython(detectorPath, payload); - - assert.equal(result.code, 0); - assert.equal(result.stderr, ""); - assert.match(result.stdout, /SURFACE-AS-SCENARIO/); - assert.match(result.stdout, /MANUAL QA \u2014 YOU EXECUTE IT, NO STUBS/); - assert.match(result.stdout, /curl -i/); - assert.match(result.stdout, /tmux new-session/); -}); - -test("#given ultrawork prompt #when detector runs #then directive enumerates 4 manual-QA channels explicitly", async () => { - const payload = JSON.stringify({ - hook_event_name: "UserPromptSubmit", - prompt: "please ultrawork", - }); - - const result = await runPython(detectorPath, payload); - - assert.equal(result.code, 0); - assert.equal(result.stderr, ""); - assert.match(result.stdout, /# Manual-QA channels/); - assert.match(result.stdout, /PICK ONE PER CRITERION \u2014 ACTUALLY RUN IT/); - assert.match(result.stdout, /1\. HTTP call/); - assert.match(result.stdout, /2\. tmux/); - assert.match(result.stdout, /3\. Browser use/); - assert.match(result.stdout, /4\. Computer use/); -}); - -test("#given ultrawork prompt #when detector runs #then directive forbids tests-alone verification", async () => { - const payload = JSON.stringify({ - hook_event_name: "UserPromptSubmit", - prompt: "please ultrawork", - }); - - const result = await runPython(detectorPath, payload); - - assert.equal(result.code, 0); - assert.equal(result.stderr, ""); - assert.match(result.stdout, /TESTS ALONE NEVER PROVE DONE/); - assert.match(result.stdout, /Every[\s\n]+criterion needs its own real-usage scenario/); - assert.match(result.stdout, /every time/); -}); - -test("#given ultrawork prompt #when detector runs #then directive mandates paired cleanup with receipt and leftover-state stop rule", async () => { - const payload = JSON.stringify({ - hook_event_name: "UserPromptSubmit", - prompt: "please ultrawork", - }); - - const result = await runPython(detectorPath, payload); - - assert.equal(result.code, 0); - assert.equal(result.stderr, ""); - assert.match(result.stdout, /CLEANUP \(PAIRED \u2014 NEVER SKIP\)/); - assert.match(result.stdout, /cleanup receipt/); - assert.match(result.stdout, /tmux kill-session/); - assert.match(result.stdout, /Leftover state from QA/); - assert.match(result.stdout, /means NOT done/); -}); - -test("#given identifier-like ulw #when detector runs #then does not emit directive", async () => { - const payload = JSON.stringify({ - hook_event_name: "UserPromptSubmit", - prompt: "refactor ulw_helper.ts", - }); - - const result = await runPython(detectorPath, payload); - - assert.equal(result.code, 0); - assert.equal(result.signal, null); - assert.equal(result.stdout, ""); - assert.equal(result.stderr, ""); -}); - -test("#given hook manifest #when read #then registers only the prompt detector hook", async () => { - const manifest = JSON.parse(await readFile(join(hookDir, "hooks.json"), "utf8")); - - assert.match( - manifest.hooks.UserPromptSubmit[0].hooks[0].command, - /ultrawork-detector\.py/, - ); - assert.equal(manifest.hooks.SessionStart, undefined); - assert.equal(pluginRoot.endsWith("components/ultrawork"), true); -}); - -test("#given component package #when inspected #then plugin identity is owned by aggregate root", async () => { - const pkg = JSON.parse(await readFile(join(pluginRoot, "package.json"), "utf8")); - - assert.equal(pkg.files.includes(".codex-plugin"), false); - await assert.rejects( - readFile(join(pluginRoot, ".codex-plugin", "plugin.json"), "utf8"), - /code: 'ENOENT'|ENOENT/, - ); -}); diff --git a/packages/omo-codex/plugin/components/ultrawork/package.json b/packages/omo-codex/plugin/components/ultrawork/package.json index 501548f86..630b1fab5 100644 --- a/packages/omo-codex/plugin/components/ultrawork/package.json +++ b/packages/omo-codex/plugin/components/ultrawork/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "description": "Codex plugin that injects the ultrawork orchestration directive and syncs the ultrawork reviewer agent role.", "type": "module", + "packageManager": "npm@11.12.1", "license": "MIT", "homepage": "https://github.com/code-yeongyu/codex-ultrawork", "repository": { @@ -20,15 +21,34 @@ "hooks", "orchestration" ], + "bin": { + "codex-ultrawork": "./dist/cli.js" + }, "scripts": { - "test": "node --test hooks/*.test.mjs" + "build": "tsc -p tsconfig.build.json", + "test": "vitest --run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "check": "tsc --noEmit && biome check . && npm run build" }, "files": [ "agents", - "hooks/hooks.json", - "hooks/ultrawork-detector.py", + "dist", + "directive.md", + "hooks", "README.md", "LICENSE", "NOTICE" - ] + ], + "devDependencies": { + "@biomejs/biome": "2.4.15", + "@types/node": "^25.7.0", + "typescript": "^6.0.3", + "vitest": "^4.1.5" + }, + "engines": { + "node": ">=20.0.0" + } } diff --git a/packages/omo-codex/plugin/components/ultrawork/src/cli.ts b/packages/omo-codex/plugin/components/ultrawork/src/cli.ts new file mode 100644 index 000000000..abc090c59 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultrawork/src/cli.ts @@ -0,0 +1,50 @@ +#!/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: codex-ultrawork hook user-prompt-submit\n"); + process.exitCode = 1; +} + +async function runHookCli(): Promise { + 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: string): unknown | undefined { + try { + const parsed: unknown = JSON.parse(raw); + return parsed; + } catch (error) { + if (error instanceof SyntaxError) return undefined; + throw error; + } +} + +function readStdin(): Promise { + return new Promise((resolve) => { + let data = ""; + processStdin.setEncoding("utf8"); + processStdin.on("data", (chunk: string) => { + data += chunk; + }); + processStdin.once("error", () => { + resolve(data); + }); + processStdin.once("end", () => { + resolve(data); + }); + }); +} diff --git a/packages/omo-codex/plugin/components/ultrawork/src/codex-hook.ts b/packages/omo-codex/plugin/components/ultrawork/src/codex-hook.ts new file mode 100644 index 000000000..5be9a3c5b --- /dev/null +++ b/packages/omo-codex/plugin/components/ultrawork/src/codex-hook.ts @@ -0,0 +1,25 @@ +import { ULTRAWORK_DIRECTIVE } from "./directive.js"; + +const ULTRAWORK_PATTERN = /\b(?:ultrawork|ulw)\b/i; + +export type CodexUserPromptSubmitInput = { + readonly hook_event_name: "UserPromptSubmit"; + readonly prompt: string; +}; + +export function runUserPromptSubmitHook(input: unknown): string { + if (!isCodexUserPromptSubmitInput(input)) return ""; + return isUltraworkPrompt(input.prompt) ? ULTRAWORK_DIRECTIVE : ""; +} + +export function isUltraworkPrompt(prompt: string): boolean { + return ULTRAWORK_PATTERN.test(prompt); +} + +function isCodexUserPromptSubmitInput(value: unknown): value is CodexUserPromptSubmitInput { + return isRecord(value) && value["hook_event_name"] === "UserPromptSubmit" && typeof value["prompt"] === "string"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/omo-codex/plugin/components/ultrawork/src/directive.ts b/packages/omo-codex/plugin/components/ultrawork/src/directive.ts new file mode 100644 index 000000000..faf60ae6a --- /dev/null +++ b/packages/omo-codex/plugin/components/ultrawork/src/directive.ts @@ -0,0 +1,3 @@ +import { readFileSync } from "node:fs"; + +export const ULTRAWORK_DIRECTIVE: string = readFileSync(new URL("../directive.md", import.meta.url), "utf8"); diff --git a/packages/omo-codex/plugin/components/ultrawork/test/codex-hook.test.ts b/packages/omo-codex/plugin/components/ultrawork/test/codex-hook.test.ts new file mode 100644 index 000000000..5d0d0b6fe --- /dev/null +++ b/packages/omo-codex/plugin/components/ultrawork/test/codex-hook.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; + +import { isUltraworkPrompt, runUserPromptSubmitHook } from "../src/codex-hook.js"; + +describe("codex ultrawork hook", () => { + it("#given ultrawork prompt #when hook runs #then emits directive", () => { + // given + const payload = { + hook_event_name: "UserPromptSubmit", + prompt: "please ulw this change", + }; + + // when + const output = runUserPromptSubmitHook(payload); + + // then + expect(output).toMatch(/^/); + expect(output).toMatch(/First user-visible line this turn MUST be exactly:/); + }); + + it("#given identifier-like ulw #when hook runs #then does not emit directive", () => { + // given + const payload = { + hook_event_name: "UserPromptSubmit", + prompt: "refactor ulw_helper.ts", + }; + + // when + const output = runUserPromptSubmitHook(payload); + + // then + expect(output).toBe(""); + expect(isUltraworkPrompt("ulw_helper.ts")).toBe(false); + }); + + it("#given malformed or empty input #when hook runs #then exits with empty output", () => { + // given + const inputs = [undefined, {}, { hook_event_name: "UserPromptSubmit", prompt: "" }] as const; + + // when + const outputs = inputs.map((input) => runUserPromptSubmitHook(input)); + + // then + expect(outputs).toEqual(["", "", ""]); + }); + + it("#given directive #when inspected #then keeps manual QA and cleanup invariants", () => { + // given + const payload = { + hook_event_name: "UserPromptSubmit", + prompt: "please ultrawork", + }; + + // when + const output = runUserPromptSubmitHook(payload); + + // then + expect(output).toMatch(/# Manual-QA channels/); + expect(output).toMatch(/TESTS ALONE NEVER PROVE DONE/); + expect(output).toMatch(/1\. HTTP call/); + expect(output).toMatch(/2\. tmux/); + expect(output).toMatch(/3\. Browser use/); + expect(output).toMatch(/4\. Computer use/); + expect(output).toMatch(/CLEANUP \(PAIRED/); + }); +}); diff --git a/packages/omo-codex/plugin/components/ultrawork/test/package-smoke.test.ts b/packages/omo-codex/plugin/components/ultrawork/test/package-smoke.test.ts new file mode 100644 index 000000000..9abf9ec17 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultrawork/test/package-smoke.test.ts @@ -0,0 +1,78 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +type PackageJson = { + readonly type: string; + readonly packageManager: string; + readonly bin: Record; + readonly files: readonly string[]; + readonly scripts: Record; +}; + +describe("codex ultrawork package metadata", () => { + it("#given package metadata #when inspected #then hook ships as built TypeScript", () => { + // given + const packageJson = readPackageJson("package.json"); + const hooksJson = readJson("hooks/hooks.json"); + const cliSource = readFileSync("src/cli.ts", "utf8"); + + // when + const packageFiles = packageJson.files; + const hookCommands = collectHookCommandsFromValue(hooksJson); + const pluginRoot = ["$", "{PLUGIN_ROOT}"].join(""); + + // then + expect(packageJson.type).toBe("module"); + expect(packageJson.packageManager).toBe("npm@11.12.1"); + expect(packageJson.bin["codex-ultrawork"]).toBe("./dist/cli.js"); + expect(packageJson.scripts["build"]).toBe("tsc -p tsconfig.build.json"); + expect(packageJson.scripts["test"]).toBe("vitest --run"); + expect(packageFiles).toContain("dist"); + expect(packageFiles).toContain("directive.md"); + expect(packageFiles).not.toContain("hooks/ultrawork-detector.py"); + expect(cliSource.startsWith("#!/usr/bin/env node")).toBe(true); + expect(hookCommands).toContain(`node "${pluginRoot}/dist/cli.js" hook user-prompt-submit`); + expect(hookCommands).not.toContainEqual(expect.stringMatching(/\bpython3?\b|ultrawork-detector\.py/)); + }); +}); + +function readJson(path: string): unknown { + return JSON.parse(readFileSync(path, "utf8")); +} + +function readPackageJson(path: string): PackageJson { + const parsed = readJson(path); + if (!isPackageJson(parsed)) throw new TypeError(`Invalid package metadata: ${path}`); + return parsed; +} + +function collectHookCommandsFromValue(value: unknown): readonly string[] { + if (typeof value === "string") return []; + if (Array.isArray(value)) return value.flatMap(collectHookCommandsFromValue); + if (!isRecord(value)) return []; + const ownCommand = typeof value["command"] === "string" ? [value["command"]] : []; + return [...ownCommand, ...Object.values(value).flatMap(collectHookCommandsFromValue)]; +} + +function isPackageJson(value: unknown): value is PackageJson { + return ( + isRecord(value) && + value["type"] === "module" && + value["packageManager"] === "npm@11.12.1" && + isStringRecord(value["bin"]) && + isStringArray(value["files"]) && + isStringRecord(value["scripts"]) + ); +} + +function isStringArray(value: unknown): value is readonly string[] { + return Array.isArray(value) && value.every((item) => typeof item === "string"); +} + +function isStringRecord(value: unknown): value is Record { + return isRecord(value) && Object.values(value).every((item) => typeof item === "string"); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/omo-codex/plugin/components/ultrawork/tsconfig.build.json b/packages/omo-codex/plugin/components/ultrawork/tsconfig.build.json new file mode 100644 index 000000000..5b5bbcafd --- /dev/null +++ b/packages/omo-codex/plugin/components/ultrawork/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": false, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "noEmit": false + }, + "include": ["src/**/*"], + "exclude": ["test/**/*"] +} diff --git a/packages/omo-codex/plugin/components/ultrawork/tsconfig.json b/packages/omo-codex/plugin/components/ultrawork/tsconfig.json new file mode 100644 index 000000000..342229c02 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultrawork/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "strict": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "noPropertyAccessFromIndexSignature": true, + "verbatimModuleSyntax": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "useDefineForClassFields": false, + "types": ["node"], + "noEmit": true + }, + "include": ["src/**/*", "test/**/*"] +} diff --git a/packages/omo-codex/plugin/hooks/hooks.json b/packages/omo-codex/plugin/hooks/hooks.json index cbef5941b..2996fe07f 100644 --- a/packages/omo-codex/plugin/hooks/hooks.json +++ b/packages/omo-codex/plugin/hooks/hooks.json @@ -36,7 +36,7 @@ "hooks": [ { "type": "command", - "command": "python3 \"${PLUGIN_ROOT}/components/ultrawork/hooks/ultrawork-detector.py\"", + "command": "node \"${PLUGIN_ROOT}/components/ultrawork/dist/cli.js\" hook user-prompt-submit", "timeout": 5 } ] diff --git a/packages/omo-codex/plugin/test/aggregate.test.mjs b/packages/omo-codex/plugin/test/aggregate.test.mjs index 504b78a4d..5f9e1787c 100644 --- a/packages/omo-codex/plugin/test/aggregate.test.mjs +++ b/packages/omo-codex/plugin/test/aggregate.test.mjs @@ -47,7 +47,7 @@ test("#given isolated components #when hooks are inspected #then commands stay i "components/rules/dist/cli.js", "components/telemetry/dist/cli.js", "components/ultragoal/dist/cli.js", - "components/ultrawork/hooks/ultrawork-detector.py", + "components/ultrawork/dist/cli.js", ]; // then diff --git a/packages/omo-codex/src/python-migration-cross-platform.test.ts b/packages/omo-codex/src/python-migration-cross-platform.test.ts new file mode 100644 index 000000000..b39b9bc97 --- /dev/null +++ b/packages/omo-codex/src/python-migration-cross-platform.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" + +import { runUserPromptSubmitHook } from "../plugin/components/ultrawork/src/codex-hook" + +const repoRoot = join(import.meta.dir, "..", "..", "..") + +describe("omo-codex Python migration cross-platform behavior", () => { + it("handles empty inventory, malformed input, and Windows paths without Python", () => { + // given + const aggregateHooks = readJson(join(repoRoot, "packages/omo-codex/plugin/hooks/hooks.json")) + const componentHooks = readJson(join(repoRoot, "packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json")) + const hookCommands = collectHookCommands([aggregateHooks, componentHooks]) + + // when + const outputs = [ + runUserPromptSubmitHook(undefined), + runUserPromptSubmitHook({ hook_event_name: "UserPromptSubmit", prompt: "" }), + runUserPromptSubmitHook({ hook_event_name: "UserPromptSubmit", prompt: "refactor ulw_helper.ts" }), + runUserPromptSubmitHook({ + cwd: "C:\\Users\\codex\\project", + hook_event_name: "UserPromptSubmit", + model: "gpt-5.5", + permission_mode: "default", + prompt: "please ulw this", + session_id: "s", + transcript_path: null, + turn_id: "t", + }), + ] + + // then + expect(hookCommands).not.toContainEqual(expect.stringMatching(/\bpython3?\b/i)) + expect(hookCommands).toContain('node "${PLUGIN_ROOT}/components/ultrawork/dist/cli.js" hook user-prompt-submit') + expect(hookCommands).toContain('node "${PLUGIN_ROOT}/dist/cli.js" hook user-prompt-submit') + expect(outputs[0]).toBe("") + expect(outputs[1]).toBe("") + expect(outputs[2]).toBe("") + expect(outputs[3]).toStartWith("") + }) +}) + +function readJson(path: string): unknown { + return JSON.parse(readFileSync(path, "utf8")) +} + +function collectHookCommands(values: readonly unknown[]): readonly string[] { + return values.flatMap(collectHookCommandsFromValue) +} + +function collectHookCommandsFromValue(value: unknown): readonly string[] { + if (typeof value === "string") return [] + if (Array.isArray(value)) return value.flatMap(collectHookCommandsFromValue) + if (!isRecord(value)) return [] + const ownCommand = typeof value["command"] === "string" ? [value["command"]] : [] + return [...ownCommand, ...Object.values(value).flatMap(collectHookCommandsFromValue)] +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/packages/omo-codex/src/python-migration-inventory.test.ts b/packages/omo-codex/src/python-migration-inventory.test.ts new file mode 100644 index 000000000..353938225 --- /dev/null +++ b/packages/omo-codex/src/python-migration-inventory.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "bun:test" +import { readdirSync, statSync } from "node:fs" +import { join, relative, sep } from "node:path" + +const repoRoot = join(import.meta.dir, "..", "..", "..") +const packageRoot = join(repoRoot, "packages", "omo-codex") + +const retainedPythonFiles = [ + "packages/omo-codex/plugin/components/lsp/test/fixtures/broken.py", + "packages/omo-codex/plugin/skills/programming/scripts/go/new-project.py", + "packages/omo-codex/plugin/skills/programming/scripts/python/check-no-excuse-rules.py", + "packages/omo-codex/plugin/skills/programming/scripts/python/new-project.py", + "packages/omo-codex/plugin/skills/programming/scripts/python/new-script.py", + "packages/omo-codex/plugin/skills/programming/scripts/rust/check-no-excuse-rules.py", + "packages/omo-codex/plugin/skills/programming/scripts/rust/new-project.py", +] as const +const retainedPythonFileSet = new Set(retainedPythonFiles) + +describe("omo-codex Python migration inventory", () => { + it("classifies every Python file under packages/omo-codex", () => { + // given + const pythonFiles = listPythonFiles(packageRoot) + + // when + const unclassified = pythonFiles.filter((path) => !retainedPythonFileSet.has(path)) + + // then + expect(unclassified).toEqual([]) + expect(pythonFiles).toEqual([...retainedPythonFiles].sort()) + }) +}) + +function listPythonFiles(root: string): readonly string[] { + const files: string[] = [] + collectPythonFiles(root, files) + return files.sort() +} + +function collectPythonFiles(directory: string, files: string[]): void { + for (const entry of readdirSync(directory)) { + if (entry === "node_modules" || entry === "dist") continue + + const absolutePath = join(directory, entry) + const stats = statSync(absolutePath) + if (stats.isDirectory()) { + collectPythonFiles(absolutePath, files) + continue + } + + if (entry.endsWith(".py") || entry.endsWith(".pyi")) { + files.push(relative(repoRoot, absolutePath).split(sep).join("/")) + } + } +} diff --git a/packages/omo-codex/src/python-migration-regression.test.ts b/packages/omo-codex/src/python-migration-regression.test.ts new file mode 100644 index 000000000..ad0780aa2 --- /dev/null +++ b/packages/omo-codex/src/python-migration-regression.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" + +const repoRoot = join(import.meta.dir, "..", "..", "..") +const ultraworkRoot = join(repoRoot, "packages/omo-codex/plugin/components/ultrawork") + +describe("omo-codex Python migration regression", () => { + it("keeps package scripts and plugin packaging Python-free", () => { + // given + const componentPackage = readJson(join(ultraworkRoot, "package.json")) + const aggregateHooks = readFileSync(join(repoRoot, "packages/omo-codex/plugin/hooks/hooks.json"), "utf8") + const componentHooks = readFileSync(join(ultraworkRoot, "hooks/hooks.json"), "utf8") + const aggregateTest = readFileSync(join(repoRoot, "packages/omo-codex/plugin/test/aggregate.test.mjs"), "utf8") + + // when + const packagedFiles = isRecord(componentPackage) && Array.isArray(componentPackage["files"]) + ? componentPackage["files"] + : [] + const scripts = isRecord(componentPackage) && isRecord(componentPackage["scripts"]) ? componentPackage["scripts"] : {} + const bin = isRecord(componentPackage) && isRecord(componentPackage["bin"]) ? componentPackage["bin"] : {} + + // then + expect(scripts["build"]).toBe("tsc -p tsconfig.build.json") + expect(scripts["test"]).toBe("vitest --run") + expect(bin["codex-ultrawork"]).toBe("./dist/cli.js") + expect(packagedFiles).toContain("dist") + expect(packagedFiles).not.toContain("hooks/ultrawork-detector.py") + expect(`${aggregateHooks}\n${componentHooks}\n${aggregateTest}`).not.toMatch(/\bpython3?\b|ultrawork-detector\.py/) + }) +}) + +function readJson(path: string): unknown { + return JSON.parse(readFileSync(path, "utf8")) +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +}