diff --git a/packages/omo-codex/MARKETPLACE.md b/packages/omo-codex/MARKETPLACE.md new file mode 100644 index 000000000..813929fa6 --- /dev/null +++ b/packages/omo-codex/MARKETPLACE.md @@ -0,0 +1,29 @@ +# codex-plugins + +Local marketplace for the `omo` Codex plugin components ported from `../pi-extensions`. + +## Plugin + +`omo` is one Codex plugin namespace with isolated internal components: + +- `components/comment-checker`: runs comment-checker automatically after successful `apply_patch` edits. +- `components/rules`: injects local project rule files into Codex context through lifecycle hooks. +- `components/lsp`: exposes Language Server Protocol diagnostics, navigation, symbols, and rename tools through MCP and post-edit hooks. +- `components/ultrawork`: injects the ultrawork orchestration directive when a user prompt contains `ultrawork` or `ulw`. +- `components/ultragoal`: durable repo-native multi-goal orchestration with embedded success criteria and observable evidence audit (`.omo/ultragoal/`). + +## Local Install + +```bash +codex plugin marketplace add /Users/yeongyu/local-workspaces/codex-plugins +node /Users/yeongyu/local-workspaces/codex-plugins/scripts/install-local.mjs /Users/yeongyu/local-workspaces/codex-plugins +``` + +The installer builds `omo`, copies a clean versioned cache entry into `~/.codex/plugins/cache/code-yeongyu-codex-plugins/omo`, installs runtime dependencies in the cache, prunes stale split-plugin cache/config entries, and enables `[plugins."omo@code-yeongyu-codex-plugins"]` in `~/.codex/config.toml`. +It also enables both `plugins = true` and `plugin_hooks = true` under `[features]` so bundled hook files run. + +If your local Codex build exposes plugin install commands, you can use those instead. For older local builds, the installer replaces the manual copy fallback: + +```text +~/.codex/plugins/cache/code-yeongyu-codex-plugins/omo/0.1.0 +``` diff --git a/packages/omo-codex/marketplace.json b/packages/omo-codex/marketplace.json new file mode 100644 index 000000000..4ceaf3d17 --- /dev/null +++ b/packages/omo-codex/marketplace.json @@ -0,0 +1,17 @@ +{ + "name": "code-yeongyu-codex-plugins", + "interface": { + "displayName": "Yeongyu Codex Plugins" + }, + "plugins": [ + { + "name": "omo", + "source": "./plugins/omo", + "category": "Developer Tools", + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + } + } + ] +} diff --git a/packages/omo-codex/plugin/.codex-plugin/plugin.json b/packages/omo-codex/plugin/.codex-plugin/plugin.json new file mode 100644 index 000000000..660b84678 --- /dev/null +++ b/packages/omo-codex/plugin/.codex-plugin/plugin.json @@ -0,0 +1,35 @@ +{ + "name": "omo", + "version": "0.1.0", + "description": "One Codex plugin namespace for Yeongyu's local Codex components.", + "author": { + "name": "Yeongyu Kim", + "email": "yeongyu@users.noreply.github.com", + "url": "https://github.com/code-yeongyu" + }, + "homepage": "https://github.com/code-yeongyu/omo", + "repository": "https://github.com/code-yeongyu/omo", + "license": "MIT", + "keywords": ["codex", "codex-plugin", "omo", "hooks", "mcp", "skills"], + "skills": "./skills/", + "hooks": "./hooks/hooks.json", + "mcpServers": "./.mcp.json", + "interface": { + "displayName": "OMO", + "shortDescription": "Unified local Codex components", + "longDescription": "OMO exposes the local Codex Rules, Comment Checker, LSP, Ultrawork, and Ultragoal components as one plugin namespace while keeping each component isolated under components/ for maintenance.", + "developerName": "Yeongyu Kim", + "category": "Developer Tools", + "capabilities": ["Hooks", "MCP Tools", "Code Intelligence", "Workflow", "Context Injection"], + "websiteURL": "https://github.com/code-yeongyu/omo", + "privacyPolicyURL": "https://github.com/code-yeongyu/omo#privacy", + "termsOfServiceURL": "https://github.com/code-yeongyu/omo#license", + "defaultPrompt": [ + "Use OMO LSP diagnostics on this workspace.", + "Show which OMO rules matched this file.", + "ulw: run this change with evidence." + ], + "brandColor": "#7C3AED", + "screenshots": [] + } +} diff --git a/packages/omo-codex/plugin/.mcp.json b/packages/omo-codex/plugin/.mcp.json new file mode 100644 index 000000000..b36e4a79b --- /dev/null +++ b/packages/omo-codex/plugin/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "lsp": { + "command": "node", + "args": ["./components/lsp/packages/lsp-tools-mcp/dist/cli.js", "mcp"], + "cwd": "." + } + } +} diff --git a/packages/omo-codex/plugin/README.md b/packages/omo-codex/plugin/README.md new file mode 100644 index 000000000..a5a9bdc73 --- /dev/null +++ b/packages/omo-codex/plugin/README.md @@ -0,0 +1,13 @@ +# omo + +`omo` is the single local Codex plugin namespace for Yeongyu's Codex components. + +Internally each component remains isolated under `components/`: + +- `components/comment-checker` +- `components/rules` +- `components/lsp` +- `components/ultrawork` +- `components/ultragoal` + +The root plugin manifest exports one Codex plugin named `omo`, with aggregate hooks, skills, and the LSP MCP server. diff --git a/packages/omo-codex/plugin/components/comment-checker/.gitattributes b/packages/omo-codex/plugin/components/comment-checker/.gitattributes new file mode 100644 index 000000000..9363fdb74 --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/.gitattributes @@ -0,0 +1,13 @@ +# Normalize line endings: store LF in git, check out LF on every platform. +# Required so biome's --check passes on Windows (default core.autocrlf=true). +* text=auto eol=lf + +# Explicit binary types +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.zip binary +*.tgz binary +*.gz binary diff --git a/packages/omo-codex/plugin/components/comment-checker/.github/CODEOWNERS b/packages/omo-codex/plugin/components/comment-checker/.github/CODEOWNERS new file mode 100644 index 000000000..ef9dbe9d5 --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/.github/CODEOWNERS @@ -0,0 +1,12 @@ +* @code-yeongyu + +.github/workflows/* @code-yeongyu +.github/dependabot.yml @code-yeongyu +package.json @code-yeongyu +package-lock.json @code-yeongyu +LICENSE @code-yeongyu +NOTICE @code-yeongyu +README.md @code-yeongyu +CHANGELOG.md @code-yeongyu +.codex-plugin/plugin.json @code-yeongyu +hooks/hooks.json @code-yeongyu diff --git a/packages/omo-codex/plugin/components/comment-checker/.github/ISSUE_TEMPLATE/bug.yml b/packages/omo-codex/plugin/components/comment-checker/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 000000000..c6c32ba1b --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,40 @@ +name: Bug Report +description: Report broken Codex hook, MCP, or comment-checking behavior +labels: [bug] +body: + - type: markdown + attributes: + value: | + Include the Codex tool payload, hook output, and plugin version needed to reproduce. + + - type: textarea + id: what + attributes: + label: What happened? + description: Include exact output/errors. + validations: + required: true + + - type: textarea + id: payload + attributes: + label: Tool payload + description: Paste the minimal PostToolUse or MCP payload that reproduces the issue. + render: json + validations: + required: false + + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + + - type: input + id: version + attributes: + label: codex-comment-checker version + placeholder: 0.1.0 + validations: + required: false diff --git a/packages/omo-codex/plugin/components/comment-checker/.github/ISSUE_TEMPLATE/feature.yml b/packages/omo-codex/plugin/components/comment-checker/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 000000000..817e49c99 --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,27 @@ +name: Feature Request +description: Propose a Codex comment-checker hook or MCP improvement +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What workflow is blocked or awkward today? + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposal + description: What should codex-comment-checker do? + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: What else could solve this? + validations: + required: false diff --git a/packages/omo-codex/plugin/components/comment-checker/.github/branch-ruleset.json b/packages/omo-codex/plugin/components/comment-checker/.github/branch-ruleset.json new file mode 100644 index 000000000..1f482bb66 --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/.github/branch-ruleset.json @@ -0,0 +1,45 @@ +{ + "name": "main protection", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["~DEFAULT_BRANCH"], + "exclude": [] + } + }, + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { "type": "required_linear_history" }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": true, + "require_last_push_approval": false, + "required_review_thread_resolution": true + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": true, + "required_status_checks": [ + { "context": "test (ubuntu-latest · node 20)" }, + { "context": "test (ubuntu-latest · node 22)" }, + { "context": "test (macos-latest · node 20)" }, + { "context": "test (macos-latest · node 22)" } + ] + } + } + ], + "bypass_actors": [ + { + "actor_id": 5, + "actor_type": "RepositoryRole", + "bypass_mode": "always" + } + ] +} diff --git a/packages/omo-codex/plugin/components/comment-checker/.github/dependabot.yml b/packages/omo-codex/plugin/components/comment-checker/.github/dependabot.yml new file mode 100644 index 000000000..1941ade14 --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + dev-dependencies: + dependency-type: development + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/packages/omo-codex/plugin/components/comment-checker/.github/pull_request_template.md b/packages/omo-codex/plugin/components/comment-checker/.github/pull_request_template.md new file mode 100644 index 000000000..9453fc8e7 --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/.github/pull_request_template.md @@ -0,0 +1,19 @@ +## Summary + + + +- + +## Verification + +- [ ] `npm run check` (typecheck + biome + build) +- [ ] `npm test` (unit tests) +- [ ] `npm pack --dry-run` (release sanity) +- [ ] Hook smoke-tested locally with `node dist/cli.js hook post-tool-use` + +## Codex plugin impact + +- [ ] `.codex-plugin/plugin.json` remains valid +- [ ] `hooks/hooks.json` still uses stable Codex hook JSON +- [ ] No MCP server or MCP tool is exposed +- [ ] CHANGELOG entry added for user-facing changes diff --git a/packages/omo-codex/plugin/components/comment-checker/.github/workflows/ci.yml b/packages/omo-codex/plugin/components/comment-checker/.github/workflows/ci.yml new file mode 100644 index 000000000..6cc7a1653 --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + name: test (${{ matrix.os }} · node ${{ matrix.node }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + node: ["20", "22"] + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node ${{ matrix.node }} + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Check + run: npm run check + + - name: Unit tests + run: npm test + + - name: Package smoke + run: npm pack --dry-run diff --git a/packages/omo-codex/plugin/components/comment-checker/.github/workflows/publish.yml b/packages/omo-codex/plugin/components/comment-checker/.github/workflows/publish.yml new file mode 100644 index 000000000..4214a99b2 --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/.github/workflows/publish.yml @@ -0,0 +1,51 @@ +name: publish + +on: + release: + types: [published] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node 22 + uses: actions/setup-node@v6 + with: + node-version: "22" + registry-url: https://registry.npmjs.org + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Check + run: npm run check + + - name: Unit tests + run: npm test + + - name: Package smoke + run: npm pack --dry-run + + - name: Publish to npm + run: | + if [ -z "$NODE_AUTH_TOKEN" ]; then + echo "NODE_AUTH_TOKEN is not configured; skipping npm publish." + exit 0 + fi + npm publish --access public --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} diff --git a/packages/omo-codex/plugin/components/comment-checker/.gitignore b/packages/omo-codex/plugin/components/comment-checker/.gitignore new file mode 100644 index 000000000..af7603749 --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +*.log +.DS_Store +.env +.env.* +coverage/ +.vitest/ diff --git a/packages/omo-codex/plugin/components/comment-checker/AGENTS.md b/packages/omo-codex/plugin/components/comment-checker/AGENTS.md new file mode 100644 index 000000000..fc3449ebd --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/AGENTS.md @@ -0,0 +1,35 @@ +# Repository Conventions + +Conventions for human contributors and AI agents working on this repository. + +## Style + +- Terse technical prose. No emojis in commits, issues, PR comments, or code. +- TypeScript strict mode. No `any`, no `unknown` casts where avoidable, no `@ts-ignore`, no `@ts-expect-error`, no enums. +- ESM modules with `.js` suffix in runtime import paths. +- Tabs for indentation. Double quotes for strings. +- Tests use vitest with `#given .. #when .. #then` descriptions or plain `// given / // when / // then` body comments. + +## Commands + +- `npm install` - install dependencies. +- `npm test` - run vitest once. +- `npm run typecheck` - strict TypeScript check. +- `npm run check` - type check, biome, and build. +- `npm pack --dry-run` - release package smoke test. +- `node dist/cli.js hook post-tool-use < fixture.json` - smoke-test the Codex hook. + +## Constraints + +- No Bun APIs. Runtime is Node only because Codex launches plugin hooks with Node. +- Keep Codex `PostToolUse` hook behavior covered by tests. +- Keep `apply_patch` extraction covered by tests. +- `apply_patch` must support Codex `tool_input.command`, raw patch text, and OMO-compatible metadata. +- Hook output must use the stable Codex hook JSON contract. +- Do not expose an MCP server or MCP tool from this plugin. + +## Don'ts + +- No `git add -A` or `git add .`. Stage only the files you changed. +- No `git commit --no-verify`. No force pushes. No history rewriting on shared branches. +- Do not couple this package back to pi, omo, or senpi internal source paths. diff --git a/packages/omo-codex/plugin/components/comment-checker/CHANGELOG.md b/packages/omo-codex/plugin/components/comment-checker/CHANGELOG.md new file mode 100644 index 000000000..c5b91529d --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog + +## Unreleased + +### Added + +- Restore `write`, `edit`, `multi_edit`, and `multiedit` PostToolUse coverage alongside `apply_patch`. +- Forward Codex `transcript_path` into native comment-checker hook input when available. +- Add package smoke coverage for portable hook entrypoints. + +### Changed + +- Treat the native checker binary as an optional dependency for unsupported platforms. +- Cap child process stdout/stderr captured from the native checker. +- Run CI on Windows in addition to Ubuntu and macOS. + +## [0.1.1] - 2026-05-15 + +### Changed + +- Limit automatic comment checking to successful `apply_patch` hook events. +- Remove the `comment_check` MCP tool and MCP server configuration. +- Update plugin metadata, docs, and contributor guidance to describe hook-only behavior. + +## [0.1.0] - 2026-05-15 + +### Added + +- Initial `codex-comment-checker` Codex plugin. +- `PostToolUse` hook for `apply_patch`, `write`, `edit`, and `multiedit` style tool calls. +- Blocking hook feedback when `comment-checker` reports warnings. +- `comment_check` MCP tool for explicit write/edit/multiedit checks. +- Codex plugin manifest, local MCP config, bundled skill, and GitHub repository metadata. diff --git a/packages/omo-codex/plugin/components/comment-checker/LICENSE b/packages/omo-codex/plugin/components/comment-checker/LICENSE new file mode 100644 index 000000000..09aac3c3b --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Yeongyu Kim + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/omo-codex/plugin/components/comment-checker/NOTICE b/packages/omo-codex/plugin/components/comment-checker/NOTICE new file mode 100644 index 000000000..5b363ec20 --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/NOTICE @@ -0,0 +1,6 @@ +codex-comment-checker + +This package ports the pi-comment-checker hook into a Codex plugin repository. + +The plugin targets Codex plugin manifests and plugin-bundled lifecycle hooks. +The checker engine is provided by @code-yeongyu/comment-checker. diff --git a/packages/omo-codex/plugin/components/comment-checker/README.md b/packages/omo-codex/plugin/components/comment-checker/README.md new file mode 100644 index 000000000..f75d813a2 --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/README.md @@ -0,0 +1,90 @@ +# codex-comment-checker + +[![ci](https://github.com/code-yeongyu/codex-comment-checker/actions/workflows/ci.yml/badge.svg)](https://github.com/code-yeongyu/codex-comment-checker/actions/workflows/ci.yml) [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) + +Codex plugin that runs [`@code-yeongyu/comment-checker`](https://github.com/code-yeongyu/go-claude-code-comment-checker) after successful edit-like `PostToolUse` hook calls. + +## Behavior + +| Case | Result | +|------|--------| +| `apply_patch` succeeds | parses `tool_input.command` and checks added/updated files | +| `write`, `edit`, `multi_edit`, or `multiedit` succeeds | maps the Codex payload to the native checker hook input | +| non-edit tool succeeds | ignored | +| checker exits `2` | returns Codex `PostToolUse` blocking feedback so the model fixes or explains the warning | +| checker binary missing or unavailable on the current platform | emits no hook output | +| checker exits unexpectedly | leaves hook output unchanged | + +Deletes are ignored because they cannot introduce new comments. + +## Codex Plugin + +The plugin ships: + +- `.codex-plugin/plugin.json` for Codex plugin discovery. +- `hooks/hooks.json` for the `PostToolUse` hook. +- `skills/comment-checker/SKILL.md` with usage guidance. + +The hook command is: + +```bash +node "${PLUGIN_ROOT}/dist/cli.js" hook post-tool-use +``` + +No MCP server or `comment_check` tool is exposed. + +## Local Development + +```bash +npm install +npm test +npm run typecheck +npm run check +npm pack --dry-run +``` + +Smoke-test the hook: + +```bash +node dist/cli.js hook post-tool-use < test/fixtures/post-tool-use.json +``` + +## Local Codex Installation + +From the marketplace root containing this plugin: + +```bash +codex plugin marketplace add /path/to/codex-plugins +node /path/to/codex-plugins/scripts/install-local.mjs /path/to/codex-plugins +``` + +If your local Codex build exposes plugin install commands, you can install from the UI or CLI instead. For older local builds, the marketplace installer builds and copies the plugin into `~/.codex/plugins/cache//omo/0.1.0`, installs runtime dependencies there, and enables: + +```toml +[features] +plugins = true +plugin_hooks = true + +[plugins."omo@code-yeongyu-codex-plugins"] +enabled = true +``` + +## Branch Rules and Releases + +- `main` is protected by `.github/branch-ruleset.json`. +- CI runs Node 20 and 22 on Ubuntu, macOS, and Windows. +- Releases are GitHub Releases tagged as `v`. +- Publishing runs from the `publish` workflow after a GitHub Release is published. + +## Privacy + +This plugin runs locally. It sends hook input to the optional local `comment-checker` binary when available and does not call a network service by itself. + +## License + +[MIT](LICENSE). + +## Related + +- [pi-comment-checker](https://github.com/code-yeongyu/pi-comment-checker) - source extension this Codex plugin ports. +- [comment-checker](https://github.com/code-yeongyu/go-claude-code-comment-checker) - native checker binary. diff --git a/packages/omo-codex/plugin/components/comment-checker/biome.json b/packages/omo-codex/plugin/components/comment-checker/biome.json new file mode 100644 index 000000000..5aa1a0dcc --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/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/comment-checker/hooks/hooks.json b/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json new file mode 100644 index 000000000..96fc9649a --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "^(apply_patch|write|Write|edit|Edit|multi_edit|multiedit|MultiEdit)$", + "hooks": [ + { + "type": "command", + "command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook post-tool-use", + "timeout": 30, + "statusMessage": "checking comments" + } + ] + } + ] + } +} diff --git a/packages/omo-codex/plugin/components/comment-checker/package.json b/packages/omo-codex/plugin/components/comment-checker/package.json new file mode 100644 index 000000000..44f855e59 --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/package.json @@ -0,0 +1,57 @@ +{ + "name": "@code-yeongyu/codex-comment-checker", + "version": "0.1.1", + "description": "Codex plugin that runs comment-checker after edit-like PostToolUse hooks.", + "type": "module", + "packageManager": "npm@11.12.1", + "license": "MIT", + "homepage": "https://github.com/code-yeongyu/codex-comment-checker", + "repository": { + "type": "git", + "url": "git+https://github.com/code-yeongyu/codex-comment-checker.git" + }, + "bugs": { + "url": "https://github.com/code-yeongyu/codex-comment-checker/issues" + }, + "keywords": [ + "codex", + "codex-plugin", + "comment-checker", + "hooks", + "typescript" + ], + "bin": { + "codex-comment-checker": "./dist/cli.js" + }, + "files": [ + "dist", + "hooks", + "skills", + ".codex-plugin", + "LICENSE", + "NOTICE", + "README.md", + "CHANGELOG.md" + ], + "scripts": { + "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" + }, + "optionalDependencies": { + "@code-yeongyu/comment-checker": "^0.8.0" + }, + "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/comment-checker/skills/comment-checker/SKILL.md b/packages/omo-codex/plugin/components/comment-checker/skills/comment-checker/SKILL.md new file mode 100644 index 000000000..7ce771015 --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/skills/comment-checker/SKILL.md @@ -0,0 +1,16 @@ +--- +name: comment-checker +description: Use when Codex needs to understand or respond to automatic comment-checker feedback emitted after an edit-like PostToolUse hook. +--- + +# Codex Comment Checker + +The plugin registers a `PostToolUse` hook for successful `apply_patch`, `write`, `edit`, `multi_edit`, and `multiedit` calls. + +When comment-checker reports a warning after a patch, Codex receives blocking feedback and should fix or explain the flagged comment before moving on. + +## Scope + +- No MCP tool is exposed. +- Non-edit tools are ignored by this plugin. +- Missing checker binaries emit no hook output so normal Codex work can continue. diff --git a/packages/omo-codex/plugin/components/comment-checker/src/cli.ts b/packages/omo-codex/plugin/components/comment-checker/src/cli.ts new file mode 100644 index 000000000..ca8991828 --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/src/cli.ts @@ -0,0 +1,12 @@ +#!/usr/bin/env node + +import { runCodexHookCli } from "./codex-hook.js"; + +const [command, subcommand] = process.argv.slice(2); + +if (command === "hook" && subcommand === "post-tool-use") { + await runCodexHookCli(); +} else { + process.stderr.write("Usage: codex-comment-checker hook post-tool-use\n"); + process.exitCode = 2; +} diff --git a/packages/omo-codex/plugin/components/comment-checker/src/codex-hook.ts b/packages/omo-codex/plugin/components/comment-checker/src/codex-hook.ts new file mode 100644 index 000000000..ae76cea54 --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/src/codex-hook.ts @@ -0,0 +1,159 @@ +import { stdin as processStdin, stdout as processStdout } from "node:process"; + +import { + type CommentCheckRequest, + extractCommentCheckRequests, + isRecord, + type ToolResultContent, + type ToolResultLike, + toHookInput, +} from "./core.js"; +import { type CommentCheckerRunner, runCommentChecker } from "./runner.js"; + +export type CodexPostToolUseInput = { + session_id: string; + turn_id: string; + transcript_path: string | null; + cwd: string; + hook_event_name: "PostToolUse"; + model: string; + permission_mode: string; + tool_name: string; + tool_input: Record; + tool_response: unknown; + tool_use_id: string; +}; + +export type CodexHookOptions = { + run?: CommentCheckerRunner; +}; + +export function extractCodexCommentCheckRequests(input: CodexPostToolUseInput): CommentCheckRequest[] { + return extractCommentCheckRequests(toToolResultLike(input)); +} + +export async function runCommentCheckerPostToolUse( + input: CodexPostToolUseInput, + options: CodexHookOptions = {}, +): Promise { + const requests = extractCodexCommentCheckRequests(input); + if (requests.length === 0) return ""; + + const runner = options.run ?? runCommentChecker; + const warnings: Array<{ filePath: string; message: string }> = []; + + for (const request of requests) { + const context = { + sessionId: input.session_id, + cwd: input.cwd, + ...(input.transcript_path === null ? {} : { transcriptPath: input.transcript_path }), + }; + const result = await runner(toHookInput(request, context)); + if (result.status === "missing" || result.status === "pass") continue; + if (result.status === "error") continue; + const message = result.message.trim(); + if (message.length > 0) { + warnings.push({ filePath: request.filePath, message }); + } + } + + if (warnings.length === 0) return ""; + + return JSON.stringify({ + decision: "block", + reason: formatWarnings(warnings), + }); +} + +export async function runCodexHookCli(): Promise { + const input = await readStdin(); + if (input.trim().length === 0) return; + const parsed = parseCodexPostToolUseInput(input); + if (!parsed) return; + const output = await runCommentCheckerPostToolUse(parsed); + if (output.length > 0) { + processStdout.write(output); + processStdout.write("\n"); + } +} + +export function parseCodexPostToolUseInput(input: string): CodexPostToolUseInput | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(input); + } catch { + return undefined; + } + return isCodexPostToolUseInput(parsed) ? parsed : undefined; +} + +function toToolResultLike(input: CodexPostToolUseInput): ToolResultLike { + return { + toolName: input.tool_name, + input: normalizeToolInput(input.tool_name, input.tool_input), + content: normalizeToolResponse(input.tool_response), + isError: isErrorResponse(input.tool_response), + details: isRecord(input.tool_response) ? input.tool_response : undefined, + }; +} + +function normalizeToolInput(toolName: string, toolInput: Record): Record { + if (toolName === "apply_patch" && typeof toolInput["command"] === "string") { + return { + ...toolInput, + input: toolInput["command"], + patch: toolInput["command"], + }; + } + return toolInput; +} + +function normalizeToolResponse(toolResponse: unknown): ToolResultContent[] { + if (typeof toolResponse === "string") { + return [{ type: "text", text: toolResponse }]; + } + if (isRecord(toolResponse) && typeof toolResponse["text"] === "string") { + return [{ type: "text", text: toolResponse["text"] }]; + } + return []; +} + +function isErrorResponse(toolResponse: unknown): boolean { + return isRecord(toolResponse) && toolResponse["is_error"] === true; +} + +function formatWarnings(warnings: Array<{ filePath: string; message: string }>): string { + return warnings + .map((warning) => `comment-checker found issues in ${warning.filePath}:\n${warning.message}`) + .join("\n\n"); +} + +function isCodexPostToolUseInput(value: unknown): value is CodexPostToolUseInput { + return ( + isRecord(value) && + value["hook_event_name"] === "PostToolUse" && + typeof value["session_id"] === "string" && + typeof value["turn_id"] === "string" && + (typeof value["transcript_path"] === "string" || value["transcript_path"] === null) && + typeof value["cwd"] === "string" && + typeof value["model"] === "string" && + typeof value["permission_mode"] === "string" && + typeof value["tool_name"] === "string" && + isRecord(value["tool_input"]) && + typeof value["tool_use_id"] === "string" + ); +} + +function readStdin(): Promise { + return new Promise((resolve, reject) => { + let data = ""; + processStdin.setEncoding("utf-8"); + processStdin.on("data", (chunk: string) => { + data += chunk; + }); + processStdin.once("error", reject); + processStdin.once("end", () => { + resolve(data); + }); + }); +} diff --git a/packages/omo-codex/plugin/components/comment-checker/src/core.ts b/packages/omo-codex/plugin/components/comment-checker/src/core.ts new file mode 100644 index 000000000..d8506d69d --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/src/core.ts @@ -0,0 +1,361 @@ +export type TextContent = { + type: "text"; + text: string; +}; + +export type ImageContent = { + type: "image"; + data: string; + mimeType: string; +}; + +export type CheckerToolName = "Write" | "Edit" | "MultiEdit"; + +export type CheckerEdit = { + old_string: string; + new_string: string; +}; + +export type CheckerToolInput = { + file_path: string; + content?: string; + old_string?: string; + new_string?: string; + edits?: CheckerEdit[]; +}; + +export type CommentCheckRequest = { + sourceToolName: string; + toolName: CheckerToolName; + filePath: string; + toolInput: CheckerToolInput; +}; + +export type CommentCheckerHookInput = { + session_id: string; + tool_name: CheckerToolName; + transcript_path: string; + cwd: string; + hook_event_name: "PostToolUse"; + tool_input: CheckerToolInput; +}; + +export type ToolResultContent = TextContent | ImageContent; + +export type ToolResultLike = { + toolName: string; + input: Record; + content?: ToolResultContent[]; + isError?: boolean; + details?: unknown; +}; + +type ApplyPatchAccumulator = { + operation: "add" | "delete" | "update"; + filePath: string; + movePath?: string; + oldLines: string[]; + newLines: string[]; +}; + +type ApplyPatchFileMetadata = { + filePath: string; + movePath?: string; + before: string; + after: string; + type?: string; +}; + +export function extractCommentCheckRequests(event: ToolResultLike): CommentCheckRequest[] { + if (event.isError) return []; + if (isToolFailureOutput(getContentText(event.content))) return []; + + const toolName = event.toolName.toLowerCase(); + if (toolName === "write") return extractWriteRequest(event); + if (toolName === "edit") return extractEditRequest(event); + if (toolName === "multiedit" || toolName === "multi_edit") return extractMultiEditRequest(event); + if (toolName === "apply_patch") return extractApplyPatchRequests(event); + return []; +} + +export function toHookInput( + request: CommentCheckRequest, + context: { + sessionId: string; + cwd: string; + transcriptPath?: string; + }, +): CommentCheckerHookInput { + return { + session_id: context.sessionId, + tool_name: request.toolName, + transcript_path: context.transcriptPath ?? "", + cwd: context.cwd, + hook_event_name: "PostToolUse", + tool_input: request.toolInput, + }; +} + +export function isToolFailureOutput(text: string): boolean { + const lower = text.trim().toLowerCase(); + return ( + lower.startsWith("error") || + lower.includes("error:") || + lower.includes("failed to") || + lower.includes("could not") + ); +} + +function extractWriteRequest(event: ToolResultLike): CommentCheckRequest[] { + const filePath = getString(event.input, ["filePath", "file_path", "path"]); + const content = getString(event.input, ["content"]); + if (!filePath || content === undefined) return []; + return [ + { + sourceToolName: event.toolName, + toolName: "Write", + filePath, + toolInput: { + file_path: filePath, + content, + }, + }, + ]; +} + +function extractEditRequest(event: ToolResultLike): CommentCheckRequest[] { + const filePath = getString(event.input, ["filePath", "file_path", "path"]); + const oldString = getString(event.input, ["oldString", "old_string"]); + const newString = getString(event.input, ["newString", "new_string"]); + if (!filePath || oldString === undefined || newString === undefined) return []; + const toolInput: CheckerToolInput = { file_path: filePath }; + toolInput.old_string = oldString; + toolInput.new_string = newString; + return [ + { + sourceToolName: event.toolName, + toolName: "Edit", + filePath, + toolInput, + }, + ]; +} + +function extractMultiEditRequest(event: ToolResultLike): CommentCheckRequest[] { + const filePath = getString(event.input, ["filePath", "file_path", "path"]); + const edits = getEdits(event.input["edits"]); + if (!filePath || edits.length === 0) return []; + return [ + { + sourceToolName: event.toolName, + toolName: "MultiEdit", + filePath, + toolInput: { + file_path: filePath, + edits, + }, + }, + ]; +} + +function extractApplyPatchRequests(event: ToolResultLike): CommentCheckRequest[] { + const metadataRequests = extractApplyPatchMetadataRequests(event.details, event.toolName); + if (metadataRequests.length > 0) return metadataRequests; + + const patch = getString(event.input, ["input", "patch", "command"]); + if (!patch) return []; + return parseApplyPatchRequests(patch, event.toolName); +} + +function extractApplyPatchMetadataRequests(details: unknown, sourceToolName: string): CommentCheckRequest[] { + const metadataFiles = getApplyPatchMetadataFiles(details); + if (metadataFiles.length === 0) return []; + + const requests: CommentCheckRequest[] = []; + for (const file of metadataFiles) { + if (file.type === "delete") continue; + const filePath = file.movePath ?? file.filePath; + if (file.before.length === 0) { + requests.push({ + sourceToolName, + toolName: "Write", + filePath, + toolInput: { + file_path: filePath, + content: file.after, + }, + }); + continue; + } + requests.push({ + sourceToolName, + toolName: "Edit", + filePath, + toolInput: { + file_path: filePath, + old_string: file.before, + new_string: file.after, + }, + }); + } + return requests; +} + +function getApplyPatchMetadataFiles(details: unknown): ApplyPatchFileMetadata[] { + if (!isRecord(details)) return []; + const direct = readApplyPatchMetadataFiles(details["files"]); + if (direct.length > 0) return direct; + const resultDetails = details["result"]; + const result = isRecord(resultDetails) ? readApplyPatchMetadataFiles(resultDetails["files"]) : []; + if (result.length > 0) return result; + const metadataDetails = details["metadata"]; + const metadata = isRecord(metadataDetails) ? readApplyPatchMetadataFiles(metadataDetails["files"]) : []; + return metadata; +} + +function readApplyPatchMetadataFiles(value: unknown): ApplyPatchFileMetadata[] { + if (!Array.isArray(value)) return []; + const files: ApplyPatchFileMetadata[] = []; + for (const item of value) { + if (!isRecord(item)) continue; + const filePath = getString(item, ["filePath", "file_path", "path"]); + const movePath = getString(item, ["movePath", "move_path"]); + const before = getString(item, ["before", "old", "oldString", "old_string"]); + const after = getString(item, ["after", "new", "newString", "new_string"]); + const type = getString(item, ["type", "operation"]); + if (!filePath || before === undefined || after === undefined) continue; + files.push({ + filePath, + before, + after, + ...(movePath === undefined ? {} : { movePath }), + ...(type === undefined ? {} : { type }), + }); + } + return files; +} + +export function parseApplyPatchRequests(patch: string, sourceToolName = "apply_patch"): CommentCheckRequest[] { + const requests: CommentCheckRequest[] = []; + let current: ApplyPatchAccumulator | undefined; + + const flush = (): void => { + if (!current) return; + if (current.operation === "add") { + const content = joinPatchLines(current.newLines); + if (content.length > 0) { + requests.push({ + sourceToolName, + toolName: "Write", + filePath: current.filePath, + toolInput: { + file_path: current.filePath, + content, + }, + }); + } + } + if (current.operation === "update") { + const newString = joinPatchLines(current.newLines); + if (newString.length > 0) { + const filePath = current.movePath ?? current.filePath; + requests.push({ + sourceToolName, + toolName: "Edit", + filePath, + toolInput: { + file_path: filePath, + old_string: joinPatchLines(current.oldLines), + new_string: newString, + }, + }); + } + } + current = undefined; + }; + + for (const line of patch.split(/\r?\n/)) { + if (line === "*** Begin Patch" || line === "*** End Patch") continue; + if (line.startsWith("*** Add File: ")) { + flush(); + current = makeAccumulator("add", line.slice("*** Add File: ".length).trim()); + continue; + } + if (line.startsWith("*** Update File: ")) { + flush(); + current = makeAccumulator("update", line.slice("*** Update File: ".length).trim()); + continue; + } + if (line.startsWith("*** Delete File: ")) { + flush(); + current = makeAccumulator("delete", line.slice("*** Delete File: ".length).trim()); + continue; + } + if (line.startsWith("*** Move to: ")) { + if (current?.operation === "update") current.movePath = line.slice("*** Move to: ".length).trim(); + continue; + } + if (!current) continue; + if (line.startsWith("@@")) continue; + if (current.operation === "add") { + if (line.startsWith("+")) current.newLines.push(line.slice(1)); + continue; + } + if (current.operation === "update") { + if (line.startsWith("+")) current.newLines.push(line.slice(1)); + if (line.startsWith("-")) current.oldLines.push(line.slice(1)); + } + } + + flush(); + return requests; +} + +function makeAccumulator(operation: ApplyPatchAccumulator["operation"], filePath: string): ApplyPatchAccumulator { + return { + operation, + filePath, + oldLines: [], + newLines: [], + }; +} + +function getEdits(value: unknown): CheckerEdit[] { + if (!Array.isArray(value)) return []; + const edits: CheckerEdit[] = []; + for (const item of value) { + if (!isRecord(item)) continue; + const oldString = getString(item, ["oldString", "old_string"]); + const newString = getString(item, ["newString", "new_string"]); + if (oldString === undefined || newString === undefined) continue; + edits.push({ + old_string: oldString, + new_string: newString, + }); + } + return edits; +} + +function getContentText(content: ToolResultContent[] | undefined): string { + if (!content) return ""; + return content + .filter((block): block is TextContent => block.type === "text") + .map((block) => block.text) + .join("\n"); +} + +function getString(input: Record, keys: string[]): string | undefined { + for (const key of keys) { + const value = input[key]; + if (typeof value === "string") return value; + } + return undefined; +} + +function joinPatchLines(lines: string[]): string { + return lines.length === 0 ? "" : `${lines.join("\n")}\n`; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/packages/omo-codex/plugin/components/comment-checker/src/runner.ts b/packages/omo-codex/plugin/components/comment-checker/src/runner.ts new file mode 100644 index 000000000..7d6ce285b --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/src/runner.ts @@ -0,0 +1,195 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; + +import type { CommentCheckerHookInput } from "./core.js"; + +export type ProcessResult = { + exitCode: number | null; + stdout: string; + stderr: string; +}; + +export const MAX_PROCESS_OUTPUT_BYTES = 64 * 1024; + +export type ProcessExecutor = (command: string, args: string[], stdin: string) => Promise; + +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 async function runCommentChecker( + input: CommentCheckerHookInput, + options: RunCommentCheckerOptions = {}, +): Promise { + const binaryPath = + options.binaryPath ?? (options.resolveBinary ? options.resolveBinary() : resolveCommentCheckerBinary()); + if (!binaryPath) { + return { + status: "missing", + message: "comment-checker binary not found. Run npm install for the codex-comment-checker plugin.", + }; + } + + const args = ["check"]; + if (options.customPrompt) { + args.push("--prompt", options.customPrompt); + } + + const executor = options.executor ?? spawnProcess; + const result = await executor(binaryPath, args, JSON.stringify(input)); + const message = result.stderr || result.stdout; + if (result.exitCode === 0) { + return { + status: "pass", + message: "", + binaryPath, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }; + } + if (result.exitCode === 2) { + return { + status: "warning", + message, + binaryPath, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }; + } + return { + status: "error", + message, + binaryPath, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }; +} + +export function resolveCommentCheckerBinary(): string | undefined { + const binaryName = process.platform === "win32" ? "comment-checker.exe" : "comment-checker"; + const fromPackageApi = resolvePackageApiBinary(); + if (fromPackageApi) return fromPackageApi; + const fromPackage = resolvePackageBinary(binaryName); + if (fromPackage) return fromPackage; + return undefined; +} + +function resolvePackageApiBinary(): string | undefined { + try { + const require = createRequire(import.meta.url); + const packageExports: unknown = require("@code-yeongyu/comment-checker"); + if (!isCommentCheckerPackage(packageExports)) return undefined; + const binaryPath = packageExports.getBinaryPath(); + return existsSync(binaryPath) ? binaryPath : undefined; + } catch { + return undefined; + } +} + +function resolvePackageBinary(binaryName: string): string | undefined { + try { + const require = createRequire(import.meta.url); + const packagePath = require.resolve("@code-yeongyu/comment-checker/package.json"); + const binaryPath = join(dirname(packagePath), "bin", binaryName); + return existsSync(binaryPath) ? binaryPath : undefined; + } catch { + return undefined; + } +} + +function isCommentCheckerPackage(value: unknown): value is { getBinaryPath: () => string } { + return isRecord(value) && typeof value["getBinaryPath"] === "function"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +interface OutputAccumulator { + text: string; + bytes: number; + truncated: boolean; +} + +function appendOutput(output: OutputAccumulator, chunk: string, maxOutputBytes: number): void { + if (output.truncated) return; + + const remainingBytes = maxOutputBytes - output.bytes; + const chunkBytes = Buffer.byteLength(chunk, "utf8"); + if (chunkBytes <= remainingBytes) { + output.text += chunk; + output.bytes += chunkBytes; + return; + } + + if (remainingBytes > 0) { + output.text += Buffer.from(chunk, "utf8").subarray(0, remainingBytes).toString("utf8"); + output.bytes += remainingBytes; + } + output.truncated = true; +} + +function formatOutput(output: OutputAccumulator, streamName: "stdout" | "stderr", maxOutputBytes: number): string { + if (!output.truncated) return output.text; + return `${output.text}\n[${streamName} truncated after ${maxOutputBytes} bytes]`; +} + +export function spawnProcess( + command: string, + args: string[], + stdin: string, + maxOutputBytes: number = MAX_PROCESS_OUTPUT_BYTES, +): Promise { + return new Promise((resolve) => { + const outputByteLimit = Number.isFinite(maxOutputBytes) && maxOutputBytes > 0 ? Math.floor(maxOutputBytes) : 0; + const proc = spawn(command, args, { + stdio: ["pipe", "pipe", "pipe"], + }); + const stdout: OutputAccumulator = { text: "", bytes: 0, truncated: false }; + const stderr: OutputAccumulator = { text: "", bytes: 0, truncated: false }; + + proc.stdout.setEncoding("utf-8"); + proc.stderr.setEncoding("utf-8"); + proc.stdout.on("data", (chunk: string) => { + appendOutput(stdout, chunk, outputByteLimit); + }); + proc.stderr.on("data", (chunk: string) => { + appendOutput(stderr, chunk, outputByteLimit); + }); + proc.once("error", (error) => { + appendOutput(stderr, error.message, outputByteLimit); + resolve({ + exitCode: null, + stdout: formatOutput(stdout, "stdout", outputByteLimit), + stderr: formatOutput(stderr, "stderr", outputByteLimit), + }); + }); + proc.once("close", (exitCode) => { + resolve({ + exitCode, + stdout: formatOutput(stdout, "stdout", outputByteLimit), + stderr: formatOutput(stderr, "stderr", outputByteLimit), + }); + }); + proc.stdin.end(stdin); + }); +} diff --git a/packages/omo-codex/plugin/components/comment-checker/test/codex-hook.test.ts b/packages/omo-codex/plugin/components/comment-checker/test/codex-hook.test.ts new file mode 100644 index 000000000..7d12f7b3c --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/test/codex-hook.test.ts @@ -0,0 +1,317 @@ +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +import { + type CodexPostToolUseInput, + extractCodexCommentCheckRequests, + runCommentCheckerPostToolUse, +} from "../src/codex-hook.ts"; + +type CliResult = { + exitCode: number | null; + stdout: string; + stderr: string; +}; + +const CLI_PATH = fileURLToPath(new URL("../dist/cli.js", import.meta.url)); + +function runHookCli(input: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [CLI_PATH, "hook", "post-tool-use"], { + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + child.once("error", reject); + child.once("close", (exitCode) => { + resolve({ exitCode, stdout, stderr }); + }); + child.stdin.end(input); + }); +} + +function postToolUseInput(overrides: Partial = {}): CodexPostToolUseInput { + return { + session_id: "thread-1", + turn_id: "turn-1", + transcript_path: null, + cwd: "/repo", + hook_event_name: "PostToolUse", + model: "gpt-5.5", + permission_mode: "never", + tool_name: "apply_patch", + tool_input: { + command: [ + "*** Begin Patch", + "*** Update File: src/example.ts", + "@@", + "-const value = 1;", + "+// explains value", + "+const value = 2;", + "*** End Patch", + ].join("\n"), + }, + tool_response: "Success. Updated files.", + tool_use_id: "call-1", + ...overrides, + }; +} + +describe("extractCodexCommentCheckRequests", () => { + it("#given codex apply_patch command #when extracting #then returns edit request for changed file", () => { + const requests = extractCodexCommentCheckRequests(postToolUseInput()); + + expect(requests).toEqual([ + { + sourceToolName: "apply_patch", + toolName: "Edit", + filePath: "src/example.ts", + toolInput: { + file_path: "src/example.ts", + old_string: "const value = 1;\n", + new_string: "// explains value\nconst value = 2;\n", + }, + }, + ]); + }); + + it("#given unsupported post tool event #when extracting #then returns no requests", () => { + const requests = extractCodexCommentCheckRequests( + postToolUseInput({ + tool_name: "read", + tool_input: { file_path: "src/example.ts", content: "// hi\nconst value = 1;\n" }, + }), + ); + + expect(requests).toEqual([]); + }); + + it("#given codex write payload #when extracting #then returns write request", () => { + const requests = extractCodexCommentCheckRequests( + postToolUseInput({ + tool_name: "write", + tool_input: { + file_path: "src/example.ts", + content: "// explains value\nconst value = 1;\n", + }, + }), + ); + + expect(requests).toEqual([ + { + sourceToolName: "write", + toolName: "Write", + filePath: "src/example.ts", + toolInput: { + file_path: "src/example.ts", + content: "// explains value\nconst value = 1;\n", + }, + }, + ]); + }); + + it("#given codex edit payload #when extracting #then returns edit request", () => { + const requests = extractCodexCommentCheckRequests( + postToolUseInput({ + tool_name: "edit", + tool_input: { + path: "src/example.ts", + oldString: "const value = 1;\n", + newString: "// explains value\nconst value = 2;\n", + }, + }), + ); + + expect(requests).toEqual([ + { + sourceToolName: "edit", + toolName: "Edit", + filePath: "src/example.ts", + toolInput: { + file_path: "src/example.ts", + old_string: "const value = 1;\n", + new_string: "// explains value\nconst value = 2;\n", + }, + }, + ]); + }); + + it("#given one-sided codex edit payload #when extracting #then returns no requests", () => { + const requests = extractCodexCommentCheckRequests( + postToolUseInput({ + tool_name: "edit", + tool_input: { + path: "src/example.ts", + oldString: "const value = 1;\n", + }, + }), + ); + + expect(requests).toEqual([]); + }); + + it("#given codex multi_edit payload #when extracting #then returns multiedit request", () => { + const requests = extractCodexCommentCheckRequests( + postToolUseInput({ + tool_name: "multi_edit", + tool_input: { + filePath: "src/example.ts", + edits: [ + { old_string: "const a = 1;\n", new_string: "// explains a\nconst a = 2;\n" }, + { oldString: "const b = 1;\n", newString: "// explains b\nconst b = 2;\n" }, + ], + }, + }), + ); + + expect(requests).toEqual([ + { + sourceToolName: "multi_edit", + toolName: "MultiEdit", + filePath: "src/example.ts", + toolInput: { + file_path: "src/example.ts", + edits: [ + { old_string: "const a = 1;\n", new_string: "// explains a\nconst a = 2;\n" }, + { old_string: "const b = 1;\n", new_string: "// explains b\nconst b = 2;\n" }, + ], + }, + }, + ]); + }); +}); + +describe("runCommentCheckerPostToolUse", () => { + it("#given checker warning #when hook runs #then returns blocking feedback JSON", async () => { + const output = await runCommentCheckerPostToolUse(postToolUseInput(), { + run: async () => ({ + status: "warning", + message: "comment warning: explain less", + }), + }); + + expect(JSON.parse(output)).toEqual({ + decision: "block", + reason: "comment-checker found issues in src/example.ts:\ncomment warning: explain less", + }); + }); + + it("#given missing checker binary #when hook runs #then emits no hook output", async () => { + const output = await runCommentCheckerPostToolUse(postToolUseInput(), { + run: async () => ({ + status: "missing", + message: "not installed", + }), + }); + + expect(output).toBe(""); + }); + + it("#given transcript path #when hook runs #then forwards it to checker input", async () => { + let transcriptPath = ""; + + await runCommentCheckerPostToolUse( + postToolUseInput({ + transcript_path: "/tmp/codex-comment-checker-transcript.jsonl", + tool_name: "write", + tool_input: { + file_path: "src/example.ts", + content: "// explains value\nconst value = 1;\n", + }, + }), + { + run: async (input) => { + transcriptPath = input.transcript_path; + return { + status: "pass", + message: "", + }; + }, + }, + ); + + expect(transcriptPath).toBe("/tmp/codex-comment-checker-transcript.jsonl"); + }); + + it("#given null transcript path #when hook runs #then forwards empty string fallback", async () => { + let transcriptPath = "unset"; + + await runCommentCheckerPostToolUse( + postToolUseInput({ + transcript_path: null, + tool_name: "write", + tool_input: { + file_path: "src/example.ts", + content: "// explains value\nconst value = 1;\n", + }, + }), + { + run: async (input) => { + transcriptPath = input.transcript_path; + return { + status: "pass", + message: "", + }; + }, + }, + ); + + expect(transcriptPath).toBe(""); + }); +}); + +describe("runCodexHookCli", () => { + it("#given malformed post-tool-use stdin #when hook CLI runs #then it no-ops without stderr", async () => { + // given + const input = "break;\n"; + + // when + const result = await runHookCli(input); + + // then + expect(result).toEqual({ + exitCode: 0, + stdout: "", + stderr: "", + }); + }); + + it("#given non-object post-tool-use JSON #when hook CLI runs #then it no-ops without stderr", async () => { + // given + const input = '"break;"\n'; + + // when + const result = await runHookCli(input); + + // then + expect(result).toEqual({ + exitCode: 0, + stdout: "", + stderr: "", + }); + }); + + it("#given non-string transcript path #when hook CLI runs #then it no-ops without stderr", async () => { + // given + const input = `${JSON.stringify({ ...postToolUseInput(), transcript_path: 42 })}\n`; + + // when + const result = await runHookCli(input); + + // then + expect(result).toEqual({ + exitCode: 0, + stdout: "", + stderr: "", + }); + }); +}); diff --git a/packages/omo-codex/plugin/components/comment-checker/test/fixtures/post-tool-use.json b/packages/omo-codex/plugin/components/comment-checker/test/fixtures/post-tool-use.json new file mode 100644 index 000000000..b1d4c0901 --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/test/fixtures/post-tool-use.json @@ -0,0 +1,15 @@ +{ + "session_id": "00000000-0000-0000-0000-000000000000", + "turn_id": "00000000-0000-0000-0000-000000000001", + "transcript_path": "/tmp/codex-comment-checker-transcript.jsonl", + "cwd": ".", + "hook_event_name": "PostToolUse", + "model": "gpt-5.5", + "permission_mode": "default", + "tool_name": "apply_patch", + "tool_input": { + "command": "*** Begin Patch\n*** Add File: src/example.ts\n+export const meaning = 42;\n*** End Patch\n" + }, + "tool_response": "Success. Updated files.", + "tool_use_id": "toolu_000000000000000000000000" +} diff --git a/packages/omo-codex/plugin/components/comment-checker/test/package-smoke.test.ts b/packages/omo-codex/plugin/components/comment-checker/test/package-smoke.test.ts new file mode 100644 index 000000000..650cc5805 --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/test/package-smoke.test.ts @@ -0,0 +1,109 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +type PackageJson = { + readonly type: string; + readonly packageManager: string; + readonly bin: Record; + readonly dependencies?: Record; + readonly optionalDependencies: Record; +}; + +type PluginJson = { + readonly hooks: string; +}; + +type HookCommand = { + readonly command: string; +}; + +type HookEntry = { + readonly hooks: readonly HookCommand[]; +}; + +type HooksJson = { + readonly hooks: Record; +}; + +function readPackageJson(path: string): PackageJson { + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + if (!isPackageJson(parsed)) throw new TypeError(`Invalid package metadata: ${path}`); + return parsed; +} + +function readPluginJson(path: string): PluginJson { + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + if (!isPluginJson(parsed)) throw new TypeError(`Invalid plugin metadata: ${path}`); + return parsed; +} + +function readHooksJson(path: string): HooksJson { + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + if (!isHooksJson(parsed)) throw new TypeError(`Invalid hooks metadata: ${path}`); + return parsed; +} + +describe("plugin package metadata", () => { + it("#given packaged plugin files #when validating entrypoints #then hook command uses portable plugin root interpolation", () => { + // given + const packageJson = readPackageJson("package.json"); + const pluginJson = readPluginJson(".codex-plugin/plugin.json"); + const hooksJson = readHooksJson("hooks/hooks.json"); + const cliSource = readFileSync("src/cli.ts", "utf8"); + + // when + const command = hooksJson.hooks["PostToolUse"]?.[0]?.hooks[0]?.command; + const pluginRoot = ["$", "{PLUGIN_ROOT}"].join(""); + + // then + expect(packageJson.type).toBe("module"); + expect(packageJson.packageManager).toBe("npm@11.12.1"); + expect(packageJson.dependencies ?? {}).not.toHaveProperty("@code-yeongyu/comment-checker"); + expect(packageJson.optionalDependencies).toHaveProperty("@code-yeongyu/comment-checker"); + expect(packageJson.bin["codex-comment-checker"]).toBe("./dist/cli.js"); + expect(pluginJson.hooks).toBe("./hooks/hooks.json"); + expect(cliSource.startsWith("#!/usr/bin/env node")).toBe(true); + expect(command).toBe(`node "${pluginRoot}/dist/cli.js" hook post-tool-use`); + }); +}); + +function isPackageJson(value: unknown): value is PackageJson { + if (!isRecord(value)) return false; + const dependencies = value["dependencies"]; + return ( + value["type"] === "module" && + value["packageManager"] === "npm@11.12.1" && + isStringRecord(value["bin"]) && + isStringRecord(value["optionalDependencies"]) && + (dependencies === undefined || isRecord(dependencies)) + ); +} + +function isPluginJson(value: unknown): value is PluginJson { + return isRecord(value) && typeof value["hooks"] === "string"; +} + +function isHooksJson(value: unknown): value is HooksJson { + if (!isRecord(value) || !isRecord(value["hooks"])) return false; + return Object.values(value["hooks"]).every(isHookEntries); +} + +function isHookEntries(value: unknown): value is readonly HookEntry[] { + return Array.isArray(value) && value.every(isHookEntry); +} + +function isHookEntry(value: unknown): value is HookEntry { + return isRecord(value) && Array.isArray(value["hooks"]) && value["hooks"].every(isHookCommand); +} + +function isHookCommand(value: unknown): value is HookCommand { + return isRecord(value) && typeof value["command"] === "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/comment-checker/test/runner.test.ts b/packages/omo-codex/plugin/components/comment-checker/test/runner.test.ts new file mode 100644 index 000000000..39a375b14 --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/test/runner.test.ts @@ -0,0 +1,66 @@ +import { existsSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +import { + MAX_PROCESS_OUTPUT_BYTES, + resolveCommentCheckerBinary, + runCommentChecker, + spawnProcess, +} from "../src/runner.js"; + +describe("spawnProcess", () => { + it("#given noisy checker process #when output exceeds cap #then stderr is bounded", async () => { + // given + const maxOutputBytes = 16; + + // when + const result = await spawnProcess( + process.execPath, + ["-e", "process.stderr.write('x'.repeat(40)); process.exit(2);"], + "", + maxOutputBytes, + ); + + // then + expect(MAX_PROCESS_OUTPUT_BYTES).toBeGreaterThan(maxOutputBytes); + expect(result.exitCode).toBe(2); + expect(result.stderr).toBe(`${"x".repeat(maxOutputBytes)}\n[stderr truncated after 16 bytes]`); + }); +}); + +describe("resolveCommentCheckerBinary", () => { + it("#given installed checker package #when resolving binary #then returns existing checker binary", () => { + // given / when + const binaryPath = resolveCommentCheckerBinary(); + + // then + expect(binaryPath).toBeDefined(); + expect(binaryPath ?? "").toContain("comment-checker"); + expect(existsSync(binaryPath ?? "")).toBe(true); + }); +}); + +describe("runCommentChecker", () => { + it("#given missing checker binary #when runner starts #then returns missing result", async () => { + // given / when + const result = await runCommentChecker( + { + session_id: "session-1", + tool_name: "Write", + transcript_path: "", + cwd: "/repo", + hook_event_name: "PostToolUse", + tool_input: { + file_path: "src/example.ts", + content: "const value = 1;\n", + }, + }, + { + resolveBinary: () => undefined, + }, + ); + + // then + expect(result.status).toBe("missing"); + }); +}); diff --git a/packages/omo-codex/plugin/components/comment-checker/tsconfig.build.json b/packages/omo-codex/plugin/components/comment-checker/tsconfig.build.json new file mode 100644 index 000000000..5b5bbcafd --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/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/comment-checker/tsconfig.json b/packages/omo-codex/plugin/components/comment-checker/tsconfig.json new file mode 100644 index 000000000..342229c02 --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/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/components/comment-checker/vitest.config.ts b/packages/omo-codex/plugin/components/comment-checker/vitest.config.ts new file mode 100644 index 000000000..57bd8f12b --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + environment: "node", + pool: "threads", + }, +}); diff --git a/packages/omo-codex/plugin/components/lsp/.gitattributes b/packages/omo-codex/plugin/components/lsp/.gitattributes new file mode 100644 index 000000000..9363fdb74 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/.gitattributes @@ -0,0 +1,13 @@ +# Normalize line endings: store LF in git, check out LF on every platform. +# Required so biome's --check passes on Windows (default core.autocrlf=true). +* text=auto eol=lf + +# Explicit binary types +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.zip binary +*.tgz binary +*.gz binary diff --git a/packages/omo-codex/plugin/components/lsp/.github/CODEOWNERS b/packages/omo-codex/plugin/components/lsp/.github/CODEOWNERS new file mode 100644 index 000000000..e2330ddf8 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/.github/CODEOWNERS @@ -0,0 +1 @@ +* @code-yeongyu diff --git a/packages/omo-codex/plugin/components/lsp/.github/ISSUE_TEMPLATE/bug.yml b/packages/omo-codex/plugin/components/lsp/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 000000000..c8529498f --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,26 @@ +name: Bug report +description: Report a reproducible codex-lsp bug. +title: "[bug]: " +labels: ["bug"] +body: + - type: textarea + id: summary + attributes: + label: Summary + description: What happened? + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Reproduction + description: Exact steps, config, command output, and affected language server. + validations: + required: true + - type: input + id: version + attributes: + label: Version + placeholder: 0.1.0 + validations: + required: true diff --git a/packages/omo-codex/plugin/components/lsp/.github/ISSUE_TEMPLATE/feature.yml b/packages/omo-codex/plugin/components/lsp/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 000000000..b9d3af725 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,19 @@ +name: Feature request +description: Propose a focused codex-lsp improvement. +title: "[feature]: " +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What workflow should improve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposal + description: What should codex-lsp do? + validations: + required: true diff --git a/packages/omo-codex/plugin/components/lsp/.github/branch-ruleset.json b/packages/omo-codex/plugin/components/lsp/.github/branch-ruleset.json new file mode 100644 index 000000000..1f482bb66 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/.github/branch-ruleset.json @@ -0,0 +1,45 @@ +{ + "name": "main protection", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["~DEFAULT_BRANCH"], + "exclude": [] + } + }, + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { "type": "required_linear_history" }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": true, + "require_last_push_approval": false, + "required_review_thread_resolution": true + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": true, + "required_status_checks": [ + { "context": "test (ubuntu-latest · node 20)" }, + { "context": "test (ubuntu-latest · node 22)" }, + { "context": "test (macos-latest · node 20)" }, + { "context": "test (macos-latest · node 22)" } + ] + } + } + ], + "bypass_actors": [ + { + "actor_id": 5, + "actor_type": "RepositoryRole", + "bypass_mode": "always" + } + ] +} diff --git a/packages/omo-codex/plugin/components/lsp/.github/dependabot.yml b/packages/omo-codex/plugin/components/lsp/.github/dependabot.yml new file mode 100644 index 000000000..be719fac4 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/packages/omo-codex/plugin/components/lsp/.github/pull_request_template.md b/packages/omo-codex/plugin/components/lsp/.github/pull_request_template.md new file mode 100644 index 000000000..39f36ad46 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/.github/pull_request_template.md @@ -0,0 +1,11 @@ +## Summary + +- + +## Validation + +- + +## Notes + +- diff --git a/packages/omo-codex/plugin/components/lsp/.github/workflows/ci.yml b/packages/omo-codex/plugin/components/lsp/.github/workflows/ci.yml new file mode 100644 index 000000000..f8d6415c2 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + name: test (${{ matrix.os }} · node ${{ matrix.node }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + node: ["20", "22"] + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Setup Node ${{ matrix.node }} + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node }} + cache: npm + + - name: Bootstrap lsp-tools-mcp submodule + shell: bash + run: | + cd packages/lsp-tools-mcp + npm ci + npm run build + + - name: Install dependencies + run: npm ci + + - name: Check + run: npm run check + + - name: Unit tests + run: npm test + + - name: Package smoke + run: npm pack --dry-run diff --git a/packages/omo-codex/plugin/components/lsp/.github/workflows/publish.yml b/packages/omo-codex/plugin/components/lsp/.github/workflows/publish.yml new file mode 100644 index 000000000..125a45587 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/.github/workflows/publish.yml @@ -0,0 +1,60 @@ +name: publish + +on: + release: + types: [published] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Setup Node 22 + uses: actions/setup-node@v6 + with: + node-version: "22" + registry-url: https://registry.npmjs.org + cache: npm + + - name: Bootstrap lsp-tools-mcp submodule + shell: bash + run: | + cd packages/lsp-tools-mcp + npm ci + npm run build + + - name: Install dependencies + run: npm ci + + - name: Check + run: npm run check + + - name: Unit tests + run: npm test + + - name: Package smoke + run: npm pack --dry-run + + - name: Publish to npm + run: | + if [ -z "$NODE_AUTH_TOKEN" ]; then + echo "NODE_AUTH_TOKEN is not configured; skipping npm publish." + exit 0 + fi + npm publish --access public --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} diff --git a/packages/omo-codex/plugin/components/lsp/.gitignore b/packages/omo-codex/plugin/components/lsp/.gitignore new file mode 100644 index 000000000..1bf09f16c --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +*.log +.env +.DS_Store +coverage/ +.vitest/ diff --git a/packages/omo-codex/plugin/components/lsp/.gitmodules b/packages/omo-codex/plugin/components/lsp/.gitmodules new file mode 100644 index 000000000..f172b519f --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/.gitmodules @@ -0,0 +1,4 @@ +[submodule "packages/lsp-tools-mcp"] + path = packages/lsp-tools-mcp + url = https://github.com/code-yeongyu/lsp-tools-mcp.git + branch = main diff --git a/packages/omo-codex/plugin/components/lsp/.mcp.json b/packages/omo-codex/plugin/components/lsp/.mcp.json new file mode 100644 index 000000000..13ff674da --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "lsp": { + "command": "node", + "args": ["./packages/lsp-tools-mcp/dist/cli.js", "mcp"], + "cwd": "." + } + } +} diff --git a/packages/omo-codex/plugin/components/lsp/AGENTS.md b/packages/omo-codex/plugin/components/lsp/AGENTS.md new file mode 100644 index 000000000..69e85a30c --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/AGENTS.md @@ -0,0 +1,25 @@ +# Repository Conventions + +Conventions for humans and agents working on this repository. + +## Style + +- TypeScript strict mode. No `any`, `@ts-ignore`, `@ts-expect-error`, or enums. +- ESM modules with `.js` suffix in import paths. +- Tabs for indentation. Double quotes for strings. +- Runtime is Node only. +- Tests use vitest and should exercise Codex hook/MCP behavior before implementation changes. + +## Commands + +- `npm install` installs dependencies. +- `npm test` runs the test suite once. +- `npm run typecheck` runs strict TypeScript checking. +- `npm run check` runs typecheck, Biome, and build. + +## LSP Constraints + +- LSP server processes are owned by `LspManager`. +- Tool execution acquires clients through `withLspClient(...)` unless it only reports static status. +- `lsp.rename` mutates files by applying workspace edits; keep it sequential at the MCP caller level. +- Do not add pi-coding-agent or omo source dependencies. This package is standalone. diff --git a/packages/omo-codex/plugin/components/lsp/CHANGELOG.md b/packages/omo-codex/plugin/components/lsp/CHANGELOG.md new file mode 100644 index 000000000..5853ed1fb --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +## Unreleased + +## 0.2.0 + +- Extracted the LSP runtime and MCP server into [`@code-yeongyu/lsp-tools-mcp`](https://github.com/code-yeongyu/lsp-tools-mcp). +- codex-lsp now consumes that runtime as a git submodule at `packages/lsp-tools-mcp`. +- Kept the Codex-specific PostToolUse hook in this package and routed MCP serving through the upstream CLI. + +- Extract LSP runtime to `lsp-tools-mcp` upstream and consume it via git submodule at `packages/lsp-tools-mcp`. +- Renamed the MCP server namespace to `lsp` and exposed shorter tool names such as `lsp.diagnostics`. +- Use portable Codex hook interpolation and add package smoke coverage for hook/MCP entrypoints. +- Spawn language servers without shell mode; Windows `.cmd` and `.bat` shims are routed through `cmd.exe` with explicit arguments. +- Cap directory diagnostics file traversal and run CI on Windows in addition to Ubuntu and macOS. +- Replace the external JSON-RPC runtime dependency with an internal LSP framing layer so clean Codex plugin installs run without `node_modules`. + +## 0.1.0 + +- Ported the standalone LSP client, server resolution, diagnostics aggregation, and workspace edit runtime from `pi-lsp-client`. +- Added Codex `PostToolUse` diagnostics for edit-style tools. +- Added MCP tools for status, diagnostics, definitions, references, symbols, prepare rename, and rename. +- Added Codex plugin metadata, skill docs, CI, and release automation. diff --git a/packages/omo-codex/plugin/components/lsp/LICENSE b/packages/omo-codex/plugin/components/lsp/LICENSE new file mode 100644 index 000000000..09aac3c3b --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Yeongyu Kim + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/omo-codex/plugin/components/lsp/NOTICE b/packages/omo-codex/plugin/components/lsp/NOTICE new file mode 100644 index 000000000..01916eda3 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/NOTICE @@ -0,0 +1,3 @@ +codex-lsp ports the standalone LSP runtime from pi-lsp-client into a Codex plugin. + +The package includes adapted code originally developed for pi-lsp-client. diff --git a/packages/omo-codex/plugin/components/lsp/README.md b/packages/omo-codex/plugin/components/lsp/README.md new file mode 100644 index 000000000..661358b33 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/README.md @@ -0,0 +1,152 @@ +# codex-lsp + +[![ci](https://github.com/code-yeongyu/codex-lsp/actions/workflows/ci.yml/badge.svg)](https://github.com/code-yeongyu/codex-lsp/actions/workflows/ci.yml) [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) + +Codex plugin that ports the standalone LSP runtime from [`pi-lsp-client`](https://github.com/code-yeongyu/pi-lsp-client). It gives Codex post-edit diagnostics plus explicit MCP tools for language-aware code work. + +## Architecture + +The LSP runtime moved to [`lsp-tools-mcp`](https://github.com/code-yeongyu/lsp-tools-mcp) and is consumed here as a git submodule at `packages/lsp-tools-mcp/`. + +- `codex-lsp` keeps Codex-specific integration (`hook post-tool-use`, plugin metadata, package wiring). +- `lsp-tools-mcp` owns MCP runtime, LSP manager, and tool implementations. +- `src/cli.ts` routes `mcp` to upstream runtime and keeps `hook post-tool-use` local. + +## Behavior + +| Case | Result | +|------|--------| +| `apply_patch` succeeds | parses `tool_input.command`, extracts added/updated/moved files, and checks each with LSP error diagnostics | +| `write` / `edit` / `multiedit` succeeds | checks `path`, `filePath`, or `file_path` aliases | +| diagnostics contain errors | returns Codex `PostToolUse` blocking feedback and injects the same diagnostics as additional context so Codex fixes the file | +| no diagnostics | emits no hook output | +| unsupported extension | emits no hook output | +| missing configured language server | surfaces the install/config message through hook or MCP output | + +Deletes are ignored because they cannot introduce new diagnostics. + +## MCP Tools + +- `lsp.status` +- `lsp.diagnostics` +- `lsp.goto_definition` +- `lsp.find_references` +- `lsp.symbols` +- `lsp.prepare_rename` +- `lsp.rename` + +`lsp.rename` applies the returned workspace edit to files. Use `lsp.prepare_rename` first when possible. + +## Configuration + +Project config: + +```text +.codex/lsp-client.json +``` + +User config: + +```text +~/.codex/lsp-client.json +``` + +Example: + +```json +{ + "lsp": { + "typescript": { + "command": ["typescript-language-server", "--stdio"], + "extensions": [".ts", ".tsx", ".js", ".jsx"] + } + } +} +``` + +Built-in server definitions are used when no custom config overrides them. `lsp.status` shows which configured servers are installed or missing. + +## Codex Plugin + +The plugin ships: + +- `.codex-plugin/plugin.json` for Codex plugin discovery. +- `.mcp.json` for the `lsp` MCP server. +- `hooks/hooks.json` for the `PostToolUse` diagnostics hook. +- `skills/lsp/SKILL.md` with MCP usage guidance. + +The runtime depends on `@code-yeongyu/lsp-tools-mcp` via `file:./packages/lsp-tools-mcp`, so marketplace builds must include submodule contents. + +The hook command is: + +```bash +node "${PLUGIN_ROOT}/dist/cli.js" hook post-tool-use +``` + +The MCP command is: + +```bash +node ./packages/lsp-tools-mcp/dist/cli.js mcp +``` + +## Local Development + +```bash +git submodule update --init --recursive +npm run bootstrap # installs + builds the lsp-tools-mcp submodule +npm install +npm test +npm run typecheck +npm run check +npm pack --dry-run +``` + +The `bootstrap` script installs and builds the `lsp-tools-mcp` git submodule so +`@code-yeongyu/lsp-tools-mcp/dist/*.js` is available for the codex-lsp build. + +Smoke-test the hook: + +```bash +node dist/cli.js hook post-tool-use < test/fixtures/post-tool-use.json +``` + +Smoke-test the MCP server: + +```bash +printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node dist/cli.js mcp +``` + +## Local Codex Installation + +From the marketplace root containing this plugin: + +```bash +codex plugin marketplace add /path/to/codex-plugins +node /path/to/codex-plugins/scripts/install-local.mjs /path/to/codex-plugins +``` + +If your local Codex build exposes plugin install commands, you can install from the UI or CLI instead. For older local builds, the marketplace installer builds and copies the plugin into `~/.codex/plugins/cache//omo/0.1.0` and enables: + +```toml +[plugins."omo@code-yeongyu-codex-plugins"] +enabled = true +``` + +## Branch Rules and Releases + +- `main` is protected by `.github/branch-ruleset.json`. +- CI runs Node 20 and 22 on Ubuntu, macOS, and Windows. +- Releases are GitHub Releases tagged as `v`. +- Publishing runs from the `publish` workflow after a GitHub Release is published. + +## Privacy + +This plugin runs locally. It starts configured language-server commands on your machine and does not call a network service by itself. + +## License + +[MIT](LICENSE). + +## Related + +- [pi-lsp-client](https://github.com/code-yeongyu/pi-lsp-client) - source extension this Codex plugin ports. diff --git a/packages/omo-codex/plugin/components/lsp/biome.json b/packages/omo-codex/plugin/components/lsp/biome.json new file mode 100644 index 000000000..5aa1a0dcc --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/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/lsp/hooks/hooks.json b/packages/omo-codex/plugin/components/lsp/hooks/hooks.json new file mode 100644 index 000000000..afdfe34a9 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/hooks/hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "^(apply_patch|Write|Edit|MultiEdit|multi_edit|write|edit|multiedit)$", + "hooks": [ + { + "type": "command", + "command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook post-tool-use", + "timeout": 60, + "statusMessage": "checking LSP diagnostics" + } + ] + } + ] + } +} diff --git a/packages/omo-codex/plugin/components/lsp/package.json b/packages/omo-codex/plugin/components/lsp/package.json new file mode 100644 index 000000000..3a45e161e --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/package.json @@ -0,0 +1,64 @@ +{ + "name": "@code-yeongyu/codex-lsp", + "version": "0.2.0", + "description": "Codex plugin that exposes Language Server Protocol tools and post-edit diagnostics.", + "type": "module", + "packageManager": "npm@11.12.1", + "license": "MIT", + "homepage": "https://github.com/code-yeongyu/codex-lsp", + "repository": { + "type": "git", + "url": "git+https://github.com/code-yeongyu/codex-lsp.git" + }, + "bugs": { + "url": "https://github.com/code-yeongyu/codex-lsp/issues" + }, + "keywords": [ + "codex", + "codex-plugin", + "lsp", + "language-server-protocol", + "mcp", + "diagnostics" + ], + "bin": { + "codex-lsp": "./dist/cli.js" + }, + "files": [ + "dist", + "hooks", + "skills", + ".codex-plugin", + ".mcp.json", + "LICENSE", + "NOTICE", + "README.md", + "CHANGELOG.md" + ], + "scripts": { + "bootstrap": "node scripts/bootstrap-submodule.mjs", + "prebuild": "node scripts/bootstrap-submodule.mjs", + "build": "tsc -p tsconfig.build.json", + "pretest": "node scripts/bootstrap-submodule.mjs", + "test": "vitest --run", + "test:watch": "vitest", + "pretypecheck": "node scripts/bootstrap-submodule.mjs", + "typecheck": "tsc --noEmit", + "lint": "biome check src test", + "lint:fix": "biome check --write src test", + "precheck": "node scripts/bootstrap-submodule.mjs", + "check": "tsc --noEmit && biome check src test && tsc -p tsconfig.build.json" + }, + "dependencies": { + "@code-yeongyu/lsp-tools-mcp": "file:./packages/lsp-tools-mcp" + }, + "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/lsp/packages/lsp-tools-mcp/.gitattributes b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.gitattributes new file mode 100644 index 000000000..9363fdb74 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.gitattributes @@ -0,0 +1,13 @@ +# Normalize line endings: store LF in git, check out LF on every platform. +# Required so biome's --check passes on Windows (default core.autocrlf=true). +* text=auto eol=lf + +# Explicit binary types +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.zip binary +*.tgz binary +*.gz binary diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/CODEOWNERS b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/CODEOWNERS new file mode 100644 index 000000000..e2330ddf8 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/CODEOWNERS @@ -0,0 +1 @@ +* @code-yeongyu diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/ISSUE_TEMPLATE/bug.yml b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 000000000..206a5f9d2 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,26 @@ +name: Bug report +description: Report a reproducible lsp-tools-mcp bug. +title: "[bug]: " +labels: ["bug"] +body: + - type: textarea + id: summary + attributes: + label: Summary + description: What happened? + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Reproduction + description: Exact steps, config, command output, and affected MCP tool or language server. + validations: + required: true + - type: input + id: version + attributes: + label: Version + placeholder: 0.1.0 + validations: + required: true diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/ISSUE_TEMPLATE/feature.yml b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 000000000..4f0042f6a --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,19 @@ +name: Feature request +description: Propose a focused lsp-tools-mcp improvement. +title: "[feature]: " +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What workflow should improve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposal + description: What should lsp-tools-mcp do? + validations: + required: true diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/branch-ruleset.json b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/branch-ruleset.json new file mode 100644 index 000000000..1f482bb66 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/branch-ruleset.json @@ -0,0 +1,45 @@ +{ + "name": "main protection", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["~DEFAULT_BRANCH"], + "exclude": [] + } + }, + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { "type": "required_linear_history" }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": true, + "require_last_push_approval": false, + "required_review_thread_resolution": true + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": true, + "required_status_checks": [ + { "context": "test (ubuntu-latest · node 20)" }, + { "context": "test (ubuntu-latest · node 22)" }, + { "context": "test (macos-latest · node 20)" }, + { "context": "test (macos-latest · node 22)" } + ] + } + } + ], + "bypass_actors": [ + { + "actor_id": 5, + "actor_type": "RepositoryRole", + "bypass_mode": "always" + } + ] +} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/dependabot.yml b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/dependabot.yml new file mode 100644 index 000000000..be719fac4 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/pull_request_template.md b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/pull_request_template.md new file mode 100644 index 000000000..39f36ad46 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/pull_request_template.md @@ -0,0 +1,11 @@ +## Summary + +- + +## Validation + +- + +## Notes + +- diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/workflows/ci.yml b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/workflows/ci.yml new file mode 100644 index 000000000..6cc7a1653 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + name: test (${{ matrix.os }} · node ${{ matrix.node }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + node: ["20", "22"] + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node ${{ matrix.node }} + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Check + run: npm run check + + - name: Unit tests + run: npm test + + - name: Package smoke + run: npm pack --dry-run diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/workflows/publish.yml b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/workflows/publish.yml new file mode 100644 index 000000000..4214a99b2 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.github/workflows/publish.yml @@ -0,0 +1,51 @@ +name: publish + +on: + release: + types: [published] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node 22 + uses: actions/setup-node@v6 + with: + node-version: "22" + registry-url: https://registry.npmjs.org + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Check + run: npm run check + + - name: Unit tests + run: npm test + + - name: Package smoke + run: npm pack --dry-run + + - name: Publish to npm + run: | + if [ -z "$NODE_AUTH_TOKEN" ]; then + echo "NODE_AUTH_TOKEN is not configured; skipping npm publish." + exit 0 + fi + npm publish --access public --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.gitignore b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.gitignore new file mode 100644 index 000000000..1bf09f16c --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +*.log +.env +.DS_Store +coverage/ +.vitest/ diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/CHANGELOG.md b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/CHANGELOG.md new file mode 100644 index 000000000..4bff36af9 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +All notable changes to this project are documented in this file. + +## [0.1.0] - 2026-05-18 + +### Added + +- Initial standalone extraction from `codex-lsp`: + - LSP runtime (`src/lsp/*`) + - MCP server (`src/mcp.ts`) + - Tool definitions (`src/tools.ts`) + - Standalone CLI (`src/cli.ts`, `mcp` subcommand only) +- Config path override support: + - `LSP_TOOLS_MCP_PROJECT_CONFIG` + - `LSP_TOOLS_MCP_USER_CONFIG` +- Full test suite import (excluding Codex-specific hook tests) +- CI workflow matrix (ubuntu/macos/windows x node 20/22) +- Release-triggered npm publish workflow +- Repository governance files (ruleset, CODEOWNERS, dependabot, issue templates, PR template) diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/LICENSE b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/LICENSE new file mode 100644 index 000000000..09aac3c3b --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Yeongyu Kim + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/NOTICE b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/NOTICE new file mode 100644 index 000000000..a3adc175d --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/NOTICE @@ -0,0 +1,3 @@ +lsp-tools-mcp extracts the standalone LSP runtime from codex-lsp into a reusable package. + +The package includes adapted code originally developed for pi-lsp-client. diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/README.md b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/README.md new file mode 100644 index 000000000..906b7c614 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/README.md @@ -0,0 +1,102 @@ +# lsp-tools-mcp + +[![ci](https://github.com/code-yeongyu/lsp-tools-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/code-yeongyu/lsp-tools-mcp/actions/workflows/ci.yml) [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) + +Standalone Language Server Protocol tools exposed as a stdio MCP server. + +## Used By + +This repository is the upstream source of truth for two downstream plugins. Both consume it as a git submodule: + +| Project | Path | Role | +|---------|------|------| +| **[codex-lsp](https://github.com/code-yeongyu/codex-lsp)** | `packages/lsp-tools-mcp/` | Codex plugin that ships these LSP MCP tools plus a Codex-specific PostToolUse diagnostics hook. | +| **[oh-my-openagent](https://github.com/code-yeongyu/oh-my-openagent)** (a.k.a. `oh-my-opencode`) | `vendor/lsp-tools-mcp/` | OpenCode plugin that registers this server as a built-in Tier-1 stdio MCP. Exposes `lsp_diagnostics`, `lsp_goto_definition`, `lsp_find_references`, `lsp_symbols`, `lsp_prepare_rename`, `lsp_rename`, and `lsp_status` to all agents. | + +If you fix or extend the LSP runtime here, both downstreams pick up the change by bumping the submodule pointer. Do not fork the runtime into a downstream; land changes here instead. + +## Quick Start + +```bash +npm install +npm run check +npm test +npm run build +printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node dist/cli.js mcp +``` + +## MCP Tools + +This server exposes the following tools: + +- `lsp.status` +- `lsp.diagnostics` +- `lsp.goto_definition` +- `lsp.find_references` +- `lsp.symbols` +- `lsp.prepare_rename` +- `lsp.rename` + +Tool aliases are also available for compatibility: + +- `lsp_status` +- `lsp_diagnostics` +- `lsp_goto_definition` +- `lsp_find_references` +- `lsp_symbols` +- `lsp_prepare_rename` +- `lsp_rename` + +When an MCP host registers this server under the name `lsp` (the default in both downstreams), the tools are exposed to agents as `lsp_status`, `lsp_diagnostics`, and so on, matching the alias names above. + +## Configuration + +Default config paths (matches codex-lsp's historical layout): + +- Project: `.codex/lsp-client.json` +- User: `~/.codex/lsp-client.json` + +Path overrides via environment variables: + +- `LSP_TOOLS_MCP_PROJECT_CONFIG` +- `LSP_TOOLS_MCP_USER_CONFIG` + +Examples (oh-my-openagent points the project config at `.opencode/lsp.json` via the env var): + +```bash +LSP_TOOLS_MCP_PROJECT_CONFIG=.opencode/lsp.json node dist/cli.js mcp +LSP_TOOLS_MCP_USER_CONFIG=.opencode/lsp.json node dist/cli.js mcp +``` + +Example config file: + +```json +{ + "lsp": { + "typescript": { + "command": ["typescript-language-server", "--stdio"], + "extensions": [".ts", ".tsx", ".js", ".jsx"] + } + } +} +``` + +## Architecture + +- `src/lsp/*` standalone LSP runtime (process management, JSON-RPC transport, configuration, diagnostics, workspace edits) +- `src/tools.ts` MCP tool definitions and handlers +- `src/mcp.ts` stdio MCP server entry and registration +- `src/cli.ts` standalone CLI entry (`mcp` subcommand only) + +## Local Development + +```bash +npm install +npm run check +npm test +npm pack --dry-run +``` + +## License + +[MIT](LICENSE) diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/biome.json b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/biome.json new file mode 100644 index 000000000..5aa1a0dcc --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/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/lsp/packages/lsp-tools-mcp/package.json b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/package.json new file mode 100644 index 000000000..52a344d02 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/package.json @@ -0,0 +1,52 @@ +{ + "name": "@code-yeongyu/lsp-tools-mcp", + "version": "0.1.0", + "description": "Standalone Language Server Protocol tools exposed as a stdio MCP server.", + "type": "module", + "packageManager": "npm@11.12.1", + "license": "MIT", + "homepage": "https://github.com/code-yeongyu/lsp-tools-mcp", + "repository": { + "type": "git", + "url": "git+https://github.com/code-yeongyu/lsp-tools-mcp.git" + }, + "bugs": { + "url": "https://github.com/code-yeongyu/lsp-tools-mcp/issues" + }, + "keywords": [ + "mcp", + "lsp", + "language-server-protocol", + "model-context-protocol", + "typescript", + "nodejs" + ], + "bin": { + "lsp-tools-mcp": "./dist/cli.js" + }, + "files": [ + "dist", + "LICENSE", + "NOTICE", + "README.md", + "CHANGELOG.md" + ], + "scripts": { + "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" + }, + "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/lsp/packages/lsp-tools-mcp/src/cli.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/cli.ts new file mode 100644 index 000000000..bdc97b15f --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/cli.ts @@ -0,0 +1,27 @@ +#!/usr/bin/env node +import { argv, stderr } from "node:process"; + +import { disposeDefaultLspManager } from "./lsp/manager.js"; +import { runMcpStdioServer } from "./mcp.js"; + +async function main(): Promise { + const [command = "mcp"] = argv.slice(2); + + try { + if (command === "mcp") { + await runMcpStdioServer(); + return; + } + + stderr.write("Usage: lsp-tools-mcp [mcp]\n"); + process.exitCode = 2; + } finally { + await disposeDefaultLspManager(); + } +} + +main().catch(async (error: unknown) => { + stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`); + await disposeDefaultLspManager(); + process.exitCode = 1; +}); diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/cleanup-errors.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/cleanup-errors.ts new file mode 100644 index 000000000..30e7018bf --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/cleanup-errors.ts @@ -0,0 +1,5 @@ +export function reportBestEffortCleanupError(operation: string, error: unknown): void { + if (process.env["CODEX_LSP_DEBUG_CLEANUP"] !== "1") return; + const message = error instanceof Error ? error.message : String(error); + console.error(`[codex-lsp] ignored ${operation} failure during cleanup: ${message}`); +} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/client-wrapper.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/client-wrapper.ts new file mode 100644 index 000000000..9e9d4bc85 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/client-wrapper.ts @@ -0,0 +1,146 @@ +import { existsSync, statSync } from "node:fs"; +import { dirname, extname, join, resolve } from "node:path"; + +import type { LspClient } from "./client.js"; +import { + isLspDeadConnectionError, + LspInvalidPathError, + LspRequestTimeoutError, + LspServerInitializingError, + LspServerLookupError, +} from "./errors.js"; +import { getLspManager, type LspManager } from "./manager.js"; +import { findServerForExtension } from "./server-resolution.js"; +import type { ServerLookupResult } from "./types.js"; + +const WORKSPACE_MARKERS = [".git", "package.json", "pyproject.toml", "Cargo.toml", "go.mod", "pom.xml", "build.gradle"]; + +export function isDirectoryPath(filePath: string): boolean { + try { + return statSync(filePath).isDirectory(); + } catch { + return false; + } +} + +export function findWorkspaceRoot(filePath: string): string { + const abs = resolve(filePath); + let dir = abs; + + if (!isDirectoryPath(dir)) { + dir = dirname(dir); + } + + let prevDir = ""; + while (dir !== prevDir) { + for (const marker of WORKSPACE_MARKERS) { + if (existsSync(join(dir, marker))) { + return dir; + } + } + prevDir = dir; + dir = dirname(dir); + } + + return dirname(abs); +} + +export function formatServerLookupError(result: Exclude): string { + if (result.status === "not_installed") { + const { server, installHint } = result; + return [ + `LSP server '${server.id}' is configured but NOT INSTALLED.`, + "", + `Command not found: ${server.command[0]}`, + "", + "To install:", + ` ${installHint}`, + "", + `Supported extensions: ${server.extensions.join(", ")}`, + "", + "After installation, the server will be available automatically.", + ].join("\n"); + } + + return [ + `No LSP server configured for extension: ${result.extension}`, + "", + `Available servers: ${result.availableServers.slice(0, 10).join(", ")}${ + result.availableServers.length > 10 ? "..." : "" + }`, + "", + "Configure a custom server in '.codex/lsp-client.json':", + " {", + ' "lsp": {', + ' "my-server": {', + ' "command": ["my-lsp", "--stdio"],', + ` "extensions": ["${result.extension}"]`, + " }", + " }", + " }", + ].join("\n"); +} + +export interface WithLspClientOptions { + signal?: AbortSignal; + manager?: LspManager; +} + +const READ_ONLY_RETRY_TOOLS = new Set([ + "diagnostics", + "definition", + "references", + "documentSymbols", + "workspaceSymbols", + "prepareRename", +]); + +export async function withLspClient( + filePath: string, + fn: (client: LspClient) => Promise, + toolName: string, + options: WithLspClientOptions = {}, +): Promise { + const absPath = resolve(filePath); + + if (isDirectoryPath(absPath)) { + throw new LspInvalidPathError( + "Directory paths are not supported by this LSP tool. " + + "Use lsp.diagnostics with a directory path for directory diagnostics.", + ); + } + + const ext = extname(absPath); + const result = findServerForExtension(ext); + if (result.status !== "found") { + throw new LspServerLookupError(formatServerLookupError(result)); + } + + const server = result.server; + const root = findWorkspaceRoot(absPath); + const manager = options.manager ?? getLspManager(); + + const acquireAndCall = async (allowRetry: boolean): Promise => { + const client = await manager.getClient(root, server, options.signal); + + try { + return await fn(client); + } catch (err) { + if (allowRetry && READ_ONLY_RETRY_TOOLS.has(toolName) && isLspDeadConnectionError(err)) { + manager.invalidateClient(root, server.id, client); + return acquireAndCall(false); + } + + if (err instanceof LspRequestTimeoutError) { + if (manager.isServerInitializing(root, server.id)) { + throw new LspServerInitializingError(err); + } + } + throw err; + } finally { + manager.releaseClient(root, server.id); + } + }; + + return acquireAndCall(true); +} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/client.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/client.ts new file mode 100644 index 000000000..45dcf86fc --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/client.ts @@ -0,0 +1,170 @@ +import { readFileSync } from "node:fs"; +import { extname, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { LspClientConnection } from "./connection.js"; +import { getLanguageId } from "./language-mappings.js"; +import type { + Diagnostic, + DocumentSymbol, + Location, + LocationLink, + PrepareRenameDefaultBehavior, + PrepareRenameResult, + Range, + SymbolInfo, + WorkspaceEdit, +} from "./types.js"; + +const POST_OPEN_DELAY_MS = 1000; +const POST_DIAGNOSTICS_WAIT_MS = 500; + +export class LspClient extends LspClientConnection { + private readonly openedFiles = new Set(); + private readonly documentVersions = new Map(); + private readonly lastSyncedText = new Map(); + private readonly diagnosticPullErrors: Error[] = []; + + getDiagnosticPullErrors(): readonly Error[] { + return this.diagnosticPullErrors; + } + + async openFile(filePath: string): Promise { + const absPath = resolve(filePath); + const uri = pathToFileURL(absPath).href; + const text = readFileSync(absPath, "utf-8"); + + if (!this.openedFiles.has(absPath)) { + const ext = extname(absPath); + const languageId = getLanguageId(ext); + const version = 1; + + await this.sendNotification("textDocument/didOpen", { + textDocument: { + uri, + languageId, + version, + text, + }, + }); + + this.openedFiles.add(absPath); + this.documentVersions.set(uri, version); + this.lastSyncedText.set(uri, text); + await new Promise((r) => setTimeout(r, POST_OPEN_DELAY_MS)); + return; + } + + const prevText = this.lastSyncedText.get(uri); + if (prevText === text) { + return; + } + + const nextVersion = (this.documentVersions.get(uri) ?? 1) + 1; + this.documentVersions.set(uri, nextVersion); + this.lastSyncedText.set(uri, text); + + await this.sendNotification("textDocument/didChange", { + textDocument: { uri, version: nextVersion }, + contentChanges: [{ text }], + }); + + await this.sendNotification("textDocument/didSave", { + textDocument: { uri }, + text, + }); + } + + async definition( + filePath: string, + line: number, + character: number, + ): Promise | null> { + const absPath = resolve(filePath); + await this.openFile(absPath); + return this.sendRequest | null>( + "textDocument/definition", + { + textDocument: { uri: pathToFileURL(absPath).href }, + position: { line: line - 1, character }, + }, + ); + } + + async references(filePath: string, line: number, character: number, includeDeclaration = true): Promise { + const absPath = resolve(filePath); + await this.openFile(absPath); + return this.sendRequest("textDocument/references", { + textDocument: { uri: pathToFileURL(absPath).href }, + position: { line: line - 1, character }, + context: { includeDeclaration }, + }); + } + + async documentSymbols(filePath: string): Promise> { + const absPath = resolve(filePath); + await this.openFile(absPath); + return this.sendRequest>("textDocument/documentSymbol", { + textDocument: { uri: pathToFileURL(absPath).href }, + }); + } + + async workspaceSymbols(query: string): Promise { + return this.sendRequest("workspace/symbol", { query }); + } + + private isUnsupportedDiagnosticPullError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const code = "code" in error && typeof error.code === "number" ? error.code : undefined; + if (code === -32601) return true; + return /unsupported|not supported|method not found|unknown request/i.test(error.message); + } + + async diagnostics(filePath: string): Promise<{ items: Diagnostic[] }> { + const absPath = resolve(filePath); + const uri = pathToFileURL(absPath).href; + await this.openFile(absPath); + await new Promise((r) => setTimeout(r, POST_DIAGNOSTICS_WAIT_MS)); + + try { + const result = await this.sendRequest<{ items?: Diagnostic[] }>("textDocument/diagnostic", { + textDocument: { uri }, + }); + if (result.items) { + return { items: result.items }; + } + } catch (error) { + if (!this.isUnsupportedDiagnosticPullError(error)) { + this.diagnosticPullErrors.push(error instanceof Error ? error : new Error(String(error))); + } + } + + return { items: this.getStoredDiagnostics(uri) }; + } + + async prepareRename( + filePath: string, + line: number, + character: number, + ): Promise { + const absPath = resolve(filePath); + await this.openFile(absPath); + return this.sendRequest( + "textDocument/prepareRename", + { + textDocument: { uri: pathToFileURL(absPath).href }, + position: { line: line - 1, character }, + }, + ); + } + + async rename(filePath: string, line: number, character: number, newName: string): Promise { + const absPath = resolve(filePath); + await this.openFile(absPath); + return this.sendRequest("textDocument/rename", { + textDocument: { uri: pathToFileURL(absPath).href }, + position: { line: line - 1, character }, + newName, + }); + } +} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/config-loader.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/config-loader.ts new file mode 100644 index 000000000..245938b9f --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/config-loader.ts @@ -0,0 +1,188 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { isAbsolute, join } from "node:path"; + +import { BUILTIN_SERVERS } from "./server-definitions.js"; +import type { ResolvedServer } from "./types.js"; + +interface LspEntry { + disabled?: boolean; + command?: string[]; + extensions?: string[]; + priority?: number; + env?: Record; + initialization?: Record; +} + +interface ConfigJson { + lsp?: Record; +} + +type ConfigSource = "project" | "user"; + +export interface ServerWithSource extends ResolvedServer { + source: "project" | "user" | "builtin"; +} + +export function getConfigPaths(): { project: string; user: string } { + const cwd = process.cwd(); + const projectOverride = process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"]; + const userOverride = process.env["LSP_TOOLS_MCP_USER_CONFIG"]; + return { + project: projectOverride + ? isAbsolute(projectOverride) + ? projectOverride + : join(cwd, projectOverride) + : join(cwd, ".codex", "lsp-client.json"), + user: userOverride + ? isAbsolute(userOverride) + ? userOverride + : join(homedir(), userOverride) + : join(homedir(), ".codex", "lsp-client.json"), + }; +} + +function loadJsonFile(path: string): ConfigJson | null { + if (!existsSync(path)) return null; + try { + const parsed: unknown = JSON.parse(readFileSync(path, "utf-8")); + return isConfigJson(parsed) ? parsed : null; + } catch { + return null; + } +} + +export function loadAllConfigs(): Map { + const paths = getConfigPaths(); + const configs = new Map(); + + const project = loadJsonFile(paths.project); + if (project) configs.set("project", project); + + const user = loadJsonFile(paths.user); + if (user) configs.set("user", user); + + return configs; +} + +export function getMergedServers(): ServerWithSource[] { + const configs = loadAllConfigs(); + const servers: ServerWithSource[] = []; + const disabled = new Set(); + const seen = new Set(); + + const sources: ConfigSource[] = ["project", "user"]; + + for (const source of sources) { + const config = configs.get(source); + if (!config?.lsp) continue; + + for (const [id, rawEntry] of Object.entries(config.lsp)) { + const entry = parseLspEntry(rawEntry); + if (!entry) continue; + if (entry.disabled) { + disabled.add(id); + continue; + } + + if (seen.has(id)) continue; + if (!entry.command || !entry.extensions) continue; + + const server: ServerWithSource = { + id, + command: entry.command, + extensions: entry.extensions, + priority: entry.priority ?? 0, + source, + }; + if (entry.env !== undefined) { + server.env = entry.env; + } + if (entry.initialization !== undefined) { + server.initialization = entry.initialization; + } + servers.push(server); + seen.add(id); + } + } + + for (const [id, config] of Object.entries(BUILTIN_SERVERS)) { + if (disabled.has(id) || seen.has(id)) continue; + + servers.push({ + id, + command: config.command, + extensions: config.extensions, + priority: -100, + source: "builtin", + }); + } + + return servers.sort((a, b) => { + if (a.source !== b.source) { + const order: Record<"project" | "user" | "builtin", number> = { + project: 0, + user: 1, + builtin: 2, + }; + return order[a.source] - order[b.source]; + } + return b.priority - a.priority; + }); +} + +function isConfigJson(value: unknown): value is ConfigJson { + if (!isRecord(value)) return false; + const lsp = value["lsp"]; + return lsp === undefined || isRecord(lsp); +} + +function parseLspEntry(value: unknown): LspEntry | null { + return isLspEntry(value) ? value : null; +} + +function isLspEntry(value: unknown): value is LspEntry { + if (!isRecord(value)) return false; + const disabled = value["disabled"]; + const command = value["command"]; + const extensions = value["extensions"]; + const priority = value["priority"]; + const env = value["env"]; + const initialization = value["initialization"]; + return ( + (disabled === undefined || typeof disabled === "boolean") && + (command === undefined || isStringArray(command)) && + (extensions === undefined || isStringArray(extensions)) && + (priority === undefined || typeof priority === "number") && + (env === undefined || isStringRecord(env)) && + (initialization === undefined || isRecord(initialization)) + ); +} + +function isStringArray(value: unknown): value is 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); +} + +export function getDisabledServerIds(): Set { + const configs = loadAllConfigs(); + const disabled = new Set(); + + for (const config of configs.values()) { + if (!config.lsp) continue; + for (const [id, rawEntry] of Object.entries(config.lsp)) { + const entry = parseLspEntry(rawEntry); + if (!entry) continue; + if (entry.disabled) disabled.add(id); + } + } + + return disabled; +} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/connection.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/connection.ts new file mode 100644 index 000000000..8cf905fce --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/connection.ts @@ -0,0 +1,69 @@ +import { pathToFileURL } from "node:url"; + +import { LspClientTransport } from "./transport.js"; + +const INITIALIZE_SETTLE_MS = 300; + +export class LspClientConnection extends LspClientTransport { + async initialize(): Promise { + const rootUri = pathToFileURL(this.root).href; + await this.sendRequest("initialize", { + processId: process.pid, + rootUri, + rootPath: this.root, + workspaceFolders: [{ uri: rootUri, name: "workspace" }], + capabilities: { + textDocument: { + hover: { contentFormat: ["markdown", "plaintext"] }, + definition: { linkSupport: true }, + references: {}, + documentSymbol: { hierarchicalDocumentSymbolSupport: true }, + publishDiagnostics: {}, + rename: { + prepareSupport: true, + prepareSupportDefaultBehavior: 1, + honorsChangeAnnotations: true, + }, + codeAction: { + codeActionLiteralSupport: { + codeActionKind: { + valueSet: [ + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports", + "source.fixAll", + ], + }, + }, + isPreferredSupport: true, + disabledSupport: true, + dataSupport: true, + resolveSupport: { + properties: ["edit", "command"], + }, + }, + }, + workspace: { + symbol: {}, + workspaceFolders: true, + configuration: true, + applyEdit: true, + workspaceEdit: { + documentChanges: true, + }, + }, + }, + initializationOptions: this.server.initialization, + }); + await this.sendNotification("initialized"); + await this.sendNotification("workspace/didChangeConfiguration", { + settings: { json: { validate: { enable: true } } }, + }); + // Some servers accept initialized before their diagnostics/indexing handlers are ready. + await new Promise((r) => setTimeout(r, INITIALIZE_SETTLE_MS)); + } +} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/constants.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/constants.ts new file mode 100644 index 000000000..8dbddbbf5 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/constants.ts @@ -0,0 +1,11 @@ +export const DEFAULT_MAX_REFERENCES = 200; +export const DEFAULT_MAX_SYMBOLS = 200; +export const DEFAULT_MAX_DIAGNOSTICS = 200; +export const DEFAULT_MAX_DIRECTORY_FILES = 50; + +export const REQUEST_TIMEOUT_MS = 15_000; +export const INIT_TIMEOUT_MS = 60_000; +export const IDLE_TIMEOUT_MS = 5 * 60_000; +export const REAPER_INTERVAL_MS = 60_000; +export const STOP_HARD_KILL_TIMEOUT_MS = 5_000; +export const STOP_SIGKILL_GRACE_MS = 1_000; diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/directory-diagnostics.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/directory-diagnostics.ts new file mode 100644 index 000000000..90e6849a8 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/directory-diagnostics.ts @@ -0,0 +1,152 @@ +import { existsSync, lstatSync, readdirSync, type Stats } from "node:fs"; +import { extname, join, resolve } from "node:path"; +import { findWorkspaceRoot, formatServerLookupError } from "./client-wrapper.js"; +import { DEFAULT_MAX_DIAGNOSTICS, DEFAULT_MAX_DIRECTORY_FILES } from "./constants.js"; +import { LspInvalidPathError, LspServerLookupError } from "./errors.js"; +import { filterDiagnosticsBySeverity, formatDiagnostic } from "./formatters.js"; +import { getLspManager } from "./manager.js"; +import { findServerForExtension } from "./server-resolution.js"; +import type { Diagnostic, SeverityFilter } from "./types.js"; + +const SKIP_DIRECTORIES = new Set(["node_modules", ".git", "dist", "build", ".next", "out"]); + +interface FileDiagnostic { + filePath: string; + diagnostic: Diagnostic; +} + +export function collectFilesWithExtension(dir: string, extension: string, maxFiles: number): string[] { + const files: string[] = []; + + function walk(currentDir: string): void { + if (files.length >= maxFiles) return; + + let entries: string[] = []; + try { + entries = readdirSync(currentDir); + } catch { + return; + } + + for (const entry of entries) { + if (files.length >= maxFiles) return; + + const fullPath = join(currentDir, entry); + + let stat: Stats | undefined; + try { + stat = lstatSync(fullPath); + } catch { + continue; + } + + if (!stat || stat.isSymbolicLink()) continue; + + if (stat.isDirectory()) { + if (!SKIP_DIRECTORIES.has(entry)) { + walk(fullPath); + } + } else if (stat.isFile() && extname(fullPath) === extension) { + files.push(fullPath); + } + } + } + + walk(dir); + return files; +} + +export async function aggregateDiagnosticsForDirectory( + directory: string, + extension: string, + severity?: SeverityFilter, + maxFiles: number = DEFAULT_MAX_DIRECTORY_FILES, +): Promise { + if (!extension.startsWith(".")) { + throw new LspInvalidPathError( + `Extension must start with a dot (e.g., ".ts", not "${extension}"). Use ".${extension}" instead.`, + ); + } + + const absDir = resolve(directory); + if (!existsSync(absDir)) { + throw new LspInvalidPathError(`Directory does not exist: ${absDir}`); + } + + const serverResult = findServerForExtension(extension); + if (serverResult.status !== "found") { + throw new LspServerLookupError(formatServerLookupError(serverResult)); + } + + const server = serverResult.server; + const allFiles = collectFilesWithExtension(absDir, extension, maxFiles + 1); + const wasCapped = allFiles.length > maxFiles; + const filesToProcess = allFiles.slice(0, maxFiles); + + if (filesToProcess.length === 0) { + return [ + `Directory: ${absDir}`, + `Extension: ${extension}`, + "Files scanned: 0", + `No files found with extension "${extension}".`, + ].join("\n"); + } + + const root = findWorkspaceRoot(absDir); + const manager = getLspManager(); + const allDiagnostics: FileDiagnostic[] = []; + const fileErrors: { file: string; error: string }[] = []; + + const client = await manager.getClient(root, server); + try { + for (const file of filesToProcess) { + try { + const result = await client.diagnostics(file); + const filtered = filterDiagnosticsBySeverity(result.items, severity); + allDiagnostics.push( + ...filtered.map((diagnostic) => ({ + filePath: file, + diagnostic, + })), + ); + } catch (e) { + fileErrors.push({ + file, + error: e instanceof Error ? e.message : String(e), + }); + } + } + } finally { + manager.releaseClient(root, server.id); + } + + const displayDiagnostics = allDiagnostics.slice(0, DEFAULT_MAX_DIAGNOSTICS); + const wasDiagCapped = allDiagnostics.length > DEFAULT_MAX_DIAGNOSTICS; + + const lines: string[] = [ + `Directory: ${absDir}`, + `Extension: ${extension}`, + `Files scanned: ${filesToProcess.length}${wasCapped ? ` (capped at ${maxFiles})` : ""}`, + `Files with errors: ${fileErrors.length}`, + `Total diagnostics: ${allDiagnostics.length}`, + ]; + + if (fileErrors.length > 0) { + lines.push("", "File processing errors:"); + for (const { file, error } of fileErrors) { + lines.push(` ${file}: ${error}`); + } + } + + if (displayDiagnostics.length > 0) { + lines.push(""); + for (const { filePath, diagnostic } of displayDiagnostics) { + lines.push(`${filePath}: ${formatDiagnostic(diagnostic)}`); + } + if (wasDiagCapped) { + lines.push("", `... (${allDiagnostics.length - DEFAULT_MAX_DIAGNOSTICS} more diagnostics not shown)`); + } + } + + return lines.join("\n"); +} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/errors.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/errors.ts new file mode 100644 index 000000000..186c3c716 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/errors.ts @@ -0,0 +1,63 @@ +export class LspConnectionClosedError extends Error { + override readonly name = "LspConnectionClosedError"; + + constructor( + readonly serverId: string, + readonly root: string, + message?: string, + ) { + super(message ?? `LSP connection closed for ${serverId} at ${root}`); + } +} + +export class LspProcessExitedError extends Error { + override readonly name = "LspProcessExitedError"; + + constructor( + readonly serverId: string, + readonly root: string, + readonly exitCode: number | null, + readonly stderrTail?: string, + ) { + const stderrSuffix = stderrTail ? `\nstderr tail: ${stderrTail}` : ""; + super(`LSP server ${serverId} at ${root} exited with code ${exitCode ?? "null"}${stderrSuffix}`); + } +} + +export class LspRequestTimeoutError extends Error { + override readonly name = "LspRequestTimeoutError"; + + constructor( + readonly method: string, + readonly stderrTail?: string, + ) { + const stderrSuffix = stderrTail ? `\nrecent stderr: ${stderrTail}` : ""; + super(`LSP request timeout (method: ${method})${stderrSuffix}`); + } +} + +export class LspInvalidPathError extends Error { + override readonly name = "LspInvalidPathError"; +} + +export class LspServerLookupError extends Error { + override readonly name = "LspServerLookupError"; +} + +export class LspServerInitializingError extends Error { + override readonly name = "LspServerInitializingError"; + + constructor(readonly originalError: LspRequestTimeoutError) { + super( + `LSP server is still initializing. Please retry in a few seconds. Original error: ${originalError.message}`, + ); + } +} + +export class LspProcessSpawnError extends Error { + override readonly name = "LspProcessSpawnError"; +} + +export function isLspDeadConnectionError(err: unknown): err is LspConnectionClosedError | LspProcessExitedError { + return err instanceof LspConnectionClosedError || err instanceof LspProcessExitedError; +} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/formatters.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/formatters.ts new file mode 100644 index 000000000..7f8e836a6 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/formatters.ts @@ -0,0 +1,141 @@ +import { fileURLToPath } from "node:url"; + +import { SEVERITY_MAP, SYMBOL_KIND_MAP } from "./language-mappings.js"; +import type { + Diagnostic, + DocumentSymbol, + Location, + LocationLink, + PrepareRenameDefaultBehavior, + PrepareRenameResult, + Range, + SeverityFilter, + SymbolInfo, +} from "./types.js"; +import type { ApplyResult } from "./workspace-edit.js"; + +type FilteredSeverity = Exclude; + +const DIAGNOSTIC_SEVERITY_FILTERS = { + error: 1, + warning: 2, + information: 3, + hint: 4, +} as const satisfies Readonly>; + +export function uriToPath(uri: string): string { + return fileURLToPath(uri); +} + +export function formatLocation(loc: Location | LocationLink): string { + if ("targetUri" in loc) { + const uri = uriToPath(loc.targetUri); + const line = loc.targetRange.start.line + 1; + const char = loc.targetRange.start.character; + return `${uri}:${line}:${char}`; + } + + const uri = uriToPath(loc.uri); + const line = loc.range.start.line + 1; + const char = loc.range.start.character; + return `${uri}:${line}:${char}`; +} + +export function formatSymbolKind(kind: number): string { + return SYMBOL_KIND_MAP[kind] ?? `Unknown(${kind})`; +} + +export function formatSeverity(severity: number | undefined): string { + if (!severity) return "unknown"; + return SEVERITY_MAP[severity] ?? `unknown(${severity})`; +} + +export function formatDocumentSymbol(symbol: DocumentSymbol, indent = 0): string { + const prefix = " ".repeat(indent); + const kind = formatSymbolKind(symbol.kind); + const line = symbol.range.start.line + 1; + let result = `${prefix}${symbol.name} (${kind}) - line ${line}`; + + if (symbol.children && symbol.children.length > 0) { + for (const child of symbol.children) { + result += `\n${formatDocumentSymbol(child, indent + 1)}`; + } + } + + return result; +} + +export function formatSymbolInfo(symbol: SymbolInfo): string { + const kind = formatSymbolKind(symbol.kind); + const loc = formatLocation(symbol.location); + const container = symbol.containerName ? ` (in ${symbol.containerName})` : ""; + return `${symbol.name} (${kind})${container} - ${loc}`; +} + +export function formatDiagnostic(diag: Diagnostic): string { + const severity = formatSeverity(diag.severity); + const line = diag.range.start.line + 1; + const char = diag.range.start.character; + const source = diag.source ? `[${diag.source}]` : ""; + const code = diag.code ? ` (${diag.code})` : ""; + return `${severity}${source}${code} at ${line}:${char}: ${diag.message}`; +} + +export function filterDiagnosticsBySeverity(diagnostics: Diagnostic[], severityFilter?: SeverityFilter): Diagnostic[] { + if (!severityFilter || severityFilter === "all") { + return diagnostics; + } + + const targetSeverity = DIAGNOSTIC_SEVERITY_FILTERS[severityFilter]; + return diagnostics.filter((d) => d.severity === targetSeverity); +} + +export function formatPrepareRenameResult( + result: PrepareRenameResult | PrepareRenameDefaultBehavior | Range | null, +): string { + if (!result) return "Cannot rename at this position"; + + if ("defaultBehavior" in result) { + return result.defaultBehavior ? "Rename supported (using default behavior)" : "Cannot rename at this position"; + } + + if ("range" in result && result.range) { + const startLine = result.range.start.line + 1; + const startChar = result.range.start.character; + const endLine = result.range.end.line + 1; + const endChar = result.range.end.character; + const placeholder = result.placeholder ? ` (current: "${result.placeholder}")` : ""; + return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}${placeholder}`; + } + + if ("start" in result && "end" in result) { + const startLine = result.start.line + 1; + const startChar = result.start.character; + const endLine = result.end.line + 1; + const endChar = result.end.character; + return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}`; + } + + return "Cannot rename at this position"; +} + +export function formatApplyResult(result: ApplyResult): string { + const lines: string[] = []; + + if (result.success) { + lines.push(`Applied ${result.totalEdits} edit(s) to ${result.filesModified.length} file(s):`); + for (const file of result.filesModified) { + lines.push(` - ${file}`); + } + } else { + lines.push("Failed to apply some changes:"); + for (const err of result.errors) { + lines.push(` Error: ${err}`); + } + if (result.filesModified.length > 0) { + lines.push(`Successfully modified: ${result.filesModified.join(", ")}`); + } + } + + return lines.join("\n"); +} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/infer-extension.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/infer-extension.ts new file mode 100644 index 000000000..31771cbcc --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/infer-extension.ts @@ -0,0 +1,65 @@ +import { lstatSync, readdirSync } from "node:fs"; +import { extname, join } from "node:path"; + +import { EXT_TO_LANG } from "./language-mappings.js"; + +const SKIP_DIRECTORIES = new Set(["node_modules", ".git", "dist", "build", ".next", "out"]); +const MAX_SCAN_ENTRIES = 500; + +export function inferExtensionFromDirectory(directory: string): string | null { + const extensionCounts = new Map(); + let scanned = 0; + + function walk(dir: string): void { + if (scanned >= MAX_SCAN_ENTRIES) return; + + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return; + } + + for (const entry of entries) { + if (scanned >= MAX_SCAN_ENTRIES) return; + + const fullPath = join(dir, entry); + + let stat: ReturnType | undefined; + try { + stat = lstatSync(fullPath); + } catch { + continue; + } + + if (stat.isSymbolicLink()) continue; + scanned++; + + if (stat.isDirectory()) { + if (!SKIP_DIRECTORIES.has(entry)) { + walk(fullPath); + } + } else if (stat.isFile()) { + const ext = extname(fullPath); + if (ext && ext in EXT_TO_LANG) { + extensionCounts.set(ext, (extensionCounts.get(ext) ?? 0) + 1); + } + } + } + } + + walk(directory); + + if (extensionCounts.size === 0) return null; + + let maxExt = ""; + let maxCount = 0; + for (const [ext, count] of extensionCounts) { + if (count > maxCount) { + maxCount = count; + maxExt = ext; + } + } + + return maxExt || null; +} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/json-rpc-connection.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/json-rpc-connection.ts new file mode 100644 index 000000000..2769466af --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/json-rpc-connection.ts @@ -0,0 +1,296 @@ +type JsonRpcId = number | string | null; + +interface PendingRequest { + resolve(result: unknown): void; + reject(error: Error): void; +} + +type NotificationHandler = (params: unknown) => void; +type RequestHandler = (params: unknown) => Promise | unknown; + +const HEADER_SEPARATOR = "\r\n\r\n"; +const PARSE_ERROR = -32700; +const INVALID_REQUEST = -32600; +const METHOD_NOT_FOUND = -32601; +const INTERNAL_ERROR = -32603; + +export class JsonRpcConnection { + private readonly pendingRequests = new Map(); + private readonly notificationHandlers = new Map(); + private readonly requestHandlers = new Map(); + private readonly closeHandlers: Array<() => void> = []; + private readonly errorHandlers: Array<(error: Error) => void> = []; + private inputBuffer = Buffer.alloc(0); + private nextRequestId = 1; + private listening = false; + private disposed = false; + + constructor( + private readonly reader: NodeJS.ReadableStream, + private readonly writer: NodeJS.WritableStream, + ) {} + + listen(): void { + if (this.listening) return; + this.listening = true; + this.reader.on("data", this.handleData); + this.reader.on("close", this.handleClose); + this.reader.on("end", this.handleClose); + this.reader.on("error", this.handleStreamError); + this.writer.on("error", this.handleStreamError); + } + + onNotification(method: string, handler: NotificationHandler): void { + this.notificationHandlers.set(method, handler); + } + + onRequest(method: string, handler: RequestHandler): void { + this.requestHandlers.set(method, handler); + } + + onClose(handler: () => void): void { + this.closeHandlers.push(handler); + } + + onError(handler: (error: Error) => void): void { + this.errorHandlers.push(handler); + } + + async sendRequest(method: string, params?: unknown): Promise { + if (this.disposed) throw new Error("JSON-RPC connection is disposed"); + + const id = this.nextRequestId; + this.nextRequestId += 1; + const message = params === undefined ? { jsonrpc: "2.0", id, method } : { jsonrpc: "2.0", id, method, params }; + + const responsePromise = new Promise((resolve, reject) => { + this.pendingRequests.set(String(id), { + resolve(result) { + resolve(result as T); + }, + reject, + }); + }); + + try { + await this.writeMessage(message); + } catch (error) { + this.pendingRequests.delete(String(id)); + throw error; + } + + return responsePromise; + } + + async sendNotification(method: string, params?: unknown): Promise { + if (this.disposed) return; + const message = params === undefined ? { jsonrpc: "2.0", method } : { jsonrpc: "2.0", method, params }; + await this.writeMessage(message); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.reader.off("data", this.handleData); + this.reader.off("close", this.handleClose); + this.reader.off("end", this.handleClose); + this.reader.off("error", this.handleStreamError); + this.writer.off("error", this.handleStreamError); + for (const pending of this.pendingRequests.values()) { + pending.reject(new Error("JSON-RPC connection disposed")); + } + this.pendingRequests.clear(); + this.notificationHandlers.clear(); + this.requestHandlers.clear(); + } + + private readonly handleData = (chunk: Buffer | string): void => { + const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "utf8"); + this.inputBuffer = Buffer.concat([this.inputBuffer, chunkBuffer]); + this.drainInputBuffer(); + }; + + private readonly handleClose = (): void => { + for (const handler of this.closeHandlers) { + handler(); + } + }; + + private readonly handleStreamError = (error: Error): void => { + this.emitError(error); + }; + + private drainInputBuffer(): void { + while (true) { + const headerEnd = this.inputBuffer.indexOf(HEADER_SEPARATOR); + if (headerEnd === -1) return; + + const headers = this.inputBuffer.subarray(0, headerEnd).toString("ascii"); + const contentLength = parseContentLength(headers); + if (contentLength === null) { + this.inputBuffer = Buffer.alloc(0); + this.emitError(new Error("JSON-RPC message is missing Content-Length header")); + return; + } + + const bodyStart = headerEnd + Buffer.byteLength(HEADER_SEPARATOR); + const bodyEnd = bodyStart + contentLength; + if (this.inputBuffer.length < bodyEnd) return; + + const body = this.inputBuffer.subarray(bodyStart, bodyEnd).toString("utf8"); + this.inputBuffer = this.inputBuffer.subarray(bodyEnd); + this.dispatchBody(body); + } + } + + private dispatchBody(body: string): void { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch (error) { + void this.writeError(null, PARSE_ERROR, error instanceof Error ? error.message : "Parse error").catch( + (writeError) => this.emitError(toError(writeError)), + ); + return; + } + + if (!isJsonRpcObject(parsed)) { + void this.writeError(null, INVALID_REQUEST, "Invalid JSON-RPC message").catch((error) => + this.emitError(toError(error)), + ); + return; + } + + if ("id" in parsed && ("result" in parsed || "error" in parsed)) { + this.handleResponse(parsed); + return; + } + + if (typeof parsed["method"] !== "string") { + const id = getMessageId(parsed) ?? null; + void this.writeError(id, INVALID_REQUEST, "Invalid JSON-RPC method").catch((error) => + this.emitError(toError(error)), + ); + return; + } + + if ("id" in parsed) { + this.handleRequest(parsed); + return; + } + + this.handleNotification(parsed["method"], parsed["params"]); + } + + private handleResponse(message: Record): void { + const id = getMessageId(message); + if (id === undefined) return; + const pending = this.pendingRequests.get(String(id)); + if (!pending) return; + this.pendingRequests.delete(String(id)); + + if ("error" in message) { + pending.reject(jsonRpcErrorToError(message["error"])); + return; + } + + pending.resolve(message["result"]); + } + + private handleNotification(method: string, params: unknown): void { + const handler = this.notificationHandlers.get(method); + if (!handler) return; + try { + handler(params); + } catch (error) { + this.emitError(toError(error)); + } + } + + private handleRequest(message: Record): void { + const id = getMessageId(message); + if (id === undefined) { + void this.writeError(null, INVALID_REQUEST, "Invalid JSON-RPC id").catch((error) => + this.emitError(toError(error)), + ); + return; + } + + const method = typeof message["method"] === "string" ? message["method"] : ""; + const handler = this.requestHandlers.get(method); + if (!handler) { + void this.writeError(id, METHOD_NOT_FOUND, `Method not found: ${method}`).catch((error) => + this.emitError(toError(error)), + ); + return; + } + + Promise.resolve() + .then(() => handler(message["params"])) + .then( + (result) => this.writeMessage({ jsonrpc: "2.0", id, result }), + (error) => this.writeError(id, INTERNAL_ERROR, toError(error).message), + ) + .catch((error) => this.emitError(toError(error))); + } + + private async writeError(id: JsonRpcId, code: number, message: string): Promise { + await this.writeMessage({ jsonrpc: "2.0", id, error: { code, message } }); + } + + private writeMessage(message: Record): Promise { + const body = JSON.stringify(message); + const payload = `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`; + return new Promise((resolve, reject) => { + this.writer.write(payload, (error?: Error | null) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + + private emitError(error: Error): void { + for (const handler of this.errorHandlers) { + handler(error); + } + } +} + +function parseContentLength(headers: string): number | null { + for (const line of headers.split("\r\n")) { + const separatorIndex = line.indexOf(":"); + if (separatorIndex === -1) continue; + const name = line.slice(0, separatorIndex).trim().toLowerCase(); + if (name !== "content-length") continue; + const value = Number.parseInt(line.slice(separatorIndex + 1).trim(), 10); + return Number.isFinite(value) && value >= 0 ? value : null; + } + return null; +} + +function isJsonRpcObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function getMessageId(message: Record): JsonRpcId | undefined { + const id = message["id"]; + if (typeof id === "number" || typeof id === "string" || id === null) return id; + return undefined; +} + +function jsonRpcErrorToError(value: unknown): Error { + if (!isJsonRpcObject(value)) return new Error("JSON-RPC request failed"); + const message = typeof value["message"] === "string" ? value["message"] : "JSON-RPC request failed"; + const error = new Error(message); + if (typeof value["code"] === "number") { + error.name = `JsonRpcError(${value["code"]})`; + } + return error; +} + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/language-mappings.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/language-mappings.ts new file mode 100644 index 000000000..ace3c2345 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/language-mappings.ts @@ -0,0 +1,172 @@ +export const SYMBOL_KIND_MAP: Record = { + 1: "File", + 2: "Module", + 3: "Namespace", + 4: "Package", + 5: "Class", + 6: "Method", + 7: "Property", + 8: "Field", + 9: "Constructor", + 10: "Enum", + 11: "Interface", + 12: "Function", + 13: "Variable", + 14: "Constant", + 15: "String", + 16: "Number", + 17: "Boolean", + 18: "Array", + 19: "Object", + 20: "Key", + 21: "Null", + 22: "EnumMember", + 23: "Struct", + 24: "Event", + 25: "Operator", + 26: "TypeParameter", +}; + +export const SEVERITY_MAP: Record = { + 1: "error", + 2: "warning", + 3: "information", + 4: "hint", +}; + +export const EXT_TO_LANG: Record = { + ".abap": "abap", + ".bat": "bat", + ".bib": "bibtex", + ".bibtex": "bibtex", + ".clj": "clojure", + ".cljs": "clojure", + ".cljc": "clojure", + ".edn": "clojure", + ".coffee": "coffeescript", + ".c": "c", + ".cpp": "cpp", + ".cxx": "cpp", + ".cc": "cpp", + ".c++": "cpp", + ".cs": "csharp", + ".css": "css", + ".d": "d", + ".pas": "pascal", + ".pascal": "pascal", + ".diff": "diff", + ".patch": "diff", + ".dart": "dart", + ".dockerfile": "dockerfile", + ".ex": "elixir", + ".exs": "elixir", + ".erl": "erlang", + ".hrl": "erlang", + ".fs": "fsharp", + ".fsi": "fsharp", + ".fsx": "fsharp", + ".fsscript": "fsharp", + ".gitcommit": "git-commit", + ".gitrebase": "git-rebase", + ".go": "go", + ".groovy": "groovy", + ".gleam": "gleam", + ".hbs": "handlebars", + ".handlebars": "handlebars", + ".hs": "haskell", + ".html": "html", + ".htm": "html", + ".ini": "ini", + ".java": "java", + ".js": "javascript", + ".jsx": "javascriptreact", + ".json": "json", + ".jsonc": "jsonc", + ".tex": "latex", + ".latex": "latex", + ".less": "less", + ".lua": "lua", + ".makefile": "makefile", + makefile: "makefile", + ".md": "markdown", + ".markdown": "markdown", + ".m": "objective-c", + ".mm": "objective-cpp", + ".pl": "perl", + ".pm": "perl", + ".pm6": "perl6", + ".php": "php", + ".ps1": "powershell", + ".psm1": "powershell", + ".pug": "jade", + ".jade": "jade", + ".py": "python", + ".pyi": "python", + ".r": "r", + ".cshtml": "razor", + ".razor": "razor", + ".rb": "ruby", + ".rake": "ruby", + ".gemspec": "ruby", + ".ru": "ruby", + ".erb": "erb", + ".html.erb": "erb", + ".js.erb": "erb", + ".css.erb": "erb", + ".json.erb": "erb", + ".rs": "rust", + ".scss": "scss", + ".sass": "sass", + ".scala": "scala", + ".shader": "shaderlab", + ".sh": "shellscript", + ".bash": "shellscript", + ".zsh": "shellscript", + ".ksh": "shellscript", + ".sql": "sql", + ".svelte": "svelte", + ".swift": "swift", + ".ts": "typescript", + ".tsx": "typescriptreact", + ".mts": "typescript", + ".cts": "typescript", + ".mtsx": "typescriptreact", + ".ctsx": "typescriptreact", + ".xml": "xml", + ".xsl": "xsl", + ".yaml": "yaml", + ".yml": "yaml", + ".mjs": "javascript", + ".cjs": "javascript", + ".vue": "vue", + ".zig": "zig", + ".zon": "zig", + ".astro": "astro", + ".ml": "ocaml", + ".mli": "ocaml", + ".tf": "terraform", + ".tfvars": "terraform-vars", + ".hcl": "hcl", + ".nix": "nix", + ".typ": "typst", + ".typc": "typst", + ".ets": "typescript", + ".lhs": "haskell", + ".kt": "kotlin", + ".kts": "kotlin", + ".prisma": "prisma", + ".h": "c", + ".hpp": "cpp", + ".hh": "cpp", + ".hxx": "cpp", + ".h++": "cpp", + ".objc": "objective-c", + ".objcpp": "objective-cpp", + ".fish": "fish", + ".graphql": "graphql", + ".gql": "graphql", +}; + +export function getLanguageId(ext: string): string { + return EXT_TO_LANG[ext] ?? "plaintext"; +} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/manager.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/manager.ts new file mode 100644 index 000000000..6b2e95a54 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/manager.ts @@ -0,0 +1,369 @@ +import { reportBestEffortCleanupError } from "./cleanup-errors.js"; +import { LspClient } from "./client.js"; +import { IDLE_TIMEOUT_MS, INIT_TIMEOUT_MS, REAPER_INTERVAL_MS } from "./constants.js"; +import { installProcessSignalCleanup } from "./process-signal-cleanup.js"; +import type { ResolvedServer } from "./types.js"; + +interface ManagedClient { + client: LspClient; + refCount: number; + pendingWaiters: number; + lastUsedAt: number; + initPromise: Promise | null; + isInitializing: boolean; + initializingSince: number | null; +} + +export interface ClientSnapshot { + root: string; + serverId: string; + refCount: number; + pendingWaiters: number; + lastUsedAt: number; + isInitializing: boolean; + alive: boolean; + command: string[]; +} + +export interface LspManagerOptions { + idleTimeoutMs?: number; + initTimeoutMs?: number; + reaperIntervalMs?: number; + clientFactory?: (root: string, server: ResolvedServer) => LspClient; + now?: () => number; +} + +async function stopClientBestEffort(client: LspClient): Promise { + try { + await client.stop(); + } catch (error) { + reportBestEffortCleanupError("client stop", error); + } +} + +function awaitWithSignal(promise: Promise, signal: AbortSignal | undefined): Promise { + if (!signal) return promise; + return new Promise((resolve, reject) => { + let settled = false; + const onAbort = () => { + if (settled) return; + settled = true; + reject(new DOMException("Aborted", "AbortError")); + }; + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener("abort", onAbort, { once: true }); + promise.then( + (value) => { + if (settled) return; + settled = true; + signal.removeEventListener("abort", onAbort); + resolve(value); + }, + (err) => { + if (settled) return; + settled = true; + signal.removeEventListener("abort", onAbort); + reject(err); + }, + ); + }); +} + +export class LspManager { + private readonly clients = new Map(); + private reaperHandle: NodeJS.Timeout | null = null; + private signalDisposer: (() => void) | null = null; + private disposed = false; + + private readonly idleTimeoutMs: number; + private readonly initTimeoutMs: number; + private readonly reaperIntervalMs: number; + private readonly clientFactory: (root: string, server: ResolvedServer) => LspClient; + private readonly now: () => number; + + constructor(options: LspManagerOptions = {}) { + this.idleTimeoutMs = options.idleTimeoutMs ?? IDLE_TIMEOUT_MS; + this.initTimeoutMs = options.initTimeoutMs ?? INIT_TIMEOUT_MS; + this.reaperIntervalMs = options.reaperIntervalMs ?? REAPER_INTERVAL_MS; + this.clientFactory = options.clientFactory ?? ((root, server) => new LspClient(root, server)); + this.now = options.now ?? (() => Date.now()); + + this.startReaper(); + this.signalDisposer = installProcessSignalCleanup(() => this.stopAll()); + } + + private startReaper(): void { + if (this.reaperHandle) return; + this.reaperHandle = setInterval(() => { + this.reapStale(); + }, this.reaperIntervalMs); + if (typeof this.reaperHandle.unref === "function") { + this.reaperHandle.unref(); + } + } + + private getKey(root: string, serverId: string): string { + return `${root}::${serverId}`; + } + + private reapStale(): void { + const t = this.now(); + for (const [key, managed] of this.clients) { + if ( + managed.isInitializing && + managed.initializingSince !== null && + t - managed.initializingSince > this.initTimeoutMs + ) { + void stopClientBestEffort(managed.client); + this.clients.delete(key); + continue; + } + + if ( + !managed.isInitializing && + managed.refCount === 0 && + managed.pendingWaiters === 0 && + t - managed.lastUsedAt > this.idleTimeoutMs + ) { + void stopClientBestEffort(managed.client); + this.clients.delete(key); + } + } + } + + private async tryDeleteIfOrphaned(key: string, managed: ManagedClient): Promise { + if ( + managed.refCount === 0 && + managed.pendingWaiters === 0 && + !managed.isInitializing && + this.clients.get(key) === managed + ) { + this.clients.delete(key); + await stopClientBestEffort(managed.client); + } + } + + async getClient(root: string, server: ResolvedServer, signal?: AbortSignal): Promise { + if (this.disposed) { + throw new Error("LspManager has been disposed"); + } + signal?.throwIfAborted(); + + const key = this.getKey(root, server.id); + let managed = this.clients.get(key); + + if (managed) { + const t = this.now(); + if ( + managed.isInitializing && + managed.initializingSince !== null && + t - managed.initializingSince > this.initTimeoutMs + ) { + await stopClientBestEffort(managed.client); + this.clients.delete(key); + managed = undefined; + } + } + + if (managed) { + if (managed.initPromise) { + managed.pendingWaiters++; + try { + await awaitWithSignal(managed.initPromise, signal); + } catch (err) { + managed.pendingWaiters--; + await this.tryDeleteIfOrphaned(key, managed); + throw err; + } + managed.pendingWaiters--; + } + + if (signal?.aborted) { + await this.tryDeleteIfOrphaned(key, managed); + signal.throwIfAborted(); + } + + if (!managed.client.isAlive()) { + await stopClientBestEffort(managed.client); + this.clients.delete(key); + return this.getClient(root, server, signal); + } + + managed.refCount++; + managed.lastUsedAt = this.now(); + return managed.client; + } + + const client = this.clientFactory(root, server); + const initStartedAt = this.now(); + const initPromise = (async () => { + await client.start(); + await client.initialize(); + })(); + + const newManaged: ManagedClient = { + client, + refCount: 0, + pendingWaiters: 1, + lastUsedAt: initStartedAt, + initPromise, + isInitializing: true, + initializingSince: initStartedAt, + }; + this.clients.set(key, newManaged); + + try { + await awaitWithSignal(initPromise, signal); + } catch (err) { + newManaged.pendingWaiters--; + if (this.clients.get(key) === newManaged) { + this.clients.delete(key); + } + await stopClientBestEffort(client); + throw err; + } + + newManaged.pendingWaiters--; + newManaged.isInitializing = false; + newManaged.initializingSince = null; + newManaged.initPromise = null; + + if (signal?.aborted) { + await this.tryDeleteIfOrphaned(key, newManaged); + signal.throwIfAborted(); + } + + newManaged.refCount++; + newManaged.lastUsedAt = this.now(); + return client; + } + + releaseClient(root: string, serverId: string): void { + const key = this.getKey(root, serverId); + const managed = this.clients.get(key); + if (managed && managed.refCount > 0) { + managed.refCount--; + managed.lastUsedAt = this.now(); + } + } + + invalidateClient(root: string, serverId: string, client?: LspClient): void { + const key = this.getKey(root, serverId); + const managed = this.clients.get(key); + if (!managed) return; + if (client && managed.client !== client) return; + this.clients.delete(key); + void stopClientBestEffort(managed.client); + } + + warmupClient(root: string, server: ResolvedServer): void { + if (this.disposed) return; + const key = this.getKey(root, server.id); + if (this.clients.has(key)) return; + + const client = this.clientFactory(root, server); + const initStartedAt = this.now(); + const initPromise = (async () => { + await client.start(); + await client.initialize(); + })(); + + const managed: ManagedClient = { + client, + refCount: 0, + pendingWaiters: 0, + lastUsedAt: initStartedAt, + initPromise, + isInitializing: true, + initializingSince: initStartedAt, + }; + this.clients.set(key, managed); + + initPromise.then( + () => { + managed.isInitializing = false; + managed.initializingSince = null; + managed.initPromise = null; + managed.lastUsedAt = this.now(); + }, + () => { + if (this.clients.get(key) === managed) { + this.clients.delete(key); + } + void stopClientBestEffort(client); + }, + ); + } + + isServerInitializing(root: string, serverId: string): boolean { + const managed = this.clients.get(this.getKey(root, serverId)); + return managed?.isInitializing ?? false; + } + + getSnapshot(): ClientSnapshot[] { + const snapshots: ClientSnapshot[] = []; + for (const [key, managed] of this.clients) { + const [root, serverId] = key.split("::") as [string, string]; + snapshots.push({ + root, + serverId, + refCount: managed.refCount, + pendingWaiters: managed.pendingWaiters, + lastUsedAt: managed.lastUsedAt, + isInitializing: managed.isInitializing, + alive: managed.client.isAlive(), + command: managed.client.command(), + }); + } + return snapshots; + } + + hasClient(root: string, serverId: string): boolean { + return this.clients.has(this.getKey(root, serverId)); + } + + clientCount(): number { + return this.clients.size; + } + + async stopAll(): Promise { + this.disposed = true; + + if (this.reaperHandle) { + clearInterval(this.reaperHandle); + this.reaperHandle = null; + } + + if (this.signalDisposer) { + this.signalDisposer(); + this.signalDisposer = null; + } + + const stopPromises: Promise[] = []; + for (const managed of this.clients.values()) { + stopPromises.push(stopClientBestEffort(managed.client)); + } + this.clients.clear(); + await Promise.allSettled(stopPromises); + } +} + +let _defaultInstance: LspManager | null = null; + +export function getLspManager(): LspManager { + if (!_defaultInstance) { + _defaultInstance = new LspManager(); + } + return _defaultInstance; +} + +export async function disposeDefaultLspManager(): Promise { + if (_defaultInstance) { + const m = _defaultInstance; + _defaultInstance = null; + await m.stopAll(); + } +} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/process-signal-cleanup.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/process-signal-cleanup.ts new file mode 100644 index 000000000..9ab3a4ee7 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/process-signal-cleanup.ts @@ -0,0 +1,21 @@ +import { reportBestEffortCleanupError } from "./cleanup-errors.js"; + +export function installProcessSignalCleanup(cleanup: () => Promise): () => void { + const signals: readonly NodeJS.Signals[] = + process.platform === "win32" ? ["SIGINT", "SIGTERM", "SIGBREAK"] : ["SIGINT", "SIGTERM"]; + const handler = () => { + void cleanup().catch((error) => { + reportBestEffortCleanupError("signal cleanup", error); + }); + }; + + for (const signal of signals) { + process.on(signal, handler); + } + + return () => { + for (const signal of signals) { + process.removeListener(signal, handler); + } + }; +} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/process.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/process.ts new file mode 100644 index 000000000..e059b1411 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/process.ts @@ -0,0 +1,202 @@ +import { type ChildProcess, spawn, spawnSync } from "node:child_process"; +import { existsSync, statSync } from "node:fs"; +import { delimiter, join } from "node:path"; + +import { reportBestEffortCleanupError } from "./cleanup-errors.js"; +import { LspInvalidPathError, LspProcessSpawnError } from "./errors.js"; + +export interface SpawnedProcess { + stdin: NodeJS.WritableStream; + stdout: NodeJS.ReadableStream; + stderr: NodeJS.ReadableStream; + pid: number | undefined; + exitCode: number | null; + exited: Promise; + kill(signal?: NodeJS.Signals): void; + killed: boolean; +} + +export interface SpawnOptions { + cwd: string; + env: Record; +} + +export interface PreparedSpawnCommand { + command: string; + args: string[]; + shell: false; +} + +function isMissingProcessError(error: unknown): boolean { + if (!(error instanceof Error) || !("code" in error)) return false; + return error.code === "ESRCH"; +} + +function reportKillError(context: string, error: unknown): void { + if (!isMissingProcessError(error)) { + reportBestEffortCleanupError(context, error); + } +} + +export function validateCwd(cwd: string): { valid: boolean; error?: string } { + try { + if (!existsSync(cwd)) { + return { valid: false, error: `Working directory does not exist: ${cwd}` }; + } + const stats = statSync(cwd); + if (!stats.isDirectory()) { + return { valid: false, error: `Path is not a directory: ${cwd}` }; + } + return { valid: true }; + } catch (err) { + return { + valid: false, + error: `Cannot access working directory: ${cwd} (${err instanceof Error ? err.message : String(err)})`, + }; + } +} + +function wrap(proc: ChildProcess): SpawnedProcess { + const exitedPromise = new Promise((resolve) => { + proc.once("close", (code) => resolve(code ?? 0)); + proc.once("error", () => resolve(1)); + }); + + if (!proc.stdin || !proc.stdout || !proc.stderr) { + throw new LspProcessSpawnError("Spawned process is missing one of stdin/stdout/stderr pipes"); + } + + return { + stdin: proc.stdin, + stdout: proc.stdout, + stderr: proc.stderr, + get pid() { + return proc.pid ?? undefined; + }, + get exitCode() { + return proc.exitCode; + }, + get killed() { + return proc.killed; + }, + exited: exitedPromise, + kill(signal?: NodeJS.Signals) { + killProcessTree(proc, signal ?? "SIGTERM"); + }, + }; +} + +function killProcessTree(proc: ChildProcess, signal: NodeJS.Signals): void { + if (process.platform === "win32" && proc.pid) { + const result = spawnSync("taskkill", ["/pid", String(proc.pid), "/f", "/t"], { stdio: "ignore" }); + if (!result.error && result.status === 0) return; + if (result.error) reportKillError("windows process tree kill", result.error); + } + + if (process.platform !== "win32" && proc.pid) { + try { + process.kill(-proc.pid, signal); + return; + } catch (error) { + reportKillError("process group kill", error); + } + } + + try { + proc.kill(signal); + } catch (error) { + reportKillError("process kill", error); + } +} + +function isWindowsShellShim(command: string): boolean { + const lowerCommand = command.toLowerCase(); + return lowerCommand.endsWith(".cmd") || lowerCommand.endsWith(".bat"); +} + +function splitPath(pathValue: string, platform: NodeJS.Platform): string[] { + const separator = platform === "win32" ? ";" : delimiter; + return pathValue.split(separator).filter(Boolean); +} + +function getWindowsPathExtensions(env: Record): string[] { + const rawExtensions = env["PATHEXT"] ?? ".COM;.EXE;.BAT;.CMD"; + const extensions = rawExtensions + .split(";") + .map((extension) => extension.trim()) + .filter(Boolean) + .map((extension) => (extension.startsWith(".") ? extension : `.${extension}`)); + return [...new Set(["", ...extensions, ".exe", ".cmd", ".bat"])]; +} + +function resolveWindowsCommand(command: string, env: Record): string { + const hasPathSeparator = command.includes("/") || command.includes("\\"); + const pathValue = env["PATH"] ?? env["Path"] ?? ""; + const baseDirectories = hasPathSeparator ? [""] : splitPath(pathValue, "win32"); + const extensions = getWindowsPathExtensions(env); + + for (const baseDirectory of baseDirectories) { + for (const extension of extensions) { + const candidate = baseDirectory ? join(baseDirectory, `${command}${extension}`) : `${command}${extension}`; + if (existsSync(candidate)) return candidate; + } + } + + return command; +} + +export function createSpawnCommand( + command: string[], + platform: NodeJS.Platform = process.platform, + commandProcessor: string = process.env["ComSpec"] ?? "cmd.exe", + env: Record = process.env, +): PreparedSpawnCommand { + const [cmd, ...args] = command; + if (!cmd) { + throw new LspProcessSpawnError("[lsp] empty command"); + } + + if (platform !== "win32") { + return { command: cmd, args, shell: false }; + } + + const resolvedCommand = resolveWindowsCommand(cmd, env); + if (!isWindowsShellShim(resolvedCommand)) { + return { command: resolvedCommand, args, shell: false }; + } + + return { + command: commandProcessor, + args: ["/d", "/s", "/c", resolvedCommand, ...args], + shell: false, + }; +} + +export function spawnProcess(command: string[], options: SpawnOptions): SpawnedProcess { + const cwdValidation = validateCwd(options.cwd); + if (!cwdValidation.valid) { + throw new LspInvalidPathError(`[lsp] ${cwdValidation.error}`); + } + + const [cmd] = command; + if (!cmd) { + throw new LspProcessSpawnError("[lsp] empty command"); + } + + const preparedCommand = createSpawnCommand( + command, + process.platform, + process.env["ComSpec"] ?? "cmd.exe", + options.env, + ); + const proc = spawn(preparedCommand.command, preparedCommand.args, { + cwd: options.cwd, + env: options.env, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + shell: preparedCommand.shell, + detached: process.platform !== "win32", + }); + + return wrap(proc); +} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/server-definitions.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/server-definitions.ts new file mode 100644 index 000000000..b8e728640 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/server-definitions.ts @@ -0,0 +1,163 @@ +import type { LspServerConfig } from "./types.js"; + +export const LSP_INSTALL_HINTS: Record = { + typescript: "npm install -g typescript-language-server typescript", + deno: "Install Deno from https://deno.land", + vue: "npm install -g @vue/language-server", + eslint: "npm install -g vscode-langservers-extracted", + oxlint: "npm install -g oxlint", + biome: "npm install -g @biomejs/biome", + gopls: "go install golang.org/x/tools/gopls@latest", + "ruby-lsp": "gem install ruby-lsp", + basedpyright: "pip install basedpyright", + pyright: "pip install pyright", + ty: "pip install ty", + ruff: "pip install ruff", + "elixir-ls": "See https://github.com/elixir-lsp/elixir-ls", + zls: "See https://github.com/zigtools/zls", + csharp: "dotnet tool install -g csharp-ls", + fsharp: "dotnet tool install -g fsautocomplete", + "sourcekit-lsp": "Included with Xcode or Swift toolchain", + rust: + "Install rust-analyzer and ensure it is in PATH. If using rustup: rustup component add rust-analyzer. " + + "If rust-analyzer exits while loading rust-src: rustup component remove rust-src && rustup component add rust-src.", + clangd: "See https://clangd.llvm.org/installation", + svelte: "npm install -g svelte-language-server", + astro: "npm install -g @astrojs/language-server", + "bash-ls": "npm install -g bash-language-server", + jdtls: "See https://github.com/eclipse-jdtls/eclipse.jdt.ls", + "yaml-ls": "npm install -g yaml-language-server", + "lua-ls": "See https://github.com/LuaLS/lua-language-server", + php: "npm install -g intelephense", + dart: "Included with Dart SDK", + "terraform-ls": "See https://github.com/hashicorp/terraform-ls", + terraform: "See https://github.com/hashicorp/terraform-ls", + prisma: "npm install -g prisma", + "ocaml-lsp": "opam install ocaml-lsp-server", + texlab: "See https://github.com/latex-lsp/texlab", + dockerfile: "npm install -g dockerfile-language-server-nodejs", + gleam: "See https://gleam.run/getting-started/installing/", + "clojure-lsp": "See https://clojure-lsp.io/installation/", + nixd: "nix profile install nixpkgs#nixd", + tinymist: "See https://github.com/Myriad-Dreamin/tinymist", + "haskell-language-server": "ghcup install hls", + bash: "npm install -g bash-language-server", + "kotlin-ls": "See https://github.com/Kotlin/kotlin-lsp", +}; + +export const BUILTIN_SERVERS: Record> = { + typescript: { + command: ["typescript-language-server", "--stdio"], + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"], + }, + deno: { command: ["deno", "lsp"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"] }, + vue: { command: ["vue-language-server", "--stdio"], extensions: [".vue"] }, + eslint: { + command: ["vscode-eslint-language-server", "--stdio"], + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"], + }, + oxlint: { + command: ["oxlint", "--lsp"], + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".astro", ".svelte"], + }, + biome: { + command: ["biome", "lsp-proxy", "--stdio"], + extensions: [ + ".ts", + ".tsx", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".mts", + ".cts", + ".json", + ".jsonc", + ".vue", + ".astro", + ".svelte", + ".css", + ".graphql", + ".gql", + ".html", + ], + }, + gopls: { command: ["gopls"], extensions: [".go"] }, + "ruby-lsp": { + command: ["rubocop", "--lsp"], + extensions: [".rb", ".rake", ".gemspec", ".ru"], + }, + basedpyright: { + command: ["basedpyright-langserver", "--stdio"], + extensions: [".py", ".pyi"], + }, + pyright: { command: ["pyright-langserver", "--stdio"], extensions: [".py", ".pyi"] }, + ty: { command: ["ty", "server"], extensions: [".py", ".pyi"] }, + ruff: { command: ["ruff", "server"], extensions: [".py", ".pyi"] }, + "elixir-ls": { command: ["elixir-ls"], extensions: [".ex", ".exs"] }, + zls: { command: ["zls"], extensions: [".zig", ".zon"] }, + csharp: { command: ["csharp-ls"], extensions: [".cs"] }, + fsharp: { command: ["fsautocomplete"], extensions: [".fs", ".fsi", ".fsx", ".fsscript"] }, + "sourcekit-lsp": { command: ["sourcekit-lsp"], extensions: [".swift", ".objc", ".objcpp"] }, + rust: { command: ["rust-analyzer"], extensions: [".rs"] }, + clangd: { + command: ["clangd", "--background-index", "--clang-tidy"], + extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"], + }, + svelte: { command: ["svelteserver", "--stdio"], extensions: [".svelte"] }, + astro: { command: ["astro-ls", "--stdio"], extensions: [".astro"] }, + bash: { + command: ["bash-language-server", "start"], + extensions: [".sh", ".bash", ".zsh", ".ksh"], + }, + "bash-ls": { + command: ["bash-language-server", "start"], + extensions: [".sh", ".bash", ".zsh", ".ksh"], + }, + jdtls: { command: ["jdtls"], extensions: [".java"] }, + "yaml-ls": { command: ["yaml-language-server", "--stdio"], extensions: [".yaml", ".yml"] }, + "lua-ls": { command: ["lua-language-server"], extensions: [".lua"] }, + php: { command: ["intelephense", "--stdio"], extensions: [".php"] }, + dart: { command: ["dart", "language-server", "--lsp"], extensions: [".dart"] }, + terraform: { command: ["terraform-ls", "serve"], extensions: [".tf", ".tfvars"] }, + "terraform-ls": { command: ["terraform-ls", "serve"], extensions: [".tf", ".tfvars"] }, + prisma: { command: ["prisma", "language-server"], extensions: [".prisma"] }, + "ocaml-lsp": { command: ["ocamllsp"], extensions: [".ml", ".mli"] }, + texlab: { command: ["texlab"], extensions: [".tex", ".bib"] }, + dockerfile: { command: ["docker-langserver", "--stdio"], extensions: [".dockerfile"] }, + gleam: { command: ["gleam", "lsp"], extensions: [".gleam"] }, + "clojure-lsp": { + command: ["clojure-lsp", "listen"], + extensions: [".clj", ".cljs", ".cljc", ".edn"], + }, + nixd: { command: ["nixd"], extensions: [".nix"] }, + tinymist: { command: ["tinymist"], extensions: [".typ", ".typc"] }, + "haskell-language-server": { + command: ["haskell-language-server-wrapper", "--lsp"], + extensions: [".hs", ".lhs"], + }, + "kotlin-ls": { command: ["kotlin-lsp"], extensions: [".kt", ".kts"] }, +}; + +export const AUTO_INSTALLABLE_SERVERS: Record = { + typescript: ["npm", "install", "-g", "typescript-language-server", "typescript"], + vue: ["npm", "install", "-g", "@vue/language-server"], + eslint: ["npm", "install", "-g", "vscode-langservers-extracted"], + oxlint: ["npm", "install", "-g", "oxlint"], + biome: ["npm", "install", "-g", "@biomejs/biome"], + svelte: ["npm", "install", "-g", "svelte-language-server"], + astro: ["npm", "install", "-g", "@astrojs/language-server"], + "bash-ls": ["npm", "install", "-g", "bash-language-server"], + bash: ["npm", "install", "-g", "bash-language-server"], + "yaml-ls": ["npm", "install", "-g", "yaml-language-server"], + php: ["npm", "install", "-g", "intelephense"], + prisma: ["npm", "install", "-g", "prisma"], + dockerfile: ["npm", "install", "-g", "dockerfile-language-server-nodejs"], + gopls: ["go", "install", "golang.org/x/tools/gopls@latest"], + pyright: ["pip", "install", "pyright"], + basedpyright: ["pip", "install", "basedpyright"], + ruff: ["pip", "install", "ruff"], + ty: ["pip", "install", "ty"], + "ruby-lsp": ["gem", "install", "ruby-lsp"], + "ocaml-lsp": ["opam", "install", "ocaml-lsp-server"], +}; diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/server-installation.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/server-installation.ts new file mode 100644 index 000000000..622a174d0 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/server-installation.ts @@ -0,0 +1,57 @@ +import { existsSync } from "node:fs"; +import { delimiter, join } from "node:path"; + +export function getAdditionalPathBases(workingDirectory: string): string[] { + return [join(workingDirectory, "node_modules", ".bin")]; +} + +export function isServerInstalled(command: string[]): boolean { + if (command.length === 0) return false; + + const [cmd] = command; + if (!cmd) return false; + + if (cmd.includes("/") || cmd.includes("\\")) { + if (existsSync(cmd)) return true; + } + + const isWindows = process.platform === "win32"; + + let exts = [""]; + if (isWindows) { + const pathExt = process.env["PATHEXT"] ?? ""; + if (pathExt) { + const systemExts = pathExt.split(";").filter(Boolean); + exts = [...new Set([...exts, ...systemExts, ".exe", ".cmd", ".bat", ".ps1"])]; + } else { + exts = ["", ".exe", ".cmd", ".bat", ".ps1"]; + } + } + + let pathEnv = process.env["PATH"] ?? ""; + if (isWindows && !pathEnv) { + pathEnv = process.env["Path"] ?? ""; + } + + const paths = pathEnv.split(delimiter); + + for (const p of paths) { + for (const suffix of exts) { + if (existsSync(join(p, cmd + suffix))) { + return true; + } + } + } + + for (const base of getAdditionalPathBases(process.cwd())) { + for (const suffix of exts) { + if (existsSync(join(base, cmd + suffix))) { + return true; + } + } + } + + if (cmd === "node") return true; + + return false; +} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/server-resolution.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/server-resolution.ts new file mode 100644 index 000000000..e08478c99 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/server-resolution.ts @@ -0,0 +1,104 @@ +import { getDisabledServerIds, getMergedServers } from "./config-loader.js"; +import { BUILTIN_SERVERS, LSP_INSTALL_HINTS } from "./server-definitions.js"; +import { isServerInstalled } from "./server-installation.js"; +import type { ServerLookupResult } from "./types.js"; + +export function findServerForExtension(ext: string): ServerLookupResult { + const servers = getMergedServers(); + + for (const server of servers) { + if (server.extensions.includes(ext) && isServerInstalled(server.command)) { + const resolvedServer = { + id: server.id, + command: server.command, + extensions: server.extensions, + priority: server.priority, + }; + if (server.env !== undefined) { + return { + status: "found", + server: { + ...resolvedServer, + env: server.env, + ...(server.initialization === undefined ? {} : { initialization: server.initialization }), + }, + }; + } + return { + status: "found", + server: { + ...resolvedServer, + ...(server.initialization === undefined ? {} : { initialization: server.initialization }), + }, + }; + } + } + + for (const server of servers) { + if (server.extensions.includes(ext)) { + const installHint = + LSP_INSTALL_HINTS[server.id] ?? `Install '${server.command[0]}' and ensure it's in your PATH`; + return { + status: "not_installed", + server: { + id: server.id, + command: server.command, + extensions: server.extensions, + }, + installHint, + }; + } + } + + const availableServers = [...new Set(servers.map((s) => s.id))]; + return { + status: "not_configured", + extension: ext, + availableServers, + }; +} + +export interface ServerStatus { + id: string; + installed: boolean; + extensions: string[]; + disabled: boolean; + source: string; + priority: number; +} + +export function getAllServers(): ServerStatus[] { + const servers = getMergedServers(); + const disabled = getDisabledServerIds(); + + const result: ServerStatus[] = []; + const seen = new Set(); + + for (const server of servers) { + if (seen.has(server.id)) continue; + result.push({ + id: server.id, + installed: isServerInstalled(server.command), + extensions: server.extensions, + disabled: false, + source: server.source, + priority: server.priority, + }); + seen.add(server.id); + } + + for (const id of disabled) { + if (seen.has(id)) continue; + const builtin = BUILTIN_SERVERS[id]; + result.push({ + id, + installed: builtin ? isServerInstalled(builtin.command) : false, + extensions: builtin?.extensions ?? [], + disabled: true, + source: "disabled", + priority: 0, + }); + } + + return result; +} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/transport.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/transport.ts new file mode 100644 index 000000000..49ecf7ead --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/transport.ts @@ -0,0 +1,285 @@ +import { delimiter } from "node:path"; + +import { reportBestEffortCleanupError } from "./cleanup-errors.js"; +import { REQUEST_TIMEOUT_MS, STOP_HARD_KILL_TIMEOUT_MS, STOP_SIGKILL_GRACE_MS } from "./constants.js"; +import { LspConnectionClosedError, LspProcessExitedError, LspRequestTimeoutError } from "./errors.js"; +import { JsonRpcConnection } from "./json-rpc-connection.js"; +import { type SpawnedProcess, spawnProcess } from "./process.js"; +import { getAdditionalPathBases } from "./server-installation.js"; +import type { Diagnostic, ResolvedServer } from "./types.js"; + +interface ConfigurationItem { + section?: string; +} + +interface DiagnosticsParams { + uri: string; + diagnostics: Diagnostic[]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseConfigurationItems(params: unknown): ConfigurationItem[] { + if (!isRecord(params) || !Array.isArray(params["items"])) return []; + const items: ConfigurationItem[] = []; + for (const item of params["items"]) { + if (!isRecord(item)) continue; + const section = item["section"]; + items.push(section === undefined || typeof section !== "string" ? {} : { section }); + } + return items; +} + +function parseDiagnosticsParams(params: unknown): DiagnosticsParams | null { + if (!isRecord(params) || typeof params["uri"] !== "string") return null; + const diagnostics = Array.isArray(params["diagnostics"]) ? params["diagnostics"].filter(isDiagnostic) : []; + return { uri: params["uri"], diagnostics }; +} + +export class LspClientTransport { + protected proc: SpawnedProcess | null = null; + protected connection: JsonRpcConnection | null = null; + protected readonly stderrBuffer: string[] = []; + protected processExited = false; + protected readonly diagnosticsStore = new Map(); + + constructor( + protected readonly root: string, + protected readonly server: ResolvedServer, + ) {} + + pid(): number | undefined { + return this.proc?.pid; + } + + command(): string[] { + return [...this.server.command]; + } + + async start(): Promise { + const env: Record = { + ...process.env, + ...this.server.env, + }; + const pathValue = process.platform === "win32" ? (env["PATH"] ?? env["Path"] ?? "") : (env["PATH"] ?? ""); + const spawnPath = [pathValue, ...getAdditionalPathBases(this.root)].filter(Boolean).join(delimiter); + if (process.platform === "win32" && env["Path"] !== undefined) { + env["Path"] = spawnPath; + } + env["PATH"] = spawnPath; + + this.proc = spawnProcess(this.server.command, { + cwd: this.root, + env, + }); + this.startStderrReading(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + if (this.proc.exitCode !== null) { + const stderr = this.stderrBuffer.join("\n"); + throw new LspProcessExitedError(this.server.id, this.root, this.proc.exitCode, stderr.slice(-2000)); + } + + this.connection = new JsonRpcConnection(this.proc.stdout, this.proc.stdin); + + this.connection.onNotification("textDocument/publishDiagnostics", (params) => { + const diagnosticsParams = parseDiagnosticsParams(params); + if (diagnosticsParams?.uri) { + this.diagnosticsStore.set(diagnosticsParams.uri, diagnosticsParams.diagnostics); + } + }); + + this.connection.onRequest("workspace/configuration", (params) => { + const items = parseConfigurationItems(params); + return items.map((item) => { + if (item.section === "json") return { validate: { enable: true } }; + return {}; + }); + }); + + this.connection.onRequest("client/registerCapability", () => null); + this.connection.onRequest("window/workDoneProgress/create", () => null); + + this.connection.onClose(() => { + this.processExited = true; + }); + + this.connection.onError((error) => { + reportBestEffortCleanupError("connection error notification", error); + }); + + this.connection.listen(); + } + + protected startStderrReading(): void { + if (!this.proc) return; + this.proc.stderr.setEncoding("utf-8"); + this.proc.stderr.on("data", (chunk: string) => { + this.stderrBuffer.push(chunk); + if (this.stderrBuffer.length > 100) { + this.stderrBuffer.shift(); + } + }); + } + + private isConnectionClosedError(error: unknown): error is Error { + if (!(error instanceof Error)) { + return false; + } + const code = "code" in error && typeof error.code === "string" ? error.code : undefined; + return ( + code === "ERR_STREAM_DESTROYED" || + /connection closed|connection is disposed|stream was destroyed/i.test(error.message) + ); + } + + protected sendRequest(method: string): Promise; + protected sendRequest(method: string, params: unknown): Promise; + protected async sendRequest(method: string, ...args: [] | [unknown]): Promise { + if (!this.connection) throw new Error("LSP client not started"); + + if (this.processExited || (this.proc && this.proc.exitCode !== null)) { + const stderrTail = this.stderrBuffer.slice(-10).join("\n"); + throw new LspProcessExitedError( + this.server.id, + this.root, + this.proc?.exitCode ?? null, + stderrTail || undefined, + ); + } + + let timeoutHandle: NodeJS.Timeout | null = null; + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + const stderrTail = this.stderrBuffer.slice(-5).join("\n"); + reject(new LspRequestTimeoutError(method, stderrTail || undefined)); + }, REQUEST_TIMEOUT_MS); + }); + + try { + const requestPromise = + args.length === 0 + ? this.connection.sendRequest(method) + : this.connection.sendRequest(method, args[0]); + const result = await Promise.race([requestPromise, timeoutPromise]); + if (timeoutHandle !== null) clearTimeout(timeoutHandle); + return result; + } catch (error) { + if (timeoutHandle !== null) clearTimeout(timeoutHandle); + if (this.processExited || (this.proc && this.proc.exitCode !== null)) { + throw new LspProcessExitedError( + this.server.id, + this.root, + this.proc?.exitCode ?? null, + this.stderrBuffer.slice(-10).join("\n") || undefined, + ); + } + if (this.isConnectionClosedError(error)) { + throw new LspConnectionClosedError(this.server.id, this.root, error.message); + } + throw error; + } + } + + protected sendNotification(method: string): Promise; + protected sendNotification(method: string, params: unknown): Promise; + protected async sendNotification(method: string, ...args: [] | [unknown]): Promise { + if (!this.connection) return; + if (this.processExited || (this.proc && this.proc.exitCode !== null)) return; + try { + if (args.length === 0) { + await this.connection.sendNotification(method); + } else { + await this.connection.sendNotification(method, args[0]); + } + } catch (error) { + if (this.isConnectionClosedError(error)) { + throw new LspConnectionClosedError(this.server.id, this.root, error.message); + } + throw error; + } + } + + isAlive(): boolean { + return this.proc !== null && !this.processExited && this.proc.exitCode === null; + } + + async stop(): Promise { + if (this.connection) { + try { + await this.sendRequest("shutdown"); + } catch (error) { + reportBestEffortCleanupError("shutdown request", error); + } + try { + await this.sendNotification("exit"); + } catch (error) { + reportBestEffortCleanupError("exit notification", error); + } + try { + this.connection.dispose(); + } catch (error) { + reportBestEffortCleanupError("connection dispose", error); + } + this.connection = null; + } + + const proc = this.proc; + if (proc) { + this.proc = null; + let exitedBeforeTimeout = false; + try { + proc.kill(); + let timeoutId: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise((resolve) => { + timeoutId = setTimeout(resolve, STOP_HARD_KILL_TIMEOUT_MS); + }); + await Promise.race([ + proc.exited + .then(() => { + exitedBeforeTimeout = true; + }) + .finally(() => { + if (timeoutId) clearTimeout(timeoutId); + }), + timeoutPromise, + ]); + if (!exitedBeforeTimeout) { + try { + proc.kill("SIGKILL"); + await Promise.race([ + proc.exited, + new Promise((resolve) => setTimeout(resolve, STOP_SIGKILL_GRACE_MS)), + ]); + } catch (error) { + reportBestEffortCleanupError("hard process kill", error); + } + } + } catch (error) { + reportBestEffortCleanupError("process stop", error); + } + } + + this.processExited = true; + this.diagnosticsStore.clear(); + } + + getStoredDiagnostics(uri: string): Diagnostic[] { + return this.diagnosticsStore.get(uri) ?? []; + } +} + +function isDiagnostic(value: unknown): value is Diagnostic { + return isRecord(value) && isRange(value["range"]) && typeof value["message"] === "string"; +} + +function isRange(value: unknown): value is Diagnostic["range"] { + return isRecord(value) && isPosition(value["start"]) && isPosition(value["end"]); +} + +function isPosition(value: unknown): value is Diagnostic["range"]["start"] { + return isRecord(value) && typeof value["line"] === "number" && typeof value["character"] === "number"; +} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/types.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/types.ts new file mode 100644 index 000000000..9fe67afe8 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/types.ts @@ -0,0 +1,126 @@ +export interface LspServerConfig { + id: string; + command: string[]; + extensions: string[]; + disabled?: boolean; + env?: Record; + initialization?: Record; +} + +export interface ResolvedServer { + id: string; + command: string[]; + extensions: string[]; + priority: number; + env?: Record; + initialization?: Record; +} + +export interface ServerLookupInfo { + id: string; + command: string[]; + extensions: string[]; +} + +export type ServerLookupResult = + | { status: "found"; server: ResolvedServer } + | { status: "not_configured"; extension: string; availableServers: string[] } + | { status: "not_installed"; server: ServerLookupInfo; installHint: string }; + +export interface Position { + line: number; + character: number; +} + +export interface Range { + start: Position; + end: Position; +} + +export interface Location { + uri: string; + range: Range; +} + +export interface LocationLink { + targetUri: string; + targetRange: Range; + targetSelectionRange: Range; + originSelectionRange?: Range; +} + +export interface SymbolInfo { + name: string; + kind: number; + location: Location; + containerName?: string; +} + +export interface DocumentSymbol { + name: string; + kind: number; + range: Range; + selectionRange: Range; + children?: DocumentSymbol[]; +} + +export interface Diagnostic { + range: Range; + severity?: number; + code?: string | number; + source?: string; + message: string; +} + +export interface TextDocumentIdentifier { + uri: string; +} + +export interface VersionedTextDocumentIdentifier extends TextDocumentIdentifier { + version: number | null; +} + +export interface TextEdit { + range: Range; + newText: string; +} + +export interface TextDocumentEdit { + textDocument: VersionedTextDocumentIdentifier; + edits: TextEdit[]; +} + +export interface CreateFile { + kind: "create"; + uri: string; + options?: { overwrite?: boolean; ignoreIfExists?: boolean }; +} + +export interface RenameFile { + kind: "rename"; + oldUri: string; + newUri: string; + options?: { overwrite?: boolean; ignoreIfExists?: boolean }; +} + +export interface DeleteFile { + kind: "delete"; + uri: string; + options?: { recursive?: boolean; ignoreIfNotExists?: boolean }; +} + +export interface WorkspaceEdit { + changes?: { [uri: string]: TextEdit[] }; + documentChanges?: (TextDocumentEdit | CreateFile | RenameFile | DeleteFile)[]; +} + +export interface PrepareRenameResult { + range: Range; + placeholder?: string; +} + +export interface PrepareRenameDefaultBehavior { + defaultBehavior: boolean; +} + +export type SeverityFilter = "error" | "warning" | "information" | "hint" | "all"; diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/utils.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/utils.ts new file mode 100644 index 000000000..94428b2e0 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/utils.ts @@ -0,0 +1,40 @@ +import { LspProcessExitedError } from "./errors.js"; + +const RUST_SRC_REPAIR_MESSAGE = [ + "rust-analyzer exited while loading Rust standard library sources.", + "", + "Repair rust-src for the active toolchain:", + " rustup component remove rust-src", + " rustup component add rust-src", +]; + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function formatKnownLspStartupFailure(error: unknown): string | null { + if (!(error instanceof LspProcessExitedError)) return null; + if (error.serverId !== "rust") return null; + + const details = error.stderrTail ?? error.message; + const lowerDetails = details.toLowerCase(); + const isRustSrcFailure = + lowerDetails.includes("rust-src") && + (lowerDetails.includes("failed to install component") || + lowerDetails.includes("detected conflict") || + lowerDetails.includes("can't load standard library") || + lowerDetails.includes("try installing") || + lowerDetails.includes("sysroot")); + + if (!isRustSrcFailure) return null; + + return [...RUST_SRC_REPAIR_MESSAGE, "", "Original stderr tail:", details].join("\n"); +} + +export function handleMissingDependencyError(error: unknown): string | null { + const knownStartupFailure = formatKnownLspStartupFailure(error); + if (knownStartupFailure) return knownStartupFailure; + + const message = errorMessage(error); + return message.includes("NOT INSTALLED") || message.includes("No LSP server configured") ? message : null; +} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/workspace-edit.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/workspace-edit.ts new file mode 100644 index 000000000..fccf7bfb3 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/lsp/workspace-edit.ts @@ -0,0 +1,132 @@ +import { readFileSync, unlinkSync, writeFileSync } from "node:fs"; + +import { uriToPath } from "./formatters.js"; +import type { TextEdit, WorkspaceEdit } from "./types.js"; + +export interface ApplyResult { + success: boolean; + filesModified: string[]; + totalEdits: number; + errors: string[]; +} + +interface FileApplyResult { + success: boolean; + editCount: number; + error?: string; +} + +function applyTextEditsToFile(filePath: string, edits: TextEdit[]): FileApplyResult { + try { + const content = readFileSync(filePath, "utf-8"); + const lines = content.split("\n"); + + const sortedEdits = [...edits].sort((a, b) => { + if (b.range.start.line !== a.range.start.line) { + return b.range.start.line - a.range.start.line; + } + return b.range.start.character - a.range.start.character; + }); + + for (const edit of sortedEdits) { + const startLine = edit.range.start.line; + const startChar = edit.range.start.character; + const endLine = edit.range.end.line; + const endChar = edit.range.end.character; + + if (startLine === endLine) { + const line = lines[startLine] ?? ""; + lines[startLine] = line.substring(0, startChar) + edit.newText + line.substring(endChar); + } else { + const firstLine = lines[startLine] ?? ""; + const lastLine = lines[endLine] ?? ""; + const newContent = firstLine.substring(0, startChar) + edit.newText + lastLine.substring(endChar); + lines.splice(startLine, endLine - startLine + 1, ...newContent.split("\n")); + } + } + + writeFileSync(filePath, lines.join("\n"), "utf-8"); + return { success: true, editCount: edits.length }; + } catch (err) { + return { + success: false, + editCount: 0, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +export function applyWorkspaceEdit(edit: WorkspaceEdit | null): ApplyResult { + if (!edit) { + return { success: false, filesModified: [], totalEdits: 0, errors: ["No edit provided"] }; + } + + const result: ApplyResult = { success: true, filesModified: [], totalEdits: 0, errors: [] }; + + if (edit.changes) { + for (const [uri, edits] of Object.entries(edit.changes)) { + const filePath = uriToPath(uri); + const applyResult = applyTextEditsToFile(filePath, edits); + + if (applyResult.success) { + result.filesModified.push(filePath); + result.totalEdits += applyResult.editCount; + } else { + result.success = false; + result.errors.push(`${filePath}: ${applyResult.error}`); + } + } + } + + if (edit.documentChanges) { + for (const change of edit.documentChanges) { + if (!("kind" in change)) { + const filePath = uriToPath(change.textDocument.uri); + const applyResult = applyTextEditsToFile(filePath, change.edits); + + if (applyResult.success) { + result.filesModified.push(filePath); + result.totalEdits += applyResult.editCount; + } else { + result.success = false; + result.errors.push(`${filePath}: ${applyResult.error}`); + } + continue; + } + + if (change.kind === "create") { + try { + const filePath = uriToPath(change.uri); + writeFileSync(filePath, "", "utf-8"); + result.filesModified.push(filePath); + } catch (err) { + result.success = false; + result.errors.push(`Create ${change.uri}: ${String(err)}`); + } + } else if (change.kind === "rename") { + try { + const oldPath = uriToPath(change.oldUri); + const newPath = uriToPath(change.newUri); + const content = readFileSync(oldPath, "utf-8"); + writeFileSync(newPath, content, "utf-8"); + unlinkSync(oldPath); + result.filesModified.push(newPath); + } catch (err) { + result.success = false; + result.errors.push(`Rename ${change.oldUri}: ${String(err)}`); + } + } else if (change.kind === "delete") { + try { + const filePath = uriToPath(change.uri); + unlinkSync(filePath); + result.filesModified.push(filePath); + } catch (err) { + result.success = false; + result.errors.push(`Delete ${change.uri}: ${String(err)}`); + } + } + } + } + + return result; +} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/mcp.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/mcp.ts new file mode 100644 index 000000000..1c22f1eec --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/mcp.ts @@ -0,0 +1,141 @@ +import { createInterface } from "node:readline"; + +import { coerceToolArguments, executeLspTool, LSP_MCP_TOOLS, type TextContent } from "./tools.js"; + +export type JsonRpcId = string | number | null; + +export interface McpToolDescriptor { + name: string; + title: string; + description: string; + inputSchema: unknown; +} + +export interface JsonRpcError { + code: number; + message: string; + data?: unknown; +} + +export interface JsonRpcResult { + capabilities?: Record; + serverInfo?: Record; + protocolVersion?: string; + tools?: McpToolDescriptor[]; + content?: TextContent[]; + isError?: boolean; + [key: string]: unknown; +} + +export interface JsonRpcResponse { + jsonrpc: "2.0"; + id: JsonRpcId; + result?: JsonRpcResult; + error?: JsonRpcError; +} + +const SERVER_NAME = "lsp"; +const SERVER_VERSION = "0.1.0"; + +export async function handleLspMcpRequest(input: unknown): Promise { + if (!isRecord(input)) { + return errorResponse(null, -32600, "Invalid Request"); + } + + const id = jsonRpcId(input["id"]); + const method = input["method"]; + if (method === "notifications/initialized") return undefined; + if (method === "ping") return successResponse(id, {}); + if (method === "initialize") { + const protocolVersion = requestedProtocolVersion(input["params"]); + return successResponse(id, { + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }, + protocolVersion, + }); + } + + if (method === "tools/list") { + return successResponse(id, { tools: LSP_MCP_TOOLS.map(describeTool) }); + } + + if (method === "tools/call") { + return handleToolCall(id, input["params"]); + } + + return errorResponse(id, -32601, `Method not found: ${String(method)}`); +} + +export async function runMcpStdioServer( + input: NodeJS.ReadableStream = process.stdin, + output: NodeJS.WritableStream = process.stdout, +): Promise { + const lines = createInterface({ input, crlfDelay: Number.POSITIVE_INFINITY }); + for await (const line of lines) { + if (!line.trim()) continue; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (error) { + output.write(`${JSON.stringify(errorResponse(null, -32700, "Parse error", messageFromError(error)))}\n`); + continue; + } + + const response = await handleLspMcpRequest(parsed); + if (response) output.write(`${JSON.stringify(response)}\n`); + } +} + +async function handleToolCall(id: JsonRpcId, params: unknown): Promise { + if (!isRecord(params) || typeof params["name"] !== "string") { + return errorResponse(id, -32602, "tools/call requires params.name"); + } + + try { + const result = await executeLspTool(params["name"], coerceToolArguments(params["arguments"])); + return successResponse(id, { + content: result.content, + isError: result.isError ?? false, + details: result.details, + }); + } catch (error) { + return successResponse(id, { + content: [{ type: "text", text: messageFromError(error) }], + isError: true, + }); + } +} + +function describeTool(tool: (typeof LSP_MCP_TOOLS)[number]): McpToolDescriptor { + return { + name: tool.name, + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema, + }; +} + +function successResponse(id: JsonRpcId, result: JsonRpcResult): JsonRpcResponse { + return { jsonrpc: "2.0", id, result }; +} + +function errorResponse(id: JsonRpcId, code: number, message: string, data?: unknown): JsonRpcResponse { + return { jsonrpc: "2.0", id, error: data === undefined ? { code, message } : { code, message, data } }; +} + +function requestedProtocolVersion(params: unknown): string { + if (!isRecord(params) || typeof params["protocolVersion"] !== "string") return "2024-11-05"; + return params["protocolVersion"]; +} + +function jsonRpcId(value: unknown): JsonRpcId { + return typeof value === "string" || typeof value === "number" || value === null ? value : null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function messageFromError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/tools.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/tools.ts new file mode 100644 index 000000000..872392395 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/src/tools.ts @@ -0,0 +1,669 @@ +import { resolve } from "node:path"; + +import { isDirectoryPath, type WithLspClientOptions, withLspClient } from "./lsp/client-wrapper.js"; +import { DEFAULT_MAX_DIAGNOSTICS, DEFAULT_MAX_REFERENCES, DEFAULT_MAX_SYMBOLS } from "./lsp/constants.js"; +import { aggregateDiagnosticsForDirectory } from "./lsp/directory-diagnostics.js"; +import { + filterDiagnosticsBySeverity, + formatApplyResult, + formatDiagnostic, + formatDocumentSymbol, + formatLocation, + formatPrepareRenameResult, + formatSymbolInfo, +} from "./lsp/formatters.js"; +import { inferExtensionFromDirectory } from "./lsp/infer-extension.js"; +import { getLspManager } from "./lsp/manager.js"; +import { getAllServers } from "./lsp/server-resolution.js"; +import type { + Diagnostic, + DocumentSymbol, + Location, + LocationLink, + PrepareRenameDefaultBehavior, + PrepareRenameResult, + Range, + SeverityFilter, + SymbolInfo, + WorkspaceEdit, +} from "./lsp/types.js"; +import { handleMissingDependencyError } from "./lsp/utils.js"; +import { type ApplyResult, applyWorkspaceEdit } from "./lsp/workspace-edit.js"; + +export interface TextContent { + type: "text"; + text: string; +} + +export interface ToolExecutionResult { + content: TextContent[]; + isError?: boolean; + details?: unknown; +} + +export interface JsonSchema { + type: string; + description?: string; + properties?: Record; + required?: string[]; + items?: JsonSchema; + enum?: string[]; +} + +export interface LspMcpTool { + name: string; + aliases?: string[]; + title: string; + description: string; + inputSchema: JsonSchema; + execute(params: Record, signal?: AbortSignal): Promise; +} + +export interface LspDiagnosticsDetails { + filePath: string; + severity: SeverityFilter; + mode: "file" | "directory"; + diagnostics: Array<{ file: string; diagnostic: Diagnostic }>; + totalDiagnostics: number; + truncated: boolean; + error?: string; + errorKind?: "missing_dependency" | "no_files" | "invalid_path"; +} + +export interface LspGotoDefinitionDetails { + filePath: string; + line: number; + character: number; + locations: Array; + error?: string; + errorKind?: "missing_dependency"; +} + +export interface LspFindReferencesDetails { + filePath: string; + line: number; + character: number; + references: Location[]; + totalReferences: number; + truncated: boolean; + error?: string; + errorKind?: "missing_dependency"; +} + +export interface LspSymbolsDetails { + filePath: string; + scope: "document" | "workspace"; + query?: string; + symbols: Array; + totalSymbols: number; + truncated: boolean; + error?: string; + errorKind?: "missing_dependency" | "missing_query"; +} + +export interface LspPrepareRenameDetails { + filePath: string; + line: number; + character: number; + result: PrepareRenameResult | PrepareRenameDefaultBehavior | Range | null; + error?: string; + errorKind?: "missing_dependency"; +} + +export interface LspRenameDetails { + filePath: string; + line: number; + character: number; + newName: string; + apply: ApplyResult | null; + edit: WorkspaceEdit | null; + error?: string; + errorKind?: "missing_dependency"; +} + +const objectSchema = (properties: Record, required: string[] = []): JsonSchema => ({ + type: "object", + properties, + required, +}); + +function text(text: string, details?: unknown, isError = false): ToolExecutionResult { + return { content: [{ type: "text", text }], details, isError }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function requireString(params: Record, key: string): string { + const value = params[key]; + if (typeof value !== "string" || value.length === 0) { + throw new Error(`Missing required string parameter '${key}'`); + } + return value; +} + +function optionalString(params: Record, key: string): string | undefined { + const value = params[key]; + return typeof value === "string" ? value : undefined; +} + +function requireNumber(params: Record, key: string): number { + const value = params[key]; + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`Missing required number parameter '${key}'`); + } + return value; +} + +function optionalNumber(params: Record, key: string): number | undefined { + const value = params[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function optionalBoolean(params: Record, key: string): boolean | undefined { + const value = params[key]; + return typeof value === "boolean" ? value : undefined; +} + +function isSeverityFilter(value: unknown): value is SeverityFilter { + return value === "error" || value === "warning" || value === "information" || value === "hint" || value === "all"; +} + +function severityFilter(params: Record): SeverityFilter { + const value = params["severity"]; + if (isSeverityFilter(value)) return value; + return "all"; +} + +function clientOptions(signal: AbortSignal | undefined): WithLspClientOptions { + return signal === undefined ? {} : { signal }; +} + +function asDiagnosticArray(result: { items?: Diagnostic[] } | Diagnostic[] | null | undefined): Diagnostic[] { + if (!result) return []; + if (Array.isArray(result)) return result; + return result.items ?? []; +} + +function isDocumentSymbol(symbol: DocumentSymbol | SymbolInfo): symbol is DocumentSymbol { + return "range" in symbol; +} + +async function executeLspStatus(): Promise { + const servers = getAllServers(); + const snapshots = getLspManager().getSnapshot(); + const installed = servers.filter((server) => server.installed && !server.disabled); + const configuredLines = servers.map((server) => { + const state = server.disabled ? "disabled" : server.installed ? "installed" : "missing"; + return `- ${server.id}: ${state}; source=${server.source}; extensions=${server.extensions.join(", ")}`; + }); + const activeLines = snapshots.map((snapshot) => { + const state = snapshot.alive ? (snapshot.isInitializing ? "initializing" : "alive") : "dead"; + return `- ${snapshot.serverId}: ${state}; root=${snapshot.root}; refs=${snapshot.refCount}`; + }); + const lines = [ + `Configured LSP servers: ${servers.length}`, + `Installed LSP servers: ${installed.length}`, + "", + ...configuredLines, + "", + `Active LSP clients: ${snapshots.length}`, + ...activeLines, + ]; + return text(lines.join("\n"), { servers, snapshots }); +} + +export async function executeLspDiagnostics( + params: Record, + signal?: AbortSignal, +): Promise { + const filePath = requireString(params, "filePath"); + const severity = severityFilter(params); + + try { + const absPath = resolve(filePath); + if (isDirectoryPath(absPath)) { + const extension = inferExtensionFromDirectory(absPath); + if (!extension) { + const message = `No supported source files found in directory: ${absPath}`; + const details: LspDiagnosticsDetails = { + filePath, + severity, + mode: "directory", + diagnostics: [], + totalDiagnostics: 0, + truncated: false, + error: message, + errorKind: "no_files", + }; + return text(message, details); + } + + const output = await aggregateDiagnosticsForDirectory(absPath, extension, severity); + const details: LspDiagnosticsDetails = { + filePath, + severity, + mode: "directory", + diagnostics: [], + totalDiagnostics: 0, + truncated: false, + }; + return text(output, details); + } + + const result = await withLspClient( + filePath, + async (client) => client.diagnostics(filePath), + "diagnostics", + clientOptions(signal), + ); + const diagnostics = filterDiagnosticsBySeverity(asDiagnosticArray(result), severity); + const total = diagnostics.length; + const truncated = total > DEFAULT_MAX_DIAGNOSTICS; + const limited = truncated ? diagnostics.slice(0, DEFAULT_MAX_DIAGNOSTICS) : diagnostics; + const output = + total === 0 + ? "No diagnostics found" + : [ + ...(truncated ? [`Found ${total} diagnostics (showing first ${DEFAULT_MAX_DIAGNOSTICS}):`] : []), + ...limited.map(formatDiagnostic), + ].join("\n"); + const details: LspDiagnosticsDetails = { + filePath, + severity, + mode: "file", + diagnostics: diagnostics.map((diagnostic) => ({ file: absPath, diagnostic })), + totalDiagnostics: total, + truncated, + }; + return text(output, details); + } catch (error) { + const message = handleMissingDependencyError(error); + if (message) { + const details: LspDiagnosticsDetails = { + filePath, + severity, + mode: "file", + diagnostics: [], + totalDiagnostics: 0, + truncated: false, + error: message, + errorKind: "missing_dependency", + }; + return text(message, details); + } + throw error; + } +} + +async function executeLspGotoDefinition( + params: Record, + signal?: AbortSignal, +): Promise { + const filePath = requireString(params, "filePath"); + const line = requireNumber(params, "line"); + const character = requireNumber(params, "character"); + + try { + const result = await withLspClient( + filePath, + async (client) => client.definition(filePath, line, character), + "definition", + clientOptions(signal), + ); + const locations = !result ? [] : Array.isArray(result) ? result : [result]; + const details: LspGotoDefinitionDetails = { filePath, line, character, locations }; + if (locations.length === 0) return text("No definition found", details); + return text(locations.map(formatLocation).join("\n"), details); + } catch (error) { + const message = handleMissingDependencyError(error); + if (message) { + return text(message, { + filePath, + line, + character, + locations: [], + error: message, + errorKind: "missing_dependency", + }); + } + throw error; + } +} + +async function executeLspFindReferences( + params: Record, + signal?: AbortSignal, +): Promise { + const filePath = requireString(params, "filePath"); + const line = requireNumber(params, "line"); + const character = requireNumber(params, "character"); + const includeDeclaration = optionalBoolean(params, "includeDeclaration") ?? true; + + try { + const result = await withLspClient( + filePath, + async (client) => client.references(filePath, line, character, includeDeclaration), + "references", + clientOptions(signal), + ); + const references = Array.isArray(result) ? result : []; + const total = references.length; + const truncated = total > DEFAULT_MAX_REFERENCES; + const limited = truncated ? references.slice(0, DEFAULT_MAX_REFERENCES) : references; + const details: LspFindReferencesDetails = { + filePath, + line, + character, + references, + totalReferences: total, + truncated, + }; + if (total === 0) return text("No references found", details); + const output = [ + ...(truncated ? [`Found ${total} references (showing first ${DEFAULT_MAX_REFERENCES}):`] : []), + ...limited.map(formatLocation), + ].join("\n"); + return text(output, details); + } catch (error) { + const message = handleMissingDependencyError(error); + if (message) { + return text(message, { + filePath, + line, + character, + references: [], + totalReferences: 0, + truncated: false, + error: message, + errorKind: "missing_dependency", + }); + } + throw error; + } +} + +async function executeLspSymbols(params: Record, signal?: AbortSignal): Promise { + const filePath = requireString(params, "filePath"); + const rawScope = optionalString(params, "scope") ?? "document"; + const scope = rawScope === "workspace" ? "workspace" : "document"; + const limit = Math.min(optionalNumber(params, "limit") ?? DEFAULT_MAX_SYMBOLS, DEFAULT_MAX_SYMBOLS); + + try { + if (scope === "workspace") { + const query = optionalString(params, "query"); + if (!query) { + const message = "Error: 'query' is required for workspace scope"; + return text(message, { + filePath, + scope, + symbols: [], + totalSymbols: 0, + truncated: false, + error: message, + errorKind: "missing_query", + }); + } + + const symbols = await withLspClient( + filePath, + async (client) => client.workspaceSymbols(query), + "workspaceSymbols", + clientOptions(signal), + ); + return formatSymbolsResult(filePath, scope, symbols, limit, query); + } + + const symbols = await withLspClient( + filePath, + async (client) => client.documentSymbols(filePath), + "documentSymbols", + clientOptions(signal), + ); + return formatSymbolsResult(filePath, scope, symbols, limit); + } catch (error) { + const message = handleMissingDependencyError(error); + if (message) { + const query = optionalString(params, "query"); + return text(message, { + filePath, + scope, + symbols: [], + totalSymbols: 0, + truncated: false, + error: message, + errorKind: "missing_dependency", + ...(query === undefined ? {} : { query }), + }); + } + throw error; + } +} + +function formatSymbolsResult( + filePath: string, + scope: "document" | "workspace", + symbols: Array, + limit: number, + query?: string, +): ToolExecutionResult { + const total = symbols.length; + const truncated = total > limit; + const limited = truncated ? symbols.slice(0, limit) : symbols; + const details: LspSymbolsDetails = { + filePath, + scope, + symbols, + totalSymbols: total, + truncated, + ...(query === undefined ? {} : { query }), + }; + if (total === 0) return text("No symbols found", details); + + const lines: string[] = []; + if (truncated) lines.push(`Found ${total} symbols (showing first ${limit}):`); + const documentSymbols = limited.filter(isDocumentSymbol); + if (documentSymbols.length === limited.length) { + lines.push(...documentSymbols.map((symbol) => formatDocumentSymbol(symbol))); + } else { + lines.push(...limited.filter((symbol): symbol is SymbolInfo => !isDocumentSymbol(symbol)).map(formatSymbolInfo)); + } + return text(lines.join("\n"), details); +} + +async function executeLspPrepareRename( + params: Record, + signal?: AbortSignal, +): Promise { + const filePath = requireString(params, "filePath"); + const line = requireNumber(params, "line"); + const character = requireNumber(params, "character"); + + try { + const result = await withLspClient( + filePath, + async (client) => client.prepareRename(filePath, line, character), + "prepareRename", + clientOptions(signal), + ); + const details: LspPrepareRenameDetails = { filePath, line, character, result }; + return text(formatPrepareRenameResult(result), details); + } catch (error) { + const message = handleMissingDependencyError(error); + if (message) { + return text(message, { + filePath, + line, + character, + result: null, + error: message, + errorKind: "missing_dependency", + }); + } + throw error; + } +} + +async function executeLspRename(params: Record, signal?: AbortSignal): Promise { + const filePath = requireString(params, "filePath"); + const line = requireNumber(params, "line"); + const character = requireNumber(params, "character"); + const newName = requireString(params, "newName"); + + try { + const edit = await withLspClient( + filePath, + async (client) => client.rename(filePath, line, character, newName), + "rename", + clientOptions(signal), + ); + const apply = applyWorkspaceEdit(edit); + const details: LspRenameDetails = { filePath, line, character, newName, apply, edit }; + return text(formatApplyResult(apply), details, !apply.success); + } catch (error) { + const message = handleMissingDependencyError(error); + if (message) { + return text(message, { + filePath, + line, + character, + newName, + apply: null, + edit: null, + error: message, + errorKind: "missing_dependency", + }); + } + throw error; + } +} + +export async function executeLspTool( + name: string, + params: Record, + signal?: AbortSignal, +): Promise { + const tool = LSP_MCP_TOOLS.find((candidate) => matchesToolName(candidate, name)); + if (!tool) throw new Error(`Unknown LSP tool: ${name}`); + return tool.execute(params, signal); +} + +function matchesToolName(tool: LspMcpTool, name: string): boolean { + return tool.name === name || (tool.aliases?.includes(name) ?? false); +} + +export function coerceToolArguments(value: unknown): Record { + return isRecord(value) ? value : {}; +} + +export const LSP_MCP_TOOLS: LspMcpTool[] = [ + { + name: "status", + aliases: ["lsp_status"], + title: "LSP Status", + description: "List configured and active LSP servers without starting a new language server.", + inputSchema: objectSchema({}), + execute: executeLspStatus, + }, + { + name: "diagnostics", + aliases: ["lsp_diagnostics"], + title: "LSP Diagnostics", + description: "Get errors, warnings, and hints for a source file or directory.", + inputSchema: objectSchema( + { + filePath: { type: "string", description: "File or directory path to check." }, + severity: { + type: "string", + enum: ["error", "warning", "information", "hint", "all"], + description: "Severity filter. Defaults to all.", + }, + }, + ["filePath"], + ), + execute: executeLspDiagnostics, + }, + { + name: "goto_definition", + aliases: ["lsp_goto_definition"], + title: "LSP Goto Definition", + description: "Find where a symbol is defined.", + inputSchema: objectSchema( + { + filePath: { type: "string", description: "Source file containing the symbol." }, + line: { type: "number", description: "1-based line number." }, + character: { type: "number", description: "0-based column." }, + }, + ["filePath", "line", "character"], + ), + execute: executeLspGotoDefinition, + }, + { + name: "find_references", + aliases: ["lsp_find_references"], + title: "LSP Find References", + description: "Find references of a symbol across the workspace.", + inputSchema: objectSchema( + { + filePath: { type: "string", description: "Source file containing the symbol." }, + line: { type: "number", description: "1-based line number." }, + character: { type: "number", description: "0-based column." }, + includeDeclaration: { type: "boolean", description: "Include the declaration. Defaults to true." }, + }, + ["filePath", "line", "character"], + ), + execute: executeLspFindReferences, + }, + { + name: "symbols", + aliases: ["lsp_symbols"], + title: "LSP Symbols", + description: "List document symbols or search workspace symbols.", + inputSchema: objectSchema( + { + filePath: { type: "string", description: "File path used as LSP context." }, + scope: { + type: "string", + enum: ["document", "workspace"], + description: "Use document for file outline or workspace for project-wide search.", + }, + query: { type: "string", description: "Workspace symbol query." }, + limit: { type: "number", description: "Maximum number of symbols to return." }, + }, + ["filePath", "scope"], + ), + execute: executeLspSymbols, + }, + { + name: "prepare_rename", + aliases: ["lsp_prepare_rename"], + title: "LSP Prepare Rename", + description: "Check whether a symbol can be renamed at a position.", + inputSchema: objectSchema( + { + filePath: { type: "string", description: "Source file path." }, + line: { type: "number", description: "1-based line number." }, + character: { type: "number", description: "0-based column." }, + }, + ["filePath", "line", "character"], + ), + execute: executeLspPrepareRename, + }, + { + name: "rename", + aliases: ["lsp_rename"], + title: "LSP Rename", + description: "Rename a symbol across the workspace and apply the returned workspace edit.", + inputSchema: objectSchema( + { + filePath: { type: "string", description: "Source file path." }, + line: { type: "number", description: "1-based line number." }, + character: { type: "number", description: "0-based column." }, + newName: { type: "string", description: "New symbol name." }, + }, + ["filePath", "line", "character", "newName"], + ), + execute: executeLspRename, + }, +]; diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/config-loader.test.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/config-loader.test.ts new file mode 100644 index 000000000..1bffd6cef --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/config-loader.test.ts @@ -0,0 +1,135 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, sep } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { getConfigPaths, getMergedServers } from "../src/lsp/config-loader.js"; + +const tempDirectories: string[] = []; + +afterEach(() => { + for (const directory of tempDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("config loader", () => { + it("uses Codex config locations instead of pi config locations", () => { + const paths = getConfigPaths(); + const expectedSuffix = join(".codex", "lsp-client.json"); + const piMarker = `${sep}.pi${sep}`; + + expect(paths.project.endsWith(expectedSuffix)).toBe(true); + expect(paths.user.endsWith(expectedSuffix)).toBe(true); + expect(paths.project).not.toContain(piMarker); + expect(paths.user).not.toContain(piMarker); + }); + + it("supports project and user config path overrides via environment variables", () => { + const previousProject = process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"]; + const previousUser = process.env["LSP_TOOLS_MCP_USER_CONFIG"]; + + process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"] = "config/lsp-opencode.json"; + process.env["LSP_TOOLS_MCP_USER_CONFIG"] = ".opencode/lsp.json"; + + try { + const paths = getConfigPaths(); + + expect(paths.project).toBe(join(process.cwd(), "config", "lsp-opencode.json")); + expect(paths.user).toBe(join(process.env["HOME"] ?? "", ".opencode", "lsp.json")); + } finally { + if (previousProject === undefined) { + delete process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"]; + } else { + process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"] = previousProject; + } + + if (previousUser === undefined) { + delete process.env["LSP_TOOLS_MCP_USER_CONFIG"]; + } else { + process.env["LSP_TOOLS_MCP_USER_CONFIG"] = previousUser; + } + } + }); + + it("keeps absolute override paths unchanged", () => { + const previousProject = process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"]; + const previousUser = process.env["LSP_TOOLS_MCP_USER_CONFIG"]; + const absoluteProject = join(process.cwd(), "overrides", "project.json"); + const absoluteUser = join(process.cwd(), "overrides", "user.json"); + + process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"] = absoluteProject; + process.env["LSP_TOOLS_MCP_USER_CONFIG"] = absoluteUser; + + try { + const paths = getConfigPaths(); + + expect(paths.project).toBe(absoluteProject); + expect(paths.user).toBe(absoluteUser); + } finally { + if (previousProject === undefined) { + delete process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"]; + } else { + process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"] = previousProject; + } + + if (previousUser === undefined) { + delete process.env["LSP_TOOLS_MCP_USER_CONFIG"]; + } else { + process.env["LSP_TOOLS_MCP_USER_CONFIG"] = previousUser; + } + } + }); + + it("#given one invalid LSP config entry #when merging servers #then keeps valid sibling entries", () => { + // given + const previousProject = process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"]; + const previousUser = process.env["LSP_TOOLS_MCP_USER_CONFIG"]; + const root = mkdtempSync(join(tmpdir(), "lsp-tools-config-")); + tempDirectories.push(root); + const projectConfig = join(root, "project.json"); + const userConfig = join(root, "user.json"); + mkdirSync(root, { recursive: true }); + writeFileSync( + projectConfig, + JSON.stringify({ + lsp: { + valid: { command: ["valid-lsp", "--stdio"], extensions: [".valid"], priority: 7 }, + invalid: "not an object", + }, + }), + ); + writeFileSync(userConfig, JSON.stringify({ lsp: {} })); + process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"] = projectConfig; + process.env["LSP_TOOLS_MCP_USER_CONFIG"] = userConfig; + + try { + // when + const servers = getMergedServers(); + + // then + expect(servers).toContainEqual( + expect.objectContaining({ + id: "valid", + command: ["valid-lsp", "--stdio"], + extensions: [".valid"], + priority: 7, + source: "project", + }), + ); + expect(servers.some((server) => server.id === "invalid")).toBe(false); + } finally { + if (previousProject === undefined) { + delete process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"]; + } else { + process.env["LSP_TOOLS_MCP_PROJECT_CONFIG"] = previousProject; + } + + if (previousUser === undefined) { + delete process.env["LSP_TOOLS_MCP_USER_CONFIG"]; + } else { + process.env["LSP_TOOLS_MCP_USER_CONFIG"] = previousUser; + } + } + }); +}); diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/directory-diagnostics.test.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/directory-diagnostics.test.ts new file mode 100644 index 000000000..cbd85cae7 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/directory-diagnostics.test.ts @@ -0,0 +1,32 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { collectFilesWithExtension } from "../src/lsp/directory-diagnostics.js"; + +const tempDirectories: string[] = []; + +afterEach(() => { + for (const directory of tempDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("collectFilesWithExtension", () => { + it("#given more matching files than max #when collecting diagnostics inputs #then traversal returns only capped files", () => { + // given + const root = mkdtempSync(join(tmpdir(), "codex-lsp-directory-")); + tempDirectories.push(root); + mkdirSync(join(root, "src"), { recursive: true }); + for (let index = 0; index < 5; index += 1) { + writeFileSync(join(root, "src", `file-${index}.ts`), `export const value${index} = ${index};\n`); + } + + // when + const files = collectFilesWithExtension(root, ".ts", 2); + + // then + expect(files).toHaveLength(2); + }); +}); diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/fixtures/broken.py b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/fixtures/broken.py new file mode 100644 index 000000000..745b30daf --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/fixtures/broken.py @@ -0,0 +1 @@ +value: str = 1 diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/formatters.test.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/formatters.test.ts new file mode 100644 index 000000000..61fbf6365 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/formatters.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { filterDiagnosticsBySeverity } from "../src/lsp/formatters.js"; +import type { Diagnostic } from "../src/lsp/types.js"; + +const range = { + start: { line: 0, character: 0 }, + end: { line: 0, character: 1 }, +}; + +function diagnostic(message: string, severity?: number): Diagnostic { + return severity === undefined ? { range, message } : { range, message, severity }; +} + +describe("filterDiagnosticsBySeverity", () => { + it("#given all severity filter #when filtering diagnostics #then returns the original diagnostics", () => { + // given + const diagnostics = [diagnostic("syntax", 1), diagnostic("note", 3)]; + + // when + const filtered = filterDiagnosticsBySeverity(diagnostics, "all"); + + // then + expect(filtered).toBe(diagnostics); + }); + + it("#given mixed severities #when filtering diagnostics #then returns only matching diagnostics", () => { + // given + const diagnostics = [ + diagnostic("syntax", 1), + diagnostic("lint", 2), + diagnostic("note", 3), + diagnostic("hint", 4), + diagnostic("unknown"), + ]; + + // when / then + expect(filterDiagnosticsBySeverity(diagnostics, "error")).toEqual([diagnostics[0]]); + expect(filterDiagnosticsBySeverity(diagnostics, "warning")).toEqual([diagnostics[1]]); + expect(filterDiagnosticsBySeverity(diagnostics, "information")).toEqual([diagnostics[2]]); + expect(filterDiagnosticsBySeverity(diagnostics, "hint")).toEqual([diagnostics[3]]); + }); +}); diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/helpers/fake-lsp-client.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/helpers/fake-lsp-client.ts new file mode 100644 index 000000000..6d199210a --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/helpers/fake-lsp-client.ts @@ -0,0 +1,74 @@ +import { LspClient } from "../../src/lsp/client.js"; +import type { ResolvedServer } from "../../src/lsp/types.js"; + +export interface FakeLspClientOptions { + startDelayMs?: number; + initDelayMs?: number; + failStart?: boolean; + failInitialize?: boolean; + stopDelayMs?: number; + startsAlive?: boolean; +} + +export class FakeLspClient extends LspClient { + private aliveFlag: boolean; + startCallCount = 0; + initializeCallCount = 0; + stopCallCount = 0; + + constructor( + root: string, + server: ResolvedServer, + private readonly opts: FakeLspClientOptions = {}, + ) { + super(root, server); + this.aliveFlag = opts.startsAlive !== false; + } + + override async start(): Promise { + this.startCallCount++; + if (this.opts.startDelayMs !== undefined) { + await new Promise((resolve) => setTimeout(resolve, this.opts.startDelayMs)); + } + if (this.opts.failStart) { + this.aliveFlag = false; + throw new Error("fake start failed"); + } + } + + override async initialize(): Promise { + this.initializeCallCount++; + if (this.opts.initDelayMs !== undefined) { + await new Promise((resolve) => setTimeout(resolve, this.opts.initDelayMs)); + } + if (this.opts.failInitialize) { + this.aliveFlag = false; + throw new Error("fake initialize failed"); + } + } + + override isAlive(): boolean { + return this.aliveFlag; + } + + override command(): string[] { + return ["fake-server"]; + } + + override async stop(): Promise { + this.stopCallCount++; + if (this.opts.stopDelayMs !== undefined) { + await new Promise((resolve) => setTimeout(resolve, this.opts.stopDelayMs)); + } + this.aliveFlag = false; + } +} + +export function makeServer(id: string, extensions: string[] = [".ts"]): ResolvedServer { + return { + id, + command: ["fake-server", "--stdio"], + extensions, + priority: 0, + }; +} diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/json-rpc-connection.test.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/json-rpc-connection.test.ts new file mode 100644 index 000000000..5b9e8d471 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/json-rpc-connection.test.ts @@ -0,0 +1,69 @@ +import { PassThrough } from "node:stream"; +import { describe, expect, it } from "vitest"; + +import { JsonRpcConnection } from "../src/lsp/json-rpc-connection.js"; + +function encodeMessage(message: Record): string { + const body = JSON.stringify(message); + return `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`; +} + +function readOneMessage(stream: PassThrough): Promise> { + return new Promise((resolve) => { + stream.once("data", (chunk: Buffer) => { + const text = chunk.toString("utf8"); + const bodyStart = text.indexOf("\r\n\r\n") + 4; + resolve(JSON.parse(text.slice(bodyStart)) as Record); + }); + }); +} + +describe("JsonRpcConnection", () => { + it("#given a framed response #when sending request #then resolves the matching result", async () => { + // given + const serverOutput = new PassThrough(); + const serverInput = new PassThrough(); + const connection = new JsonRpcConnection(serverOutput, serverInput); + connection.listen(); + const requestMessage = readOneMessage(serverInput); + + // when + const resultPromise = connection.sendRequest<{ capabilities: Record }>("initialize", { + rootUri: "file:///tmp/project", + }); + const request = await requestMessage; + serverOutput.write(encodeMessage({ jsonrpc: "2.0", id: request["id"], result: { capabilities: {} } })); + + // then + await expect(resultPromise).resolves.toEqual({ capabilities: {} }); + connection.dispose(); + }); + + it("#given a server request #when handler returns #then writes a json-rpc response", async () => { + // given + const serverOutput = new PassThrough(); + const serverInput = new PassThrough(); + const connection = new JsonRpcConnection(serverOutput, serverInput); + connection.onRequest("workspace/configuration", () => [{ validate: { enable: true } }]); + connection.listen(); + + // when + const responseMessage = readOneMessage(serverInput); + serverOutput.write( + encodeMessage({ + jsonrpc: "2.0", + id: 7, + method: "workspace/configuration", + params: { items: [{ section: "json" }] }, + }), + ); + + // then + await expect(responseMessage).resolves.toMatchObject({ + jsonrpc: "2.0", + id: 7, + result: [{ validate: { enable: true } }], + }); + connection.dispose(); + }); +}); diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/manager.test.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/manager.test.ts new file mode 100644 index 000000000..4242e2622 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/manager.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; + +import type { LspClient } from "../src/lsp/client.js"; +import { LspManager } from "../src/lsp/manager.js"; +import type { ResolvedServer } from "../src/lsp/types.js"; + +import { FakeLspClient, type FakeLspClientOptions, makeServer } from "./helpers/fake-lsp-client.js"; + +interface FakeContext { + manager: LspManager; + clients: FakeLspClient[]; + now: { value: number }; +} + +function setupManager(options?: { + idleTimeoutMs?: number; + initTimeoutMs?: number; + reaperIntervalMs?: number; + clientFactoryOptions?: () => FakeLspClientOptions; +}): FakeContext { + const clients: FakeLspClient[] = []; + const now = { value: 1_000 }; + const manager = new LspManager({ + idleTimeoutMs: options?.idleTimeoutMs ?? 5_000, + initTimeoutMs: options?.initTimeoutMs ?? 1_000, + reaperIntervalMs: options?.reaperIntervalMs ?? 100, + now: () => now.value, + clientFactory: (root: string, server: ResolvedServer): LspClient => { + const client = new FakeLspClient(root, server, options?.clientFactoryOptions?.()); + clients.push(client); + return client; + }, + }); + return { manager, clients, now }; +} + +type ProcessSignalListener = (...args: never[]) => unknown; + +function findAddedListener( + signal: NodeJS.Signals, + before: readonly ProcessSignalListener[], +): ProcessSignalListener | undefined { + return process.listeners(signal).find((listener) => !before.includes(listener)); +} + +describe("LspManager", () => { + it("#given failed start #when later getClient #then failed client is stopped and a fresh client is built", async () => { + // given + let firstCall = true; + const failingFactory = () => { + if (firstCall) { + firstCall = false; + return { failStart: true }; + } + return {}; + }; + const { manager, clients } = setupManager({ clientFactoryOptions: failingFactory }); + const server = makeServer("typescript"); + + // when + await expect(manager.getClient("/root/a", server)).rejects.toThrow("fake start failed"); + + // then + expect(manager.getSnapshot()).toEqual([]); + expect(clients[0]?.stopCallCount).toBeGreaterThan(0); + + const fresh = await manager.getClient("/root/a", server); + expect(clients.length).toBe(2); + expect(fresh).toBe(clients[1]); + + await manager.stopAll(); + }); + + it("#given failed initialize #when later getClient #then failed client is stopped and a fresh client is built", async () => { + // given + let firstCall = true; + const failingFactory = () => { + if (firstCall) { + firstCall = false; + return { failInitialize: true }; + } + return {}; + }; + const { manager, clients } = setupManager({ clientFactoryOptions: failingFactory }); + const server = makeServer("typescript"); + + // when + await expect(manager.getClient("/root/a", server)).rejects.toThrow("fake initialize failed"); + + // then + expect(manager.getSnapshot()).toEqual([]); + expect(clients[0]?.stopCallCount).toBeGreaterThan(0); + + const fresh = await manager.getClient("/root/a", server); + expect(clients.length).toBe(2); + expect(fresh).toBe(clients[1]); + + await manager.stopAll(); + }); + + it("#given active client #when signal cleanup runs #then client is stopped and handlers unregister", async () => { + // given + const beforeSigterm = process.listeners("SIGTERM"); + const { manager, clients } = setupManager(); + const server = makeServer("typescript"); + + try { + await manager.getClient("/root/a", server); + manager.releaseClient("/root/a", server.id); + + // when + const listener = findAddedListener("SIGTERM", beforeSigterm); + + // then + expect(listener).toBeDefined(); + listener?.(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(clients[0]?.stopCallCount).toBe(1); + expect(manager.clientCount()).toBe(0); + expect(process.listeners("SIGTERM")).toEqual(beforeSigterm); + } finally { + await manager.stopAll(); + } + }); +}); diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/mcp.test.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/mcp.test.ts new file mode 100644 index 000000000..61f380a7b --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/mcp.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; + +import { handleLspMcpRequest } from "../src/mcp.js"; + +describe("lsp MCP server", () => { + it("responds to initialize with tool capabilities", async () => { + const response = await handleLspMcpRequest({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "test", version: "0.0.0" }, + }, + }); + + expect(response).toMatchObject({ + jsonrpc: "2.0", + id: 1, + result: { + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: "lsp", version: "0.1.0" }, + }, + }); + }); + + it("lists LSP MCP tools", async () => { + const response = await handleLspMcpRequest({ + jsonrpc: "2.0", + id: 2, + method: "tools/list", + }); + + const tools = response?.result?.tools as Array<{ name: string }>; + expect(tools.map((tool) => tool.name)).toEqual([ + "status", + "diagnostics", + "goto_definition", + "find_references", + "symbols", + "prepare_rename", + "rename", + ]); + }); + + it("calls status without starting a language server", async () => { + const response = await handleLspMcpRequest({ + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "status", arguments: {} }, + }); + + expect(response).toMatchObject({ + jsonrpc: "2.0", + id: 3, + result: { + isError: false, + }, + }); + expect(response?.result?.content?.[0]?.text).toContain("Configured LSP servers"); + }); + + it("accepts legacy lsp-prefixed tool names without listing them", async () => { + const response = await handleLspMcpRequest({ + jsonrpc: "2.0", + id: 4, + method: "tools/call", + params: { name: "lsp_status", arguments: {} }, + }); + + expect(response).toMatchObject({ + jsonrpc: "2.0", + id: 4, + result: { + isError: false, + }, + }); + expect(response?.result?.content?.[0]?.text).toContain("Configured LSP servers"); + }); +}); diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/package-smoke.test.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/package-smoke.test.ts new file mode 100644 index 000000000..bc6196fc7 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/package-smoke.test.ts @@ -0,0 +1,63 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +type PackageJson = { + readonly type: string; + readonly packageManager: string; + readonly name: string; + readonly license: string; + readonly bin: Record; + readonly files: readonly string[]; + readonly dependencies?: Record; +}; + +function readPackageJson(path: string): PackageJson { + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + if (!isPackageJson(parsed)) throw new TypeError(`Invalid package metadata: ${path}`); + return parsed; +} + +describe("package metadata", () => { + it("#given packaged files #when validating entrypoints #then package metadata is consistent", () => { + // given + const packageJson = readPackageJson("package.json"); + const cliSource = readFileSync("src/cli.ts", "utf8"); + + // then + expect(packageJson.type).toBe("module"); + expect(packageJson.packageManager).toBe("npm@11.12.1"); + expect(packageJson.name).toBe("@code-yeongyu/lsp-tools-mcp"); + expect(packageJson.license).toBe("MIT"); + expect(packageJson.dependencies ?? {}).toEqual({}); + expect(packageJson.bin["lsp-tools-mcp"]).toBe("./dist/cli.js"); + expect(packageJson.files).toEqual(["dist", "LICENSE", "NOTICE", "README.md", "CHANGELOG.md"]); + expect(cliSource.startsWith("#!/usr/bin/env node")).toBe(true); + expect(cliSource).toContain("Usage: lsp-tools-mcp [mcp]"); + }); +}); + +function isPackageJson(value: unknown): value is PackageJson { + const dependencies = isRecord(value) ? value["dependencies"] : undefined; + return ( + isRecord(value) && + value["type"] === "module" && + value["packageManager"] === "npm@11.12.1" && + value["name"] === "@code-yeongyu/lsp-tools-mcp" && + value["license"] === "MIT" && + isStringRecord(value["bin"]) && + isStringArray(value["files"]) && + (dependencies === undefined || isRecord(dependencies)) + ); +} + +function isStringRecord(value: unknown): value is Record { + return isRecord(value) && Object.values(value).every((item) => typeof item === "string"); +} + +function isStringArray(value: unknown): value is readonly string[] { + return Array.isArray(value) && 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/lsp/packages/lsp-tools-mcp/test/process.test.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/process.test.ts new file mode 100644 index 000000000..c71a0c0db --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/process.test.ts @@ -0,0 +1,151 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { createSpawnCommand, spawnProcess } from "../src/lsp/process.js"; + +const tempDirectories: string[] = []; + +afterEach(() => { + for (const directory of tempDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function readFirstLine(stream: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + let buffer = ""; + + const cleanup = () => { + stream.off("data", onData); + stream.off("error", onError); + }; + + const onData = (chunk: Buffer | string) => { + buffer += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk; + const newlineIndex = buffer.indexOf("\n"); + if (newlineIndex === -1) return; + cleanup(); + resolve(buffer.slice(0, newlineIndex).trim()); + }; + + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + + stream.on("data", onData); + stream.on("error", onError); + }); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function killPidBestEffort(pid: number): void { + try { + process.kill(pid, "SIGKILL"); + } catch { + // Already exited. + } +} + +describe("createSpawnCommand", () => { + it("#given windows executable command #when building spawn command #then it avoids shell mode", () => { + // given + const command = ["typescript-language-server", "--stdio"]; + + // when + const prepared = createSpawnCommand(command, "win32", "cmd.exe"); + + // then + expect(prepared).toEqual({ + command: "typescript-language-server", + args: ["--stdio"], + shell: false, + }); + }); + + it("#given windows cmd shim #when building spawn command #then it uses cmd only for the shim", () => { + // given + const command = ["typescript-language-server.cmd", "--stdio"]; + + // when + const prepared = createSpawnCommand(command, "win32", "cmd.exe"); + + // then + expect(prepared).toEqual({ + command: "cmd.exe", + args: ["/d", "/s", "/c", "typescript-language-server.cmd", "--stdio"], + shell: false, + }); + }); + + it("#given windows PATH shim #when resolving spawn command #then it executes the shim without shell mode", () => { + // given + const binaryDirectory = mkdtempSync(join(tmpdir(), "codex-lsp-bin-")); + tempDirectories.push(binaryDirectory); + mkdirSync(binaryDirectory, { recursive: true }); + const shimPath = join(binaryDirectory, "typescript-language-server.cmd"); + writeFileSync(shimPath, "@echo off\n"); + + // when + const prepared = createSpawnCommand(["typescript-language-server", "--stdio"], "win32", "cmd.exe", { + PATH: binaryDirectory, + PATHEXT: ".cmd;.exe", + }); + + // then + expect(prepared).toEqual({ + command: "cmd.exe", + args: ["/d", "/s", "/c", shimPath, "--stdio"], + shell: false, + }); + }); +}); + +describe("spawnProcess", () => { + it.skipIf(process.platform === "win32")( + "#given child process tree #when killing spawned wrapper #then descendant process exits too", + async () => { + // given + const directory = mkdtempSync(join(tmpdir(), "lsp-tools-process-tree-")); + tempDirectories.push(directory); + const script = [ + "const { spawn } = require('node:child_process')", + "const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' })", + "console.error(String(child.pid))", + "process.on('SIGTERM', () => process.exit(0))", + "setInterval(() => {}, 1000)", + ].join(";"); + const proc = spawnProcess([process.execPath, "-e", script], { cwd: directory, env: process.env }); + const childPid = Number(await readFirstLine(proc.stderr)); + + try { + // when + proc.kill("SIGTERM"); + await Promise.race([proc.exited, sleep(2_000)]); + await sleep(200); + + // then + expect(Number.isInteger(childPid)).toBe(true); + expect(isPidAlive(childPid)).toBe(false); + } finally { + killPidBestEffort(childPid); + proc.kill("SIGKILL"); + } + }, + ); +}); diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/server-definitions.test.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/server-definitions.test.ts new file mode 100644 index 000000000..5fb9bdbac --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/server-definitions.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { AUTO_INSTALLABLE_SERVERS, BUILTIN_SERVERS, LSP_INSTALL_HINTS } from "../src/lsp/server-definitions.js"; + +describe("BUILTIN_SERVERS", () => { + it("#given rust #when looking it up #then maps to rust-analyzer", () => { + // given + const rust = BUILTIN_SERVERS["rust"]; + + // when / then + expect(rust).toBeDefined(); + expect(rust?.command[0]).toBe("rust-analyzer"); + expect(rust?.extensions).toEqual([".rs"]); + }); + + it("#given rust install guidance #when inspecting registry #then rust is manual install only", () => { + // given + const hint = LSP_INSTALL_HINTS["rust"]; + + // when / then + expect(AUTO_INSTALLABLE_SERVERS["rust"]).toBeUndefined(); + expect(hint).toContain("rust-analyzer"); + expect(hint).toContain("rustup component add rust-analyzer"); + expect(hint).toContain("rustup component remove rust-src"); + expect(hint).toContain("rustup component add rust-src"); + }); +}); diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/utils.test.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/utils.test.ts new file mode 100644 index 000000000..614743d1d --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/test/utils.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import { LspProcessExitedError } from "../src/lsp/errors.js"; +import { formatKnownLspStartupFailure, handleMissingDependencyError } from "../src/lsp/utils.js"; + +describe("formatKnownLspStartupFailure", () => { + it("#given rust-src component conflict #when formatting startup failure #then returns repair guidance", () => { + // given + const error = new LspProcessExitedError( + "rust", + "/repo", + 1, + "failed to install component: 'rust-src', detected conflict: 'lib/rustlib/src/rust/library/Cargo.lock'", + ); + + // when + const message = formatKnownLspStartupFailure(error); + + // then + expect(message).toContain("rust-analyzer"); + expect(message).toContain("rustup component remove rust-src"); + expect(message).toContain("rustup component add rust-src"); + expect(message).toContain("detected conflict"); + expect(message).toContain("Cargo.lock"); + expect(message).not.toContain("automatic repair"); + }); + + it("#given rust-analyzer sysroot error #when handling missing dependency #then returns repair guidance", () => { + // given + const error = new LspProcessExitedError( + "rust", + "/repo", + 1, + "can't load standard library from sysroot\ntry installing `rust-src` the same way you installed `rustc`", + ); + + // when + const message = handleMissingDependencyError(error); + + // then + expect(message).toContain("rustup component remove rust-src"); + expect(message).toContain("rustup component add rust-src"); + expect(message).toContain("can't load standard library"); + }); + + it("#given unrelated process exits #when formatting startup failure #then returns null", () => { + // given + const typescriptError = new LspProcessExitedError( + "typescript", + "/repo", + 1, + "failed to install component: 'rust-src', detected conflict", + ); + const rustPanic = new LspProcessExitedError("rust", "/repo", 1, "thread panicked while loading crate graph"); + + // when / then + expect(formatKnownLspStartupFailure(typescriptError)).toBeNull(); + expect(formatKnownLspStartupFailure(rustPanic)).toBeNull(); + }); +}); + +describe("handleMissingDependencyError", () => { + it("#given existing dependency messages #when handling error #then preserves current messages", () => { + // given + const notInstalled = new Error("LSP server 'typescript' is configured but NOT INSTALLED."); + const notConfigured = new Error("No LSP server configured for extension: .md"); + + // when / then + expect(handleMissingDependencyError(notInstalled)).toBe(notInstalled.message); + expect(handleMissingDependencyError(notConfigured)).toBe(notConfigured.message); + }); +}); diff --git a/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/tsconfig.build.json b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/tsconfig.build.json new file mode 100644 index 000000000..5b5bbcafd --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/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/lsp/packages/lsp-tools-mcp/tsconfig.json b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/tsconfig.json new file mode 100644 index 000000000..342229c02 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/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/components/lsp/packages/lsp-tools-mcp/vitest.config.ts b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/vitest.config.ts new file mode 100644 index 000000000..57bd8f12b --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/packages/lsp-tools-mcp/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + environment: "node", + pool: "threads", + }, +}); diff --git a/packages/omo-codex/plugin/components/lsp/scripts/bootstrap-submodule.mjs b/packages/omo-codex/plugin/components/lsp/scripts/bootstrap-submodule.mjs new file mode 100644 index 000000000..a1b45e3de --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/scripts/bootstrap-submodule.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +// Bootstrap the lsp-tools-mcp git submodule for local development. +// CI runs the install+build steps explicitly in the workflow, so this +// script is mostly for `npm run bootstrap` after a fresh clone and as a +// chained pre-step before typecheck / test / check so contributors do not +// have to remember it. +// +// Idempotent: skips when dist/cli.js already exists, unless --force is passed. +import { existsSync } from "node:fs"; +import { execSync } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const submoduleDir = join(__dirname, "..", "packages", "lsp-tools-mcp"); +const submodulePackageJson = join(submoduleDir, "package.json"); +const submoduleDistCli = join(submoduleDir, "dist", "cli.js"); +const force = process.argv.includes("--force"); + +if (!existsSync(submodulePackageJson)) { + console.error( + "lsp-tools-mcp submodule is missing. Run: git submodule update --init --recursive", + ); + process.exit(1); +} + +if (!force && existsSync(submoduleDistCli)) { + // Already built; nothing to do. + process.exit(0); +} + +console.log("Installing lsp-tools-mcp dependencies..."); +execSync("npm ci", { cwd: submoduleDir, stdio: "inherit" }); + +console.log("Building lsp-tools-mcp..."); +execSync("npm run build", { cwd: submoduleDir, stdio: "inherit" }); + +console.log("Done."); diff --git a/packages/omo-codex/plugin/components/lsp/skills/lsp/SKILL.md b/packages/omo-codex/plugin/components/lsp/skills/lsp/SKILL.md new file mode 100644 index 000000000..36be06844 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/skills/lsp/SKILL.md @@ -0,0 +1,35 @@ +--- +name: lsp +description: Use when Codex needs language-server diagnostics, definitions, references, symbols, or rename safety checks in the current workspace. +--- + +# Codex LSP + +Call `lsp` MCP tools through the tool interface; `lsp.*`/`mcp__lsp__*` are tool-call names, not shell commands. + +## Tools + +- `lsp.status`: list configured, installed, missing, disabled, and active language servers. +- `lsp.diagnostics`: check one file or directory for LSP diagnostics. Prefer `severity: "error"` after edits. +- `lsp.goto_definition`: locate a symbol definition from file, line, and character. +- `lsp.find_references`: find usages of a symbol across the workspace. +- `lsp.symbols`: inspect document symbols or search workspace symbols. +- `lsp.prepare_rename`: check whether a rename is valid at a position. +- `lsp.rename`: apply a language-server workspace edit for a rename. + +## Config + +Project config lives at `.codex/lsp-client.json`; user config lives at `~/.codex/lsp-client.json`. + +```json +{ + "lsp": { + "typescript": { + "command": ["typescript-language-server", "--stdio"], + "extensions": [".ts", ".tsx", ".js", ".jsx"] + } + } +} +``` + +Use `lsp.status` first when diagnostics report a missing language server. diff --git a/packages/omo-codex/plugin/components/lsp/src/cli.ts b/packages/omo-codex/plugin/components/lsp/src/cli.ts new file mode 100644 index 000000000..9373ad7d9 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/src/cli.ts @@ -0,0 +1,33 @@ +#!/usr/bin/env node +import { argv, stderr } from "node:process"; + +import { disposeDefaultLspManager } from "@code-yeongyu/lsp-tools-mcp/dist/lsp/manager.js"; +import { runMcpStdioServer } from "@code-yeongyu/lsp-tools-mcp/dist/mcp.js"; +import { runPostToolUseHookCli } from "./codex-hook.js"; + +async function main(): Promise { + const [command = "mcp", subcommand = ""] = argv.slice(2); + + try { + if (command === "hook" && subcommand === "post-tool-use") { + await runPostToolUseHookCli(); + return; + } + + if (command === "mcp") { + await runMcpStdioServer(); + return; + } + + stderr.write("Usage: codex-lsp [mcp | hook post-tool-use]\n"); + process.exitCode = 2; + } finally { + await disposeDefaultLspManager(); + } +} + +main().catch(async (error: unknown) => { + stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`); + await disposeDefaultLspManager(); + process.exitCode = 1; +}); diff --git a/packages/omo-codex/plugin/components/lsp/src/codex-hook.ts b/packages/omo-codex/plugin/components/lsp/src/codex-hook.ts new file mode 100644 index 000000000..c5ea956fc --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/src/codex-hook.ts @@ -0,0 +1,171 @@ +import { stdin as processStdin } from "node:process"; + +import { executeLspDiagnostics } from "@code-yeongyu/lsp-tools-mcp/dist/tools.js"; + +export type DiagnosticsRunner = (filePath: string) => Promise; + +export interface CodexPostToolUseInput { + tool_name?: unknown; + tool_input?: unknown; + tool_response?: unknown; +} + +interface DiagnosticBlock { + filePath: string; + diagnostics: string; +} + +interface PostToolUseHookOutput { + decision: "block"; + reason: string; + hookSpecificOutput: { + hookEventName: "PostToolUse"; + additionalContext: string; + }; +} + +const MUTATION_TOOL_NAMES = new Set(["apply_patch", "write", "edit", "multiedit", "multi_edit"]); +const CLEAN_DIAGNOSTICS_TEXT = "No diagnostics found"; +const UNSUPPORTED_EXTENSION_TEXT = "No LSP server configured for extension:"; + +export async function runLspDiagnosticsText(filePath: string): Promise { + const result = await executeLspDiagnostics({ filePath, severity: "error" }); + return result.content.map((block) => block.text).join("\n"); +} + +export async function runLspPostToolUseHook( + input: CodexPostToolUseInput, + runDiagnostics: DiagnosticsRunner = runLspDiagnosticsText, +): Promise { + const filePaths = extractMutatedFilePaths(input); + if (filePaths.length === 0) return ""; + + const blocks: DiagnosticBlock[] = []; + for (const filePath of filePaths) { + const diagnostics = (await runDiagnostics(filePath)).trim(); + if (isCleanDiagnostics(diagnostics)) continue; + blocks.push({ filePath, diagnostics }); + } + + if (blocks.length === 0) return ""; + + const reason = blocks + .map(({ filePath, diagnostics }) => `LSP diagnostics after editing ${filePath}:\n${diagnostics}`) + .join("\n\n"); + const output: PostToolUseHookOutput = { + decision: "block", + reason, + hookSpecificOutput: { + hookEventName: "PostToolUse", + additionalContext: reason, + }, + }; + return `${JSON.stringify(output)}\n`; +} + +export function extractMutatedFilePaths(input: CodexPostToolUseInput): string[] { + 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]; +} + +export async function runPostToolUseHookCli(stdin: NodeJS.ReadStream = processStdin): Promise { + const raw = await readStdin(stdin); + if (!raw.trim()) return; + const parsed: unknown = JSON.parse(raw); + const input = isRecord(parsed) ? parsed : {}; + const output = await runLspPostToolUseHook(input); + if (output) process.stdout.write(output); +} + +function isMutationTool(value: unknown): boolean { + if (typeof value !== "string") return false; + return MUTATION_TOOL_NAMES.has(value.toLowerCase()); +} + +function isCleanDiagnostics(diagnostics: string): boolean { + return ( + diagnostics.length === 0 || + diagnostics === CLEAN_DIAGNOSTICS_TEXT || + diagnostics.startsWith(UNSUPPORTED_EXTENSION_TEXT) + ); +} + +function isFailedToolResponse(value: unknown): boolean { + if (!isRecord(value)) return false; + return ( + value["isError"] === true || value["is_error"] === true || value["error"] === true || value["status"] === "error" + ); +} + +function addStringValue(paths: Set, value: unknown): void { + if (typeof value === "string" && value.length > 0) { + paths.add(value); + } +} + +function addStringArray(paths: Set, value: unknown): void { + if (!Array.isArray(value)) return; + for (const item of value) { + addStringValue(paths, item); + } +} + +function addPatchPayloads(paths: Set, input: Record): void { + addPatchInput(paths, input["input"]); + addPatchInput(paths, input["patch"]); + addPatchInput(paths, input["command"]); +} + +function addPatchInput(paths: Set, value: unknown): void { + 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: string): string | undefined { + const prefixes = ["*** Add File: ", "*** Update File: ", "*** Move to: "] as const; + for (const prefix of prefixes) { + if (line.startsWith(prefix)) return line.slice(prefix.length).trim(); + } + return undefined; +} + +function addPatchFiles(paths: Set, value: unknown): void { + 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: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +async function readStdin(stdin: NodeJS.ReadStream): Promise { + stdin.setEncoding("utf8"); + let raw = ""; + for await (const chunk of stdin) { + raw += chunk; + } + return raw; +} diff --git a/packages/omo-codex/plugin/components/lsp/test/codex-hook.test.ts b/packages/omo-codex/plugin/components/lsp/test/codex-hook.test.ts new file mode 100644 index 000000000..0bda45d19 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/test/codex-hook.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; + +import { extractMutatedFilePaths, runLspPostToolUseHook } from "../src/codex-hook.js"; + +describe("codex PostToolUse hook", () => { + it("extracts files from Codex apply_patch command payloads", () => { + const paths = extractMutatedFilePaths({ + tool_name: "apply_patch", + tool_input: { + command: [ + "*** Begin Patch", + "*** Add File: src/new.ts", + "+export const value = 1;", + "*** Update File: src/existing.ts", + "@@", + "-export const old = true;", + "+export const old = false;", + "*** End Patch", + ].join("\n"), + }, + tool_response: "Success. Updated files.", + }); + + expect(paths).toEqual(["src/new.ts", "src/existing.ts"]); + }); + + it("extracts files from edit-style tool input aliases", () => { + const paths = extractMutatedFilePaths({ + tool_name: "Edit", + tool_input: { file_path: "src/edit.ts" }, + tool_response: { ok: true }, + }); + + expect(paths).toEqual(["src/edit.ts"]); + }); + + it("returns blocking feedback when post-edit diagnostics contain errors", async () => { + const output = await runLspPostToolUseHook( + { + tool_name: "apply_patch", + tool_input: { + command: "*** Begin Patch\n*** Update File: src/broken.ts\n@@\n+missing();\n*** End Patch\n", + }, + tool_response: "Success. Updated files.", + }, + async (filePath) => { + expect(filePath).toBe("src/broken.ts"); + return "error[typescript] (2304) at 1:1: Cannot find name 'missing'."; + }, + ); + + expect(JSON.parse(output)).toEqual({ + decision: "block", + hookSpecificOutput: { + hookEventName: "PostToolUse", + additionalContext: + "LSP diagnostics after editing src/broken.ts:\n" + + "error[typescript] (2304) at 1:1: Cannot find name 'missing'.", + }, + reason: + "LSP diagnostics after editing src/broken.ts:\n" + + "error[typescript] (2304) at 1:1: Cannot find name 'missing'.", + }); + }); + + it("injects only files with diagnostics when multiple files are edited", async () => { + const checkedFilePaths: string[] = []; + const output = await runLspPostToolUseHook( + { + tool_name: "MultiEdit", + tool_input: { + file_paths: ["src/clean.ts", "README.md", "src/broken.ts", "src/broken.ts"], + }, + tool_response: { ok: true }, + }, + async (filePath) => { + checkedFilePaths.push(filePath); + if (filePath === "src/broken.ts") { + return "error[typescript] (2322) at 1:7: Type 'number' is not assignable to type 'string'."; + } + if (filePath === "README.md") { + return "No LSP server configured for extension: .md"; + } + return "No diagnostics found"; + }, + ); + + const expectedDiagnostics = + "LSP diagnostics after editing src/broken.ts:\n" + + "error[typescript] (2322) at 1:7: Type 'number' is not assignable to type 'string'."; + + expect(checkedFilePaths).toEqual(["src/clean.ts", "README.md", "src/broken.ts"]); + expect(JSON.parse(output)).toEqual({ + decision: "block", + hookSpecificOutput: { + hookEventName: "PostToolUse", + additionalContext: expectedDiagnostics, + }, + reason: expectedDiagnostics, + }); + }); + + it("does not run diagnostics for failed mutation tool responses", async () => { + const output = await runLspPostToolUseHook( + { + tool_name: "apply_patch", + tool_input: { + command: "*** Begin Patch\n*** Update File: src/broken.ts\n@@\n+missing();\n*** End Patch\n", + }, + tool_response: { isError: true }, + }, + async () => { + throw new Error("diagnostics should not run after failed mutations"); + }, + ); + + expect(output).toBe(""); + }); + + it("is silent for clean diagnostics and unsupported extensions", async () => { + const output = await runLspPostToolUseHook( + { + tool_name: "apply_patch", + tool_input: { + command: "*** Begin Patch\n*** Update File: README.md\n@@\n+hello\n*** End Patch\n", + }, + tool_response: "Success. Updated files.", + }, + async () => "No LSP server configured for extension: .md", + ); + + expect(output).toBe(""); + }); +}); diff --git a/packages/omo-codex/plugin/components/lsp/test/fixtures/broken.py b/packages/omo-codex/plugin/components/lsp/test/fixtures/broken.py new file mode 100644 index 000000000..745b30daf --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/test/fixtures/broken.py @@ -0,0 +1 @@ +value: str = 1 diff --git a/packages/omo-codex/plugin/components/lsp/test/fixtures/post-tool-use.json b/packages/omo-codex/plugin/components/lsp/test/fixtures/post-tool-use.json new file mode 100644 index 000000000..26045e320 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/test/fixtures/post-tool-use.json @@ -0,0 +1,15 @@ +{ + "session_id": "00000000-0000-0000-0000-000000000000", + "turn_id": "00000000-0000-0000-0000-000000000001", + "transcript_path": "/tmp/codex-lsp-transcript.jsonl", + "cwd": ".", + "hook_event_name": "PostToolUse", + "model": "gpt-5.5", + "permission_mode": "default", + "tool_name": "apply_patch", + "tool_input": { + "command": "*** Begin Patch\n*** Update File: test/fixtures/broken.py\n@@\n-value: str = 1\n+value: str = 1\n*** End Patch\n" + }, + "tool_response": "Success. Updated files.", + "tool_use_id": "toolu_000000000000000000000000" +} diff --git a/packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts b/packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts new file mode 100644 index 000000000..260a745b4 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts @@ -0,0 +1,164 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +type PackageJson = { + readonly version: string; + readonly type: string; + readonly packageManager: string; + readonly bin: Record; + readonly dependencies: Record; +}; + +type PluginJson = { + readonly version: string; + readonly hooks: string; + readonly mcpServers: string; +}; + +type HookCommand = { + readonly command: string; +}; + +type HookEntry = { + readonly hooks: readonly HookCommand[]; +}; + +type HooksJson = { + readonly hooks: Record; +}; + +type McpServer = { + readonly command: string; + readonly args: readonly string[]; +}; + +type McpJson = { + readonly mcpServers: Record; +}; + +function readPackageJson(path: string): PackageJson { + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + if (!isPackageJson(parsed)) throw new TypeError(`Invalid package metadata: ${path}`); + return parsed; +} + +function readPluginJson(path: string): PluginJson { + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + if (!isPluginJson(parsed)) throw new TypeError(`Invalid plugin metadata: ${path}`); + return parsed; +} + +function readHooksJson(path: string): HooksJson { + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + if (!isHooksJson(parsed)) throw new TypeError(`Invalid hooks metadata: ${path}`); + return parsed; +} + +function readMcpJson(path: string): McpJson { + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + if (!isMcpJson(parsed)) throw new TypeError(`Invalid MCP metadata: ${path}`); + return parsed; +} + +describe("plugin package metadata", () => { + it("#given packaged plugin files #when validating entrypoints #then hook command uses portable plugin root interpolation", () => { + // given + const packageJson = readPackageJson("package.json"); + const pluginJson = readPluginJson(".codex-plugin/plugin.json"); + const hooksJson = readHooksJson("hooks/hooks.json"); + const mcpJson = readMcpJson(".mcp.json"); + const cliSource = readFileSync("src/cli.ts", "utf8"); + + // when + const command = hooksJson.hooks["PostToolUse"]?.[0]?.hooks[0]?.command; + const lspServer = mcpJson.mcpServers["lsp"]; + const pluginRoot = ["$", "{PLUGIN_ROOT}"].join(""); + + // then + expect(pluginJson.version).toBe(packageJson.version); + expect(packageJson.type).toBe("module"); + expect(packageJson.packageManager).toBe("npm@11.12.1"); + expect(packageJson.dependencies).toEqual({ + "@code-yeongyu/lsp-tools-mcp": "file:./packages/lsp-tools-mcp", + }); + expect(packageJson.bin["codex-lsp"]).toBe("./dist/cli.js"); + expect(pluginJson.hooks).toBe("./hooks/hooks.json"); + expect(pluginJson.mcpServers).toBe("./.mcp.json"); + expect(cliSource.startsWith("#!/usr/bin/env node")).toBe(true); + expect(command).toBe(`node "${pluginRoot}/dist/cli.js" hook post-tool-use`); + expect(lspServer?.command).toBe("node"); + expect(lspServer?.args).toEqual(["./packages/lsp-tools-mcp/dist/cli.js", "mcp"]); + }); + + it("#given LSP skill guidance #when validating MCP tool instructions #then tool names are not framed as shell commands", () => { + // given + const skill = readFileSync("skills/lsp/SKILL.md", "utf8"); + + // when + const mentionsToolInterface = skill.includes("through the tool interface"); + const rejectsShellExecution = skill.includes("not shell commands"); + + // then + expect(mentionsToolInterface).toBe(true); + expect(rejectsShellExecution).toBe(true); + }); +}); + +function isPackageJson(value: unknown): value is PackageJson { + return ( + isRecord(value) && + typeof value["version"] === "string" && + value["type"] === "module" && + value["packageManager"] === "npm@11.12.1" && + isStringRecord(value["bin"]) && + isStringRecord(value["dependencies"]) + ); +} + +function isPluginJson(value: unknown): value is PluginJson { + return ( + isRecord(value) && + typeof value["version"] === "string" && + typeof value["hooks"] === "string" && + typeof value["mcpServers"] === "string" + ); +} + +function isHooksJson(value: unknown): value is HooksJson { + if (!isRecord(value) || !isRecord(value["hooks"])) return false; + return Object.values(value["hooks"]).every(isHookEntries); +} + +function isHookEntries(value: unknown): value is readonly HookEntry[] { + return Array.isArray(value) && value.every(isHookEntry); +} + +function isHookEntry(value: unknown): value is HookEntry { + return isRecord(value) && Array.isArray(value["hooks"]) && value["hooks"].every(isHookCommand); +} + +function isHookCommand(value: unknown): value is HookCommand { + return isRecord(value) && typeof value["command"] === "string"; +} + +function isMcpJson(value: unknown): value is McpJson { + if (!isRecord(value) || !isRecord(value["mcpServers"])) return false; + return Object.values(value["mcpServers"]).every(isMcpServer); +} + +function isMcpServer(value: unknown): value is McpServer { + return ( + isRecord(value) && + typeof value["command"] === "string" && + Array.isArray(value["args"]) && + value["args"].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/lsp/tsconfig.build.json b/packages/omo-codex/plugin/components/lsp/tsconfig.build.json new file mode 100644 index 000000000..5b5bbcafd --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/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/lsp/tsconfig.json b/packages/omo-codex/plugin/components/lsp/tsconfig.json new file mode 100644 index 000000000..342229c02 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/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/components/lsp/vitest.config.ts b/packages/omo-codex/plugin/components/lsp/vitest.config.ts new file mode 100644 index 000000000..57bd8f12b --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + environment: "node", + pool: "threads", + }, +}); diff --git a/packages/omo-codex/plugin/components/rules/.gitattributes b/packages/omo-codex/plugin/components/rules/.gitattributes new file mode 100644 index 000000000..9363fdb74 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/.gitattributes @@ -0,0 +1,13 @@ +# Normalize line endings: store LF in git, check out LF on every platform. +# Required so biome's --check passes on Windows (default core.autocrlf=true). +* text=auto eol=lf + +# Explicit binary types +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.zip binary +*.tgz binary +*.gz binary diff --git a/packages/omo-codex/plugin/components/rules/.github/CODEOWNERS b/packages/omo-codex/plugin/components/rules/.github/CODEOWNERS new file mode 100644 index 000000000..ef9dbe9d5 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/.github/CODEOWNERS @@ -0,0 +1,12 @@ +* @code-yeongyu + +.github/workflows/* @code-yeongyu +.github/dependabot.yml @code-yeongyu +package.json @code-yeongyu +package-lock.json @code-yeongyu +LICENSE @code-yeongyu +NOTICE @code-yeongyu +README.md @code-yeongyu +CHANGELOG.md @code-yeongyu +.codex-plugin/plugin.json @code-yeongyu +hooks/hooks.json @code-yeongyu diff --git a/packages/omo-codex/plugin/components/rules/.github/ISSUE_TEMPLATE/bug.yml b/packages/omo-codex/plugin/components/rules/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 000000000..60b58ecb8 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,49 @@ +name: Bug Report +description: Report broken Codex rule injection or matching behavior +labels: [bug] +body: + - type: markdown + attributes: + value: | + Include the Codex hook payload, hook output, rule file, and plugin version needed to reproduce. + + - type: textarea + id: what + attributes: + label: What happened? + description: Include exact output/errors. + validations: + required: true + + - type: textarea + id: payload + attributes: + label: Hook payload + description: Paste the minimal SessionStart, UserPromptSubmit, or PostToolUse payload that reproduces the issue. + render: json + validations: + required: false + + - type: textarea + id: rule + attributes: + label: Rule file + description: Paste the relevant rule file or frontmatter. + render: markdown + validations: + required: false + + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + + - type: input + id: version + attributes: + label: codex-rules version + placeholder: 0.1.0 + validations: + required: false diff --git a/packages/omo-codex/plugin/components/rules/.github/ISSUE_TEMPLATE/feature.yml b/packages/omo-codex/plugin/components/rules/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 000000000..3c3fc3ab2 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,27 @@ +name: Feature Request +description: Propose a Codex rule source, matcher, or hook improvement +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What workflow is blocked or awkward today? + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposal + description: What should codex-rules do? + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: What else could solve this? + validations: + required: false diff --git a/packages/omo-codex/plugin/components/rules/.github/branch-ruleset.json b/packages/omo-codex/plugin/components/rules/.github/branch-ruleset.json new file mode 100644 index 000000000..1f482bb66 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/.github/branch-ruleset.json @@ -0,0 +1,45 @@ +{ + "name": "main protection", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["~DEFAULT_BRANCH"], + "exclude": [] + } + }, + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { "type": "required_linear_history" }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": true, + "require_last_push_approval": false, + "required_review_thread_resolution": true + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": true, + "required_status_checks": [ + { "context": "test (ubuntu-latest · node 20)" }, + { "context": "test (ubuntu-latest · node 22)" }, + { "context": "test (macos-latest · node 20)" }, + { "context": "test (macos-latest · node 22)" } + ] + } + } + ], + "bypass_actors": [ + { + "actor_id": 5, + "actor_type": "RepositoryRole", + "bypass_mode": "always" + } + ] +} diff --git a/packages/omo-codex/plugin/components/rules/.github/dependabot.yml b/packages/omo-codex/plugin/components/rules/.github/dependabot.yml new file mode 100644 index 000000000..1941ade14 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + dev-dependencies: + dependency-type: development + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/packages/omo-codex/plugin/components/rules/.github/pull_request_template.md b/packages/omo-codex/plugin/components/rules/.github/pull_request_template.md new file mode 100644 index 000000000..4530c7f0a --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/.github/pull_request_template.md @@ -0,0 +1,20 @@ +## Summary + + + +- + +## Verification + +- [ ] `npm run check` (typecheck + biome + build) +- [ ] `npm test` (unit tests) +- [ ] `npm pack --dry-run` (release sanity) +- [ ] Hook smoke-tested locally with `node dist/cli.js hook session-start` +- [ ] Hook smoke-tested locally with `node dist/cli.js hook post-tool-use` + +## Codex plugin impact + +- [ ] `.codex-plugin/plugin.json` remains valid +- [ ] `hooks/hooks.json` still uses stable Codex hook JSON +- [ ] Session deduplication behavior is covered by tests +- [ ] CHANGELOG entry added for user-facing changes diff --git a/packages/omo-codex/plugin/components/rules/.github/workflows/ci.yml b/packages/omo-codex/plugin/components/rules/.github/workflows/ci.yml new file mode 100644 index 000000000..6cc7a1653 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + name: test (${{ matrix.os }} · node ${{ matrix.node }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + node: ["20", "22"] + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node ${{ matrix.node }} + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Check + run: npm run check + + - name: Unit tests + run: npm test + + - name: Package smoke + run: npm pack --dry-run diff --git a/packages/omo-codex/plugin/components/rules/.github/workflows/publish.yml b/packages/omo-codex/plugin/components/rules/.github/workflows/publish.yml new file mode 100644 index 000000000..4214a99b2 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/.github/workflows/publish.yml @@ -0,0 +1,51 @@ +name: publish + +on: + release: + types: [published] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node 22 + uses: actions/setup-node@v6 + with: + node-version: "22" + registry-url: https://registry.npmjs.org + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Check + run: npm run check + + - name: Unit tests + run: npm test + + - name: Package smoke + run: npm pack --dry-run + + - name: Publish to npm + run: | + if [ -z "$NODE_AUTH_TOKEN" ]; then + echo "NODE_AUTH_TOKEN is not configured; skipping npm publish." + exit 0 + fi + npm publish --access public --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} diff --git a/packages/omo-codex/plugin/components/rules/.gitignore b/packages/omo-codex/plugin/components/rules/.gitignore new file mode 100644 index 000000000..bc848922c --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.DS_Store +*.log +coverage/ +.vitest/ +*.tgz diff --git a/packages/omo-codex/plugin/components/rules/AGENTS.md b/packages/omo-codex/plugin/components/rules/AGENTS.md new file mode 100644 index 000000000..83d16c844 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/AGENTS.md @@ -0,0 +1,34 @@ +# Repository Conventions + +Conventions for human contributors and AI agents working on this repository. + +## Style + +- Terse technical prose. No emojis in commits, issues, PR comments, or code. +- TypeScript strict mode. No `any`, no `@ts-ignore`, no `@ts-expect-error`, no enums. +- ESM modules with `.js` suffix in runtime import paths. +- Tabs for indentation. Double quotes for strings. +- Tests use vitest with `#given .. #when .. #then` descriptions or plain `// given / // when / // then` body comments. + +## Commands + +- `npm install` - install dependencies. +- `npm test` - run vitest once. +- `npm run typecheck` - strict TypeScript check. +- `npm run check` - type check, biome, and build. +- `npm pack --dry-run` - release package smoke test. +- `node dist/cli.js hook session-start < fixture.json` - smoke-test static rule injection. +- `node dist/cli.js hook post-tool-use < fixture.json` - smoke-test dynamic rule injection. + +## Constraints + +- No Bun APIs. Runtime is Node only because Codex launches plugin hooks with Node. +- Keep `SessionStart`, `UserPromptSubmit`, and `PostToolUse` hook behavior covered by tests. +- Keep Codex file path extraction for reads, edits, `apply_patch`, and shell-style tools covered by tests. +- Hook output must use the stable Codex hook JSON contract. +- Do not couple this package back to pi, omo, or senpi internal source paths. + +## Don'ts + +- No `git add -A` or `git add .`. Stage only the files you changed. +- No `git commit --no-verify`. No force pushes. No history rewriting on shared branches. diff --git a/packages/omo-codex/plugin/components/rules/CHANGELOG.md b/packages/omo-codex/plugin/components/rules/CHANGELOG.md new file mode 100644 index 000000000..ae8f598c3 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +## Unreleased + +- Restrict the default `PostToolUse` hook matcher to Codex's canonical `apply_patch` tool name. +- Add opt-in `NODE_DEBUG=codex-rules` phase timing logs for `PostToolUse` debugging. +- Harden dynamic hook coverage for additional-context JSON output, disabled/static modes, failed tool responses, and duplicate suppression. +- Remove redundant apply_patch path scanning and stale tracked-tool constants. +- Use portable Codex hook interpolation and add package smoke coverage for hook entrypoints. +- Cap recursive rule directory scans and run CI on Windows in addition to Ubuntu and macOS. +- Replace the external glob matcher dependency with an internal matcher so clean Codex plugin installs run without `node_modules`. + +## 0.1.0 - 2026-05-15 + +- Port `pi-rules` rule loading, matching, formatting, truncation, and deduplication to a Codex plugin. +- Add `SessionStart`, `UserPromptSubmit`, and `PostToolUse` hooks for static and file-specific context injection. +- Add persistent per-session deduplication under Codex plugin data. +- Add Codex-aware path extraction for read, write, edit, multi-edit, `apply_patch`, and shell command payloads. +- Add tests, CI, release workflow, marketplace metadata, and local install support. diff --git a/packages/omo-codex/plugin/components/rules/LICENSE b/packages/omo-codex/plugin/components/rules/LICENSE new file mode 100644 index 000000000..09aac3c3b --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Yeongyu Kim + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/omo-codex/plugin/components/rules/NOTICE b/packages/omo-codex/plugin/components/rules/NOTICE new file mode 100644 index 000000000..f86989913 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/NOTICE @@ -0,0 +1,15 @@ +codex-rules + +This package implements rule/context loading for Codex plugins. +Its behavior is ported from pi-rules in the pi coding-agent extension ecosystem +and inspired by oh-my-openagent (omo) at https://github.com/code-yeongyu/oh-my-openagent, +including omo's `.omo/rules/` workflow and rules-injector hook architecture. +omo is originally licensed under the Sustainable Use License 1.0. + +Yeongyu Kim (https://github.com/code-yeongyu), author of omo, pi-rules, and this +package, licenses the source distributed in this repository under the MIT License. +If any source was ported from omo or pi-rules, that ported source is re-licensed +here under MIT for distribution as a Codex plugin. See LICENSE for terms. + +picomatch is by Jon Schlinkert and contributors (https://github.com/micromatch/picomatch). +Distributed under the MIT License. diff --git a/packages/omo-codex/plugin/components/rules/README.md b/packages/omo-codex/plugin/components/rules/README.md new file mode 100644 index 000000000..eb85634d8 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/README.md @@ -0,0 +1,127 @@ +# codex-rules + +Codex plugin that injects local project rule files into model context through lifecycle hooks. + +It ports the `pi-rules` rule injector to Codex: + +- `SessionStart` and `UserPromptSubmit` load static project instructions once per session. +- `PostToolUse` watches Codex `apply_patch` by default, then injects matching file-specific rules as additional context. +- `PostCompact` clears the per-session injection cache after manual or automatic compaction so relevant rules can be reintroduced into the compacted conversation. +- Session-level deduplication prevents the same rule from being repeated after it has been injected. + +`PostToolUse` output is context-only: it emits `hookSpecificOutput.additionalContext` and does not rewrite tool output. + +The runtime has no npm production dependencies, so a clean Codex marketplace copy can run without a follow-up `npm install`. + +## Rule Sources + +Project-level sources: + +- `AGENTS.md` +- `CLAUDE.md` +- `CONTEXT.md` +- `.omo/rules/**/*.md` +- `.claude/rules/**/*.md` +- `.cursor/rules/**/*.md` +- `.github/instructions/**/*.md` +- `.github/copilot-instructions.md` + +User-home sources are also supported by the ported engine when available. + +Markdown rule files may use frontmatter such as: + +```md +--- +description: TypeScript defaults +globs: ["**/*.ts", "**/*.tsx"] +alwaysApply: false +--- + +Prefer strict TypeScript and keep runtime imports ESM-compatible. +``` + +## Install Locally + +From the marketplace workspace: + +```bash +codex plugin marketplace add /Users/yeongyu/local-workspaces/codex-plugins +node /Users/yeongyu/local-workspaces/codex-plugins/scripts/install-local.mjs /Users/yeongyu/local-workspaces/codex-plugins +``` + +The local installer builds the plugin and copies a clean cache entry to: + +```text +~/.codex/plugins/cache/code-yeongyu-codex-plugins/omo/0.1.0 +``` + +It also enables: + +```toml +[features] +plugins = true +plugin_hooks = true + +[plugins."omo@code-yeongyu-codex-plugins"] +enabled = true +``` + +## Configuration + +Use `CODEX_RULES_*` environment variables: + +| Variable | Values | Default | +| --- | --- | --- | +| `CODEX_RULES_DISABLED` | `1`, `true`, `yes`, `on` | unset | +| `CODEX_RULES_MODE` | `both`, `static`, `dynamic`, `off` | `both` | +| `CODEX_RULES_MAX_RULE_CHARS` | positive integer | `12000` | +| `CODEX_RULES_MAX_RESULT_CHARS` | positive integer | `40000` | +| `CODEX_RULES_ENABLED_SOURCES` | comma-separated source names | `auto` | + +For migration from `pi-rules`, equivalent `PI_RULES_*` variables are accepted as fallbacks. + +## Debugging + +Enable hook phase timing with `NODE_DEBUG=codex-rules`: + +```bash +NODE_DEBUG=codex-rules node dist/cli.js hook post-tool-use < fixture.json +``` + +Debug lines go to stderr and hook JSON stays on stdout. The log includes `PostToolUse` phases such as `extract`, `fingerprint`, `load`, `persist`, elapsed `ms`, target counts, pending counts, rule counts, and output bytes. It does not log rule bodies or tool response contents. + +The default `PostToolUse` hook matcher is intentionally strict: it matches only Codex's canonical `apply_patch` hook tool name. Read tools, MCP filesystem tools, shell commands, and Claude-style `Write`/`Edit` aliases are not registered by default. + +## Development + +```bash +npm install +npm test +npm run check +npm run typecheck +npm pack --dry-run +``` + +Performance smoke test: + +```bash +npm run bench +``` + +Benchmark timings depend on the local machine. Use the relative counters and repeat-output checks when comparing runs. + +Hook smoke test: + +```bash +npm run build +printf '%s\n' '{"session_id":"s","transcript_path":null,"cwd":"/path/to/project","hook_event_name":"SessionStart","model":"gpt-5.5","permission_mode":"default","source":"startup"}' \ + | PLUGIN_DATA=/tmp/codex-rules-data node dist/cli.js hook session-start +``` + +## Privacy + +`codex-rules` runs locally. It reads local rule files and Codex hook payloads, writes per-session deduplication state under the Codex plugin data directory, and does not make network requests. + +## License + +MIT. See [LICENSE](LICENSE) and [NOTICE](NOTICE). diff --git a/packages/omo-codex/plugin/components/rules/biome.json b/packages/omo-codex/plugin/components/rules/biome.json new file mode 100644 index 000000000..5aa1a0dcc --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/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/rules/hooks/hooks.json b/packages/omo-codex/plugin/components/rules/hooks/hooks.json new file mode 100644 index 000000000..a6e2f1ab8 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/hooks/hooks.json @@ -0,0 +1,54 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook session-start", + "timeout": 10, + "statusMessage": "loading project rules" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook user-prompt-submit", + "timeout": 10, + "statusMessage": "loading project rules" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "^apply_patch$", + "hooks": [ + { + "type": "command", + "command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook post-tool-use", + "timeout": 10, + "statusMessage": "matching project rules" + } + ] + } + ], + "PostCompact": [ + { + "matcher": "manual|auto", + "hooks": [ + { + "type": "command", + "command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook post-compact", + "timeout": 10, + "statusMessage": "resetting project rule cache" + } + ] + } + ] + } +} diff --git a/packages/omo-codex/plugin/components/rules/package.json b/packages/omo-codex/plugin/components/rules/package.json new file mode 100644 index 000000000..ff8cb0b47 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/package.json @@ -0,0 +1,61 @@ +{ + "name": "@code-yeongyu/codex-rules", + "version": "0.1.0", + "description": "Codex plugin that injects project rule files into model context through lifecycle hooks.", + "type": "module", + "packageManager": "npm@11.12.1", + "license": "MIT", + "homepage": "https://github.com/code-yeongyu/codex-rules", + "repository": { + "type": "git", + "url": "git+https://github.com/code-yeongyu/codex-rules.git" + }, + "bugs": { + "url": "https://github.com/code-yeongyu/codex-rules/issues" + }, + "keywords": [ + "codex", + "codex-plugin", + "rules", + "hooks", + "agents-md", + "context-injection", + "typescript" + ], + "bin": { + "codex-rules": "./dist/cli.js" + }, + "files": [ + "dist", + "hooks", + "skills", + ".codex-plugin", + "LICENSE", + "NOTICE", + "README.md", + "CHANGELOG.md" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "test": "vitest --run", + "test:watch": "vitest", + "bench": "npm run build --silent && node scripts/bench-codex-rules.mjs", + "typecheck": "tsc --noEmit", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "check": "tsc --noEmit && biome check . && npm run build" + }, + "dependencies": { + "picomatch": "^4.0.3" + }, + "devDependencies": { + "@biomejs/biome": "2.4.15", + "@types/node": "^25.7.0", + "@types/picomatch": "^4.0.0", + "typescript": "^6.0.3", + "vitest": "^4.1.5" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/packages/omo-codex/plugin/components/rules/scripts/bench-codex-rules.mjs b/packages/omo-codex/plugin/components/rules/scripts/bench-codex-rules.mjs new file mode 100644 index 000000000..13a5b53eb --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/scripts/bench-codex-rules.mjs @@ -0,0 +1,268 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runPostToolUseHook } from "../dist/codex-hook.js"; +import { createEngine, defaultConfig } from "../dist/rules/engine.js"; + +const ITERATIONS = 40; +const WARMUP_ITERATIONS = 5; +const RULE_COUNT = 120; +const DISTINCT_TARGET_COUNT = 80; +const DUPLICATE_TARGET_COUNT = 240; + +const args = process.argv.slice(2); +const writeBaselinePath = readOption("--write-baseline"); +const comparePath = readOption("--compare"); + +const result = await runBenchmark(); + +if (writeBaselinePath !== undefined) { + writeFileSync(writeBaselinePath, `${JSON.stringify(result, null, "\t")}\n`); +} + +if (comparePath !== undefined) { + const baseline = JSON.parse(readFileSync(comparePath, "utf8")); + const failures = compareResults(baseline, result); + if (failures.length > 0) { + for (const failure of failures) { + process.stderr.write(`${failure}\n`); + } + process.exitCode = 1; + } +} + +process.stdout.write(`${JSON.stringify(result, null, "\t")}\n`); + +function readOption(name) { + const index = args.indexOf(name); + if (index === -1) { + return undefined; + } + + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) { + throw new Error(`${name} requires a value`); + } + return value; +} + +async function runBenchmark() { + const scenarios = [ + runScenario("duplicate-targets", duplicateTargets, DUPLICATE_TARGET_COUNT), + runScenario("distinct-targets", distinctTargets, DISTINCT_TARGET_COUNT), + ]; + return { + commit: gitCommit(), + iterations: ITERATIONS, + warmupIterations: WARMUP_ITERATIONS, + ruleCount: RULE_COUNT, + scenarios, + hookFastPath: await runHookFastPathScenario(), + }; +} + +async function runHookFastPathScenario() { + const durations = []; + let repeatOutputBytes = 0; + + for (let iteration = 0; iteration < ITERATIONS + WARMUP_ITERATIONS; iteration += 1) { + const run = await measureHookFastPathRun(); + if (iteration >= WARMUP_ITERATIONS) { + durations.push(run.repeatDurationMs); + repeatOutputBytes += run.repeatOutputBytes; + } + } + + return { + name: "repeat-post-tool-use", + medianRepeatMs: median(durations), + minRepeatMs: Math.min(...durations), + maxRepeatMs: Math.max(...durations), + repeatOutputBytes, + }; +} + +async function measureHookFastPathRun() { + const projectRoot = mkdtempSync(join(tmpdir(), "codex-rules-hook-bench-")); + const pluginData = mkdtempSync(join(tmpdir(), "codex-rules-hook-data-")); + try { + mkdirSync(join(projectRoot, "src"), { recursive: true }); + mkdirSync(join(projectRoot, ".omo", "rules"), { recursive: true }); + writeFileSync(join(projectRoot, "package.json"), JSON.stringify({ name: "bench" })); + writeFileSync(join(projectRoot, "src", "app.ts"), "export const app = true;\n"); + for (let index = 0; index < RULE_COUNT; index += 1) { + writeFileSync(join(projectRoot, ".omo", "rules", `rule-${index}.md`), ruleContent(`rule-${index}`)); + } + + const input = { + session_id: "bench-session", + turn_id: "bench-turn", + transcript_path: null, + cwd: projectRoot, + hook_event_name: "PostToolUse", + model: "gpt-5.5", + permission_mode: "default", + tool_name: "mcp__filesystem__read_file", + tool_input: { path: join(projectRoot, "src", "app.ts") }, + tool_response: { text: "file contents" }, + tool_use_id: "bench-call", + }; + + await runPostToolUseHook(input, { + pluginDataRoot: pluginData, + env: { CODEX_RULES_ENABLED_SOURCES: ".omo/rules" }, + }); + const start = process.hrtime.bigint(); + const repeatOutput = await runPostToolUseHook(input, { + pluginDataRoot: pluginData, + env: { CODEX_RULES_ENABLED_SOURCES: ".omo/rules" }, + }); + return { + repeatDurationMs: Number(process.hrtime.bigint() - start) / 1_000_000, + repeatOutputBytes: Buffer.byteLength(repeatOutput), + }; + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + rmSync(pluginData, { recursive: true, force: true }); + } +} + +function runScenario(name, targetFactory, targetCount) { + const durations = []; + let counters = { findProjectRoot: 0, findCandidates: 0, readFile: 0 }; + + for (let iteration = 0; iteration < ITERATIONS + WARMUP_ITERATIONS; iteration += 1) { + const run = measureRun(targetFactory); + if (iteration >= WARMUP_ITERATIONS) { + durations.push(run.durationMs); + counters = addCounters(counters, run.counters); + } + } + + return { + name, + targetCount, + medianMs: median(durations), + minMs: Math.min(...durations), + maxMs: Math.max(...durations), + counters, + }; +} + +function measureRun(targetPaths) { + const projectRoot = mkdtempSync(join(tmpdir(), "codex-rules-bench-")); + try { + const candidates = makeCandidates(projectRoot); + mkdirSync(join(projectRoot, ".omo", "rules"), { recursive: true }); + for (const candidate of candidates) { + writeFileSync(candidate.path, ""); + } + const counters = { findProjectRoot: 0, findCandidates: 0, readFile: 0 }; + const engine = createEngine(defaultConfig(), { + findProjectRoot: () => { + counters.findProjectRoot += 1; + return projectRoot; + }, + findCandidates: () => { + counters.findCandidates += 1; + return candidates; + }, + readFile: (path) => { + counters.readFile += 1; + return ruleContent(path); + }, + }); + const generatedTargetPaths = targetPaths(projectRoot); + const start = process.hrtime.bigint(); + engine.loadDynamicRules(projectRoot, generatedTargetPaths); + const durationMs = Number(process.hrtime.bigint() - start) / 1_000_000; + return { durationMs, counters }; + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + } +} + +function duplicateTargets(projectRoot) { + const targetPath = join(projectRoot, "src", "app.ts"); + return Array.from({ length: DUPLICATE_TARGET_COUNT }, () => targetPath); +} + +function distinctTargets(projectRoot) { + return Array.from({ length: DISTINCT_TARGET_COUNT }, (_, index) => join(projectRoot, "src", `file-${index}.ts`)); +} + +function makeCandidates(projectRoot) { + return Array.from({ length: RULE_COUNT }, (_, index) => ({ + path: join(projectRoot, ".omo", "rules", `rule-${index}.md`), + realPath: join(projectRoot, ".omo", "rules", `rule-${index}.md`), + source: ".omo/rules", + distance: 0, + isGlobal: false, + isSingleFile: false, + relativePath: `.omo/rules/rule-${index}.md`, + })); +} + +function ruleContent(path) { + return ["---", "globs: **/*.ts", "---", "", `Rule from ${path}`].join("\n"); +} + +function addCounters(left, right) { + return { + findProjectRoot: left.findProjectRoot + right.findProjectRoot, + findCandidates: left.findCandidates + right.findCandidates, + readFile: left.readFile + right.readFile, + }; +} + +function median(values) { + const sorted = [...values].sort((left, right) => left - right); + const index = Math.floor(sorted.length / 2); + return sorted[index] ?? 0; +} + +function gitCommit() { + try { + return execFileSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).trim(); + } catch { + return "unknown"; + } +} + +function compareResults(baseline, current) { + const failures = []; + for (const scenario of current.scenarios) { + const baselineScenario = baseline.scenarios.find((candidate) => candidate.name === scenario.name); + if (baselineScenario === undefined) { + failures.push(`missing baseline scenario: ${scenario.name}`); + continue; + } + + for (const counterName of ["findProjectRoot", "findCandidates", "readFile"]) { + if (scenario.counters[counterName] > baselineScenario.counters[counterName]) { + failures.push( + `${scenario.name}.${counterName} regressed: ${scenario.counters[counterName]} > ${baselineScenario.counters[counterName]}`, + ); + } + } + } + if (baseline.hookFastPath === undefined) { + failures.push("missing baseline hookFastPath scenario"); + } else { + if (current.hookFastPath.repeatOutputBytes > baseline.hookFastPath.repeatOutputBytes) { + failures.push( + `hookFastPath.repeatOutputBytes regressed: ${current.hookFastPath.repeatOutputBytes} > ${baseline.hookFastPath.repeatOutputBytes}`, + ); + } + + const maxMedianRepeatMs = baseline.hookFastPath.medianRepeatMs * 1.5; + if (current.hookFastPath.medianRepeatMs > maxMedianRepeatMs) { + failures.push( + `hookFastPath.medianRepeatMs regressed: ${current.hookFastPath.medianRepeatMs} > ${maxMedianRepeatMs}`, + ); + } + } + return failures; +} diff --git a/packages/omo-codex/plugin/components/rules/skills/rules/SKILL.md b/packages/omo-codex/plugin/components/rules/skills/rules/SKILL.md new file mode 100644 index 000000000..3ac401302 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/skills/rules/SKILL.md @@ -0,0 +1,34 @@ +--- +name: rules +description: Use when the user asks about Codex Rules behavior, injected project rules, supported rule file locations, matching, or environment configuration. +--- + +# Codex Rules + +Codex Rules is automatic once the plugin is enabled. It injects: + +- static project instructions on `SessionStart` and `UserPromptSubmit` +- matching file-specific rules after Codex `apply_patch` by default + +Dynamic `PostToolUse` output is injected as additional context and is deduplicated per plugin data session. Codex Rules does not rewrite tool output. + +Supported project sources: + +- `AGENTS.md` +- `CLAUDE.md` +- `CONTEXT.md` +- `.sisyphus/rules/**/*.md` +- `.claude/rules/**/*.md` +- `.cursor/rules/**/*.md` +- `.github/instructions/**/*.md` +- `.github/copilot-instructions.md` + +Supported environment knobs: + +- `CODEX_RULES_DISABLED=1` +- `CODEX_RULES_MODE=both|static|dynamic|off` +- `CODEX_RULES_MAX_RULE_CHARS=` +- `CODEX_RULES_MAX_RESULT_CHARS=` +- `CODEX_RULES_ENABLED_SOURCES=AGENTS.md,.sisyphus/rules` + +The legacy `PI_RULES_*` variables are accepted as fallbacks for users migrating from `pi-rules`. diff --git a/packages/omo-codex/plugin/components/rules/src/cli.ts b/packages/omo-codex/plugin/components/rules/src/cli.ts new file mode 100644 index 000000000..64fa4ddf7 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/cli.ts @@ -0,0 +1,143 @@ +#!/usr/bin/env node +import { stdin as processStdin, stdout as processStdout } from "node:process"; + +import { + type CodexPostCompactInput, + type CodexPostToolUseInput, + type CodexRulesHookOptions, + type CodexSessionStartInput, + type CodexUserPromptSubmitInput, + runPostCompactHook, + runPostToolUseHook, + runSessionStartHook, + runUserPromptSubmitHook, +} from "./codex-hook.js"; + +const command = process.argv[2]; +const subcommand = process.argv[3]; +type HookCliEventName = "SessionStart" | "UserPromptSubmit" | "PostToolUse" | "PostCompact"; + +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: codex-rules hook [session-start|user-prompt-submit|post-tool-use|post-compact]\n"); + process.exitCode = 1; +} + +async function runHookCli(eventName: HookCliEventName): Promise { + 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: CodexRulesHookOptions = pluginDataRoot === undefined ? {} : { pluginDataRoot }; + const output = await runHook(eventName, parsed, options); + if (output.length > 0) { + processStdout.write(output); + } +} + +async function runHook(eventName: HookCliEventName, parsed: unknown, options: CodexRulesHookOptions): Promise { + 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: string): unknown | undefined { + try { + const parsed: unknown = JSON.parse(raw); + return parsed; + } catch { + return undefined; + } +} + +function isCodexSessionStartInput(value: unknown): value is CodexSessionStartInput { + 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: unknown): value is CodexUserPromptSubmitInput { + 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: unknown): value is CodexPostToolUseInput { + 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: unknown): value is CodexPostCompactInput { + 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: unknown): value is string | null { + return typeof value === "string" || value === null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readStdin(): Promise { + return new Promise((resolve, reject) => { + let data = ""; + processStdin.setEncoding("utf8"); + processStdin.on("data", (chunk: string) => { + data += chunk; + }); + processStdin.once("error", reject); + processStdin.once("end", () => { + resolve(data); + }); + }); +} diff --git a/packages/omo-codex/plugin/components/rules/src/codex-hook.ts b/packages/omo-codex/plugin/components/rules/src/codex-hook.ts new file mode 100644 index 000000000..8a29d2455 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/codex-hook.ts @@ -0,0 +1,475 @@ +import { readFileSync, statSync } from "node:fs"; +import { isAbsolute, relative, resolve } from "node:path"; + +import { configFromEnvironment } from "./config.js"; +import { createHookDebugTimer } from "./debug-log.js"; +import { + clearSessionState, + hasPostCompactPending, + hydrateEngineState, + isPostCompactPending, + markSessionCompacted, + persistEngineState, + sessionCachePath, +} from "./persistent-cache.js"; +import { SOURCE_PRIORITY } from "./rules/constants.js"; +import { createEngine } from "./rules/engine.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 type { LoadedRule, PiRulesConfig, RuleCandidate } from "./rules/types.js"; +import { extractCodexToolPaths } from "./tool-paths.js"; + +type ContextInjectionHookEventName = "SessionStart" | "UserPromptSubmit" | "PostToolUse"; + +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 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 interface CodexRulesHookOptions { + env?: NodeJS.ProcessEnv; + pluginDataRoot?: string; +} + +interface DynamicTargetFingerprint { + targetPath: string; + cacheKey: string; + fingerprint: string; +} + +export async function runSessionStartHook( + input: CodexSessionStartInput, + options: CodexRulesHookOptions = {}, +): Promise { + const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot); + if (input.source === "clear") { + clearSessionState(cachePath); + } else if (input.source !== "resume" && !hasPostCompactPending(cachePath)) { + clearSessionState(cachePath); + } + const postCompactPending = input.source !== "clear" && isPostCompactPending(cachePath, "static"); + const transcriptPath = input.source === "clear" || postCompactPending ? null : input.transcript_path; + return runStaticInjection( + input.cwd, + transcriptPath, + "SessionStart", + cachePath, + options, + postCompactPending ? "static" : undefined, + ); +} + +export async function runPostCompactHook( + input: CodexPostCompactInput, + options: CodexRulesHookOptions = {}, +): Promise { + markSessionCompacted(sessionCachePath(input.session_id, options.pluginDataRoot)); + return ""; +} + +export async function runUserPromptSubmitHook( + input: CodexUserPromptSubmitInput, + options: CodexRulesHookOptions = {}, +): Promise { + const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot); + const postCompactPending = isPostCompactPending(cachePath, "static"); + const transcriptPath = postCompactPending ? null : input.transcript_path; + return runStaticInjection( + input.cwd, + transcriptPath, + "UserPromptSubmit", + cachePath, + options, + postCompactPending ? "static" : undefined, + ); +} + +export async function runPostToolUseHook( + input: CodexPostToolUseInput, + options: CodexRulesHookOptions = {}, +): Promise { + 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 postCompactPending = isPostCompactPending(cachePath, "dynamic"); + const transcriptPath = postCompactPending ? null : input.transcript_path; + const engine = createRulesEngine(options); + 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, postCompactPending ? "dynamic" : undefined); + 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)), + transcriptPath, + (rule) => { + engine.markDynamicInjected(rule); + }, + ); + 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, postCompactPending ? "dynamic" : undefined); + 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, postCompactPending ? "dynamic" : undefined); + debugTimer.lap("persist", { reason: "emit" }); + const output = formatAdditionalContextOutput("PostToolUse", block); + debugTimer.done({ outputBytes: Buffer.byteLength(output), reason: "emit" }); + return output; +} + +function runStaticInjection( + cwd: string, + transcriptPath: string | null, + eventName: "SessionStart" | "UserPromptSubmit", + cachePath: string, + options: CodexRulesHookOptions, + completedPostCompactChannel?: "static", +): string { + const config = configFromEnvironment(options.env); + if (config.disabled || config.mode === "off" || config.mode === "dynamic") { + return ""; + } + + const engine = createRulesEngine(options); + 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); + }, + ); + if (rules.length === 0) { + persistEngineState(engine, cachePath, completedPostCompactChannel); + return ""; + } + + const block = engine.formatStatic(rules); + for (const rule of rules) { + engine.markStaticInjected(rule); + } + persistEngineState(engine, cachePath, completedPostCompactChannel); + return formatAdditionalContextOutput(eventName, block); +} + +function filterRulesAlreadyInTranscript( + rules: ReadonlyArray, + transcriptPath: string | null, + markInjected: (rule: LoadedRule) => void, +): LoadedRule[] { + if (rules.length === 0 || transcriptPath === null) { + return [...rules]; + } + + const transcriptText = readTranscriptSearchText(transcriptPath); + if (transcriptText === null) { + return [...rules]; + } + + const pendingRules: LoadedRule[] = []; + for (const rule of rules) { + if (isRuleAlreadyInTranscript(rule, transcriptText)) { + markInjected(rule); + continue; + } + + pendingRules.push(rule); + } + return pendingRules; +} + +function isRuleAlreadyInTranscript(rule: LoadedRule, transcriptText: string): boolean { + 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 is string => marker !== null); + return markers.some((marker) => transcriptText.includes(marker)); +} + +function readTranscriptSearchText(transcriptPath: string): string | null { + try { + const rawTranscript = readFileSync(transcriptPath, "utf8"); + return [rawTranscript, ...collectJsonLineStrings(rawTranscript)].join("\n"); + } catch { + return null; + } +} + +function collectJsonLineStrings(rawTranscript: string): string[] { + const values: string[] = []; + for (const line of rawTranscript.split(/\r?\n/)) { + if (line.trim().length === 0) { + continue; + } + + try { + const parsed: unknown = JSON.parse(line); + collectStrings(parsed, values); + } catch { + // Non-JSON transcript lines are still covered by the raw transcript text. + } + } + return values; +} + +function collectStrings(value: unknown, output: string[]): void { + if (typeof value === "string") { + output.push(value); + return; + } + + if (Array.isArray(value)) { + for (const item of value) { + collectStrings(item, output); + } + return; + } + + if (typeof value !== "object" || value === null) { + return; + } + + for (const item of Object.values(value)) { + collectStrings(item, output); + } +} + +function createRulesEngine(options: CodexRulesHookOptions) { + const config = configFromEnvironment(options.env); + return createEngine(config, { + findCandidates: findRuleCandidates, + findProjectRoot, + readFile: (path) => { + try { + return readFileSync(path, "utf8"); + } catch { + return null; + } + }, + }); +} + +function fingerprintDynamicTargets( + cwd: string, + targetPaths: ReadonlyArray, + config: PiRulesConfig, +): DynamicTargetFingerprint[] { + const disabledSources = disabledSourcesFor(config); + const discoveryCache = createRuleDiscoveryCache(); + const cwdProjectRoot = findProjectRoot(cwd); + const fingerprints: DynamicTargetFingerprint[] = []; + + for (const targetPath of uniqueStrings(targetPaths)) { + const projectRoot = + cwdProjectRoot !== null && isSameOrChildPath(targetPath, cwdProjectRoot) + ? cwdProjectRoot + : findProjectRoot(targetPath); + const findOptions: { + projectRoot: string | null; + targetFile: string; + disabledSources?: ReadonlySet; + cache: ReturnType; + } = { + 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: RuleCandidate): string { + 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: string): string { + try { + const stats = statSync(filePath, { bigint: true }); + return `${stats.mtimeNs}:${stats.ctimeNs}:${stats.size}`; + } catch { + return "missing"; + } +} + +function disabledSourcesFor(config: PiRulesConfig): ReadonlySet | undefined { + if (config.enabledSources === "auto") { + return undefined; + } + + const enabledSources = new Set(config.enabledSources); + return new Set([...SOURCE_PRIORITY.keys()].filter((source) => !enabledSources.has(source))); +} + +function dynamicTargetCacheKey(targetPath: string): string { + return toPosixPath(resolve(targetPath)); +} + +function isSameOrChildPath(childPath: string, parentPath: string): boolean { + const childRelativePath = relative(parentPath, resolve(childPath)); + return childRelativePath === "" || (!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath)); +} + +function uniqueStrings(values: ReadonlyArray): string[] { + const uniqueValues: string[] = []; + const seenValues = new Set(); + for (const value of values) { + if (seenValues.has(value)) { + continue; + } + + seenValues.add(value); + uniqueValues.push(value); + } + return uniqueValues; +} + +function formatAdditionalContextOutput(eventName: ContextInjectionHookEventName, additionalContext: string): string { + if (additionalContext.trim().length === 0) return ""; + return `${JSON.stringify({ + hookSpecificOutput: { + hookEventName: eventName, + additionalContext, + }, + })}\n`; +} + +function displayPath(cwd: string, filePath: string): string { + const rel = isAbsolute(filePath) ? relative(cwd, filePath) : filePath; + // Normalize to POSIX separators so injected rule context renders the same + // path string on Linux/macOS and Windows (Codex feeds this verbatim into + // the model prompt, and the existing engine already emits POSIX paths). + return toPosixPath(rel); +} + +function toPosixPath(path: string): string { + return path.replaceAll("\\", "/"); +} diff --git a/packages/omo-codex/plugin/components/rules/src/config.ts b/packages/omo-codex/plugin/components/rules/src/config.ts new file mode 100644 index 000000000..1ca7a6c77 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/config.ts @@ -0,0 +1,65 @@ +import { SOURCE_PRIORITY } from "./rules/constants.js"; +import { defaultConfig } from "./rules/engine.js"; +import type { PiRulesConfig, RuleSource } from "./rules/types.js"; + +const MODE_VALUES = new Set(["static", "dynamic", "both", "off"]); + +export function configFromEnvironment(env: NodeJS.ProcessEnv = process.env): PiRulesConfig { + const config = defaultConfig(); + 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.enabledSources = parseEnabledSources( + firstEnv(env, "CODEX_RULES_ENABLED_SOURCES", "PI_RULES_ENABLED_SOURCES"), + ); + return config; +} + +function firstEnv(env: NodeJS.ProcessEnv, ...names: string[]): string | undefined { + for (const name of names) { + const value = env[name]; + if (typeof value === "string" && value.trim().length > 0) { + return value; + } + } + return undefined; +} + +function isTruthy(value: string | undefined): boolean { + if (value === undefined) return false; + return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase()); +} + +function parseMode(value: string | undefined): PiRulesConfig["mode"] | undefined { + if (value === undefined) return undefined; + const normalized = value.trim().toLowerCase(); + return MODE_VALUES.has(normalized as PiRulesConfig["mode"]) ? (normalized as PiRulesConfig["mode"]) : undefined; +} + +function parsePositiveInteger(value: string | undefined): number | undefined { + if (value === undefined) return undefined; + const parsed = Number.parseInt(value.trim(), 10); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; +} + +function parseEnabledSources(value: string | undefined): RuleSource[] | "auto" { + if (value === undefined || value.trim().toLowerCase() === "auto") { + return "auto"; + } + + const validSources = new Set(SOURCE_PRIORITY.keys()); + const sources: RuleSource[] = []; + for (const rawSource of value.split(",")) { + const source = rawSource.trim(); + if (!validSources.has(source as RuleSource)) { + continue; + } + sources.push(source as RuleSource); + } + return sources.length > 0 ? sources : "auto"; +} diff --git a/packages/omo-codex/plugin/components/rules/src/debug-log.ts b/packages/omo-codex/plugin/components/rules/src/debug-log.ts new file mode 100644 index 000000000..cab97047b --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/debug-log.ts @@ -0,0 +1,65 @@ +import { performance } from "node:perf_hooks"; +import { debuglog } from "node:util"; + +type DebugFieldValue = boolean | number | string | null; + +type DebugFields = Record; + +const debug = debuglog("codex-rules"); +const noopTimer: HookDebugTimer = { + lap: () => {}, + done: () => {}, +}; + +export interface HookDebugTimer { + lap(phase: string, fields?: DebugFields): void; + done(fields?: DebugFields): void; +} + +export function createHookDebugTimer(hookName: string): HookDebugTimer { + 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: string, + phase: string, + durationMs: number, + totalMs: number, + fields: DebugFields, +): void { + debug( + "%s phase=%s ms=%s total_ms=%s%s", + hookName, + phase, + durationMs.toFixed(3), + totalMs.toFixed(3), + formatFields(fields), + ); +} + +function formatFields(fields: DebugFields): string { + const entries = Object.entries(fields); + if (entries.length === 0) { + return ""; + } + + return ` ${entries.map(([key, value]) => `${key}=${String(value)}`).join(" ")}`; +} diff --git a/packages/omo-codex/plugin/components/rules/src/persistent-cache.ts b/packages/omo-codex/plugin/components/rules/src/persistent-cache.ts new file mode 100644 index 000000000..d95caeed0 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/persistent-cache.ts @@ -0,0 +1,167 @@ +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +import type { Engine } from "./rules/engine.js"; + +export type PostCompactPendingKind = "static" | "dynamic"; + +interface PostCompactPendingState { + static?: boolean; + dynamic?: boolean; +} + +interface SerializedSessionState { + staticDedup: string[]; + dynamicDedup: Record; + dynamicTargetFingerprints?: Record; + postCompactPending?: PostCompactPendingState; + compacted?: boolean; +} + +export function hydrateEngineState(engine: Engine, cachePath: string): void { + 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: Engine, + cachePath: string, + completedPostCompactKind?: PostCompactPendingKind, +): void { + const currentState = readSessionState(cachePath); + const dynamicDedup: Record = {}; + for (const [scope, keys] of engine.state.dynamicDedup.entries()) { + dynamicDedup[scope] = [...keys]; + } + + const postCompactPending = nextPostCompactPending(currentState, completedPostCompactKind); + writeSessionState(cachePath, { + staticDedup: [...engine.state.staticDedup], + dynamicDedup, + dynamicTargetFingerprints: Object.fromEntries(engine.state.dynamicTargetFingerprints.entries()), + ...(postCompactPending === undefined ? {} : { postCompactPending }), + }); +} + +export function clearSessionState(cachePath: string): void { + rmSync(cachePath, { force: true }); +} + +export function markSessionCompacted(cachePath: string): void { + writeSessionState(cachePath, { ...emptyState(), postCompactPending: { static: true, dynamic: true } }); +} + +export function hasPostCompactPending(cachePath: string): boolean { + return postCompactPendingKinds(readSessionState(cachePath)).size > 0; +} + +export function isPostCompactPending(cachePath: string, kind: PostCompactPendingKind): boolean { + return postCompactPendingKinds(readSessionState(cachePath)).has(kind); +} + +export function sessionCachePath(sessionId: string, pluginDataRoot: string | undefined): string { + const root = pluginDataRoot ?? process.env["PLUGIN_DATA"] ?? join(homedir(), ".codex", "codex-rules"); + return join(root, "sessions", `${safePathSegment(sessionId)}.json`); +} + +function readSessionState(cachePath: string): SerializedSessionState { + try { + const parsed = JSON.parse(readFileSync(cachePath, "utf8")); + if (!isSerializedSessionState(parsed)) return emptyState(); + return parsed; + } catch { + return emptyState(); + } +} + +function writeSessionState(cachePath: string, state: SerializedSessionState): void { + mkdirSync(dirname(cachePath), { recursive: true }); + writeFileSync(cachePath, `${JSON.stringify(state)}\n`); +} + +function emptyState(): SerializedSessionState { + return { staticDedup: [], dynamicDedup: {}, dynamicTargetFingerprints: {} }; +} + +function nextPostCompactPending( + state: SerializedSessionState, + completedKind: PostCompactPendingKind | undefined, +): PostCompactPendingState | undefined { + 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 postCompactPendingKinds(state: SerializedSessionState): Set { + 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; +} + +function safePathSegment(value: string): string { + return value.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120) || "unknown-session"; +} + +function isSerializedSessionState(value: unknown): value is SerializedSessionState { + 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 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)) && + (compacted === undefined || typeof compacted === "boolean") + ); +} + +function isPostCompactPendingState(value: unknown): value is PostCompactPendingState { + return ( + isRecord(value) && + (value["static"] === undefined || typeof value["static"] === "boolean") && + (value["dynamic"] === undefined || typeof value["dynamic"] === "boolean") + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/omo-codex/plugin/components/rules/src/rules/cache.ts b/packages/omo-codex/plugin/components/rules/src/rules/cache.ts new file mode 100644 index 000000000..2433d4543 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/cache.ts @@ -0,0 +1,64 @@ +import type { LoadedRule, SessionState } from "./types.js"; + +const DYNAMIC_SESSION_KEY = "__pi-rules-session__"; + +export function createSessionState(cwd?: string): SessionState { + return { + cwd, + staticDedup: new Set(), + dynamicDedup: new Map(), + dynamicTargetFingerprints: new Map(), + loadedRules: [], + diagnostics: [], + }; +} + +export function staticDedupKey(cwd: string, rulePath: string, contentHash: string): string { + return `${cwd}::${rulePath}::${contentHash}`; +} + +export function dynamicDedupKey(rulePath: string, contentHash: string): string { + return `${rulePath}::${contentHash}`; +} + +export function markStaticInjected(state: SessionState, rule: LoadedRule): boolean { + 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: SessionState, rule: LoadedRule): boolean { + 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: SessionState, rule: LoadedRule): boolean { + return state.staticDedup.has(staticDedupKey(state.cwd ?? "", rule.realPath, rule.contentHash)); +} + +export function isDynamicInjected(state: SessionState, rule: LoadedRule): boolean { + return state.dynamicDedup.get(DYNAMIC_SESSION_KEY)?.has(dynamicDedupKey(rule.realPath, rule.contentHash)) === true; +} + +export function clearSession(state: SessionState): void { + state.staticDedup.clear(); + state.dynamicDedup.clear(); + state.dynamicTargetFingerprints.clear(); + state.loadedRules.length = 0; + state.diagnostics.length = 0; +} diff --git a/packages/omo-codex/plugin/components/rules/src/rules/constants.ts b/packages/omo-codex/plugin/components/rules/src/rules/constants.ts new file mode 100644 index 000000000..d8eb3f047 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/constants.ts @@ -0,0 +1,105 @@ +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 const PROJECT_MARKERS: readonly string[] = [ + ".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: ReadonlyArray = [ + [".omo", "rules"], + [".claude", "rules"], + [".cursor", "rules"], + [".github", "instructions"], +]; + +/** + * Single-file project rules (always apply, frontmatter optional). + */ +export const PROJECT_SINGLE_FILES: readonly string[] = [ + ".github/copilot-instructions.md", + "AGENTS.md", + "CLAUDE.md", + "CONTEXT.md", +]; + +/** + * User-home rule directories. + */ +export const USER_HOME_RULE_SUBDIRS: readonly string[] = [".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: readonly string[] = [".config/opencode/AGENTS.md", ".claude/CLAUDE.md"]; + +/** + * File extensions accepted as rule files in scanned directories. + */ +export const RULE_FILE_EXTENSIONS: readonly string[] = [".md", ".mdc"]; + +/** + * Per-rule source priority for deterministic ordering. Lower = earlier. + */ +export const SOURCE_PRIORITY: ReadonlyMap = new Map([ + [".omo/rules", 0], + [".claude/rules", 1], + [".cursor/rules", 2], + [".github/instructions", 3], + [".github/copilot-instructions.md", 4], + ["AGENTS.md", 5], + ["CLAUDE.md", 6], + ["CONTEXT.md", 7], + ["~/.omo/rules", 100], + ["~/.opencode/rules", 101], + ["~/.claude/rules", 102], + ["~/.config/opencode/AGENTS.md", 103], + ["~/.claude/CLAUDE.md", 104], +]); + +/** + * 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; + +/** + * Truncation marker template. `{path}` is replaced with the relative path. + */ +export const TRUNCATION_NOTICE = "\n\n[Rule truncated. Read full rule: {path}]"; + +/** + * Directories excluded by the recursive scanner regardless of glob settings. + */ +export const SCANNER_EXCLUDED_DIRS: readonly string[] = [ + "node_modules", + ".git", + "dist", + "build", + ".turbo", + ".next", + "coverage", +]; diff --git a/packages/omo-codex/plugin/components/rules/src/rules/engine.ts b/packages/omo-codex/plugin/components/rules/src/rules/engine.ts new file mode 100644 index 000000000..84ad3471c --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/engine.ts @@ -0,0 +1,531 @@ +import { realpathSync } from "node:fs"; +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; + +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, + PROJECT_SINGLE_FILES, + SOURCE_PRIORITY, +} from "./constants.js"; +import { createRuleDiscoveryCache, type RuleDiscoveryCache } from "./finder.js"; +import { formatDynamicBlock, formatStaticBlock } from "./formatter.js"; +import { hashContent, matchRule } from "./matcher.js"; +import { sortCandidates } from "./ordering.js"; +import { parseRule } from "./parser.js"; +import type { LoadedRule, MatchReason, PiRulesConfig, RuleCandidate, RuleDiagnostic, SessionState } from "./types.js"; + +interface LoadedRuleContent { + frontmatter: LoadedRule["frontmatter"]; + body: string; + contentHash: string; + diagnostic?: string; +} + +type CandidateProjectMembership = Map; +type CandidateDiscoveryCache = Map; +type DynamicMatchCache = Map; + +const MAX_DYNAMIC_MATCH_CACHE_ENTRIES = 4096; + +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; +} + +const ROOT_SINGLE_FILE_SOURCES = new Set(PROJECT_SINGLE_FILES.filter((source) => !source.includes("/"))); + +export function defaultConfig(): PiRulesConfig { + return { + disabled: false, + mode: "both", + maxRuleChars: DEFAULT_MAX_RULE_CHARS, + maxResultChars: DEFAULT_MAX_RESULT_CHARS, + enabledSources: "auto", + }; +} + +export function createEngine(config: PiRulesConfig, deps: EngineDeps): Engine { + const state = createSessionState(); + const dynamicMatchCache: DynamicMatchCache = new Map(); + + function loadStaticRules(cwd: string): { rules: LoadedRule[]; diagnostics: RuleDiagnostic[] } { + state.cwd = cwd; + if (config.disabled || config.mode === "off" || config.mode === "dynamic") { + return emptyLoadResult(state); + } + + const projectRoot = deps.findProjectRoot(cwd); + const findOptions: Parameters[0] = { + projectRoot, + targetFile: null, + }; + const disabledSources = disabledSourcesFor(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: string, + targetPaths: ReadonlyArray, + ): { rules: LoadedRule[]; diagnostics: RuleDiagnostic[] } { + state.cwd = cwd; + if (config.disabled || config.mode === "off" || config.mode === "static" || targetPaths.length === 0) { + return emptyLoadResult(state); + } + + const rules: LoadedRule[] = []; + const diagnostics: RuleDiagnostic[] = []; + const seenRules = new Set(); + const loadedRuleContent = new Map(); + const projectMembership = new Map(); + const disabledSources = disabledSourcesFor(config); + const discoveryCache = createRuleDiscoveryCache(); + const candidateDiscoveryCache: 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: Parameters[0] = { + 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 }); + } + } + + const sortedRules = sortCandidates(rules); + storeLastLoad(state, sortedRules, diagnostics); + return { rules: sortedRules, diagnostics }; + } + + 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 matchDynamicRuleCached( + cache: DynamicMatchCache, + projectRoot: string | null, + targetFile: string, + candidate: RuleCandidate, + loadedRule: LoadedRule, + matchRuleImpl: typeof matchRule, +): MatchReason | null { + 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; +} + +function setDynamicMatchCacheEntry(cache: DynamicMatchCache, cacheKey: string, reason: MatchReason | null): void { + 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: string | null, + targetFile: string, + candidate: RuleCandidate, + contentHash: string, +): string { + 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 loadStaticCandidates(candidates: ReadonlyArray, deps: EngineDeps, projectRoot: string | null) { + const rules: LoadedRule[] = []; + const diagnostics: RuleDiagnostic[] = []; + 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 loadCandidate( + candidate: RuleCandidate, + deps: EngineDeps, + diagnostics: RuleDiagnostic[], + projectRoot: string | null, + loadedRuleContent?: Map, + projectMembership?: CandidateProjectMembership, +): (LoadedRule & { matchReason: MatchReason }) | null { + 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 }), + } satisfies LoadedRuleContent; + loadedRuleContent?.set(candidate.realPath, loadedContent); + return loadedRuleFromContent(candidate, loadedContent, diagnostics); +} + +function loadedRuleFromContent( + candidate: RuleCandidate, + content: LoadedRuleContent | null, + diagnostics: RuleDiagnostic[], +): (LoadedRule & { matchReason: MatchReason }) | null { + 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" }, + }; +} + +function ruleDedupKey(rule: LoadedRule): string { + return `${rule.realPath}::${rule.contentHash}`; +} + +function isCandidateWithinProject(candidate: RuleCandidate, projectRoot: string | null): boolean { + 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 isCandidateWithinProjectCached( + candidate: RuleCandidate, + projectRoot: string | null, + projectMembership: CandidateProjectMembership | undefined, +): boolean { + 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; +} + +function realPathOrResolved(path: string): string { + try { + return realpathSync.native(path); + } catch { + return resolve(path); + } +} + +function findSortedCandidatesCached( + cache: CandidateDiscoveryCache, + findCandidates: EngineDeps["findCandidates"], + options: Parameters[0], +): RuleCandidate[] { + 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 candidateDiscoveryCacheKey(options: Parameters[0]): string { + return [ + options.projectRoot ?? "", + options.targetFile === null ? "" : dirname(resolve(options.targetFile)), + ...[...(options.disabledSources ?? [])].sort(), + ].join("\0"); +} + +function isSameOrChildPath(childPath: string, parentPath: string): boolean { + const childRelativePath = relative(parentPath, resolve(childPath)); + return childRelativePath === "" || (!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath)); +} + +function staticMatchReason(rule: LoadedRule): MatchReason | null { + if (rule.frontmatter.alwaysApply === true) { + return "alwaysApply"; + } + + if (rule.isSingleFile) { + return "single-file"; + } + + return null; +} + +function disabledSourcesFor(config: PiRulesConfig): ReadonlySet | undefined { + if (config.enabledSources === "auto") { + return undefined; + } + + const enabledSources = new Set(config.enabledSources); + return new Set([...SOURCE_PRIORITY.keys()].filter((source) => !enabledSources.has(source))); +} + +function isDedupedRootSingleFile(candidate: RuleCandidate, rootSingleFileSelected: boolean): boolean { + return rootSingleFileSelected && isRootSingleFile(candidate); +} + +function isRootSingleFile(candidate: RuleCandidate): boolean { + return candidate.distance === 0 && candidate.isSingleFile && ROOT_SINGLE_FILE_SOURCES.has(candidate.source); +} + +function pathBasesForTarget( + projectRoot: string | null, + targetFile: string, + candidate: RuleCandidate, +): { projectRelative: string; scopeRelative?: string; basename: string } { + 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, + }; +} + +function scopeDirectoryForCandidate(projectRoot: string, candidate: RuleCandidate): string | null { + 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); +} + +function toPosixPath(path: string): string { + return path.replaceAll("\\", "/"); +} + +function storeLastLoad( + state: SessionState, + rules: ReadonlyArray, + diagnostics: ReadonlyArray, +): void { + state.loadedRules.length = 0; + state.loadedRules.push(...rules); + state.diagnostics.length = 0; + state.diagnostics.push(...diagnostics); +} + +function emptyLoadResult(state: SessionState): { rules: LoadedRule[]; diagnostics: RuleDiagnostic[] } { + storeLastLoad(state, [], []); + return { rules: [], diagnostics: [] }; +} + +function uniqueStrings(values: ReadonlyArray): string[] { + const uniqueValues: string[] = []; + 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/packages/omo-codex/plugin/components/rules/src/rules/errors.ts b/packages/omo-codex/plugin/components/rules/src/rules/errors.ts new file mode 100644 index 000000000..99e49bed9 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/errors.ts @@ -0,0 +1,13 @@ +export class UnsupportedRuleSourceError extends Error { + constructor(message: string) { + super(message); + this.name = "UnsupportedRuleSourceError"; + } +} + +export class RuleFrontmatterParseError extends Error { + constructor(message: string) { + super(message); + this.name = "RuleFrontmatterParseError"; + } +} diff --git a/packages/omo-codex/plugin/components/rules/src/rules/finder.ts b/packages/omo-codex/plugin/components/rules/src/rules/finder.ts new file mode 100644 index 000000000..ebd2f8363 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/finder.ts @@ -0,0 +1,326 @@ +import { existsSync, realpathSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, posix, relative, resolve } from "node:path"; + +import { + GLOBAL_DISTANCE, + PROJECT_RULE_SUBDIRS, + PROJECT_SINGLE_FILES, + USER_HOME_RULE_SUBDIRS, + USER_HOME_SINGLE_FILES, +} from "./constants.js"; +import { UnsupportedRuleSourceError } from "./errors.js"; +import { scanRuleFiles } from "./scanner.js"; +import type { RuleCandidate, RuleSource } from "./types.js"; + +interface SingleFileInfo { + path: string; + realPath: string; +} + +export interface RuleDiscoveryCache { + scannedRuleFiles: Map>; + singleFileInfo: Map; +} + +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; + cache?: RuleDiscoveryCache; +} + +interface WalkDirectory { + directory: string; + distance: number; +} + +export function createRuleDiscoveryCache(): RuleDiscoveryCache { + return { scannedRuleFiles: new Map(), singleFileInfo: new Map() }; +} + +export function findRuleCandidates(options: FinderOptions): RuleCandidate[] { + const skipUserHome = options.skipUserHome ?? false; + if (options.projectRoot === null && skipUserHome) { + return []; + } + + const disabledSources = options.disabledSources ?? new Set(); + const candidates: RuleCandidate[] = []; + const homeDirectory = resolve(options.homeDir ?? homedir()); + + if (options.projectRoot !== null) { + candidates.push( + ...findProjectCandidates(options.projectRoot, options.targetFile, disabledSources, options.cache), + ); + } + + if (!skipUserHome) { + candidates.push(...findUserHomeCandidates(homeDirectory, disabledSources, options.cache)); + } + + return candidates; +} + +function findProjectCandidates( + projectRoot: string, + targetFile: string | null, + disabledSources: ReadonlySet, + cache: RuleDiscoveryCache | undefined, +): RuleCandidate[] { + const rootDirectory = resolve(projectRoot); + const walkDirectories = getWalkDirectories(rootDirectory, targetFile); + const candidates: RuleCandidate[] = []; + + 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: string, + disabledSources: ReadonlySet, + cache: RuleDiscoveryCache | undefined, +): RuleCandidate[] { + const candidates: RuleCandidate[] = []; + + 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; +} + +function scanRuleFilesCached(rootDir: string, cache: RuleDiscoveryCache | undefined): ReturnType { + 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; +} + +function singleFileInfoCached(filePath: string, cache: RuleDiscoveryCache | undefined): SingleFileInfo | null { + 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 getWalkDirectories(projectRoot: string, targetFile: string | null): WalkDirectory[] { + if (targetFile === null) { + return [{ directory: projectRoot, distance: 0 }]; + } + + const startDirectory = dirname(resolve(targetFile)); + if (!isSameOrChildPath(startDirectory, projectRoot)) { + return [{ directory: projectRoot, distance: 0 }]; + } + + const walkDirectories: WalkDirectory[] = []; + 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; +} + +function isSameOrChildPath(childPath: string, parentPath: string): boolean { + const childRelativePath = relative(parentPath, childPath); + return childRelativePath === "" || (!childRelativePath.startsWith("..") && !childRelativePath.startsWith("/")); +} + +function readSingleFileInfo(filePath: string): SingleFileInfo | null { + if (!existsSync(filePath)) { + return null; + } + + try { + if (!statSync(filePath).isFile()) { + return null; + } + + return { path: filePath, realPath: resolveRealPath(filePath) }; + } catch { + return null; + } +} + +function resolveRealPath(filePath: string): string { + try { + return realpathSync.native(filePath); + } catch { + return filePath; + } +} + +function toRelativePath(rootDirectory: string, filePath: string): string { + return posix.normalize(relative(rootDirectory, filePath).replace(/\\/g, "/")); +} + +function toProjectRuleSource(parentDirectory: string, subDirectory: string): RuleSource { + 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}`); + } +} + +function toProjectSingleFileSource(ruleFile: string): RuleSource { + switch (ruleFile) { + case ".github/copilot-instructions.md": + case "AGENTS.md": + case "CLAUDE.md": + case "CONTEXT.md": + return ruleFile; + default: + throw new UnsupportedRuleSourceError(`Unsupported project single-file source: ${ruleFile}`); + } +} + +function toUserHomeRuleSource(ruleSubdir: string): RuleSource { + 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}`); + } +} + +function toUserHomeSingleFileSource(ruleFile: string): RuleSource { + const source = `~/${ruleFile}`; + switch (source) { + case "~/.config/opencode/AGENTS.md": + case "~/.claude/CLAUDE.md": + return source; + default: + throw new UnsupportedRuleSourceError(`Unsupported user-home single-file source: ${source}`); + } +} diff --git a/packages/omo-codex/plugin/components/rules/src/rules/formatter.ts b/packages/omo-codex/plugin/components/rules/src/rules/formatter.ts new file mode 100644 index 000000000..a05aee826 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/formatter.ts @@ -0,0 +1,68 @@ +import { truncateBudget, truncateRule } from "./truncator.js"; +import type { LoadedRule } from "./types.js"; + +export interface FormatOptions { + maxRuleChars: number; + maxResultChars: number; +} + +type TruncatedRule = { + path: string; + relativePath: string; + body: string; +}; + +function formatRule(rule: TruncatedRule): string { + return `Instructions from: ${rule.path}\n${rule.body}`; +} + +function truncateRules(rules: ReadonlyArray, options: FormatOptions): TruncatedRule[] { + const perRuleTruncated = rules.map((rule) => ({ + path: rule.path, + relativePath: rule.relativePath, + body: truncateRule(rule.body, { maxChars: options.maxRuleChars, relativePath: rule.relativePath }).body, + })); + const budgetedRules = truncateBudget({ + rules: perRuleTruncated.map((rule) => ({ body: rule.body, relativePath: rule.relativePath })), + maxResultChars: options.maxResultChars, + }); + const truncatedRules: TruncatedRule[] = []; + + for (let index = 0; index < budgetedRules.length; index += 1) { + const sourceRule = perRuleTruncated[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: ReadonlyArray, options: FormatOptions): string { + if (rules.length === 0) { + return ""; + } + + return `\n\n## Project Instructions\n${truncateRules(rules, options).map(formatRule).join("\n\n")}`; +} + +export function formatDynamicBlock( + rules: ReadonlyArray, + targetRelativePath: string, + options: FormatOptions, +): string { + if (rules.length === 0) { + return ""; + } + + return `\n\nAdditional project instructions matched for ${targetRelativePath}:\n\n${truncateRules(rules, options) + .map(formatRule) + .join("\n\n")}`; +} diff --git a/packages/omo-codex/plugin/components/rules/src/rules/matcher.ts b/packages/omo-codex/plugin/components/rules/src/rules/matcher.ts new file mode 100644 index 000000000..227e427e1 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/matcher.ts @@ -0,0 +1,142 @@ +import { createHash } from "node:crypto"; +import picomatch from "picomatch"; +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; +} + +interface CompiledPattern { + pattern: string; + isMatch: (path: string) => boolean; +} + +interface CompiledPatternSet { + positivePatterns: CompiledPattern[]; + negativeMatchers: Array<(path: string) => boolean>; +} + +const compiledPatternSets = new Map(); + +export function matchRule(input: MatcherInput): MatchResult { + 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: RuleFrontmatter): string[] { + const patterns = [ + ...normalizePatternList(frontmatter.globs), + ...normalizePatternList(frontmatter.paths), + ...normalizePatternList(frontmatter.applyTo), + ]; + + return [...new Set(patterns.map(normalizePath))]; +} + +export function hashContent(body: string): string { + return createHash("sha256").update(body).digest("hex"); +} + +function normalizePatternList(patterns: string | string[] | undefined): string[] { + if (patterns === undefined) { + return []; + } + + return Array.isArray(patterns) ? patterns : [patterns]; +} + +function normalizePath(path: string): string { + return path.replaceAll("\\", "/"); +} + +function normalizedPathBases(pathBases: MatcherInput["pathBases"]): string[] { + const normalizedBases = [normalizePath(pathBases.projectRelative)]; + if (pathBases.scopeRelative !== undefined) { + normalizedBases.push(normalizePath(pathBases.scopeRelative)); + } + normalizedBases.push(normalizePath(pathBases.basename)); + return normalizedBases; +} + +function compiledPatternSetFor(patterns: ReadonlyArray): CompiledPatternSet { + 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: ReadonlyArray): CompiledPatternSet { + const positivePatterns: CompiledPattern[] = []; + const negativeMatchers: Array<(path: string) => boolean> = []; + + 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: string): (path: string) => boolean { + return picomatch(normalizePath(pattern), { bash: true, dot: true }); +} + +function isExcluded(pathBase: string, negativeMatchers: ReadonlyArray<(path: string) => boolean>): boolean { + for (const isMatch of negativeMatchers) { + if (isMatch(pathBase)) { + return true; + } + } + + return false; +} + +function noMatch(): MatchResult { + return { matched: false, reason: { kind: "no-match" } }; +} diff --git a/packages/omo-codex/plugin/components/rules/src/rules/ordering.ts b/packages/omo-codex/plugin/components/rules/src/rules/ordering.ts new file mode 100644 index 000000000..c811e2f2f --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/ordering.ts @@ -0,0 +1,33 @@ +import { SOURCE_PRIORITY } from "./constants.js"; +import type { RuleCandidate } from "./types.js"; + +export function sortCandidates(candidates: ReadonlyArray): T[] { + 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: RuleCandidate, b: RuleCandidate): number { + 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: boolean, b: boolean): number { + return Number(a) - Number(b); +} + +function compareNumber(a: number, b: number): number { + return a - b; +} + +function compareString(a: string, b: string): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} diff --git a/packages/omo-codex/plugin/components/rules/src/rules/parser.ts b/packages/omo-codex/plugin/components/rules/src/rules/parser.ts new file mode 100644 index 000000000..34f13d0d0 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/parser.ts @@ -0,0 +1,326 @@ +import { RuleFrontmatterParseError } from "./errors.js"; +import type { ParsedRule, RuleFrontmatter } from "./types.js"; + +const FRONTMATTER_OPENING = "---\n"; +const FRONTMATTER_OPENING_CRLF = "---\r\n"; + +/** Parse markdown rule content and extract the supported YAML frontmatter subset. */ +export function parseRule(content: string): ParsedRule { + 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}`, + }; + } +} + +function stripBom(content: string): string { + return content.startsWith("\uFEFF") ? content.slice(1) : content; +} + +function getOpeningDelimiterLength(content: string): number { + if (content.startsWith(FRONTMATTER_OPENING_CRLF)) return FRONTMATTER_OPENING_CRLF.length; + if (content.startsWith(FRONTMATTER_OPENING)) return FRONTMATTER_OPENING.length; + return 0; +} + +function findClosingDelimiter(content: string, openingLength: number): { start: number; bodyStart: number } | null { + 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; +} + +function parseYamlFrontmatter(yamlContent: string): RuleFrontmatter { + const lines = yamlContent.replace(/\r\n/g, "\n").split("\n"); + const frontmatter: RuleFrontmatter = {}; + const globValues: string[] = []; + 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 (!globValues.includes(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: string, lineNumber: number): boolean { + if (value === "true") return true; + if (value === "false") return false; + throw new RuleFrontmatterParseError(`Expected boolean on line ${lineNumber}`); +} + +function parseGlobValue(rawValue: string, lines: string[], lineIndex: number): { values: string[]; consumed: number } { + if (rawValue.startsWith("[")) { + return { values: parseInlineArray(rawValue), consumed: 1 }; + } + + if (rawValue.length === 0) { + return parseMultilineArray(lines, lineIndex); + } + + const value = parseStringValue(rawValue); + if (value.includes(",")) { + return { + values: value + .split(",") + .map((item) => item.trim()) + .filter(Boolean), + consumed: 1, + }; + } + + return { values: [value], consumed: 1 }; +} + +function parseMultilineArray(lines: string[], lineIndex: number): { values: string[]; consumed: number } { + const values: string[] = []; + 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: string): string[] { + 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: string): number { + let quote: string | null = 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: string): string[] { + const values: string[] = []; + let current = ""; + let quote: string | null = 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: string): string { + 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: string): string { + let parsedValue: unknown; + try { + parsedValue = JSON.parse(value); + } catch { + throw new RuleFrontmatterParseError("Invalid JSON-quoted string"); + } + + if (typeof parsedValue !== "string") { + throw new RuleFrontmatterParseError("Expected JSON-quoted string"); + } + + return parsedValue; +} + +function stripComment(line: string): string { + let quote: string | null = 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/packages/omo-codex/plugin/components/rules/src/rules/project-root.ts b/packages/omo-codex/plugin/components/rules/src/rules/project-root.ts new file mode 100644 index 000000000..525358e84 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/project-root.ts @@ -0,0 +1,30 @@ +import { existsSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; + +import { PROJECT_MARKERS } from "./constants.js"; + +export function findProjectRoot(startPath: string, markers: ReadonlyArray = PROJECT_MARKERS): string | null { + 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/packages/omo-codex/plugin/components/rules/src/rules/scanner.ts b/packages/omo-codex/plugin/components/rules/src/rules/scanner.ts new file mode 100644 index 000000000..8c6f4ef77 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/scanner.ts @@ -0,0 +1,162 @@ +import { type Dirent, existsSync, lstatSync, readdirSync, realpathSync, type Stats, 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 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 function scanRuleFiles(options: ScanOptions): ScannedFile[] { + const rootPath = toAbsolutePath(options.rootDir); + if (!existsSync(rootPath)) { + return []; + } + + let rootStats: Stats; + try { + rootStats = statSync(rootPath); + } catch { + return []; + } + + if (!rootStats.isDirectory()) { + return []; + } + + const results: ScannedFile[] = []; + 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: number | undefined): number { + 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: string): string { + return isAbsolute(filePath) ? filePath : resolve(filePath); +} + +function scanDirectory( + directoryPath: string, + depth: number, + maxDepth: number, + maxFiles: number, + excludedDirs: ReadonlySet, + visitedDirectories: Set, + results: ScannedFile[], +): void { + if (results.length >= maxFiles) { + return; + } + + let realDirectoryPath: string; + try { + realDirectoryPath = realpathSync.native(directoryPath); + } catch { + return; + } + + if (visitedDirectories.has(realDirectoryPath)) { + return; + } + visitedDirectories.add(realDirectoryPath); + + let entries: Dirent[]; + 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: string, + linkName: string, + depth: number, + maxDepth: number, + maxFiles: number, + excludedDirs: ReadonlySet, + visitedDirectories: Set, + results: ScannedFile[], +): void { + if (results.length >= maxFiles) { + return; + } + + let targetStats: Stats; + 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: string): boolean { + return RULE_FILE_EXTENSIONS.some((extension) => fileName.endsWith(extension)); +} + +function resolveRealPath(filePath: string): string { + try { + const realPath = realpathSync.native(filePath); + const fileStats = lstatSync(filePath); + return fileStats.isSymbolicLink() ? realPath : filePath; + } catch { + return filePath; + } +} diff --git a/packages/omo-codex/plugin/components/rules/src/rules/truncator.ts b/packages/omo-codex/plugin/components/rules/src/rules/truncator.ts new file mode 100644 index 000000000..2c54bea8a --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/truncator.ts @@ -0,0 +1,67 @@ +import { TRUNCATION_NOTICE } from "./constants.js"; +import type { TruncationResult } from "./types.js"; + +type BudgetRule = { + body: string; + relativePath: string; +}; + +type BudgetResult = BudgetRule & { + truncated: boolean; +}; + +function truncationNotice(relativePath: string): string { + return TRUNCATION_NOTICE.replace("{path}", relativePath); +} + +function safeSliceEnd(body: string, end: number): number { + 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: string, options: { maxChars: number; relativePath: string }): TruncationResult { + 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: { rules: ReadonlyArray; maxResultChars: number }): BudgetResult[] { + const results: BudgetResult[] = []; + 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/packages/omo-codex/plugin/components/rules/src/rules/types.ts b/packages/omo-codex/plugin/components/rules/src/rules/types.ts new file mode 100644 index 000000000..245ed52a7 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/rules/types.ts @@ -0,0 +1,138 @@ +/** + * 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 AGENTS.md or + * `.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" + | "AGENTS.md" + | "CLAUDE.md" + | "CONTEXT.md" + | "~/.omo/rules" + | "~/.opencode/rules" + | "~/.claude/rules" + | "~/.config/opencode/AGENTS.md" + | "~/.claude/CLAUDE.md"; + +/** + * 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; + 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/packages/omo-codex/plugin/components/rules/src/tool-paths.ts b/packages/omo-codex/plugin/components/rules/src/tool-paths.ts new file mode 100644 index 000000000..5974c4ae1 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/src/tool-paths.ts @@ -0,0 +1,192 @@ +import { existsSync, statSync } from "node:fs"; +import { isAbsolute, resolve } from "node:path"; + +export interface CodexPostToolUseLike { + tool_name: string; + tool_input: unknown; + tool_response: unknown; +} + +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: CodexPostToolUseLike, cwd: string): string[] { + 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: Set, input: Record, cwd: string): void { + 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: Set, input: Record, cwd: string): void { + for (const key of ["input", "patch", "command", "cmd"]) { + const value = input[key]; + if (typeof value === "string") { + addPatchHeaderPaths(paths, value, cwd); + } + } +} + +function addPatchHeaderPaths(paths: Set, patch: string, cwd: string): void { + 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: Set, value: unknown, cwd: string): void { + 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: Set, command: string | undefined, cwd: string): void { + 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: Set, value: unknown, cwd: string, mustExist: boolean): void { + if (!Array.isArray(value)) return; + for (const item of value) { + addPath(paths, item, cwd, mustExist); + } +} + +function addPath(paths: Set, value: unknown, cwd: string, mustExist: boolean): void { + 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: string, filePath: string): string { + return isAbsolute(filePath) ? filePath : resolve(cwd, filePath); +} + +function isExistingFile(filePath: string): boolean { + try { + return existsSync(filePath) && statSync(filePath).isFile(); + } catch { + return false; + } +} + +function looksLikeUrl(value: string): boolean { + return /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(value); +} + +function stringProperty(value: Record, key: string): string | undefined { + const property = value[key]; + return typeof property === "string" && property.length > 0 ? property : undefined; +} + +function tokenizeShell(command: string): string[] { + const tokens: string[] = []; + let current = ""; + let quote: "'" | '"' | null = 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: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isFailedToolResponse(value: unknown): boolean { + if (!isRecord(value)) return false; + return ( + value["isError"] === true || value["is_error"] === true || value["error"] === true || value["status"] === "error" + ); +} diff --git a/packages/omo-codex/plugin/components/rules/test/codex-hook-performance.test.ts b/packages/omo-codex/plugin/components/rules/test/codex-hook-performance.test.ts new file mode 100644 index 000000000..6cb812bd8 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/codex-hook-performance.test.ts @@ -0,0 +1,99 @@ +import fs from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import type { CodexPostToolUseInput } from "../src/codex-hook.js"; + +const tempDirectories: string[] = []; + +afterEach(() => { + for (const directory of tempDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +function makeTempProject(ruleCount: number): { root: string; pluginData: string; targetPath: string } { + const root = fs.mkdtempSync(path.join(tmpdir(), "codex-rules-hook-perf-project-")); + const pluginData = fs.mkdtempSync(path.join(tmpdir(), "codex-rules-hook-perf-data-")); + tempDirectories.push(root, pluginData); + + fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "fixture" })); + fs.mkdirSync(path.join(root, ".omo", "rules"), { recursive: true }); + fs.mkdirSync(path.join(root, "src"), { recursive: true }); + const targetPath = path.join(root, "src", "app.ts"); + fs.writeFileSync(targetPath, "export const app = true;\n"); + + for (let index = 0; index < ruleCount; index += 1) { + fs.writeFileSync( + path.join(root, ".omo", "rules", `rule-${index}.md`), + ["---", 'globs: "**/*.ts"', "---", "", `Rule ${index}`].join("\n"), + ); + } + + return { root, pluginData, targetPath }; +} + +function postToolUseInput(root: string, filePath: string): CodexPostToolUseInput { + return { + session_id: "session-1", + turn_id: "turn-1", + transcript_path: null, + cwd: root, + hook_event_name: "PostToolUse", + model: "gpt-5.5", + permission_mode: "default", + tool_name: "mcp__filesystem__read_file", + tool_input: { path: filePath }, + tool_response: { text: "file contents" }, + tool_use_id: "call-1", + }; +} + +function isProjectRuleRead(filePath: unknown): boolean { + return String(filePath).includes(`${path.sep}.omo${path.sep}rules${path.sep}`); +} + +describe("codex rules hook performance", () => { + it("#given unchanged dynamic target #when PostToolUse repeats #then rule files are not reread for fingerprinting", async () => { + // given + const { root, pluginData, targetPath } = makeTempProject(3); + let ruleFileReads = 0; + const originalReadFileSync = fs.readFileSync; + const wrappedReadFileSync = ((...args: Parameters) => { + if (isProjectRuleRead(args[0])) { + ruleFileReads += 1; + } + + return originalReadFileSync(...args); + }) as typeof fs.readFileSync; + fs.readFileSync = wrappedReadFileSync; + syncBuiltinESMExports(); + const { runPostToolUseHook } = await import("../src/codex-hook.js"); + + try { + // when + const firstOutput = await runPostToolUseHook(postToolUseInput(root, targetPath), { + pluginDataRoot: pluginData, + env: { CODEX_RULES_ENABLED_SOURCES: ".omo/rules" }, + }); + const firstRunRuleFileReads = ruleFileReads; + ruleFileReads = 0; + const secondOutput = await runPostToolUseHook(postToolUseInput(root, targetPath), { + pluginDataRoot: pluginData, + env: { CODEX_RULES_ENABLED_SOURCES: ".omo/rules" }, + }); + + // then + expect(firstOutput).toContain("Rule 0"); + expect(firstRunRuleFileReads).toBe(3); + expect(secondOutput).toBe(""); + expect(ruleFileReads).toBe(0); + } finally { + fs.readFileSync = originalReadFileSync; + syncBuiltinESMExports(); + } + }); +}); diff --git a/packages/omo-codex/plugin/components/rules/test/codex-hook.test.ts b/packages/omo-codex/plugin/components/rules/test/codex-hook.test.ts new file mode 100644 index 000000000..b05dbdb84 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/codex-hook.test.ts @@ -0,0 +1,675 @@ +import { spawn } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + type CodexPostCompactInput, + type CodexPostToolUseInput, + type CodexSessionStartInput, + runPostCompactHook, + runPostToolUseHook, + runSessionStartHook, + runUserPromptSubmitHook, +} from "../src/codex-hook.js"; + +type CliResult = { + exitCode: number | null; + stdout: string; + stderr: string; +}; + +type SessionCache = { + staticDedup?: string[]; + dynamicDedup?: Record; + dynamicTargetFingerprints?: Record; +}; + +const CLI_PATH = fileURLToPath(new URL("../dist/cli.js", import.meta.url)); + +function runHookCli(input: string, subcommand = "post-tool-use", env: NodeJS.ProcessEnv = {}): Promise { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [CLI_PATH, "hook", subcommand], { + env: { ...process.env, ...env }, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + child.once("error", reject); + child.once("close", (exitCode) => { + resolve({ exitCode, stdout, stderr }); + }); + child.stdin.end(input); + }); +} + +const tempDirectories: string[] = []; +const PROJECT_ONLY_ENV = { + CODEX_RULES_ENABLED_SOURCES: "AGENTS.md,.omo/rules", +}; + +const RULES_ONLY_ENV = { + CODEX_RULES_ENABLED_SOURCES: ".omo/rules", +}; + +afterEach(() => { + for (const directory of tempDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function makeTempProject(): { root: string; pluginData: string } { + const root = mkdtempSync(path.join(tmpdir(), "codex-rules-project-")); + const pluginData = mkdtempSync(path.join(tmpdir(), "codex-rules-data-")); + tempDirectories.push(root, pluginData); + writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "fixture" })); + writeFileSync(path.join(root, "AGENTS.md"), "Always wear safety goggles when refactoring."); + mkdirSync(path.join(root, ".omo", "rules"), { recursive: true }); + writeFileSync( + path.join(root, ".omo", "rules", "typescript.md"), + [ + "---", + "description: TypeScript", + 'globs: ["**/*.ts", "**/*.tsx"]', + "---", + "", + "Prefer strict TypeScript for all source files.", + ].join("\n"), + ); + mkdirSync(path.join(root, "src"), { recursive: true }); + writeFileSync(path.join(root, "src", "app.ts"), "export const app = true;\n"); + writeFileSync(path.join(root, "src", "other.ts"), "export const other = true;\n"); + return { root, pluginData }; +} + +function sessionStartInput(root: string): CodexSessionStartInput { + return { + session_id: "session-1", + transcript_path: null, + cwd: root, + hook_event_name: "SessionStart", + model: "gpt-5.5", + permission_mode: "default", + source: "startup", + }; +} + +function postCompactInput(root: string): CodexPostCompactInput { + return { + session_id: "session-1", + turn_id: "turn-compact", + transcript_path: null, + cwd: root, + hook_event_name: "PostCompact", + model: "gpt-5.5", + trigger: "manual", + }; +} + +function userPromptSubmitInput( + root: string, + transcriptPath: string | null = null, +): Parameters[0] { + return { + session_id: "session-1", + turn_id: "turn-1", + transcript_path: transcriptPath, + cwd: root, + hook_event_name: "UserPromptSubmit", + model: "gpt-5.5", + permission_mode: "default", + prompt: "read src/app.ts", + }; +} + +function postToolUseInput(root: string, filePath: string): CodexPostToolUseInput { + return { + session_id: "session-1", + turn_id: "turn-1", + transcript_path: null, + cwd: root, + hook_event_name: "PostToolUse", + model: "gpt-5.5", + permission_mode: "default", + tool_name: "mcp__filesystem__read_file", + tool_input: { path: filePath }, + tool_response: { text: "file contents" }, + tool_use_id: "call-1", + }; +} + +function parseHookOutput(output: string): { + hookSpecificOutput?: { + hookEventName?: string; + additionalContext?: string; + }; +} { + expect(output.trim().length).toBeGreaterThan(0); + return JSON.parse(output) as { + hookSpecificOutput?: { + hookEventName?: string; + additionalContext?: string; + }; + }; +} + +function writeTranscriptWithContext(root: string, ...additionalContexts: string[]): string { + const transcriptPath = path.join(root, "transcript.jsonl"); + writeFileSync( + transcriptPath, + `${additionalContexts + .map((additionalContext) => JSON.stringify({ hookSpecificOutput: { additionalContext } })) + .join("\n")}\n`, + ); + return transcriptPath; +} + +function occurrenceCount(value: string, search: string): number { + return value.split(search).length - 1; +} + +function sessionCacheFilePath(pluginData: string, sessionId = "session-1"): string { + return path.join(pluginData, "sessions", `${sessionId}.json`); +} + +function readSessionCache(pluginData: string): SessionCache { + return JSON.parse(readFileSync(sessionCacheFilePath(pluginData), "utf8")) as SessionCache; +} + +function writeTypeScriptRule(root: string, globExpression: string, body: string): void { + writeFileSync( + path.join(root, ".omo", "rules", "typescript.md"), + ["---", "description: TypeScript", `globs: ${globExpression}`, "---", "", body].join("\n"), + ); +} + +describe("codex rules hooks", () => { + it("#given project rules #when SessionStart runs #then emits static additional context", async () => { + // given + const { root, pluginData } = makeTempProject(); + + // when + const output = await runSessionStartHook(sessionStartInput(root), { + pluginDataRoot: pluginData, + env: PROJECT_ONLY_ENV, + }); + + // then + const parsed = parseHookOutput(output); + expect(parsed.hookSpecificOutput?.hookEventName).toBe("SessionStart"); + expect(parsed.hookSpecificOutput?.additionalContext).toContain("## Project Instructions"); + expect(parsed.hookSpecificOutput?.additionalContext).toContain("Always wear safety goggles"); + }); + + it("#given static context already injected #when UserPromptSubmit runs #then it emits no duplicate context", async () => { + // given + const { root, pluginData } = makeTempProject(); + await runSessionStartHook(sessionStartInput(root), { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV }); + + // when + const output = await runUserPromptSubmitHook( + { + session_id: "session-1", + turn_id: "turn-1", + transcript_path: null, + cwd: root, + hook_event_name: "UserPromptSubmit", + model: "gpt-5.5", + permission_mode: "default", + prompt: "read src/app.ts", + }, + { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV }, + ); + + // then + expect(output).toBe(""); + }); + + it("#given resumed session #when SessionStart runs #then it preserves the session cache", async () => { + // given + const { root, pluginData } = makeTempProject(); + const input = sessionStartInput(root); + await runSessionStartHook(input, { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV }); + + // when + const resumeOutput = await runSessionStartHook( + { ...input, source: "resume" }, + { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV }, + ); + const clearOutput = await runSessionStartHook( + { ...input, source: "clear" }, + { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV }, + ); + + // then + expect(resumeOutput).toBe(""); + expect(parseHookOutput(clearOutput).hookSpecificOutput?.additionalContext).toContain( + "Always wear safety goggles", + ); + }); + + it("#given static context remains in transcript but cache is missing #when SessionStart runs #then it emits no duplicate context", async () => { + // given + const { root, pluginData } = makeTempProject(); + const firstOutput = await runSessionStartHook(sessionStartInput(root), { + pluginDataRoot: pluginData, + env: PROJECT_ONLY_ENV, + }); + const firstContext = parseHookOutput(firstOutput).hookSpecificOutput?.additionalContext ?? ""; + const transcriptPath = writeTranscriptWithContext(root, firstContext); + rmSync(sessionCacheFilePath(pluginData), { force: true }); + + // when + const output = await runSessionStartHook( + { ...sessionStartInput(root), transcript_path: transcriptPath }, + { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV }, + ); + + // then + expect(output).toBe(""); + expect(readSessionCache(pluginData).staticDedup).toHaveLength(1); + }); + + it("#given read-file tool result #when PostToolUse runs #then emits matching dynamic rule context", async () => { + // given + const { root, pluginData } = makeTempProject(); + const filePath = path.join(root, "src", "app.ts"); + + // when + const output = await runPostToolUseHook(postToolUseInput(root, filePath), { + pluginDataRoot: pluginData, + env: PROJECT_ONLY_ENV, + }); + + // then + // The literal "src/app.ts" pins POSIX separators and acts as the Windows + // regression line: prior versions emitted "src\\app.ts" on Windows. + const parsed = parseHookOutput(output); + expect(parsed.hookSpecificOutput?.hookEventName).toBe("PostToolUse"); + expect(parsed.hookSpecificOutput?.additionalContext).toContain( + "Additional project instructions matched for src/app.ts", + ); + expect(parsed.hookSpecificOutput?.additionalContext).toContain("Prefer strict TypeScript"); + expect(parsed.hookSpecificOutput?.additionalContext ?? "").not.toContain("src\\app.ts"); + expect(output).not.toContain("updatedMCPToolOutput"); + expect(output).not.toContain("suppressOutput"); + expect(output).not.toContain('"decision"'); + }); + + it("#given multiple target paths matching one rule #when PostToolUse runs #then emits dynamic context once for the first target", async () => { + // given + const { root, pluginData } = makeTempProject(); + const firstFilePath = path.join(root, "src", "app.ts"); + const secondFilePath = path.join(root, "src", "other.ts"); + + // when + const output = await runPostToolUseHook( + { + ...postToolUseInput(root, firstFilePath), + tool_name: "mcp__filesystem__read_multiple_files", + tool_input: { paths: [firstFilePath, secondFilePath, firstFilePath] }, + }, + { + pluginDataRoot: pluginData, + env: PROJECT_ONLY_ENV, + }, + ); + + // then + const parsed = parseHookOutput(output); + const additionalContext = parsed.hookSpecificOutput?.additionalContext ?? ""; + expect(parsed.hookSpecificOutput?.hookEventName).toBe("PostToolUse"); + expect(additionalContext).toContain("Additional project instructions matched for src/app.ts"); + expect(additionalContext).not.toContain("src\\app.ts"); + expect(occurrenceCount(additionalContext, "Prefer strict TypeScript")).toBe(1); + }); + + it("#given dynamic context already injected #when PostToolUse repeats #then emits no duplicate context", async () => { + // given + const { root, pluginData } = makeTempProject(); + const filePath = path.join(root, "src", "app.ts"); + const input = postToolUseInput(root, filePath); + await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV }); + const cachedState = readSessionCache(pluginData); + + // when + const output = await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV }); + + // then + expect(output).toBe(""); + expect(Object.keys(cachedState.dynamicTargetFingerprints ?? {})).toHaveLength(1); + expect(readSessionCache(pluginData).dynamicTargetFingerprints).toEqual(cachedState.dynamicTargetFingerprints); + }); + + it("#given dynamic context remains in transcript but cache is missing #when PostToolUse repeats #then it emits no duplicate context", async () => { + // given + const { root, pluginData } = makeTempProject(); + const filePath = path.join(root, "src", "app.ts"); + const input = postToolUseInput(root, filePath); + const firstOutput = await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV }); + const firstContext = parseHookOutput(firstOutput).hookSpecificOutput?.additionalContext ?? ""; + const transcriptPath = writeTranscriptWithContext(root, firstContext); + rmSync(sessionCacheFilePath(pluginData), { force: true }); + + // when + const output = await runPostToolUseHook( + { ...input, transcript_path: transcriptPath }, + { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV }, + ); + + // then + const cachedState = readSessionCache(pluginData); + expect(output).toBe(""); + expect(Object.values(cachedState.dynamicDedup ?? {}).flat()).toHaveLength(2); + expect(Object.keys(cachedState.dynamicTargetFingerprints ?? {})).toHaveLength(1); + }); + + it("#given cached target in one session #when another session reads it #then PostToolUse rechecks independently", async () => { + // given + const { root, pluginData } = makeTempProject(); + const filePath = path.join(root, "src", "app.ts"); + await runPostToolUseHook(postToolUseInput(root, filePath), { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV }); + + // when + const output = await runPostToolUseHook( + { ...postToolUseInput(root, filePath), session_id: "session-2" }, + { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV }, + ); + + // then + expect(parseHookOutput(output).hookSpecificOutput?.additionalContext).toContain("Prefer strict TypeScript"); + }); + + it("#given cached dynamic target #when rule frontmatter changes #then PostToolUse rechecks the target", async () => { + // given + const { root, pluginData } = makeTempProject(); + const filePath = path.join(root, "src", "app.ts"); + const input = postToolUseInput(root, filePath); + await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: RULES_ONLY_ENV }); + writeTypeScriptRule(root, '"**/*.ts"', "Prefer readonly TypeScript after rule edits."); + + // when + const output = await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: RULES_ONLY_ENV }); + + // then + expect(parseHookOutput(output).hookSpecificOutput?.additionalContext).toContain( + "Prefer readonly TypeScript after rule edits.", + ); + }); + + it("#given cached dynamic context #when PostCompact runs #then PostToolUse can re-inject after compaction", async () => { + // given + const { root, pluginData } = makeTempProject(); + const filePath = path.join(root, "src", "app.ts"); + const input = postToolUseInput(root, filePath); + const firstOutput = await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV }); + const firstContext = parseHookOutput(firstOutput).hookSpecificOutput?.additionalContext ?? ""; + const transcriptPath = writeTranscriptWithContext(root, firstContext); + expect( + await runPostToolUseHook( + { ...input, transcript_path: transcriptPath }, + { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV }, + ), + ).toBe(""); + + // when + const compactOutput = await runPostCompactHook( + { ...postCompactInput(root), transcript_path: transcriptPath }, + { pluginDataRoot: pluginData }, + ); + const output = await runPostToolUseHook( + { ...input, transcript_path: transcriptPath }, + { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV }, + ); + + // then + expect(compactOutput).toBe(""); + expect(parseHookOutput(output).hookSpecificOutput?.additionalContext).toContain("Prefer strict TypeScript"); + }); + + it("#given compacted transcript #when static re-injects before dynamic #then dynamic still re-injects", async () => { + // given + const { root, pluginData } = makeTempProject(); + const filePath = path.join(root, "src", "app.ts"); + const staticOutput = await runSessionStartHook(sessionStartInput(root), { + pluginDataRoot: pluginData, + env: PROJECT_ONLY_ENV, + }); + const dynamicOutput = await runPostToolUseHook(postToolUseInput(root, filePath), { + pluginDataRoot: pluginData, + env: PROJECT_ONLY_ENV, + }); + const transcriptPath = writeTranscriptWithContext( + root, + parseHookOutput(staticOutput).hookSpecificOutput?.additionalContext ?? "", + parseHookOutput(dynamicOutput).hookSpecificOutput?.additionalContext ?? "", + ); + await runPostCompactHook( + { ...postCompactInput(root), transcript_path: transcriptPath }, + { pluginDataRoot: pluginData }, + ); + + // when + const staticReinjectOutput = await runUserPromptSubmitHook(userPromptSubmitInput(root, transcriptPath), { + pluginDataRoot: pluginData, + env: PROJECT_ONLY_ENV, + }); + const dynamicReinjectOutput = await runPostToolUseHook( + { ...postToolUseInput(root, filePath), transcript_path: transcriptPath }, + { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV }, + ); + + // then + expect(parseHookOutput(staticReinjectOutput).hookSpecificOutput?.additionalContext).toContain( + "Always wear safety goggles", + ); + expect(parseHookOutput(dynamicReinjectOutput).hookSpecificOutput?.additionalContext).toContain( + "Prefer strict TypeScript", + ); + }); + + it("#given compacted transcript #when dynamic re-injects before static #then static still re-injects", async () => { + // given + const { root, pluginData } = makeTempProject(); + const filePath = path.join(root, "src", "app.ts"); + const staticOutput = await runSessionStartHook(sessionStartInput(root), { + pluginDataRoot: pluginData, + env: PROJECT_ONLY_ENV, + }); + const dynamicOutput = await runPostToolUseHook(postToolUseInput(root, filePath), { + pluginDataRoot: pluginData, + env: PROJECT_ONLY_ENV, + }); + const transcriptPath = writeTranscriptWithContext( + root, + parseHookOutput(staticOutput).hookSpecificOutput?.additionalContext ?? "", + parseHookOutput(dynamicOutput).hookSpecificOutput?.additionalContext ?? "", + ); + await runPostCompactHook( + { ...postCompactInput(root), transcript_path: transcriptPath }, + { pluginDataRoot: pluginData }, + ); + + // when + const dynamicReinjectOutput = await runPostToolUseHook( + { ...postToolUseInput(root, filePath), transcript_path: transcriptPath }, + { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV }, + ); + const staticReinjectOutput = await runUserPromptSubmitHook(userPromptSubmitInput(root, transcriptPath), { + pluginDataRoot: pluginData, + env: PROJECT_ONLY_ENV, + }); + + // then + expect(parseHookOutput(dynamicReinjectOutput).hookSpecificOutput?.additionalContext).toContain( + "Prefer strict TypeScript", + ); + expect(parseHookOutput(staticReinjectOutput).hookSpecificOutput?.additionalContext).toContain( + "Always wear safety goggles", + ); + }); + + it("#given legacy session cache #when PostToolUse hydrates state #then it accepts the old shape", async () => { + // given + const { root, pluginData } = makeTempProject(); + mkdirSync(path.join(pluginData, "sessions"), { recursive: true }); + writeFileSync(sessionCacheFilePath(pluginData), `${JSON.stringify({ staticDedup: [], dynamicDedup: {} })}\n`); + + // when + const output = await runPostToolUseHook(postToolUseInput(root, path.join(root, "src", "app.ts")), { + pluginDataRoot: pluginData, + env: PROJECT_ONLY_ENV, + }); + + // then + expect(parseHookOutput(output).hookSpecificOutput?.additionalContext).toContain("Prefer strict TypeScript"); + }); + + it("#given static-only mode #when PostToolUse runs #then emits no dynamic context", async () => { + // given + const { root, pluginData } = makeTempProject(); + const filePath = path.join(root, "src", "app.ts"); + + // when + const output = await runPostToolUseHook(postToolUseInput(root, filePath), { + pluginDataRoot: pluginData, + env: { + ...PROJECT_ONLY_ENV, + CODEX_RULES_MODE: "static", + }, + }); + + // then + expect(output).toBe(""); + }); + + it("#given rules disabled #when PostToolUse runs #then emits no dynamic context", async () => { + // given + const { root, pluginData } = makeTempProject(); + const filePath = path.join(root, "src", "app.ts"); + + // when + const output = await runPostToolUseHook(postToolUseInput(root, filePath), { + pluginDataRoot: pluginData, + env: { + ...PROJECT_ONLY_ENV, + CODEX_RULES_DISABLED: "true", + }, + }); + + // then + expect(output).toBe(""); + }); + + it("#given failed tool response #when PostToolUse runs #then emits no dynamic context", async () => { + // given + const { root, pluginData } = makeTempProject(); + const filePath = path.join(root, "src", "app.ts"); + + // when + const output = await runPostToolUseHook( + { + ...postToolUseInput(root, filePath), + tool_response: { is_error: true }, + }, + { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV }, + ); + + // then + expect(output).toBe(""); + }); + + it("#given tracked tool without path #when PostToolUse runs #then emits no dynamic context", async () => { + // given + const { root, pluginData } = makeTempProject(); + + // when + const output = await runPostToolUseHook( + { + ...postToolUseInput(root, ""), + tool_input: {}, + }, + { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV }, + ); + + // then + expect(output).toBe(""); + }); + + it("#given malformed post-tool-use stdin #when hook CLI runs #then it no-ops without stderr", async () => { + // given + const input = "break;\n"; + + // when + const result = await runHookCli(input); + + // then + expect(result).toEqual({ + exitCode: 0, + stdout: "", + stderr: "", + }); + }); + + it("#given non-object post-tool-use JSON #when hook CLI runs #then it no-ops without stderr", async () => { + // given + const input = "[]\n"; + + // when + const result = await runHookCli(input); + + // then + expect(result).toEqual({ + exitCode: 0, + stdout: "", + stderr: "", + }); + }); + + it("#given debug timing enabled #when PostToolUse hook CLI runs #then phase logs go to stderr only", async () => { + // given + const { root, pluginData } = makeTempProject(); + const input = `${JSON.stringify(postToolUseInput(root, path.join(root, "src", "app.ts")))}\n`; + + // when + const result = await runHookCli(input, "post-tool-use", { + NODE_DEBUG: "codex-rules", + PLUGIN_DATA: pluginData, + }); + + // then + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("hookSpecificOutput"); + expect(result.stderr).toContain("PostToolUse"); + expect(result.stderr).toContain("extract"); + expect(result.stderr).toContain("fingerprint"); + expect(result.stderr).toContain("load"); + expect(result.stderr).toContain("persist"); + expect(result.stderr).toContain("ms"); + }); + + it("#given malformed post-compact stdin #when hook CLI runs #then it no-ops without stderr", async () => { + // given + const input = `${JSON.stringify({ hook_event_name: "PostCompact", session_id: "s", turn_id: "t" })}\n`; + + // when + const result = await runHookCli(input, "post-compact"); + + // then + expect(result).toEqual({ + exitCode: 0, + stdout: "", + stderr: "", + }); + }); +}); diff --git a/packages/omo-codex/plugin/components/rules/test/engine.test.ts b/packages/omo-codex/plugin/components/rules/test/engine.test.ts new file mode 100644 index 000000000..4b9363530 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/engine.test.ts @@ -0,0 +1,192 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { createEngine, defaultConfig, type EngineDeps } from "../src/rules/engine.js"; +import { matchRule as defaultMatchRule } from "../src/rules/matcher.js"; +import type { RuleCandidate } from "../src/rules/types.js"; + +const projectRoot = "/tmp/codex-rules-engine"; + +function makeCandidate(): RuleCandidate { + return { + path: join(projectRoot, ".omo", "rules", "typescript.md"), + realPath: join(projectRoot, ".omo", "rules", "typescript.md"), + source: ".omo/rules", + distance: 0, + isGlobal: false, + isSingleFile: false, + relativePath: ".omo/rules/typescript.md", + }; +} + +describe("rule engine dynamic matching", () => { + it("#given duplicate target paths #when loading dynamic rules #then repeated discovery and parsing work is avoided", () => { + // given + const targetPath = join(projectRoot, "src", "app.ts"); + const candidate = makeCandidate(); + const counters = { + findProjectRoot: 0, + findCandidates: 0, + readFile: 0, + }; + const deps = { + findProjectRoot: () => { + counters.findProjectRoot += 1; + return projectRoot; + }, + findCandidates: () => { + counters.findCandidates += 1; + return [candidate]; + }, + readFile: () => { + counters.readFile += 1; + return ["---", "globs: **/*.ts", "---", "", "Prefer strict TypeScript."].join("\n"); + }, + } satisfies EngineDeps; + const engine = createEngine(defaultConfig(), deps); + + // when + const result = engine.loadDynamicRules(projectRoot, [targetPath, targetPath, targetPath]); + + // then + expect(result.rules).toHaveLength(1); + expect(counters).toEqual({ + findProjectRoot: 1, + findCandidates: 1, + readFile: 1, + }); + }); + + it("#given distinct target files in same directory #when loading dynamic rules #then candidate discovery is reused", () => { + // given + const firstTarget = join(projectRoot, "src", "first.ts"); + const secondTarget = join(projectRoot, "src", "second.ts"); + const thirdTarget = join(projectRoot, "src", "third.ts"); + const candidate = makeCandidate(); + let findCandidatesCalls = 0; + const deps = { + findProjectRoot: () => projectRoot, + findCandidates: () => { + findCandidatesCalls += 1; + return [candidate]; + }, + readFile: () => ["---", "globs: **/*.ts", "---", "", "Prefer strict TypeScript."].join("\n"), + } satisfies EngineDeps; + const engine = createEngine(defaultConfig(), deps); + + // when + const result = engine.loadDynamicRules(projectRoot, [firstTarget, secondTarget, thirdTarget]); + + // then + expect(result.rules).toHaveLength(1); + expect(findCandidatesCalls).toBe(1); + }); + + it("#given same rule content and target across loads #when loading dynamic rules repeats #then cached match decision is reused", () => { + // given + const targetPath = join(projectRoot, "src", "app.ts"); + const candidate = makeCandidate(); + let matchCalls = 0; + const deps = { + findProjectRoot: () => projectRoot, + findCandidates: () => [candidate], + readFile: () => ["---", "globs: **/*.ts", "---", "", "Prefer strict TypeScript."].join("\n"), + matchRule: (input) => { + matchCalls += 1; + return defaultMatchRule(input); + }, + } satisfies EngineDeps; + const engine = createEngine(defaultConfig(), deps); + + // when + const firstResult = engine.loadDynamicRules(projectRoot, [targetPath]); + const secondResult = engine.loadDynamicRules(projectRoot, [targetPath]); + + // then + expect(firstResult.rules).toHaveLength(1); + expect(secondResult.rules).toHaveLength(1); + expect(matchCalls).toBe(1); + }); + + it("#given same rule path changes body #when loading dynamic rules repeats #then cached match decision invalidates", () => { + // given + const targetPath = join(projectRoot, "src", "app.ts"); + const candidate = makeCandidate(); + let body = "Prefer strict TypeScript."; + let matchCalls = 0; + const deps = { + findProjectRoot: () => projectRoot, + findCandidates: () => [candidate], + readFile: () => ["---", "globs: **/*.ts", "---", "", body].join("\n"), + matchRule: (input) => { + matchCalls += 1; + return defaultMatchRule(input); + }, + } satisfies EngineDeps; + const engine = createEngine(defaultConfig(), deps); + + // when + engine.loadDynamicRules(projectRoot, [targetPath]); + body = "Prefer readonly TypeScript."; + engine.loadDynamicRules(projectRoot, [targetPath]); + + // then + expect(matchCalls).toBe(2); + }); + + it("#given same rule path changes frontmatter #when loading dynamic rules repeats #then cached match decision invalidates", () => { + // given + const targetPath = join(projectRoot, "src", "app.ts"); + const candidate = makeCandidate(); + let globs = "**/*.ts"; + let matchCalls = 0; + const deps = { + findProjectRoot: () => projectRoot, + findCandidates: () => [candidate], + readFile: () => ["---", `globs: ${globs}`, "---", "", "Prefer strict TypeScript."].join("\n"), + matchRule: (input) => { + matchCalls += 1; + return defaultMatchRule(input); + }, + } satisfies EngineDeps; + const engine = createEngine(defaultConfig(), deps); + + // when + const firstResult = engine.loadDynamicRules(projectRoot, [targetPath]); + globs = "**/*.tsx"; + const secondResult = engine.loadDynamicRules(projectRoot, [targetPath]); + + // then + expect(firstResult.rules).toHaveLength(1); + expect(secondResult.rules).toHaveLength(0); + expect(matchCalls).toBe(2); + }); + + it("#given same rule and different targets #when loading dynamic rules repeats #then target-specific decisions do not leak", () => { + // given + const sourceTarget = join(projectRoot, "src", "app.ts"); + const testTarget = join(projectRoot, "src", "app.test.ts"); + const candidate = makeCandidate(); + let matchCalls = 0; + const deps = { + findProjectRoot: () => projectRoot, + findCandidates: () => [candidate], + readFile: () => + ["---", 'globs: ["**/*.ts", "!**/*.test.ts"]', "---", "", "Prefer strict TypeScript."].join("\n"), + matchRule: (input) => { + matchCalls += 1; + return defaultMatchRule(input); + }, + } satisfies EngineDeps; + const engine = createEngine(defaultConfig(), deps); + + // when + const sourceResult = engine.loadDynamicRules(projectRoot, [sourceTarget]); + const testResult = engine.loadDynamicRules(projectRoot, [testTarget]); + + // then + expect(sourceResult.rules).toHaveLength(1); + expect(testResult.rules).toHaveLength(0); + expect(matchCalls).toBe(2); + }); +}); diff --git a/packages/omo-codex/plugin/components/rules/test/finder.test.ts b/packages/omo-codex/plugin/components/rules/test/finder.test.ts new file mode 100644 index 000000000..7b7fbe7b8 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/finder.test.ts @@ -0,0 +1,96 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { findRuleCandidates } from "../src/rules/finder.js"; +import type { RuleCandidate } from "../src/rules/types.js"; + +const tempDirectories: string[] = []; + +afterEach(() => { + for (const directory of tempDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function makeProject(): { projectRoot: string; homeRoot: string; targetPath: string } { + const projectRoot = mkdtempSync(join(tmpdir(), "codex-rules-finder-project-")); + const homeRoot = mkdtempSync(join(tmpdir(), "codex-rules-finder-home-")); + tempDirectories.push(projectRoot, homeRoot); + mkdirSync(join(projectRoot, "src", ".omo", "rules"), { recursive: true }); + mkdirSync(join(projectRoot, ".omo", "rules"), { recursive: true }); + mkdirSync(join(homeRoot, ".opencode", "rules"), { recursive: true }); + mkdirSync(join(homeRoot, ".config", "opencode"), { recursive: true }); + writeFileSync(join(projectRoot, "package.json"), JSON.stringify({ name: "fixture" })); + writeFileSync(join(projectRoot, "AGENTS.md"), "Project rule\n"); + writeFileSync(join(projectRoot, "src", ".omo", "rules", "local.md"), "Local rule\n"); + writeFileSync(join(projectRoot, ".omo", "rules", "root.md"), "Root rule\n"); + writeFileSync(join(homeRoot, ".opencode", "rules", "global.md"), "Global rule\n"); + writeFileSync(join(homeRoot, ".config", "opencode", "AGENTS.md"), "Home rule\n"); + const targetPath = join(projectRoot, "src", "app.ts"); + writeFileSync(targetPath, "export const app = true;\n"); + return { projectRoot, homeRoot, targetPath }; +} + +function candidateSummary(candidate: RuleCandidate): string { + return `${candidate.source}:${candidate.distance}:${candidate.relativePath}`; +} + +describe("findRuleCandidates", () => { + it("#given project and user-home rules #when target file is inside project #then candidates keep source distance", () => { + // given + const { projectRoot, homeRoot, targetPath } = makeProject(); + + // when + const candidates = findRuleCandidates({ projectRoot, targetFile: targetPath, homeDir: homeRoot }); + + // then + expect(candidates.map(candidateSummary)).toEqual([ + ".omo/rules:0:src/.omo/rules/local.md", + ".omo/rules:1:.omo/rules/root.md", + "AGENTS.md:1:AGENTS.md", + "~/.opencode/rules:9999:.opencode/rules/global.md", + "~/.config/opencode/AGENTS.md:9999:.config/opencode/AGENTS.md", + ]); + }); + + it("#given disabled source #when finding candidates #then matching source is omitted", () => { + // given + const { projectRoot, homeRoot, targetPath } = makeProject(); + + // when + const candidates = findRuleCandidates({ + projectRoot, + targetFile: targetPath, + homeDir: homeRoot, + disabledSources: new Set([".omo/rules", "~/.opencode/rules"]), + }); + + // then + expect(candidates.map(candidateSummary)).toEqual([ + "AGENTS.md:1:AGENTS.md", + "~/.config/opencode/AGENTS.md:9999:.config/opencode/AGENTS.md", + ]); + }); + + it("#given skip user home #when finding candidates #then only project rules are returned", () => { + // given + const { projectRoot, homeRoot, targetPath } = makeProject(); + + // when + const candidates = findRuleCandidates({ + projectRoot, + targetFile: targetPath, + homeDir: homeRoot, + skipUserHome: true, + }); + + // then + expect(candidates.map(candidateSummary)).toEqual([ + ".omo/rules:0:src/.omo/rules/local.md", + ".omo/rules:1:.omo/rules/root.md", + "AGENTS.md:1:AGENTS.md", + ]); + }); +}); diff --git a/packages/omo-codex/plugin/components/rules/test/matcher.test.ts b/packages/omo-codex/plugin/components/rules/test/matcher.test.ts new file mode 100644 index 000000000..5a4b5c223 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/matcher.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from "vitest"; + +import { matchRule, normalizeGlobs } from "../src/rules/matcher.js"; +import type { RuleFrontmatter } from "../src/rules/types.js"; + +function matchFrontmatter( + frontmatter: RuleFrontmatter, + pathBases: { + projectRelative: string; + scopeRelative?: string; + basename?: string; + }, +): ReturnType { + const scopeRelative = pathBases.scopeRelative; + const pathBase = { + projectRelative: pathBases.projectRelative, + basename: pathBases.basename ?? pathBases.projectRelative.split("/").at(-1) ?? pathBases.projectRelative, + ...(scopeRelative === undefined ? {} : { scopeRelative }), + }; + return matchRule({ + frontmatter, + isSingleFile: false, + pathBases: pathBase, + }); +} + +function matchGlobs(globs: string | string[], projectRelative: string): boolean { + return matchFrontmatter({ globs } satisfies RuleFrontmatter, { projectRelative }).matched; +} + +describe("matchRule", () => { + it("#given single-file rule #when matching any target #then it always matches", () => { + // given + const frontmatter = {} satisfies RuleFrontmatter; + + // when + const result = matchRule({ + frontmatter, + isSingleFile: true, + pathBases: { projectRelative: "docs/readme.md", basename: "readme.md" }, + }); + + // then + expect(result).toEqual({ matched: true, reason: "single-file" }); + }); + + it("#given always apply rule #when no glob is configured #then it matches", () => { + // given + const frontmatter = { alwaysApply: true } satisfies RuleFrontmatter; + + // when + const result = matchFrontmatter(frontmatter, { projectRelative: "src/app.ts" }); + + // then + expect(result).toEqual({ matched: true, reason: "alwaysApply" }); + }); + + it("#given rule without patterns #when target is checked #then no match is returned", () => { + // given + const frontmatter = {} satisfies RuleFrontmatter; + + // when + const result = matchFrontmatter(frontmatter, { projectRelative: "src/app.ts" }); + + // then + expect(result).toEqual({ matched: false, reason: { kind: "no-match" } }); + }); + + it("#given recursive glob #when target is nested #then matches without runtime dependencies", () => { + // given + const globs = "**/*.ts"; + + // when + const matched = matchGlobs(globs, "src/features/app.ts"); + + // then + expect(matched).toBe(true); + }); + + it("#given paths alias #when target matches #then glob match is returned", () => { + // given + const frontmatter = { paths: "src/**/*.ts" } satisfies RuleFrontmatter; + + // when + const result = matchFrontmatter(frontmatter, { projectRelative: "src/features/app.ts" }); + + // then + expect(result).toEqual({ matched: true, reason: { kind: "glob", pattern: "src/**/*.ts" } }); + }); + + it("#given applyTo alias #when basename matches #then glob match is returned", () => { + // given + const frontmatter = { applyTo: "*.md" } satisfies RuleFrontmatter; + + // when + const result = matchFrontmatter(frontmatter, { projectRelative: "docs/README.md" }); + + // then + expect(result).toEqual({ matched: true, reason: { kind: "glob", pattern: "*.md" } }); + }); + + it("#given scope-relative target #when scoped path matches #then glob match is returned", () => { + // given + const frontmatter = { globs: "components/**/*.tsx" } satisfies RuleFrontmatter; + + // when + const result = matchFrontmatter(frontmatter, { + projectRelative: "packages/ui/components/button.tsx", + scopeRelative: "components/button.tsx", + }); + + // then + expect(result).toEqual({ matched: true, reason: { kind: "glob", pattern: "components/**/*.tsx" } }); + }); + + it("#given backslash glob and target #when matching #then paths are normalized", () => { + // given + const frontmatter = { globs: "src\\**\\*.ts" } satisfies RuleFrontmatter; + + // when + const result = matchFrontmatter(frontmatter, { projectRelative: "src\\features\\app.ts" }); + + // then + expect(result).toEqual({ matched: true, reason: { kind: "glob", pattern: "src/**/*.ts" } }); + }); + + it("#given multiple positive globs #when later glob matches #then matching pattern is reported", () => { + // given + const frontmatter = { globs: ["docs/**/*.md", "src/**/*.ts"] } satisfies RuleFrontmatter; + + // when + const result = matchFrontmatter(frontmatter, { projectRelative: "src/features/app.ts" }); + + // then + expect(result).toEqual({ matched: true, reason: { kind: "glob", pattern: "src/**/*.ts" } }); + }); + + it("#given negative glob #when target is excluded #then no match is returned", () => { + // given + const globs = ["**/*.ts", "!**/*.test.ts"]; + + // when + const matched = matchGlobs(globs, "src/features/app.test.ts"); + + // then + expect(matched).toBe(false); + }); + + it("#given question-mark glob #when one filename character differs #then target matches", () => { + // given + const globs = "src/app-?.ts"; + + // when + const matched = matchGlobs(globs, "src/app-a.ts"); + + // then + expect(matched).toBe(true); + }); + + it("#given brace glob #when target extension is listed #then matches", () => { + // given + const globs = "src/**/*.{ts,tsx}"; + + // when + const matched = matchGlobs(globs, "src/features/app.tsx"); + + // then + expect(matched).toBe(true); + }); + + it("#given character class glob #when matching listed extension #then target matches", () => { + // given + const globs = "src/**/*.[tj]s"; + + // when + const matched = matchGlobs(globs, "src/features/app.ts"); + + // then + expect(matched).toBe(true); + }); + + it("#given extglob pattern #when matching allowed extension #then target matches", () => { + // given + const globs = "src/**/*.@(ts|tsx)"; + + // when + const matched = matchGlobs(globs, "src/features/app.tsx"); + + // then + expect(matched).toBe(true); + }); + + it("#given duplicate normalized patterns #when normalizing #then first unique pattern order is kept", () => { + // given + const frontmatter = { + globs: ["src\\**\\*.ts", "src/**/*.ts", "!src/**/*.test.ts"], + paths: "!src/**/*.test.ts", + } satisfies RuleFrontmatter; + + // when + const patterns = normalizeGlobs(frontmatter); + + // then + expect(patterns).toEqual(["src/**/*.ts", "!src/**/*.test.ts"]); + }); +}); diff --git a/packages/omo-codex/plugin/components/rules/test/package-smoke.test.ts b/packages/omo-codex/plugin/components/rules/test/package-smoke.test.ts new file mode 100644 index 000000000..996866d23 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/package-smoke.test.ts @@ -0,0 +1,144 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +type PackageJson = { + readonly type: string; + readonly packageManager: string; + readonly bin: Record; + readonly dependencies?: Record; +}; + +type PluginJson = { + readonly hooks: string; +}; + +type HookCommand = { + readonly command: string; +}; + +type HookEntry = { + readonly matcher?: string; + readonly hooks: readonly HookCommand[]; +}; + +type HooksJson = { + readonly hooks: Record; +}; + +function readPackageJson(path: string): PackageJson { + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + if (!isPackageJson(parsed)) throw new TypeError(`Invalid package metadata: ${path}`); + return parsed; +} + +function readPluginJson(path: string): PluginJson { + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + if (!isPluginJson(parsed)) throw new TypeError(`Invalid plugin metadata: ${path}`); + return parsed; +} + +function readHooksJson(path: string): HooksJson { + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + if (!isHooksJson(parsed)) throw new TypeError(`Invalid hooks metadata: ${path}`); + return parsed; +} + +describe("plugin package metadata", () => { + it("#given packaged plugin files #when validating entrypoints #then hook commands use portable plugin root interpolation", () => { + // given + const packageJson = readPackageJson("package.json"); + const pluginJson = readPluginJson(".codex-plugin/plugin.json"); + const hooksJson = readHooksJson("hooks/hooks.json"); + const cliSource = readFileSync("src/cli.ts", "utf8"); + + // when + const hookConfig = hooksJson.hooks; + const pluginRoot = ["$", "{PLUGIN_ROOT}"].join(""); + const commands = [ + hookConfig["SessionStart"]?.[0]?.hooks[0]?.command, + hookConfig["UserPromptSubmit"]?.[0]?.hooks[0]?.command, + hookConfig["PostToolUse"]?.[0]?.hooks[0]?.command, + hookConfig["PostCompact"]?.[0]?.hooks[0]?.command, + ]; + const postToolUseMatcher = hookConfig["PostToolUse"]?.[0]?.matcher ?? ""; + + // then + expect(packageJson.type).toBe("module"); + expect(packageJson.packageManager).toBe("npm@11.12.1"); + expect(packageJson.dependencies ?? {}).toEqual({ picomatch: "^4.0.3" }); + expect(packageJson.bin["codex-rules"]).toBe("./dist/cli.js"); + expect(pluginJson.hooks).toBe("./hooks/hooks.json"); + expect(cliSource.startsWith("#!/usr/bin/env node")).toBe(true); + expect(commands).toEqual([ + `node "${pluginRoot}/dist/cli.js" hook session-start`, + `node "${pluginRoot}/dist/cli.js" hook user-prompt-submit`, + `node "${pluginRoot}/dist/cli.js" hook post-tool-use`, + `node "${pluginRoot}/dist/cli.js" hook post-compact`, + ]); + expect(postToolUseMatcher).toBe("^apply_patch$"); + const postToolUseMatcherRegex = new RegExp(postToolUseMatcher); + expect(postToolUseMatcherRegex.test("apply_patch")).toBe(true); + expect( + [ + "read", + "Read", + "read_file", + "mcp__filesystem__read_file", + "mcp__filesystem__read_multiple_files", + "mcp__filesystem__write_file", + "mcp__filesystem__edit_file", + "write", + "Write", + "edit", + "Edit", + "multi_edit", + "MultiEdit", + "multiedit", + "exec_command", + "shell_command", + "bash", + "Bash", + ].some((toolName) => postToolUseMatcherRegex.test(toolName)), + ).toBe(false); + }); +}); + +function isPackageJson(value: unknown): value is PackageJson { + if (!isRecord(value)) return false; + const dependencies = value["dependencies"]; + return ( + value["type"] === "module" && + value["packageManager"] === "npm@11.12.1" && + isStringRecord(value["bin"]) && + (dependencies === undefined || isRecord(dependencies)) + ); +} + +function isPluginJson(value: unknown): value is PluginJson { + return isRecord(value) && typeof value["hooks"] === "string"; +} + +function isHooksJson(value: unknown): value is HooksJson { + if (!isRecord(value) || !isRecord(value["hooks"])) return false; + return Object.values(value["hooks"]).every(isHookEntries); +} + +function isHookEntries(value: unknown): value is readonly HookEntry[] { + return Array.isArray(value) && value.every(isHookEntry); +} + +function isHookEntry(value: unknown): value is HookEntry { + return isRecord(value) && Array.isArray(value["hooks"]) && value["hooks"].every(isHookCommand); +} + +function isHookCommand(value: unknown): value is HookCommand { + return isRecord(value) && typeof value["command"] === "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/rules/test/scanner.test.ts b/packages/omo-codex/plugin/components/rules/test/scanner.test.ts new file mode 100644 index 000000000..fb178639f --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/scanner.test.ts @@ -0,0 +1,63 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { scanRuleFiles } from "../src/rules/scanner.js"; + +const tempDirectories: string[] = []; + +afterEach(() => { + for (const directory of tempDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("scanRuleFiles", () => { + it("#given more rule files than max #when scanning #then returns only capped files", () => { + // given + const root = mkdtempSync(join(tmpdir(), "codex-rules-scanner-")); + tempDirectories.push(root); + for (let index = 0; index < 5; index += 1) { + writeFileSync(join(root, `rule-${index}.md`), `Rule ${index}\n`); + } + + // when + const files = scanRuleFiles({ rootDir: root, maxFiles: 2 }); + + // then + expect(files).toHaveLength(2); + }); + + it("#given rule files and an excluded directory #when scanning #then returns sorted non-excluded files", () => { + // given + const root = mkdtempSync(join(tmpdir(), "codex-rules-scanner-")); + tempDirectories.push(root); + mkdirSync(join(root, "dist"), { recursive: true }); + writeFileSync(join(root, "beta.md"), "Beta\n"); + writeFileSync(join(root, "alpha.md"), "Alpha\n"); + writeFileSync(join(root, "dist", "ignored.md"), "Ignored\n"); + + // when + const files = scanRuleFiles({ rootDir: root }); + + // then + expect(files.map((file) => file.path)).toEqual([join(root, "alpha.md"), join(root, "beta.md")]); + }); + + it("#given symlink loop #when scanning #then traversal terminates without duplicate files", () => { + // given + const root = mkdtempSync(join(tmpdir(), "codex-rules-scanner-")); + tempDirectories.push(root); + const nested = join(root, "nested"); + mkdirSync(nested, { recursive: true }); + writeFileSync(join(root, "root.md"), "Root\n"); + symlinkSync(root, join(nested, "loop")); + + // when + const files = scanRuleFiles({ rootDir: root }); + + // then + expect(files.map((file) => file.path)).toEqual([join(root, "root.md")]); + }); +}); diff --git a/packages/omo-codex/plugin/components/rules/test/tool-paths.test.ts b/packages/omo-codex/plugin/components/rules/test/tool-paths.test.ts new file mode 100644 index 000000000..17be09442 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/test/tool-paths.test.ts @@ -0,0 +1,198 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { type CodexPostToolUseLike, extractCodexToolPaths } from "../src/tool-paths.js"; + +const tempDirectories: string[] = []; + +afterEach(() => { + for (const directory of tempDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function makeProject(): string { + const root = mkdtempSync(path.join(tmpdir(), "codex-rules-paths-")); + tempDirectories.push(root); + mkdirSync(path.join(root, "src"), { recursive: true }); + writeFileSync(path.join(root, "src", "app.ts"), "export const app = true;\n"); + return root; +} + +function postToolUse(input: { toolName: string; toolInput?: unknown; toolResponse?: unknown }): CodexPostToolUseLike { + return { + tool_name: input.toolName, + tool_input: input.toolInput ?? {}, + tool_response: input.toolResponse ?? { text: "ok" }, + }; +} + +describe("extractCodexToolPaths", () => { + it("#given filesystem read payload #when extracting #then returns resolved path", () => { + // given + const root = makeProject(); + + // when + const paths = extractCodexToolPaths( + postToolUse({ + toolName: "mcp__filesystem__read_file", + toolInput: { path: "src/app.ts" }, + }), + root, + ); + + // then + expect(paths).toEqual([path.join(root, "src", "app.ts")]); + }); + + it("#given apply_patch payload #when extracting #then returns patched file paths", () => { + // given + const root = makeProject(); + + // when + const paths = extractCodexToolPaths( + postToolUse({ + toolName: "apply_patch", + toolInput: { + command: [ + "*** Begin Patch", + "*** Update File: src/app.ts", + "@@", + "+export const changed = true;", + "*** End Patch", + ].join("\n"), + }, + }), + root, + ); + + // then + expect(paths).toEqual([path.join(root, "src", "app.ts")]); + }); + + it("#given apply_patch add update and move payload #when extracting #then returns each target once", () => { + // given + const root = makeProject(); + + // when + const paths = extractCodexToolPaths( + postToolUse({ + toolName: "apply_patch", + toolInput: { + command: [ + "*** Begin Patch", + "*** Add File: src/new.ts", + "+export const created = true;", + "*** Update File: src/app.ts", + "*** Move to: src/moved.ts", + "@@", + "-export const app = true;", + "+export const moved = true;", + "*** Update File: src/moved.ts", + "@@", + "-export const moved = true;", + "+export const moved = false;", + "*** End Patch", + ].join("\n"), + }, + }), + root, + ); + + // then + expect(paths).toEqual([ + path.join(root, "src", "new.ts"), + path.join(root, "src", "app.ts"), + path.join(root, "src", "moved.ts"), + ]); + }); + + it("#given mcp write-file payload #when extracting #then returns resolved path", () => { + // given + const root = makeProject(); + + // when + const paths = extractCodexToolPaths( + postToolUse({ + toolName: "mcp__filesystem__write_file", + toolInput: { path: "src/app.ts", content: "export const app = true;\n" }, + }), + root, + ); + + // then + expect(paths).toEqual([path.join(root, "src", "app.ts")]); + }); + + it("#given mcp edit-file payload #when extracting #then returns resolved path", () => { + // given + const root = makeProject(); + + // when + const paths = extractCodexToolPaths( + postToolUse({ + toolName: "mcp__filesystem__edit_file", + toolInput: { path: "src/app.ts", edits: [] }, + }), + root, + ); + + // then + expect(paths).toEqual([path.join(root, "src", "app.ts")]); + }); + + it("#given mcp read-multiple-files payload #when extracting #then returns all resolved paths", () => { + // given + const root = makeProject(); + writeFileSync(path.join(root, "src", "other.ts"), "export const other = true;\n"); + + // when + const paths = extractCodexToolPaths( + postToolUse({ + toolName: "mcp__filesystem__read_multiple_files", + toolInput: { paths: ["src/app.ts", "src/other.ts"] }, + }), + root, + ); + + // then + expect(paths).toEqual([path.join(root, "src", "app.ts"), path.join(root, "src", "other.ts")]); + }); + + it("#given shell command payload #when extracting #then returns only existing file tokens", () => { + // given + const root = makeProject(); + + // when + const paths = extractCodexToolPaths( + postToolUse({ + toolName: "exec_command", + toolInput: { cmd: "sed -n '1,80p' src/app.ts src/missing.ts", workdir: root }, + }), + "/tmp", + ); + + // then + expect(paths).toEqual([path.join(root, "src", "app.ts")]); + }); + + it("#given failed tracked tool payload #when extracting #then returns no paths", () => { + // given + const root = makeProject(); + + // when + const paths = extractCodexToolPaths( + postToolUse({ + toolName: "read", + toolInput: { path: "src/app.ts" }, + toolResponse: { is_error: true }, + }), + root, + ); + + // then + expect(paths).toEqual([]); + }); +}); diff --git a/packages/omo-codex/plugin/components/rules/tsconfig.build.json b/packages/omo-codex/plugin/components/rules/tsconfig.build.json new file mode 100644 index 000000000..5b5bbcafd --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/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/rules/tsconfig.json b/packages/omo-codex/plugin/components/rules/tsconfig.json new file mode 100644 index 000000000..342229c02 --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/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/components/rules/vitest.config.ts b/packages/omo-codex/plugin/components/rules/vitest.config.ts new file mode 100644 index 000000000..c4fddb41c --- /dev/null +++ b/packages/omo-codex/plugin/components/rules/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + pool: "threads", + }, +}); diff --git a/packages/omo-codex/plugin/components/ultragoal/.gitattributes b/packages/omo-codex/plugin/components/ultragoal/.gitattributes new file mode 100644 index 000000000..9363fdb74 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/.gitattributes @@ -0,0 +1,13 @@ +# Normalize line endings: store LF in git, check out LF on every platform. +# Required so biome's --check passes on Windows (default core.autocrlf=true). +* text=auto eol=lf + +# Explicit binary types +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.zip binary +*.tgz binary +*.gz binary diff --git a/packages/omo-codex/plugin/components/ultragoal/.gitignore b/packages/omo-codex/plugin/components/ultragoal/.gitignore new file mode 100644 index 000000000..e5f7145b7 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +*.log +.DS_Store +coverage/ +.vitest/ diff --git a/packages/omo-codex/plugin/components/ultragoal/AGENTS.md b/packages/omo-codex/plugin/components/ultragoal/AGENTS.md new file mode 100644 index 000000000..bc76bb207 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/AGENTS.md @@ -0,0 +1,48 @@ +# Repository Conventions + +Conventions for human contributors and AI agents working on this repository. + +## Stack + +- Node >=20 runtime. +- npm package manager. +- TypeScript 6 strict mode. +- Biome 2 linting and formatting. +- Vitest 4 test runner. + +## Forbidden + +- No `as any` or `as unknown`. +- No `@ts-ignore` or `@ts-expect-error`. +- No enums. +- No non-null assertions. +- No default exports. `vitest.config.ts` is exempt because the framework requires that shape. + +## File Ceiling + +- Keep each `src/` TypeScript file under 250 pure LOC. +- Split by responsibility before a file reaches the ceiling. + +## Test Discipline + +- Use Vitest with nested `describe` names in `#given`, `#when`, and `#then` form, or inline `// given`, `// when`, and `// then` comments. +- Never use Arrange-Act-Assert comments. +- Keep fixtures in `test/fixtures/`. + +## Commit Style + +- Use Conventional Commits. +- Keep commits atomic. +- Each commit's tests and build must pass on its own. + +## Branding + +- Repo artifacts live under `.omo/ultragoal/` paths. +- Environment variables use the `OMO_ULTRAGOAL_*` prefix. +- CLI commands use the `omo ultragoal` form. +- Do not use any alternate legacy CLI alias anywhere. + +## Build and Hooks + +- Build output goes to `dist/`. +- `hooks/hooks.json` runs `node ${PLUGIN_ROOT}/dist/cli.js hook user-prompt-submit`. diff --git a/packages/omo-codex/plugin/components/ultragoal/CHANGELOG.md b/packages/omo-codex/plugin/components/ultragoal/CHANGELOG.md new file mode 100644 index 000000000..0d9de92ad --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## [0.1.0] - unreleased + +- Initial scaffold of codex-ultragoal plugin. diff --git a/packages/omo-codex/plugin/components/ultragoal/LICENSE b/packages/omo-codex/plugin/components/ultragoal/LICENSE new file mode 100644 index 000000000..09aac3c3b --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Yeongyu Kim + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/omo-codex/plugin/components/ultragoal/NOTICE b/packages/omo-codex/plugin/components/ultragoal/NOTICE new file mode 100644 index 000000000..4b0ea0736 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/NOTICE @@ -0,0 +1,6 @@ +codex-ultragoal + +This package ports the oh-my-codex ultragoal feature into a Codex plugin repository. + +The plugin targets Codex plugin manifests and plugin-bundled lifecycle hooks. +The orchestration engine is added in later port waves. diff --git a/packages/omo-codex/plugin/components/ultragoal/README.md b/packages/omo-codex/plugin/components/ultragoal/README.md new file mode 100644 index 000000000..ff7ce78ad --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/README.md @@ -0,0 +1,78 @@ +# codex-ultragoal + +[![ci](https://img.shields.io/badge/ci-pending-lightgrey.svg)](#) [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) + +Codex plugin scaffold for durable repo-native multi-goal orchestration with embedded success criteria and observable evidence audit. + +## Behavior + +| Subcommand | Purpose | +|------------|---------| +| `omo ultragoal create-goals` | Create repo-native goals from a brief and seed criteria. | +| `omo ultragoal record-evidence` | Record observable evidence for the active criterion. | +| `omo ultragoal criteria` | Inspect or revise goal success criteria. | +| `omo ultragoal complete-goals` | Complete eligible goals after criteria pass. | +| `omo ultragoal checkpoint` | Refuse completion until criteria and evidence gates pass. | +| `omo ultragoal steer` | Apply steering updates to the plan. | +| `omo ultragoal status` | Report active goal, criteria, and evidence state. | + +Wave 1 is scaffold only. Command behavior lands in later waves. + +## Codex Plugin + +The plugin ships: + +- `.codex-plugin/plugin.json` for Codex plugin discovery. +- `hooks/hooks.json` for the `UserPromptSubmit` hook. +- `skills/ultragoal/` as the future skill directory. + +The hook command is: + +```bash +node "${PLUGIN_ROOT}/dist/cli.js" hook user-prompt-submit +``` + +No MCP server or Codex tool is exposed in this scaffold. + +## Local Development + +```bash +npm install +npm test +npm run typecheck +npm run check +npm pack --dry-run +``` + +## Local Codex Installation + +From the marketplace root containing this plugin: + +```bash +codex plugin marketplace add /path/to/codex-plugins +node /path/to/codex-plugins/scripts/install-local.mjs /path/to/codex-plugins +``` + +If your local Codex build exposes plugin install commands, you can install from the UI or CLI instead. For older local builds, the marketplace installer builds and copies the plugin into `~/.codex/plugins/cache//omo/0.1.0`, installs runtime dependencies there, and enables: + +```toml +[features] +plugins = true +plugin_hooks = true + +[plugins."omo@code-yeongyu-codex-plugins"] +enabled = true +``` + +## Privacy + +This plugin runs locally. The scaffold does not call a network service by itself. + +## License + +[MIT](LICENSE). + +## Related + +- [oh-my-codex](https://github.com/code-yeongyu/oh-my-codex) - source project for the ultragoal port. +- [codex-plugins](https://github.com/code-yeongyu/codex-plugins) - local Codex plugin marketplace. diff --git a/packages/omo-codex/plugin/components/ultragoal/biome.json b/packages/omo-codex/plugin/components/ultragoal/biome.json new file mode 100644 index 000000000..5aa1a0dcc --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/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/ultragoal/hooks/hooks.json b/packages/omo-codex/plugin/components/ultragoal/hooks/hooks.json new file mode 100644 index 000000000..f3e52faca --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/hooks/hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook user-prompt-submit", + "timeout": 10, + "statusMessage": "checking ultragoal steering" + } + ] + } + ] + } +} diff --git a/packages/omo-codex/plugin/components/ultragoal/package.json b/packages/omo-codex/plugin/components/ultragoal/package.json new file mode 100644 index 000000000..77782d91c --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/package.json @@ -0,0 +1,55 @@ +{ + "name": "@code-yeongyu/codex-ultragoal", + "version": "0.1.0", + "description": "Codex plugin: durable repo-native multi-goal orchestration with embedded success criteria and observable evidence audit.", + "type": "module", + "packageManager": "npm@11.12.1", + "license": "MIT", + "homepage": "https://github.com/code-yeongyu/codex-ultragoal", + "repository": { + "type": "git", + "url": "git+https://github.com/code-yeongyu/codex-ultragoal.git" + }, + "bugs": { + "url": "https://github.com/code-yeongyu/codex-ultragoal/issues" + }, + "keywords": [ + "codex", + "codex-plugin", + "ultragoal", + "goal-mode", + "orchestration", + "evidence", + "typescript" + ], + "bin": { + "omo": "./dist/cli.js" + }, + "files": [ + "dist", + "hooks", + "skills", + "LICENSE", + "NOTICE", + "README.md", + "CHANGELOG.md" + ], + "scripts": { + "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" + }, + "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/ultragoal/skills/ultragoal/.gitkeep b/packages/omo-codex/plugin/components/ultragoal/skills/ultragoal/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/omo-codex/plugin/components/ultragoal/skills/ultragoal/SKILL.md b/packages/omo-codex/plugin/components/ultragoal/skills/ultragoal/SKILL.md new file mode 100644 index 000000000..b047917d4 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/skills/ultragoal/SKILL.md @@ -0,0 +1,143 @@ +--- +name: ultragoal +description: Durable repo-native multi-goal plans with embedded success criteria and evidence audit. +--- + +## Role +Expert goal orchestration agent. Plan multi-goal work that survives across turns and sessions. +Use GPT-5.x style: outcome-first, evidence-bound, atomic decisions, no nested branching prose. + +## Goal +Deliver every goal in `.omo/ultragoal/goals.json` end-to-end. +Prove EVERY success criterion with captured observable evidence from the real surface. +Audit each pass, fail, block, steering change, and checkpoint in `.omo/ultragoal/ledger.jsonl`. + +## Artifacts +- `.omo/ultragoal/brief.md`: original brief and durable constraints. +- `.omo/ultragoal/goals.json`: goals with embedded `successCriteria` per goal. +- `.omo/ultragoal/ledger.jsonl`: append-only audit trail. +- Read artifacts before resuming, steering, or checkpointing. +- Never invent state outside `.omo/ultragoal` artifacts or `omo ultragoal status --json`. + +## Bootstrap +Do all three steps before execution. No edits, goal tools, or checkpointing before bootstrap completes. + +### 1. Create goals from the brief +Run one form: +```sh +omo ultragoal create-goals --brief "" --json +omo ultragoal create-goals --brief-file --json +cat | omo ultragoal create-goals --from-stdin --json +``` +Write state through the CLI path. Do not hand-edit state files. + +### 2. Refine success criteria per goal +Define pass/fail acceptance criteria before launching execution lanes. Include the command, artifact, or manual check that will prove success. +Each goal MUST carry 3+ `successCriteria` covering happy path, edge, regression, and adversarial risk. +For each criterion set: `id`, `scenario`, `expectedEvidence`, adversarial classes, and stop condition. +Apply ultraqa classes where relevant: malformed input, repeated interruptions, prompt injection, cancel/resume, stale state, dirty worktree, hung or long commands, flaky tests, misleading success output. +Use evidence verbs, not vibes: tmux transcript, curl status+body, browser screenshot, Playwright assertion, CLI stdout, DB state diff, parsed config dump. +"Tests pass" is supporting signal, not completion proof. +Record manual QA notes when behavior is user-visible. +Revise any criterion that lacks observable `expectedEvidence` before execution. + +### 3. Inspect state +Run `omo ultragoal status --json`. +Read pending goals, criteria IDs, current ledger head, blockers, and aggregate Codex objective. + +## Execution Loop +Loop per goal. Cap at 5 cycles per goal. Cap identical same-criterion failures at 3. + +### Acquire Next Goal +1. Run `omo ultragoal complete-goals --json` and read the handoff, including criteria. +2. Call `get_goal` and inspect active Codex state. +3. Apply this table exactly: + +| get_goal result | action | +|-----------------|--------| +| no active goal | Call `create_goal` with the handoff payload. | +| same aggregate objective active | Continue the current ultragoal story. | +| different goal active | STOP. Checkpoint blocked and surface the conflict. | +4. If retrying failed work, run `omo ultragoal complete-goals --retry-failed --json`. +5. Never create a second Codex goal for the same aggregate objective. + +### Per-Criterion Cycle +1. PLAN: read `criterion.scenario`, `criterion.expectedEvidence`, prior ledger entries, and safety bounds. +2. Register atomic todos: `path: for - verify by `. +3. EXECUTE: do one bounded change or check, then exercise the real surface named by the criterion. +4. CAPTURE: collect actual observable evidence: transcript, stdout, screenshot, assertion, status+body, diff, or parsed dump. +5. RECORD exactly one result: + - PASS: `omo ultragoal record-evidence --goal-id --criterion-id --status pass --evidence "" --json` + - FAIL: `omo ultragoal record-evidence --goal-id --criterion-id --status fail --evidence "" --notes "" --json` + - BLOCKED: `omo ultragoal record-evidence --goal-id --criterion-id --status blocked --evidence "" --notes "" --json` +6. If actual does not match expected, diagnose, fix minimally, and rerun the SAME criterion. +7. After 3 same-criterion failures, exit the goal with diagnosis. +8. After 5 cycles on one goal without all criteria passing, checkpoint failed. +9. Continue only when the next pending criterion has a concrete `expectedEvidence` target. + +### Goal Completion +1. Confirm every criterion is `pass` with `omo ultragoal criteria --goal-id --json`. +2. Call `get_goal` for a fresh snapshot. +3. Run `omo ultragoal checkpoint --goal-id --status complete --evidence "" --codex-goal-json --json`. +4. If blocked or failed, checkpoint with `--status blocked` or `--status failed` and include diagnosis evidence. +5. If this is the final goal, run the final quality gate first and pass `--quality-gate-json`. + +## Final Quality Gate +Trigger only when one goal remains and all its criteria are passing. +1. Run targeted verification for changed behavior. +2. Run `ai-slop-cleaner` on changed files. If no relevant edits exist, record a passed no-op cleaner report. +3. Rerun verification after cleanup. +4. Run `$code-review`. +5. Clean review means `codeReview.recommendation == "APPROVE"` and `codeReview.architectStatus == "CLEAR"`. +6. If review is non-clean, run `omo ultragoal record-review-blockers --goal-id --title "<...>" --objective "<...>" --evidence "" --codex-goal-json --json`. +7. If clean, checkpoint final completion: +```sh +omo ultragoal checkpoint --goal-id --status complete --evidence "" --codex-goal-json --quality-gate-json --json +``` +`--quality-gate-json` shape: +```json +{ + "aiSlopCleaner": { "status": "passed", "evidence": "cleaner report" }, + "verification": { "status": "passed", "commands": ["npm test"], "evidence": "post-cleaner verification" }, + "codeReview": { "recommendation": "APPROVE", "architectStatus": "CLEAR", "evidence": "review synthesis" }, + "criteriaCoverage": { "totalCriteria": N, "passCount": N, "adversarialClassesCovered": ["malformed_input", "..."] } +} +``` + +## Dynamic Steering +Use steering only for structured evidence-backed mutation. Reject natural-language steering requests. + +| Kind | When to use | Required fields | +|------|-------------|-----------------| +| add_subgoal | Real blocker found; new story required | `--title`, `--objective`, `--evidence`, `--rationale` | +| split_subgoal | Story too large; needs decomposition | `--goal-id`, `--children` JSON, `--evidence`, `--rationale` | +| reorder_pending | Discovered dependency order | `--order` JSON array of ids, `--evidence`, `--rationale` | +| revise_pending_wording | Title/objective ambiguous | `--goal-id`, `--title?`, `--objective?`, `--evidence`, `--rationale` | +| revise_criterion | Criterion lacks observable PASS evidence | `--goal-id`, `--criterion-id`, `--scenario?`, `--expected-evidence?`, `--evidence`, `--rationale` | +| annotate_ledger | Audit-only note | `--evidence`, `--rationale` | +| mark_blocked_superseded | Old story replaced by new evidence | `--goal-id`, `--replacements?`, `--evidence`, `--rationale` | + +Command form: `omo ultragoal steer --kind [] --evidence "<...>" --rationale "<...>" --json`. +Structured prompt directives accepted: `OMO_ULTRAGOAL_STEER: { ... }`, `omo.ultragoal.steer: {...}`, `omo ultragoal steer: {...}`. + +## Constraints +1. NEVER call `update_goal` mid-aggregate; only on final story after the quality gate passes. +2. NEVER call `create_goal` when `get_goal` shows a different active goal. +3. NEVER mark `criterion.status == "pass"` without captured observable evidence in `record-evidence`. +4. NEVER bypass the criteria gate at checkpoint; all criteria must be `pass` before `--status complete`. +5. Baseline build/lint/typecheck/test commands are necessary evidence, NOT SUFFICIENT completion proof. Criteria coverage with observable evidence is the gate. +6. Treat `.omo/ultragoal/ledger.jsonl` as the durable audit trail; checkpoint after every success or failure. +7. Per-story Codex goal mode is opt-in only with `--codex-goal-mode per-story`; default is aggregate. +8. Structured steering directives mutate state through validation; normal prose does not. +9. Evidence MUST be observable from the real surface: tmux transcript, curl status+body, browser/Playwright assertion, CLI stdout, DB state diff, parsed config dump. +10. Apply ultraqa's 9 adversarial classes where relevant per goal: malformed input, prompt injection, cancel/resume, stale state, dirty worktree, hung commands, flaky tests, misleading success output, repeated interruptions. +11. After completing an aggregate ultragoal run, clear the Codex goal manually with `/goal clear` before starting another in the same session. +12. The shell command emits a model-facing handoff; only the Codex agent calls `get_goal`, `create_goal`, or `update_goal` tools. + +## Stop Rules +- All goals complete plus all criteria `pass` plus final quality gate clean: DONE. +- 3x same criterion failure: checkpoint failed, surface diagnosis. +- 5 cycles on one goal without all-pass: checkpoint failed, surface. +- Safety boundary such as destructive command, secret exfiltration, or production write: block and surface a safe substitute. +- Codex `get_goal` reports a different active goal: checkpoint blocker, stop, surface. +- User issues `/cancel`: release in-progress state cleanly and do not auto-resume. diff --git a/packages/omo-codex/plugin/components/ultragoal/src/.gitkeep b/packages/omo-codex/plugin/components/ultragoal/src/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/omo-codex/plugin/components/ultragoal/src/checkpoint.ts b/packages/omo-codex/plugin/components/ultragoal/src/checkpoint.ts new file mode 100644 index 000000000..151a4607b --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/src/checkpoint.ts @@ -0,0 +1,155 @@ +// 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 { ultragoalBriefPath } from "./paths.js"; +import { appendLedger, readUltragoalPlan, withUltragoalMutationLock, writePlan } from "./plan-io.js"; +import { classifyExternalAuthorizationBlocker, clearGoalBlockerFields, sameBlockerOccurrences, validateQualityGate } from "./quality-gate.js"; +import type { UltragoalAggregateCompletion, UltragoalItem, UltragoalLedgerEntry, UltragoalPlan, UltragoalQualityGate } from "./types.js"; +import { iso, ULTRAGOAL_DIR, ULTRAGOAL_GOALS, ULTRAGOAL_LEDGER, UltragoalError } from "./types.js"; + +export interface CheckpointUltragoalArgs { readonly goalId: string; readonly status: "complete" | "failed" | "blocked"; readonly evidence: string; readonly codexGoalJson?: string; readonly qualityGateJson?: string } +export interface CheckpointUltragoalResult { readonly plan: UltragoalPlan; readonly goal: UltragoalItem; readonly ledgerEntry: UltragoalLedgerEntry; readonly aggregateCompletion?: UltragoalAggregateCompletion } + +function ultragoalFail(message: string, code: string): never { throw new UltragoalError(message, code); } +function normalizeObjective(value: string): string { return value.replace(/\s+/g, " ").trim(); } +function nonEmptyEvidence(value: string): string { const trimmed = value.trim(); return trimmed || ultragoalFail("Evidence must be a non-empty string.", "ultragoal_evidence_required"); } +function findGoal(plan: UltragoalPlan, goalId: string): UltragoalItem { const goal = plan.goals.find((candidate) => candidate.id === goalId); return goal ?? ultragoalFail(`Unknown ultragoal id: ${goalId}.`, "ultragoal_goal_not_found"); } + +function textMentionsUltragoalPlanArtifact(value: string | undefined): boolean { + const normalized = (value ?? "").toLowerCase(); + return normalized.includes(ULTRAGOAL_DIR.toLowerCase()) || normalized.includes(ULTRAGOAL_GOALS.toLowerCase()) || normalized.includes(ULTRAGOAL_LEDGER.toLowerCase()); +} +function textMentionsGoalId(value: string | undefined, goalId: string): boolean { return (value ?? "").toLowerCase().includes(goalId.toLowerCase()); } +function textHasCompletionValidationEvidence(value: string | undefined): boolean { + 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 snapshotObjectiveMapsToUltragoalPlan(repoRoot: string, snapshotObjective: string): Promise { + const actual = normalizeObjective(snapshotObjective).toLowerCase(); + if (textMentionsUltragoalPlanArtifact(actual)) return true; + if (actual.length < 24 || !existsSync(ultragoalBriefPath(repoRoot))) return false; + try { + const brief = normalizeObjective(await readFile(ultragoalBriefPath(repoRoot), "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: string, plan: UltragoalPlan, goal: UltragoalItem, snapshotObjective: string, evidence: string): Promise { + if (codexGoalMode(plan) !== "aggregate") return false; + if (goal.status !== "in_progress" || plan.activeGoalId !== goal.id) return false; + if (isFinalRunCompletionCandidate(plan, goal)) return snapshotObjectiveMapsToUltragoalPlan(repoRoot, snapshotObjective); + if (!textMentionsUltragoalPlanArtifact(evidence) || !textMentionsGoalId(evidence, goal.id)) return false; + if (!textHasCompletionValidationEvidence(evidence)) return false; + return snapshotObjectiveMapsToUltragoalPlan(repoRoot, snapshotObjective); +} + +function buildCompletedLegacyGoalRemediation(goal: UltragoalItem): string { + 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 ultragoal 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: UltragoalItem, final: boolean): string { + 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 ultragoal 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/ultragoal/goals.json or ledger.jsonl, includes completed implementation plus validation/review evidence, and a get_goal objective that maps to the ultragoal brief/artifact. ${buildCompletedLegacyGoalRemediation(goal)}`; +} + +async function readJsonInput(raw: string | undefined, repoRoot: string): Promise { + 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 ultragoalFail("Quality gate JSON is neither valid JSON nor a readable path.", "ultragoal_json_input_invalid"); + try { return JSON.parse(await readFile(path, "utf8")); } catch (error) { return ultragoalFail(`Quality gate path does not contain valid JSON${error instanceof Error ? `: ${error.message}` : "."}`, "ultragoal_json_input_invalid"); } +} + +function makeAggregateCompletion(now: string, evidence: string, codexGoal: unknown): UltragoalAggregateCompletion { + return { status: "complete", completedAt: now, evidence, codexGoal }; +} + +function applyBlockedOrFailed(goal: UltragoalItem, plan: UltragoalPlan, status: "failed" | "blocked", evidence: string, now: string): void { + 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: CheckpointUltragoalArgs["status"], goal: UltragoalItem, aggregateCompletion: UltragoalAggregateCompletion | undefined): UltragoalLedgerEntry["kind"] { + 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: string, args: CheckpointUltragoalArgs, goal: UltragoalItem, qualityGate: UltragoalQualityGate | undefined, codexGoal: unknown, aggregateCompletion: UltragoalAggregateCompletion | undefined): UltragoalLedgerEntry { + const entry: UltragoalLedgerEntry = { 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 checkpointUltragoal(repoRoot: string, args: CheckpointUltragoalArgs): Promise { + return withUltragoalMutationLock(repoRoot, async () => { + const plan = await readUltragoalPlan(repoRoot); + const goal = findGoal(plan, args.goalId); + if (args.status === "complete") requireAllCriteriaPass(goal); + const evidence = nonEmptyEvidence(args.evidence); + const now = iso(); + let aggregateCompletion: UltragoalAggregateCompletion | undefined; + let qualityGate: UltragoalQualityGate | undefined; + let codexGoal: unknown; + 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 taskScoped = snapshot?.available === true && snapshot.status === "complete" && objective !== undefined && normalizeObjective(objective) !== normalizeObjective(expectedCodexObjective(plan, goal)) && await canReconcileCompletedTaskScopedAggregateSnapshot(repoRoot, plan, goal, objective, evidence); + if (!taskScoped) throw new UltragoalError(`${formatCodexGoalReconciliation(reconciliation)}${aggregate && snapshot?.status === "complete" && objective !== undefined ? buildTaskScopedAggregateReconciliationHint(goal, final) : ""}`, "ultragoal_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); + const ledgerEntry = buildLedger(now, args, goal, qualityGate, codexGoal, aggregateCompletion); + await appendLedger(repoRoot, ledgerEntry); + return aggregateCompletion === undefined ? { plan, goal, ledgerEntry } : { plan, goal, ledgerEntry, aggregateCompletion }; + }); +} diff --git a/packages/omo-codex/plugin/components/ultragoal/src/cli-arg-parser.ts b/packages/omo-codex/plugin/components/ultragoal/src/cli-arg-parser.ts new file mode 100644 index 000000000..e9028f4ce --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/src/cli-arg-parser.ts @@ -0,0 +1,95 @@ +// biome-ignore-all format: keep this module under the mandated pure LOC budget. +import { readFile } from "node:fs/promises"; + +import { UltragoalError } from "./types.js"; + +type RecordEvidenceCliArgs = { readonly goalId: string; readonly criterionId: string; readonly status: "pass" | "fail" | "blocked"; readonly evidence: string; readonly notes?: string }; + +const VALUE_FLAGS = new Set("--brief --brief-file --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: readonly string[], flag: string): boolean { return argv.includes(flag); } + +export function readValue(argv: readonly string[], flag: string): string | undefined { + 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: readonly string[], flag: string): string[] { + const values: string[] = []; + 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: readonly string[]): string | undefined { return readValue(argv, "--goal-id") ?? readValue(argv, "--goal"); } + +export async function readStdin(): Promise { + const chunks: Buffer[] = []; + 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: readonly string[]): string { + const words: string[] = []; + 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: string): boolean { const trimmed = value.trim(); return trimmed.startsWith("{") || trimmed.startsWith("["); } + +export async function readJsonInput(value: string | undefined): Promise { + 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 UltragoalError(`Invalid JSON input: ${message}`, "ULTRAGOAL_JSON_INPUT_INVALID", { cause: error }); + } +} + +export async function parseCodexGoalJson(value: string | undefined): Promise { + 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 UltragoalError(`Invalid --codex-goal-json: ${message}`, "ULTRAGOAL_CODEX_GOAL_JSON_INVALID", { cause: error }); + } +} + +function required(argv: readonly string[], flag: string, code: string): string { + const value = readValue(argv, flag)?.trim(); + if (value) return value; + throw new UltragoalError(`Missing ${flag}.`, code, { details: { flag } }); +} + +function evidenceStatus(value: string): RecordEvidenceCliArgs["status"] { + switch (value) { + case "pass": return "pass"; + case "fail": return "fail"; + case "blocked": return "blocked"; + default: throw new UltragoalError("Invalid --status; expected pass, fail, or blocked.", "ULTRAGOAL_EVIDENCE_STATUS_INVALID", { details: { status: value } }); + } +} + +export function parseRecordEvidenceArgs(argv: readonly string[]): RecordEvidenceCliArgs { + const result = { goalId: required(argv, "--goal-id", "ULTRAGOAL_GOAL_ID_REQUIRED"), criterionId: required(argv, "--criterion-id", "ULTRAGOAL_CRITERION_ID_REQUIRED"), status: evidenceStatus(required(argv, "--status", "ULTRAGOAL_EVIDENCE_STATUS_REQUIRED")), evidence: required(argv, "--evidence", "ULTRAGOAL_EVIDENCE_REQUIRED") }; + const notes = readValue(argv, "--notes")?.trim(); + return notes ? { ...result, notes } : result; +} diff --git a/packages/omo-codex/plugin/components/ultragoal/src/cli-commands.ts b/packages/omo-codex/plugin/components/ultragoal/src/cli-commands.ts new file mode 100644 index 000000000..c53ef4580 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/src/cli-commands.ts @@ -0,0 +1,142 @@ +// biome-ignore-all format: keep cli-commands dispatcher under the 200 pure LOC budget. +import { readFile } from "node:fs/promises"; +import { checkpointUltragoal } from "./checkpoint.js"; +import { hasFlag, parseCodexGoalJson, parseRecordEvidenceArgs, positionalText, readStdin, readValue } from "./cli-arg-parser.js"; +import { blockedDecisionHandoff, normalizeCodexGoalMode, printJson, printStatus, ULTRAGOAL_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 { addUltragoalGoal, createUltragoalPlan, startNextUltragoal, summarizeUltragoalPlan } from "./plan-crud.js"; +import { readUltragoalPlan } from "./plan-io.js"; +import { recordFinalReviewBlockers } from "./review-blockers.js"; +import { steerUltragoal } from "./steering.js"; +import type { UltragoalItem } from "./types.js"; +import { UltragoalError } from "./types.js"; + +type CheckpointStatus = "complete" | "failed" | "blocked"; + +export async function ultragoalCommand(argv: readonly string[]): Promise { + const command = argv[0] ?? "help"; + const rest = argv.slice(1); + const repoRoot = process.cwd(); + const json = hasFlag(rest, "--json"); + try { + switch (command) { + case "help": case "--help": case "-h": process.stdout.write(`${ULTRAGOAL_HELP}\n`); return 0; + case "create-goals": return await createGoals(repoRoot, rest, json); + case "status": return await status(repoRoot, json); + case "complete-goals": return await completeGoals(repoRoot, rest, json); + case "checkpoint": return await checkpoint(repoRoot, rest, json); + case "steer": return await steer(repoRoot, rest, json); + case "add-goal": return await addGoal(repoRoot, rest, json); + case "criteria": return await criteria(repoRoot, rest, json); + case "record-evidence": return await captureEvidence(repoRoot, rest, json); + case "record-review-blockers": return await reviewBlockers(repoRoot, rest, json); + default: process.stdout.write(`${ULTRAGOAL_HELP}\n`); return 1; + } + } catch (error) { + if (error instanceof UltragoalError) process.stderr.write(`[ultragoal] ${error.message}\n`); + else if (error instanceof Error) process.stderr.write(`[ultragoal] unexpected: ${error.message}\n`); + else process.stderr.write("[ultragoal] unknown error\n"); + return 1; + } +} + +async function createGoals(repoRoot: string, argv: readonly string[], json: boolean): Promise { + 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 UltragoalError("Missing brief text. Pass --brief, --brief-file, --from-stdin, or positional text.", "ULTRAGOAL_BRIEF_REQUIRED"); + const plan = await createUltragoalPlan(repoRoot, { brief, codexGoalMode: normalizeCodexGoalMode(readValue(argv, "--codex-goal-mode")), force: hasFlag(argv, "--force") }); + if (json) printJson({ ok: true, plan, summary: summarizeUltragoalPlan(plan) }); + else process.stdout.write(`ultragoal plan created: ${plan.goals.length} goal(s)\nbrief: ${plan.briefPath}\ngoals: ${plan.goalsPath}\nledger: ${plan.ledgerPath}\n`); + return 0; +} + +async function status(repoRoot: string, json: boolean): Promise { + const plan = await readUltragoalPlan(repoRoot); + if (json) printJson({ ok: true, plan, summary: summarizeUltragoalPlan(plan) }); + else printStatus(plan); + return 0; +} + +async function completeGoals(repoRoot: string, argv: readonly string[], json: boolean): Promise { + const result = await startNextUltragoal(repoRoot, { retryFailed: hasFlag(argv, "--retry-failed") }); + if ("done" in result) { + const handoff = blockedDecisionHandoff(result.plan); + if (json) printJson({ ok: true, done: true, blocked: handoff.length > 0, handoff, summary: summarizeUltragoalPlan(result.plan), plan: result.plan }); + else process.stdout.write(`${handoff || "ultragoal: 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: string, argv: readonly string[], json: boolean): Promise { + const goalId = required(argv, "--goal-id"); + const statusValue = checkpointStatus(required(argv, "--status")); + const evidence = required(argv, "--evidence"); + const codexGoalJson = await parseCodexGoalJson(required(argv, "--codex-goal-json")); + if (codexGoalJson === undefined) throw new UltragoalError("Missing --codex-goal-json.", "ULTRAGOAL_CODEX_GOAL_JSON_REQUIRED"); + const qualityGateJson = readValue(argv, "--quality-gate-json"); + const result = await checkpointUltragoal(repoRoot, qualityGateJson === undefined ? { goalId, status: statusValue, evidence, codexGoalJson } : { goalId, status: statusValue, evidence, codexGoalJson, qualityGateJson }); + if (json) printJson({ ok: true, ...result, summary: summarizeUltragoalPlan(result.plan) }); + else process.stdout.write(`ultragoal checkpoint: ${result.goal.id} -> ${result.goal.status}\n`); + return 0; +} + +async function steer(repoRoot: string, argv: readonly string[], json: boolean): Promise { + const proposal = await parseSteeringProposal(argv); + const result = await steerUltragoal(repoRoot, proposal); + printSteerResult(result, json); + return result.accepted ? 0 : 1; +} + +async function addGoal(repoRoot: string, argv: readonly string[], json: boolean): Promise { + const result = await addUltragoalGoal(repoRoot, { title: required(argv, "--title"), objective: required(argv, "--objective") }); + if (json) printJson({ ok: true, plan: result.plan, goal: result.goal, summary: summarizeUltragoalPlan(result.plan) }); + else { process.stdout.write(`ultragoal added goal: ${result.goal.id}\n`); printStatus(result.plan); } + return 0; +} + +async function criteria(repoRoot: string, argv: readonly string[], json: boolean): Promise { + const goalId = required(argv, "--goal-id"); + const goal = findGoal(await readUltragoalPlan(repoRoot), 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: string, argv: readonly string[], json: boolean): Promise { + const result = await recordEvidence(repoRoot, parseRecordEvidenceArgs(argv)); + if (json) printJson({ ok: true, ...result, summary: summarizeUltragoalPlan(result.plan) }); + else process.stdout.write(`ultragoal evidence recorded: ${result.goal.id}/${result.criterion.id} -> ${result.criterion.status}\n`); + return 0; +} + +async function reviewBlockers(repoRoot: string, argv: readonly string[], json: boolean): Promise { + const codexGoalJson = await parseCodexGoalJson(required(argv, "--codex-goal-json")); + if (codexGoalJson === undefined) throw new UltragoalError("Missing --codex-goal-json.", "ULTRAGOAL_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 }); + if (json) printJson({ ok: true, plan: result.plan, blockedGoal: result.blockedGoal, goal: result.newGoal, ledgerEntries: result.ledgerEntries, summary: summarizeUltragoalPlan(result.plan) }); + else process.stdout.write(`ultragoal final review blockers recorded: ${result.blockedGoal.id} -> review_blocked; added ${result.newGoal.id}\n`); + return 0; +} + +function required(argv: readonly string[], flag: string): string { + const value = readValue(argv, flag)?.trim(); + if (value) return value; + throw new UltragoalError(`Missing ${flag}.`, "ULTRAGOAL_ARGUMENT_MISSING", { details: { flag } }); +} + +function checkpointStatus(value: string): CheckpointStatus { + if (value === "complete" || value === "failed" || value === "blocked") return value; + throw new UltragoalError("Missing or invalid --status; expected complete, failed, or blocked.", "ULTRAGOAL_STATUS_INVALID", { details: { status: value } }); +} + +function findGoal(plan: { readonly goals: readonly UltragoalItem[] }, goalId: string): UltragoalItem { + const goal = plan.goals.find((candidate) => candidate.id === goalId); + if (goal !== undefined) return goal; + throw new UltragoalError(`Unknown ultragoal id: ${goalId}.`, "ULTRAGOAL_GOAL_NOT_FOUND", { details: { goalId } }); +} diff --git a/packages/omo-codex/plugin/components/ultragoal/src/cli-output.ts b/packages/omo-codex/plugin/components/ultragoal/src/cli-output.ts new file mode 100644 index 000000000..ef7ed253f --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/src/cli-output.ts @@ -0,0 +1,61 @@ +import type { UltragoalCodexGoalMode, UltragoalItem, UltragoalPlan } from "./types.js"; +import { UltragoalError } from "./types.js"; + +export const ULTRAGOAL_HELP = `Usage: + omo ultragoal create-goals --brief "..." [--brief-file ] [--from-stdin] [--codex-goal-mode aggregate|per_story] [--force] [--json] + omo ultragoal status [--json] + omo ultragoal complete-goals [--retry-failed] [--json] + omo ultragoal criteria --goal-id [--json] + omo ultragoal record-evidence --goal-id --criterion-id --status pass|fail|blocked --evidence "..." [--notes "..."] [--json] + omo ultragoal checkpoint --goal-id --status complete|failed|blocked --evidence "..." --codex-goal-json <...> [--quality-gate-json <...>] [--json] + omo ultragoal steer --kind ... --evidence "..." --rationale "..." [--json] + omo ultragoal add-goal --title "..." --objective "..." [--json] + omo ultragoal record-review-blockers --goal-id --title "..." --objective "..." --evidence "..." --codex-goal-json <...> [--json]`; + +type CriteriaCounts = { readonly pass: number; readonly total: number }; + +export function printJson(value: unknown): void { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +function criteriaCounts(goal: UltragoalItem): CriteriaCounts { + 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: UltragoalPlan): void { + let totalCriteria = 0; + let passCriteria = 0; + const lines = ["ultragoal 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: UltragoalPlan): string { + const blocked = plan.goals.find((goal) => goal.status === "needs_user_decision" && goal.nonRetriable); + if (blocked === undefined) return ""; + return [ + "ultragoal: 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: string | undefined): UltragoalCodexGoalMode { + if (value === undefined) return "aggregate"; + if (value === "aggregate" || value === "per_story") return value; + throw new UltragoalError( + "Invalid --codex-goal-mode; expected aggregate or per_story.", + "ULTRAGOAL_CODEX_GOAL_MODE_INVALID", + { details: { value } }, + ); +} diff --git a/packages/omo-codex/plugin/components/ultragoal/src/cli-steering.ts b/packages/omo-codex/plugin/components/ultragoal/src/cli-steering.ts new file mode 100644 index 000000000..d0794da81 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/src/cli-steering.ts @@ -0,0 +1,94 @@ +// 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 type { SteerUltragoalResult, UltragoalSteeringChildGoal, UltragoalSteeringMutationKind, UltragoalSteeringProposal, UltragoalSteeringSource, UltragoalSuccessCriterionUserModel } from "./types.js"; +import { ULTRAGOAL_STEERING_MUTATION_KINDS, ULTRAGOAL_SUCCESS_CRITERION_USER_MODELS, UltragoalError } from "./types.js"; + +const SOURCES = ["user_prompt_submit", "finding", "cli"] as const satisfies readonly UltragoalSteeringSource[]; + +export type CliSteeringProposal = UltragoalSteeringProposal & { readonly goalId?: string; readonly scenario?: string; readonly expectedEvidence?: string; readonly userModel?: UltragoalSuccessCriterionUserModel }; + +function isKind(value: string | undefined): value is UltragoalSteeringMutationKind { return value !== undefined && ULTRAGOAL_STEERING_MUTATION_KINDS.some((kind) => kind === value); } +function isSource(value: string | undefined): value is UltragoalSteeringSource { return value !== undefined && SOURCES.some((source) => source === value); } +function isModel(value: string): value is UltragoalSuccessCriterionUserModel { return ULTRAGOAL_SUCCESS_CRITERION_USER_MODELS.some((model) => model === value); } +function fail(message: string, code: string, details: Record): never { throw new UltragoalError(message, code, { details }); } +function text(value: string | undefined, field: string): string | undefined { if (value === undefined) return undefined; const trimmed = value.trim(); if (trimmed.length > 0) return trimmed; return fail(`Empty ${field}.`, "ULTRAGOAL_STEERING_FIELD_EMPTY", { field }); } +function required(argv: readonly string[], flag: string): string { const value = text(readValue(argv, flag), flag); return value ?? fail(`Missing ${flag}.`, "ULTRAGOAL_STEERING_FIELD_REQUIRED", { flag }); } +function requiredGoal(argv: readonly string[]): string { const value = text(parseGoalArg(argv), "--goal-id"); return value ?? fail("Missing --goal-id.", "ULTRAGOAL_GOAL_ID_REQUIRED", { flag: "--goal-id" }); } +function readObject(value: object, key: string): unknown { return Object.entries(value).find(([name]) => name === key)?.[1]; } +function isPlain(value: unknown): value is object { return typeof value === "object" && value !== null && !Array.isArray(value); } +function objectText(value: object, key: string): string | undefined { const candidate = readObject(value, key); return typeof candidate === "string" ? candidate : undefined; } + +export function parseSteeringKind(argv: readonly string[]): UltragoalSteeringMutationKind { + const value = readValue(argv, "--kind"); + if (isKind(value)) return value; + return value === undefined ? fail("Missing --kind.", "ULTRAGOAL_STEERING_KIND_REQUIRED", { flag: "--kind" }) : fail(`Invalid --kind: ${value}.`, "ULTRAGOAL_STEERING_KIND_INVALID", { value, expected: ULTRAGOAL_STEERING_MUTATION_KINDS }); +} + +export function parseSteeringSource(argv: readonly string[]): UltragoalSteeringSource { + const value = readValue(argv, "--source"); + if (value === undefined) return "cli"; + return isSource(value) ? value : fail(`Invalid --source: ${value}.`, "ULTRAGOAL_STEERING_SOURCE_INVALID", { value, expected: SOURCES }); +} + +function child(value: unknown): UltragoalSteeringChildGoal | null { + 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: readonly string[], flag: string, needed: boolean): Promise { + 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.`, "ULTRAGOAL_STEERING_JSON_ARRAY_REQUIRED", { flag }); + const parsed: UltragoalSteeringChildGoal[] = []; + for (const item of raw) { const next = child(item); if (next === null) return fail(`${flag} entries require title/objective.`, "ULTRAGOAL_STEERING_CHILD_INVALID", { flag }); parsed.push(next); } + return parsed; +} + +async function stringArray(argv: readonly string[], flag: string): Promise { + const raw = await readJsonInput(required(argv, flag)); + if (!Array.isArray(raw)) return fail(`${flag} must be a JSON array.`, "ULTRAGOAL_STEERING_JSON_ARRAY_REQUIRED", { flag }); + const values: string[] = []; + for (const item of raw) { if (typeof item !== "string") return fail(`${flag} entries must be strings.`, "ULTRAGOAL_STEERING_STRING_ARRAY_REQUIRED", { flag }); values.push(text(item, flag) ?? ""); } + return values; +} + +function model(value: string | undefined): UltragoalSuccessCriterionUserModel | undefined { const trimmed = text(value, "--user-model"); if (trimmed === undefined) return undefined; return isModel(trimmed) ? trimmed : fail(`Invalid --user-model: ${trimmed}.`, "ULTRAGOAL_STEERING_USER_MODEL_INVALID", { value: trimmed, expected: ULTRAGOAL_SUCCESS_CRITERION_USER_MODELS }); } +function neverKind(kind: never): never { return fail(`Unsupported steering kind: ${String(kind)}.`, "ULTRAGOAL_STEERING_KIND_UNSUPPORTED", { kind }); } + +export async function parseSteeringProposal(argv: readonly string[]): Promise { + 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.", "ULTRAGOAL_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.", "ULTRAGOAL_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: readonly UltragoalSteeringChildGoal[] | undefined): UltragoalSteeringChildGoal[] | undefined { if (values === undefined) return undefined; return values.map((item) => ({ title: text(item.title, "child.title") ?? "", objective: text(item.objective, "child.objective") ?? "" })); } +function normalizedStrings(values: readonly string[] | undefined, field: string): string[] | undefined { if (values === undefined) return undefined; return values.map((value) => text(value, field) ?? ""); } + +export function normalizeSteeringProposal(proposal: CliSteeringProposal): CliSteeringProposal { + 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: SteerUltragoalResult, json: boolean): void { + 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(`ultragoal 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/packages/omo-codex/plugin/components/ultragoal/src/cli.ts b/packages/omo-codex/plugin/components/ultragoal/src/cli.ts new file mode 100644 index 000000000..4570f6921 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/src/cli.ts @@ -0,0 +1,36 @@ +#!/usr/bin/env node +import { ultragoalCommand } from "./cli-commands.js"; +import { runUltragoalHookCli } from "./codex-hook.js"; + +const TOP_LEVEL_HELP = + "Usage:\n omo ultragoal [args]\n omo hook user-prompt-submit (Codex UserPromptSubmit hook)\n omo help | --help | -h (this message)\n\nRun `omo ultragoal help` for ultragoal subcommands.\n"; + +async function main(): Promise { + 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 === "ultragoal") return ultragoalCommand(argv.slice(1)); + if (command === "hook") { + const sub = argv[1]; + if (sub === "user-prompt-submit") { + await runUltragoalHookCli(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: unknown) => { + process.stderr.write(`[omo] ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + }); diff --git a/packages/omo-codex/plugin/components/ultragoal/src/codex-goal-instruction.ts b/packages/omo-codex/plugin/components/ultragoal/src/codex-goal-instruction.ts new file mode 100644 index 000000000..b1994eb83 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/src/codex-goal-instruction.ts @@ -0,0 +1,121 @@ +import { codexGoalMode, expectedCodexObjective, isFinalRunCompletionCandidate } from "./goal-status.js"; +import type { UltragoalCodexGoalMode, UltragoalItem, UltragoalPlan, UltragoalSuccessCriterion } from "./types.js"; + +export interface CodexCreateGoalPayload { + readonly objective: string; + readonly status: "active"; +} + +export interface UltragoalGoalInstruction { + readonly text: string; + readonly json: CodexCreateGoalPayload; +} + +export function buildCodexGoalInstruction(args: { + readonly plan: UltragoalPlan; + readonly goal: UltragoalItem; + readonly isFinal?: boolean; +}): UltragoalGoalInstruction { + 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: UltragoalPlan, goal: UltragoalItem): CodexCreateGoalPayload { + return { objective: expectedCodexObjective(plan, goal), status: "active" }; +} + +function buildText( + mode: UltragoalCodexGoalMode, + plan: UltragoalPlan, + goal: UltragoalItem, + createGoal: CodexCreateGoalPayload, + isFinal: boolean, +): string { + return joinLines([ + mode === "aggregate" ? "Ultragoal aggregate-goal handoff" : "Ultragoal 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 and status only.", + "- Goals are unlimited. Do not add numeric limits.", + ...modeConstraintLines(mode, isFinal), + finalSection(goal, isFinal, mode === "aggregate"), + ...checkpointLines(mode), + "", + "create_goal payload:", + JSON.stringify(createGoal, null, 2), + ]); +} + +function modeConstraintLines(mode: UltragoalCodexGoalMode, isFinal: boolean): readonly string[] { + 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 ultragoal.", + "- Work only this goal until its completion audit passes.", + ]; + } + return [ + "- Codex goal = the whole omo ultragoal 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 ultragoal.", + 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(mode: UltragoalCodexGoalMode): readonly string[] { + const failureLine = + "- If blocked or failed, checkpoint with --status failed and the failure evidence; rerun complete-goals --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: UltragoalItem): readonly string[] { + return ["Active goal:", `- id: ${goal.id}`, `- title: ${goal.title}`, `- objective: ${goal.objective}`]; +} + +function successCriteriaLines(criteria: readonly UltragoalSuccessCriterion[]): readonly string[] { + if (criteria.length === 0) return ["Success criteria:", "- No success criteria recorded for this goal."]; + return ["Success criteria:", ...criteria.map(formatCriterionLine)]; +} + +function formatCriterionLine(criterion: UltragoalSuccessCriterion): string { + const remainingWork = criterion.status === "pending" ? " remaining work:" : ""; + return `-${remainingWork} [${criterion.id}] (${criterion.userModel}) ${criterion.scenario} — expect: ${criterion.expectedEvidence} — status: ${criterion.status}`; +} + +function finalSection(goal: UltragoalItem, isFinal: boolean, aggregate: boolean): string { + if (!isFinal) + return "- This is not the final ultragoal story; do not run the final ai-slop-cleaner/$code-review gate yet."; + const blockerCommand = `omo ultragoal record-review-blockers --goal-id ${goal.id} --title "Resolve final code-review blockers" --objective "" --evidence "" --codex-goal-json ""`; + const checkpointCommand = `omo ultragoal checkpoint --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 $code-review.", + "- If final $code-review is not APPROVE with architect status CLEAR, do not call update_goal. Record blocker work first:", + ` ${blockerCommand}`, + aggregate + ? '- If final $code-review is clean, call update_goal({status: "complete"}), call get_goal again, then checkpoint the aggregate story:' + : '- If final $code-review is clean, call update_goal({status: "complete"}), call get_goal again, then checkpoint:', + ` ${checkpointCommand}`, + ]); +} + +function joinLines(lines: readonly string[]): string { + return lines.join("\n"); +} diff --git a/packages/omo-codex/plugin/components/ultragoal/src/codex-goal-snapshot.ts b/packages/omo-codex/plugin/components/ultragoal/src/codex-goal-snapshot.ts new file mode 100644 index 000000000..8361c3540 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/src/codex-goal-snapshot.ts @@ -0,0 +1,139 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +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 class CodexGoalSnapshotError extends Error {} +function safeObject(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; +} + +function safeString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function normalizeStatus(value: unknown): CodexGoalSnapshotStatus { + 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: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +export function parseCodexGoalSnapshot(value: unknown): CodexGoalSnapshot { + 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: string | undefined, + cwd = process.cwd(), +): Promise { + 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: CodexGoalSnapshot | null | undefined, + options: ReconcileCodexGoalOptions, +): CodexGoalReconciliation { + const effectiveSnapshot = snapshot ?? { available: false, raw: null }; + const errors: string[] = []; + const warnings: string[] = []; + + 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: CodexGoalReconciliation): string { + const parts = [...reconciliation.errors, ...reconciliation.warnings]; + return parts.join(" "); +} diff --git a/packages/omo-codex/plugin/components/ultragoal/src/codex-hook.ts b/packages/omo-codex/plugin/components/ultragoal/src/codex-hook.ts new file mode 100644 index 000000000..3b6a7687f --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/src/codex-hook.ts @@ -0,0 +1,85 @@ +import { parseUltragoalSteeringDirective, steerUltragoal } from "./steering.js"; + +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 function parseUserPromptSubmitPayload(raw: string): UserPromptSubmitPayload | null { + if (raw.trim().length === 0) return null; + try { + const parsed: unknown = JSON.parse(raw); + return isUserPromptSubmitPayload(parsed) ? parsed : null; + } catch (error) { + if (error instanceof SyntaxError) return null; + return null; + } +} + +export async function applyUserPromptUltragoalSteering(payload: UserPromptSubmitPayload): Promise { + try { + if (payload.hook_event_name !== "UserPromptSubmit") return ""; + const proposal = parseUltragoalSteeringDirective(payload.prompt); + if (proposal === null) return ""; + const result = await steerUltragoal(payload.cwd, proposal); + 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 ""; + } +} + +export async function runUltragoalHookCli(stdin: NodeJS.ReadableStream, stdout: NodeJS.WritableStream): Promise { + try { + const payload = parseUserPromptSubmitPayload(await readAll(stdin)); + if (payload === null) return; + const output = await applyUserPromptUltragoalSteering(payload); + if (output.length > 0) stdout.write(output); + } catch (error) { + if (error instanceof Error) return; + return; + } +} + +function isUserPromptSubmitPayload(value: unknown): value is UserPromptSubmitPayload { + 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 isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function optionalString(value: unknown): boolean { + return value === undefined || typeof value === "string"; +} + +function readAll(stdin: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + let data = ""; + stdin.setEncoding("utf8"); + stdin.on("data", (chunk: unknown) => { + data += chunk instanceof Buffer ? chunk.toString() : String(chunk); + }); + stdin.once("error", reject); + stdin.once("end", () => resolve(data)); + }); +} diff --git a/packages/omo-codex/plugin/components/ultragoal/src/evidence.ts b/packages/omo-codex/plugin/components/ultragoal/src/evidence.ts new file mode 100644 index 000000000..4d8014813 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/src/evidence.ts @@ -0,0 +1,121 @@ +// biome-ignore-all format: keep this module under the mandated pure LOC budget. +import { hasAllCriteriaPass } from "./goal-status.js"; +import { appendLedger, readUltragoalPlan, withUltragoalMutationLock, writePlan } from "./plan-io.js"; +import type { UltragoalItem, UltragoalLedgerEntry, UltragoalPlan, UltragoalSuccessCriterion } from "./types.js"; +import { iso, UltragoalError } 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 }; + +function ultragoalFail(message: string, code: string, details: Record): never { throw new UltragoalError(message, code, { details }); } + +function ledgerKind(status: EvidenceStatus): UltragoalLedgerEntry["kind"] { + switch (status) { + case "pass": + return "evidence_captured"; + case "fail": + return "criterion_failed"; + case "blocked": + return "criterion_blocked"; + default: + return ultragoalFail("Invalid criterion status.", "ULTRAGOAL_CRITERION_STATUS_INVALID", { status }); + } +} + +function findGoal(plan: UltragoalPlan, goalId: string): UltragoalItem { + const goal = plan.goals.find((candidate) => candidate.id === goalId); + return goal ?? ultragoalFail(`Ultragoal goal not found: ${goalId}.`, "ULTRAGOAL_GOAL_NOT_FOUND", { goalId }); +} + +function findCriterion(goal: UltragoalItem, criterionId: string): UltragoalSuccessCriterion { + const criterion = goal.successCriteria.find((candidate) => candidate.id === criterionId); + return criterion ?? ultragoalFail(`Success criterion not found: ${criterionId}.`, "ULTRAGOAL_CRITERION_NOT_FOUND", { goalId: goal.id, criterionId }); +} + +function nonEmptyEvidence(evidence: string): string { const trimmed = evidence.trim(); return trimmed || ultragoalFail("Evidence must be a non-empty string.", "ULTRAGOAL_EVIDENCE_REQUIRED", {}); } + +export async function recordEvidence(repoRoot: string, args: RecordEvidenceArgs): Promise<{ plan: UltragoalPlan; goal: UltragoalItem; criterion: UltragoalSuccessCriterion; ledgerEntry: UltragoalLedgerEntry }> { + return withUltragoalMutationLock(repoRoot, async () => { + const plan = await readUltragoalPlan(repoRoot); + 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); + const ledgerEntry: UltragoalLedgerEntry = { + 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); + return { plan, goal, criterion, ledgerEntry }; + }); +} + +export async function markCriteriaPendingResetForGoal(repoRoot: string, goalId: string): Promise<{ plan: UltragoalPlan; resetCount: number }> { + return withUltragoalMutationLock(repoRoot, async () => { + const plan = await readUltragoalPlan(repoRoot); + 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); + await appendLedger(repoRoot, { at: now, kind: "criteria_revised", goalId, message: `Reset ${goal.successCriteria.length} criteria to pending.`, before, after: { resetCount: goal.successCriteria.length } }); + return { plan, resetCount: goal.successCriteria.length }; + }); +} + +export function criteriaSummary(plan: UltragoalPlan): { totalCriteria: number; passCount: number; pendingCount: number; failCount: number; blockedCount: number; goalsWithUnresolvedCriteria: string[] } { + let totalCriteria = 0; + let passCount = 0; + let pendingCount = 0; + let failCount = 0; + let blockedCount = 0; + const goalsWithUnresolvedCriteria: string[] = []; + 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: ultragoalFail("Invalid criterion status.", "ULTRAGOAL_CRITERION_STATUS_INVALID", { status: criterion.status }); + } + } + if (unresolved) goalsWithUnresolvedCriteria.push(goal.id); + } + return { totalCriteria, passCount, pendingCount, failCount, blockedCount, goalsWithUnresolvedCriteria }; +} + +export function unresolvedCriteriaOf(goal: UltragoalItem): UltragoalSuccessCriterion[] { return goal.successCriteria.filter((criterion) => criterion.status !== "pass"); } + +export function requireAllCriteriaPass(goal: UltragoalItem): void { + if (hasAllCriteriaPass(goal)) return; + throw new UltragoalError(`Goal ${goal.id} has unresolved success criteria.`, "ultragoal_criteria_not_all_pass", { + details: { goalId: goal.id, unresolved: unresolvedCriteriaOf(goal).map((criterion) => ({ id: criterion.id, status: criterion.status })) }, + }); +} diff --git a/packages/omo-codex/plugin/components/ultragoal/src/goal-status.ts b/packages/omo-codex/plugin/components/ultragoal/src/goal-status.ts new file mode 100644 index 000000000..a140c4bd8 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/src/goal-status.ts @@ -0,0 +1,84 @@ +import type { + UltragoalCodexGoalMode, + UltragoalItem, + UltragoalPlan, + UltragoalStatus, + UltragoalSuccessCriterion, +} from "./types.js"; + +export const ULTRAGOAL_AGGREGATE_CODEX_OBJECTIVE: string = + "Complete the durable ultragoal plan in .omo/ultragoal/goals.json, including later accepted/appended stories, under the original brief constraints; use .omo/ultragoal/ledger.jsonl as the audit trail."; + +export function codexGoalMode(plan: UltragoalPlan): UltragoalCodexGoalMode { + return plan.codexGoalMode ?? "per_story"; +} + +function isResolvedStatus(status: UltragoalStatus): boolean { + return status === "complete"; +} + +function isSupersededResolved(goal: UltragoalItem, plan: UltragoalPlan): boolean { + 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: UltragoalItem, plan: UltragoalPlan): boolean { + if (goal.steeringStatus === "superseded") return !isSupersededResolved(goal, plan); + if (goal.steeringStatus === "blocked") return true; + return !isResolvedStatus(goal.status); +} + +function isCompletionBlockingForFinalCandidate( + candidate: UltragoalItem, + finalCandidate: UltragoalItem, + plan: UltragoalPlan, +): boolean { + 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 isUltragoalDone(plan: UltragoalPlan): boolean { + if (plan.aggregateCompletion?.status === "complete") return true; + return plan.goals.every((goal) => !isCompletionBlocking(goal, plan)); +} + +export function isFinalRunCompletionCandidate(plan: UltragoalPlan, goal: UltragoalItem): boolean { + return ( + isCompletionBlocking(goal, plan) && + plan.goals.every((candidate) => !isCompletionBlockingForFinalCandidate(candidate, goal, plan)) + ); +} + +export function aggregateCodexObjective(plan: UltragoalPlan): string { + return plan.codexObjective ?? ULTRAGOAL_AGGREGATE_CODEX_OBJECTIVE; +} + +export function expectedCodexObjective(plan: UltragoalPlan, goal: UltragoalItem): string { + return codexGoalMode(plan) === "aggregate" ? aggregateCodexObjective(plan) : goal.objective; +} + +export function compatibleCodexObjectives(plan: UltragoalPlan): readonly string[] { + return [aggregateCodexObjective(plan), ...(plan.codexObjectiveAliases ?? [])]; +} + +export function hasAllCriteriaPass(goal: UltragoalItem): boolean { + return goal.successCriteria.length > 0 && goal.successCriteria.every((criterion) => criterion.status === "pass"); +} + +export function firstUnresolvedCriterion(goal: UltragoalItem): UltragoalSuccessCriterion | undefined { + return goal.successCriteria.find((criterion) => criterion.status !== "pass"); +} diff --git a/packages/omo-codex/plugin/components/ultragoal/src/paths.ts b/packages/omo-codex/plugin/components/ultragoal/src/paths.ts new file mode 100644 index 000000000..835d65362 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/src/paths.ts @@ -0,0 +1,27 @@ +import { join } from "node:path"; +import { ULTRAGOAL_BRIEF, ULTRAGOAL_DIR, ULTRAGOAL_GOALS, ULTRAGOAL_LEDGER } from "./types.js"; + +export function ultragoalDir(repoRoot: string): string { + return join(repoRoot, ULTRAGOAL_DIR); +} + +export function ultragoalBriefPath(repoRoot: string): string { + return join(ultragoalDir(repoRoot), ULTRAGOAL_BRIEF); +} + +export function ultragoalGoalsPath(repoRoot: string): string { + return join(ultragoalDir(repoRoot), ULTRAGOAL_GOALS); +} + +export function ultragoalLedgerPath(repoRoot: string): string { + return join(ultragoalDir(repoRoot), ULTRAGOAL_LEDGER); +} + +export function repoRelative(absolutePath: string, repoRoot: string): string { + 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/packages/omo-codex/plugin/components/ultragoal/src/plan-crud.ts b/packages/omo-codex/plugin/components/ultragoal/src/plan-crud.ts new file mode 100644 index 000000000..e40b5fc0e --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/src/plan-crud.ts @@ -0,0 +1,113 @@ +// 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 { ULTRAGOAL_AGGREGATE_CODEX_OBJECTIVE } from "./goal-status.js"; +import { ultragoalBriefPath, ultragoalDir, ultragoalGoalsPath, ultragoalLedgerPath } from "./paths.js"; +import { appendLedger, readUltragoalPlan, withUltragoalMutationLock, writePlan } from "./plan-io.js"; +import type { UltragoalCodexGoalMode, UltragoalItem, UltragoalPlan, UltragoalSuccessCriterion } from "./types.js"; +import { iso, ULTRAGOAL_BRIEF, ULTRAGOAL_DIR, ULTRAGOAL_GOALS, ULTRAGOAL_LEDGER, UltragoalError } from "./types.js"; + +export type UltragoalPlanSummary = { 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 } }; + +function cleanLine(line: string): string { return line.replace(/^\s*(?:[-*+]\s+|\d+[.)]\s+)/, "").trim(); } +function normalizeObjective(value: string): string { return value.replace(/\s+/g, " ").trim(); } +function titleFromObjective(objective: string, fallback: string): string { 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: string, index: number): string { 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: string | undefined, label: string): string { const trimmed = value?.trim(); if (!trimmed) throw new UltragoalError(`Missing ${label}.`, "ULTRAGOAL_ARGUMENT_MISSING"); return trimmed; } +function truncateObjective(objective: string): string { return objective.length > 80 ? `${objective.slice(0, 77).trimEnd()}...` : objective; } + +export function seedDefaultSuccessCriteria(goalIndex: number, objective: string): UltragoalSuccessCriterion[] { + 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}.`], + ] as const; + return rows.map(([id, userModel, scenario, expectedEvidence]) => ({ id, scenario, userModel, expectedEvidence, capturedEvidence: null, status: "pending" })); +} + +export function deriveGoalCandidates(brief: string): Array<{ title: string; objective: string }> { + 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: string, objective: string, index: number, now: string): UltragoalItem { + 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: UltragoalPlan, title: string, objective: string, now: string): UltragoalItem { + const goal = makeGoal(title, objective, plan.goals.length, now); + plan.goals.push(goal); + plan.updatedAt = now; + return goal; +} + +function isScheduleEligible(goal: UltragoalItem): boolean { return goal.steeringStatus !== "superseded" && goal.steeringStatus !== "blocked"; } + +function clearGoalBlockerFields(goal: UltragoalItem): void { + for (const key of ["blockedReason", "blockerSignature", "blockerOccurrenceCount", "requiredExternalDecision", "nonRetriable", "failedAt", "failureReason"] as const) delete goal[key]; +} + +export async function createUltragoalPlan(repoRoot: string, args: { brief: string; codexGoalMode?: UltragoalCodexGoalMode; force?: boolean }): Promise { + return withUltragoalMutationLock(repoRoot, async () => { + if (!args.force && existsSync(ultragoalGoalsPath(repoRoot))) throw new UltragoalError(`Refusing to overwrite existing ${ULTRAGOAL_DIR}/${ULTRAGOAL_GOALS}; pass --force to recreate it.`, "ULTRAGOAL_PLAN_EXISTS"); + const now = iso(); + const goals = deriveGoalCandidates(args.brief).map((goal, index) => makeGoal(goal.title, goal.objective, index, now)); + const plan: UltragoalPlan = { version: 1, createdAt: now, updatedAt: now, briefPath: `${ULTRAGOAL_DIR}/${ULTRAGOAL_BRIEF}`, goalsPath: `${ULTRAGOAL_DIR}/${ULTRAGOAL_GOALS}`, ledgerPath: `${ULTRAGOAL_DIR}/${ULTRAGOAL_LEDGER}`, codexGoalMode: args.codexGoalMode ?? "aggregate", goals }; + if (plan.codexGoalMode === "aggregate") plan.codexObjective = ULTRAGOAL_AGGREGATE_CODEX_OBJECTIVE; + await mkdir(ultragoalDir(repoRoot), { recursive: true }); + await writeFile(ultragoalBriefPath(repoRoot), args.brief.endsWith("\n") ? args.brief : `${args.brief}\n`, "utf8"); + await writePlan(repoRoot, plan); + await writeFile(ultragoalLedgerPath(repoRoot), "", "utf8"); + await appendLedger(repoRoot, { at: now, kind: "plan_created", message: `${goals.length} goal(s) created` }); + return plan; + }); +} + +export async function addUltragoalGoal(repoRoot: string, args: { title: string; objective: string }): Promise<{ plan: UltragoalPlan; goal: UltragoalItem }> { + return withUltragoalMutationLock(repoRoot, async () => { + const plan = await readUltragoalPlan(repoRoot); + const now = iso(); + const goal = appendGoalToPlan(plan, args.title, args.objective, now); + await writePlan(repoRoot, plan); + await appendLedger(repoRoot, { at: now, kind: "goal_added", goalId: goal.id, status: goal.status, message: goal.title }); + return { plan, goal }; + }); +} + +export async function startNextUltragoal(repoRoot: string, args: { retryFailed?: boolean } = {}): Promise<{ plan: UltragoalPlan; goal: UltragoalItem; resumed: boolean } | { done: true; plan: UltragoalPlan }> { + return withUltragoalMutationLock(repoRoot, async () => { + const plan = await readUltragoalPlan(repoRoot); + 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 ultragoal" }); 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 } : {}) }); + } + 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); + await appendLedger(repoRoot, { at: now, kind: "goal_started", goalId: next.id, status: next.status, message: `Attempt ${next.attempt}` }); + return { plan, goal: next, resumed: false }; + }); +} + +export function summarizeUltragoalPlan(plan: UltragoalPlan): UltragoalPlanSummary { + const countStatus = (status: UltragoalItem["status"]): number => plan.goals.filter((goal) => goal.status === status).length; + const countCriteria = (status: UltragoalSuccessCriterion["status"]): number => 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/packages/omo-codex/plugin/components/ultragoal/src/plan-io.ts b/packages/omo-codex/plugin/components/ultragoal/src/plan-io.ts new file mode 100644 index 000000000..d8df442cf --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/src/plan-io.ts @@ -0,0 +1,99 @@ +import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises"; + +import { repoRelative, ultragoalDir, ultragoalGoalsPath, ultragoalLedgerPath } from "./paths.js"; +import type { UltragoalLedgerEntry, UltragoalPlan } from "./types.js"; +import { iso, ULTRAGOAL_DIR, ULTRAGOAL_GOALS, ULTRAGOAL_LEDGER, UltragoalError } from "./types.js"; + +const AGGREGATE_CODEX_OBJECTIVE = `Complete the durable ultragoal plan in ${ULTRAGOAL_DIR}/${ULTRAGOAL_GOALS}, including later accepted/appended stories, under the original brief constraints; use ${ULTRAGOAL_DIR}/${ULTRAGOAL_LEDGER} as the audit trail.`; +const LEGACY_OBJECTIVE_PREFIX = `Complete all ultragoal stories in ${ULTRAGOAL_DIR}/${ULTRAGOAL_GOALS}: `; +const LEGACY_OBJECTIVE = `Complete all ultragoal stories listed in ${ULTRAGOAL_DIR}/${ULTRAGOAL_GOALS}. Use ${ULTRAGOAL_DIR}/${ULTRAGOAL_LEDGER} as the durable audit trail.`; +const locks = new Map>(); + +function hasCode(error: unknown, code: string): boolean { + return error instanceof Error && "code" in error && error.code === code; +} + +function isLegacyEnumeratedAggregateObjective(objective: string | undefined): objective is string { + return objective === LEGACY_OBJECTIVE || Boolean(objective?.startsWith(LEGACY_OBJECTIVE_PREFIX)); +} + +function isSteeringKind(value: unknown): value is UltragoalLedgerEntry["kind"] { + return value === "steering_accepted" || value === "steering_rejected" || value === "criteria_revised"; +} + +export async function withUltragoalMutationLock(repoRoot: string, fn: () => Promise): Promise { + const prior = locks.get(repoRoot) ?? Promise.resolve(); + const run = prior.then(fn, fn); + locks.set( + repoRoot, + run.catch(() => undefined), + ); + return run; +} + +export async function readUltragoalPlan(repoRoot: string): Promise { + const path = ultragoalGoalsPath(repoRoot); + let raw: string; + try { + raw = await readFile(path, "utf8"); + } catch (error) { + if (!hasCode(error, "ENOENT")) throw error; + throw new UltragoalError( + `No ultragoal plan found at ${repoRelative(path, repoRoot)}. Run \`omo ultragoal create-goals ...\` first.`, + "ULTRAGOAL_PLAN_MISSING", + { cause: error }, + ); + } + const parsed: UltragoalPlan = JSON.parse(raw); + if (parsed.version !== 1 || !Array.isArray(parsed.goals)) { + throw new UltragoalError(`Invalid ultragoal plan at ${repoRelative(path, repoRoot)}.`, "ULTRAGOAL_PLAN_INVALID"); + } + const previousObjective = parsed.codexObjective; + if ( + (parsed.codexGoalMode ?? "per_story") === "aggregate" && + isLegacyEnumeratedAggregateObjective(previousObjective) + ) { + const now = iso(); + parsed.codexObjective = AGGREGATE_CODEX_OBJECTIVE; + parsed.codexObjectiveAliases = [...new Set([...(parsed.codexObjectiveAliases ?? []), previousObjective])]; + parsed.updatedAt = now; + await writePlan(repoRoot, parsed); + 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 }, + }); + } + return parsed; +} + +export async function writePlan(repoRoot: string, plan: UltragoalPlan): Promise { + await mkdir(ultragoalDir(repoRoot), { recursive: true }); + const path = ultragoalGoalsPath(repoRoot); + 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: string, entry: UltragoalLedgerEntry): Promise { + await mkdir(ultragoalDir(repoRoot), { recursive: true }); + await appendFile(ultragoalLedgerPath(repoRoot), `${JSON.stringify(entry)}\n`, "utf8"); +} + +export async function readSteeringLedgerEntries(repoRoot: string): Promise { + let raw: string; + try { + raw = await readFile(ultragoalLedgerPath(repoRoot), "utf8"); + } catch (error) { + if (hasCode(error, "ENOENT")) return []; + throw error; + } + const entries: UltragoalLedgerEntry[] = []; + for (const line of raw.split(/\r?\n/).filter(Boolean)) { + const entry: UltragoalLedgerEntry = JSON.parse(line); + if (isSteeringKind(entry.kind)) entries.push(entry); + } + return entries; +} diff --git a/packages/omo-codex/plugin/components/ultragoal/src/quality-gate.ts b/packages/omo-codex/plugin/components/ultragoal/src/quality-gate.ts new file mode 100644 index 000000000..b5e08d41f --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/src/quality-gate.ts @@ -0,0 +1,102 @@ +import type { UltragoalItem, UltragoalPlan, UltragoalQualityGate } from "./types.js"; +import { UltragoalError } 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/; + +function invalid(message: string, field: string): never { + throw new UltragoalError(message, "ULTRAGOAL_QUALITY_GATE_INVALID", { details: { field } }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function section(value: unknown, field: string): Record { + return isRecord(value) ? value : invalid(`Final quality gate is missing ${field} evidence.`, field); +} + +function nonEmptyString(value: unknown, field: string): string { + return typeof value === "string" && value.trim() !== "" + ? value + : invalid(`Final quality gate requires non-empty ${field}.`, field); +} + +function numberField(value: unknown, field: string): number { + return typeof value === "number" && Number.isFinite(value) + ? value + : invalid(`Final quality gate requires numeric ${field}.`, field); +} + +function stringArray(value: unknown, field: string): string[] { + if (!Array.isArray(value) || value.length === 0) return invalid(`Final quality gate requires ${field}.`, field); + return value.map((item) => nonEmptyString(item, field)); +} + +export function validateQualityGate(input: unknown): UltragoalQualityGate { + 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"); + if (review["recommendation"] !== "APPROVE") invalid("recommendation must be APPROVE.", "codeReview.recommendation"); + if (review["architectStatus"] !== "CLEAR") invalid("architectStatus must be CLEAR.", "codeReview.architectStatus"); + 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 result: UltragoalQualityGate = { + aiSlopCleaner: { status: "passed", evidence: cleanerEvidence }, + verification: { status: "passed", commands, evidence: verificationEvidence }, + codeReview: { recommendation: "APPROVE", architectStatus: "CLEAR", evidence: reviewEvidence }, + }; + Object.assign(result, { criteriaCoverage: { totalCriteria, passCount, adversarialClassesCovered: covered } }); + return result; +} + +export function normalizeBlockerEvidence(evidence: string): string { + const withoutUrls = evidence.toLowerCase().replace(URL_PATTERN, " "); + const withoutPunctuation = withoutUrls.replace(PUNCTUATION_PATTERN, " "); + return withoutPunctuation.replace(WHITESPACE_PATTERN, " ").trim(); +} + +export function classifyExternalAuthorizationBlocker(evidence: string): string | null { + 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 is string => part !== null).join("+"); + return `GHCR_PULL_ACCESS:${status || "AUTHORIZATION_REQUIRED"}:GHCR_VISIBILITY_OR_CREDENTIAL_REQUIRED`; +} + +function nestedBlockerSignature(goal: UltragoalItem): string | null { + const blocker = Reflect.get(goal, "blocker"); + const signature = isRecord(blocker) ? blocker["signature"] : null; + return typeof signature === "string" ? signature : null; +} + +export function sameBlockerOccurrences(plan: UltragoalPlan, signature: string): number { + return plan.goals.filter((goal) => goal.blockerSignature === signature || nestedBlockerSignature(goal) === signature) + .length; +} + +export function clearGoalBlockerFields(goal: UltragoalItem): void { + for (const key of BLOCKER_FIELD_KEYS) Reflect.deleteProperty(goal, key); +} diff --git a/packages/omo-codex/plugin/components/ultragoal/src/review-blockers.ts b/packages/omo-codex/plugin/components/ultragoal/src/review-blockers.ts new file mode 100644 index 000000000..08fbc18c4 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/src/review-blockers.ts @@ -0,0 +1,79 @@ +// 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, readUltragoalPlan, withUltragoalMutationLock, writePlan } from "./plan-io.js"; +import type { UltragoalItem, UltragoalLedgerEntry, UltragoalPlan } from "./types.js"; +import { iso, UltragoalError } 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: UltragoalPlan; readonly blockedGoal: UltragoalItem; readonly newGoal: UltragoalItem; readonly ledgerEntries: UltragoalLedgerEntry[] } + +const BLOCKER_FIELDS = "blockedReason blockerSignature blockerOccurrenceCount requiredExternalDecision nonRetriable failedAt failureReason completedAt blocker blockerEvidence blockerOccurrences blockedAt".split(" "); + +function ultragoalError(message: string, code: string): never { + throw new UltragoalError(message, code); +} + +function nextGoalId(plan: UltragoalPlan): string { + 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: UltragoalPlan, args: RecordFinalReviewBlockersArgs, now: string): UltragoalItem { + const index = plan.goals.length; + const goal: UltragoalItem = { + 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: string, + args: RecordFinalReviewBlockersArgs, +): Promise { + return withUltragoalMutationLock(repoRoot, async () => { + const plan = await readUltragoalPlan(repoRoot); + const goal = plan.goals.find((candidate) => candidate.id === args.goalId); + if (goal === undefined) ultragoalError(`Unknown ultragoal id: ${args.goalId}`, "ultragoal_goal_not_found"); + if (goal.status !== "in_progress") ultragoalError(`${goal.id} is ${goal.status}.`, "ultragoal_goal_not_in_progress"); + if (!isFinalRunCompletionCandidate(plan, goal)) ultragoalError(`${goal.id} is not final.`, "ultragoal_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) ultragoalError(reconciliation.errors.join(" "), "ultragoal_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: UltragoalLedgerEntry = { at: now, kind: "goal_review_blocked", goalId: goal.id, status: goal.status, evidence: args.evidence, codexGoal }; + const addedEntry: UltragoalLedgerEntry = { at: now, kind: "goal_added", goalId: newGoal.id, status: newGoal.status, evidence: args.evidence, message: newGoal.title }; + const summaryEntry: UltragoalLedgerEntry = { 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); + for (const entry of ledgerEntries) await appendLedger(repoRoot, entry); + return { plan, blockedGoal: goal, newGoal, ledgerEntries }; + }); +} diff --git a/packages/omo-codex/plugin/components/ultragoal/src/steering.ts b/packages/omo-codex/plugin/components/ultragoal/src/steering.ts new file mode 100644 index 000000000..81c151cc3 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/src/steering.ts @@ -0,0 +1,265 @@ +// biome-ignore-all format: compact steering module must stay below the 240 pure-LOC budget +import { isUltragoalDone } from "./goal-status.js"; +import { appendLedger, readSteeringLedgerEntries, readUltragoalPlan, withUltragoalMutationLock, writePlan } from "./plan-io.js"; +import type { + SteerUltragoalResult, + UltragoalItem, + UltragoalLedgerEntry, + UltragoalPlan, + UltragoalSteeringAudit, + UltragoalSteeringChildGoal, + UltragoalSteeringMutationKind, + UltragoalSteeringProposal, + UltragoalSteeringSource, + UltragoalSuccessCriterionUserModel, +} from "./types.js"; +import { iso, ULTRAGOAL_STEERING_MUTATION_KINDS, ULTRAGOAL_SUCCESS_CRITERION_USER_MODELS } from "./types.js"; + +const SOURCES = ["user_prompt_submit", "finding", "cli"] as const satisfies readonly UltragoalSteeringSource[]; +const PROTECTED = new Set(["aggregateCompletion", "codexObjective", "codexObjectiveAliases", "originalConstraints", "qualityGate", "status", "completedAt", "completionStatus"]); +const isObject = (value: unknown): value is object => typeof value === "object" && value !== null; const isPlain = (value: unknown): value is object => isObject(value) && !Array.isArray(value); +const read = (value: object, key: string): unknown => Object.entries(value).find(([name]) => name === key)?.[1]; +const isText = (value: unknown): value is string => typeof value === "string" && value.trim().length > 0; +const text = (value: object, key: string): string | undefined => { + const candidate = read(value, key); + return isText(candidate) ? candidate.trim() : undefined; +}; +const isKind = (value: unknown): value is UltragoalSteeringMutationKind => typeof value === "string" && ULTRAGOAL_STEERING_MUTATION_KINDS.some((kind) => kind === value); +const isSource = (value: unknown): value is UltragoalSteeringSource => typeof value === "string" && SOURCES.some((source) => source === value); +const isModel = (value: unknown): value is UltragoalSuccessCriterionUserModel => typeof value === "string" && ULTRAGOAL_SUCCESS_CRITERION_USER_MODELS.some((model) => model === value); +const texts = (value: object, key: string): string[] => { + const candidate = read(value, key); + return Array.isArray(candidate) && candidate.every((item) => typeof item === "string") ? candidate : []; +}; + +function targets(proposal: object): string[] { + const many = texts(proposal, "targetGoalIds"); + const one = text(proposal, "targetGoalId") ?? text(proposal, "goalId"); + return many.length > 0 ? many : one === undefined ? [] : [one]; +} + +const after = (proposal: object): object | undefined => { + const candidate = read(proposal, "after"); + return isPlain(candidate) ? candidate : undefined; +}; +const revised = (proposal: object, direct: string, nested: string): string | undefined => text(proposal, direct) ?? text(after(proposal) ?? proposal, nested); + +function child(value: unknown): UltragoalSteeringChildGoal | null { + 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: object): unknown[] { + 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: object): UltragoalSteeringChildGoal[] => childValues(proposal).map(child).filter((item): item is UltragoalSteeringChildGoal => item !== null); +const pendingOrder = (proposal: object): string[] => { + const direct = texts(proposal, "pendingOrder"); + return direct.length > 0 ? direct : texts(after(proposal) ?? proposal, "pendingGoalIds"); +}; + +function hasProtected(value: unknown): boolean { + 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: unknown): string { + if (typeof value === "string") return value; + return isObject(value) ? Object.values(value).map(allText).filter(Boolean).join("\n") : ""; +} + +function weakens(value: unknown): boolean { + 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: unknown, reasons: string[]): UltragoalSteeringAudit { + 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: UltragoalSteeringAudit = { 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 validateUltragoalSteeringProposal(plan: UltragoalPlan, proposal: unknown): UltragoalSteeringAudit { + const reasons: string[] = []; + 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 (isUltragoalDone(plan)) reasons.push("plan already complete"); + if (isKind(kind)) validateKind(plan, object, kind, reasons); + return auditFor(proposal, reasons); +} + +function goal(plan: UltragoalPlan, id: string | undefined): UltragoalItem | undefined { + return id === undefined ? undefined : plan.goals.find((item) => item.id === id); +} + +function validateKind(plan: UltragoalPlan, proposal: object, kind: UltragoalSteeringMutationKind, reasons: string[]): void { + 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: UltragoalPlan, proposal: object, reasons: string[]): void { + 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: UltragoalPlan, proposal: object, reasons: string[]): void { + 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: UltragoalPlan, offset: number): string { + 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: UltragoalPlan, childGoal: UltragoalSteeringChildGoal, evidence: string, now: string, offset: number): UltragoalItem { + return { id: nextId(plan, offset), title: childGoal.title, objective: childGoal.objective, status: "pending", successCriteria: [], attempt: 0, createdAt: now, updatedAt: now, evidence }; +} + +export function applySteeringMutation(plan: UltragoalPlan, proposal: UltragoalSteeringProposal, audit: UltragoalSteeringAudit): UltragoalPlan { + 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 is UltragoalItem => 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: UltragoalPlan, proposal: UltragoalSteeringProposal, now: string): void { + 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: UltragoalPlan, proposal: UltragoalSteeringProposal, now: string): void { + 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: UltragoalPlan, proposal: UltragoalSteeringProposal, now: string): void { + 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: unknown): value is UltragoalSteeringProposal { + return isPlain(value) && isKind(read(value, "kind")) && isSource(read(value, "source")) && isText(read(value, "evidence")) && isText(read(value, "rationale")); +} + +export function parseUltragoalSteeringDirective(text: string): UltragoalSteeringProposal | null { + const match = /(?:^|\s)(?:OMO_ULTRAGOAL_STEER|omo\.ultragoal\.steer|omo ultragoal steer):\s*([\s\S]+)$/u.exec(text); + if (match?.[1] === undefined) return null; + try { + const parsed: unknown = JSON.parse(match[1].trim()); + return isProposal(parsed) ? parsed : null; + } catch (error) { + if (error instanceof SyntaxError) return null; + throw error; + } +} + +export async function steerUltragoal(repoRoot: string, proposal: UltragoalSteeringProposal): Promise { + return withUltragoalMutationLock(repoRoot, async () => { + const plan = await readUltragoalPlan(repoRoot); + const key = proposal.idempotencyKey ?? proposal.promptSignature; + const prior = key === undefined ? undefined : (await readSteeringLedgerEntries(repoRoot)).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 = validateUltragoalSteeringProposal(plan, proposal); + const accepted = audit.invariant.accepted; + const next = accepted ? applySteeringMutation(plan, proposal, audit) : plan; + const finalAudit: UltragoalSteeringAudit = { ...audit, before: plan }; + if (accepted) finalAudit.after = next; + if (accepted) await writePlan(repoRoot, next); + await appendLedger(repoRoot, ledgerEntry(proposal, finalAudit, proposal.now?.toISOString() ?? iso())); + return { plan: next, accepted, audit: finalAudit, rejectedReasons: audit.invariant.rejectedReasons, deduped: false }; + }); +} + +function ledgerEntry(proposal: UltragoalSteeringProposal, audit: UltragoalSteeringAudit, at: string): UltragoalLedgerEntry { + const entry: UltragoalLedgerEntry = { 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/packages/omo-codex/plugin/components/ultragoal/src/types.ts b/packages/omo-codex/plugin/components/ultragoal/src/types.ts new file mode 100644 index 000000000..d065e672f --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/src/types.ts @@ -0,0 +1,277 @@ +export const ULTRAGOAL_DIR = ".omo/ultragoal"; +export const ULTRAGOAL_BRIEF = "brief.md"; +export const ULTRAGOAL_GOALS = "goals.json"; +export const ULTRAGOAL_LEDGER = "ledger.jsonl"; + +export type UltragoalStatus = + | "pending" + | "in_progress" + | "complete" + | "failed" + | "blocked" + | "review_blocked" + | "needs_user_decision"; + +export type UltragoalCodexGoalMode = "aggregate" | "per_story"; + +export type UltragoalSteeringStatus = "superseded" | "blocked"; + +export const ULTRAGOAL_STEERING_MUTATION_KINDS = [ + "add_subgoal", + "split_subgoal", + "reorder_pending", + "revise_pending_wording", + "revise_criterion", + "annotate_ledger", + "mark_blocked_superseded", +] as const satisfies readonly string[]; +export type UltragoalSteeringMutationKind = (typeof ULTRAGOAL_STEERING_MUTATION_KINDS)[number]; + +export type UltragoalSteeringSource = "user_prompt_submit" | "finding" | "cli"; + +export const ULTRAGOAL_SUCCESS_CRITERION_USER_MODELS = [ + "happy", + "edge", + "regression", + "adversarial", +] as const satisfies readonly string[]; +export type UltragoalSuccessCriterionUserModel = (typeof ULTRAGOAL_SUCCESS_CRITERION_USER_MODELS)[number]; + +export const ULTRAGOAL_CRITERION_STATUSES = ["pending", "pass", "fail", "blocked"] as const satisfies readonly string[]; +export type UltragoalCriterionStatus = (typeof ULTRAGOAL_CRITERION_STATUSES)[number]; + +export const ULTRAGOAL_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", +] as const satisfies readonly string[]; +export type UltragoalLedgerEventKind = (typeof ULTRAGOAL_LEDGER_EVENT_KINDS)[number]; + +export interface UltragoalSuccessCriterion { + readonly id: string; + readonly scenario: string; + readonly userModel: UltragoalSuccessCriterionUserModel; + readonly expectedEvidence: string; + capturedEvidence: string | null; + status: UltragoalCriterionStatus; + capturedAt?: string; + notes?: string; +} + +export interface UltragoalSteeringInvariantResult { + accepted: boolean; + structuralInvariantAccepted: boolean; + evidenceBackedNecessity: boolean; + noEasierCompletion: boolean; + rejectedReasons: string[]; + reasons?: string[]; +} + +export interface UltragoalSteeringChildGoal { + title: string; + objective: string; +} + +export interface UltragoalSteeringAfterPayload { + title?: string; + objective?: string; + pendingGoalIds?: string[]; + children?: UltragoalSteeringChildGoal[]; +} + +export interface UltragoalSteeringProposal { + kind: UltragoalSteeringMutationKind; + source: UltragoalSteeringSource; + targetGoalId?: string; + targetGoalIds?: string[]; + criterionId?: string; + evidence: string; + rationale: string; + title?: string; + objective?: string; + childGoals?: UltragoalSteeringChildGoal[]; + revisedTitle?: string; + revisedObjective?: string; + pendingOrder?: string[]; + blockedReason?: string; + after?: UltragoalSteeringAfterPayload; + directiveText?: string; + promptSignature?: string; + idempotencyKey?: string; + now?: Date; +} + +export interface UltragoalSteeringAudit { + kind: UltragoalSteeringMutationKind; + source: UltragoalSteeringSource; + targetGoalIds: string[]; + criterionId?: string; + before?: unknown; + after?: unknown; + evidence: string; + rationale: string; + invariant: UltragoalSteeringInvariantResult; + directiveText?: string; + promptSignature?: string; + idempotencyKey?: string; + deduped?: boolean; +} + +export interface SteerUltragoalResult { + plan: UltragoalPlan; + accepted: boolean; + audit: UltragoalSteeringAudit; + rejectedReasons: string[]; + deduped: boolean; +} + +export interface UltragoalItem { + id: string; + title: string; + objective: string; + status: UltragoalStatus; + successCriteria: UltragoalSuccessCriterion[]; + attempt: number; + createdAt: string; + updatedAt: string; + startedAt?: string; + completedAt?: string; + failedAt?: string; + reviewBlockedAt?: string; + evidence?: string; + failureReason?: string; + steeringStatus?: UltragoalSteeringStatus; + supersededBy?: string[]; + supersedes?: string[]; + blockedReason?: string; + blockerSignature?: string; + blockerOccurrenceCount?: number; + requiredExternalDecision?: string; + nonRetriable?: boolean; + steeringEvidence?: string; + steeringRationale?: string; +} + +export interface UltragoalAggregateCompletion { + status: "complete"; + completedAt: string; + evidence: string; + codexGoal?: unknown; +} + +export interface UltragoalPlan { + version: 1; + createdAt: string; + updatedAt: string; + briefPath: string; + goalsPath: string; + ledgerPath: string; + codexGoalMode?: UltragoalCodexGoalMode; + codexObjective?: string; + codexObjectiveAliases?: string[]; + aggregateCompletion?: UltragoalAggregateCompletion; + activeGoalId?: string; + goals: UltragoalItem[]; +} + +export interface UltragoalLedgerEntry { + at: string; + kind: UltragoalLedgerEventKind; + goalId?: string; + criterionId?: string; + status?: UltragoalStatus; + criterionStatus?: UltragoalCriterionStatus; + message?: string; + codexGoal?: unknown; + evidence?: string; + capturedEvidence?: string; + qualityGate?: UltragoalQualityGate; + steering?: UltragoalSteeringAudit; + before?: unknown; + after?: unknown; + mutationKind?: UltragoalSteeringMutationKind; + idempotencyKey?: string; + blockerSignature?: string; + blockerOccurrenceCount?: number; + requiredExternalDecision?: string; +} + +export interface CreateUltragoalOptions { + brief: string; + goals?: Array<{ title?: string; objective: string }>; + codexGoalMode?: UltragoalCodexGoalMode; + 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 AddUltragoalGoalOptions { + title: string; + objective: string; + evidence?: string; + now?: Date; +} + +export interface RecordFinalReviewBlockersOptions extends AddUltragoalGoalOptions { + goalId: string; + codexGoal?: unknown; +} + +export interface UltragoalQualityGate { + aiSlopCleaner: { status: "passed"; evidence: string }; + verification: { status: "passed"; commands: string[]; evidence: string }; + codeReview: { recommendation: "APPROVE"; architectStatus: "CLEAR"; evidence: string }; +} + +export interface UltragoalErrorOptions { + readonly cause?: unknown; + readonly details?: Record; +} + +export class UltragoalError extends Error { + readonly code: string; + readonly details?: Record; + + constructor(message: string, code: string, opts?: UltragoalErrorOptions) { + super(message, opts?.cause === undefined ? undefined : { cause: opts.cause }); + this.name = "UltragoalError"; + this.code = code; + if (opts?.details !== undefined) { + this.details = opts.details; + } + } +} + +export function iso(): string { + return new Date().toISOString(); +} diff --git a/packages/omo-codex/plugin/components/ultragoal/test/checkpoint.test.ts b/packages/omo-codex/plugin/components/ultragoal/test/checkpoint.test.ts new file mode 100644 index 000000000..8bd8031a5 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/test/checkpoint.test.ts @@ -0,0 +1,213 @@ +// biome-ignore-all format: keep the single mandated checkpoint spec under the pure LOC budget. +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { checkpointUltragoal } from "../src/checkpoint.js"; +import { ULTRAGOAL_AGGREGATE_CODEX_OBJECTIVE } from "../src/goal-status.js"; +import { ultragoalBriefPath, ultragoalDir, ultragoalLedgerPath } from "../src/paths.js"; +import { writePlan } from "../src/plan-io.js"; +import type { UltragoalItem, UltragoalLedgerEntry, UltragoalPlan, UltragoalSuccessCriterion } from "../src/types.js"; +import { UltragoalError } from "../src/types.js"; + +const NOW = "2026-05-23T00:00:00.000Z"; +const QUALITY_GATE_PATH = join(process.cwd(), "test", "fixtures", "sample-quality-gate.json"); + +function criterion(id: string, status: UltragoalSuccessCriterion["status"]): UltragoalSuccessCriterion { + return { id, scenario: `${id} scenario`, userModel: "happy", expectedEvidence: `${id} proof`, capturedEvidence: status === "pass" ? `${id} passed` : null, status }; +} + +function goal(overrides: Partial = {}): UltragoalItem { + return { id: "G001", title: "Build auth", objective: "Implement JWT auth endpoint", status: "in_progress", successCriteria: [criterion("C001", "pass"), criterion("C002", "pass"), criterion("C003", "pass")], attempt: 1, createdAt: NOW, updatedAt: NOW, ...overrides }; +} + +function plan(goals: UltragoalItem[], overrides: Partial = {}): UltragoalPlan { + const result: UltragoalPlan = { version: 1, createdAt: NOW, updatedAt: NOW, briefPath: ".omo/ultragoal/brief.md", goalsPath: ".omo/ultragoal/goals.json", ledgerPath: ".omo/ultragoal/ledger.jsonl", codexGoalMode: "aggregate", codexObjective: ULTRAGOAL_AGGREGATE_CODEX_OBJECTIVE, goals }; + Object.assign(result, overrides); + const activeGoalId = goals.find((candidate) => candidate.status === "in_progress")?.id; + if (result.activeGoalId === undefined && activeGoalId !== undefined) result.activeGoalId = activeGoalId; + return result; +} + +async function samplePlan(overrides: Partial = {}): Promise { + const fixture: UltragoalPlan = JSON.parse(await readFile(new URL("./fixtures/sample-plan.json", import.meta.url), "utf8")); + return plan(fixture.goals.map((item, index) => goal({ ...item, attempt: index + 1, createdAt: NOW, updatedAt: NOW })), overrides); +} + +async function repoWith(seed: UltragoalPlan): Promise { + const repo = await mkdtemp(join(tmpdir(), "ug-checkpoint-")); + await mkdir(ultragoalDir(repo), { recursive: true }); + await writePlan(repo, seed); + return repo; +} + +function snapshot(status: "active" | "complete", objective = ULTRAGOAL_AGGREGATE_CODEX_OBJECTIVE): string { + return JSON.stringify({ goal: { objective, status } }); +} + +async function lastLedger(repo: string): Promise { + const last = (await readFile(ultragoalLedgerPath(repo), "utf8")).trim().split(/\r?\n/).at(-1); + if (last === undefined) throw new Error("expected ledger entry"); + const entry: UltragoalLedgerEntry = JSON.parse(last); + return entry; +} + +async function expectCode(action: () => Promise, code: string): Promise { + try { + await action(); + } catch (error) { + expect(error).toBeInstanceOf(UltragoalError); + if (!(error instanceof UltragoalError)) throw error; + expect(error.code).toBe(code); + return; + } + throw new Error("Expected UltragoalError"); +} + +function passGoal(id: string, overrides: Partial = {}): UltragoalItem { + return goal({ id, successCriteria: [criterion("C001", "pass"), criterion("C002", "pass"), criterion("C003", "pass")], ...overrides }); +} + +describe("checkpointUltragoal status=complete criteria gate", () => { + it("THROWS ultragoal_criteria_not_all_pass when any criterion is pending", async () => { + const repo = await repoWith(await samplePlan({ goals: [goal({ successCriteria: [criterion("C001", "pass"), criterion("C002", "pending"), criterion("C003", "pass")] })] })); + await expectCode(() => checkpointUltragoal(repo, { goalId: "G001", status: "complete", evidence: "done" }), "ultragoal_criteria_not_all_pass"); + }); + + it("THROWS when any criterion is fail or blocked", async () => { + for (const status of ["fail", "blocked"] satisfies UltragoalSuccessCriterion["status"][]) { + const repo = await repoWith(plan([goal({ successCriteria: [criterion("C001", "pass"), criterion("C002", status), criterion("C003", "pass")] })])); + await expectCode(() => checkpointUltragoal(repo, { goalId: "G001", status: "complete", evidence: "done" }), "ultragoal_criteria_not_all_pass"); + } + }); + + it("THROWS when criteria list is empty", async () => { + const repo = await repoWith(plan([goal({ successCriteria: [] }), goal({ id: "G002", status: "pending" })])); + await expectCode(() => checkpointUltragoal(repo, { goalId: "G001", status: "complete", evidence: "done", codexGoalJson: snapshot("active") }), "ultragoal_criteria_not_all_pass"); + }); + + it("ACCEPTS complete when ALL criteria pass (with valid snapshot)", async () => { + const repo = await repoWith(plan([passGoal("G001"), goal({ id: "G002", status: "pending" })])); + const result = await checkpointUltragoal(repo, { goalId: "G001", status: "complete", evidence: "implementation done and tests passed", codexGoalJson: snapshot("active") }); + expect(result.goal.status).toBe("complete"); + expect((await lastLedger(repo)).kind).toBe("goal_completed"); + }); +}); + +describe("checkpointUltragoal reconciliation (status=complete)", () => { + it("succeeds when snapshot objective matches expected (aggregate active)", async () => { + const repo = await repoWith(plan([passGoal("G001"), goal({ id: "G002", status: "pending" })])); + await expect(checkpointUltragoal(repo, { goalId: "G001", status: "complete", evidence: "work complete and validation passed", codexGoalJson: snapshot("active") })).resolves.toMatchObject({ goal: { status: "complete" } }); + }); + + it("throws on mismatched objective", async () => { + const repo = await repoWith(plan([passGoal("G001"), goal({ id: "G002", status: "pending" })])); + await expectCode(() => checkpointUltragoal(repo, { goalId: "G001", status: "complete", evidence: "work complete and validation passed", codexGoalJson: snapshot("active", "wrong objective") }), "ultragoal_codex_snapshot_mismatch"); + }); + + it("throws on mismatched status (snapshot complete when expected active)", async () => { + const repo = await repoWith(plan([passGoal("G001"), goal({ id: "G002", status: "pending" })])); + await expectCode(() => checkpointUltragoal(repo, { goalId: "G001", status: "complete", evidence: "work complete and validation passed", codexGoalJson: snapshot("complete") }), "ultragoal_codex_snapshot_mismatch"); + }); +}); + +describe("checkpointUltragoal final story", () => { + it("requires quality-gate-json for the final goal complete", async () => { + const repo = await repoWith(plan([passGoal("G001", { status: "complete" }), passGoal("G002")], { activeGoalId: "G002" })); + await expectCode(() => checkpointUltragoal(repo, { goalId: "G002", status: "complete", evidence: "final work complete and validation passed", codexGoalJson: snapshot("complete") }), "ULTRAGOAL_QUALITY_GATE_INVALID"); + }); + + it("accepts final story when quality gate JSON includes valid criteriaCoverage", async () => { + const repo = await repoWith(plan([passGoal("G001", { status: "complete" }), passGoal("G002")], { activeGoalId: "G002" })); + const result = await checkpointUltragoal(repo, { goalId: "G002", status: "complete", evidence: "final work complete and validation passed", codexGoalJson: snapshot("complete"), qualityGateJson: QUALITY_GATE_PATH }); + expect(result.aggregateCompletion?.status).toBe("complete"); + expect(result.plan.aggregateCompletion?.status).toBe("complete"); + }); + + it("ACCEPTS complete when task-scoped completed Codex objective maps to the ultragoal brief", async () => { + const taskObjective = "Fix ultragoal objective mismatch and install local ulw"; + const repo = await repoWith(plan([passGoal("G001")], { activeGoalId: "G001" })); + await writeFile(ultragoalBriefPath(repo), `${taskObjective}\n`, "utf8"); + + const result = await checkpointUltragoal(repo, { + goalId: "G001", + status: "complete", + evidence: "final implementation complete and quality gate passed", + codexGoalJson: snapshot("complete", taskObjective), + qualityGateJson: QUALITY_GATE_PATH, + }); + + expect(result.aggregateCompletion?.status).toBe("complete"); + expect(result.ledgerEntry.kind).toBe("aggregate_completed"); + }); + + it("explains final task-scoped objective mapping when completed Codex objective is unrelated", async () => { + const repo = await repoWith(plan([passGoal("G001")], { activeGoalId: "G001" })); + await writeFile(ultragoalBriefPath(repo), "Fix ultragoal objective mismatch and install local ulw\n", "utf8"); + + await expect( + checkpointUltragoal(repo, { + goalId: "G001", + status: "complete", + evidence: "final implementation complete and quality gate passed", + codexGoalJson: snapshot("complete", "unrelated completed task"), + qualityGateJson: QUALITY_GATE_PATH, + }), + ).rejects.toThrow("Final task-scoped aggregate reconciliation"); + }); +}); + +describe("checkpointUltragoal status=failed", () => { + it("sets goal.status=failed, goal.failedAt, appends ledger", async () => { + const repo = await repoWith(plan([goal({ successCriteria: [criterion("C001", "pending")] })])); + const result = await checkpointUltragoal(repo, { goalId: "G001", status: "failed", evidence: "tests failed" }); + expect(result.goal.status).toBe("failed"); + expect(result.goal.failedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/u); + expect((await lastLedger(repo)).kind).toBe("goal_failed"); + }); + + it("classifies external authorization blocker signatures", async () => { + const repo = await repoWith(plan([goal()])); + const result = await checkpointUltragoal(repo, { goalId: "G001", status: "failed", evidence: "ghcr.io returned 401 authentication required because token missing" }); + expect(result.goal.blockerSignature).toBe("GHCR_PULL_ACCESS:HTTP_401_ANONYMOUS:GHCR_VISIBILITY_OR_CREDENTIAL_REQUIRED"); + }); + + it("after 3 same-signature blockers, marks needs_user_decision + nonRetriable", async () => { + const repo = await repoWith(plan([goal({ id: "G001", status: "failed", blockerSignature: "EXTERNAL_AUTHORIZATION_REQUIRED" }), goal({ id: "G002", status: "blocked", blockerSignature: "EXTERNAL_AUTHORIZATION_REQUIRED" }), goal({ id: "G003" })], { activeGoalId: "G003" })); + const result = await checkpointUltragoal(repo, { goalId: "G003", status: "failed", evidence: "Registry returned 401 because credentials are missing" }); + expect(result.goal.status).toBe("needs_user_decision"); + expect(result.goal.nonRetriable).toBe(true); + }); + + it("skips the criteria gate for failed status", async () => { + const repo = await repoWith(plan([goal({ successCriteria: [criterion("C001", "pending")] })])); + await expect(checkpointUltragoal(repo, { goalId: "G001", status: "failed", evidence: "not done" })).resolves.toMatchObject({ goal: { status: "failed" } }); + }); +}); + +describe("checkpointUltragoal status=blocked", () => { + it("preserves blocker fields + appends ledger", async () => { + const repo = await repoWith(plan([goal()])); + const result = await checkpointUltragoal(repo, { goalId: "G001", status: "blocked", evidence: "ghcr.io requires token and credentials are missing" }); + expect(result.goal.status).toBe("blocked"); + expect(result.goal.blockedReason).toContain("ghcr.io"); + expect(result.goal.blockerSignature).toContain("GHCR_PULL_ACCESS"); + expect((await lastLedger(repo)).kind).toBe("goal_blocked"); + }); + + it("skips the criteria gate for blocked status", async () => { + const repo = await repoWith(plan([goal({ successCriteria: [criterion("C001", "pending")] })])); + await expect(checkpointUltragoal(repo, { goalId: "G001", status: "blocked", evidence: "waiting for approval" })).resolves.toMatchObject({ goal: { status: "blocked" } }); + }); +}); + +describe("checkpointUltragoal rebrand", () => { + it("does not emit legacy brand token in any returned text or ledger payload", async () => { + const repo = await repoWith(plan([passGoal("G001"), goal({ id: "G002", status: "pending" })])); + const result = await checkpointUltragoal(repo, { goalId: "G001", status: "complete", evidence: "implementation done in .omo/ultragoal/goals.json for G001 and validation passed", codexGoalJson: snapshot("active") }); + const forbidden = ["o", "m", "x"].join(""); + const payload = `${JSON.stringify(result)}\n${await readFile(ultragoalLedgerPath(repo), "utf8")}`.toLowerCase(); + expect(payload).not.toContain(forbidden); + }); +}); diff --git a/packages/omo-codex/plugin/components/ultragoal/test/cli-commands.test.ts b/packages/omo-codex/plugin/components/ultragoal/test/cli-commands.test.ts new file mode 100644 index 000000000..6a6418868 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/test/cli-commands.test.ts @@ -0,0 +1,274 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ultragoalCommand } from "../src/cli-commands.ts"; +import { ULTRAGOAL_AGGREGATE_CODEX_OBJECTIVE } from "../src/goal-status.js"; + +let testDir: string; +let out: string[]; +let err: string[]; + +beforeEach(async () => { + testDir = await mkdtemp(join(tmpdir(), "ug-cli-")); + out = []; + err = []; + vi.spyOn(process, "cwd").mockReturnValue(testDir); + vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array): boolean => { + out.push(chunk.toString()); + return true; + }); + vi.spyOn(process.stderr, "write").mockImplementation((chunk: string | Uint8Array): boolean => { + err.push(chunk.toString()); + return true; + }); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await rm(testDir, { recursive: true, force: true }); +}); + +function resetOutput(): void { + out = []; + err = []; +} +function stdoutJson(): Record { + return JSON.parse(out.join("")); +} +function codexSnapshot(status: "active" | "complete" = "active"): string { + return JSON.stringify({ goal: { objective: ULTRAGOAL_AGGREGATE_CODEX_OBJECTIVE, status } }); +} + +async function createPlan(brief = "- Goal A\n- Goal B"): Promise> { + resetOutput(); + expect(await ultragoalCommand(["create-goals", "--brief", brief, "--json"])).toBe(0); + const parsed = stdoutJson(); + resetOutput(); + return parsed; +} + +async function passCriterion(goalId: string, criterionId: string): Promise { + expect( + await ultragoalCommand([ + "record-evidence", + "--goal-id", + goalId, + "--criterion-id", + criterionId, + "--status", + "pass", + "--evidence", + `${criterionId} observable proof`, + ]), + ).toBe(0); + resetOutput(); +} + +describe("ultragoalCommand help", () => { + it("prints usage when no subcommand", async () => { + expect(await ultragoalCommand([])).toBe(0); + expect(out.join("")).toContain("omo ultragoal"); + }); +}); + +describe("ultragoalCommand create-goals", () => { + it("creates plan + writes 3 artifacts + seeds criteria per goal", async () => { + const code = await ultragoalCommand(["create-goals", "--brief", "- Goal A\n- Goal B", "--json"]); + + expect(code).toBe(0); + const parsed = stdoutJson(); + expect(parsed).toMatchObject({ ok: true }); + expect(parsed).toHaveProperty("plan.goals.0.successCriteria.0.id", "C001"); + expect(await readFile(join(testDir, ".omo/ultragoal/brief.md"), "utf8")).toContain("Goal A"); + expect(await readFile(join(testDir, ".omo/ultragoal/goals.json"), "utf8")).toContain("successCriteria"); + expect(await readFile(join(testDir, ".omo/ultragoal/ledger.jsonl"), "utf8")).toContain("plan_created"); + }); +}); + +describe("ultragoalCommand status", () => { + it("prints plan summary including criteria counts", async () => { + await createPlan(); + + expect(await ultragoalCommand(["status"])).toBe(0); + expect(out.join("")).toContain("criteria: 0/6 pass"); + }); +}); + +describe("ultragoalCommand complete-goals", () => { + it("starts the next goal and returns a Codex instruction", async () => { + await createPlan(); + + expect(await ultragoalCommand(["complete-goals", "--json"])).toBe(0); + expect(stdoutJson()).toMatchObject({ + ok: true, + goal: { status: "in_progress" }, + instruction: { json: { status: "active" } }, + }); + }); +}); + +describe("ultragoalCommand record-evidence", () => { + it("records evidence + returns updated criterion", async () => { + await createPlan(); + + expect( + await ultragoalCommand([ + "record-evidence", + "--goal-id", + "G001-goal-a", + "--criterion-id", + "C001", + "--status", + "pass", + "--evidence", + "curl passed", + "--json", + ]), + ).toBe(0); + expect(stdoutJson()).toMatchObject({ + ok: true, + criterion: { id: "C001", status: "pass", capturedEvidence: "curl passed" }, + }); + }); + + it("returns 1 + error on unknown goal-id", async () => { + await createPlan(); + + expect( + await ultragoalCommand([ + "record-evidence", + "--goal-id", + "G404", + "--criterion-id", + "C001", + "--status", + "pass", + "--evidence", + "x", + ]), + ).toBe(1); + expect(err.join("")).toContain("[ultragoal]"); + }); + + it("returns 1 + error on missing flags", async () => { + expect( + await ultragoalCommand(["record-evidence", "--criterion-id", "C001", "--status", "pass", "--evidence", "x"]), + ).toBe(1); + expect(err.join("")).toContain("Missing --goal-id"); + }); +}); + +describe("ultragoalCommand criteria", () => { + it("lists criteria for a goal", async () => { + await createPlan(); + + expect(await ultragoalCommand(["criteria", "--goal-id", "G001-goal-a"])).toBe(0); + expect(out.join("")).toContain("C001"); + expect(out.join("")).toContain("happy"); + }); + + it("supports --json output", async () => { + await createPlan(); + + expect(await ultragoalCommand(["criteria", "--goal-id", "G001-goal-a", "--json"])).toBe(0); + expect(stdoutJson()).toMatchObject({ ok: true, goalId: "G001-goal-a" }); + expect(stdoutJson()).toHaveProperty("criteria.0.id", "C001"); + }); +}); + +describe("ultragoalCommand checkpoint", () => { + it("REJECTS status=complete when criteria pending", async () => { + await createPlan(); + + expect( + await ultragoalCommand([ + "checkpoint", + "--goal-id", + "G001-goal-a", + "--status", + "complete", + "--evidence", + "x", + "--codex-goal-json", + codexSnapshot(), + ]), + ).toBe(1); + expect(err.join("").toLowerCase()).toContain("criteria"); + }); + + it("ACCEPTS when all criteria pass", async () => { + await createPlan(); + await passCriterion("G001-goal-a", "C001"); + await passCriterion("G001-goal-a", "C002"); + await passCriterion("G001-goal-a", "C003"); + + expect( + await ultragoalCommand([ + "checkpoint", + "--goal-id", + "G001-goal-a", + "--status", + "complete", + "--evidence", + "implementation done and validation passed", + "--codex-goal-json", + codexSnapshot(), + "--json", + ]), + ).toBe(0); + expect(stdoutJson()).toHaveProperty("goal.status", "complete"); + }); +}); + +describe("ultragoalCommand steer", () => { + it("dispatches to the steering engine", async () => { + await createPlan(); + + expect( + await ultragoalCommand([ + "steer", + "--kind", + "add_subgoal", + "--title", + "Extra", + "--objective", + "Do extra", + "--evidence", + "user requested it", + "--rationale", + "keeps plan accurate", + "--json", + ]), + ).toBe(0); + expect(stdoutJson()).toMatchObject({ + ok: true, + accepted: true, + plan: { goals: [{ id: "G001-goal-a" }, { id: "G002-goal-b" }, { title: "Extra" }] }, + }); + }); +}); + +describe("ultragoalCommand add-goal", () => { + it("appends a pending goal", async () => { + await createPlan(); + + expect(await ultragoalCommand(["add-goal", "--title", "Later", "--objective", "Do later", "--json"])).toBe(0); + expect(stdoutJson()).toMatchObject({ ok: true, goal: { title: "Later", status: "pending" } }); + }); +}); + +describe("ultragoalCommand unknown", () => { + it("returns 1 + prints help on unknown subcommand", async () => { + expect(await ultragoalCommand(["wat"])).toBe(1); + expect(out.join("")).toContain("omo ultragoal"); + }); +}); + +describe("ultragoalCommand error handling", () => { + it("returns 1 + prints [ultragoal] prefix on UltragoalError", async () => { + expect(await ultragoalCommand(["status"])).toBe(1); + expect(err.join("")).toContain("[ultragoal]"); + }); +}); diff --git a/packages/omo-codex/plugin/components/ultragoal/test/cli-helpers.test.ts b/packages/omo-codex/plugin/components/ultragoal/test/cli-helpers.test.ts new file mode 100644 index 000000000..80e2193e5 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/test/cli-helpers.test.ts @@ -0,0 +1,250 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +import { + hasFlag, + parseGoalArg, + parseRecordEvidenceArgs, + positionalText, + readJsonInput, + readRepeated, + readValue, +} from "../src/cli-arg-parser.js"; +import { normalizeCodexGoalMode, printStatus, ULTRAGOAL_HELP } from "../src/cli-output.js"; +import type { UltragoalItem, UltragoalPlan, UltragoalSuccessCriterion } from "../src/types.js"; +import { UltragoalError } from "../src/types.js"; + +const NOW = "2026-05-23T00:00:00.000Z"; + +function criterion(overrides: Partial = {}): UltragoalSuccessCriterion { + return { + id: "C001", + scenario: "happy path returns 200", + userModel: "happy", + expectedEvidence: "HTTP 200", + capturedEvidence: null, + status: "pending", + ...overrides, + }; +} + +function goal(overrides: Partial = {}): UltragoalItem { + return { + id: "G001", + title: "Auth endpoint", + objective: "Build JWT auth", + status: "in_progress", + successCriteria: [ + criterion({ id: "C001", status: "pass" }), + criterion({ id: "C002", status: "pass" }), + criterion({ id: "C003" }), + ], + attempt: 1, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }; +} + +function plan(overrides: Partial = {}): UltragoalPlan { + return { + version: 1, + createdAt: NOW, + updatedAt: NOW, + briefPath: ".omo/ultragoal/brief.md", + goalsPath: ".omo/ultragoal/goals.json", + ledgerPath: ".omo/ultragoal/ledger.jsonl", + activeGoalId: "G001", + goals: [goal()], + ...overrides, + }; +} + +function captureStdout(action: () => void): string { + let output = ""; + const write = vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array): boolean => { + output += chunk.toString(); + return true; + }); + action(); + write.mockRestore(); + return output; +} + +describe("hasFlag", () => { + it("returns true for present flag", () => { + expect(hasFlag(["status", "--json"], "--json")).toBe(true); + }); + + it("returns false otherwise", () => { + expect(hasFlag(["status"], "--json")).toBe(false); + }); +}); + +describe("readValue", () => { + it("returns value after flag", () => { + expect(readValue(["criteria", "--goal-id", "G001"], "--goal-id")).toBe("G001"); + }); + + it("returns undefined when absent", () => { + expect(readValue(["criteria"], "--goal-id")).toBeUndefined(); + }); + + it("returns undefined when flag has no following value", () => { + expect(readValue(["criteria", "--goal-id"], "--goal-id")).toBeUndefined(); + }); +}); + +describe("readRepeated", () => { + it("collects all occurrences", () => { + expect(readRepeated(["create-goals", "--goal", "A", "--goal=B"], "--goal")).toEqual(["A", "B"]); + }); +}); + +describe("parseGoalArg", () => { + it("returns value of --goal-id or --goal", () => { + expect(parseGoalArg(["criteria", "--goal", "G002"])).toBe("G002"); + expect(parseGoalArg(["criteria", "--goal-id", "G001"])).toBe("G001"); + }); +}); + +describe("positionalText", () => { + it("returns joined positional args after subcommand", () => { + expect(positionalText(["create-goals", "Build", "auth", "--json", "--brief", "ignored"])).toBe("Build auth"); + }); +}); + +describe("readJsonInput", () => { + it("parses inline JSON when value looks like JSON", async () => { + await expect(readJsonInput('{"ok":true}')).resolves.toEqual({ ok: true }); + }); + + it("reads from file path", async () => { + const dir = await mkdtemp(join(tmpdir(), "ug-cli-json-")); + try { + const file = join(dir, "input.json"); + await writeFile(file, JSON.stringify({ fromFile: true }), "utf8"); + + await expect(readJsonInput(file)).resolves.toEqual({ fromFile: true }); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("returns undefined when value is undefined", async () => { + await expect(readJsonInput(undefined)).resolves.toBeUndefined(); + }); +}); + +describe("parseRecordEvidenceArgs", () => { + it("parses --goal-id + --criterion-id + --status + --evidence", () => { + expect( + parseRecordEvidenceArgs([ + "record-evidence", + "--goal-id", + "G001", + "--criterion-id", + "C001", + "--status", + "pass", + "--evidence", + "curl 200", + ]), + ).toEqual({ goalId: "G001", criterionId: "C001", status: "pass", evidence: "curl 200" }); + }); + + it("throws when goal-id missing", () => { + expect(() => + parseRecordEvidenceArgs(["record-evidence", "--criterion-id", "C001", "--status", "pass", "--evidence", "x"]), + ).toThrow(UltragoalError); + }); + + it("throws when status is not pass|fail|blocked", () => { + expect(() => + parseRecordEvidenceArgs([ + "record-evidence", + "--goal-id", + "G001", + "--criterion-id", + "C001", + "--status", + "skip", + "--evidence", + "x", + ]), + ).toThrow(UltragoalError); + }); + + it("includes optional --notes when present", () => { + expect( + parseRecordEvidenceArgs([ + "record-evidence", + "--goal-id", + "G001", + "--criterion-id", + "C001", + "--status", + "blocked", + "--evidence", + "auth missing", + "--notes", + "waiting", + ]), + ).toMatchObject({ notes: "waiting" }); + }); +}); + +describe("ULTRAGOAL_HELP", () => { + it("mentions omo ultragoal + every subcommand", () => { + expect(ULTRAGOAL_HELP).toContain("omo ultragoal"); + expect(ULTRAGOAL_HELP).toContain("create-goals"); + expect(ULTRAGOAL_HELP).toContain("complete-goals"); + expect(ULTRAGOAL_HELP).toContain("status"); + expect(ULTRAGOAL_HELP).toContain("checkpoint"); + expect(ULTRAGOAL_HELP).toContain("steer"); + expect(ULTRAGOAL_HELP).toContain("record-evidence"); + expect(ULTRAGOAL_HELP).toContain("criteria"); + expect(ULTRAGOAL_HELP).toContain("add-goal"); + expect(ULTRAGOAL_HELP).toContain("record-review-blockers"); + }); + + it("never mentions the legacy typo", () => { + const typo = ["o", "m", "x"].join(""); + + expect(ULTRAGOAL_HELP).not.toMatch(new RegExp(typo, "i")); + }); +}); + +describe("printStatus", () => { + it("shows criteria P/T per goal", () => { + const output = captureStdout(() => printStatus(plan())); + + expect(output).toContain("criteria: 2/3"); + }); + + it("shows aggregate counts", () => { + const output = captureStdout(() => + printStatus(plan({ goals: [goal(), goal({ id: "G002", successCriteria: [criterion({ status: "pass" })] })] })), + ); + + expect(output).toContain("total goals: 2"); + expect(output).toContain("criteria: 3/4 pass"); + }); +}); + +describe("normalizeCodexGoalMode", () => { + it("returns aggregate when undefined", () => { + expect(normalizeCodexGoalMode(undefined)).toBe("aggregate"); + }); + + it("returns the explicit value when valid", () => { + expect(normalizeCodexGoalMode("per_story")).toBe("per_story"); + }); + + it("throws UltragoalError when invalid", () => { + expect(() => normalizeCodexGoalMode("per-story")).toThrow(UltragoalError); + }); +}); diff --git a/packages/omo-codex/plugin/components/ultragoal/test/cli-steering.test.ts b/packages/omo-codex/plugin/components/ultragoal/test/cli-steering.test.ts new file mode 100644 index 000000000..0070ae354 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/test/cli-steering.test.ts @@ -0,0 +1,407 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +import { + normalizeSteeringProposal, + parseSteeringKind, + parseSteeringProposal, + parseSteeringSource, + printSteerResult, +} from "../src/cli-steering.js"; +import type { SteerUltragoalResult, UltragoalPlan } from "../src/types.js"; +import { UltragoalError } from "../src/types.js"; + +const NOW = "2026-05-23T00:00:00.000Z"; + +function plan(): UltragoalPlan { + return { + version: 1, + createdAt: NOW, + updatedAt: NOW, + briefPath: ".omo/ultragoal/brief.md", + goalsPath: ".omo/ultragoal/goals.json", + ledgerPath: ".omo/ultragoal/ledger.jsonl", + goals: [], + }; +} + +function steerResult(overrides: Partial = {}): SteerUltragoalResult { + return { + plan: plan(), + accepted: true, + audit: { + kind: "add_subgoal", + source: "cli", + targetGoalIds: ["G001"], + evidence: "x", + rationale: "y", + invariant: { + accepted: true, + structuralInvariantAccepted: true, + evidenceBackedNecessity: true, + noEasierCompletion: true, + rejectedReasons: [], + }, + }, + rejectedReasons: [], + deduped: false, + ...overrides, + }; +} + +function captureStdout(action: () => void): string { + let output = ""; + const write = vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array): boolean => { + output += chunk.toString(); + return true; + }); + action(); + write.mockRestore(); + return output; +} + +describe("parseSteeringKind", () => { + it("returns valid kind from --kind", () => { + expect(parseSteeringKind(["--kind", "add_subgoal"])).toBe("add_subgoal"); + }); + + it("accepts revise_criterion", () => { + expect(parseSteeringKind(["--kind", "revise_criterion"])).toBe("revise_criterion"); + }); + + it("throws when --kind missing", () => { + expect(() => parseSteeringKind([])).toThrow(UltragoalError); + }); + + it("throws when kind unknown", () => { + expect(() => parseSteeringKind(["--kind", "bogus"])).toThrow(UltragoalError); + }); +}); + +describe("parseSteeringSource", () => { + it("defaults to cli", () => { + expect(parseSteeringSource([])).toBe("cli"); + }); + + it("returns explicit value", () => { + expect(parseSteeringSource(["--source", "user_prompt_submit"])).toBe("user_prompt_submit"); + }); +}); + +describe("parseSteeringProposal add_subgoal", () => { + it("builds proposal from required flags", async () => { + const p = await parseSteeringProposal([ + "--kind", + "add_subgoal", + "--title", + " New ", + "--objective", + " Build ", + "--evidence", + " x ", + "--rationale", + " y ", + ]); + + expect(p).toMatchObject({ + kind: "add_subgoal", + source: "cli", + title: "New", + objective: "Build", + evidence: "x", + rationale: "y", + }); + }); + + it("throws when --title missing", async () => { + await expect( + parseSteeringProposal([ + "--kind", + "add_subgoal", + "--objective", + "Build", + "--evidence", + "x", + "--rationale", + "y", + ]), + ).rejects.toThrow(UltragoalError); + }); + + it("throws when --evidence missing", async () => { + await expect( + parseSteeringProposal(["--kind", "add_subgoal", "--title", "New", "--objective", "Build", "--rationale", "y"]), + ).rejects.toThrow(UltragoalError); + }); +}); + +describe("parseSteeringProposal revise_criterion", () => { + it("builds proposal with goal, criterion, scenario, evidence, and rationale", async () => { + const p = await parseSteeringProposal([ + "--kind", + "revise_criterion", + "--goal-id", + "G001", + "--criterion-id", + "C002", + "--scenario", + "new scenario", + "--evidence", + "x", + "--rationale", + "y", + ]); + + expect(p.kind).toBe("revise_criterion"); + expect(p.goalId).toBe("G001"); + expect(p.targetGoalId).toBe("G001"); + expect(p.criterionId).toBe("C002"); + expect(p.scenario).toBe("new scenario"); + }); + + it("accepts --expected-evidence as an update field", async () => { + const p = await parseSteeringProposal([ + "--kind", + "revise_criterion", + "--goal-id", + "G001", + "--criterion-id", + "C002", + "--expected-evidence", + "new evidence", + "--evidence", + "x", + "--rationale", + "y", + ]); + + expect(p.expectedEvidence).toBe("new evidence"); + }); + + it("accepts --user-model as an update field", async () => { + const p = await parseSteeringProposal([ + "--kind", + "revise_criterion", + "--goal-id", + "G001", + "--criterion-id", + "C002", + "--user-model", + "edge", + "--evidence", + "x", + "--rationale", + "y", + ]); + + expect(p.userModel).toBe("edge"); + }); + + it("throws when none of scenario/expected-evidence/user-model provided", async () => { + await expect( + parseSteeringProposal([ + "--kind", + "revise_criterion", + "--goal-id", + "G001", + "--criterion-id", + "C002", + "--evidence", + "x", + "--rationale", + "y", + ]), + ).rejects.toThrow(UltragoalError); + }); + + it("throws when goal-id missing", async () => { + await expect( + parseSteeringProposal([ + "--kind", + "revise_criterion", + "--criterion-id", + "C002", + "--scenario", + "s", + "--evidence", + "x", + "--rationale", + "y", + ]), + ).rejects.toThrow(UltragoalError); + }); + + it("throws when criterion-id missing", async () => { + await expect( + parseSteeringProposal([ + "--kind", + "revise_criterion", + "--goal-id", + "G001", + "--scenario", + "s", + "--evidence", + "x", + "--rationale", + "y", + ]), + ).rejects.toThrow(UltragoalError); + }); +}); + +describe("parseSteeringProposal split_subgoal", () => { + it("reads --children from inline JSON", async () => { + const p = await parseSteeringProposal([ + "--kind", + "split_subgoal", + "--goal-id", + "G001", + "--children", + '[{"title":"A","objective":"Do A"}]', + "--evidence", + "x", + "--rationale", + "y", + ]); + + expect(p.childGoals).toEqual([{ title: "A", objective: "Do A" }]); + }); + + it("reads --children from JSON file path", async () => { + const dir = await mkdtemp(join(tmpdir(), "ug-steer-")); + try { + const file = join(dir, "children.json"); + await writeFile(file, '[{"title":"B","objective":"Do B"}]', "utf8"); + + const p = await parseSteeringProposal([ + "--kind", + "split_subgoal", + "--goal-id", + "G001", + "--children", + file, + "--evidence", + "x", + "--rationale", + "y", + ]); + + expect(p.childGoals).toEqual([{ title: "B", objective: "Do B" }]); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +describe("parseSteeringProposal reorder_pending", () => { + it("reads --order from inline JSON array", async () => { + const p = await parseSteeringProposal([ + "--kind", + "reorder_pending", + "--order", + '["G002","G001"]', + "--evidence", + "x", + "--rationale", + "y", + ]); + + expect(p.pendingOrder).toEqual(["G002", "G001"]); + }); +}); + +describe("parseSteeringProposal remaining kinds", () => { + it("builds revise_pending_wording proposal", async () => { + const p = await parseSteeringProposal([ + "--kind", + "revise_pending_wording", + "--goal-id", + "G001", + "--title", + "New", + "--evidence", + "x", + "--rationale", + "y", + ]); + + expect(p).toMatchObject({ kind: "revise_pending_wording", targetGoalId: "G001", revisedTitle: "New" }); + }); + + it("builds mark_blocked_superseded proposal with replacements", async () => { + const p = await parseSteeringProposal([ + "--kind", + "mark_blocked_superseded", + "--goal-id", + "G001", + "--replacements", + '[{"title":"C","objective":"Do C"}]', + "--evidence", + "x", + "--rationale", + "y", + ]); + + expect(p).toMatchObject({ + kind: "mark_blocked_superseded", + targetGoalId: "G001", + childGoals: [{ title: "C", objective: "Do C" }], + }); + }); +}); + +describe("parseSteeringProposal annotate_ledger", () => { + it("builds minimal proposal", async () => { + const p = await parseSteeringProposal(["--kind", "annotate_ledger", "--evidence", "x", "--rationale", "y"]); + + expect(p).toMatchObject({ kind: "annotate_ledger", source: "cli", evidence: "x", rationale: "y" }); + }); +}); + +describe("normalizeSteeringProposal", () => { + it("trims string fields", () => { + const p = normalizeSteeringProposal({ + kind: "revise_criterion", + source: "cli", + goalId: " G001 ", + targetGoalId: " G001 ", + criterionId: " C002 ", + evidence: " x ", + rationale: " y ", + scenario: " z ", + }); + + expect(p).toMatchObject({ + goalId: "G001", + targetGoalId: "G001", + criterionId: "C002", + evidence: "x", + rationale: "y", + scenario: "z", + }); + }); + + it("rejects empty evidence after trim", () => { + expect(() => + normalizeSteeringProposal({ kind: "annotate_ledger", source: "cli", evidence: " ", rationale: "y" }), + ).toThrow(UltragoalError); + }); +}); + +describe("printSteerResult", () => { + it("prints JSON when json=true", () => { + const output = captureStdout(() => printSteerResult(steerResult(), true)); + + expect(JSON.parse(output)).toMatchObject({ accepted: true, deduped: false, audit: { kind: "add_subgoal" } }); + }); + + it("prints human-readable when json=false", () => { + const output = captureStdout(() => printSteerResult(steerResult(), false)); + + expect(output).toContain("ultragoal steer: accepted add_subgoal"); + expect(output).toContain("ultragoal status"); + }); +}); diff --git a/packages/omo-codex/plugin/components/ultragoal/test/codex-goal-instruction.test.ts b/packages/omo-codex/plugin/components/ultragoal/test/codex-goal-instruction.test.ts new file mode 100644 index 000000000..4dcb9d711 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/test/codex-goal-instruction.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; + +import { buildCodexGoalInstruction } from "../src/codex-goal-instruction.js"; +import { ULTRAGOAL_AGGREGATE_CODEX_OBJECTIVE } from "../src/goal-status.js"; +import type { UltragoalItem, UltragoalPlan, UltragoalSuccessCriterion } from "../src/types.js"; + +const NOW = "2026-05-23T00:00:00.000Z"; + +function makeCriterion(overrides: Partial = {}): UltragoalSuccessCriterion { + return { + id: "C001", + scenario: "happy path", + userModel: "happy", + expectedEvidence: "observable proof", + capturedEvidence: null, + status: "pending", + ...overrides, + }; +} + +function makeGoal(overrides: Partial = {}): UltragoalItem { + return { + id: "G001", + title: "Goal one", + objective: "Complete goal one", + status: "pending", + successCriteria: [], + attempt: 1, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }; +} + +function makePlan(overrides: Partial = {}): UltragoalPlan { + return { + version: 1, + createdAt: NOW, + updatedAt: NOW, + briefPath: ".omo/ultragoal/brief.md", + goalsPath: ".omo/ultragoal/goals.json", + ledgerPath: ".omo/ultragoal/ledger.jsonl", + goals: [], + ...overrides, + }; +} + +describe("buildCodexGoalInstruction aggregate mode", () => { + it("references the aggregate handoff and the .omo/ultragoal/goals.json artifact", () => { + const { text } = buildCodexGoalInstruction({ plan: makePlan({ codexGoalMode: "aggregate" }), goal: makeGoal() }); + expect(text).toContain("aggregate"); + expect(text).toContain(".omo/ultragoal/goals.json"); + }); + + it("given aggregate mode when rendering create_goal payload then omits numeric limits", () => { + const { json, text } = buildCodexGoalInstruction({ + plan: makePlan({ codexGoalMode: "aggregate" }), + goal: makeGoal(), + }); + expect(json).toEqual({ + objective: ULTRAGOAL_AGGREGATE_CODEX_OBJECTIVE, + status: "active", + }); + expect(text).toContain("objective and status only"); + expect(text).toContain("Goals are unlimited"); + expect(text).not.toMatch(/token[_-]?budget/i); + }); + + it("instructs not to call update_goal mid-aggregate when not final", () => { + const { text } = buildCodexGoalInstruction({ + plan: makePlan({ codexGoalMode: "aggregate" }), + goal: makeGoal(), + isFinal: false, + }); + expect(text).toMatch(/do not.*update_goal/i); + }); + + it("includes quality gate instruction when isFinal", () => { + const { text } = buildCodexGoalInstruction({ + plan: makePlan({ codexGoalMode: "aggregate" }), + goal: makeGoal(), + isFinal: true, + }); + expect(text).toMatch(/quality gate/i); + }); +}); + +describe("buildCodexGoalInstruction per_story mode", () => { + it("uses the goal's own objective for create_goal", () => { + const goal = makeGoal({ objective: "Build the auth service" }); + const { text } = buildCodexGoalInstruction({ plan: makePlan({ codexGoalMode: "per_story" }), goal }); + expect(text).toContain("Build the auth service"); + }); +}); + +describe("buildCodexGoalInstruction criteria section", () => { + it("lists every successCriteria entry with id + scenario + status", () => { + const goal = makeGoal({ + successCriteria: [ + makeCriterion({ + id: "C001", + scenario: "happy login", + userModel: "happy", + expectedEvidence: "200 OK", + status: "pending", + }), + makeCriterion({ + id: "C002", + scenario: "invalid creds", + userModel: "edge", + expectedEvidence: "401", + status: "pass", + }), + makeCriterion({ + id: "C003", + scenario: "no regression /health", + userModel: "regression", + expectedEvidence: "/health unaffected", + status: "fail", + }), + ], + }); + + const { text } = buildCodexGoalInstruction({ plan: makePlan(), goal }); + + expect(text).toContain("C001"); + expect(text).toContain("happy login"); + expect(text).toContain("pending"); + expect(text).toContain("C002"); + expect(text).toContain("pass"); + expect(text).toContain("C003"); + expect(text).toContain("fail"); + }); + + it("highlights pending criteria as remaining work", () => { + const goal = makeGoal({ successCriteria: [makeCriterion({ id: "C001", status: "pending" })] }); + const { text } = buildCodexGoalInstruction({ plan: makePlan(), goal }); + expect(text).toMatch(/remaining|pending/i); + }); +}); + +describe("buildCodexGoalInstruction rebrand audit", () => { + it("emits no legacy brand references in any rendered string", () => { + const legacyBrand = ["o", "m", "x"].join(""); + const { text } = buildCodexGoalInstruction({ plan: makePlan(), goal: makeGoal() }); + expect(text).not.toMatch(new RegExp(legacyBrand, "i")); + }); + + it("references .omo/ultragoal in artifact paths", () => { + const { text } = buildCodexGoalInstruction({ plan: makePlan({ codexGoalMode: "aggregate" }), goal: makeGoal() }); + expect(text).toContain(".omo/ultragoal"); + }); +}); diff --git a/packages/omo-codex/plugin/components/ultragoal/test/codex-goal-snapshot.test.ts b/packages/omo-codex/plugin/components/ultragoal/test/codex-goal-snapshot.test.ts new file mode 100644 index 000000000..a1e0d29d5 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/test/codex-goal-snapshot.test.ts @@ -0,0 +1,156 @@ +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + CodexGoalSnapshotError, + formatCodexGoalReconciliation, + parseCodexGoalSnapshot, + readCodexGoalSnapshotInput, + reconcileCodexGoalSnapshot, +} from "../src/codex-goal-snapshot.ts"; + +describe("parseCodexGoalSnapshot", () => { + it("returns available snapshot from { goal: { ... } } JSON", () => { + // given + const payload = { goal: { objective: "X", status: "active" } }; + + // when + const snapshot = parseCodexGoalSnapshot(payload); + + // then + expect(snapshot.available).toBe(true); + expect(snapshot.objective).toBe("X"); + expect(snapshot.status).toBe("active"); + }); + + it("ignores remaining token budget fields from goal snapshots", () => { + // given + const payload = { goal: { objective: "X", status: "active" }, remainingTokens: 123 }; + + // when + const snapshot = parseCodexGoalSnapshot(payload); + + // then + expect("remainingTokens" in snapshot).toBe(false); + }); + + it("returns unavailable snapshot from null", () => { + // when + const snapshot = parseCodexGoalSnapshot(null); + + // then + expect(snapshot.available).toBe(false); + }); + + it("returns unavailable snapshot from malformed payload", () => { + // when + const snapshot = parseCodexGoalSnapshot({ wrong: "shape" }); + + // then + expect(snapshot.available).toBe(false); + expect(snapshot.status).toBe("unknown"); + }); +}); + +describe("readCodexGoalSnapshotInput", () => { + let dir = ""; + + beforeEach(async () => { + // given + dir = await mkdtemp(join(tmpdir(), "ug-snap-")); + }); + + it("parses inline JSON string", async () => { + // when + const snapshot = await readCodexGoalSnapshotInput('{"goal":{"objective":"X","status":"active"}}'); + + // then + expect(snapshot?.available).toBe(true); + expect(snapshot?.objective).toBe("X"); + }); + + it("reads from file path", async () => { + // given + const filePath = join(dir, "snap.json"); + await writeFile(filePath, '{"goal":{"objective":"X","status":"complete"}}', "utf8"); + + // when + const snapshot = await readCodexGoalSnapshotInput(filePath); + + // then + expect(snapshot?.available).toBe(true); + expect(snapshot?.status).toBe("complete"); + }); + + it("reads from sample fixture path", async () => { + // given + const filePath = join(process.cwd(), "test", "fixtures", "codex-goal-snapshot.json"); + + // when + const snapshot = await readCodexGoalSnapshotInput(filePath); + + // then + expect(snapshot?.available).toBe(true); + expect(snapshot?.objective).toBe("Complete the durable ultragoal plan"); + }); + + it("throws CodexGoalSnapshotError when input is neither JSON nor a path", async () => { + // when/then + await expect(readCodexGoalSnapshotInput("not json and not a path")).rejects.toThrow(CodexGoalSnapshotError); + }); +}); + +describe("reconcileCodexGoalSnapshot", () => { + it("returns ok=true when snapshot matches expected", () => { + // when + const reconciliation = reconcileCodexGoalSnapshot( + { available: true, objective: "X", status: "active", raw: null }, + { expectedObjective: "X" }, + ); + + // then + expect(reconciliation.ok).toBe(true); + expect(reconciliation.errors).toHaveLength(0); + }); + + it("reports error when objective mismatches", () => { + // when + const reconciliation = reconcileCodexGoalSnapshot( + { available: true, objective: "X", status: "active", raw: null }, + { expectedObjective: "Y" }, + ); + + // then + expect(reconciliation.ok).toBe(false); + expect(reconciliation.errors.length).toBeGreaterThan(0); + }); + + it("reports error when status mismatches", () => { + // when + const reconciliation = reconcileCodexGoalSnapshot( + { available: true, objective: "X", status: "active", raw: null }, + { expectedObjective: "X", allowedStatuses: ["complete"] }, + ); + + // then + expect(reconciliation.ok).toBe(false); + expect(reconciliation.errors.length).toBeGreaterThan(0); + }); +}); + +describe("formatCodexGoalReconciliation", () => { + it("renders errors joined", () => { + // given + const reconciliation = reconcileCodexGoalSnapshot( + { available: true, objective: "X", status: "active", raw: null }, + { expectedObjective: "Y", allowedStatuses: ["complete"] }, + ); + + // when + const formatted = formatCodexGoalReconciliation(reconciliation); + + // then + expect(formatted).toMatch(/objective|status/i); + }); +}); diff --git a/packages/omo-codex/plugin/components/ultragoal/test/codex-hook.test.ts b/packages/omo-codex/plugin/components/ultragoal/test/codex-hook.test.ts new file mode 100644 index 000000000..c6be05e81 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/test/codex-hook.test.ts @@ -0,0 +1,187 @@ +import { mkdir, mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Readable, Writable } from "node:stream"; +import { describe, expect, it } from "vitest"; + +import { + applyUserPromptUltragoalSteering, + parseUserPromptSubmitPayload, + runUltragoalHookCli, + type UserPromptSubmitPayload, +} from "../src/codex-hook.js"; +import { ultragoalDir } from "../src/paths.js"; +import { writePlan } from "../src/plan-io.js"; +import type { UltragoalPlan } from "../src/types.js"; + +const NOW = "2026-05-23T00:00:00.000Z"; + +async function bootstrapPlanRepo(): Promise { + const repoRoot = await mkdtemp(join(tmpdir(), "ug-hook-")); + await mkdir(ultragoalDir(repoRoot), { recursive: true }); + await writePlan(repoRoot, samplePlan()); + return repoRoot; +} + +function samplePlan(): UltragoalPlan { + return { + version: 1, + createdAt: NOW, + updatedAt: NOW, + briefPath: ".omo/ultragoal/brief.md", + goalsPath: ".omo/ultragoal/goals.json", + ledgerPath: ".omo/ultragoal/ledger.jsonl", + goals: [ + { + id: "G001", + title: "Build hook", + objective: "Apply safe steering directives from Codex hooks.", + status: "pending", + successCriteria: [], + attempt: 0, + createdAt: NOW, + updatedAt: NOW, + }, + ], + }; +} + +function payload(prompt: string, cwd: string): UserPromptSubmitPayload { + return { cwd, hook_event_name: "UserPromptSubmit", prompt, session_id: "s1" }; +} + +function payloadWithRuntimeEvent(hookEventName: string): UserPromptSubmitPayload { + const input = payload( + 'OMO_ULTRAGOAL_STEER: {"kind":"annotate_ledger","source":"user_prompt_submit","evidence":"x","rationale":"y"}', + "/tmp", + ); + Object.defineProperty(input, "hook_event_name", { value: hookEventName }); + return input; +} + +function captureStdout(): { readonly stdout: Writable; readonly read: () => string } { + let captured = ""; + const stdout = new Writable({ + write(chunk: unknown, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void { + captured += chunk instanceof Buffer ? chunk.toString() : String(chunk); + callback(); + }, + }); + return { stdout, read: () => captured }; +} + +describe("parseUserPromptSubmitPayload", () => { + it("parses valid JSON payload", async () => { + const raw = await readFile("test/fixtures/user-prompt-submit.json", "utf8"); + const parsed = parseUserPromptSubmitPayload(raw); + expect(parsed?.hook_event_name).toBe("UserPromptSubmit"); + expect(parsed?.prompt).toContain("OMO_ULTRAGOAL_STEER"); + }); + + it("returns null for empty input", () => { + expect(parseUserPromptSubmitPayload("")).toBeNull(); + }); + + it("returns null for invalid JSON", () => { + expect(parseUserPromptSubmitPayload("{bad")).toBeNull(); + }); + + it("returns null when hook_event_name missing", () => { + expect(parseUserPromptSubmitPayload(JSON.stringify({ cwd: "/repo", prompt: "x", session_id: "s1" }))).toBeNull(); + }); +}); + +describe("applyUserPromptUltragoalSteering - OMO directive patterns", () => { + it("processes OMO_ULTRAGOAL_STEER: prompt and returns audit text on success", async () => { + const repoRoot = await bootstrapPlanRepo(); + const out = await applyUserPromptUltragoalSteering( + payload( + 'OMO_ULTRAGOAL_STEER: {"kind":"annotate_ledger","source":"user_prompt_submit","evidence":"x","rationale":"y"}', + repoRoot, + ), + ); + expect(out.length).toBeGreaterThan(0); + expect(out).toContain("annotate_ledger"); + }); + + it("processes omo.ultragoal.steer: pattern", async () => { + const repoRoot = await bootstrapPlanRepo(); + const out = await applyUserPromptUltragoalSteering( + payload( + 'omo.ultragoal.steer: {"kind":"annotate_ledger","source":"user_prompt_submit","evidence":"x","rationale":"y"}', + repoRoot, + ), + ); + expect(out).toContain("accepted"); + }); + + it("processes omo ultragoal steer: pattern", async () => { + const repoRoot = await bootstrapPlanRepo(); + const out = await applyUserPromptUltragoalSteering( + payload( + 'omo ultragoal steer: {"kind":"annotate_ledger","source":"user_prompt_submit","evidence":"x","rationale":"y"}', + repoRoot, + ), + ); + expect(out).toContain("annotate_ledger"); + }); +}); + +describe("applyUserPromptUltragoalSteering - non-matching prompts", () => { + it("returns empty string when no directive in prompt", async () => { + expect(await applyUserPromptUltragoalSteering(payload("just a normal user message", "/tmp"))).toBe(""); + }); + + it("returns empty for OMX_ULTRAGOAL_STEER (deprecated marker - must reject)", async () => { + expect( + await applyUserPromptUltragoalSteering( + payload('OMX_ULTRAGOAL_STEER: {"kind":"annotate_ledger","evidence":"x","rationale":"y"}', "/tmp"), + ), + ).toBe(""); + }); + + it("returns empty when hook_event_name is not UserPromptSubmit", async () => { + expect(await applyUserPromptUltragoalSteering(payloadWithRuntimeEvent("PostToolUse"))).toBe(""); + }); +}); + +describe("applyUserPromptUltragoalSteering - error swallowing", () => { + it("returns empty (never throws) when plan does not exist", async () => { + const repoRoot = await mkdtemp(join(tmpdir(), "ug-nohook-")); + const out = await applyUserPromptUltragoalSteering( + payload( + 'OMO_ULTRAGOAL_STEER: {"kind":"annotate_ledger","source":"user_prompt_submit","evidence":"x","rationale":"y"}', + repoRoot, + ), + ); + expect(out).toBe(""); + }); + + it("returns empty when steering proposal is malformed JSON after marker", async () => { + const out = await applyUserPromptUltragoalSteering(payload("OMO_ULTRAGOAL_STEER: {bad", "/tmp")); + expect(out).toBe(""); + }); +}); + +describe("runUltragoalHookCli (stdin/stdout integration)", () => { + it("reads stdin, applies steering, writes audit to stdout", async () => { + const repoRoot = await bootstrapPlanRepo(); + const stdin = Readable.from([ + JSON.stringify( + payload( + 'OMO_ULTRAGOAL_STEER: {"kind":"annotate_ledger","source":"user_prompt_submit","evidence":"x","rationale":"y"}', + repoRoot, + ), + ), + ]); + const capture = captureStdout(); + await runUltragoalHookCli(stdin, capture.stdout); + expect(capture.read().length).toBeGreaterThan(0); + }); + + it("writes nothing when stdin is empty", async () => { + const capture = captureStdout(); + await runUltragoalHookCli(Readable.from([""]), capture.stdout); + expect(capture.read()).toBe(""); + }); +}); diff --git a/packages/omo-codex/plugin/components/ultragoal/test/evidence-criteria-gate.test.ts b/packages/omo-codex/plugin/components/ultragoal/test/evidence-criteria-gate.test.ts new file mode 100644 index 000000000..ee7452f18 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/test/evidence-criteria-gate.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; + +import { requireAllCriteriaPass } from "../src/evidence.js"; +import type { UltragoalItem, UltragoalSuccessCriterion } from "../src/types.js"; +import { UltragoalError } from "../src/types.js"; + +const NOW = "2026-05-23T00:00:00.000Z"; + +function makeCriterion(overrides: Partial = {}): UltragoalSuccessCriterion { + return { + id: "C001", + scenario: "happy path login returns 200", + userModel: "happy", + expectedEvidence: "curl /login -d {valid} returns 200 + token", + capturedEvidence: null, + status: "pending", + ...overrides, + }; +} + +function makeGoal(overrides: Partial = {}): UltragoalItem { + return { + id: "G001", + title: "Auth endpoint", + objective: "Build JWT auth", + status: "in_progress", + successCriteria: [ + makeCriterion({ id: "C001" }), + makeCriterion({ id: "C002", userModel: "edge" }), + makeCriterion({ id: "C003", userModel: "regression" }), + ], + attempt: 1, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }; +} + +describe("requireAllCriteriaPass", () => { + it("does NOT throw when all criteria pass", () => { + // given + const goal = makeGoal({ + successCriteria: [ + makeCriterion({ id: "C001", status: "pass" }), + makeCriterion({ id: "C002", status: "pass" }), + ], + }); + + // when / then + expect(() => requireAllCriteriaPass(goal)).not.toThrow(); + }); + + it("throws UltragoalError when any criterion pending", () => { + // given + const goal = makeGoal({ + successCriteria: [ + makeCriterion({ id: "C001", status: "pass" }), + makeCriterion({ id: "C002", status: "pending" }), + makeCriterion({ id: "C003", status: "pass" }), + ], + }); + + // when / then + expect(() => requireAllCriteriaPass(goal)).toThrow(UltragoalError); + }); + + it("throws when any fail/blocked too", () => { + // given + const goal1 = makeGoal({ successCriteria: [makeCriterion({ id: "C001", status: "fail" })] }); + const goal2 = makeGoal({ successCriteria: [makeCriterion({ id: "C001", status: "blocked" })] }); + + // when / then + expect(() => requireAllCriteriaPass(goal1)).toThrow(UltragoalError); + expect(() => requireAllCriteriaPass(goal2)).toThrow(UltragoalError); + }); + + it("UltragoalError includes details.goalId + details.unresolved", () => { + // given + const goal = makeGoal({ + id: "G001", + successCriteria: [ + makeCriterion({ id: "C001", status: "pass" }), + makeCriterion({ id: "C002", status: "pending" }), + makeCriterion({ id: "C003", status: "pass" }), + ], + }); + + // when / then + try { + requireAllCriteriaPass(goal); + expect.fail("expected throw"); + } catch (error) { + expect(error).toBeInstanceOf(UltragoalError); + if (!(error instanceof UltragoalError)) throw error; + expect(error.code).toBe("ultragoal_criteria_not_all_pass"); + expect(error.details?.["goalId"]).toBe("G001"); + expect(Array.isArray(error.details?.["unresolved"])).toBe(true); + } + }); +}); diff --git a/packages/omo-codex/plugin/components/ultragoal/test/evidence.test.ts b/packages/omo-codex/plugin/components/ultragoal/test/evidence.test.ts new file mode 100644 index 000000000..77243e2d5 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/test/evidence.test.ts @@ -0,0 +1,263 @@ +import { mkdir, mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { + criteriaSummary, + markCriteriaPendingResetForGoal, + recordEvidence, + unresolvedCriteriaOf, +} from "../src/evidence.js"; +import { ultragoalDir } from "../src/paths.js"; +import { readUltragoalPlan, writePlan } from "../src/plan-io.js"; +import type { UltragoalItem, UltragoalLedgerEntry, UltragoalPlan, UltragoalSuccessCriterion } from "../src/types.js"; +import { UltragoalError } from "../src/types.js"; + +const NOW = "2026-05-23T00:00:00.000Z"; + +async function bootstrapRepo(plan: UltragoalPlan): Promise { + const repo = await mkdtemp(join(tmpdir(), "ug-evidence-")); + await mkdir(ultragoalDir(repo), { recursive: true }); + await writePlan(repo, plan); + return repo; +} + +async function readLastLedgerEntry(repo: string): Promise { + const lines = (await readFile(join(repo, ".omo/ultragoal/ledger.jsonl"), "utf8")).trim().split("\n"); + const last = lines.at(-1); + if (last === undefined) throw new Error("expected ledger entry"); + return JSON.parse(last); +} + +function firstGoal(plan: UltragoalPlan): UltragoalItem { + const goal = plan.goals.at(0); + if (goal === undefined) throw new Error("expected goal"); + return goal; +} + +function makeCriterion(overrides: Partial = {}): UltragoalSuccessCriterion { + return { + id: "C001", + scenario: "happy path login returns 200", + userModel: "happy", + expectedEvidence: "curl /login -d {valid} returns 200 + token", + capturedEvidence: null, + status: "pending", + ...overrides, + }; +} + +function makeGoal(overrides: Partial = {}): UltragoalItem { + return { + id: "G001", + title: "Auth endpoint", + objective: "Build JWT auth", + status: "in_progress", + successCriteria: [ + makeCriterion({ id: "C001" }), + makeCriterion({ id: "C002", userModel: "edge" }), + makeCriterion({ id: "C003", userModel: "regression" }), + ], + attempt: 1, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }; +} + +function makePlan(overrides: Partial = {}): UltragoalPlan { + return { + version: 1, + createdAt: NOW, + updatedAt: NOW, + briefPath: ".omo/ultragoal/brief.md", + goalsPath: ".omo/ultragoal/goals.json", + ledgerPath: ".omo/ultragoal/ledger.jsonl", + codexGoalMode: "aggregate", + codexObjective: "Complete the durable ultragoal plan in .omo/ultragoal/goals.json", + codexObjectiveAliases: [], + goals: [makeGoal()], + ...overrides, + }; +} + +describe("recordEvidence (status=pass)", () => { + it("sets criterion.status=pass + capturedEvidence + capturedAt", async () => { + const repo = await bootstrapRepo(makePlan()); + + const result = await recordEvidence(repo, { + goalId: "G001", + criterionId: "C001", + status: "pass", + evidence: "curl /login returns 200 + token verified", + }); + + expect(result.criterion.status).toBe("pass"); + expect(result.criterion.capturedEvidence).toContain("curl /login returns 200"); + expect(result.criterion.capturedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it("appends evidence_captured ledger event", async () => { + const repo = await bootstrapRepo(makePlan()); + + await recordEvidence(repo, { goalId: "G001", criterionId: "C001", status: "pass", evidence: "observable proof" }); + + const last = await readLastLedgerEntry(repo); + expect(last.kind).toBe("evidence_captured"); + expect(last.goalId).toBe("G001"); + expect(last.criterionId).toBe("C001"); + }); + + it("persists the change so a fresh read sees status=pass", async () => { + const repo = await bootstrapRepo(makePlan()); + + await recordEvidence(repo, { goalId: "G001", criterionId: "C001", status: "pass", evidence: "observable proof" }); + + const criterion = firstGoal(await readUltragoalPlan(repo)).successCriteria.find((c) => c.id === "C001"); + expect(criterion?.status).toBe("pass"); + }); +}); + +describe("recordEvidence (status=fail)", () => { + it("sets criterion.status=fail + appends criterion_failed event", async () => { + const repo = await bootstrapRepo(makePlan()); + + const result = await recordEvidence(repo, { + goalId: "G001", + criterionId: "C001", + status: "fail", + evidence: "got 500 not 200", + }); + + expect(result.criterion.status).toBe("fail"); + expect((await readLastLedgerEntry(repo)).kind).toBe("criterion_failed"); + }); +}); + +describe("recordEvidence (status=blocked)", () => { + it("sets criterion.status=blocked + appends criterion_blocked event", async () => { + const repo = await bootstrapRepo(makePlan()); + + const result = await recordEvidence(repo, { + goalId: "G001", + criterionId: "C001", + status: "blocked", + evidence: "auth not in CI yet", + }); + + expect(result.criterion.status).toBe("blocked"); + expect((await readLastLedgerEntry(repo)).kind).toBe("criterion_blocked"); + }); +}); + +describe("recordEvidence error cases", () => { + it("throws when goalId not found", async () => { + const repo = await bootstrapRepo(makePlan()); + + await expect( + recordEvidence(repo, { goalId: "GUNKNOWN", criterionId: "C001", status: "pass", evidence: "x" }), + ).rejects.toBeInstanceOf(UltragoalError); + }); + + it("throws when criterionId not found within goal", async () => { + const repo = await bootstrapRepo(makePlan()); + + await expect( + recordEvidence(repo, { goalId: "G001", criterionId: "CUNKNOWN", status: "pass", evidence: "x" }), + ).rejects.toBeInstanceOf(UltragoalError); + }); + + it("throws when evidence is empty/whitespace", async () => { + const repo = await bootstrapRepo(makePlan()); + + await expect( + recordEvidence(repo, { goalId: "G001", criterionId: "C001", status: "pass", evidence: " " }), + ).rejects.toBeInstanceOf(UltragoalError); + }); +}); + +describe("markCriteriaPendingResetForGoal", () => { + it("resets every criterion of the goal to pending + capturedEvidence=null", async () => { + const goal = makeGoal({ + successCriteria: [ + makeCriterion({ id: "C001", status: "pass", capturedEvidence: "old" }), + makeCriterion({ id: "C002", status: "fail", capturedEvidence: "older" }), + makeCriterion({ id: "C003", status: "blocked", capturedEvidence: "oldest" }), + ], + }); + const repo = await bootstrapRepo(makePlan({ goals: [goal] })); + + const result = await markCriteriaPendingResetForGoal(repo, "G001"); + + expect(result.resetCount).toBe(3); + for (const c of firstGoal(result.plan).successCriteria) { + expect(c.status).toBe("pending"); + expect(c.capturedEvidence).toBeNull(); + } + }); + + it("appends a single criteria_revised ledger event describing the reset", async () => { + const repo = await bootstrapRepo(makePlan()); + + await markCriteriaPendingResetForGoal(repo, "G001"); + + expect((await readLastLedgerEntry(repo)).kind).toBe("criteria_revised"); + }); +}); + +describe("criteriaSummary (pure)", () => { + it("aggregates counts across all goals", () => { + const plan = makePlan({ + goals: [ + makeGoal({ + id: "G001", + successCriteria: [ + makeCriterion({ id: "C001", status: "pass" }), + makeCriterion({ id: "C002", status: "pending" }), + ], + }), + makeGoal({ + id: "G002", + successCriteria: [ + makeCriterion({ id: "C001", status: "fail" }), + makeCriterion({ id: "C002", status: "blocked" }), + makeCriterion({ id: "C003", status: "pass" }), + ], + }), + ], + }); + + const summary = criteriaSummary(plan); + + expect(summary.totalCriteria).toBe(5); + expect(summary.passCount).toBe(2); + expect(summary.pendingCount).toBe(1); + expect(summary.failCount).toBe(1); + expect(summary.blockedCount).toBe(1); + expect(summary.goalsWithUnresolvedCriteria).toEqual(["G001", "G002"]); + }); + + it("returns empty when no criteria exist", () => { + const summary = criteriaSummary(makePlan({ goals: [makeGoal({ successCriteria: [] })] })); + + expect(summary.totalCriteria).toBe(0); + expect(summary.goalsWithUnresolvedCriteria).toEqual([]); + }); +}); + +describe("unresolvedCriteriaOf (pure)", () => { + it("returns only non-pass criteria", () => { + const goal = makeGoal({ + successCriteria: [ + makeCriterion({ id: "C001", status: "pass" }), + makeCriterion({ id: "C002", status: "pending" }), + makeCriterion({ id: "C003", status: "fail" }), + ], + }); + + const unresolved = unresolvedCriteriaOf(goal); + + expect(unresolved.map((c) => c.id)).toEqual(["C002", "C003"]); + }); +}); diff --git a/packages/omo-codex/plugin/components/ultragoal/test/fixtures/.gitkeep b/packages/omo-codex/plugin/components/ultragoal/test/fixtures/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/omo-codex/plugin/components/ultragoal/test/fixtures/codex-goal-snapshot.json b/packages/omo-codex/plugin/components/ultragoal/test/fixtures/codex-goal-snapshot.json new file mode 100644 index 000000000..f88a3c5e7 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/test/fixtures/codex-goal-snapshot.json @@ -0,0 +1 @@ +{ "goal": { "objective": "Complete the durable ultragoal plan", "status": "active" } } diff --git a/packages/omo-codex/plugin/components/ultragoal/test/fixtures/sample-brief.md b/packages/omo-codex/plugin/components/ultragoal/test/fixtures/sample-brief.md new file mode 100644 index 000000000..e7c653e35 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/test/fixtures/sample-brief.md @@ -0,0 +1,5 @@ +# Auth service feature brief + +- Build the JWT auth endpoint +- Add IP rate limiting on login +- Write the integration test suite diff --git a/packages/omo-codex/plugin/components/ultragoal/test/fixtures/sample-plan.json b/packages/omo-codex/plugin/components/ultragoal/test/fixtures/sample-plan.json new file mode 100644 index 000000000..f49feee34 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/test/fixtures/sample-plan.json @@ -0,0 +1,108 @@ +{ + "version": 1, + "createdAt": "2026-05-23T00:00:00.000Z", + "codexGoalMode": "aggregate", + "codexObjective": "Complete the durable ultragoal plan in .omo/ultragoal/goals.json...", + "codexObjectiveAliases": [], + "goals": [ + { + "id": "G001", + "title": "Build auth service", + "objective": "Implement JWT auth endpoint", + "status": "pending", + "successCriteria": [ + { + "id": "C001", + "scenario": "valid login returns 200", + "userModel": "happy", + "expectedEvidence": "curl /login -d '{...}' returns 200 + token", + "capturedEvidence": null, + "status": "pending" + }, + { + "id": "C002", + "scenario": "invalid creds return 401", + "userModel": "edge", + "expectedEvidence": "curl /login -d '{bad}' returns 401", + "capturedEvidence": null, + "status": "pending" + }, + { + "id": "C003", + "scenario": "no regression in /health", + "userModel": "regression", + "expectedEvidence": "GET /health returns 200 OK after auth merge", + "capturedEvidence": null, + "status": "pending" + } + ] + }, + { + "id": "G002", + "title": "Add rate limiting", + "objective": "Throttle login by IP", + "status": "in_progress", + "successCriteria": [ + { + "id": "C001", + "scenario": "limit kicks at N reqs", + "userModel": "happy", + "expectedEvidence": "100 reqs from same IP -> last is 429", + "capturedEvidence": null, + "status": "pending" + }, + { + "id": "C002", + "scenario": "different IPs not affected", + "userModel": "edge", + "expectedEvidence": "concurrent 2 IPs both succeed", + "capturedEvidence": null, + "status": "pending" + }, + { + "id": "C003", + "scenario": "limiter does not block /health", + "userModel": "regression", + "expectedEvidence": "/health unaffected during throttle", + "capturedEvidence": null, + "status": "pending" + } + ] + }, + { + "id": "G003", + "title": "Integration tests", + "objective": "End-to-end suite", + "status": "complete", + "successCriteria": [ + { + "id": "C001", + "scenario": "all int tests green", + "userModel": "happy", + "expectedEvidence": "npm run test:integration exit 0", + "capturedEvidence": "npm run test:integration exit 0, 12/12 tests", + "status": "pass", + "capturedAt": "2026-05-23T00:30:00.000Z" + }, + { + "id": "C002", + "scenario": "no flaky 3x rerun", + "userModel": "edge", + "expectedEvidence": "3 reruns all green", + "capturedEvidence": "3 reruns all green, no flakes", + "status": "pass", + "capturedAt": "2026-05-23T00:31:00.000Z" + }, + { + "id": "C003", + "scenario": "no new console errors", + "userModel": "regression", + "expectedEvidence": "0 errors in build log", + "capturedEvidence": "no console errors", + "status": "pass", + "capturedAt": "2026-05-23T00:32:00.000Z" + } + ] + } + ] +} diff --git a/packages/omo-codex/plugin/components/ultragoal/test/fixtures/sample-quality-gate.json b/packages/omo-codex/plugin/components/ultragoal/test/fixtures/sample-quality-gate.json new file mode 100644 index 000000000..fb63bbb92 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/test/fixtures/sample-quality-gate.json @@ -0,0 +1,18 @@ +{ + "aiSlopCleaner": { "status": "passed", "evidence": "no slop detected after cleaner run" }, + "verification": { + "status": "passed", + "commands": ["npm test", "npm run build"], + "evidence": "all tests pass + build green" + }, + "codeReview": { + "recommendation": "APPROVE", + "architectStatus": "CLEAR", + "evidence": "review synthesis: ship it" + }, + "criteriaCoverage": { + "totalCriteria": 9, + "passCount": 9, + "adversarialClassesCovered": ["malformed_input", "prompt_injection", "stale_state"] + } +} diff --git a/packages/omo-codex/plugin/components/ultragoal/test/fixtures/steering-proposal.json b/packages/omo-codex/plugin/components/ultragoal/test/fixtures/steering-proposal.json new file mode 100644 index 000000000..7a1e567d6 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/test/fixtures/steering-proposal.json @@ -0,0 +1,8 @@ +{ + "kind": "add_subgoal", + "title": "Investigate auth blocker", + "objective": "Validate the blocker, capture evidence, and report findings.", + "evidence": "log/test output showing the blocker", + "rationale": "blocker materially changes safe execution order", + "source": "cli" +} diff --git a/packages/omo-codex/plugin/components/ultragoal/test/fixtures/user-prompt-submit.json b/packages/omo-codex/plugin/components/ultragoal/test/fixtures/user-prompt-submit.json new file mode 100644 index 000000000..0f6e079e3 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/test/fixtures/user-prompt-submit.json @@ -0,0 +1,10 @@ +{ + "cwd": "/repo", + "hook_event_name": "UserPromptSubmit", + "model": "gpt-5.5", + "permission_mode": "default", + "prompt": "OMO_ULTRAGOAL_STEER: {\"kind\":\"annotate_ledger\",\"source\":\"user_prompt_submit\",\"evidence\":\"test note\",\"rationale\":\"testing hook\"}", + "session_id": "s1", + "transcript_path": "/tmp/transcript.log", + "turn_id": "t1" +} diff --git a/packages/omo-codex/plugin/components/ultragoal/test/goal-status.test.ts b/packages/omo-codex/plugin/components/ultragoal/test/goal-status.test.ts new file mode 100644 index 000000000..9b79b8bb4 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/test/goal-status.test.ts @@ -0,0 +1,327 @@ +import { describe, expect, it } from "vitest"; + +import { + aggregateCodexObjective, + codexGoalMode, + compatibleCodexObjectives, + expectedCodexObjective, + firstUnresolvedCriterion, + hasAllCriteriaPass, + isFinalRunCompletionCandidate, + isUltragoalDone, + ULTRAGOAL_AGGREGATE_CODEX_OBJECTIVE, +} from "../src/goal-status.js"; +import type { UltragoalItem, UltragoalPlan, UltragoalSuccessCriterion } from "../src/types.js"; + +const NOW = "2026-05-23T00:00:00.000Z"; + +function makeCriterion(overrides: Partial = {}): UltragoalSuccessCriterion { + return { + id: "C001", + scenario: "happy path", + userModel: "happy", + expectedEvidence: "observable proof", + capturedEvidence: null, + status: "pending", + ...overrides, + }; +} + +function makeGoal(overrides: Partial = {}): UltragoalItem { + return { + id: "G001", + title: "Goal one", + objective: "Complete goal one", + status: "pending", + successCriteria: [], + attempt: 1, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }; +} + +function makePlan(overrides: Partial = {}): UltragoalPlan { + return { + version: 1, + createdAt: NOW, + updatedAt: NOW, + briefPath: ".omo/ultragoal/brief.md", + goalsPath: ".omo/ultragoal/goals.json", + ledgerPath: ".omo/ultragoal/ledger.jsonl", + goals: [], + ...overrides, + }; +} + +describe("isUltragoalDone", () => { + it("returns true when all goals complete", () => { + // given + const plan = makePlan({ + goals: [makeGoal({ status: "complete" }), makeGoal({ id: "G002", status: "complete" })], + }); + + // when + const done = isUltragoalDone(plan); + + // then + expect(done).toBe(true); + }); + + it("returns false when any pending remains", () => { + // given + const plan = makePlan({ goals: [makeGoal({ status: "complete" }), makeGoal({ id: "G002", status: "pending" })] }); + + // when + const done = isUltragoalDone(plan); + + // then + expect(done).toBe(false); + }); + + it("treats superseded-with-complete-replacements as resolved", () => { + // given + const replacement = makeGoal({ id: "G002", status: "complete" }); + const superseded = makeGoal({ + id: "G001", + status: "pending", + steeringStatus: "superseded", + supersededBy: [replacement.id], + }); + const plan = makePlan({ goals: [superseded, replacement] }); + + // when + const done = isUltragoalDone(plan); + + // then + expect(done).toBe(true); + }); +}); + +describe("isFinalRunCompletionCandidate", () => { + it("returns true when only one unresolved goal remains", () => { + // given + const finalGoal = makeGoal({ id: "G002", status: "pending" }); + const plan = makePlan({ goals: [makeGoal({ status: "complete" }), finalGoal] }); + + // when + const candidate = isFinalRunCompletionCandidate(plan, finalGoal); + + // then + expect(candidate).toBe(true); + }); + + it("returns false when multiple unresolved", () => { + // given + const goal = makeGoal({ id: "G001", status: "pending" }); + const plan = makePlan({ goals: [goal, makeGoal({ id: "G002", status: "pending" })] }); + + // when + const candidate = isFinalRunCompletionCandidate(plan, goal); + + // then + expect(candidate).toBe(false); + }); +}); + +describe("codexGoalMode", () => { + it("defaults to per_story when undefined", () => { + // when + const mode = codexGoalMode(makePlan()); + + // then + expect(mode).toBe("per_story"); + }); + + it("returns aggregate when explicitly aggregate", () => { + // when + const mode = codexGoalMode(makePlan({ codexGoalMode: "aggregate" })); + + // then + expect(mode).toBe("aggregate"); + }); +}); + +describe("expectedCodexObjective", () => { + it("aggregate mode returns plan.codexObjective", () => { + // given + const goal = makeGoal({ objective: "story objective" }); + const plan = makePlan({ codexGoalMode: "aggregate", codexObjective: "aggregate objective" }); + + // when + const objective = expectedCodexObjective(plan, goal); + + // then + expect(objective).toBe("aggregate objective"); + }); + + it("aggregate mode falls back to ULTRAGOAL_AGGREGATE_CODEX_OBJECTIVE when codexObjective missing", () => { + // given + const goal = makeGoal({ objective: "story objective" }); + const plan = makePlan({ codexGoalMode: "aggregate" }); + + // when + const objective = expectedCodexObjective(plan, goal); + + // then + expect(objective).toBe(ULTRAGOAL_AGGREGATE_CODEX_OBJECTIVE); + }); + + it("per_story mode returns goal.objective", () => { + // given + const goal = makeGoal({ objective: "story objective" }); + const plan = makePlan({ codexGoalMode: "per_story", codexObjective: "aggregate objective" }); + + // when + const objective = expectedCodexObjective(plan, goal); + + // then + expect(objective).toBe("story objective"); + }); +}); + +describe("aggregateCodexObjective", () => { + it("returns plan.codexObjective when set", () => { + // when + const objective = aggregateCodexObjective(makePlan({ codexObjective: "aggregate objective" })); + + // then + expect(objective).toBe("aggregate objective"); + }); + + it("falls back to ULTRAGOAL_AGGREGATE_CODEX_OBJECTIVE", () => { + // when + const objective = aggregateCodexObjective(makePlan()); + + // then + expect(objective).toBe(ULTRAGOAL_AGGREGATE_CODEX_OBJECTIVE); + }); +}); + +describe("compatibleCodexObjectives", () => { + it("includes aggregate objective + aliases", () => { + // given + const plan = makePlan({ + codexObjective: "aggregate objective", + codexObjectiveAliases: ["legacy one", "legacy two"], + }); + + // when + const objectives = compatibleCodexObjectives(plan); + + // then + expect(objectives).toEqual(["aggregate objective", "legacy one", "legacy two"]); + }); +}); + +describe("hasAllCriteriaPass", () => { + it("returns true when all criteria pass", () => { + // given + const goal = makeGoal({ + successCriteria: [makeCriterion({ status: "pass" }), makeCriterion({ id: "C002", status: "pass" })], + }); + + // when + const passed = hasAllCriteriaPass(goal); + + // then + expect(passed).toBe(true); + }); + + it("returns false when any criterion pending", () => { + // given + const goal = makeGoal({ + successCriteria: [makeCriterion({ status: "pass" }), makeCriterion({ id: "C002", status: "pending" })], + }); + + // when + const passed = hasAllCriteriaPass(goal); + + // then + expect(passed).toBe(false); + }); + + it("returns false when any criterion fail", () => { + // given + const goal = makeGoal({ + successCriteria: [makeCriterion({ status: "pass" }), makeCriterion({ id: "C002", status: "fail" })], + }); + + // when + const passed = hasAllCriteriaPass(goal); + + // then + expect(passed).toBe(false); + }); + + it("returns false when any criterion blocked", () => { + // given + const goal = makeGoal({ + successCriteria: [makeCriterion({ status: "pass" }), makeCriterion({ id: "C002", status: "blocked" })], + }); + + // when + const passed = hasAllCriteriaPass(goal); + + // then + expect(passed).toBe(false); + }); + + it("returns false for empty criteria array", () => { + // when + const passed = hasAllCriteriaPass(makeGoal({ successCriteria: [] })); + + // then + expect(passed).toBe(false); + }); +}); + +describe("firstUnresolvedCriterion", () => { + it("returns first non-pass criterion", () => { + // given + const unresolved = makeCriterion({ id: "C002", status: "fail" }); + const goal = makeGoal({ successCriteria: [makeCriterion({ status: "pass" }), unresolved] }); + + // when + const criterion = firstUnresolvedCriterion(goal); + + // then + expect(criterion).toBe(unresolved); + }); + + it("returns undefined when all pass", () => { + // given + const goal = makeGoal({ + successCriteria: [makeCriterion({ status: "pass" }), makeCriterion({ id: "C002", status: "pass" })], + }); + + // when + const criterion = firstUnresolvedCriterion(goal); + + // then + expect(criterion).toBeUndefined(); + }); + + it("returns first pending in mixed pass/pending/fail", () => { + // given + const pending = makeCriterion({ id: "C002", status: "pending" }); + const goal = makeGoal({ + successCriteria: [makeCriterion({ status: "pass" }), pending, makeCriterion({ id: "C003", status: "fail" })], + }); + + // when + const criterion = firstUnresolvedCriterion(goal); + + // then + expect(criterion).toBe(pending); + }); +}); + +describe("ULTRAGOAL_AGGREGATE_CODEX_OBJECTIVE", () => { + it("references the .omo/ultragoal path and excludes the legacy workspace", () => { + const legacyWorkspace = [".", "om", "x"].join(""); + + expect(ULTRAGOAL_AGGREGATE_CODEX_OBJECTIVE).toContain(".omo/ultragoal"); + expect(ULTRAGOAL_AGGREGATE_CODEX_OBJECTIVE).not.toContain(legacyWorkspace); + }); +}); diff --git a/packages/omo-codex/plugin/components/ultragoal/test/package-smoke.test.ts b/packages/omo-codex/plugin/components/ultragoal/test/package-smoke.test.ts new file mode 100644 index 000000000..a1181e524 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/test/package-smoke.test.ts @@ -0,0 +1,106 @@ +// biome-ignore-all format: smoke test pulls verbatim JSON for structural assertion. +import { readFile, stat } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +async function readText(relative: string): Promise { + return readFile(join(repoRoot, relative), "utf8"); +} + +async function readJson(relative: string): Promise { + return JSON.parse(await readText(relative)); +} + +describe("package.json", () => { + it("declares ESM + npm + Node >=20", async () => { + const pkg = await readJson("package.json") as Record; + expect(pkg["type"]).toBe("module"); + expect(pkg["packageManager"]).toBe("npm@11.12.1"); + expect((pkg["engines"] as Record)["node"]).toBe(">=20.0.0"); + }); + + it("exposes the omo binary pointing at dist/cli.js", async () => { + const pkg = await readJson("package.json") as Record; + const bin = pkg["bin"] as Record; + expect(bin["omo"]).toBe("./dist/cli.js"); + }); + + it("ships the expected files for npm publish", async () => { + const pkg = await readJson("package.json") as Record; + const files = pkg["files"] as readonly string[]; + expect(files).toContain("dist"); + expect(files).toContain("hooks"); + expect(files).toContain("skills"); + expect(files).not.toContain(".codex-plugin"); + }); +}); + +describe("component plugin identity", () => { + it("is owned by the aggregate OMO plugin root", async () => { + await expect(readText(".codex-plugin/plugin.json")).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); + +describe("hooks/hooks.json", () => { + it("registers UserPromptSubmit with PLUGIN_ROOT interpolation", async () => { + const hooks = await readJson("hooks/hooks.json") as Record; + const events = (hooks["hooks"] as Record)["UserPromptSubmit"] as readonly Record[]; + expect(events.length).toBeGreaterThan(0); + const command = ((events[0]?.["hooks"] as readonly Record[])[0]?.["command"]) as string; + expect(command).toContain(`$${"{PLUGIN_ROOT}"}`); + expect(command).toContain("dist/cli.js"); + expect(command).toContain("hook user-prompt-submit"); + }); +}); + +describe("src/cli.ts", () => { + it("starts with #!/usr/bin/env node shebang", async () => { + const text = await readText("src/cli.ts"); + expect(text.split("\n")[0]).toBe("#!/usr/bin/env node"); + }); +}); + +describe("skills/ultragoal/SKILL.md", () => { + it("exists", async () => { + const info = await stat(join(repoRoot, "skills/ultragoal/SKILL.md")); + expect(info.isFile()).toBe(true); + }); + + it("contains no omx references", async () => { + const text = await readText("skills/ultragoal/SKILL.md"); + expect(text.toLowerCase()).not.toContain("omx"); + }); + + it("references the success criteria and record-evidence vocabulary", async () => { + const text = await readText("skills/ultragoal/SKILL.md"); + expect(text.toLowerCase()).toMatch(/success criteria|successcriteria/); + expect(text.toLowerCase()).toContain("record-evidence"); + }); + + it("uses the .omo workspace path", async () => { + const text = await readText("skills/ultragoal/SKILL.md"); + expect(text).toContain(".omo/ultragoal"); + }); +}); + +describe("source LOC budget", () => { + it("every source file stays at or under 250 pure LOC", async () => { + const files = [ + "src/types.ts", "src/paths.ts", "src/plan-io.ts", "src/plan-crud.ts", "src/goal-status.ts", + "src/evidence.ts", "src/quality-gate.ts", "src/checkpoint.ts", "src/review-blockers.ts", + "src/steering.ts", "src/codex-goal-instruction.ts", "src/codex-goal-snapshot.ts", "src/codex-hook.ts", + "src/cli.ts", "src/cli-arg-parser.ts", "src/cli-output.ts", "src/cli-steering.ts", "src/cli-commands.ts", + ]; + for (const file of files) { + const text = await readText(file); + const pure = text.split("\n").filter((line) => { + const trimmed = line.trim(); + return trimmed.length > 0 && !trimmed.startsWith("//"); + }).length; + expect(pure, `${file} pure LOC`).toBeLessThanOrEqual(250); + } + }); +}); diff --git a/packages/omo-codex/plugin/components/ultragoal/test/paths.test.ts b/packages/omo-codex/plugin/components/ultragoal/test/paths.test.ts new file mode 100644 index 000000000..aa35f4600 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/test/paths.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { + repoRelative, + ultragoalBriefPath, + ultragoalDir, + ultragoalGoalsPath, + ultragoalLedgerPath, +} from "../src/paths.ts"; + +describe("ultragoalDir(repo)", () => { + it("returns repo + '/.omo/ultragoal'", () => { + // when/then + expect(ultragoalDir("/repo")).toBe("/repo/.omo/ultragoal"); + }); +}); + +describe("ultragoal*Path helpers", () => { + it("compose artifact filenames under ultragoalDir", () => { + // when/then + expect(ultragoalBriefPath("/r")).toBe("/r/.omo/ultragoal/brief.md"); + expect(ultragoalGoalsPath("/r")).toBe("/r/.omo/ultragoal/goals.json"); + expect(ultragoalLedgerPath("/r")).toBe("/r/.omo/ultragoal/ledger.jsonl"); + }); +}); + +describe("repoRelative", () => { + it("strips repo prefix when path is inside repo", () => { + // when/then + expect(repoRelative("/repo/.omo/ultragoal/goals.json", "/repo")).toBe(".omo/ultragoal/goals.json"); + }); + + it("returns absolute when path is outside repo", () => { + // when/then + expect(repoRelative("/elsewhere/file", "/repo")).toBe("/elsewhere/file"); + }); +}); diff --git a/packages/omo-codex/plugin/components/ultragoal/test/plan-crud.test.ts b/packages/omo-codex/plugin/components/ultragoal/test/plan-crud.test.ts new file mode 100644 index 000000000..4d3d366cf --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/test/plan-crud.test.ts @@ -0,0 +1,256 @@ +import { mkdir, mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { ultragoalBriefPath, ultragoalGoalsPath, ultragoalLedgerPath } from "../src/paths.js"; +import { + addUltragoalGoal, + createUltragoalPlan, + deriveGoalCandidates, + seedDefaultSuccessCriteria, + startNextUltragoal, + summarizeUltragoalPlan, +} from "../src/plan-crud.js"; +import { writePlan } from "../src/plan-io.js"; +import type { UltragoalItem, UltragoalPlan, UltragoalSuccessCriterion } from "../src/types.js"; +import { UltragoalError } from "../src/types.js"; + +const NOW = "2026-05-23T00:00:00.000Z"; + +async function makeRepo(): Promise { + return mkdtemp(join(tmpdir(), "ug-crud-")); +} + +async function readBriefFixture(): Promise { + return readFile(join(process.cwd(), "test", "fixtures", "sample-brief.md"), "utf8"); +} + +async function ledgerKinds(repoRoot: string): Promise { + const raw = await readFile(ultragoalLedgerPath(repoRoot), "utf8"); + return raw + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line).kind); +} + +function criterion(status: UltragoalSuccessCriterion["status"]): UltragoalSuccessCriterion { + const [base] = seedDefaultSuccessCriteria(0, "Implement auth endpoint"); + if (base === undefined) throw new Error("expected seeded criterion"); + return { ...base, status }; +} + +function makeGoal(overrides: Partial = {}): UltragoalItem { + return { + id: "G001", + title: "Build auth service", + objective: "Implement JWT auth endpoint", + status: "pending", + successCriteria: seedDefaultSuccessCriteria(0, "Implement JWT auth endpoint"), + attempt: 0, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }; +} + +function makePlan(goals: UltragoalItem[]): UltragoalPlan { + return { + version: 1, + createdAt: NOW, + updatedAt: NOW, + briefPath: ".omo/ultragoal/brief.md", + goalsPath: ".omo/ultragoal/goals.json", + ledgerPath: ".omo/ultragoal/ledger.jsonl", + codexGoalMode: "aggregate", + goals, + }; +} + +function scheduled(result: Awaited>) { + if ("done" in result) throw new Error("expected scheduled goal"); + return result; +} + +describe("seedDefaultSuccessCriteria", () => { + it("produces 3 criteria with C001/C002/C003 ids", () => { + const cs = seedDefaultSuccessCriteria(0, "Implement auth endpoint"); + expect(cs).toHaveLength(3); + expect(cs.map((c) => c.id)).toEqual(["C001", "C002", "C003"]); + }); + + it("covers happy + edge + regression user models", () => { + const cs = seedDefaultSuccessCriteria(0, "Implement auth endpoint"); + expect(cs.map((c) => c.userModel).sort()).toEqual(["edge", "happy", "regression"]); + }); + + it("seeds all criteria as pending with null capturedEvidence", () => { + const cs = seedDefaultSuccessCriteria(0, "Implement auth endpoint"); + for (const c of cs) { + expect(c.status).toBe("pending"); + expect(c.capturedEvidence).toBeNull(); + } + }); +}); + +describe("createUltragoalPlan", () => { + it("creates .omo/ultragoal/{brief.md, goals.json, ledger.jsonl} in repoRoot", async () => { + const repoRoot = await makeRepo(); + const brief = await readBriefFixture(); + + await createUltragoalPlan(repoRoot, { brief }); + + expect(await readFile(ultragoalBriefPath(repoRoot), "utf8")).toBe(brief.endsWith("\n") ? brief : `${brief}\n`); + expect(await readFile(ultragoalGoalsPath(repoRoot), "utf8")).toContain("G001-build-the-jwt-auth-endpoint"); + expect(await ledgerKinds(repoRoot)).toEqual(["plan_created"]); + }); + + it("seeds at least 3 successCriteria per goal", async () => { + const plan = await createUltragoalPlan(await makeRepo(), { brief: await readBriefFixture() }); + + expect(plan.goals).toHaveLength(3); + expect(plan.goals.every((goal) => goal.successCriteria.length >= 3)).toBe(true); + }); + + it("refuses overwrite of an existing plan without --force", async () => { + const repoRoot = await makeRepo(); + await createUltragoalPlan(repoRoot, { brief: "first" }); + + await expect(createUltragoalPlan(repoRoot, { brief: "second" })).rejects.toThrow(UltragoalError); + await expect(createUltragoalPlan(repoRoot, { brief: "second" })).rejects.toThrow("Refusing to overwrite"); + }); + + it("aggregate is the default codexGoalMode", async () => { + const plan = await createUltragoalPlan(await makeRepo(), { brief: "Ship the feature" }); + + expect(plan.codexGoalMode).toBe("aggregate"); + expect(plan.codexObjective).toContain(".omo/ultragoal/goals.json"); + }); +}); + +describe("deriveGoalCandidates", () => { + it("extracts bullets as goals", () => { + expect(deriveGoalCandidates("# Brief\n\n- Build auth\n- Add tests")).toEqual([ + { title: "Build auth", objective: "Build auth" }, + { title: "Add tests", objective: "Add tests" }, + ]); + }); + + it("falls back to paragraph parsing when no bullets", () => { + expect(deriveGoalCandidates("First objective.\n\nSecond objective.").map((goal) => goal.objective)).toEqual([ + "First objective.", + "Second objective.", + ]); + }); + + it("returns single default goal for empty/whitespace brief", () => { + expect(deriveGoalCandidates(" \n\t ")).toEqual([ + { title: "Complete the requested project objective.", objective: "Complete the requested project objective." }, + ]); + }); +}); + +describe("addUltragoalGoal", () => { + it("appends a new goal to plan with seeded successCriteria", async () => { + const repoRoot = await makeRepo(); + await createUltragoalPlan(repoRoot, { brief: "Build auth" }); + + const { plan, goal } = await addUltragoalGoal(repoRoot, { title: "Add rate limit", objective: "Throttle login" }); + + expect(plan.goals).toHaveLength(2); + expect(goal.id).toBe("G002-add-rate-limit"); + expect(goal.successCriteria).toHaveLength(3); + }); + + it("appends a ledger entry for goal_added", async () => { + const repoRoot = await makeRepo(); + await createUltragoalPlan(repoRoot, { brief: "Build auth" }); + + await addUltragoalGoal(repoRoot, { title: "Add rate limit", objective: "Throttle login" }); + + expect(await ledgerKinds(repoRoot)).toEqual(["plan_created", "goal_added"]); + }); +}); + +describe("startNextUltragoal", () => { + it("picks the first pending goal", async () => { + const repoRoot = await makeRepo(); + await createUltragoalPlan(repoRoot, { brief: "- First\n- Second" }); + + const result = scheduled(await startNextUltragoal(repoRoot, {})); + + expect(result.goal.id).toBe("G001-first"); + expect(result.goal.status).toBe("in_progress"); + expect(result.resumed).toBe(false); + }); + + it("resumes the in_progress goal when one exists", async () => { + const repoRoot = await makeRepo(); + const plan = await createUltragoalPlan(repoRoot, { brief: "- First\n- Second" }); + const active = makeGoal({ ...plan.goals[1], status: "in_progress" }); + await writePlan(repoRoot, { ...plan, goals: [makeGoal({ ...plan.goals[0] }), active], activeGoalId: active.id }); + + const result = scheduled(await startNextUltragoal(repoRoot, {})); + + expect(result.goal.id).toBe(active.id); + expect(result.resumed).toBe(true); + }); + + it("with retryFailed picks first failed (non-blocked) goal", async () => { + const repoRoot = await makeRepo(); + const failed = makeGoal({ status: "failed", failureReason: "flake" }); + await mkdir(join(repoRoot, ".omo", "ultragoal"), { recursive: true }); + await writePlan(repoRoot, makePlan([failed])); + + const result = scheduled(await startNextUltragoal(repoRoot, { retryFailed: true })); + + expect(result.goal.id).toBe("G001"); + expect(result.goal.attempt).toBe(1); + expect(await ledgerKinds(repoRoot)).toEqual(["goal_retried", "goal_started"]); + }); + + it("returns { done: true } when no eligible goals remain", async () => { + const repoRoot = await makeRepo(); + await mkdir(join(repoRoot, ".omo", "ultragoal"), { recursive: true }); + await writePlan(repoRoot, makePlan([makeGoal({ status: "complete" })])); + + const result = await startNextUltragoal(repoRoot, {}); + + expect(result).toMatchObject({ done: true }); + }); +}); + +describe("summarizeUltragoalPlan", () => { + it("counts goals by status", () => { + const plan = makePlan([ + makeGoal({ id: "G001", status: "pending" }), + makeGoal({ id: "G002", status: "in_progress" }), + makeGoal({ id: "G003", status: "complete" }), + makeGoal({ id: "G004", status: "failed" }), + makeGoal({ id: "G005", status: "blocked", steeringStatus: "blocked" }), + makeGoal({ id: "G006", status: "review_blocked" }), + makeGoal({ id: "G007", status: "needs_user_decision", steeringStatus: "superseded" }), + ]); + + expect(summarizeUltragoalPlan(plan)).toMatchObject({ + total: 7, + pending: 1, + in_progress: 1, + complete: 1, + failed: 1, + blocked: 1, + review_blocked: 1, + needs_user_decision: 1, + superseded: 1, + }); + }); + + it("aggregates criteria pass/pending/fail/blocked across all goals", () => { + const plan = makePlan([ + makeGoal({ successCriteria: [criterion("pass"), criterion("pending")] }), + makeGoal({ id: "G002", successCriteria: [criterion("fail"), criterion("blocked"), criterion("pending")] }), + ]); + + expect(summarizeUltragoalPlan(plan).criteria).toEqual({ total: 5, pass: 1, pending: 2, fail: 1, blocked: 1 }); + }); +}); diff --git a/packages/omo-codex/plugin/components/ultragoal/test/plan-io.test.ts b/packages/omo-codex/plugin/components/ultragoal/test/plan-io.test.ts new file mode 100644 index 000000000..9791e5d9c --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/test/plan-io.test.ts @@ -0,0 +1,239 @@ +import { copyFile, mkdir, mkdtemp, readdir, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; +import { ultragoalDir, ultragoalGoalsPath, ultragoalLedgerPath } from "../src/paths.js"; +import { + appendLedger, + readSteeringLedgerEntries, + readUltragoalPlan, + withUltragoalMutationLock, + writePlan, +} from "../src/plan-io.js"; +import type { UltragoalItem, UltragoalLedgerEntry, UltragoalPlan } from "../src/types.js"; +import { UltragoalError } from "../src/types.js"; + +const NOW = "2026-05-23T00:00:00.000Z"; +const STABLE_OBJECTIVE = + "Complete the durable ultragoal plan in .omo/ultragoal/goals.json, including later accepted/appended stories, under the original brief constraints; use .omo/ultragoal/ledger.jsonl as the audit trail."; + +function makeGoal(overrides: Partial = {}): UltragoalItem { + return { + id: "G001", + title: "Build auth service", + objective: "Implement JWT auth endpoint", + status: "pending", + successCriteria: [], + attempt: 1, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }; +} + +function makePlan(overrides: Partial = {}): UltragoalPlan { + return { + version: 1, + createdAt: NOW, + updatedAt: NOW, + briefPath: ".omo/ultragoal/brief.md", + goalsPath: ".omo/ultragoal/goals.json", + ledgerPath: ".omo/ultragoal/ledger.jsonl", + codexGoalMode: "aggregate", + codexObjective: STABLE_OBJECTIVE, + codexObjectiveAliases: [], + goals: [makeGoal()], + ...overrides, + }; +} + +function entry(kind: UltragoalLedgerEntry["kind"], goalId = "G001"): UltragoalLedgerEntry { + return { at: NOW, kind, goalId }; +} + +async function makeRepo(): Promise { + return mkdtemp(join(tmpdir(), "ug-io-")); +} + +async function writeRawPlan(repoRoot: string, plan: UltragoalPlan): Promise { + await mkdir(ultragoalDir(repoRoot), { recursive: true }); + await writeFile(ultragoalGoalsPath(repoRoot), `${JSON.stringify(plan, null, 2)}\n`, "utf8"); +} + +async function readLedgerLines(repoRoot: string): Promise { + const raw = await readFile(ultragoalLedgerPath(repoRoot), "utf8"); + return raw.split(/\r?\n/).filter(Boolean); +} + +describe("readUltragoalPlan", () => { + let repoRoot = ""; + + beforeEach(async () => { + // given + repoRoot = await makeRepo(); + }); + + it("throws UltragoalError when goals.json is missing", async () => { + // when/then + await expect(readUltragoalPlan(repoRoot)).rejects.toThrow(UltragoalError); + await expect(readUltragoalPlan(repoRoot)).rejects.toThrow("omo ultragoal create-goals"); + }); + + it("returns parsed plan when fixture is present", async () => { + // given + await mkdir(ultragoalDir(repoRoot), { recursive: true }); + await copyFile(join(process.cwd(), "test", "fixtures", "sample-plan.json"), ultragoalGoalsPath(repoRoot)); + + // when + const plan = await readUltragoalPlan(repoRoot); + + // then + expect(plan.version).toBe(1); + expect(plan.codexGoalMode).toBe("aggregate"); + expect(plan.goals).toHaveLength(3); + expect(plan.goals[0]?.successCriteria).toHaveLength(3); + }); + + it("migrates legacy aggregate objective on read + writes aggregate_objective_migrated ledger entry + retains alias", async () => { + // given + const legacyObjective = "Complete all ultragoal stories in .omo/ultragoal/goals.json: G001 Build auth service"; + await writeRawPlan(repoRoot, makePlan({ codexObjective: legacyObjective })); + + // when + const plan = await readUltragoalPlan(repoRoot); + + // then + expect(plan.codexObjective).toBe(STABLE_OBJECTIVE); + expect(plan.codexObjectiveAliases).toContain(legacyObjective); + const persisted = JSON.parse(await readFile(ultragoalGoalsPath(repoRoot), "utf8")); + expect(persisted).toMatchObject({ codexObjective: STABLE_OBJECTIVE, codexObjectiveAliases: [legacyObjective] }); + const lines = await readLedgerLines(repoRoot); + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0] ?? "{}")).toMatchObject({ + kind: "aggregate_objective_migrated", + before: { codexObjective: legacyObjective }, + }); + }); +}); + +describe("writePlan", () => { + it("writes goals.json atomically with no temp file left behind", async () => { + // given + const repoRoot = await makeRepo(); + + // when + await writePlan(repoRoot, makePlan()); + + // then + const raw = await readFile(ultragoalGoalsPath(repoRoot), "utf8"); + expect(JSON.parse(raw)).toMatchObject({ version: 1, goals: [{ id: "G001" }] }); + expect((await readdir(ultragoalDir(repoRoot))).filter((name) => name.endsWith(".tmp"))).toEqual([]); + }); + + it("overwrites existing file", async () => { + // given + const repoRoot = await makeRepo(); + await writePlan(repoRoot, makePlan({ codexObjective: "first" })); + + // when + await writePlan(repoRoot, makePlan({ codexObjective: "second" })); + + // then + expect(JSON.parse(await readFile(ultragoalGoalsPath(repoRoot), "utf8"))).toMatchObject({ + codexObjective: "second", + }); + }); +}); + +describe("appendLedger", () => { + it("appends a single JSONL line to ledger.jsonl", async () => { + // given + const repoRoot = await makeRepo(); + const ledgerEntry = entry("goal_started"); + + // when + await appendLedger(repoRoot, ledgerEntry); + + // then + expect(await readLedgerLines(repoRoot)).toEqual([JSON.stringify(ledgerEntry)]); + }); + + it("creates ledger.jsonl if missing", async () => { + // given + const repoRoot = await makeRepo(); + + // when + await appendLedger(repoRoot, entry("goal_completed")); + + // then + expect(await readFile(ultragoalLedgerPath(repoRoot), "utf8")).toContain("goal_completed"); + }); + + it("preserves prior entries", async () => { + // given + const repoRoot = await makeRepo(); + const first = entry("goal_started"); + const second = entry("goal_completed"); + + // when + await appendLedger(repoRoot, first); + await appendLedger(repoRoot, second); + + // then + expect(await readLedgerLines(repoRoot)).toEqual([JSON.stringify(first), JSON.stringify(second)]); + }); +}); + +describe("readSteeringLedgerEntries", () => { + it("returns only steering-related event kinds", async () => { + // given + const repoRoot = await makeRepo(); + await appendLedger(repoRoot, entry("steering_accepted")); + await appendLedger(repoRoot, entry("goal_started")); + await appendLedger(repoRoot, entry("steering_rejected")); + await appendLedger(repoRoot, entry("criteria_revised")); + + // when + const entries = await readSteeringLedgerEntries(repoRoot); + + // then + expect(entries.map((item) => item.kind)).toEqual(["steering_accepted", "steering_rejected", "criteria_revised"]); + }); + + it("returns empty array when ledger missing", async () => { + // given + const repoRoot = await makeRepo(); + + // when/then + await expect(readSteeringLedgerEntries(repoRoot)).resolves.toEqual([]); + }); +}); + +describe("withUltragoalMutationLock", () => { + it("serializes concurrent invocations", async () => { + // given + const repoRoot = await makeRepo(); + const counterPath = join(repoRoot, "counter.txt"); + let active = 0; + let maxActive = 0; + await writeFile(counterPath, "0", "utf8"); + + // when + await Promise.all( + [1, 2, 3].map((_) => + withUltragoalMutationLock(repoRoot, async () => { + active += 1; + maxActive = Math.max(maxActive, active); + const current = Number(await readFile(counterPath, "utf8")); + await Promise.resolve(); + await writeFile(counterPath, String(current + 1), "utf8"); + active -= 1; + }), + ), + ); + + // then + expect(maxActive).toBe(1); + expect(await readFile(counterPath, "utf8")).toBe("3"); + }); +}); diff --git a/packages/omo-codex/plugin/components/ultragoal/test/quality-gate.test.ts b/packages/omo-codex/plugin/components/ultragoal/test/quality-gate.test.ts new file mode 100644 index 000000000..afb975739 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/test/quality-gate.test.ts @@ -0,0 +1,203 @@ +import { readFile } from "node:fs/promises"; + +import { describe, expect, it } from "vitest"; + +import { + classifyExternalAuthorizationBlocker, + clearGoalBlockerFields, + normalizeBlockerEvidence, + sameBlockerOccurrences, + validateQualityGate, +} from "../src/quality-gate.js"; +import type { UltragoalItem, UltragoalPlan } from "../src/types.js"; +import { UltragoalError } from "../src/types.js"; + +const NOW = "2026-05-23T00:00:00.000Z"; +const VALID_GATE = { + aiSlopCleaner: { status: "passed", evidence: "no slop detected after cleaner run" }, + verification: { status: "passed", commands: ["npm test"], evidence: "all tests pass" }, + codeReview: { recommendation: "APPROVE", architectStatus: "CLEAR", evidence: "ship it" }, + criteriaCoverage: { totalCriteria: 2, passCount: 2, adversarialClassesCovered: ["malformed_input"] }, +} as const; + +interface GoalWithBlocker extends UltragoalItem { + blocker?: { readonly signature: string }; + blockerEvidence?: string; + blockerOccurrences?: number; + blockedAt?: string; +} + +function makeGate(overrides: Record = {}): Record { + return { ...VALID_GATE, ...overrides }; +} + +function getQualityGateError(input: unknown): UltragoalError { + try { + validateQualityGate(input); + } catch (error) { + if (error instanceof UltragoalError) return error; + throw error; + } + throw new Error("Expected UltragoalError"); +} + +function makeGoal(overrides: Partial = {}): UltragoalItem { + return { + id: "G001", + title: "Goal one", + objective: "Complete goal one", + status: "pending", + successCriteria: [], + attempt: 1, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }; +} + +function makePlan(goals: UltragoalItem[]): UltragoalPlan { + return { + version: 1, + createdAt: NOW, + updatedAt: NOW, + briefPath: ".omo/ultragoal/brief.md", + goalsPath: ".omo/ultragoal/goals.json", + ledgerPath: ".omo/ultragoal/ledger.jsonl", + goals, + }; +} + +describe("validateQualityGate", () => { + it("accepts valid quality gate from fixture", async () => { + // given + const raw = await readFile(new URL("./fixtures/sample-quality-gate.json", import.meta.url), "utf8"); + const parsed: unknown = JSON.parse(raw); + + // when + const gate = validateQualityGate(parsed); + + // then + expect(gate.aiSlopCleaner.status).toBe("passed"); + expect(gate).toMatchObject({ criteriaCoverage: { totalCriteria: 9, passCount: 9 } }); + }); + + it("throws UltragoalError when aiSlopCleaner missing", () => { + // when + const error = getQualityGateError(makeGate({ aiSlopCleaner: undefined })); + + // then + expect(error.code).toBe("ULTRAGOAL_QUALITY_GATE_INVALID"); + }); + + it("throws UltragoalError when verification missing", () => { + // when + const error = getQualityGateError(makeGate({ verification: undefined })); + + // then + expect(error.code).toBe("ULTRAGOAL_QUALITY_GATE_INVALID"); + }); + + it("throws UltragoalError when codeReview missing", () => { + // when + const error = getQualityGateError(makeGate({ codeReview: undefined })); + + // then + expect(error.code).toBe("ULTRAGOAL_QUALITY_GATE_INVALID"); + }); + + it("throws UltragoalError when criteriaCoverage missing (NEW)", () => { + // when + const error = getQualityGateError(makeGate({ criteriaCoverage: undefined })); + + // then + expect(error.code).toBe("ULTRAGOAL_QUALITY_GATE_INVALID"); + }); + + it("throws UltragoalError when criteriaCoverage.passCount < totalCriteria (NEW)", () => { + // when + const error = getQualityGateError( + makeGate({ criteriaCoverage: { totalCriteria: 3, passCount: 2, adversarialClassesCovered: [] } }), + ); + + // then + expect(error.message).toContain("criteriaCoverage.passCount"); + }); + + it("throws UltragoalError when codeReview.recommendation is not APPROVE", () => { + // when + const error = getQualityGateError( + makeGate({ codeReview: { ...VALID_GATE.codeReview, recommendation: "COMMENT" } }), + ); + + // then + expect(error.message).toContain("recommendation"); + }); + + it("throws UltragoalError when architectStatus is not CLEAR", () => { + // when + const error = getQualityGateError( + makeGate({ codeReview: { ...VALID_GATE.codeReview, architectStatus: "WATCH" } }), + ); + + // then + expect(error.message).toContain("architectStatus"); + }); +}); + +describe("classifyExternalAuthorizationBlocker", () => { + it("returns GHCR signature when evidence mentions ghcr.io auth failure", () => { + expect( + classifyExternalAuthorizationBlocker("ghcr.io returned 401 authentication required for package pull"), + ).toBe("GHCR_PULL_ACCESS:HTTP_401_ANONYMOUS:GHCR_VISIBILITY_OR_CREDENTIAL_REQUIRED"); + }); + + it("returns generic auth signature for generic 401 evidence", () => { + expect(classifyExternalAuthorizationBlocker("Registry returned 401 because credentials are missing")).toBe( + "EXTERNAL_AUTHORIZATION_REQUIRED", + ); + }); + + it("returns null when no auth keywords", () => { + expect(classifyExternalAuthorizationBlocker("build failed because tests failed")).toBeNull(); + }); +}); + +describe("normalizeBlockerEvidence", () => { + it("collapses whitespace + lowercases", () => { + expect(normalizeBlockerEvidence(" GHCR.IO\n\tNeeds TOKEN ")).toBe("ghcr.io needs token"); + }); +}); + +describe("sameBlockerOccurrences", () => { + it("counts goals matching signature", () => { + // given + const nested: GoalWithBlocker = { ...makeGoal({ id: "G002" }), blocker: { signature: "AUTH" } }; + const plan = makePlan([makeGoal({ blockerSignature: "AUTH" }), nested, makeGoal({ id: "G003" })]); + + // when/then + expect(sameBlockerOccurrences(plan, "AUTH")).toBe(2); + }); +}); + +describe("clearGoalBlockerFields", () => { + it("clears all 5 blocker fields", () => { + // given + const goal: GoalWithBlocker = { + ...makeGoal({ blockerSignature: "AUTH" }), + blocker: { signature: "AUTH" }, + blockerEvidence: "401 unauthorized", + blockerOccurrences: 2, + blockedAt: NOW, + }; + + // when + clearGoalBlockerFields(goal); + + // then + expect(goal).not.toHaveProperty("blocker"); + expect(goal).not.toHaveProperty("blockerSignature"); + expect(goal).not.toHaveProperty("blockerEvidence"); + expect(goal).not.toHaveProperty("blockerOccurrences"); + expect(goal).not.toHaveProperty("blockedAt"); + }); +}); diff --git a/packages/omo-codex/plugin/components/ultragoal/test/review-blockers.test.ts b/packages/omo-codex/plugin/components/ultragoal/test/review-blockers.test.ts new file mode 100644 index 000000000..95d47da94 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/test/review-blockers.test.ts @@ -0,0 +1,180 @@ +import { mkdir, mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { ULTRAGOAL_AGGREGATE_CODEX_OBJECTIVE } from "../src/goal-status.js"; +import { ultragoalDir, ultragoalLedgerPath } from "../src/paths.js"; +import { writePlan } from "../src/plan-io.js"; +import { recordFinalReviewBlockers } from "../src/review-blockers.js"; +import type { UltragoalItem, UltragoalPlan, UltragoalSuccessCriterion } from "../src/types.js"; +import { UltragoalError } from "../src/types.js"; + +const NOW = "2026-05-23T00:00:00.000Z"; +const VALID_SNAPSHOT_JSON = JSON.stringify({ + goal: { objective: ULTRAGOAL_AGGREGATE_CODEX_OBJECTIVE, status: "active" }, +}); + +const validArgs = { + goalId: "G002", + title: "Resolve final code-review blockers", + objective: "Address the BLOCK findings from the architect", + evidence: "review verdict: REQUEST_CHANGES (3 issues)", + codexGoalJson: VALID_SNAPSHOT_JSON, +}; + +function makeCriterion(overrides: Partial = {}): UltragoalSuccessCriterion { + return { + id: "C001", + scenario: "happy path", + userModel: "happy", + expectedEvidence: "observable proof", + capturedEvidence: null, + status: "pending", + ...overrides, + }; +} + +function makeGoal(overrides: Partial = {}): UltragoalItem { + return { + id: "G001", + title: "Build durable plan", + objective: "Complete one ultragoal story", + status: "pending", + successCriteria: [makeCriterion()], + attempt: 1, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }; +} + +function makePlan(overrides: Partial = {}): UltragoalPlan { + return { + version: 1, + createdAt: NOW, + updatedAt: NOW, + briefPath: ".omo/ultragoal/brief.md", + goalsPath: ".omo/ultragoal/goals.json", + ledgerPath: ".omo/ultragoal/ledger.jsonl", + codexGoalMode: "aggregate", + codexObjective: ULTRAGOAL_AGGREGATE_CODEX_OBJECTIVE, + goals: [makeGoal({ status: "in_progress" })], + ...overrides, + }; +} + +async function bootstrapRepo(plan: UltragoalPlan): Promise { + const repo = await mkdtemp(join(tmpdir(), "ug-review-blockers-")); + await mkdir(ultragoalDir(repo), { recursive: true }); + await writePlan(repo, plan); + return repo; +} + +async function ledgerKinds(repo: string): Promise { + const raw = await readFile(ultragoalLedgerPath(repo), "utf8"); + return raw + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line).kind); +} + +async function expectUltragoalCode(action: () => Promise, code: string): Promise { + try { + await action(); + } catch (error) { + expect(error).toBeInstanceOf(UltragoalError); + if (!(error instanceof UltragoalError)) throw error; + expect(error.code).toBe(code); + return; + } + throw new Error("Expected UltragoalError"); +} + +function finalPlan(): UltragoalPlan { + return makePlan({ + activeGoalId: "G002", + goals: [ + makeGoal({ id: "G001", status: "complete" }), + makeGoal({ id: "G002", status: "in_progress", title: "ship it", objective: "Finish final story" }), + ], + }); +} + +describe("recordFinalReviewBlockers happy path", () => { + it("marks the final goal review_blocked + appends new pending goal", async () => { + const repo = await bootstrapRepo(finalPlan()); + + const result = await recordFinalReviewBlockers(repo, validArgs); + + expect(result.blockedGoal.status).toBe("review_blocked"); + expect(result.blockedGoal.evidence).toBe(validArgs.evidence); + expect(result.newGoal).toMatchObject({ id: "G003", status: "pending", title: validArgs.title }); + expect(result.newGoal.successCriteria.length).toBeGreaterThanOrEqual(3); + expect(result.plan.activeGoalId).toBeUndefined(); + expect(result.ledgerEntries.length).toBeGreaterThanOrEqual(3); + }); + + it("seeded successCriteria cover happy/edge/regression on the blocker-resolution goal", async () => { + const repo = await bootstrapRepo(finalPlan()); + + const result = await recordFinalReviewBlockers(repo, validArgs); + + expect(result.newGoal.successCriteria.map((criterion) => criterion.userModel).sort()).toEqual([ + "edge", + "happy", + "regression", + ]); + }); +}); + +describe("recordFinalReviewBlockers error cases", () => { + it("throws ultragoal_goal_not_found for unknown goalId", async () => { + const repo = await bootstrapRepo(finalPlan()); + await expectUltragoalCode( + () => recordFinalReviewBlockers(repo, { ...validArgs, goalId: "G999" }), + "ultragoal_goal_not_found", + ); + }); + + it("throws ultragoal_goal_not_in_progress when goal.status !== in_progress", async () => { + const repo = await bootstrapRepo( + makePlan({ + goals: [makeGoal({ id: "G001", status: "in_progress" }), makeGoal({ id: "G002", status: "pending" })], + }), + ); + await expectUltragoalCode(() => recordFinalReviewBlockers(repo, validArgs), "ultragoal_goal_not_in_progress"); + }); + + it("throws ultragoal_not_final_story when other unresolved goals remain", async () => { + const repo = await bootstrapRepo( + makePlan({ + goals: [makeGoal({ id: "G001", status: "in_progress" }), makeGoal({ id: "G002", status: "pending" })], + }), + ); + await expectUltragoalCode( + () => recordFinalReviewBlockers(repo, { ...validArgs, goalId: "G001" }), + "ultragoal_not_final_story", + ); + }); + + it("throws ultragoal_codex_snapshot_mismatch when objective mismatches", async () => { + const repo = await bootstrapRepo(finalPlan()); + const codexGoalJson = JSON.stringify({ goal: { objective: "wrong", status: "active" } }); + + await expectUltragoalCode( + () => recordFinalReviewBlockers(repo, { ...validArgs, codexGoalJson }), + "ultragoal_codex_snapshot_mismatch", + ); + }); +}); + +describe("recordFinalReviewBlockers ledger entries", () => { + it("appends goal_review_blocked + goal_added + blocker_recorded events", async () => { + const repo = await bootstrapRepo(finalPlan()); + + await recordFinalReviewBlockers(repo, validArgs); + + expect(await ledgerKinds(repo)).toEqual(["goal_review_blocked", "goal_added", "blocker_recorded"]); + }); +}); diff --git a/packages/omo-codex/plugin/components/ultragoal/test/steering.test.ts b/packages/omo-codex/plugin/components/ultragoal/test/steering.test.ts new file mode 100644 index 000000000..5a66a6f01 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/test/steering.test.ts @@ -0,0 +1,304 @@ +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { ultragoalGoalsPath } from "../src/paths.js"; +import { readSteeringLedgerEntries, readUltragoalPlan, writePlan } from "../src/plan-io.js"; +import { + applySteeringMutation, + parseUltragoalSteeringDirective, + steerUltragoal, + validateUltragoalSteeringProposal, +} from "../src/steering.js"; +import type { + UltragoalItem, + UltragoalPlan, + UltragoalSteeringProposal, + UltragoalSuccessCriterion, + UltragoalSuccessCriterionUserModel, +} from "../src/types.js"; + +const NOW = "2026-05-23T00:00:00.000Z"; + +type CriterionSteeringFields = { + readonly goalId?: string; + readonly scenario?: string; + readonly expectedEvidence?: string; + readonly userModel?: UltragoalSuccessCriterionUserModel; +}; +type SteeringInput = UltragoalSteeringProposal & CriterionSteeringFields; + +function criterion(overrides: Partial = {}): UltragoalSuccessCriterion { + return { + id: "C001", + scenario: "old scenario", + userModel: "happy", + expectedEvidence: "vague evidence", + capturedEvidence: null, + status: "pending", + ...overrides, + }; +} + +function goal(overrides: Partial = {}): UltragoalItem { + return { + id: "G001", + title: "Build auth service", + objective: "Implement JWT auth endpoint", + status: "pending", + successCriteria: [criterion(), criterion({ id: "C002", status: "pass" })], + attempt: 0, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }; +} + +function plan(overrides: Partial = {}): UltragoalPlan { + return { + version: 1, + createdAt: NOW, + updatedAt: NOW, + briefPath: ".omo/ultragoal/brief.md", + goalsPath: ".omo/ultragoal/goals.json", + ledgerPath: ".omo/ultragoal/ledger.jsonl", + goals: [ + goal(), + goal({ id: "G002", title: "Rate limit", objective: "Throttle login" }), + goal({ id: "G003", status: "complete" }), + ], + ...overrides, + }; +} + +function steering(overrides: Partial = {}): SteeringInput { + return { + kind: "add_subgoal", + source: "cli", + evidence: "observable blocker evidence", + rationale: "the plan must change to stay safe", + title: "Investigate auth blocker", + objective: "Validate the blocker, capture evidence, and report findings.", + ...overrides, + }; +} + +async function repoWithPlan(seed: UltragoalPlan = plan()): Promise { + const repoRoot = await mkdtemp(join(tmpdir(), "ug-steer-")); + await writePlan(repoRoot, seed); + return repoRoot; +} + +describe("validateUltragoalSteeringProposal", () => { + it("accepts valid add_subgoal", async () => { + const proposal: unknown = JSON.parse( + await readFile(join(process.cwd(), "test/fixtures/steering-proposal.json"), "utf8"), + ); + expect(validateUltragoalSteeringProposal(plan(), proposal).invariant.accepted).toBe(true); + }); + + it.each([ + ["missing evidence", { evidence: "" }], + ["missing rationale", { rationale: "" }], + ["unknown kind", { kind: "teleport_goal" }], + ["protected payload mutations", { after: { codexObjective: "replace", qualityGate: { status: "passed" } } }], + ["weakened completion text", { objective: "skip tests and mark complete faster" }], + ])("rejects %s", (_name, overrides) => { + const audit = validateUltragoalSteeringProposal(plan(), { ...steering(), ...overrides }); + expect(audit.invariant.accepted).toBe(false); + expect(audit.invariant.rejectedReasons.length).toBeGreaterThan(0); + }); + + it("rejects when plan already complete", () => { + const done = plan({ goals: [goal({ status: "complete" }), goal({ id: "G002", status: "complete" })] }); + expect(validateUltragoalSteeringProposal(done, steering()).invariant.accepted).toBe(false); + }); + + it("rejects split_subgoal without children", () => { + const audit = validateUltragoalSteeringProposal( + plan(), + steering({ kind: "split_subgoal", targetGoalId: "G001" }), + ); + expect(audit.invariant.accepted).toBe(false); + }); + + it("rejects reorder_pending with unknown goal id", () => { + const audit = validateUltragoalSteeringProposal( + plan(), + steering({ kind: "reorder_pending", pendingOrder: ["missing"] }), + ); + expect(audit.invariant.accepted).toBe(false); + }); + + it.each([ + ["new scenario", { scenario: "new precise scenario" }], + ["new expectedEvidence", { expectedEvidence: "specific command output" }], + ])("accepts valid revise_criterion with %s", (_name, update) => { + const audit = validateUltragoalSteeringProposal( + plan(), + steering({ kind: "revise_criterion", goalId: "G001", criterionId: "C001", ...update }), + ); + expect(audit.invariant.accepted).toBe(true); + }); + + it.each([ + ["unknown goalId", { goalId: "missing", criterionId: "C001", scenario: "new" }], + ["unknown criterionId", { goalId: "G001", criterionId: "missing", scenario: "new" }], + ["no updates", { goalId: "G001", criterionId: "C001" }], + ])("rejects revise_criterion with %s", (_name, overrides) => { + const audit = validateUltragoalSteeringProposal(plan(), steering({ kind: "revise_criterion", ...overrides })); + expect(audit.invariant.accepted).toBe(false); + }); +}); + +describe("steerUltragoal", () => { + it("add_subgoal: appends goal + ledger entry", async () => { + const repoRoot = await repoWithPlan(); + const result = await steerUltragoal(repoRoot, steering({ idempotencyKey: "add" })); + const persisted = await readUltragoalPlan(repoRoot); + expect(result.accepted).toBe(true); + expect(persisted.goals.at(-1)).toMatchObject({ id: "G004", title: "Investigate auth blocker" }); + expect((await readSteeringLedgerEntries(repoRoot)).at(-1)).toMatchObject({ + kind: "steering_accepted", + mutationKind: "add_subgoal", + }); + }); + + it("split_subgoal: creates children + supersedes parent", async () => { + const repoRoot = await repoWithPlan(); + const result = await steerUltragoal( + repoRoot, + steering({ + kind: "split_subgoal", + targetGoalId: "G001", + childGoals: [{ title: "Child", objective: "Do child" }], + }), + ); + expect(result.plan.goals.map((item) => item.id).slice(0, 2)).toEqual(["G001", "G004"]); + expect(result.plan.goals[0]).toMatchObject({ steeringStatus: "superseded", supersededBy: ["G004"] }); + }); + + it("reorder_pending: changes goal order", async () => { + const repoRoot = await repoWithPlan(); + const result = await steerUltragoal( + repoRoot, + steering({ kind: "reorder_pending", pendingOrder: ["G002", "G001"] }), + ); + expect(result.plan.goals.map((item) => item.id).slice(0, 2)).toEqual(["G002", "G001"]); + }); + + it("revise_pending_wording: updates title/objective", async () => { + const repoRoot = await repoWithPlan(); + const result = await steerUltragoal( + repoRoot, + steering({ + kind: "revise_pending_wording", + targetGoalId: "G001", + revisedTitle: "Build safer auth", + revisedObjective: "Implement guarded JWT auth", + }), + ); + expect(result.plan.goals[0]).toMatchObject({ + title: "Build safer auth", + objective: "Implement guarded JWT auth", + }); + }); + + it("annotate_ledger: ledger-only, no plan mutation", async () => { + const seed = plan(); + const repoRoot = await repoWithPlan(seed); + const result = await steerUltragoal(repoRoot, steering({ kind: "annotate_ledger" })); + expect(result.plan.goals).toEqual(seed.goals); + expect(await readFile(ultragoalGoalsPath(repoRoot), "utf8")).toBe(`${JSON.stringify(seed, null, 2)}\n`); + }); + + it("mark_blocked_superseded with children: supersede + replace", async () => { + const repoRoot = await repoWithPlan(); + const result = await steerUltragoal( + repoRoot, + steering({ + kind: "mark_blocked_superseded", + targetGoalId: "G001", + childGoals: [{ title: "Replacement", objective: "Replace blocked path" }], + }), + ); + expect(result.plan.goals[0]).toMatchObject({ steeringStatus: "superseded", supersededBy: ["G004"] }); + expect(result.plan.goals[1]).toMatchObject({ id: "G004", supersedes: ["G001"] }); + }); + + it("mark_blocked_superseded without children: blocks goal", async () => { + const repoRoot = await repoWithPlan(); + const result = await steerUltragoal( + repoRoot, + steering({ kind: "mark_blocked_superseded", targetGoalId: "G001", blockedReason: "external blocker" }), + ); + expect(result.plan.goals[0]).toMatchObject({ + status: "blocked", + steeringStatus: "blocked", + blockedReason: "external blocker", + }); + }); + + it.each(["pending", "pass"] as const)("revise_criterion: works on a %s criterion", async (status) => { + const repoRoot = await repoWithPlan(); + const criterionId = status === "pending" ? "C001" : "C002"; + const result = await steerUltragoal( + repoRoot, + steering({ + kind: "revise_criterion", + goalId: "G001", + criterionId, + scenario: "new scenario", + expectedEvidence: "precise evidence", + }), + ); + const updated = result.plan.goals[0]?.successCriteria.find((item) => item.id === criterionId); + expect(updated).toMatchObject({ scenario: "new scenario", expectedEvidence: "precise evidence", status }); + expect((await readSteeringLedgerEntries(repoRoot)).at(-1)).toMatchObject({ + kind: "criteria_revised", + criterionId, + }); + }); + + it("revise_criterion: updates the targeted criterion in plan", () => { + const audit = validateUltragoalSteeringProposal( + plan(), + steering({ kind: "revise_criterion", goalId: "G001", criterionId: "C001", scenario: "new value" }), + ); + const next = applySteeringMutation( + plan(), + steering({ kind: "revise_criterion", goalId: "G001", criterionId: "C001", scenario: "new value" }), + audit, + ); + expect(next.goals[0]?.successCriteria[0]?.scenario).toBe("new value"); + }); + + it("idempotency: same idempotencyKey produces deduped true second time", async () => { + const repoRoot = await repoWithPlan(); + await steerUltragoal(repoRoot, steering({ idempotencyKey: "same-key" })); + const second = await steerUltragoal(repoRoot, steering({ idempotencyKey: "same-key" })); + expect(second.deduped).toBe(true); + expect((await readUltragoalPlan(repoRoot)).goals).toHaveLength(4); + }); +}); + +describe("parseUltragoalSteeringDirective", () => { + it.each(["OMO_ULTRAGOAL_STEER", "omo.ultragoal.steer", "omo ultragoal steer"])("parses %s pattern", (marker) => { + expect(parseUltragoalSteeringDirective(`${marker}: ${JSON.stringify(steering())}`)).toMatchObject({ + kind: "add_subgoal", + }); + }); + + it("returns null when no marker", () => { + expect(parseUltragoalSteeringDirective(JSON.stringify(steering()))).toBeNull(); + }); + + it("returns null when JSON malformed after marker", () => { + expect(parseUltragoalSteeringDirective("OMO_ULTRAGOAL_STEER: {bad json")).toBeNull(); + }); + + it("returns null for deprecated markers", () => { + const marker = ["OM", "X_ULTRAGOAL_STEER"].join(""); + expect(parseUltragoalSteeringDirective(`${marker}: ${JSON.stringify(steering())}`)).toBeNull(); + }); +}); diff --git a/packages/omo-codex/plugin/components/ultragoal/test/types.test.ts b/packages/omo-codex/plugin/components/ultragoal/test/types.test.ts new file mode 100644 index 000000000..e4d7ddfa0 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/test/types.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; + +import { + iso, + ULTRAGOAL_BRIEF, + ULTRAGOAL_CRITERION_STATUSES, + ULTRAGOAL_DIR, + ULTRAGOAL_GOALS, + ULTRAGOAL_LEDGER, + ULTRAGOAL_STEERING_MUTATION_KINDS, + ULTRAGOAL_SUCCESS_CRITERION_USER_MODELS, + UltragoalError, +} from "../src/types.ts"; + +describe("ultragoal domain constants", () => { + describe("when checking workspace paths", () => { + it("then ULTRAGOAL_DIR points to the omo workspace", () => { + expect(ULTRAGOAL_DIR).toBe(".omo/ultragoal"); + }); + + it("then artifact filenames are stable", () => { + expect(ULTRAGOAL_BRIEF).toBe("brief.md"); + expect(ULTRAGOAL_GOALS).toBe("goals.json"); + expect(ULTRAGOAL_LEDGER).toBe("ledger.jsonl"); + }); + }); + + describe("when checking steering mutation kinds", () => { + it("then includes the new revise_criterion kind", () => { + expect(ULTRAGOAL_STEERING_MUTATION_KINDS).toContain("revise_criterion"); + }); + + it("then totals 7 kinds", () => { + expect(ULTRAGOAL_STEERING_MUTATION_KINDS).toHaveLength(7); + }); + }); + + describe("when checking criterion user models", () => { + it("then exposes 4 user models including adversarial", () => { + expect(ULTRAGOAL_SUCCESS_CRITERION_USER_MODELS).toEqual(["happy", "edge", "regression", "adversarial"]); + }); + }); + + describe("when checking criterion statuses", () => { + it("then exposes pending/pass/fail/blocked", () => { + expect(ULTRAGOAL_CRITERION_STATUSES).toEqual(["pending", "pass", "fail", "blocked"]); + }); + }); +}); + +describe("UltragoalError", () => { + describe("when constructed with code", () => { + it("then is an Error instance carrying the code", () => { + const err = new UltragoalError("bad", "TEST_CODE"); + + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe("TEST_CODE"); + expect(err.message).toBe("bad"); + }); + + it("then accepts optional cause + details", () => { + const cause = new Error("upstream"); + const err = new UltragoalError("wrap", "WRAP", { cause, details: { goalId: "G001" } }); + + expect(err.cause).toBe(cause); + expect(err.details).toEqual({ goalId: "G001" }); + }); + }); +}); + +describe("iso()", () => { + describe("when called", () => { + it("then returns an ISO 8601 string", () => { + const s = iso(); + + expect(s).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + }); + }); +}); diff --git a/packages/omo-codex/plugin/components/ultragoal/tsconfig.build.json b/packages/omo-codex/plugin/components/ultragoal/tsconfig.build.json new file mode 100644 index 000000000..5b5bbcafd --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/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/ultragoal/tsconfig.json b/packages/omo-codex/plugin/components/ultragoal/tsconfig.json new file mode 100644 index 000000000..73e5001dd --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/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/**/*", "vitest.config.ts"] +} diff --git a/packages/omo-codex/plugin/components/ultragoal/vitest.config.ts b/packages/omo-codex/plugin/components/ultragoal/vitest.config.ts new file mode 100644 index 000000000..5453488cc --- /dev/null +++ b/packages/omo-codex/plugin/components/ultragoal/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + environment: "node", + pool: "threads", + isolate: true, + }, +}); diff --git a/packages/omo-codex/plugin/components/ultrawork/.gitignore b/packages/omo-codex/plugin/components/ultrawork/.gitignore new file mode 100644 index 000000000..f3d1d95e2 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultrawork/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +.DS_Store +.env +.env.* diff --git a/packages/omo-codex/plugin/components/ultrawork/AGENTS.md b/packages/omo-codex/plugin/components/ultrawork/AGENTS.md new file mode 100644 index 000000000..b46c9128d --- /dev/null +++ b/packages/omo-codex/plugin/components/ultrawork/AGENTS.md @@ -0,0 +1,37 @@ +# Repository Conventions + +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. +- 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. +- `hooks/sync-agents.py` — pure stdlib `SessionStart` hook. Copies bundled `agents/*.toml` into `CODEX_HOME/agents`, exits 0. +- `agents/*.toml` — bundled Codex agent role files. +- `hooks/hooks.json` — registers hook scripts. +- `.codex-plugin/plugin.json` — Codex plugin manifest. Marketplace metadata lives here, not in `package.json`. + +## Constraints + +- 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 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. + +## 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 + +# pattern boundary check (must be empty) +echo '{"hook_event_name":"UserPromptSubmit","prompt":"refactor ulw_helper.ts"}' | python3 hooks/ultrawork-detector.py | wc -c +``` diff --git a/packages/omo-codex/plugin/components/ultrawork/CHANGELOG.md b/packages/omo-codex/plugin/components/ultrawork/CHANGELOG.md new file mode 100644 index 000000000..8244d1a40 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultrawork/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +## 0.1.0 — 2026-05-23 + +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. +- 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/LICENSE b/packages/omo-codex/plugin/components/ultrawork/LICENSE new file mode 100644 index 000000000..09aac3c3b --- /dev/null +++ b/packages/omo-codex/plugin/components/ultrawork/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Yeongyu Kim + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/omo-codex/plugin/components/ultrawork/NOTICE b/packages/omo-codex/plugin/components/ultrawork/NOTICE new file mode 100644 index 000000000..30094837b --- /dev/null +++ b/packages/omo-codex/plugin/components/ultrawork/NOTICE @@ -0,0 +1,5 @@ +codex-ultrawork +Copyright (c) 2026 Yeongyu Kim + +This product includes software released under the MIT License. +See LICENSE for the full text. diff --git a/packages/omo-codex/plugin/components/ultrawork/README.md b/packages/omo-codex/plugin/components/ultrawork/README.md new file mode 100644 index 000000000..5f52d05e5 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultrawork/README.md @@ -0,0 +1,67 @@ +# codex-ultrawork + +Codex plugin that injects a compact orchestration directive (the **ultrawork** prompt) when the user prompt contains `ultrawork` or `ulw` (word-bounded, case-insensitive). It also syncs the bundled `codex-ultrawork-reviewer` agent role into `CODEX_HOME/agents` on `SessionStart`. + +## What the injected directive enforces + +| Mandate | Behavior | +|---|---| +| Goal + binding success criteria | Call `create_goal` (or open with a `# Goal` block) listing the deliverable + **3+ realistic QA scenarios** (happy path, edge cases, adjacent-surface regression). Each scenario's PASS condition is **observable evidence from the real surface** (`tmux` transcript, `curl` status+body, browser screenshot, Playwright assertion, computer-use action log, CLI stdout, parsed config dump, DB state diff). "Tests pass" alone is not evidence. | +| Durable /tmp notepad | `mktemp -t ulw-$(date +%Y%m%d-%H%M%S).XXXXXX.md` with sections `Plan`, `Success criteria + QA scenarios`, `Now`, `Todo`, `Findings`, `Learnings`. **Append**, never rewrite. | +| 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 5,821 chars (was 7,761) and follows the GPT-5.5 prompting structure (Role / Goal / Bootstrap / Execution loop / Verification gate / Commits / Constraints / Output / Stop rules). + +## Install (via this marketplace) + +```bash +codex plugin marketplace add /path/to/codex-plugins +node /path/to/codex-plugins/scripts/install-local.mjs /path/to/codex-plugins +``` + +The installer copies the plugin into `~/.codex/plugins/cache/code-yeongyu-codex-plugins/omo/0.1.0`, enables it in `~/.codex/config.toml`, and registers the `UserPromptSubmit` and `SessionStart` hooks. + +## How it works + +`hooks/hooks.json` registers a `UserPromptSubmit` hook running: + +``` +python3 ${PLUGIN_ROOT}/hooks/ultrawork-detector.py +``` + +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. + +It also registers a `SessionStart` hook running: + +``` +python3 ${PLUGIN_ROOT}/hooks/sync-agents.py +``` + +That hook copies bundled `agents/*.toml` files into `CODEX_HOME/agents`. It writes nothing on success and exits 0 even on malformed input. + +## Smoke test + +```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 +``` + +Expect `` ... directive body. + +## Agent role smoke test + +```bash +CODEX_HOME="$(mktemp -d)" +echo '{"hook_event_name":"SessionStart"}' | CODEX_HOME="$CODEX_HOME" python3 hooks/sync-agents.py +``` + +Expect `CODEX_HOME/agents/codex-ultrawork-reviewer.toml` to exist. + +## License + +MIT. See `LICENSE`. + +## Privacy + +This plugin only reads local hook payloads, emits the bundled directive text on keyword match, and syncs bundled agent TOML files locally. It does not perform network requests or telemetry. diff --git a/packages/omo-codex/plugin/components/ultrawork/agents/codex-ultrawork-reviewer.toml b/packages/omo-codex/plugin/components/ultrawork/agents/codex-ultrawork-reviewer.toml new file mode 100644 index 000000000..c7af592a3 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultrawork/agents/codex-ultrawork-reviewer.toml @@ -0,0 +1,17 @@ +name = "codex-ultrawork-reviewer" +description = "Strict ultrawork verification reviewer. Use after full QA evidence to audit the diff, goal, and scenario evidence before declaring done." +nickname_candidates = ["Verifier"] +model = "gpt-5.2" +model_reasoning_effort = "xhigh" +developer_instructions = """You are the ultrawork verification reviewer. + +Review only. Do not implement. + +Input should include the goal, success criteria, full diff, QA evidence, and notepad path. + +Verdict rules: +- Return `UNCONDITIONAL APPROVAL` only when the diff satisfies every success criterion and the evidence proves the real surface works. +- Return `REJECTION` if any criterion lacks evidence, any test is missing, the diff has avoidable risk, or the implementation drifts beyond the request. +- Treat "looks good but..." as rejection. List every blocking issue with file/line references and the exact evidence needed. + +Be concise, specific, and strict.""" diff --git a/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json b/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json new file mode 100644 index 000000000..6c8f00133 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json @@ -0,0 +1,26 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${PLUGIN_ROOT}/hooks/sync-agents.py\"", + "timeout": 5 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${PLUGIN_ROOT}/hooks/ultrawork-detector.py\"", + "timeout": 5 + } + ] + } + ] + } +} diff --git a/packages/omo-codex/plugin/components/ultrawork/hooks/sync-agents.py b/packages/omo-codex/plugin/components/ultrawork/hooks/sync-agents.py new file mode 100644 index 000000000..55398e20d --- /dev/null +++ b/packages/omo-codex/plugin/components/ultrawork/hooks/sync-agents.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from typing import Final, cast + + +AGENTS_DIR: Final = "agents" +SESSION_START_EVENT: Final = "SessionStart" + + +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_sync(payload: dict[str, object]) -> bool: + return payload.get("hook_event_name") == SESSION_START_EVENT + + +def _plugin_root() -> Path: + env_root = os.environ.get("PLUGIN_ROOT") + if env_root: + root = Path(env_root).expanduser().resolve() + if root.joinpath(AGENTS_DIR).is_dir(): + return root + return Path(__file__).resolve().parents[1] + + +def _codex_home() -> Path: + env_home = os.environ.get("CODEX_HOME") + if env_home: + return Path(env_home).expanduser().resolve() + return Path.home().joinpath(".codex") + + +def _copy_agent_file(source: Path, target: Path) -> None: + target.parent.mkdir(parents=True, exist_ok=True) + if target.is_symlink() or target.is_file(): + target.unlink() + elif target.exists(): + raise IsADirectoryError(target) + _ = target.write_bytes(source.read_bytes()) + + +def _sync_agents(plugin_root: Path, codex_home: Path) -> None: + source_dir = plugin_root / AGENTS_DIR + if not source_dir.is_dir(): + return + + target_dir = codex_home / AGENTS_DIR + for source in sorted(source_dir.rglob("*.toml")): + if not source.is_file(): + continue + target = target_dir / source.relative_to(source_dir) + _copy_agent_file(source, target) + + +def main() -> None: + try: + payload = _load_payload() + if payload is not None and _should_sync(payload): + _sync_agents(_plugin_root(), _codex_home()) + except Exception as err: # noqa: BLE001 - hook boundary must never block turns. + _ = sys.stderr.write(f"codex-ultrawork agent sync failed: {err}\n") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/packages/omo-codex/plugin/components/ultrawork/hooks/ultrawork-detector.py b/packages/omo-codex/plugin/components/ultrawork/hooks/ultrawork-detector.py new file mode 100755 index 000000000..1452ff029 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultrawork/hooks/ultrawork-detector.py @@ -0,0 +1,246 @@ +#!/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!` + +[CODE RED] Maximum precision. Outcome-first. Evidence-driven. + +# Role +Expert coding agent. Plan obsessively. Ship verified work. No process +narration. + +# Goal +Deliver EXACTLY what the user asked, end-to-end working, proven by +(a) a test written test-first that went RED→GREEN and (b) manual QA +from the real surface with captured observable evidence. BOTH gates, +every change, no exceptions. + +# Bootstrap (DO ALL THREE BEFORE ANY OTHER WORK — NO SKIPPING) + +## 1. Create the goal with binding success criteria +Call `create_goal` (or open your reply with a `# Goal` block treated as +binding) using exactly `objective` and `status` fields. Goals are +unlimited; never invent a numeric budget or limit. +The criteria MUST list, upfront: +- The user-visible deliverable in one line. +- 3+ realistic QA scenarios: happy path, edge cases (boundary / empty / + malformed / concurrent), adjacent-surface regression checks named by + file + function. +- Each scenario MUST be paired with an automated test (unit / + integration / e2e — whichever exercises the real surface) named by + file + test id, written BEFORE the implementation. +- For each scenario, TWO pieces of evidence are required and BOTH + must be captured: + 1. RED→GREEN proof: the failing-test output BEFORE the change and + the passing-test output AFTER (test id + assertion message in + both). Tests added AFTER the green code do NOT satisfy this. + 2. Real-surface artifact — `tmux` session transcript, `curl` status + + body, browser screenshot / Playwright assertion, computer-use + action log, CLI stdout, parsed config dump, DB state diff. + Tests are the FLOOR (required, never sufficient); the surface + artifact is the CEILING (also required). "tests pass" alone is NOT + done. + +These scenarios are the contract. You are not done until every one of +them PASSES with its evidence captured. + +## 2. Open the durable notepad +Run: `NOTE=$(mktemp -t ulw-$(date +%Y%m%d-%H%M%S).XXXXXX.md)`. Echo the +path. Initialise it with these sections and APPEND (never rewrite) as +you work: + +``` +# Ultrawork Notepad — +Started: + +## Plan (exhaustively detailed) + + +## Success criteria + QA scenarios + + +## Now + + +## Todo + + +## Findings + + +## Learnings + +``` + +Update `## Now` and `## Todo` on every status change. Append findings +and learnings the moment they surface. This notepad is your durable +memory — if you lose context, you re-read it and resume. + +## 3. Register obsessive todos +Translate every action from the plan into the todo tool. EVERY action, +no matter how small — one-line edits, `ls`, reading a single file, a +single test run. If you will do it, it is a todo. Format: +`path: for — verify by ` encoding WHERE / +WHY (which criterion it advances) / HOW / VERIFY. Exactly ONE in_progress +at a time. Mark completed IMMEDIATELY — never batch. + +GOOD pair (test-first, ordered): + `foo.test.ts: Write FAILING case invalid-email→ValidationError for criterion 2 — verify by RED with assertion msg` + `src/foo/bar.ts: Implement validateEmail() RFC-5322-lite for criterion 2 — verify by foo.test.ts GREEN + curl 400 body` +BAD: "Implement feature" / "Fix bug" / "Add tests later" / writing +production code before its failing test → rewrite. + +# Execution loop (strict TDD — RED → GREEN → SURFACE) +Until every success-criteria scenario PASSES with BOTH evidence pieces: +1. Pick next criterion → mark in_progress → update notepad `## Now`. +2. RED: write the failing test FIRST. Run it. Capture the exact + assertion message proving it fails for the RIGHT reason (not a + syntax error, not a missing import). Paste RED output into the + notepad. No production code yet. +3. GREEN: write the SMALLEST production change that flips RED→GREEN. + Re-run the test. Capture GREEN output. If GREEN required more than + ~20 lines, your test was too coarse — split it. +4. SURFACE: exercise the real user-facing surface named by the + criterion (tmux / curl / browser / computer-use / CLI / DB). + Capture the artifact path into the notepad. +5. Verify: LSP diagnostics clean on changed files + full test suite + green (no skipped, no xfail added this turn). +6. Mark completed. Append non-obvious findings / learnings. +7. After each increment, re-run the FULL scenario list. Record + PASS/FAIL inline with BOTH evidence paths. Loop until all PASS. + +Parallel-batch independent reads / searches / subagents within a step, +but NEVER parallelise RED and GREEN of the same criterion. + +# Verification gate (TRIGGERED, NOT OPTIONAL) + +Trigger when ANY apply: +- User said "엄밀", "strictly", "rigorously", "properly review", or + explicitly demanded review. +- Task touches 3+ files OR ran 20+ turns OR 30+ minutes wall-clock. +- Refactor, migration, performance change, security-sensitive work, or + anything the user called "깊게" / "deeply". + +Procedure (NON-NEGOTIABLE): +1. Spawn agent_type `codex-ultrawork-reviewer` (or any `gpt-5.2` + xhigh reviewer if unavailable). Pass: goal, success-criteria, + scenario evidence, full diff, notepad path. +2. Treat the reviewer's verdict as binding. There is NO "false + positive". Every concern is real. Do not argue. Do not minimise. Do + not explain it away. +3. Fix every issue. Re-run the FULL scenario QA. Capture fresh + evidence. Update notepad. +4. Re-submit to the SAME reviewer. Loop until you receive an + UNCONDITIONAL approval ("looks good but..." = REJECTION). +5. Only on unconditional approval may you declare done. Stopping early + IS failure. + +# Commits +Atomic, Conventional Commits (`(): ` — feat / +fix / refactor / test / docs / chore / build / ci / perf). One logical +change per commit; each commit builds + tests green on its own. No WIP +on the final branch. If a plan file exists, final commit footer: +`Plan: plans/.md`. Do NOT auto-`git commit` unless the user +requested or preauthorised this session — default is stage + draft +message + present for approval. + +# Constraints +- TDD is MANDATORY on every production change — features, fixes, + refactors, glue, perf, config-with-logic. No "too small", "too + obvious", or "just a one-liner" exemptions. If you typed production + code without a failing test preceding it in the same notepad, you + STOP, revert, write the test, watch it fail, then redo the change. +- Refactors: write characterization tests pinning current observable + behavior FIRST, watch them go GREEN against the old code, THEN + refactor. They must remain green throughout. +- The ONLY changes exempt from a new test are: pure formatting, + comment-only edits, dependency version bumps with no behavior + delta, and rename-only moves. Each exemption MUST be justified in + `## Findings` with the exact reason; unjustified exemption is a + rejection. +- Smallest correct change. No drive-by refactors. +- Never suppress lints / errors / test failures. Never delete, skip, + `.only`, `.skip`, `xfail`, or comment out tests to green the suite. +- Never claim done from inference — only from RED→GREEN + surface. +- Parallel tool calls for any independent work. + +# Output discipline +- First line literally: `ULTRAWORK MODE ENABLED!` +- After bootstrap: 1-2 paragraph plan summary + notepad path. +- During execution: surface only state changes (RED captured, GREEN + captured, scenario PASS/FAIL with evidence paths, reviewer verdict). +- Final message: outcome + success-criteria checklist with evidence + refs + notepad path + reviewer approval (if gate triggered) + commit + list (` `). No file-by-file changelog unless asked. + +# Stop rules +- Stop ONLY when every scenario PASSES with captured evidence, notepad + is current, and (if gate triggered) reviewer approved unconditionally. +- After 2 identical failed attempts at one step, surface what was tried + and ask the user before another retry. +- 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/ultrawork-hooks.test.mjs b/packages/omo-codex/plugin/components/ultrawork/hooks/ultrawork-hooks.test.mjs new file mode 100644 index 000000000..6c8fba543 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultrawork/hooks/ultrawork-hooks.test.mjs @@ -0,0 +1,158 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { lstat, mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +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"); +const syncAgentsPath = join(hookDir, "sync-agents.py"); + +async function makeTempDir() { + return mkdtemp(join(tmpdir(), "codex-ultrawork-")); +} + +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 session start #when sync hook runs #then installs bundled reviewer agent", async () => { + const codexHome = await makeTempDir(); + try { + const result = await runPython( + syncAgentsPath, + '{"hook_event_name":"SessionStart"}', + { CODEX_HOME: codexHome }, + ); + + assert.equal(result.code, 0); + assert.equal(result.signal, null); + assert.equal(result.stdout, ""); + assert.equal(result.stderr, ""); + + const targetPath = join(codexHome, "agents", "codex-ultrawork-reviewer.toml"); + const targetStat = await lstat(targetPath); + assert.equal(targetStat.isFile(), true); + assert.equal(targetStat.isSymbolicLink(), false); + const syncedAgent = await readFile(targetPath, "utf8"); + assert.match(syncedAgent, /^name = "codex-ultrawork-reviewer"$/m); + assert.match(syncedAgent, /^model = "gpt-5.2"$/m); + assert.match(syncedAgent, /^model_reasoning_effort = "xhigh"$/m); + assert.match(syncedAgent, /^developer_instructions = """/m); + } finally { + await rm(codexHome, { recursive: true, force: true }); + } +}); + +test("#given malformed session payload #when sync hook runs #then exits zero without output", async () => { + const codexHome = await makeTempDir(); + try { + const result = await runPython(syncAgentsPath, "{", { CODEX_HOME: codexHome }); + + assert.equal(result.code, 0); + assert.equal(result.signal, null); + assert.equal(result.stdout, ""); + assert.equal(result.stderr, ""); + await assert.rejects( + stat(join(codexHome, "agents", "codex-ultrawork-reviewer.toml")), + /code: 'ENOENT'|ENOENT/, + ); + } finally { + await rm(codexHome, { recursive: true, force: true }); + } +}); + +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 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 prompt and session hooks", 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.match( + manifest.hooks.SessionStart[0].hooks[0].command, + /sync-agents\.py/, + ); + 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 new file mode 100644 index 000000000..24d484330 --- /dev/null +++ b/packages/omo-codex/plugin/components/ultrawork/package.json @@ -0,0 +1,35 @@ +{ + "name": "@code-yeongyu/codex-ultrawork", + "version": "0.1.0", + "description": "Codex plugin that injects the ultrawork orchestration directive and syncs the ultrawork reviewer agent role.", + "type": "module", + "license": "MIT", + "homepage": "https://github.com/code-yeongyu/codex-ultrawork", + "repository": { + "type": "git", + "url": "git+https://github.com/code-yeongyu/codex-ultrawork.git" + }, + "bugs": { + "url": "https://github.com/code-yeongyu/codex-ultrawork/issues" + }, + "keywords": [ + "codex", + "codex-plugin", + "ultrawork", + "agents", + "hooks", + "orchestration" + ], + "scripts": { + "test": "node --test hooks/*.test.mjs" + }, + "files": [ + "agents", + "hooks/hooks.json", + "hooks/sync-agents.py", + "hooks/ultrawork-detector.py", + "README.md", + "LICENSE", + "NOTICE" + ] +} diff --git a/packages/omo-codex/plugin/hooks/hooks.json b/packages/omo-codex/plugin/hooks/hooks.json new file mode 100644 index 000000000..71040bd45 --- /dev/null +++ b/packages/omo-codex/plugin/hooks/hooks.json @@ -0,0 +1,99 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${PLUGIN_ROOT}/components/rules/dist/cli.js\" hook session-start", + "timeout": 10, + "statusMessage": "loading OMO project rules" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${PLUGIN_ROOT}/components/ultrawork/hooks/sync-agents.py\"", + "timeout": 5 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${PLUGIN_ROOT}/components/rules/dist/cli.js\" hook user-prompt-submit", + "timeout": 10, + "statusMessage": "loading OMO project rules" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${PLUGIN_ROOT}/components/ultrawork/hooks/ultrawork-detector.py\"", + "timeout": 5 + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "node \"${PLUGIN_ROOT}/components/ultragoal/dist/cli.js\" hook user-prompt-submit", + "timeout": 10, + "statusMessage": "checking OMO ultragoal steering" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "^(apply_patch|write|Write|edit|Edit|multi_edit|multiedit|MultiEdit)$", + "hooks": [ + { + "type": "command", + "command": "node \"${PLUGIN_ROOT}/components/comment-checker/dist/cli.js\" hook post-tool-use", + "timeout": 30, + "statusMessage": "checking OMO comments" + }, + { + "type": "command", + "command": "node \"${PLUGIN_ROOT}/components/lsp/dist/cli.js\" hook post-tool-use", + "timeout": 60, + "statusMessage": "checking OMO LSP diagnostics" + } + ] + }, + { + "matcher": "^apply_patch$", + "hooks": [ + { + "type": "command", + "command": "node \"${PLUGIN_ROOT}/components/rules/dist/cli.js\" hook post-tool-use", + "timeout": 10, + "statusMessage": "matching OMO project rules" + } + ] + } + ], + "PostCompact": [ + { + "matcher": "manual|auto", + "hooks": [ + { + "type": "command", + "command": "node \"${PLUGIN_ROOT}/components/rules/dist/cli.js\" hook post-compact", + "timeout": 10, + "statusMessage": "resetting OMO project rule cache" + } + ] + } + ] + } +} diff --git a/packages/omo-codex/plugin/package.json b/packages/omo-codex/plugin/package.json new file mode 100644 index 000000000..45593e304 --- /dev/null +++ b/packages/omo-codex/plugin/package.json @@ -0,0 +1,22 @@ +{ + "name": "@code-yeongyu/omo-codex-plugin", + "version": "0.1.0", + "description": "Aggregate Codex plugin root for OMO components.", + "type": "module", + "packageManager": "npm@11.12.1", + "private": true, + "workspaces": [ + "components/comment-checker", + "components/rules", + "components/lsp", + "components/lsp/packages/lsp-tools-mcp", + "components/ultragoal", + "components/ultrawork" + ], + "scripts": { + "build": "node scripts/sync-skills.mjs && npm run build --workspaces --if-present", + "check": "npm run build && npm test", + "sync:skills": "node scripts/sync-skills.mjs", + "test": "node --test test/*.test.mjs" + } +} diff --git a/packages/omo-codex/plugin/scripts/sync-skills.mjs b/packages/omo-codex/plugin/scripts/sync-skills.mjs new file mode 100644 index 000000000..7e7b78710 --- /dev/null +++ b/packages/omo-codex/plugin/scripts/sync-skills.mjs @@ -0,0 +1,19 @@ +#!/usr/bin/env node +import { cp, mkdir, rm } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const skillSources = [ + ["comment-checker", "components/comment-checker/skills/comment-checker"], + ["lsp", "components/lsp/skills/lsp"], + ["rules", "components/rules/skills/rules"], + ["ultragoal", "components/ultragoal/skills/ultragoal"], +]; + +await rm(join(root, "skills"), { recursive: true, force: true }); +await mkdir(join(root, "skills"), { recursive: true }); + +for (const [name, source] of skillSources) { + await cp(join(root, source), join(root, "skills", name), { recursive: true }); +} diff --git a/packages/omo-codex/plugin/skills/comment-checker/SKILL.md b/packages/omo-codex/plugin/skills/comment-checker/SKILL.md new file mode 100644 index 000000000..7ce771015 --- /dev/null +++ b/packages/omo-codex/plugin/skills/comment-checker/SKILL.md @@ -0,0 +1,16 @@ +--- +name: comment-checker +description: Use when Codex needs to understand or respond to automatic comment-checker feedback emitted after an edit-like PostToolUse hook. +--- + +# Codex Comment Checker + +The plugin registers a `PostToolUse` hook for successful `apply_patch`, `write`, `edit`, `multi_edit`, and `multiedit` calls. + +When comment-checker reports a warning after a patch, Codex receives blocking feedback and should fix or explain the flagged comment before moving on. + +## Scope + +- No MCP tool is exposed. +- Non-edit tools are ignored by this plugin. +- Missing checker binaries emit no hook output so normal Codex work can continue. diff --git a/packages/omo-codex/plugin/skills/lsp/SKILL.md b/packages/omo-codex/plugin/skills/lsp/SKILL.md new file mode 100644 index 000000000..36be06844 --- /dev/null +++ b/packages/omo-codex/plugin/skills/lsp/SKILL.md @@ -0,0 +1,35 @@ +--- +name: lsp +description: Use when Codex needs language-server diagnostics, definitions, references, symbols, or rename safety checks in the current workspace. +--- + +# Codex LSP + +Call `lsp` MCP tools through the tool interface; `lsp.*`/`mcp__lsp__*` are tool-call names, not shell commands. + +## Tools + +- `lsp.status`: list configured, installed, missing, disabled, and active language servers. +- `lsp.diagnostics`: check one file or directory for LSP diagnostics. Prefer `severity: "error"` after edits. +- `lsp.goto_definition`: locate a symbol definition from file, line, and character. +- `lsp.find_references`: find usages of a symbol across the workspace. +- `lsp.symbols`: inspect document symbols or search workspace symbols. +- `lsp.prepare_rename`: check whether a rename is valid at a position. +- `lsp.rename`: apply a language-server workspace edit for a rename. + +## Config + +Project config lives at `.codex/lsp-client.json`; user config lives at `~/.codex/lsp-client.json`. + +```json +{ + "lsp": { + "typescript": { + "command": ["typescript-language-server", "--stdio"], + "extensions": [".ts", ".tsx", ".js", ".jsx"] + } + } +} +``` + +Use `lsp.status` first when diagnostics report a missing language server. diff --git a/packages/omo-codex/plugin/skills/rules/SKILL.md b/packages/omo-codex/plugin/skills/rules/SKILL.md new file mode 100644 index 000000000..3ac401302 --- /dev/null +++ b/packages/omo-codex/plugin/skills/rules/SKILL.md @@ -0,0 +1,34 @@ +--- +name: rules +description: Use when the user asks about Codex Rules behavior, injected project rules, supported rule file locations, matching, or environment configuration. +--- + +# Codex Rules + +Codex Rules is automatic once the plugin is enabled. It injects: + +- static project instructions on `SessionStart` and `UserPromptSubmit` +- matching file-specific rules after Codex `apply_patch` by default + +Dynamic `PostToolUse` output is injected as additional context and is deduplicated per plugin data session. Codex Rules does not rewrite tool output. + +Supported project sources: + +- `AGENTS.md` +- `CLAUDE.md` +- `CONTEXT.md` +- `.sisyphus/rules/**/*.md` +- `.claude/rules/**/*.md` +- `.cursor/rules/**/*.md` +- `.github/instructions/**/*.md` +- `.github/copilot-instructions.md` + +Supported environment knobs: + +- `CODEX_RULES_DISABLED=1` +- `CODEX_RULES_MODE=both|static|dynamic|off` +- `CODEX_RULES_MAX_RULE_CHARS=` +- `CODEX_RULES_MAX_RESULT_CHARS=` +- `CODEX_RULES_ENABLED_SOURCES=AGENTS.md,.sisyphus/rules` + +The legacy `PI_RULES_*` variables are accepted as fallbacks for users migrating from `pi-rules`. diff --git a/packages/omo-codex/plugin/skills/ultragoal/.gitkeep b/packages/omo-codex/plugin/skills/ultragoal/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/omo-codex/plugin/skills/ultragoal/SKILL.md b/packages/omo-codex/plugin/skills/ultragoal/SKILL.md new file mode 100644 index 000000000..b047917d4 --- /dev/null +++ b/packages/omo-codex/plugin/skills/ultragoal/SKILL.md @@ -0,0 +1,143 @@ +--- +name: ultragoal +description: Durable repo-native multi-goal plans with embedded success criteria and evidence audit. +--- + +## Role +Expert goal orchestration agent. Plan multi-goal work that survives across turns and sessions. +Use GPT-5.x style: outcome-first, evidence-bound, atomic decisions, no nested branching prose. + +## Goal +Deliver every goal in `.omo/ultragoal/goals.json` end-to-end. +Prove EVERY success criterion with captured observable evidence from the real surface. +Audit each pass, fail, block, steering change, and checkpoint in `.omo/ultragoal/ledger.jsonl`. + +## Artifacts +- `.omo/ultragoal/brief.md`: original brief and durable constraints. +- `.omo/ultragoal/goals.json`: goals with embedded `successCriteria` per goal. +- `.omo/ultragoal/ledger.jsonl`: append-only audit trail. +- Read artifacts before resuming, steering, or checkpointing. +- Never invent state outside `.omo/ultragoal` artifacts or `omo ultragoal status --json`. + +## Bootstrap +Do all three steps before execution. No edits, goal tools, or checkpointing before bootstrap completes. + +### 1. Create goals from the brief +Run one form: +```sh +omo ultragoal create-goals --brief "" --json +omo ultragoal create-goals --brief-file --json +cat | omo ultragoal create-goals --from-stdin --json +``` +Write state through the CLI path. Do not hand-edit state files. + +### 2. Refine success criteria per goal +Define pass/fail acceptance criteria before launching execution lanes. Include the command, artifact, or manual check that will prove success. +Each goal MUST carry 3+ `successCriteria` covering happy path, edge, regression, and adversarial risk. +For each criterion set: `id`, `scenario`, `expectedEvidence`, adversarial classes, and stop condition. +Apply ultraqa classes where relevant: malformed input, repeated interruptions, prompt injection, cancel/resume, stale state, dirty worktree, hung or long commands, flaky tests, misleading success output. +Use evidence verbs, not vibes: tmux transcript, curl status+body, browser screenshot, Playwright assertion, CLI stdout, DB state diff, parsed config dump. +"Tests pass" is supporting signal, not completion proof. +Record manual QA notes when behavior is user-visible. +Revise any criterion that lacks observable `expectedEvidence` before execution. + +### 3. Inspect state +Run `omo ultragoal status --json`. +Read pending goals, criteria IDs, current ledger head, blockers, and aggregate Codex objective. + +## Execution Loop +Loop per goal. Cap at 5 cycles per goal. Cap identical same-criterion failures at 3. + +### Acquire Next Goal +1. Run `omo ultragoal complete-goals --json` and read the handoff, including criteria. +2. Call `get_goal` and inspect active Codex state. +3. Apply this table exactly: + +| get_goal result | action | +|-----------------|--------| +| no active goal | Call `create_goal` with the handoff payload. | +| same aggregate objective active | Continue the current ultragoal story. | +| different goal active | STOP. Checkpoint blocked and surface the conflict. | +4. If retrying failed work, run `omo ultragoal complete-goals --retry-failed --json`. +5. Never create a second Codex goal for the same aggregate objective. + +### Per-Criterion Cycle +1. PLAN: read `criterion.scenario`, `criterion.expectedEvidence`, prior ledger entries, and safety bounds. +2. Register atomic todos: `path: for - verify by `. +3. EXECUTE: do one bounded change or check, then exercise the real surface named by the criterion. +4. CAPTURE: collect actual observable evidence: transcript, stdout, screenshot, assertion, status+body, diff, or parsed dump. +5. RECORD exactly one result: + - PASS: `omo ultragoal record-evidence --goal-id --criterion-id --status pass --evidence "" --json` + - FAIL: `omo ultragoal record-evidence --goal-id --criterion-id --status fail --evidence "" --notes "" --json` + - BLOCKED: `omo ultragoal record-evidence --goal-id --criterion-id --status blocked --evidence "" --notes "" --json` +6. If actual does not match expected, diagnose, fix minimally, and rerun the SAME criterion. +7. After 3 same-criterion failures, exit the goal with diagnosis. +8. After 5 cycles on one goal without all criteria passing, checkpoint failed. +9. Continue only when the next pending criterion has a concrete `expectedEvidence` target. + +### Goal Completion +1. Confirm every criterion is `pass` with `omo ultragoal criteria --goal-id --json`. +2. Call `get_goal` for a fresh snapshot. +3. Run `omo ultragoal checkpoint --goal-id --status complete --evidence "" --codex-goal-json --json`. +4. If blocked or failed, checkpoint with `--status blocked` or `--status failed` and include diagnosis evidence. +5. If this is the final goal, run the final quality gate first and pass `--quality-gate-json`. + +## Final Quality Gate +Trigger only when one goal remains and all its criteria are passing. +1. Run targeted verification for changed behavior. +2. Run `ai-slop-cleaner` on changed files. If no relevant edits exist, record a passed no-op cleaner report. +3. Rerun verification after cleanup. +4. Run `$code-review`. +5. Clean review means `codeReview.recommendation == "APPROVE"` and `codeReview.architectStatus == "CLEAR"`. +6. If review is non-clean, run `omo ultragoal record-review-blockers --goal-id --title "<...>" --objective "<...>" --evidence "" --codex-goal-json --json`. +7. If clean, checkpoint final completion: +```sh +omo ultragoal checkpoint --goal-id --status complete --evidence "" --codex-goal-json --quality-gate-json --json +``` +`--quality-gate-json` shape: +```json +{ + "aiSlopCleaner": { "status": "passed", "evidence": "cleaner report" }, + "verification": { "status": "passed", "commands": ["npm test"], "evidence": "post-cleaner verification" }, + "codeReview": { "recommendation": "APPROVE", "architectStatus": "CLEAR", "evidence": "review synthesis" }, + "criteriaCoverage": { "totalCriteria": N, "passCount": N, "adversarialClassesCovered": ["malformed_input", "..."] } +} +``` + +## Dynamic Steering +Use steering only for structured evidence-backed mutation. Reject natural-language steering requests. + +| Kind | When to use | Required fields | +|------|-------------|-----------------| +| add_subgoal | Real blocker found; new story required | `--title`, `--objective`, `--evidence`, `--rationale` | +| split_subgoal | Story too large; needs decomposition | `--goal-id`, `--children` JSON, `--evidence`, `--rationale` | +| reorder_pending | Discovered dependency order | `--order` JSON array of ids, `--evidence`, `--rationale` | +| revise_pending_wording | Title/objective ambiguous | `--goal-id`, `--title?`, `--objective?`, `--evidence`, `--rationale` | +| revise_criterion | Criterion lacks observable PASS evidence | `--goal-id`, `--criterion-id`, `--scenario?`, `--expected-evidence?`, `--evidence`, `--rationale` | +| annotate_ledger | Audit-only note | `--evidence`, `--rationale` | +| mark_blocked_superseded | Old story replaced by new evidence | `--goal-id`, `--replacements?`, `--evidence`, `--rationale` | + +Command form: `omo ultragoal steer --kind [] --evidence "<...>" --rationale "<...>" --json`. +Structured prompt directives accepted: `OMO_ULTRAGOAL_STEER: { ... }`, `omo.ultragoal.steer: {...}`, `omo ultragoal steer: {...}`. + +## Constraints +1. NEVER call `update_goal` mid-aggregate; only on final story after the quality gate passes. +2. NEVER call `create_goal` when `get_goal` shows a different active goal. +3. NEVER mark `criterion.status == "pass"` without captured observable evidence in `record-evidence`. +4. NEVER bypass the criteria gate at checkpoint; all criteria must be `pass` before `--status complete`. +5. Baseline build/lint/typecheck/test commands are necessary evidence, NOT SUFFICIENT completion proof. Criteria coverage with observable evidence is the gate. +6. Treat `.omo/ultragoal/ledger.jsonl` as the durable audit trail; checkpoint after every success or failure. +7. Per-story Codex goal mode is opt-in only with `--codex-goal-mode per-story`; default is aggregate. +8. Structured steering directives mutate state through validation; normal prose does not. +9. Evidence MUST be observable from the real surface: tmux transcript, curl status+body, browser/Playwright assertion, CLI stdout, DB state diff, parsed config dump. +10. Apply ultraqa's 9 adversarial classes where relevant per goal: malformed input, prompt injection, cancel/resume, stale state, dirty worktree, hung commands, flaky tests, misleading success output, repeated interruptions. +11. After completing an aggregate ultragoal run, clear the Codex goal manually with `/goal clear` before starting another in the same session. +12. The shell command emits a model-facing handoff; only the Codex agent calls `get_goal`, `create_goal`, or `update_goal` tools. + +## Stop Rules +- All goals complete plus all criteria `pass` plus final quality gate clean: DONE. +- 3x same criterion failure: checkpoint failed, surface diagnosis. +- 5 cycles on one goal without all-pass: checkpoint failed, surface. +- Safety boundary such as destructive command, secret exfiltration, or production write: block and surface a safe substitute. +- Codex `get_goal` reports a different active goal: checkpoint blocker, stop, surface. +- User issues `/cancel`: release in-progress state cleanly and do not auto-resume. diff --git a/packages/omo-codex/plugin/test/aggregate.test.mjs b/packages/omo-codex/plugin/test/aggregate.test.mjs new file mode 100644 index 000000000..8d4537e12 --- /dev/null +++ b/packages/omo-codex/plugin/test/aggregate.test.mjs @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { readdir, readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); + +async function readJson(relativePath) { + return JSON.parse(await readFile(join(root, relativePath), "utf8")); +} + +test("#given aggregate plugin manifest #when inspected #then it owns the omo namespace", async () => { + // given + const manifest = await readJson(".codex-plugin/plugin.json"); + + // when + const hookPath = manifest.hooks; + const skillsPath = manifest.skills; + const mcpPath = manifest.mcpServers; + + // then + assert.equal(manifest.name, "omo"); + assert.equal(hookPath, "./hooks/hooks.json"); + assert.equal(skillsPath, "./skills/"); + assert.equal(mcpPath, "./.mcp.json"); +}); + +test("#given isolated components #when hooks are inspected #then commands stay inside component roots", async () => { + // given + const hooks = await readJson("hooks/hooks.json"); + const text = JSON.stringify(hooks); + + // when + const componentMarkers = [ + "components/comment-checker/dist/cli.js", + "components/lsp/dist/cli.js", + "components/rules/dist/cli.js", + "components/ultragoal/dist/cli.js", + "components/ultrawork/hooks/sync-agents.py", + "components/ultrawork/hooks/ultrawork-detector.py", + ]; + + // then + for (const marker of componentMarkers) { + assert.match(text, new RegExp(marker.replaceAll("/", "\\/"))); + } + assert.doesNotMatch(text, /codex-(comment-checker|lsp|rules|ultragoal|ultrawork)@/); +}); + +test("#given aggregate MCP config #when inspected #then lsp server stays component isolated", async () => { + // given + const mcp = await readJson(".mcp.json"); + + // when + const server = mcp.mcpServers.lsp; + + // then + assert.equal(server.command, "node"); + assert.deepEqual(server.args, ["./components/lsp/packages/lsp-tools-mcp/dist/cli.js", "mcp"]); + assert.equal(server.cwd, "."); +}); + +test("#given component directories #when scanned #then only root owns plugin identity", async () => { + // given + const components = await readdir(join(root, "components"), { withFileTypes: true }); + + // when + const componentNames = components.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort(); + + // then + assert.deepEqual(componentNames, ["comment-checker", "lsp", "rules", "ultragoal", "ultrawork"]); + for (const name of componentNames) { + await assert.rejects( + readFile(join(root, "components", name, ".codex-plugin", "plugin.json"), "utf8"), + /code: 'ENOENT'|ENOENT/, + ); + } +}); diff --git a/packages/omo-codex/scripts/install-local.mjs b/packages/omo-codex/scripts/install-local.mjs new file mode 100644 index 000000000..a00fa3d61 --- /dev/null +++ b/packages/omo-codex/scripts/install-local.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { installCachedPlugin, linkCachedPluginBins, pruneMarketplaceCache } from "./install/cache.mjs"; +import { updateCodexConfig } from "./install/config.mjs"; +import { trustedHookStatesForPlugin } from "./install/hook-trust.mjs"; +import { defaultRunCommand } from "./install/process.mjs"; +import { + readMarketplace, + readPluginManifest, + resolvePluginSource, + validatePathSegment, +} from "./install/marketplace.mjs"; + +export async function installMarketplaceLocally(options = {}) { + const repoRoot = resolve(options.repoRoot ?? process.cwd()); + const codexHome = resolve(options.codexHome ?? process.env.CODEX_HOME ?? join(homedir(), ".codex")); + const binDir = resolve(options.binDir ?? process.env.CODEX_LOCAL_BIN_DIR ?? join(homedir(), ".local", "bin")); + const runCommand = options.runCommand ?? defaultRunCommand; + const log = options.log ?? console.log; + const marketplace = await readMarketplace(repoRoot); + const installed = []; + + for (const entry of marketplace.plugins) { + const sourcePath = resolvePluginSource(repoRoot, entry); + const manifest = await readPluginManifest(sourcePath); + if (manifest.name !== entry.name) { + throw new Error( + `plugin manifest name ${JSON.stringify(manifest.name)} does not match marketplace name ${JSON.stringify(entry.name)}`, + ); + } + const version = manifest.version ?? "local"; + validatePathSegment(version, "plugin version"); + + log(`Building ${entry.name}@${version}`); + const plugin = await installCachedPlugin({ + codexHome, + marketplaceName: marketplace.name, + name: entry.name, + runCommand, + sourcePath, + version, + }); + const binLinks = await linkCachedPluginBins({ binDir, pluginRoot: plugin.path }); + for (const link of binLinks) { + log(`Linked ${link.name} -> ${link.target}`); + } + installed.push(plugin); + } + + const pluginNames = marketplace.plugins.map((plugin) => plugin.name); + const trustedHookStates = ( + await Promise.all( + installed.map((plugin) => + trustedHookStatesForPlugin({ + marketplaceName: marketplace.name, + pluginName: plugin.name, + pluginRoot: plugin.path, + }), + ), + ) + ).flat(); + await pruneMarketplaceCache({ codexHome, marketplaceName: marketplace.name, keepPluginNames: pluginNames }); + await updateCodexConfig({ + configPath: join(codexHome, "config.toml"), + repoRoot, + marketplaceName: marketplace.name, + pluginNames, + trustedHookStates, + }); + + for (const plugin of installed) { + log(`Installed ${plugin.name}@${marketplace.name} -> ${plugin.path}`); + } + + return { marketplaceName: marketplace.name, installed }; +} + +async function main() { + const repoRoot = process.argv[2] ? resolve(process.argv[2]) : process.cwd(); + const result = await installMarketplaceLocally({ repoRoot }); + console.log(`Installed ${result.installed.length} plugin(s) from ${result.marketplaceName}.`); +} + +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : ""; +if (invokedPath === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/packages/omo-codex/scripts/install-local.test.mjs b/packages/omo-codex/scripts/install-local.test.mjs new file mode 100644 index 000000000..a277e46e9 --- /dev/null +++ b/packages/omo-codex/scripts/install-local.test.mjs @@ -0,0 +1,210 @@ +import assert from "node:assert/strict"; +import { mkdir, readFile, readlink, stat, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { join } from "node:path"; +import test from "node:test"; +import { tmpdir } from "node:os"; +import { mkdtemp } from "node:fs/promises"; + +import { installMarketplaceLocally } from "./install-local.mjs"; + +async function makeTempDir() { + return mkdtemp(join(tmpdir(), "codex-plugins-install-")); +} + +async function writeJson(path, value) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); +} + +async function writePlugin(root, name, version) { + const pluginRoot = join(root, "plugins", name); + await mkdir(join(pluginRoot, ".codex-plugin"), { recursive: true }); + await mkdir(join(pluginRoot, "dist"), { recursive: true }); + await mkdir(join(pluginRoot, "hooks"), { recursive: true }); + await mkdir(join(pluginRoot, "skills", name), { recursive: true }); + await writeJson(join(pluginRoot, ".codex-plugin", "plugin.json"), { + name, + version, + description: `${name} test plugin`, + mcpServers: "./.mcp.json", + hooks: "./hooks/hooks.json", + skills: "./skills/", + }); + await writeJson(join(pluginRoot, ".mcp.json"), { + mcpServers: { + [name]: { + command: "node", + args: ["./dist/cli.js", "mcp"], + cwd: ".", + }, + }, + }); + await writeJson(join(pluginRoot, "hooks", "hooks.json"), { hooks: {} }); + await writeFile(join(pluginRoot, "skills", name, "SKILL.md"), "---\nname: test\n---\n"); + await writeJson(join(pluginRoot, "package.json"), { + name: `@example/${name}`, + version, + bin: { + [name]: "./dist/cli.js", + }, + scripts: { + build: "node -e \"require('fs').writeFileSync('dist/cli.js', 'console.log(1)')\"", + }, + dependencies: {}, + }); +} + +test("#given local marketplace #when installing #then copies versioned plugins and enables config", async () => { + const repoRoot = await makeTempDir(); + const codexHome = await makeTempDir(); + const binDir = await makeTempDir(); + + await mkdir(join(repoRoot, ".agents", "plugins"), { recursive: true }); + await writeJson(join(repoRoot, ".agents", "plugins", "marketplace.json"), { + name: "debug-marketplace", + plugins: [ + { + name: "alpha", + source: "./plugins/alpha", + }, + { + name: "beta", + source: { + source: "local", + path: "./plugins/beta", + }, + }, + ], + }); + await writePlugin(repoRoot, "alpha", "1.2.3"); + await writePlugin(repoRoot, "beta", "0.4.0"); + await mkdir(join(repoRoot, "plugins", "alpha", "node_modules"), { recursive: true }); + await writeFile(join(repoRoot, "plugins", "alpha", "node_modules", "skip.txt"), "skip"); + await mkdir(join(codexHome, "plugins", "cache", "debug-marketplace", "stale", "0.1.0"), { recursive: true }); + await writeFile( + join(codexHome, "config.toml"), + [ + '[plugins."stale@debug-marketplace"]', + "enabled = true", + "", + '[hooks.state."stale@debug-marketplace:hooks/hooks.json:user_prompt_submit:0:0"]', + 'trusted_hash = "sha256:old"', + "", + ].join("\n"), + ); + + const commands = []; + const result = await installMarketplaceLocally({ + repoRoot, + codexHome, + binDir, + runCommand: async (command, args, options) => { + commands.push([command, args, options.cwd]); + }, + log: () => {}, + }); + + assert.deepEqual( + result.installed.map((plugin) => `${plugin.name}@${plugin.version}`), + ["alpha@1.2.3", "beta@0.4.0"], + ); + const alphaCacheRoot = join(codexHome, "plugins", "cache", "debug-marketplace", "alpha", "1.2.3"); + assert.equal((await stat(join(alphaCacheRoot, ".mcp.json"))).isFile(), true); + assert.equal(await readlink(join(binDir, "alpha")), join(alphaCacheRoot, "dist", "cli.js")); + const alphaMcp = JSON.parse(await readFile(join(alphaCacheRoot, ".mcp.json"), "utf8")); + assert.deepEqual(alphaMcp.mcpServers.alpha.args, [join(alphaCacheRoot, "dist", "cli.js"), "mcp"]); + assert.equal( + Object.hasOwn(alphaMcp.mcpServers.alpha, "cwd"), + false, + "`cwd: \".\"` must be stripped so the spawned MCP server inherits the caller's workspace cwd", + ); + assert.equal(alphaMcp.mcpServers.alpha.command, "node"); + await assert.rejects( + stat(join(codexHome, "plugins", "cache", "debug-marketplace", "alpha", "1.2.3", "node_modules")), + /code: 'ENOENT'|ENOENT/, + ); + await assert.rejects( + stat(join(codexHome, "plugins", "cache", "debug-marketplace", "stale")), + /code: 'ENOENT'|ENOENT/, + ); + assert.deepEqual( + commands.map(([command, args, cwd]) => [command, args.join(" "), cwd]), + [ + ["npm", "install", join(repoRoot, "plugins", "alpha")], + ["npm", "run build", join(repoRoot, "plugins", "alpha")], + ["npm", "install --omit=dev", join(codexHome, "plugins", "cache", "debug-marketplace", "alpha", "1.2.3")], + ["npm", "install", join(repoRoot, "plugins", "beta")], + ["npm", "run build", join(repoRoot, "plugins", "beta")], + ["npm", "install --omit=dev", join(codexHome, "plugins", "cache", "debug-marketplace", "beta", "0.4.0")], + ], + ); + + const config = await readFile(join(codexHome, "config.toml"), "utf8"); + assert.match(config, /\[features\]\n(?:plugin_hooks = true\n)?plugins = true/); + assert.match(config, /\[marketplaces\.debug-marketplace\]/); + assert.match(config, /source_type = "local"/); + assert.match(config, /\[plugins\."alpha@debug-marketplace"\]\nenabled = true/); + assert.match(config, /\[plugins\."beta@debug-marketplace"\]\nenabled = true/); + assert.doesNotMatch(config, /stale@debug-marketplace/); +}); + +test("#given plugin hooks #when installing #then records trusted hook hashes", async () => { + const repoRoot = await makeTempDir(); + const codexHome = await makeTempDir(); + + await mkdir(join(repoRoot, ".agents", "plugins"), { recursive: true }); + await writeJson(join(repoRoot, ".agents", "plugins", "marketplace.json"), { + name: "debug-marketplace", + plugins: [{ name: "alpha", source: "./plugins/alpha" }], + }); + await writePlugin(repoRoot, "alpha", "1.2.3"); + await writeJson(join(repoRoot, "plugins", "alpha", "hooks", "hooks.json"), { + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: "command", + command: "node \"${PLUGIN_ROOT}/dist/cli.js\" hook user-prompt-submit", + timeout: 10, + statusMessage: "checking alpha", + }, + ], + }, + ], + }, + }); + + await installMarketplaceLocally({ + repoRoot, + codexHome, + runCommand: async () => {}, + log: () => {}, + }); + + const config = await readFile(join(codexHome, "config.toml"), "utf8"); + assert.match(config, /\[hooks\.state\."alpha@debug-marketplace:hooks\/hooks\.json:user_prompt_submit:0:0"\]/); + assert.match(config, /trusted_hash = "sha256:[a-f0-9]{64}"/); +}); + +test("#given bad plugin source path #when installing #then rejects traversal", async () => { + const repoRoot = await makeTempDir(); + const codexHome = await makeTempDir(); + + await mkdir(join(repoRoot, ".agents", "plugins"), { recursive: true }); + await writeJson(join(repoRoot, ".agents", "plugins", "marketplace.json"), { + name: "debug-marketplace", + plugins: [ + { + name: "escape", + source: "../escape", + }, + ], + }); + + await assert.rejects( + installMarketplaceLocally({ repoRoot, codexHome, log: () => {} }), + /local plugin source path must start with \.\//, + ); +}); diff --git a/packages/omo-codex/scripts/install/cache.mjs b/packages/omo-codex/scripts/install/cache.mjs new file mode 100644 index 000000000..2c0b4892c --- /dev/null +++ b/packages/omo-codex/scripts/install/cache.mjs @@ -0,0 +1,152 @@ +import { basename, dirname, join, sep } from "node:path"; +import { cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, symlink, writeFile } from "node:fs/promises"; + +import { exists, isRecord } from "./utils.mjs"; + +export async function installCachedPlugin({ codexHome, marketplaceName, name, runCommand, sourcePath, version }) { + await maybeRunNpmInstall(sourcePath, runCommand); + await maybeRunNpmBuild(sourcePath, runCommand); + + const targetPath = join(codexHome, "plugins", "cache", marketplaceName, name, version); + await replaceDirectory(sourcePath, targetPath, shouldCopyPluginPath); + await maybeRunNpmInstall(targetPath, runCommand, ["install", "--omit=dev"]); + await rewriteCachedMcpManifest(targetPath); + return { name, version, path: targetPath }; +} + +export async function pruneMarketplaceCache({ codexHome, marketplaceName, keepPluginNames }) { + const cacheRoot = join(codexHome, "plugins", "cache", marketplaceName); + if (!(await exists(cacheRoot))) return; + const keep = new Set(keepPluginNames); + const entries = await readdir(cacheRoot, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() || keep.has(entry.name)) continue; + await rm(join(cacheRoot, entry.name), { recursive: true, force: true }); + } +} + +export async function linkCachedPluginBins({ binDir, pluginRoot }) { + const binLinks = await discoverPackageBins(pluginRoot); + await mkdir(binDir, { recursive: true }); + const linked = []; + for (const link of binLinks) { + const linkPath = join(binDir, link.name); + await replaceSymlink(linkPath, link.target); + linked.push({ name: link.name, path: linkPath, target: link.target }); + } + return linked; +} + +async function maybeRunNpmInstall(cwd, runCommand, args = ["install"]) { + if (!(await exists(join(cwd, "package.json")))) return; + await runCommand("npm", args, { cwd }); +} + +async function maybeRunNpmBuild(cwd, runCommand) { + if (!(await exists(join(cwd, "package.json")))) return; + const packageJson = JSON.parse(await readFile(join(cwd, "package.json"), "utf8")); + if (!isRecord(packageJson.scripts) || typeof packageJson.scripts.build !== "string") return; + await runCommand("npm", ["run", "build"], { cwd }); +} + +async function replaceDirectory(sourcePath, targetPath, filter) { + await mkdir(dirname(targetPath), { recursive: true }); + const tempPath = join(dirname(targetPath), `.tmp-${basename(targetPath)}-${process.pid}-${Date.now()}`); + await rm(tempPath, { recursive: true, force: true }); + await cp(sourcePath, tempPath, { + recursive: true, + filter: (source) => filter(source, sourcePath), + }); + await rm(targetPath, { recursive: true, force: true }); + await rename(tempPath, targetPath); +} + +async function discoverPackageBins(root) { + const links = []; + await collectPackageBins(root, root, links); + return links; +} + +async function collectPackageBins(directory, root, links) { + const entries = await readdir(directory, { withFileTypes: true }); + const packageJsonPath = join(directory, "package.json"); + if (entries.some((entry) => entry.isFile() && entry.name === "package.json")) { + await appendPackageBinLinks(packageJsonPath, directory, links); + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") continue; + const childPath = join(directory, entry.name); + if (!childPath.startsWith(root)) continue; + await collectPackageBins(childPath, root, links); + } +} + +async function appendPackageBinLinks(packageJsonPath, packageRoot, links) { + const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")); + if (!isRecord(packageJson)) return; + const bin = packageJson.bin; + if (typeof bin === "string" && typeof packageJson.name === "string") { + links.push({ name: basename(packageJson.name), target: join(packageRoot, bin) }); + return; + } + if (!isRecord(bin)) return; + for (const [name, target] of Object.entries(bin)) { + if (typeof target !== "string") continue; + links.push({ name, target: join(packageRoot, target) }); + } +} + +async function replaceSymlink(linkPath, targetPath) { + if (await existingNonSymlink(linkPath)) { + throw new Error(`${linkPath} already exists and is not a symlink`); + } + await rm(linkPath, { force: true }); + await symlink(targetPath, linkPath); +} + +async function existingNonSymlink(path) { + try { + const stat = await lstat(path); + if (!stat.isSymbolicLink()) return true; + await readlink(path); + return false; + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return false; + throw error; + } +} + +function shouldCopyPluginPath(path, root) { + const relative = path === root ? "" : path.slice(root.length + sep.length); + if (relative === "") return true; + const parts = relative.split(sep); + return !parts.some((part) => part === ".git" || part === "node_modules"); +} + +async function rewriteCachedMcpManifest(pluginRoot) { + const manifestPath = join(pluginRoot, ".mcp.json"); + if (!(await exists(manifestPath))) return; + const raw = await readFile(manifestPath, "utf8"); + const parsed = JSON.parse(raw); + if (!isRecord(parsed) || !isRecord(parsed.mcpServers)) return; + let changed = false; + for (const server of Object.values(parsed.mcpServers)) { + if (!isRecord(server)) continue; + if (server.cwd === "." || server.cwd === "./") { + delete server.cwd; + changed = true; + } + if (!Array.isArray(server.args)) continue; + const nextArgs = server.args.map((arg) => { + if (typeof arg !== "string") return arg; + if (arg.startsWith("./") || arg.startsWith("../")) return join(pluginRoot, arg); + return arg; + }); + if (nextArgs.some((value, index) => value !== server.args[index])) { + server.args = nextArgs; + changed = true; + } + } + if (changed) await writeFile(manifestPath, `${JSON.stringify(parsed, null, "\t")}\n`); +} diff --git a/packages/omo-codex/scripts/install/config.mjs b/packages/omo-codex/scripts/install/config.mjs new file mode 100644 index 000000000..48d22827b --- /dev/null +++ b/packages/omo-codex/scripts/install/config.mjs @@ -0,0 +1,174 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +import { exists } from "./utils.mjs"; + +export async function updateCodexConfig({ configPath, repoRoot, marketplaceName, pluginNames, trustedHookStates = [] }) { + await mkdir(dirname(configPath), { recursive: true }); + let config = ""; + if (await exists(configPath)) config = await readFile(configPath, "utf8"); + + config = removeStaleMarketplacePluginBlocks(config, marketplaceName, new Set(pluginNames)); + config = removeStaleMarketplaceHookStateBlocks(config, marketplaceName, new Set(pluginNames)); + config = ensureFeatureEnabled(config, "plugins"); + config = ensureFeatureEnabled(config, "plugin_hooks"); + config = ensureMarketplaceBlock(config, marketplaceName, repoRoot); + for (const pluginName of pluginNames) { + config = ensurePluginEnabled(config, `${pluginName}@${marketplaceName}`); + } + for (const state of trustedHookStates) { + config = ensureHookTrusted(config, state.key, state.trustedHash); + } + + await writeFile(configPath, config.trimEnd() + "\n"); +} + +function removeStaleMarketplacePluginBlocks(config, marketplaceName, keepPluginNames) { + return removeTomlSections(config, (header) => { + const pluginKey = parseQuotedPluginHeader(header); + if (pluginKey === null) return false; + const suffix = `@${marketplaceName}`; + if (!pluginKey.endsWith(suffix)) return false; + return !keepPluginNames.has(pluginKey.slice(0, -suffix.length)); + }); +} + +function removeStaleMarketplaceHookStateBlocks(config, marketplaceName, keepPluginNames) { + return removeTomlSections(config, (header) => { + const prefix = "hooks.state."; + if (!header.startsWith(prefix)) return false; + const hookKey = parseJsonString(header.slice(prefix.length)); + if (hookKey === null) return false; + const separator = hookKey.indexOf(":"); + if (separator === -1) return false; + const pluginKey = hookKey.slice(0, separator); + const suffix = `@${marketplaceName}`; + if (!pluginKey.endsWith(suffix)) return false; + return !keepPluginNames.has(pluginKey.slice(0, -suffix.length)); + }); +} + +function ensureFeatureEnabled(config, featureName) { + const section = findTomlSection(config, "features"); + if (!section) return appendBlock(config, `[features]\n${featureName} = true\n`); + return replaceOrInsertSetting(config, section, featureName, "true"); +} + +function ensureMarketplaceBlock(config, marketplaceName, repoRoot) { + const header = `marketplaces.${marketplaceName}`; + if (findTomlSection(config, header)) return config; + return appendBlock( + config, + [ + `[${header}]`, + `last_updated = "${new Date().toISOString().replace(/\.\d{3}Z$/, "Z")}"`, + "source_type = \"local\"", + `source = ${JSON.stringify(repoRoot)}`, + "", + ].join("\n"), + ); +} + +function ensurePluginEnabled(config, pluginKey) { + const header = `plugins.${JSON.stringify(pluginKey)}`; + const section = findTomlSection(config, header); + if (!section) return appendBlock(config, `[${header}]\nenabled = true\n`); + return replaceOrInsertSetting(config, section, "enabled", "true"); +} + +function ensureHookTrusted(config, key, trustedHash) { + const header = `hooks.state.${JSON.stringify(key)}`; + const section = findTomlSection(config, header); + if (!section) return appendBlock(config, `[${header}]\ntrusted_hash = ${JSON.stringify(trustedHash)}\n`); + return replaceOrInsertSetting(config, section, "trusted_hash", JSON.stringify(trustedHash)); +} + +function removeTomlSections(config, shouldRemove) { + return splitTomlSections(config) + .filter((section) => section.header === null || !shouldRemove(section.header)) + .map((section) => section.text) + .join("") + .replace(/\n{3,}/g, "\n\n"); +} + +function splitTomlSections(config) { + const lines = config.match(/[^\n]*\n?|$/g) ?? []; + const sections = []; + let current = { header: null, text: "" }; + for (const line of lines) { + if (line.length === 0) break; + const header = parseTomlHeader(line); + if (header !== null) { + if (current.text.length > 0) sections.push(current); + current = { header, text: line }; + } else { + current.text += line; + } + } + if (current.text.length > 0) sections.push(current); + return sections; +} + +function findTomlSection(config, header) { + const headerLine = `[${header}]`; + const lines = config.match(/[^\n]*\n?|$/g) ?? []; + let offset = 0; + let start = -1; + for (const line of lines) { + if (line.length === 0) break; + const trimmed = line.trim(); + if (start === -1) { + if (trimmed === headerLine) start = offset; + } else if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + return { start, end: offset, text: config.slice(start, offset) }; + } + offset += line.length; + } + if (start === -1) return null; + return { start, end: config.length, text: config.slice(start) }; +} + +function replaceOrInsertSetting(config, section, key, value) { + const linePattern = new RegExp(`^${escapeRegExp(key)}\\s*=.*$`, "m"); + const replacement = linePattern.test(section.text) + ? section.text.replace(linePattern, `${key} = ${value}`) + : insertSetting(section.text, key, value); + return config.slice(0, section.start) + replacement + config.slice(section.end); +} + +function insertSetting(sectionText, key, value) { + const lines = sectionText.split("\n"); + lines.splice(1, 0, `${key} = ${value}`); + return lines.join("\n"); +} + +function parseTomlHeader(line) { + const trimmed = line.trim(); + if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return null; + if (trimmed.startsWith("[[")) return null; + return trimmed.slice(1, -1); +} + +function parseQuotedPluginHeader(header) { + const prefix = "plugins."; + if (!header.startsWith(prefix)) return null; + return parseJsonString(header.slice(prefix.length)); +} + +function parseJsonString(value) { + try { + const parsed = JSON.parse(value); + return typeof parsed === "string" ? parsed : null; + } catch { + return null; + } +} + +function appendBlock(config, block) { + const prefix = config.trimEnd(); + return `${prefix}${prefix.length > 0 ? "\n\n" : ""}${block.trimEnd()}\n`; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/packages/omo-codex/scripts/install/hook-trust.mjs b/packages/omo-codex/scripts/install/hook-trust.mjs new file mode 100644 index 000000000..b2710f795 --- /dev/null +++ b/packages/omo-codex/scripts/install/hook-trust.mjs @@ -0,0 +1,84 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { exists, isRecord } from "./utils.mjs"; + +const EVENT_LABELS = new Map([ + ["PreToolUse", "pre_tool_use"], + ["PermissionRequest", "permission_request"], + ["PostToolUse", "post_tool_use"], + ["PreCompact", "pre_compact"], + ["PostCompact", "post_compact"], + ["SessionStart", "session_start"], + ["UserPromptSubmit", "user_prompt_submit"], + ["SubagentStart", "subagent_start"], + ["SubagentStop", "subagent_stop"], + ["Stop", "stop"], +]); + +export async function trustedHookStatesForPlugin({ marketplaceName, pluginName, pluginRoot }) { + const manifestPath = join(pluginRoot, ".codex-plugin", "plugin.json"); + if (!(await exists(manifestPath))) return []; + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + if (!isRecord(manifest) || typeof manifest.hooks !== "string") return []; + + const hooksPath = join(pluginRoot, manifest.hooks); + if (!(await exists(hooksPath))) return []; + const parsed = JSON.parse(await readFile(hooksPath, "utf8")); + if (!isRecord(parsed) || !isRecord(parsed.hooks)) return []; + + const keySource = `${pluginName}@${marketplaceName}:${stripDotSlash(manifest.hooks)}`; + const states = []; + for (const [eventName, groups] of Object.entries(parsed.hooks)) { + if (!Array.isArray(groups)) continue; + const eventLabel = EVENT_LABELS.get(eventName); + if (eventLabel === undefined) continue; + for (const [groupIndex, group] of groups.entries()) { + if (!isRecord(group) || !Array.isArray(group.hooks)) continue; + for (const [handlerIndex, handler] of group.hooks.entries()) { + if (!isRecord(handler) || handler.type !== "command") continue; + if (handler.async === true) continue; + if (typeof handler.command !== "string" || handler.command.trim() === "") continue; + const key = `${keySource}:${eventLabel}:${groupIndex}:${handlerIndex}`; + states.push({ + key, + trustedHash: commandHookHash(eventLabel, group.matcher, handler), + }); + } + } + } + return states; +} + +function commandHookHash(eventName, matcher, handler) { + const command = handler.command; + const timeout = Math.max(Number(handler.timeout ?? 600), 1); + const normalizedHandler = { + type: "command", + command, + timeout, + async: false, + }; + if (typeof handler.statusMessage === "string") normalizedHandler.statusMessage = handler.statusMessage; + const identity = { + event_name: eventName, + hooks: [normalizedHandler], + }; + if (typeof matcher === "string") identity.matcher = matcher; + return `sha256:${createHash("sha256").update(JSON.stringify(canonicalJson(identity))).digest("hex")}`; +} + +function canonicalJson(value) { + if (Array.isArray(value)) return value.map(canonicalJson); + if (!isRecord(value)) return value; + const result = {}; + for (const key of Object.keys(value).sort()) { + result[key] = canonicalJson(value[key]); + } + return result; +} + +function stripDotSlash(value) { + return value.startsWith("./") ? value.slice(2) : value; +} diff --git a/packages/omo-codex/scripts/install/marketplace.mjs b/packages/omo-codex/scripts/install/marketplace.mjs new file mode 100644 index 000000000..12ab8358e --- /dev/null +++ b/packages/omo-codex/scripts/install/marketplace.mjs @@ -0,0 +1,93 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { isRecord } from "./utils.mjs"; + +const MARKETPLACE_PATH = ".agents/plugins/marketplace.json"; + +export async function readMarketplace(repoRoot) { + const marketplacePath = join(repoRoot, MARKETPLACE_PATH); + const raw = await readFile(marketplacePath, "utf8"); + const parsed = JSON.parse(raw); + if (!isRecord(parsed)) throw new Error("marketplace.json must be an object"); + if (typeof parsed.name !== "string" || parsed.name.trim() === "") { + throw new Error("marketplace.json name must be a non-empty string"); + } + validatePathSegment(parsed.name, "marketplace name"); + if (!Array.isArray(parsed.plugins)) throw new Error("marketplace.json plugins must be an array"); + + return { + name: parsed.name, + plugins: parsed.plugins.map((plugin, index) => normalizeMarketplacePlugin(plugin, index)), + }; +} + +export function resolvePluginSource(repoRoot, plugin) { + const sourcePath = localSourcePath(plugin.source); + const relativePath = sourcePath.slice(2); + return join(repoRoot, ...relativePath.split(/[\\/]/)); +} + +export async function readPluginManifest(pluginRoot) { + const raw = await readFile(join(pluginRoot, ".codex-plugin", "plugin.json"), "utf8"); + const parsed = JSON.parse(raw); + if (!isRecord(parsed)) throw new Error(`${pluginRoot} plugin.json must be an object`); + if (typeof parsed.name !== "string" || parsed.name.trim() === "") { + throw new Error(`${pluginRoot} plugin.json name must be a non-empty string`); + } + const manifest = { name: parsed.name }; + if (parsed.version !== undefined) { + if (typeof parsed.version !== "string" || parsed.version.trim() === "") { + throw new Error(`${pluginRoot} plugin.json version must be a non-empty string`); + } + manifest.version = parsed.version.trim(); + } + return manifest; +} + +export function validatePathSegment(value, label) { + if (!/^[A-Za-z0-9._+-]+$/.test(value)) { + throw new Error(`${label} contains unsupported characters: ${value}`); + } + if (value === "." || value === "..") { + throw new Error(`${label} must not be a path traversal segment`); + } +} + +function normalizeMarketplacePlugin(plugin, index) { + if (!isRecord(plugin)) throw new Error(`marketplace plugin ${index} must be an object`); + if (typeof plugin.name !== "string" || plugin.name.trim() === "") { + throw new Error(`marketplace plugin ${index} name must be a non-empty string`); + } + validatePathSegment(plugin.name, "plugin name"); + return { + name: plugin.name, + source: plugin.source, + }; +} + +function localSourcePath(source) { + if (typeof source === "string") return validateLocalSourcePath(source); + if ( + isRecord(source) && + source.source === "local" && + typeof source.path === "string" + ) { + return validateLocalSourcePath(source.path); + } + throw new Error("local plugin source must be a string path or { source: \"local\", path } object"); +} + +function validateLocalSourcePath(path) { + if (!path.startsWith("./")) { + throw new Error("local plugin source path must start with ./"); + } + const relative = path.slice(2); + if (relative.length === 0) throw new Error("local plugin source path must not be empty"); + for (const part of relative.split(/[\\/]/)) { + if (part === "" || part === "." || part === "..") { + throw new Error("local plugin source path must stay within the marketplace root"); + } + } + return path; +} diff --git a/packages/omo-codex/scripts/install/process.mjs b/packages/omo-codex/scripts/install/process.mjs new file mode 100644 index 000000000..56b05ab43 --- /dev/null +++ b/packages/omo-codex/scripts/install/process.mjs @@ -0,0 +1,19 @@ +import { spawn } from "node:child_process"; + +export async function defaultRunCommand(command, args, options) { + await new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + cwd: options.cwd, + stdio: "inherit", + }); + child.once("error", reject); + child.once("exit", (code, signal) => { + if (code === 0) { + resolvePromise(); + return; + } + const suffix = signal ? `signal ${signal}` : `exit code ${code}`; + reject(new Error(`${command} ${args.join(" ")} failed in ${options.cwd} with ${suffix}`)); + }); + }); +} diff --git a/packages/omo-codex/scripts/install/utils.mjs b/packages/omo-codex/scripts/install/utils.mjs new file mode 100644 index 000000000..718db2c2a --- /dev/null +++ b/packages/omo-codex/scripts/install/utils.mjs @@ -0,0 +1,15 @@ +import { constants as fsConstants } from "node:fs"; +import { access } from "node:fs/promises"; + +export async function exists(path) { + try { + await access(path, fsConstants.F_OK); + return true; + } catch { + return false; + } +} + +export function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +}