@@ -33,12 +33,20 @@ jobs:
|
||||
fi
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Build lsp-tools-mcp submodule
|
||||
run: npm ci && npm run build
|
||||
working-directory: packages/lsp-tools-mcp
|
||||
@@ -61,12 +69,20 @@ jobs:
|
||||
run: bun test
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Build lsp-tools-mcp submodule
|
||||
run: npm ci && npm run build
|
||||
working-directory: packages/lsp-tools-mcp
|
||||
@@ -91,9 +107,41 @@ jobs:
|
||||
- name: Type check script tooling
|
||||
run: bun run typecheck:script
|
||||
|
||||
codex-compatibility:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.12"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-1.3.12-${{ hashFiles('bun.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli @ast-grep/napi"
|
||||
|
||||
- name: Run Codex compatibility tests
|
||||
run: bun run test:codex
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test, typecheck]
|
||||
needs: [test, typecheck, codex-compatibility]
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
@@ -159,15 +207,17 @@ jobs:
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
bun-version: "1.3.12"
|
||||
|
||||
- name: Generate release notes
|
||||
id: notes
|
||||
run: |
|
||||
NOTES=$(bun run script/generate-changelog.ts)
|
||||
echo "notes<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$NOTES" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
{
|
||||
echo "notes<<EOF"
|
||||
echo "$NOTES"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
bun-version: "1.3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
@@ -75,8 +75,8 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "dist_tag=$DIST_TAG" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "dist_tag=$DIST_TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check if already published
|
||||
id: check
|
||||
@@ -95,26 +95,26 @@ jobs:
|
||||
echo "oh-my-openagent-${{ matrix.platform }}@${VERSION}: ${OA_STATUS}"
|
||||
|
||||
if [ "$OC_STATUS" = "200" ]; then
|
||||
echo "skip_opencode=true" >> $GITHUB_OUTPUT
|
||||
echo "skip_opencode=true" >> "$GITHUB_OUTPUT"
|
||||
echo "✓ oh-my-opencode-${{ matrix.platform }}@${VERSION} already published"
|
||||
else
|
||||
echo "skip_opencode=false" >> $GITHUB_OUTPUT
|
||||
echo "skip_opencode=false" >> "$GITHUB_OUTPUT"
|
||||
echo "→ oh-my-opencode-${{ matrix.platform }}@${VERSION} needs publishing"
|
||||
fi
|
||||
|
||||
if [ "$OA_STATUS" = "200" ]; then
|
||||
echo "skip_openagent=true" >> $GITHUB_OUTPUT
|
||||
echo "skip_openagent=true" >> "$GITHUB_OUTPUT"
|
||||
echo "✓ oh-my-openagent-${{ matrix.platform }}@${VERSION} already published"
|
||||
else
|
||||
echo "skip_openagent=false" >> $GITHUB_OUTPUT
|
||||
echo "skip_openagent=false" >> "$GITHUB_OUTPUT"
|
||||
echo "→ oh-my-openagent-${{ matrix.platform }}@${VERSION} needs publishing"
|
||||
fi
|
||||
|
||||
# Skip build only if BOTH are already published
|
||||
if [ "$OC_STATUS" = "200" ] && [ "$OA_STATUS" = "200" ]; then
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Update version in package.json
|
||||
@@ -288,8 +288,8 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "dist_tag=$DIST_TAG" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "dist_tag=$DIST_TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check if already published
|
||||
id: check
|
||||
@@ -300,30 +300,29 @@ jobs:
|
||||
OA_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-openagent-${{ matrix.platform }}/${VERSION}")
|
||||
|
||||
if [ "$OC_STATUS" = "200" ]; then
|
||||
echo "skip_opencode=true" >> $GITHUB_OUTPUT
|
||||
echo "skip_opencode=true" >> "$GITHUB_OUTPUT"
|
||||
echo "✓ oh-my-opencode-${{ matrix.platform }}@${VERSION} already published"
|
||||
else
|
||||
echo "skip_opencode=false" >> $GITHUB_OUTPUT
|
||||
echo "skip_opencode=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
if [ "$OA_STATUS" = "200" ]; then
|
||||
echo "skip_openagent=true" >> $GITHUB_OUTPUT
|
||||
echo "skip_openagent=true" >> "$GITHUB_OUTPUT"
|
||||
echo "✓ oh-my-openagent-${{ matrix.platform }}@${VERSION} already published"
|
||||
else
|
||||
echo "skip_openagent=false" >> $GITHUB_OUTPUT
|
||||
echo "skip_openagent=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Need artifact if either package needs publishing
|
||||
if [ "$OC_STATUS" = "200" ] && [ "$OA_STATUS" = "200" ]; then
|
||||
echo "skip_all=true" >> $GITHUB_OUTPUT
|
||||
echo "skip_all=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "skip_all=false" >> $GITHUB_OUTPUT
|
||||
echo "skip_all=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Download artifact
|
||||
id: download
|
||||
if: steps.check.outputs.skip_all != 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: binary-${{ matrix.platform }}
|
||||
@@ -369,6 +368,7 @@ jobs:
|
||||
|
||||
- name: Publish oh-my-opencode-${{ matrix.platform }}
|
||||
if: steps.check.outputs.skip_opencode != 'true' && steps.download.outcome == 'success'
|
||||
continue-on-error: true
|
||||
env:
|
||||
DIST_TAG: ${{ steps.validate.outputs.dist_tag }}
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
@@ -383,7 +383,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Publish oh-my-openagent-${{ matrix.platform }}
|
||||
if: steps.check.outputs.skip_openagent != 'true' && steps.download.outcome == 'success'
|
||||
if: always() && steps.check.outputs.skip_openagent != 'true' && steps.download.outcome == 'success'
|
||||
env:
|
||||
DIST_TAG: ${{ steps.validate.outputs.dist_tag }}
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
|
||||
+267
-79
@@ -22,6 +22,11 @@ on:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
sync_lazycodex_marketplace:
|
||||
description: "Sync the LazyCodex Codex marketplace repository"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
@@ -38,7 +43,7 @@ jobs:
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.11"
|
||||
bun-version: "1.3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
@@ -55,7 +60,7 @@ jobs:
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.11"
|
||||
bun-version: "1.3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
@@ -72,7 +77,17 @@ jobs:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Verify trusted publisher for all 24 packages
|
||||
- name: Require LazyCodex sync token
|
||||
if: inputs.sync_lazycodex_marketplace == true
|
||||
env:
|
||||
LAZYCODEX_SYNC_TOKEN: ${{ secrets.LAZYCODEX_SYNC_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$LAZYCODEX_SYNC_TOKEN" ]; then
|
||||
echo "::error::LAZYCODEX_SYNC_TOKEN is required to push the Codex marketplace bundle to code-yeongyu/lazycodex."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify trusted publisher for release packages
|
||||
env:
|
||||
REPO: code-yeongyu/oh-my-openagent
|
||||
WORKFLOW_FILE: publish.yml
|
||||
@@ -87,7 +102,7 @@ jobs:
|
||||
fi
|
||||
|
||||
PLATFORMS=(darwin-arm64 darwin-x64 darwin-x64-baseline linux-x64 linux-x64-baseline linux-arm64 linux-x64-musl linux-x64-musl-baseline linux-arm64-musl windows-x64 windows-x64-baseline)
|
||||
ALL_PACKAGES=(oh-my-opencode oh-my-openagent)
|
||||
ALL_PACKAGES=(oh-my-opencode oh-my-openagent lazycodex)
|
||||
for plat in "${PLATFORMS[@]}"; do
|
||||
ALL_PACKAGES+=("oh-my-opencode-${plat}")
|
||||
ALL_PACKAGES+=("oh-my-openagent-${plat}")
|
||||
@@ -115,7 +130,7 @@ jobs:
|
||||
if [ ${#FAILED[@]} -gt 0 ]; then
|
||||
{
|
||||
echo
|
||||
echo "::error::Trusted publisher not configured for ${#FAILED[@]} package(s)."
|
||||
echo "::error::Trusted publisher not configured for ${#FAILED[@]} required package(s)."
|
||||
echo "::error::Configure each below at the URL with these values:"
|
||||
echo "::error:: Provider: GitHub Actions"
|
||||
echo "::error:: Organization: code-yeongyu"
|
||||
@@ -132,38 +147,12 @@ jobs:
|
||||
echo
|
||||
echo "All ${#ALL_PACKAGES[@]} packages have trusted publisher configured."
|
||||
|
||||
publish-main:
|
||||
release-metadata:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test, typecheck, preflight-trust]
|
||||
if: github.repository == 'code-yeongyu/oh-my-openagent'
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
dist_tag: ${{ steps.version.outputs.dist_tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- run: git fetch --force --tags
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.11"
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Upgrade npm for trusted publishing (>=11.5.1)
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli @ast-grep/napi"
|
||||
|
||||
- name: Calculate version
|
||||
id: version
|
||||
env:
|
||||
@@ -187,7 +176,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [[ "$VERSION" == *"-"* ]]; then
|
||||
DIST_TAG=$(printf '%s' "$VERSION" | cut -d'-' -f2 | cut -d'.' -f1)
|
||||
@@ -195,30 +184,122 @@ jobs:
|
||||
echo "::error::Invalid dist_tag: $DIST_TAG"
|
||||
exit 1
|
||||
fi
|
||||
echo "dist_tag=${DIST_TAG:-next}" >> $GITHUB_OUTPUT
|
||||
echo "dist_tag=${DIST_TAG:-next}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "dist_tag=" >> $GITHUB_OUTPUT
|
||||
echo "dist_tag=" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
echo "Version: $VERSION"
|
||||
|
||||
publish-main:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test, typecheck, preflight-trust, release-metadata, publish-platform]
|
||||
if: >-
|
||||
always() &&
|
||||
github.repository == 'code-yeongyu/oh-my-openagent' &&
|
||||
needs.test.result == 'success' &&
|
||||
needs.typecheck.result == 'success' &&
|
||||
needs.preflight-trust.result == 'success' &&
|
||||
needs.release-metadata.result == 'success' &&
|
||||
(inputs.skip_platform == true || needs.publish-platform.result == 'success')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- run: git fetch --force --tags
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.12"
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Upgrade npm for trusted publishing (>=11.5.1)
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/cli @ast-grep/napi"
|
||||
|
||||
- name: Verify platform packages are published
|
||||
env:
|
||||
VERSION: ${{ needs.release-metadata.outputs.version }}
|
||||
run: |
|
||||
PLATFORMS=(darwin-arm64 darwin-x64 darwin-x64-baseline linux-x64 linux-x64-baseline linux-arm64 linux-x64-musl linux-x64-musl-baseline linux-arm64-musl windows-x64 windows-x64-baseline)
|
||||
FAILED=()
|
||||
for platform in "${PLATFORMS[@]}"; do
|
||||
for family in oh-my-opencode oh-my-openagent; do
|
||||
pkg="${family}-${platform}"
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/${pkg}/${VERSION}")
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
echo "OK ${pkg}@${VERSION}"
|
||||
else
|
||||
echo "MISS ${pkg}@${VERSION} (HTTP ${STATUS})"
|
||||
FAILED+=("${pkg}")
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
if [ ${#FAILED[@]} -gt 0 ]; then
|
||||
echo "::error::Missing platform package(s); refusing to publish wrappers."
|
||||
for pkg in "${FAILED[@]}"; do
|
||||
echo "::error:: ${pkg}@${VERSION}"
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check if already published
|
||||
id: check
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
VERSION: ${{ needs.release-metadata.outputs.version }}
|
||||
run: |
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-opencode/${VERSION}")
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "✓ oh-my-opencode@${VERSION} already published"
|
||||
else
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Check if oh-my-openagent already published
|
||||
id: check-openagent
|
||||
env:
|
||||
VERSION: ${{ needs.release-metadata.outputs.version }}
|
||||
run: |
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-openagent/${VERSION}")
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "✓ oh-my-openagent@${VERSION} already published"
|
||||
else
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Check if lazycodex already published
|
||||
id: check-lazycodex
|
||||
env:
|
||||
VERSION: ${{ needs.release-metadata.outputs.version }}
|
||||
run: |
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/lazycodex/${VERSION}")
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "✓ lazycodex@${VERSION} already published"
|
||||
else
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Update version
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
if: >-
|
||||
steps.check.outputs.skip != 'true' ||
|
||||
steps.check-openagent.outputs.skip != 'true' ||
|
||||
steps.check-lazycodex.outputs.skip != 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
VERSION: ${{ needs.release-metadata.outputs.version }}
|
||||
run: |
|
||||
jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json
|
||||
|
||||
@@ -231,11 +312,17 @@ jobs:
|
||||
jq --arg v "$VERSION" '.optionalDependencies = (.optionalDependencies | to_entries | map(.value = $v) | from_entries)' package.json > tmp.json && mv tmp.json package.json
|
||||
|
||||
- name: Build main package
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
if: >-
|
||||
steps.check.outputs.skip != 'true' ||
|
||||
steps.check-openagent.outputs.skip != 'true' ||
|
||||
steps.check-lazycodex.outputs.skip != 'true'
|
||||
run: bun run build
|
||||
|
||||
- name: Strip token auth from .npmrc to force OIDC
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
if: >-
|
||||
steps.check.outputs.skip != 'true' ||
|
||||
steps.check-openagent.outputs.skip != 'true' ||
|
||||
steps.check-lazycodex.outputs.skip != 'true'
|
||||
run: |
|
||||
for f in .npmrc "$HOME/.npmrc"; do
|
||||
if [ -f "$f" ]; then
|
||||
@@ -248,33 +335,20 @@ jobs:
|
||||
- name: Publish oh-my-opencode
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
env:
|
||||
DIST_TAG: ${{ steps.version.outputs.dist_tag }}
|
||||
DIST_TAG: ${{ needs.release-metadata.outputs.dist_tag }}
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
run: |
|
||||
if [ -n "$DIST_TAG" ]; then
|
||||
npm publish --access public --provenance --tag "$DIST_TAG" --loglevel verbose
|
||||
else
|
||||
npm publish --access public --provenance --loglevel verbose
|
||||
fi
|
||||
|
||||
- name: Check if oh-my-openagent already published
|
||||
id: check-openagent
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-openagent/${VERSION}")
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
echo "✓ oh-my-openagent@${VERSION} already published"
|
||||
else
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
npm publish --access public --provenance --tag latest --loglevel verbose
|
||||
fi
|
||||
|
||||
- name: Publish oh-my-openagent
|
||||
if: steps.check-openagent.outputs.skip != 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
DIST_TAG: ${{ steps.version.outputs.dist_tag }}
|
||||
VERSION: ${{ needs.release-metadata.outputs.version }}
|
||||
DIST_TAG: ${{ needs.release-metadata.outputs.dist_tag }}
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
run: |
|
||||
# Update package name, version, and optionalDependencies for oh-my-openagent
|
||||
@@ -299,19 +373,58 @@ jobs:
|
||||
run: |
|
||||
git checkout -- package.json
|
||||
|
||||
- name: Publish lazycodex
|
||||
if: steps.check-lazycodex.outputs.skip != 'true'
|
||||
env:
|
||||
OMO_VERSION: ${{ needs.release-metadata.outputs.version }}
|
||||
DIST_TAG: ${{ needs.release-metadata.outputs.dist_tag }}
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
run: |
|
||||
jq --arg omo_version "$OMO_VERSION" '
|
||||
.name = "lazycodex" |
|
||||
.version = $omo_version |
|
||||
.optionalDependencies = (
|
||||
.optionalDependencies | to_entries |
|
||||
map(.key = (.key | sub("^oh-my-opencode-"; "oh-my-openagent-")) | .value = $omo_version) |
|
||||
from_entries
|
||||
)
|
||||
' package.json > tmp.json && mv tmp.json package.json
|
||||
|
||||
if [ -n "$DIST_TAG" ]; then
|
||||
npm publish --access public --provenance --tag "$DIST_TAG" --loglevel verbose
|
||||
else
|
||||
npm publish --access public --provenance --tag latest --loglevel verbose
|
||||
fi
|
||||
|
||||
- name: Restore package.json after lazycodex publish attempt
|
||||
if: always() && steps.check-lazycodex.outputs.skip != 'true'
|
||||
run: |
|
||||
git checkout -- package.json
|
||||
|
||||
publish-platform:
|
||||
needs: publish-main
|
||||
if: inputs.skip_platform != true
|
||||
needs: [test, typecheck, preflight-trust, release-metadata]
|
||||
if: >-
|
||||
always() &&
|
||||
github.repository == 'code-yeongyu/oh-my-openagent' &&
|
||||
inputs.skip_platform != true &&
|
||||
needs.test.result == 'success' &&
|
||||
needs.typecheck.result == 'success' &&
|
||||
needs.preflight-trust.result == 'success' &&
|
||||
needs.release-metadata.result == 'success'
|
||||
uses: ./.github/workflows/publish-platform.yml
|
||||
with:
|
||||
version: ${{ needs.publish-main.outputs.version }}
|
||||
dist_tag: ${{ needs.publish-main.outputs.dist_tag }}
|
||||
version: ${{ needs.release-metadata.outputs.version }}
|
||||
dist_tag: ${{ needs.release-metadata.outputs.dist_tag }}
|
||||
secrets: inherit
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [publish-main, publish-platform]
|
||||
if: always() && needs.publish-main.result == 'success' && (inputs.skip_platform == true || needs.publish-platform.result == 'success')
|
||||
needs: [release-metadata, publish-main, publish-platform]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.release-metadata.result == 'success' &&
|
||||
needs.publish-main.result == 'success' &&
|
||||
(inputs.skip_platform == true || needs.publish-platform.result == 'success')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -321,7 +434,7 @@ jobs:
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
bun-version: "1.3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
@@ -335,9 +448,36 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Apply release version to source tree
|
||||
- name: Resolve release state
|
||||
id: release-state
|
||||
env:
|
||||
VERSION: ${{ needs.publish-main.outputs.version }}
|
||||
VERSION: ${{ needs.release-metadata.outputs.version }}
|
||||
RELEASE_REF: ${{ github.ref_name }}
|
||||
run: |
|
||||
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
|
||||
echo "tag_exists=true" >> "$GITHUB_OUTPUT"
|
||||
echo "release_commit_exists=true" >> "$GITHUB_OUTPUT"
|
||||
git checkout --detach "v${VERSION}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "tag_exists=false" >> "$GITHUB_OUTPUT"
|
||||
RELEASE_SHA=""
|
||||
if [ -n "$RELEASE_REF" ]; then
|
||||
RELEASE_SHA=$(git rev-list --max-count=1 --grep="^release: v${VERSION}$" "origin/${RELEASE_REF}" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [ -n "$RELEASE_SHA" ]; then
|
||||
echo "release_commit_exists=true" >> "$GITHUB_OUTPUT"
|
||||
git checkout --detach "$RELEASE_SHA"
|
||||
else
|
||||
echo "release_commit_exists=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Apply release version to source tree
|
||||
if: steps.release-state.outputs.release_commit_exists != 'true'
|
||||
env:
|
||||
VERSION: ${{ needs.release-metadata.outputs.version }}
|
||||
run: |
|
||||
jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json
|
||||
|
||||
@@ -350,8 +490,9 @@ jobs:
|
||||
jq --arg v "$VERSION" '.optionalDependencies = (.optionalDependencies | to_entries | map(.value = $v) | from_entries)' package.json > tmp.json && mv tmp.json package.json
|
||||
|
||||
- name: Commit version bump
|
||||
if: steps.release-state.outputs.release_commit_exists != 'true'
|
||||
env:
|
||||
VERSION: ${{ needs.publish-main.outputs.version }}
|
||||
VERSION: ${{ needs.release-metadata.outputs.version }}
|
||||
run: |
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config user.name "github-actions[bot]"
|
||||
@@ -359,26 +500,73 @@ jobs:
|
||||
git diff --cached --quiet || git commit -m "release: v${VERSION}"
|
||||
|
||||
- name: Create release tag
|
||||
if: steps.release-state.outputs.tag_exists != 'true'
|
||||
env:
|
||||
VERSION: ${{ needs.publish-main.outputs.version }}
|
||||
VERSION: ${{ needs.release-metadata.outputs.version }}
|
||||
run: |
|
||||
if git rev-parse "v${VERSION}" >/dev/null 2>&1; then
|
||||
echo "::error::Tag v${VERSION} already exists"
|
||||
exit 1
|
||||
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
|
||||
echo "Release tag v${VERSION} already exists locally"
|
||||
else
|
||||
git tag "v${VERSION}"
|
||||
fi
|
||||
git tag "v${VERSION}"
|
||||
|
||||
- name: Push release state
|
||||
if: steps.release-state.outputs.tag_exists != 'true'
|
||||
env:
|
||||
VERSION: ${{ needs.publish-main.outputs.version }}
|
||||
VERSION: ${{ needs.release-metadata.outputs.version }}
|
||||
RELEASE_REF: ${{ github.ref_name }}
|
||||
RELEASE_COMMIT_EXISTS: ${{ steps.release-state.outputs.release_commit_exists }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git push origin HEAD
|
||||
git push origin "v${VERSION}"
|
||||
if [ "$RELEASE_COMMIT_EXISTS" != "true" ]; then
|
||||
if [ -z "$RELEASE_REF" ]; then
|
||||
echo "::error::Cannot push release commit because github.ref_name is empty."
|
||||
exit 1
|
||||
fi
|
||||
git push origin "HEAD:${RELEASE_REF}"
|
||||
else
|
||||
echo "Release commit already exists on origin/${RELEASE_REF}"
|
||||
fi
|
||||
|
||||
if git ls-remote --exit-code --tags origin "refs/tags/v${VERSION}" >/dev/null 2>&1; then
|
||||
echo "Release tag v${VERSION} already exists on origin"
|
||||
else
|
||||
git push origin "v${VERSION}"
|
||||
fi
|
||||
|
||||
- name: Checkout LazyCodex marketplace
|
||||
if: inputs.sync_lazycodex_marketplace == true
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: code-yeongyu/lazycodex
|
||||
path: lazycodex-marketplace
|
||||
token: ${{ secrets.LAZYCODEX_SYNC_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Sync LazyCodex Codex marketplace
|
||||
if: inputs.sync_lazycodex_marketplace == true
|
||||
env:
|
||||
VERSION: ${{ needs.release-metadata.outputs.version }}
|
||||
run: |
|
||||
npm --prefix packages/omo-codex/plugin ci
|
||||
bun run --cwd packages/omo-codex/plugin build
|
||||
bun run build:ast-grep-mcp
|
||||
bun run build:lsp-tools-mcp
|
||||
bun run script/sync-lazycodex-marketplace.ts "$GITHUB_WORKSPACE" "$GITHUB_WORKSPACE/lazycodex-marketplace"
|
||||
cd "$GITHUB_WORKSPACE/lazycodex-marketplace"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config user.name "github-actions[bot]"
|
||||
git add .agents/plugins/marketplace.json plugins/omo
|
||||
if git diff --cached --quiet; then
|
||||
echo "LazyCodex marketplace already up to date"
|
||||
else
|
||||
git commit -m "chore: sync Codex marketplace v${VERSION}"
|
||||
git push origin HEAD:main
|
||||
fi
|
||||
|
||||
- name: Create GitHub release
|
||||
env:
|
||||
VERSION: ${{ needs.publish-main.outputs.version }}
|
||||
VERSION: ${{ needs.release-metadata.outputs.version }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release view "v${VERSION}" >/dev/null 2>&1 || \
|
||||
@@ -392,7 +580,7 @@ jobs:
|
||||
- name: Merge to master
|
||||
continue-on-error: true
|
||||
env:
|
||||
VERSION: ${{ needs.publish-main.outputs.version }}
|
||||
VERSION: ${{ needs.release-metadata.outputs.version }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
bun-version: "1.3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
bun-version: "1.3.12"
|
||||
|
||||
- name: Cache Bun dependencies
|
||||
uses: actions/cache@v4
|
||||
@@ -345,29 +345,31 @@ jobs:
|
||||
COMMENT_ID_VAL: ${{ github.event.comment.id }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [[ "$EVENT_NAME" == "issue_comment" ]]; then
|
||||
ISSUE_NUM="$ISSUE_NUMBER"
|
||||
AUTHOR="$COMMENT_AUTHOR"
|
||||
COMMENT_ID="$COMMENT_ID_VAL"
|
||||
{
|
||||
if [[ "$EVENT_NAME" == "issue_comment" ]]; then
|
||||
ISSUE_NUM="$ISSUE_NUMBER"
|
||||
AUTHOR="$COMMENT_AUTHOR"
|
||||
COMMENT_ID="$COMMENT_ID_VAL"
|
||||
|
||||
# Check if PR or Issue and get title
|
||||
ISSUE_DATA=$(gh api "repos/$REPO/issues/${ISSUE_NUM}")
|
||||
TITLE=$(echo "$ISSUE_DATA" | jq -r '.title')
|
||||
if echo "$ISSUE_DATA" | jq -e '.pull_request' > /dev/null; then
|
||||
echo "type=pr" >> $GITHUB_OUTPUT
|
||||
echo "number=${ISSUE_NUM}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "type=issue" >> $GITHUB_OUTPUT
|
||||
echo "number=${ISSUE_NUM}" >> $GITHUB_OUTPUT
|
||||
# Check if PR or Issue and get title
|
||||
ISSUE_DATA=$(gh api "repos/$REPO/issues/${ISSUE_NUM}")
|
||||
TITLE=$(echo "$ISSUE_DATA" | jq -r '.title')
|
||||
if echo "$ISSUE_DATA" | jq -e '.pull_request' > /dev/null; then
|
||||
echo "type=pr"
|
||||
echo "number=${ISSUE_NUM}"
|
||||
else
|
||||
echo "type=issue"
|
||||
echo "number=${ISSUE_NUM}"
|
||||
fi
|
||||
echo "title=${TITLE}"
|
||||
fi
|
||||
echo "title=${TITLE}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
echo "comment<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$COMMENT_BODY" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
echo "author=$AUTHOR" >> $GITHUB_OUTPUT
|
||||
echo "comment_id=$COMMENT_ID" >> $GITHUB_OUTPUT
|
||||
echo "comment<<EOF"
|
||||
echo "$COMMENT_BODY"
|
||||
echo "EOF"
|
||||
echo "author=$AUTHOR"
|
||||
echo "comment_id=$COMMENT_ID"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Add :eyes: reaction (as sisyphus-dev-ai)
|
||||
- name: Add eyes reaction
|
||||
|
||||
+1
-1
@@ -31,6 +31,7 @@ npm-debug.log*
|
||||
|
||||
# Lock files (use bun.lockb instead)
|
||||
package-lock.json
|
||||
!packages/omo-codex/plugin/package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Environment
|
||||
@@ -40,7 +41,6 @@ test-injection/
|
||||
notepad.md
|
||||
oauth-success.html
|
||||
*.bun-build
|
||||
.omx/
|
||||
.dori-sync/
|
||||
.dori/
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -22,6 +22,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Per-agent `displayName` for i18n. Agents can present localized names in UI and logs. (PR #4081)
|
||||
- Grok family models registered with `reasoningEffort` support. (PR #4186)
|
||||
- CLI `setup` alias for `install`. Either command runs the interactive setup wizard. (PR #4174)
|
||||
- Codex CLI Light edition (`omo-codex`): one-command install via `bunx omo install --platform=codex` or the new `lazycodex` bin entry. Vendored Codex plugin namespace `omo` with rules, comment-checker, LSP, ultrawork, and ulw-loop components. Plugin lands in `~/.codex/plugins/cache/sisyphuslabs/omo/` and is enabled in `~/.codex/config.toml`. Idempotent installer (re-running is safe).
|
||||
- New `--platform <opencode|codex|both>` install flag (default `opencode`). Replaces the previous Codex-as-optional-addon model — `--platform=codex` installs only the Codex Light edition, `--platform=both` installs both editions in one run.
|
||||
- Three new bin entries: `omo` (short alias) and `lazycodex` (auto-defaults `--platform=codex`). Existing `oh-my-opencode` and `oh-my-openagent` continue to work unchanged.
|
||||
- New PostHog telemetry stream `omo_codex_daily_active` distinguishing omo-codex installations from omo-opencode. Independent opt-out via `OMO_CODEX_DISABLE_POSTHOG=1` or `OMO_CODEX_SEND_ANONYMOUS_TELEMETRY=0`; global `OMO_DISABLE_POSTHOG` and `OMO_SEND_ANONYMOUS_TELEMETRY` still suppress both products.
|
||||
- omo-codex now reports true daily-active usage (DAU/WAU/MAU). A new Codex plugin component `telemetry` (`packages/omo-codex/plugin/components/telemetry/`) fires a single `omo_codex_daily_active` event with `reason: "session_start"` from every Codex `SessionStart` hook, with the same UTC-day deduplication, hashed installation identifier, and opt-out env vars as the install-time event. Identity constants stay byte-equivalent across the CLI installer and the plugin runtime via `packages/omo-codex/src/telemetry/cross-package-equivalence.test.ts`.
|
||||
- Triple-publish to npm: `oh-my-opencode`, `oh-my-openagent`, and the new `lazycodex` package with the same compiled CLI and four bin commands. See `docs/reference/lazycodex-npm-reservation.md` for the first-publish playbook.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
+33
-18
@@ -123,6 +123,8 @@ curl -s https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/refs/head
|
||||
|
||||
匿名のテレメトリは、アクティブなインストール数(DAU/WAU/MAU)の集計のためにデフォルトで有効になっています。マシン1台につきUTC日あたり最大1回イベントが送信され、ハッシュ化されたインストール識別子を使用し、生のホスト名は使用せず、PostHog person profile も作成されません。無効化するには `OMO_SEND_ANONYMOUS_TELEMETRY=0` または `OMO_DISABLE_POSTHOG=1` を設定してください。[プライバシーポリシー](docs/legal/privacy-policy.md)と[利用規約](docs/legal/terms-of-service.md)をご覧ください。
|
||||
|
||||
**Ultimate と Light:** oh-my-openagent は同じ製品の 2 つのエディションとして提供されます。**Ultimate エディション**(`bunx omo install` または `--platform=opencode`、デフォルト)は OpenCode 上のフル機能で、11 エージェント、54+ フック、Team Mode、すべての MCP、スラッシュコマンド、IntentGate モードを提供します。**Light エディション**(`bunx omo install --platform=codex`)は OpenAI Codex CLI のプラグインシステムへ綺麗に移植できる 5 コンポーネント(`rules`、`comment-checker`、`lsp`、`ultrawork`、`ulw-loop`)のみを提供します。`bunx lazycodex install` は `--platform=codex` のショートカット別名です。両方を同時にインストールするには `--platform=both`。Codex 専用テレメトリは `OMO_CODEX_DISABLE_POSTHOG=1` または `OMO_CODEX_SEND_ANONYMOUS_TELEMETRY=0` で無効化できます。
|
||||
|
||||
---
|
||||
|
||||
## この README をスキップする
|
||||
@@ -150,24 +152,29 @@ Read this and tell me why it's not just another boilerplate: https://raw.githubu
|
||||
- [GLM Coding プラン ($10)](https://z.ai/subscribe)
|
||||
- 従量課金 (pay-per-token) の対象であれば、Kimi や Gemini モデルを使っても費用はそれほどかかりません。
|
||||
|
||||
| | 機能 | 何をするのか |
|
||||
| :---: | :------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🤖 | **規律あるエージェント (Discipline Agents)** | Sisyphus が Hephaestus、Oracle、Librarian、Explore をオーケストレーションします。完全な AI 開発チームが並列で動きます。 |
|
||||
| 👥 | **Team Mode** (v4.0, オプトイン) | リードエージェント + 最大 8 メンバーの並列実行、リアルタイム tmux 可視化、専用 `team_*` ツール群。`hyperplan`(5 人の敵対的批評家)と `security-research`(3 人のハンター + 2 人の PoC エンジニア)を駆動します。[ドキュメント →](docs/guide/team-mode.md) |
|
||||
| ⚡ | **`ultrawork` / `ulw`** | 一言で OK。すべてのエージェントがアクティブになり、終わるまで止まりません。 |
|
||||
| 🚪 | **[IntentGate](https://factory.ai/news/terminal-bench)** | ユーザーの真の意図を分析してから分類・行動します。もう文字通りに誤解して的外れなことをすることはありません。 |
|
||||
| 🔗 | **ハッシュベースの編集ツール** | `LINE#ID` のコンテンツハッシュですべての変更を検証します。stale-line エラー 0%。[oh-my-pi](https://github.com/can1357/oh-my-pi) にインスパイアされています。[The Harness Problem →](https://blog.can.ac/2026/02/12/the-harness-problem/) |
|
||||
| 🛠️ | **LSP + AST-Grep** | ワークスペース単位のリネーム、ビルド前の診断、AST を考慮した書き換え。エージェントに IDE レベルの精度を提供します。 |
|
||||
| 🧠 | **バックグラウンドエージェント** | 5 人以上の専門家を並列で投入します。コンテキストは軽く保ち、結果は準備ができ次第受け取ります。 |
|
||||
| 📚 | **組み込み MCP** | Exa (Web 検索)、Context7 (公式ドキュメント)、Grep.app (GitHub 検索)。常にオンです。 |
|
||||
| 🔁 | **Ralph Loop / `/ulw-loop`** | 自己参照ループ。100% 完了するまで絶対に止まりません。 |
|
||||
| ✅ | **Todo Enforcer** | エージェントがサボる?システムが首根っこを掴んで戻します。あなたのタスクは必ず終わります。 |
|
||||
| 💬 | **コメントチェッカー** | コメントから AI 臭い無駄話を排除します。シニアエンジニアが書いたようなコードになります。 |
|
||||
| 🖥️ | **Tmux 統合** | 完全なインタラクティブターミナル。REPL、デバッガー、TUI アプリがすべてリアルタイムで動きます。 |
|
||||
| 🔌 | **Claude Code 互換性** | 既存のフック、コマンド、スキル、MCP、プラグイン?すべてここでそのまま動きます。 |
|
||||
| 🎯 | **スキル内蔵 MCP** | スキルが独自の MCP サーバーを持ち歩きます。コンテキストが肥大化しません。 |
|
||||
| 📋 | **Prometheus プランナー** | インタビューモードで、実行前に戦略的な計画から立てます。 |
|
||||
| 🔍 | **`/init-deep`** | プロジェクト全体にわたって階層的な `AGENTS.md` ファイルを自動生成します。トークン効率とエージェントのパフォーマンスの両方を向上させます。 |
|
||||
| | 機能 | Editions | 何をするのか |
|
||||
| :---: | :------------------------------------------------------- | :------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🤖 | **規律あるエージェント (Discipline Agents)** | Ultimate | Sisyphus が Hephaestus、Oracle、Librarian、Explore をオーケストレーションします。完全な AI 開発チームが並列で動きます。 |
|
||||
| 🧩 | **Codex CLI Light Edition** | Light | OpenAI Codex CLI 上で動作する omo の 5 つの移植コンポーネント (rules, comment-checker, LSP, ultrawork, ulw-loop)。インストール: `bunx omo install --platform=codex`。 |
|
||||
| 👥 | **Team Mode** (v4.0, オプトイン) | Ultimate | リードエージェント + 最大 8 メンバーの並列実行、リアルタイム tmux 可視化、専用 `team_*` ツール群。`hyperplan`(5 人の敵対的批評家)と `security-research`(3 人のハンター + 2 人の PoC エンジニア)を駆動します。[ドキュメント →](docs/guide/team-mode.md) |
|
||||
| ⚡ | **`ultrawork` / `ulw`** | Both | 一言で OK。すべてのエージェント (Ultimate) または Codex `ultrawork` コンポーネント (Light) がアクティブになり、終わるまで止まりません。 |
|
||||
| 🚪 | **[IntentGate](https://factory.ai/news/terminal-bench)** | Ultimate | ユーザーの真の意図を分析してから分類・行動します。`search` / `analyze` / `team` / `hyperplan` をトリガー。(Light は `ulw` / `ultrawork` のみフック。) |
|
||||
| 🔗 | **ハッシュベースの編集ツール** | Ultimate | `LINE#ID` のコンテンツハッシュですべての変更を検証します。stale-line エラー 0%。[oh-my-pi](https://github.com/can1357/oh-my-pi) にインスパイア。[The Harness Problem →](https://blog.can.ac/2026/02/12/the-harness-problem/) (Codex はネイティブの `apply_patch` を使用。) |
|
||||
| 🛠️ | **LSP + AST-Grep** | Ultimate | ワークスペース単位のリネーム、ビルド前の診断、AST を考慮した書き換え。エージェントに IDE レベルの精度を提供。(LSP は Light でも `lsp` コンポーネントで動作; AST-Grep は Ultimate のみ。) |
|
||||
| 🧠 | **バックグラウンドエージェント** | Ultimate | 5 人以上の専門家を並列で投入。コンテキストは軽く保ち、結果は準備ができ次第受け取ります。 |
|
||||
| 📚 | **組み込み MCP** | Ultimate | Exa (Web 検索)、Context7 (公式ドキュメント)、Grep.app (GitHub 検索)。常にオン。(Light は LSP MCP のみ。) |
|
||||
| 🔁 | **Ralph Loop / `/ulw-loop`** | Ultimate | 自己参照ループ。100% 完了するまで絶対に止まりません。 |
|
||||
| ✅ | **Todo Enforcer** (Boulder) | Ultimate | エージェントがサボる?システムが首根っこを掴んで戻します。あなたのタスクは必ず終わります。 |
|
||||
| 💬 | **コメントチェッカー** | Both | コメントから AI 臭い無駄話を排除。両エディションで同じ `@code-yeongyu/comment-checker` バイナリが動作。 |
|
||||
| 📜 | **Rules Injection** | Both | `AGENTS.md` / `CLAUDE.md` / `.omo/rules/**` の階層的コンテキスト注入。Ultimate はフック、Light は `rules` コンポーネント。 |
|
||||
| 🧬 | **Ulw Loop** | Light | `.omo/ulw-loop/` evidence audit ベースの永続的マルチゴール オーケストレーション。現在は Codex 専用; OpenCode 側への移植はロードマップ。 |
|
||||
| 🖥️ | **Tmux 統合** | Ultimate | 完全なインタラクティブターミナル。REPL、デバッガー、TUI アプリがすべてリアルタイムで動きます。 |
|
||||
| 🔌 | **Claude Code 互換性** | Ultimate | 既存のフック、コマンド、スキル、MCP、プラグイン?すべてここでそのまま動きます。(Codex は独自のネイティブプラグインシステムを保有。) |
|
||||
| 🎯 | **スキル内蔵 MCP** | Ultimate | スキルが独自の MCP サーバーを持ち歩きます。コンテキストが肥大化しません。 |
|
||||
| 📋 | **Prometheus プランナー** | Ultimate | インタビューモードで、実行前に戦略的な計画から立てます。 |
|
||||
| 🔍 | **`/init-deep`** | Ultimate | プロジェクト全体にわたって階層的な `AGENTS.md` ファイルを自動生成。トークン効率とエージェントのパフォーマンスの両方を向上させます。 |
|
||||
|
||||
> **Editions legend.** **Ultimate** = OpenCode 専用 (`bunx omo install`)。**Light** = Codex CLI 専用 (`bunx omo install --platform=codex`)。**Both** = 両エディションに提供、しばしば内部実装は若干異なる。
|
||||
|
||||
### 規律あるエージェント (Discipline Agents)
|
||||
|
||||
@@ -339,6 +346,14 @@ oh-my-openagent を削除するには:
|
||||
# プラグインがロードされなくなっているはずです
|
||||
```
|
||||
|
||||
4. **omo-codex (Codex CLI Light エディション) を削除する**
|
||||
|
||||
```bash
|
||||
rm -rf ~/.codex/plugins/cache/sisyphuslabs
|
||||
```
|
||||
|
||||
その後 `~/.codex/config.toml` を開き、`[marketplaces.sisyphuslabs]`、`[plugins."omo@sisyphuslabs"]`、`[hooks.state."omo@sisyphuslabs:..."]` ブロックを削除してください。
|
||||
|
||||
## Features
|
||||
|
||||
最初から存在していて当然だと感じる機能たち。一度使うと戻れなくなります。
|
||||
|
||||
+33
-18
@@ -124,6 +124,8 @@ curl -s https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/refs/head
|
||||
|
||||
익명 텔레메트리는 활성 설치 수(DAU/WAU/MAU) 집계를 위해 기본적으로 활성화되어 있습니다. 머신당 UTC 하루에 최대 1회만 이벤트가 전송되며, 해시된 설치 식별자를 사용하고 원시 호스트명은 절대 사용하지 않으며 PostHog person profile은 생성되지 않습니다. `OMO_SEND_ANONYMOUS_TELEMETRY=0` 또는 `OMO_DISABLE_POSTHOG=1`로 비활성화할 수 있습니다. [개인정보처리방침](docs/legal/privacy-policy.md)과 [서비스 이용약관](docs/legal/terms-of-service.md)을 참조하세요.
|
||||
|
||||
**Ultimate vs Light:** oh-my-openagent는 같은 제품의 두 에디션으로 출시됩니다. **Ultimate 에디션**(`bunx omo install` 또는 `--platform=opencode`, 기본값)은 OpenCode 위에서 풀 기능 — 11 agent, 54+ hook, Team Mode, 모든 MCP, 슬래시 명령, IntentGate 모드 — 을 제공합니다. **Light 에디션**(`bunx omo install --platform=codex`)은 OpenAI Codex CLI의 플러그인 시스템에 깔끔히 포팅되는 5개 컴포넌트(`rules`, `comment-checker`, `lsp`, `ultrawork`, `ulw-loop`)만 제공합니다. `bunx lazycodex install`은 `--platform=codex`의 단축 별칭입니다. 둘 다 설치하려면 `--platform=both`. Codex 전용 텔레메트리는 `OMO_CODEX_DISABLE_POSTHOG=1` 또는 `OMO_CODEX_SEND_ANONYMOUS_TELEMETRY=0`으로 비활성화할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 이 README 건너뛰기
|
||||
@@ -151,24 +153,29 @@ Read this and tell me why it's not just another boilerplate: https://raw.githubu
|
||||
- [GLM Coding 요금제 ($10)](https://z.ai/subscribe)
|
||||
- 종량제(pay-per-token) 대상자라면 kimi와 gemini 모델을 써도 비용이 별로 안 나옵니다.
|
||||
|
||||
| | 기능 | 하는 일 |
|
||||
| :---: | :------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🤖 | **Discipline Agents** | Sisyphus가 Hephaestus, Oracle, Librarian, Explore를 지휘합니다. 병렬로 도는 풀스택 AI 개발팀. |
|
||||
| 👥 | **Team Mode** (v4.0, opt-in) | 리드 에이전트 + 최대 8명의 병렬 멤버, 실시간 tmux 시각화, 전용 `team_*` 도구. `hyperplan`(5명의 적대적 비평가)과 `security-research`(3명의 헌터 + 2명의 PoC 엔지니어)를 구동합니다. [문서 →](docs/guide/team-mode.md) |
|
||||
| ⚡ | **`ultrawork` / `ulw`** | 한 단어. 모든 에이전트가 켜집니다. 끝날 때까지 멈추지 않습니다. |
|
||||
| 🚪 | **[IntentGate](https://factory.ai/news/terminal-bench)** | 분류하거나 행동하기 전에 사용자의 진짜 의도부터 분석합니다. 문자 그대로 오해하는 일은 끝. |
|
||||
| 🔗 | **Hash-Anchored Edit Tool** | `LINE#ID` 콘텐츠 해시가 모든 변경을 검증합니다. 낡은 라인 에러 0건. [oh-my-pi](https://github.com/can1357/oh-my-pi)에서 영감. [The Harness Problem →](https://blog.can.ac/2026/02/12/the-harness-problem/) |
|
||||
| 🛠️ | **LSP + AST-Grep** | 워크스페이스 리네임, 빌드 전 진단, AST 기반 리라이트. 에이전트에게도 IDE 수준의 정밀도. |
|
||||
| 🧠 | **Background Agents** | 전문가 5명 이상을 동시에 발사. 컨텍스트는 가볍게. 결과는 준비되면 도착. |
|
||||
| 📚 | **Built-in MCPs** | Exa(웹 검색), Context7(공식 문서), Grep.app(GitHub 검색). 항상 켜져 있음. |
|
||||
| 🔁 | **Ralph Loop / `/ulw-loop`** | 자기참조 루프. 100% 끝날 때까지 멈추지 않습니다. |
|
||||
| ✅ | **Todo Enforcer** | 에이전트가 놀고 있나요? 시스템이 다시 끌어옵니다. 당신의 작업은 반드시 끝납니다. |
|
||||
| 💬 | **Comment Checker** | 주석에 AI 슬롭 금지. 시니어가 쓴 것처럼 읽히는 코드. |
|
||||
| 🖥️ | **Tmux Integration** | 풀 인터랙티브 터미널. REPL, 디버거, TUI 전부 라이브. |
|
||||
| 🔌 | **Claude Code Compatible** | 쓰시던 hook, command, skill, MCP, plugin 전부 그대로 동작합니다. |
|
||||
| 🎯 | **Skill-Embedded MCPs** | 스킬이 자기만의 MCP 서버를 들고 다닙니다. 컨텍스트 낭비 없음. |
|
||||
| 📋 | **Prometheus Planner** | 실행 전 인터뷰 모드로 전략 플래닝. |
|
||||
| 🔍 | **`/init-deep`** | 프로젝트 전반에 계층형 `AGENTS.md` 파일을 자동 생성합니다. 토큰 효율에도, 에이전트 성능에도 좋습니다. |
|
||||
| | 기능 | Editions | 하는 일 |
|
||||
| :---: | :------------------------------------------------------- | :------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🤖 | **Discipline Agents** | Ultimate | Sisyphus가 Hephaestus, Oracle, Librarian, Explore를 지휘합니다. 병렬로 도는 풀스택 AI 개발팀. |
|
||||
| 🧩 | **Codex CLI Light Edition** | Light | OpenAI Codex CLI에서 동작하는 omo의 5개 포팅 컴포넌트(rules, comment-checker, LSP, ultrawork, ulw-loop). 설치: `bunx omo install --platform=codex`. |
|
||||
| 👥 | **Team Mode** (v4.0, opt-in) | Ultimate | 리드 에이전트 + 최대 8명의 병렬 멤버, 실시간 tmux 시각화, 전용 `team_*` 도구. `hyperplan`(5명의 적대적 비평가)과 `security-research`(3명의 헌터 + 2명의 PoC 엔지니어)를 구동합니다. [문서 →](docs/guide/team-mode.md) |
|
||||
| ⚡ | **`ultrawork` / `ulw`** | Both | 한 단어. 모든 에이전트(Ultimate)나 Codex `ultrawork` 컴포넌트(Light)가 켜집니다. 끝날 때까지 멈추지 않습니다. |
|
||||
| 🚪 | **[IntentGate](https://factory.ai/news/terminal-bench)** | Ultimate | 분류하거나 행동하기 전에 사용자의 진짜 의도부터 분석합니다. `search` / `analyze` / `team` / `hyperplan` 트리거. (Light는 `ulw` / `ultrawork`만 hook.) |
|
||||
| 🔗 | **Hash-Anchored Edit Tool** | Ultimate | `LINE#ID` 콘텐츠 해시가 모든 변경을 검증합니다. 낡은 라인 에러 0건. [oh-my-pi](https://github.com/can1357/oh-my-pi)에서 영감. [The Harness Problem →](https://blog.can.ac/2026/02/12/the-harness-problem/) (Codex는 자체 `apply_patch` 사용.) |
|
||||
| 🛠️ | **LSP + AST-Grep** | Ultimate | 워크스페이스 리네임, 빌드 전 진단, AST 기반 리라이트. 에이전트에게도 IDE 수준의 정밀도. (LSP는 Light에서도 `lsp` 컴포넌트로 동작; AST-Grep은 Ultimate 전용.) |
|
||||
| 🧠 | **Background Agents** | Ultimate | 전문가 5명 이상을 동시에 발사. 컨텍스트는 가볍게. 결과는 준비되면 도착. |
|
||||
| 📚 | **Built-in MCPs** | Ultimate | Exa(웹 검색), Context7(공식 문서), Grep.app(GitHub 검색). 항상 켜져 있음. (Light는 LSP MCP만.) |
|
||||
| 🔁 | **Ralph Loop / `/ulw-loop`** | Ultimate | 자기참조 루프. 100% 끝날 때까지 멈추지 않습니다. |
|
||||
| ✅ | **Todo Enforcer** (Boulder) | Ultimate | 에이전트가 놀고 있나요? 시스템이 다시 끌어옵니다. 당신의 작업은 반드시 끝납니다. |
|
||||
| 💬 | **Comment Checker** | Both | 주석에 AI 슬롭 금지. 동일한 `@code-yeongyu/comment-checker` 바이너리가 두 에디션 모두에서 동작. |
|
||||
| 📜 | **Rules Injection** | Both | `AGENTS.md` / `CLAUDE.md` / `.omo/rules/**` 계층형 컨텍스트 주입. Ultimate은 hook, Light는 `rules` 컴포넌트. |
|
||||
| 🧬 | **Ulw Loop** | Light | `.omo/ulw-loop/` evidence audit 기반 영속 멀티 골 오케스트레이션. 현재 Codex 전용; OpenCode 사이드 포팅은 로드맵에 있음. |
|
||||
| 🖥️ | **Tmux Integration** | Ultimate | 풀 인터랙티브 터미널. REPL, 디버거, TUI 전부 라이브. |
|
||||
| 🔌 | **Claude Code Compatible** | Ultimate | 쓰시던 hook, command, skill, MCP, plugin 전부 그대로 동작합니다. (Codex는 자체 플러그인 시스템 보유.) |
|
||||
| 🎯 | **Skill-Embedded MCPs** | Ultimate | 스킬이 자기만의 MCP 서버를 들고 다닙니다. 컨텍스트 낭비 없음. |
|
||||
| 📋 | **Prometheus Planner** | Ultimate | 실행 전 인터뷰 모드로 전략 플래닝. |
|
||||
| 🔍 | **`/init-deep`** | Ultimate | 프로젝트 전반에 계층형 `AGENTS.md` 파일을 자동 생성합니다. 토큰 효율에도, 에이전트 성능에도 좋습니다. |
|
||||
|
||||
> **Editions legend.** **Ultimate** = OpenCode 전용 (`bunx omo install`). **Light** = Codex CLI 전용 (`bunx omo install --platform=codex`). **Both** = 두 에디션 모두 제공, 종종 내부 구현은 약간 다름.
|
||||
|
||||
### Discipline Agents
|
||||
|
||||
@@ -340,6 +347,14 @@ oh-my-openagent를 제거하려면:
|
||||
# 더 이상 플러그인이 로드되지 않아야 합니다
|
||||
```
|
||||
|
||||
4. **omo-codex (Codex CLI Light 에디션) 제거**
|
||||
|
||||
```bash
|
||||
rm -rf ~/.codex/plugins/cache/sisyphuslabs
|
||||
```
|
||||
|
||||
그런 다음 `~/.codex/config.toml`을 열어 `[marketplaces.sisyphuslabs]`, `[plugins."omo@sisyphuslabs"]`, `[hooks.state."omo@sisyphuslabs:..."]` 블록들을 삭제하세요.
|
||||
|
||||
## Features
|
||||
|
||||
진작 있었어야 했다고 느낄 기능들입니다. 한 번 쓰면 되돌아갈 수 없습니다.
|
||||
|
||||
@@ -101,28 +101,72 @@ Install oh-my-openagent. Type `ultrawork`. Done.
|
||||
|
||||
## Installation
|
||||
|
||||
oh-my-openagent ships in two editions of the same product:
|
||||
|
||||
- **Ultimate Edition (omo for OpenCode)** — full omo. 11 agents, 54+ lifecycle hooks, 5 built-in MCPs, all slash commands, Team Mode, ulw-loop, ultrawork, hashline edits — everything.
|
||||
- **Light Edition (omo for Codex CLI)** — the portable components that fit Codex's plugin system: `rules`, `comment-checker`, `lsp`, `ultrawork`, `ulw-loop`, `start-work-continuation`, and `telemetry`. No agent orchestration, no `team_*` tools, no built-in MCPs beyond LSP — Codex CLI's own surface does that work.
|
||||
|
||||
Pick the edition(s) you want.
|
||||
|
||||
### TL;DR
|
||||
|
||||
| You want | Run | What lands on disk |
|
||||
| :--- | :--- | :--- |
|
||||
| **Ultimate** (OpenCode) | `bunx omo install` (TUI walks you through it) | Plugin registered in `opencode.json` + agent/model config + provider auth prompts |
|
||||
| **Light** (Codex CLI) | `bunx omo install --platform=codex` or `bunx lazycodex install` | `~/.codex/plugins/cache/sisyphuslabs/omo/` + local Codex marketplace cache + `~/.codex/config.toml` marketplace/plugin/agent blocks + optional autonomous permissions + component CLIs in `~/.local/bin` |
|
||||
| **Both** | `bunx omo install --platform=both` | Both of the above |
|
||||
|
||||
`--platform` defaults to `opencode` (Ultimate). The `bunx lazycodex install` alias is a shortcut for `bunx omo install --platform=codex`; use whichever reads cleaner.
|
||||
|
||||
### For Humans
|
||||
|
||||
Copy and paste this prompt to your LLM agent (Claude Code, AmpCode, Cursor, etc.):
|
||||
**Strongly recommended: let an LLM agent install this for you.** The Ultimate edition setup involves subscription detection, model selection across 11 agents, and per-provider authentication — humans fat-finger these. An LLM agent reads the full guide and walks every step correctly.
|
||||
|
||||
Paste this prompt into Claude Code, AmpCode, Cursor, or any agent:
|
||||
|
||||
```
|
||||
Install and configure oh-my-openagent by following the instructions here:
|
||||
https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/refs/heads/dev/docs/guide/installation.md
|
||||
```
|
||||
|
||||
Or read the [Installation Guide](docs/guide/installation.md), but seriously, let an agent do it. Humans fat-finger configs.
|
||||
If you only want the **Light edition** (Codex CLI), the installer asks whether to configure Codex for autonomous full-permissions mode. You can run it yourself in one line:
|
||||
|
||||
```bash
|
||||
bunx omo install --platform=codex
|
||||
# equivalent:
|
||||
bunx lazycodex install
|
||||
# non-interactive recommended mode:
|
||||
bunx lazycodex install --no-tui --codex-autonomous
|
||||
```
|
||||
|
||||
> **Do not** use `npm install -g`, `bun add -g`, or `bun install -g`. Global installation is not officially supported — oh-my-openagent is a plugin that must resolve from where OpenCode/Codex loads plugins. Always invoke via `bunx`.
|
||||
|
||||
### For LLM Agents
|
||||
|
||||
Fetch the installation guide and follow it:
|
||||
Fetch the full guide and follow it step by step:
|
||||
|
||||
```bash
|
||||
curl -s https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/refs/heads/dev/docs/guide/installation.md
|
||||
curl -fsSL https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/refs/heads/dev/docs/guide/installation.md
|
||||
```
|
||||
|
||||
**Note**: The published npm package and CLI binary are still named `oh-my-opencode` (dual-published as `oh-my-openagent` during the transition). Inside `opencode.json`, the compatibility layer now prefers the plugin entry `oh-my-openagent`, while legacy `oh-my-opencode` entries still load with a warning. Plugin config files still commonly use `oh-my-opencode.json` or `oh-my-opencode.jsonc`; both legacy and renamed basenames are recognized during the transition.
|
||||
The guide covers: platform selection, the subscription interview, provider authentication (Anthropic / Gemini / Copilot / Z.ai / OpenCode Zen), the agent-to-model matching matrix, modes (`ultrawork`, `search`, `analyze`, `team`, `hyperplan`), slash commands, the Light edition's 5 Codex components, Team Mode, and uninstall. Don't summarize it; read it end to end.
|
||||
|
||||
Anonymous telemetry is enabled by default to track active installations (DAU/WAU/MAU). A single event is sent at most once per UTC day per machine using a hashed installation identifier, never the raw hostname, and PostHog person profiles are not created. Disable with `OMO_SEND_ANONYMOUS_TELEMETRY=0` or `OMO_DISABLE_POSTHOG=1`. See [Privacy Policy](docs/legal/privacy-policy.md) and [Terms of Service](docs/legal/terms-of-service.md).
|
||||
### Note on package and command names
|
||||
|
||||
The published npm package and CLI binary are still named `oh-my-opencode` (dual-published as `oh-my-openagent` during the rename transition). Inside `opencode.json`, the compatibility layer prefers the plugin entry `oh-my-openagent`, while legacy `oh-my-opencode` entries still load with a warning. Plugin config files still commonly use `oh-my-opencode.json[c]`; both legacy and renamed basenames are recognized.
|
||||
|
||||
All four `bunx` aliases - `oh-my-opencode`, `oh-my-openagent`, `omo`, `lazycodex` - invoke the same compiled CLI. `omo` is the recommended short form for documentation and prompts. `lazycodex` is a single-purpose npm/bin alias: `bunx lazycodex install` is exactly equivalent to `bunx omo install --platform=codex`. It is not the Codex marketplace name. Codex sees marketplace `sisyphuslabs` and plugin `omo`, enabled as `omo@sisyphuslabs`.
|
||||
|
||||
### Telemetry
|
||||
|
||||
Anonymous telemetry is enabled by default to track active installations (DAU/WAU/MAU). For both products, a single event is sent **at most once per UTC day per machine** using a SHA256-hashed installation identifier (never the raw hostname), and PostHog person profiles are not created. The main plugin emits `oh_my_openagent_daily_active`; the Codex CLI Light edition emits `omo_codex_daily_active` from two sources (`install_completed` and `session_start`).
|
||||
|
||||
Opt out per product:
|
||||
|
||||
- Main plugin: `OMO_DISABLE_POSTHOG=1` or `OMO_SEND_ANONYMOUS_TELEMETRY=0`
|
||||
- Codex CLI Light edition: `OMO_CODEX_DISABLE_POSTHOG=1` or `OMO_CODEX_SEND_ANONYMOUS_TELEMETRY=0` (the global flags also disable Codex)
|
||||
|
||||
See [Privacy Policy](docs/legal/privacy-policy.md) and [Terms of Service](docs/legal/terms-of-service.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -157,24 +201,30 @@ Even with only the following subscriptions, `ultrawork` works well (this project
|
||||
- [GLM Coding Plan ($10)](https://z.ai/subscribe)
|
||||
- If you're eligible for pay-per-token, using Kimi and Gemini models won't cost much.
|
||||
|
||||
| | Feature | What it does |
|
||||
| :---: | :------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🤖 | **Discipline Agents** | Sisyphus orchestrates Hephaestus, Oracle, Librarian, Explore. A full AI dev team in parallel. |
|
||||
| 👥 | **Team Mode** (v4.0, opt-in) | Lead agent + up to 8 parallel members, real-time tmux visualization, dedicated `team_*` tools. Powers `hyperplan` (5 hostile critics) and `security-research` (3 hunters + 2 PoC engineers). [Docs →](docs/guide/team-mode.md) |
|
||||
| ⚡ | **`ultrawork` / `ulw`** | One word. Every agent activates. Doesn't stop until done. |
|
||||
| 🚪 | **[IntentGate](https://factory.ai/news/terminal-bench)** | Analyzes true user intent before classifying or acting. No more literal misinterpretations. |
|
||||
| 🔗 | **Hash-Anchored Edit Tool** | `LINE#ID` content hash validates every change. Zero stale-line errors. Inspired by [oh-my-pi](https://github.com/can1357/oh-my-pi). [The Harness Problem →](https://blog.can.ac/2026/02/12/the-harness-problem/) |
|
||||
| 🛠️ | **LSP + AST-Grep** | Workspace rename, pre-build diagnostics, AST-aware rewrites. IDE precision for agents. |
|
||||
| 🧠 | **Background Agents** | Fire 5+ specialists in parallel. Context stays lean. Results when ready. |
|
||||
| 📚 | **Built-in MCPs** | Exa (web search), Context7 (official docs), Grep.app (GitHub search). Always on. |
|
||||
| 🔁 | **Ralph Loop / `/ulw-loop`** | Self-referential loop. Doesn't stop until 100% done. |
|
||||
| ✅ | **Todo Enforcer** | Agent goes idle? System yanks it back. Your task gets done, period. |
|
||||
| 💬 | **Comment Checker** | No AI slop in comments. Code reads like a senior wrote it. |
|
||||
| 🖥️ | **Tmux Integration** | Full interactive terminal. REPLs, debuggers, TUIs. All live. |
|
||||
| 🔌 | **Claude Code Compatible** | Your hooks, commands, skills, MCPs, and plugins? All work here. |
|
||||
| 🎯 | **Skill-Embedded MCPs** | Skills carry their own MCP servers. No context bloat. |
|
||||
| 📋 | **Prometheus Planner** | Interview-mode strategic planning before any execution. |
|
||||
| 🔍 | **`/init-deep`** | Auto-generates hierarchical `AGENTS.md` files throughout your project. Great for both token efficiency and your agent's performance. |
|
||||
| | Feature | Edition | What it does |
|
||||
| :---: | :------------------------------------------------------- | :------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🤖 | **Discipline Agents** | Ultimate | Sisyphus orchestrates Hephaestus, Oracle, Librarian, Explore. A full AI dev team in parallel. |
|
||||
| 🧩 | **Codex CLI Light Edition** | Light | Portable OMO components (rules, comment-checker, LSP, ultrawork, ulw-loop, start-work continuation, telemetry) running inside OpenAI Codex CLI. Install via `bunx omo install --platform=codex`. |
|
||||
| 👥 | **Team Mode** (v4.0, opt-in) | Ultimate | Lead agent + up to 8 parallel members, real-time tmux visualization, dedicated `team_*` tools. Powers `hyperplan` (5 hostile critics) and `security-research` (3 hunters + 2 PoC engineers). [Docs →](docs/guide/team-mode.md) |
|
||||
| ⚡ | **`ultrawork` / `ulw`** | Both | One word. Every agent activates. Doesn't stop until done. |
|
||||
| 🚪 | **[IntentGate](https://factory.ai/news/terminal-bench)** | Ultimate | Analyzes true user intent before classifying or acting. No more literal misinterpretations. (Light edition only recognises the `ultrawork`/`ulw` keyword.) |
|
||||
| 🔗 | **Hash-Anchored Edit Tool** | Ultimate | `LINE#ID` content hash validates every change. Zero stale-line errors. Inspired by [oh-my-pi](https://github.com/can1357/oh-my-pi). [The Harness Problem →](https://blog.can.ac/2026/02/12/the-harness-problem/) |
|
||||
| 🛠️ | **LSP integration** | Both | Diagnostics, navigation, symbols, workspace rename. IDE precision for agents. Same LSP MCP server in both editions. |
|
||||
| 🔎 | **AST-Grep** | Ultimate | Pattern-aware code search and rewriting across 25 languages. |
|
||||
| 🧠 | **Background Agents** | Ultimate | Fire 5+ specialists in parallel. Context stays lean. Results when ready. |
|
||||
| 📚 | **Built-in MCPs** (web/docs/code search) | Ultimate | Exa (web search), Context7 (official docs), Grep.app (GitHub search). Always on. |
|
||||
| 🔁 | **Ralph Loop / `/ulw-loop`** | Ultimate | Self-referential loop. Doesn't stop until 100% done. |
|
||||
| ✅ | **Todo Enforcer** | Ultimate | Agent goes idle? System yanks it back. Your task gets done, period. |
|
||||
| 💬 | **Comment Checker** | Both | No AI slop in comments. Code reads like a senior wrote it. |
|
||||
| 📐 | **Rules Injection** (`AGENTS.md` / `.omo/rules/**`) | Both | Project rules and AGENTS.md auto-loaded into the agent's context at every prompt. |
|
||||
| 🎯 | **Ulw Loop** | Both | Durable multi-goal orchestration with evidence audit, backed by `.omo/ulw-loop/`. |
|
||||
| 🖥️ | **Tmux Integration** | Ultimate | Full interactive terminal. REPLs, debuggers, TUIs. All live. |
|
||||
| 🔌 | **Claude Code Compatible** | Ultimate | Your hooks, commands, skills, MCPs, and plugins? All work here. |
|
||||
| 🧬 | **Skill-Embedded MCPs** | Ultimate | Skills carry their own MCP servers. No context bloat. |
|
||||
| 📋 | **Prometheus Planner** | Ultimate | Interview-mode strategic planning before any execution. |
|
||||
| 🔍 | **`/init-deep`** | Ultimate | Auto-generates hierarchical `AGENTS.md` files throughout your project. Great for both token efficiency and your agent's performance. |
|
||||
|
||||
> **Edition legend.** **Ultimate** = OpenCode-only (`bunx omo install`). **Light** = Codex CLI-only (`bunx omo install --platform=codex`). **Both** = shipped in both editions, often with slightly different implementations under the hood.
|
||||
|
||||
### Discipline Agents
|
||||
|
||||
@@ -346,6 +396,14 @@ To remove oh-my-openagent:
|
||||
# Plugin should no longer be loaded
|
||||
```
|
||||
|
||||
4. **Remove omo-codex (Codex CLI Light edition)**
|
||||
|
||||
```bash
|
||||
rm -rf ~/.codex/plugins/cache/sisyphuslabs
|
||||
```
|
||||
|
||||
Then open `~/.codex/config.toml` and remove `[marketplaces.sisyphuslabs]`, `[plugins."omo@sisyphuslabs"]`, and any `[hooks.state."omo@sisyphuslabs:..."]` blocks.
|
||||
|
||||
## Features
|
||||
|
||||
Features you'll think should've always existed. Once you use them, you can't go back.
|
||||
|
||||
+33
-18
@@ -121,6 +121,8 @@ curl -s https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/refs/head
|
||||
|
||||
Анонимная телеметрия включена по умолчанию для подсчёта активных установок (DAU/WAU/MAU). Не более одного события на машину за UTC-сутки, использует хешированный идентификатор установки, никогда не использует исходное имя хоста, и не создаёт PostHog person profile. Можно отключить через `OMO_SEND_ANONYMOUS_TELEMETRY=0` или `OMO_DISABLE_POSTHOG=1`. См. [Политику конфиденциальности](docs/legal/privacy-policy.md) и [Условия обслуживания](docs/legal/terms-of-service.md).
|
||||
|
||||
**Ultimate и Light:** oh-my-openagent поставляется в двух редакциях одного продукта. **Ultimate** (`bunx omo install` или `--platform=opencode`, по умолчанию) — полнофункциональная редакция поверх OpenCode: 11 агентов, 54+ хука, Team Mode, все MCP, все слэш-команды, режимы IntentGate. **Light** (`bunx omo install --platform=codex`) — только 5 компонентов omo, которые портируются в систему плагинов OpenAI Codex CLI: `rules`, `comment-checker`, `lsp`, `ultrawork`, `ulw-loop`. `bunx lazycodex install` — это сокращённый псевдоним для `--platform=codex`. Чтобы установить обе редакции одной командой, используйте `--platform=both`. Телеметрию только для Codex можно отключить через `OMO_CODEX_DISABLE_POSTHOG=1` или `OMO_CODEX_SEND_ANONYMOUS_TELEMETRY=0`.
|
||||
|
||||
------
|
||||
|
||||
## Пропустите этот README
|
||||
@@ -149,24 +151,29 @@ Read this and tell me why it's not just another boilerplate: https://raw.githubu
|
||||
- [Тариф GLM Coding ($10)](https://z.ai/subscribe)
|
||||
- Если у вас есть доступ к оплате за токены, использование моделей Kimi и Gemini обойдётся недорого.
|
||||
|
||||
| | Функция | Что делает |
|
||||
| --- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🤖 | **Дисциплинированные агенты** | Sisyphus оркестрирует Hephaestus, Oracle, Librarian, Explore. Полноценная AI-команда разработки в параллельном режиме. |
|
||||
| 👥 | **Team Mode** (v4.0, opt-in) | Лид-агент + до 8 параллельных участников, визуализация в tmux в реальном времени, выделенные инструменты `team_*`. Питает `hyperplan` (5 враждебных критиков) и `security-research` (3 охотника + 2 PoC-инженера). [Документация →](docs/guide/team-mode.md) |
|
||||
| ⚡ | **`ultrawork` / `ulw`** | Одно слово. Все агенты активируются. Не останавливается, пока задача не выполнена. |
|
||||
| 🚪 | **[IntentGate](https://factory.ai/news/terminal-bench)** | Анализирует истинное намерение пользователя перед классификацией и действием. Никакого буквального неверного толкования. |
|
||||
| 🔗 | **Инструмент правок на основе хэш-якорей** | Хэш содержимого `LINE#ID` проверяет каждое изменение. Ноль ошибок с устаревшими строками. Вдохновлено [oh-my-pi](https://github.com/can1357/oh-my-pi). [The Harness Problem →](https://blog.can.ac/2026/02/12/the-harness-problem/) |
|
||||
| 🛠️ | **LSP + AST-Grep** | Переименование в рабочем пространстве, диагностика перед сборкой, переписывание с учётом AST. Точность IDE для агентов. |
|
||||
| 🧠 | **Фоновые агенты** | Запускайте 5+ специалистов параллельно. Контекст остаётся компактным. Результаты — когда готовы. |
|
||||
| 📚 | **Встроенные MCP** | Exa (веб-поиск), Context7 (официальная документация), Grep.app (поиск по GitHub). Всегда включены. |
|
||||
| 🔁 | **Ralph Loop / `/ulw-loop`** | Самореферентный цикл. Не останавливается, пока задача не выполнена на 100%. |
|
||||
| ✅ | **Todo Enforcer** | Агент завис? Система немедленно возвращает его в работу. Ваша задача будет выполнена, точка. |
|
||||
| 💬 | **Comment Checker** | Никакого AI-мусора в комментариях. Код читается так, словно его писал опытный разработчик. |
|
||||
| 🖥️ | **Интеграция с Tmux** | Полноценный интерактивный терминал. REPL, дебаггеры, TUI. Всё живое. |
|
||||
| 🔌 | **Совместимость с Claude Code** | Ваши хуки, команды, навыки, MCP и плагины? Всё работает без изменений. |
|
||||
| 🎯 | **MCP, встроенные в навыки** | Навыки несут собственные MCP-серверы. Никакого раздувания контекста. |
|
||||
| 📋 | **Prometheus Planner** | Стратегическое планирование в режиме интервью перед любым выполнением. |
|
||||
| 🔍 | **`/init-deep`** | Автоматически генерирует иерархические файлы `AGENTS.md` по всему проекту. Отлично работает на эффективность токенов и производительность агента. |
|
||||
| | Функция | Editions | Что делает |
|
||||
| --- | -------------------------------------------------------- | :------: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🤖 | **Дисциплинированные агенты** | Ultimate | Sisyphus оркестрирует Hephaestus, Oracle, Librarian, Explore. Полноценная AI-команда разработки в параллельном режиме. |
|
||||
| 🧩 | **Codex CLI Light Edition** | Light | 5 компонентов omo, портированных в OpenAI Codex CLI (rules, comment-checker, LSP, ultrawork, ulw-loop). Установка: `bunx omo install --platform=codex`. |
|
||||
| 👥 | **Team Mode** (v4.0, opt-in) | Ultimate | Лид-агент + до 8 параллельных участников, визуализация в tmux в реальном времени, выделенные инструменты `team_*`. Питает `hyperplan` (5 враждебных критиков) и `security-research` (3 охотника + 2 PoC-инженера). [Документация →](docs/guide/team-mode.md) |
|
||||
| ⚡ | **`ultrawork` / `ulw`** | Both | Одно слово. Все агенты (Ultimate) или Codex-компонент `ultrawork` (Light) активируются. Не останавливается, пока задача не выполнена. |
|
||||
| 🚪 | **[IntentGate](https://factory.ai/news/terminal-bench)** | Ultimate | Анализирует истинное намерение пользователя перед классификацией и действием. Триггеры `search` / `analyze` / `team` / `hyperplan`. (Light хукает только `ulw` / `ultrawork`.) |
|
||||
| 🔗 | **Инструмент правок на основе хэш-якорей** | Ultimate | Хэш содержимого `LINE#ID` проверяет каждое изменение. Ноль ошибок с устаревшими строками. Вдохновлено [oh-my-pi](https://github.com/can1357/oh-my-pi). [The Harness Problem →](https://blog.can.ac/2026/02/12/the-harness-problem/) (Codex использует собственный `apply_patch`.) |
|
||||
| 🛠️ | **LSP + AST-Grep** | Ultimate | Переименование в рабочем пространстве, диагностика перед сборкой, переписывание с учётом AST. Точность IDE для агентов. (LSP также работает в Light через компонент `lsp`; AST-Grep только Ultimate.) |
|
||||
| 🧠 | **Фоновые агенты** | Ultimate | Запускайте 5+ специалистов параллельно. Контекст остаётся компактным. Результаты — когда готовы. |
|
||||
| 📚 | **Встроенные MCP** | Ultimate | Exa (веб-поиск), Context7 (официальная документация), Grep.app (поиск по GitHub). Всегда включены. (В Light только LSP MCP.) |
|
||||
| 🔁 | **Ralph Loop / `/ulw-loop`** | Ultimate | Самореферентный цикл. Не останавливается, пока задача не выполнена на 100%. |
|
||||
| ✅ | **Todo Enforcer** (Boulder) | Ultimate | Агент завис? Система немедленно возвращает его в работу. Ваша задача будет выполнена, точка. |
|
||||
| 💬 | **Comment Checker** | Both | Никакого AI-мусора в комментариях. Тот же бинарник `@code-yeongyu/comment-checker` работает в обеих редакциях. |
|
||||
| 📜 | **Rules Injection** | Both | Иерархическое внедрение контекста из `AGENTS.md` / `CLAUDE.md` / `.omo/rules/**`. В Ultimate это хук, в Light — компонент `rules`. |
|
||||
| 🧬 | **Ulw Loop** | Light | Долговечная оркестрация нескольких целей с аудитом доказательств в `.omo/ulw-loop/`. Сейчас только в Codex; порт в сторону OpenCode в дорожной карте. |
|
||||
| 🖥️ | **Интеграция с Tmux** | Ultimate | Полноценный интерактивный терминал. REPL, дебаггеры, TUI. Всё живое. |
|
||||
| 🔌 | **Совместимость с Claude Code** | Ultimate | Ваши хуки, команды, навыки, MCP и плагины? Всё работает без изменений. (У Codex своя нативная плагин-система.) |
|
||||
| 🎯 | **MCP, встроенные в навыки** | Ultimate | Навыки несут собственные MCP-серверы. Никакого раздувания контекста. |
|
||||
| 📋 | **Prometheus Planner** | Ultimate | Стратегическое планирование в режиме интервью перед любым выполнением. |
|
||||
| 🔍 | **`/init-deep`** | Ultimate | Автоматически генерирует иерархические файлы `AGENTS.md` по всему проекту. Отлично работает на эффективность токенов и производительность агента. |
|
||||
|
||||
> **Editions, легенда.** **Ultimate** = только OpenCode (`bunx omo install`). **Light** = только Codex CLI (`bunx omo install --platform=codex`). **Both** = поставляется в обеих редакциях, часто с немного отличающейся реализацией.
|
||||
|
||||
### Дисциплинированные агенты
|
||||
|
||||
@@ -338,6 +345,14 @@ project/
|
||||
# Плагин больше не должен загружаться
|
||||
```
|
||||
|
||||
4. **Удалите omo-codex (Codex CLI Light edition)**
|
||||
|
||||
```bash
|
||||
rm -rf ~/.codex/plugins/cache/sisyphuslabs
|
||||
```
|
||||
|
||||
Затем откройте `~/.codex/config.toml` и удалите блоки `[marketplaces.sisyphuslabs]`, `[plugins."omo@sisyphuslabs"]` и `[hooks.state."omo@sisyphuslabs:..."]`.
|
||||
|
||||
## Функции
|
||||
|
||||
Функции, которые, как вы будете думать, должны были существовать всегда. Попробовав раз, вы не сможете вернуться назад.
|
||||
|
||||
+33
-18
@@ -123,6 +123,8 @@ curl -s https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/refs/head
|
||||
|
||||
匿名遥测默认开启,用于统计活跃安装数(DAU/WAU/MAU)。每台机器每个 UTC 日最多发送一次事件,使用哈希化的安装标识符,绝不会使用原始主机名,且不会创建 PostHog person profile。可通过 `OMO_SEND_ANONYMOUS_TELEMETRY=0` 或 `OMO_DISABLE_POSTHOG=1` 禁用。详见 [隐私政策](docs/legal/privacy-policy.md) 和 [服务条款](docs/legal/terms-of-service.md)。
|
||||
|
||||
**Ultimate 与 Light:** oh-my-openagent 以同一产品的两个版本发布。**Ultimate 版本**(`bunx omo install` 或 `--platform=opencode`,默认值)在 OpenCode 上提供完整功能 —— 11 个智能体、54+ 个生命周期钩子、Team Mode、所有 MCP、所有斜杠命令、IntentGate 模式。**Light 版本**(`bunx omo install --platform=codex`)仅提供能够干净地移植到 OpenAI Codex CLI 插件系统的 5 个组件(`rules`、`comment-checker`、`lsp`、`ultrawork`、`ulw-loop`)。`bunx lazycodex install` 是 `--platform=codex` 的快捷别名。要同时安装两个版本,使用 `--platform=both`。Codex 专用遥测可通过 `OMO_CODEX_DISABLE_POSTHOG=1` 或 `OMO_CODEX_SEND_ANONYMOUS_TELEMETRY=0` 禁用。
|
||||
|
||||
---
|
||||
|
||||
## 跳过这个 README 吧
|
||||
@@ -156,24 +158,29 @@ Read this and tell me why it's not just another boilerplate: https://raw.githubu
|
||||
- [GLM Coding 套餐 ($10)](https://z.ai/subscribe)
|
||||
- 如果你能使用按 token 计费的方式,用 Kimi 和 Gemini 模型花不了多少钱。
|
||||
|
||||
| | 特性 | 功能说明 |
|
||||
| :---: | :-------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 🤖 | **自律军团 (Discipline Agents)** | Sisyphus 负责调度 Hephaestus、Oracle、Librarian 和 Explore。一支完整的 AI 开发团队并行工作。 |
|
||||
| 👥 | **Team Mode** (v4.0, 选择性启用) | 领导 Agent + 最多 8 个并行成员,实时 tmux 可视化,专用 `team_*` 工具家族。驱动 `hyperplan`(5 个敌对评论者) 和 `security-research`(3 个猎手 + 2 个 PoC 工程师)。[文档 →](docs/guide/team-mode.md) |
|
||||
| ⚡ | **`ultrawork` / `ulw`** | 一键触发,所有智能体出动。任务完成前绝不罢休。 |
|
||||
| 🚪 | **[IntentGate 意图门](https://factory.ai/news/terminal-bench)** | 真正行动前,先分析用户的真实意图。彻底告别被字面意思误导的 AI 废话。 |
|
||||
| 🔗 | **基于哈希的编辑工具** | 每次修改都通过 `LINE#ID` 内容哈希验证、0% 错误修改。灵感来自 [oh-my-pi](https://github.com/can1357/oh-my-pi)。[The Harness Problem →](https://blog.can.ac/2026/02/12/the-harness-problem/) |
|
||||
| 🛠️ | **LSP + AST-Grep** | 工作区级别的重命名、构建前诊断、基于 AST 的重写。为 Agent 提供 IDE 级别的精度。 |
|
||||
| 🧠 | **后台智能体** | 同时发射 5+ 个专家并行工作。保持上下文干净,随时获取成果。 |
|
||||
| 📚 | **内置 MCP** | Exa(网络搜索)、Context7(官方文档)、Grep.app(GitHub 源码搜索)。默认开启。 |
|
||||
| 🔁 | **Ralph Loop / `/ulw-loop`** | 自我引用闭环。达不到 100% 完成度绝不停止。 |
|
||||
| ✅ | **Todo 强制执行** | Agent 想要摸鱼?系统直接揪着领子拽回来。你的任务,必须完成。 |
|
||||
| 💬 | **注释审查员** | 剔除带有浓烈 AI 味的冗余注释。写出的代码就像老练的高级工程师写的。 |
|
||||
| 🖥️ | **Tmux 集成** | 完整的交互式终端支持。跑 REPL、用调试器、用 TUI 工具,全都在实时会话中完成。 |
|
||||
| 🔌 | **Claude Code 兼容** | 你现有的 Hooks、命令、技能、MCP 和插件?全都能无缝迁移过来。 |
|
||||
| 🎯 | **技能内嵌 MCP** | 技能自带其所需的 MCP 服务器。按需开启,不会撑爆你的上下文窗口。 |
|
||||
| 📋 | **Prometheus 规划师** | 动手写代码前,先通过访谈模式做好战略规划。 |
|
||||
| 🔍 | **`/init-deep`** | 在整个项目目录层级中自动生成 `AGENTS.md`。不仅省 Token,还能大幅提升 Agent 理解力。 |
|
||||
| | 特性 | Editions | 功能说明 |
|
||||
| :---: | :-------------------------------------------------------------- | :------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 🤖 | **自律军团 (Discipline Agents)** | Ultimate | Sisyphus 负责调度 Hephaestus、Oracle、Librarian 和 Explore。一支完整的 AI 开发团队并行工作。 |
|
||||
| 🧩 | **Codex CLI Light Edition** | Light | 在 OpenAI Codex CLI 中运行的 omo 的 5 个可移植组件 (rules, comment-checker, LSP, ultrawork, ulw-loop)。安装: `bunx omo install --platform=codex`。 |
|
||||
| 👥 | **Team Mode** (v4.0, 选择性启用) | Ultimate | 领导 Agent + 最多 8 个并行成员,实时 tmux 可视化,专用 `team_*` 工具家族。驱动 `hyperplan`(5 个敌对评论者) 和 `security-research`(3 个猎手 + 2 个 PoC 工程师)。[文档 →](docs/guide/team-mode.md) |
|
||||
| ⚡ | **`ultrawork` / `ulw`** | Both | 一键触发,所有智能体(Ultimate)或 Codex `ultrawork` 组件(Light)出动。任务完成前绝不罢休。 |
|
||||
| 🚪 | **[IntentGate 意图门](https://factory.ai/news/terminal-bench)** | Ultimate | 真正行动前,先分析用户的真实意图。触发 `search` / `analyze` / `team` / `hyperplan`。(Light 仅 hook `ulw` / `ultrawork`。) |
|
||||
| 🔗 | **基于哈希的编辑工具** | Ultimate | 每次修改都通过 `LINE#ID` 内容哈希验证、0% 错误修改。灵感来自 [oh-my-pi](https://github.com/can1357/oh-my-pi)。[The Harness Problem →](https://blog.can.ac/2026/02/12/the-harness-problem/) (Codex 使用其原生 `apply_patch`。) |
|
||||
| 🛠️ | **LSP + AST-Grep** | Ultimate | 工作区级别的重命名、构建前诊断、基于 AST 的重写。为 Agent 提供 IDE 级别的精度。(LSP 在 Light 中也通过 `lsp` 组件提供; AST-Grep 仅 Ultimate。) |
|
||||
| 🧠 | **后台智能体** | Ultimate | 同时发射 5+ 个专家并行工作。保持上下文干净,随时获取成果。 |
|
||||
| 📚 | **内置 MCP** | Ultimate | Exa(网络搜索)、Context7(官方文档)、Grep.app(GitHub 源码搜索)。默认开启。(Light 仅 LSP MCP。) |
|
||||
| 🔁 | **Ralph Loop / `/ulw-loop`** | Ultimate | 自我引用闭环。达不到 100% 完成度绝不停止。 |
|
||||
| ✅ | **Todo 强制执行** (Boulder) | Ultimate | Agent 想要摸鱼?系统直接揪着领子拽回来。你的任务,必须完成。 |
|
||||
| 💬 | **注释审查员** | Both | 剔除带有浓烈 AI 味的冗余注释。同一个 `@code-yeongyu/comment-checker` 二进制在两个版本中运行。 |
|
||||
| 📜 | **Rules Injection** | Both | `AGENTS.md` / `CLAUDE.md` / `.omo/rules/**` 的分层上下文注入。Ultimate 中为 hook,Light 中为 `rules` 组件。 |
|
||||
| 🧬 | **Ulw Loop** | Light | 基于 `.omo/ulw-loop/` 证据审计的持久化多目标编排。目前仅 Codex 可用; OpenCode 侧的移植在路线图上。 |
|
||||
| 🖥️ | **Tmux 集成** | Ultimate | 完整的交互式终端支持。跑 REPL、用调试器、用 TUI 工具,全都在实时会话中完成。 |
|
||||
| 🔌 | **Claude Code 兼容** | Ultimate | 你现有的 Hooks、命令、技能、MCP 和插件?全都能无缝迁移过来。(Codex 拥有其自己的原生插件系统。) |
|
||||
| 🎯 | **技能内嵌 MCP** | Ultimate | 技能自带其所需的 MCP 服务器。按需开启,不会撑爆你的上下文窗口。 |
|
||||
| 📋 | **Prometheus 规划师** | Ultimate | 动手写代码前,先通过访谈模式做好战略规划。 |
|
||||
| 🔍 | **`/init-deep`** | Ultimate | 在整个项目目录层级中自动生成 `AGENTS.md`。不仅省 Token,还能大幅提升 Agent 理解力。 |
|
||||
|
||||
> **Editions 图例。** **Ultimate** = 仅 OpenCode (`bunx omo install`)。**Light** = 仅 Codex CLI (`bunx omo install --platform=codex`)。**Both** = 两个版本均提供 (内部实现可能略有不同)。
|
||||
|
||||
### 自律军团 (Discipline Agents)
|
||||
|
||||
@@ -345,6 +352,14 @@ Agent 会自动顺藤摸瓜加载对应的 Context,免去了你所有的手动
|
||||
# 这个时候就应该没有任何关于插件的输出信息了
|
||||
```
|
||||
|
||||
4. **移除 omo-codex(Codex CLI Light 版本)**
|
||||
|
||||
```bash
|
||||
rm -rf ~/.codex/plugins/cache/sisyphuslabs
|
||||
```
|
||||
|
||||
然后打开 `~/.codex/config.toml`,删除 `[marketplaces.sisyphuslabs]`、`[plugins."omo@sisyphuslabs"]` 以及所有 `[hooks.state."omo@sisyphuslabs:..."]` 区块。
|
||||
|
||||
## Features
|
||||
|
||||
那种"这个功能本来就该一直存在"的感觉。一用就回不去。
|
||||
|
||||
@@ -53,7 +53,8 @@
|
||||
"frontend-ui-ux",
|
||||
"git-master",
|
||||
"review-work",
|
||||
"ai-slop-remover",
|
||||
"remove-ai-slops",
|
||||
"init-deep",
|
||||
"security-research",
|
||||
"security-review",
|
||||
"team-mode"
|
||||
@@ -71,7 +72,6 @@
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"init-deep",
|
||||
"ralph-loop",
|
||||
"ulw-loop",
|
||||
"cancel-ralph",
|
||||
|
||||
+47
-2
@@ -5,7 +5,14 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { getPlatformPackageCandidates, getBinaryPath } from "./platform.js";
|
||||
import { basename } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
getPlatformPackageCandidates,
|
||||
getBinaryPath,
|
||||
getPackageBareName,
|
||||
resolvePlatformPackageBaseName,
|
||||
} from "./platform.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
@@ -72,6 +79,10 @@ function getSignalExitCode(signal) {
|
||||
}
|
||||
|
||||
function getPackageBaseName() {
|
||||
return resolvePlatformPackageBaseName(getWrapperPackageName());
|
||||
}
|
||||
|
||||
function getWrapperPackageName() {
|
||||
try {
|
||||
const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
||||
return packageJson.name || "oh-my-opencode";
|
||||
@@ -80,10 +91,37 @@ function getPackageBaseName() {
|
||||
}
|
||||
}
|
||||
|
||||
function getWrapperPackageRoot() {
|
||||
return fileURLToPath(new URL("..", import.meta.url));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which bin name the user invoked us with (oh-my-opencode, oh-my-openagent, omo, lazycodex).
|
||||
* Propagated to the compiled CLI binary via OMO_INVOCATION_NAME so it can route accordingly
|
||||
* (e.g. `lazycodex` defaults to the Codex install flow).
|
||||
* @returns {string}
|
||||
*/
|
||||
function getInvocationName(wrapperPackageName) {
|
||||
if (process.env.OMO_INVOCATION_NAME) {
|
||||
return process.env.OMO_INVOCATION_NAME;
|
||||
}
|
||||
if (getPackageBareName(wrapperPackageName) === "lazycodex") {
|
||||
return "lazycodex";
|
||||
}
|
||||
const argv1 = process.argv[1] ?? "";
|
||||
if (!argv1) {
|
||||
return "oh-my-opencode";
|
||||
}
|
||||
return basename(argv1, ".js").replace(/\.exe$/, "");
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { platform, arch } = process;
|
||||
const libcFamily = getLibcFamily();
|
||||
const packageBaseName = getPackageBaseName();
|
||||
const wrapperPackageName = getWrapperPackageName();
|
||||
const invocationName = getInvocationName(wrapperPackageName);
|
||||
|
||||
const packageBaseName = resolvePlatformPackageBaseName(wrapperPackageName);
|
||||
const avx2Supported = supportsAvx2();
|
||||
|
||||
let packageCandidates;
|
||||
@@ -119,11 +157,18 @@ function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const childEnv = {
|
||||
...process.env,
|
||||
OMO_INVOCATION_NAME: invocationName,
|
||||
OMO_WRAPPER_PACKAGE_ROOT: getWrapperPackageRoot(),
|
||||
};
|
||||
|
||||
for (let index = 0; index < resolvedBinaries.length; index += 1) {
|
||||
const currentBinary = resolvedBinaries[index];
|
||||
const hasFallback = index < resolvedBinaries.length - 1;
|
||||
const result = spawnSync(currentBinary.binPath, process.argv.slice(2), {
|
||||
stdio: "inherit",
|
||||
env: childEnv,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chmod, cp, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { getPlatformPackageCandidates } from "./platform.js";
|
||||
|
||||
const testRoots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(testRoots.splice(0).map((path) => rm(path, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("lazycodex bin wrapper", () => {
|
||||
test("runs the platform binary so npx lazycodex does not require Bun", async () => {
|
||||
// #given
|
||||
const fixture = await createLazyCodexFixture();
|
||||
const nodePath = Bun.which("node") ?? "node";
|
||||
|
||||
// #when
|
||||
const result = spawnSync(nodePath, [fixture.lazycodexBin, "install", "--no-tui"], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
CAPTURE_DIR: fixture.captureDir,
|
||||
PATH: fixture.fakeBinDir,
|
||||
},
|
||||
});
|
||||
|
||||
// #then
|
||||
expect(result.status).toBe(23);
|
||||
expect((await readFile(join(fixture.captureDir, "env"), "utf8")).trim()).toBe("lazycodex");
|
||||
expect(await canonicalizePackageRootCapture(fixture)).toBe(await realpath(fixture.root));
|
||||
expect((await readFile(join(fixture.captureDir, "args"), "utf8")).trim().split("\n")).toEqual([
|
||||
"install",
|
||||
"--no-tui",
|
||||
]);
|
||||
});
|
||||
|
||||
test("runs the platform binary when published under an npm scope", async () => {
|
||||
// #given
|
||||
const fixture = await createLazyCodexFixture({ packageName: "@code-yeongyu/lazycodex" });
|
||||
const nodePath = Bun.which("node") ?? "node";
|
||||
|
||||
// #when
|
||||
const result = spawnSync(nodePath, [fixture.lazycodexBin, "install", "--no-tui"], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
CAPTURE_DIR: fixture.captureDir,
|
||||
PATH: fixture.fakeBinDir,
|
||||
},
|
||||
});
|
||||
|
||||
// #then
|
||||
expect(result.status).toBe(23);
|
||||
expect((await readFile(join(fixture.captureDir, "env"), "utf8")).trim()).toBe("lazycodex");
|
||||
expect(await canonicalizePackageRootCapture(fixture)).toBe(await realpath(fixture.root));
|
||||
expect((await readFile(join(fixture.captureDir, "args"), "utf8")).trim().split("\n")).toEqual([
|
||||
"install",
|
||||
"--no-tui",
|
||||
]);
|
||||
});
|
||||
|
||||
test("routes npm shim execution from the lazycodex package to the Codex installer", async () => {
|
||||
// #given
|
||||
const fixture = await createLazyCodexFixture({ wrapperFileName: "oh-my-opencode.js" });
|
||||
const nodePath = Bun.which("node") ?? "node";
|
||||
|
||||
// #when
|
||||
const result = spawnSync(nodePath, [fixture.wrapperBin, "install", "--no-tui"], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
CAPTURE_DIR: fixture.captureDir,
|
||||
PATH: fixture.fakeBinDir,
|
||||
},
|
||||
});
|
||||
|
||||
// #then
|
||||
expect(result.status).toBe(23);
|
||||
expect((await readFile(join(fixture.captureDir, "env"), "utf8")).trim()).toBe("lazycodex");
|
||||
expect(await canonicalizePackageRootCapture(fixture)).toBe(await realpath(fixture.root));
|
||||
expect((await readFile(join(fixture.captureDir, "args"), "utf8")).trim().split("\n")).toEqual([
|
||||
"install",
|
||||
"--no-tui",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
async function createLazyCodexFixture(options: { packageName?: string; wrapperFileName?: string } = {}) {
|
||||
const root = await mkdtemp(join(tmpdir(), "lazycodex-bin-wrapper-"));
|
||||
testRoots.push(root);
|
||||
|
||||
const binDir = join(root, "bin");
|
||||
const distCli = join(root, "dist", "cli", "index.js");
|
||||
const fakeBinDir = join(root, "fake-bin");
|
||||
const captureDir = join(root, "capture");
|
||||
await mkdir(binDir, { recursive: true });
|
||||
await mkdir(dirname(distCli), { recursive: true });
|
||||
await mkdir(fakeBinDir, { recursive: true });
|
||||
await mkdir(captureDir, { recursive: true });
|
||||
|
||||
const wrapperFileName = options.wrapperFileName ?? "lazycodex";
|
||||
const wrapperBin = join(binDir, wrapperFileName);
|
||||
await cp(fileURLToPath(new URL("./oh-my-opencode.js", import.meta.url)), wrapperBin);
|
||||
if (wrapperFileName !== "lazycodex") {
|
||||
await symlink(wrapperFileName, join(binDir, "lazycodex"));
|
||||
}
|
||||
await cp(fileURLToPath(new URL("./platform.js", import.meta.url)), join(binDir, "platform.js"));
|
||||
await writeFile(join(root, "package.json"), JSON.stringify({ name: options.packageName ?? "lazycodex", type: "module" }));
|
||||
await writeFile(distCli, "#!/usr/bin/env bun\n");
|
||||
await writePlatformPackages(root);
|
||||
|
||||
const fakeBun = join(fakeBinDir, "bun");
|
||||
await writeFile(
|
||||
fakeBun,
|
||||
[
|
||||
"#!/bin/sh",
|
||||
"printf '%s\\n' \"$OMO_INVOCATION_NAME\" > \"$CAPTURE_DIR/env\"",
|
||||
"printf '%s\\n' \"$@\" > \"$CAPTURE_DIR/args\"",
|
||||
"exit 23",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
await chmod(fakeBun, 0o755);
|
||||
|
||||
return {
|
||||
bundledCli: distCli,
|
||||
captureDir,
|
||||
fakeBinDir,
|
||||
lazycodexBin: join(binDir, "lazycodex"),
|
||||
root,
|
||||
wrapperBin,
|
||||
};
|
||||
}
|
||||
|
||||
async function canonicalizePackageRootCapture(fixture: { readonly captureDir: string }): Promise<string> {
|
||||
return realpath((await readFile(join(fixture.captureDir, "wrapper-root"), "utf8")).trim());
|
||||
}
|
||||
|
||||
async function writePlatformPackages(root: string): Promise<void> {
|
||||
const packages = getPlatformPackageCandidates({
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
libcFamily: process.platform === "linux" ? "glibc" : undefined,
|
||||
packageBaseName: "oh-my-openagent",
|
||||
});
|
||||
for (const packageName of packages) {
|
||||
const binaryPath = join(root, "node_modules", packageName, "bin", process.platform === "win32" ? "oh-my-opencode.exe" : "oh-my-opencode");
|
||||
await mkdir(dirname(binaryPath), { recursive: true });
|
||||
await writeFile(
|
||||
binaryPath,
|
||||
[
|
||||
"#!/bin/sh",
|
||||
"printf '%s\\n' \"$OMO_INVOCATION_NAME\" > \"$CAPTURE_DIR/env\"",
|
||||
"printf '%s\\n' \"$OMO_WRAPPER_PACKAGE_ROOT\" > \"$CAPTURE_DIR/wrapper-root\"",
|
||||
"printf '%s\\n' \"$@\" > \"$CAPTURE_DIR/args\"",
|
||||
"exit 23",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
await chmod(binaryPath, 0o755);
|
||||
}
|
||||
|
||||
if (process.platform === "linux") {
|
||||
const detectLibcPath = join(root, "node_modules", "detect-libc", "index.js");
|
||||
await mkdir(dirname(detectLibcPath), { recursive: true });
|
||||
await writeFile(detectLibcPath, 'exports.familySync = () => "glibc";\n');
|
||||
}
|
||||
}
|
||||
Vendored
+6
@@ -2,6 +2,7 @@ export declare function getPlatformPackage(options: {
|
||||
platform: string;
|
||||
arch: string;
|
||||
libcFamily?: string | null;
|
||||
packageBaseName?: string;
|
||||
}): string;
|
||||
|
||||
export declare function getPlatformPackageCandidates(options: {
|
||||
@@ -9,6 +10,11 @@ export declare function getPlatformPackageCandidates(options: {
|
||||
arch: string;
|
||||
libcFamily?: string | null;
|
||||
preferBaseline?: boolean;
|
||||
packageBaseName?: string;
|
||||
}): string[];
|
||||
|
||||
export declare function getBinaryPath(pkg: string, platform: string): string;
|
||||
|
||||
export declare function getPackageBareName(packageName: string): string;
|
||||
|
||||
export declare function resolvePlatformPackageBaseName(wrapperPackageName: string): string;
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
// bin/platform.js
|
||||
// Shared platform detection module - used by wrapper and postinstall
|
||||
|
||||
const PLATFORM_PACKAGE_BASE_BY_WRAPPER_NAME = {
|
||||
lazycodex: "oh-my-openagent",
|
||||
};
|
||||
|
||||
export function getPackageBareName(packageName) {
|
||||
return packageName.split("/").pop() || packageName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve platform package base from a wrapper package name.
|
||||
* Wrapper aliases can intentionally reuse an existing platform package family.
|
||||
* @param {string} wrapperPackageName
|
||||
* @returns {string}
|
||||
*/
|
||||
export function resolvePlatformPackageBaseName(wrapperPackageName) {
|
||||
const bareName = getPackageBareName(wrapperPackageName);
|
||||
return PLATFORM_PACKAGE_BASE_BY_WRAPPER_NAME[bareName] ?? wrapperPackageName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the platform-specific package name
|
||||
* @param {{ platform: string, arch: string, libcFamily?: string | null, packageBaseName?: string }} options
|
||||
|
||||
+66
-1
@@ -1,6 +1,71 @@
|
||||
// bin/platform.test.ts
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { getBinaryPath, getPlatformPackage, getPlatformPackageCandidates } from "./platform.js";
|
||||
import {
|
||||
getBinaryPath,
|
||||
getPackageBareName,
|
||||
getPlatformPackage,
|
||||
getPlatformPackageCandidates,
|
||||
resolvePlatformPackageBaseName,
|
||||
} from "./platform.js";
|
||||
|
||||
describe("getPackageBareName", () => {
|
||||
test("strips npm scope from package name", () => {
|
||||
// #given
|
||||
const packageName = "@code-yeongyu/lazycodex";
|
||||
|
||||
// #when
|
||||
const bareName = getPackageBareName(packageName);
|
||||
|
||||
// #then
|
||||
expect(bareName).toBe("lazycodex");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePlatformPackageBaseName", () => {
|
||||
test("maps lazycodex wrapper to oh-my-openagent platform package family", () => {
|
||||
// #given
|
||||
const wrapperPackageName = "lazycodex";
|
||||
|
||||
// #when
|
||||
const resolvedPlatformBase = resolvePlatformPackageBaseName(wrapperPackageName);
|
||||
|
||||
// #then
|
||||
expect(resolvedPlatformBase).toBe("oh-my-openagent");
|
||||
});
|
||||
|
||||
test("maps scoped lazycodex wrapper to oh-my-openagent platform package family", () => {
|
||||
// #given
|
||||
const wrapperPackageName = "@code-yeongyu/lazycodex";
|
||||
|
||||
// #when
|
||||
const resolvedPlatformBase = resolvePlatformPackageBaseName(wrapperPackageName);
|
||||
|
||||
// #then
|
||||
expect(resolvedPlatformBase).toBe("oh-my-openagent");
|
||||
});
|
||||
|
||||
test("keeps oh-my-opencode wrapper mapped to oh-my-opencode platform package family", () => {
|
||||
// #given
|
||||
const wrapperPackageName = "oh-my-opencode";
|
||||
|
||||
// #when
|
||||
const resolvedPlatformBase = resolvePlatformPackageBaseName(wrapperPackageName);
|
||||
|
||||
// #then
|
||||
expect(resolvedPlatformBase).toBe("oh-my-opencode");
|
||||
});
|
||||
|
||||
test("keeps oh-my-openagent wrapper mapped to oh-my-openagent platform package family", () => {
|
||||
// #given
|
||||
const wrapperPackageName = "oh-my-openagent";
|
||||
|
||||
// #when
|
||||
const resolvedPlatformBase = resolvePlatformPackageBaseName(wrapperPackageName);
|
||||
|
||||
// #then
|
||||
expect(resolvedPlatformBase).toBe("oh-my-openagent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPlatformPackage", () => {
|
||||
// #region Darwin platforms
|
||||
|
||||
@@ -31,8 +31,10 @@
|
||||
"@oh-my-opencode/comment-checker-core": "workspace:*",
|
||||
"@oh-my-opencode/hashline-core": "workspace:*",
|
||||
"@oh-my-opencode/model-core": "workspace:*",
|
||||
"@oh-my-opencode/omo-codex": "workspace:*",
|
||||
"@oh-my-opencode/prompts-core": "workspace:*",
|
||||
"@oh-my-opencode/rules-engine": "workspace:*",
|
||||
"@oh-my-opencode/shared-skills": "workspace:*",
|
||||
"@oh-my-opencode/utils": "workspace:*",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/picomatch": "^4.0.3",
|
||||
@@ -74,7 +76,7 @@
|
||||
"name": "@oh-my-opencode/ast-grep-mcp",
|
||||
"version": "0.0.0",
|
||||
"bin": {
|
||||
"ast-grep-mcp": "dist/cli.js",
|
||||
"omo-ast-grep": "dist/cli.js",
|
||||
},
|
||||
"dependencies": {
|
||||
"@ast-grep/cli": "^0.41.1",
|
||||
@@ -109,6 +111,17 @@
|
||||
"@oh-my-opencode/utils": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/omo-codex": {
|
||||
"name": "@oh-my-opencode/omo-codex",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@oh-my-opencode/utils": "workspace:*",
|
||||
"posthog-node": "^5.34.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "1.3.14",
|
||||
},
|
||||
},
|
||||
"packages/prompts-core": {
|
||||
"name": "@oh-my-opencode/prompts-core",
|
||||
"version": "0.1.0",
|
||||
@@ -124,6 +137,10 @@
|
||||
"picomatch": "^4.0.4",
|
||||
},
|
||||
},
|
||||
"packages/shared-skills": {
|
||||
"name": "@oh-my-opencode/shared-skills",
|
||||
"version": "0.1.0",
|
||||
},
|
||||
"packages/utils": {
|
||||
"name": "@oh-my-opencode/utils",
|
||||
"version": "0.1.0",
|
||||
@@ -218,10 +235,14 @@
|
||||
|
||||
"@oh-my-opencode/model-core": ["@oh-my-opencode/model-core@workspace:packages/model-core"],
|
||||
|
||||
"@oh-my-opencode/omo-codex": ["@oh-my-opencode/omo-codex@workspace:packages/omo-codex"],
|
||||
|
||||
"@oh-my-opencode/prompts-core": ["@oh-my-opencode/prompts-core@workspace:packages/prompts-core"],
|
||||
|
||||
"@oh-my-opencode/rules-engine": ["@oh-my-opencode/rules-engine@workspace:packages/rules-engine"],
|
||||
|
||||
"@oh-my-opencode/shared-skills": ["@oh-my-opencode/shared-skills@workspace:packages/shared-skills"],
|
||||
|
||||
"@oh-my-opencode/utils": ["@oh-my-opencode/utils@workspace:packages/utils"],
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.15.10", "", { "dependencies": { "@opencode-ai/sdk": "1.15.10", "effect": "4.0.0-beta.66", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.2.15", "@opentui/keymap": ">=0.2.15", "@opentui/solid": ">=0.2.15" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-V2p7CvpBtKWB+FID7Dl1y0Ci02zUT40A9b2RD9R9BOiuD8ZcKhHWov+irN0xVJA0Eg6OhEBfA0lPKRn1FNKPlw=="],
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[test]
|
||||
preload = ["./test-setup.ts"]
|
||||
pathIgnorePatterns = ["packages/web/**", "packages/lsp-tools-mcp/**"]
|
||||
pathIgnorePatterns = ["packages/web/**", "packages/lsp-tools-mcp/**", "packages/omo-codex/plugin/**"]
|
||||
|
||||
[loader]
|
||||
".md" = "text"
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
| Known issues & workarounds | [docs/reference/known-issues.md](file:///Users/yeongyu/local-workspaces/omo/docs/reference/known-issues.md) |
|
||||
| `prompt_async_gate` deep-dive | [docs/reference/prompt-async-gate-rfc.md](file:///Users/yeongyu/local-workspaces/omo/docs/reference/prompt-async-gate-rfc.md) |
|
||||
| Release process | [docs/reference/release-process.md](file:///Users/yeongyu/local-workspaces/omo/docs/reference/release-process.md) |
|
||||
| Claiming the lazycodex npm name | [docs/reference/lazycodex-npm-reservation.md](file:///Users/yeongyu/local-workspaces/omo/docs/reference/lazycodex-npm-reservation.md) |
|
||||
| Rules-injector cross-module comparison | [docs/reference/rules-injection-cross-module-comparison.md](file:///Users/yeongyu/local-workspaces/omo/docs/reference/rules-injection-cross-module-comparison.md) |
|
||||
| Sample configs | [docs/examples/](file:///Users/yeongyu/local-workspaces/omo/docs/examples/) (default, coding-focused, planning-focused) |
|
||||
| Privacy & ToS | [docs/legal/](file:///Users/yeongyu/local-workspaces/omo/docs/legal/) |
|
||||
|
||||
+577
-262
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ We collect limited non-personal information needed to operate and improve the Se
|
||||
When anonymous telemetry is enabled, the Application may collect a single anonymous usage event:
|
||||
|
||||
- `omo_daily_active`, sent at most once per UTC day per machine when the plugin loads or when the `run` CLI is invoked, used to estimate daily, weekly, and monthly active installations
|
||||
- `omo_codex_daily_active`, sent at most once per UTC day per machine when the `omo-codex` adapter is installed (`reason: "install_completed"`) or when its Codex plugin runtime fires on a Codex `SessionStart` hook (`reason: "session_start"`), with the same opt-out posture as `omo_daily_active`
|
||||
- Anonymous machine metadata bundled with that event, such as package version, plugin name, runtime, OS family, locale, and timezone
|
||||
- A pseudonymous installation identifier derived from a one-way hash of the local hostname
|
||||
|
||||
@@ -36,8 +37,14 @@ Telemetry can be disabled at any time by setting one of these environment variab
|
||||
export OMO_SEND_ANONYMOUS_TELEMETRY=0
|
||||
# or
|
||||
export OMO_DISABLE_POSTHOG=1
|
||||
# codex-only opt-out flags
|
||||
export OMO_CODEX_DISABLE_POSTHOG=1
|
||||
export OMO_CODEX_SEND_ANONYMOUS_TELEMETRY=0
|
||||
```
|
||||
|
||||
`OMO_CODEX_DISABLE_POSTHOG` and `OMO_CODEX_SEND_ANONYMOUS_TELEMETRY` suppress only `omo-codex` telemetry.
|
||||
Global flags (`OMO_DISABLE_POSTHOG`, `OMO_SEND_ANONYMOUS_TELEMETRY`) suppress telemetry for both oh-my-openagent/oh-my-opencode and omo-codex.
|
||||
|
||||
When telemetry is disabled, PostHog events are not sent.
|
||||
|
||||
## 3. Third-Party Services
|
||||
|
||||
+36
-10
@@ -7,6 +7,15 @@ Complete reference for the published CLI package. During the rename transition,
|
||||
|
||||
Plugin registration inside `opencode.json` prefers `oh-my-openagent`.
|
||||
|
||||
## Bin Commands
|
||||
|
||||
All published packages expose the same compiled CLI with these bin entries:
|
||||
|
||||
- `oh-my-opencode` (legacy name, still primary)
|
||||
- `oh-my-openagent` (renamed primary)
|
||||
- `omo` (short alias, recommended in docs and prompts)
|
||||
- `lazycodex` (Light edition shortcut; `lazycodex install` is equivalent to `omo install --platform=codex` unless `--platform` is explicitly overridden)
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```bash
|
||||
@@ -47,18 +56,35 @@ bunx oh-my-openagent install
|
||||
| Option | Description |
|
||||
| --- | --- |
|
||||
| `--no-tui` | Run in non-interactive mode (requires all needed options) |
|
||||
| `--claude <value>` | Claude subscription: `no`, `yes`, `max20` |
|
||||
| `--openai <value>` | OpenAI/ChatGPT subscription: `no`, `yes` |
|
||||
| `--gemini <value>` | Gemini integration: `no`, `yes` |
|
||||
| `--copilot <value>` | GitHub Copilot subscription: `no`, `yes` |
|
||||
| `--opencode-zen <value>` | OpenCode Zen access: `no`, `yes` |
|
||||
| `--zai-coding-plan <value>` | Z.ai Coding Plan subscription: `no`, `yes` |
|
||||
| `--kimi-for-coding <value>` | Kimi For Coding subscription: `no`, `yes` |
|
||||
| `--opencode-go <value>` | OpenCode Go subscription: `no`, `yes` |
|
||||
| `--vercel-ai-gateway <value>` | Vercel AI Gateway: `no`, `yes` |
|
||||
| `--platform <value>` | Install target edition: `opencode` (Ultimate, default), `codex` (Light), or `both` |
|
||||
| `--claude <value>` | Claude subscription: `no`, `yes`, `max20` (Ultimate only) |
|
||||
| `--openai <value>` | OpenAI/ChatGPT subscription: `no`, `yes` (Ultimate only) |
|
||||
| `--gemini <value>` | Gemini integration: `no`, `yes` (Ultimate only) |
|
||||
| `--copilot <value>` | GitHub Copilot subscription: `no`, `yes` (Ultimate only) |
|
||||
| `--opencode-zen <value>` | OpenCode Zen access: `no`, `yes` (Ultimate only) |
|
||||
| `--zai-coding-plan <value>` | Z.ai Coding Plan subscription: `no`, `yes` (Ultimate only) |
|
||||
| `--kimi-for-coding <value>` | Kimi For Coding subscription: `no`, `yes` (Ultimate only) |
|
||||
| `--opencode-go <value>` | OpenCode Go subscription: `no`, `yes` (Ultimate only) |
|
||||
| `--vercel-ai-gateway <value>` | Vercel AI Gateway: `no`, `yes` (Ultimate only) |
|
||||
| `--codex-autonomous` | Configure Codex with `approval_policy = "never"`, `sandbox_mode = "danger-full-access"`, and `network_access = "enabled"` when installing Light or Both |
|
||||
| `--no-codex-autonomous` | Leave existing Codex permission settings unchanged when installing Light or Both |
|
||||
| `--skip-auth` | Skip authentication setup hints |
|
||||
|
||||
Anonymous telemetry uses PostHog with a hashed installation identifier. Disable with `OMO_SEND_ANONYMOUS_TELEMETRY=0` or `OMO_DISABLE_POSTHOG=1`.
|
||||
When using the `lazycodex` bin alias, `install` defaults to `--platform=codex`. `lazycodex` is only the npm/bin alias and marketplace repository name. The Codex config uses marketplace `sisyphuslabs` and plugin `omo`, enabled as `omo@sisyphuslabs`, with the marketplace source set to the local built cache under `~/.codex/plugins/cache/sisyphuslabs`.
|
||||
|
||||
Subscription flags (`--claude`, `--openai`, etc.) only apply when `--platform` is `opencode` or `both`. They are rejected under `--platform=codex` because the Light edition does not write OpenCode model config. `--codex-autonomous` and `--no-codex-autonomous` only affect installs where the selected platform includes Codex.
|
||||
|
||||
### Telemetry and opt-out
|
||||
|
||||
Anonymous telemetry uses PostHog with a hashed installation identifier. Two streams exist:
|
||||
|
||||
- `omo_daily_active`: fired by the main plugin and `oh-my-openagent run`.
|
||||
- `omo_codex_daily_active`: fired by `omo install --platform=codex` or `--platform=both` (`reason: "install_completed"`) and by the Codex plugin's `SessionStart` hook on every Codex session (`reason: "session_start"`). Both sources share the same UTC-day deduplication, so daily/weekly/monthly active counts reflect real Codex usage, not just install events.
|
||||
|
||||
Opt-out env vars:
|
||||
|
||||
- Global opt-out for oh-my-openagent and omo-codex: `OMO_SEND_ANONYMOUS_TELEMETRY=0` or `OMO_DISABLE_POSTHOG=1`
|
||||
- Codex-only opt-out for `omo_codex_daily_active`: `OMO_CODEX_SEND_ANONYMOUS_TELEMETRY=0` or `OMO_CODEX_DISABLE_POSTHOG=1`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1007,6 +1007,8 @@ When enabled, OmO registers the hash-anchored `edit` tool and activates the `has
|
||||
| `OPENCODE_CONFIG_DIR` | Override OpenCode config directory (useful for profile isolation) |
|
||||
| `OMO_SEND_ANONYMOUS_TELEMETRY` | Set to `0`, `false`, or `no` to disable anonymous telemetry |
|
||||
| `OMO_DISABLE_POSTHOG` | Legacy telemetry opt-out flag. Set to `1` or `true` to disable PostHog |
|
||||
| `OMO_CODEX_DISABLE_POSTHOG` | Set to `1` or `true` to disable PostHog telemetry for the `omo-codex` adapter only. Does not affect oh-my-opencode telemetry |
|
||||
| `OMO_CODEX_SEND_ANONYMOUS_TELEMETRY` | Set to `0`, `false`, or `no` to disable anonymous telemetry for `omo-codex` only |
|
||||
| `POSTHOG_API_KEY` | Optional override for the built-in PostHog project API key |
|
||||
| `POSTHOG_HOST` | Override the PostHog ingestion host. Defaults to `https://us.i.posthog.com` |
|
||||
|
||||
|
||||
@@ -300,7 +300,7 @@ Skills provide specialized workflows with embedded MCP servers and detailed inst
|
||||
| **dev-browser** | Stateful browser scripting | Browser automation with persistent page state for iterative workflows and authenticated sessions. |
|
||||
| **frontend-ui-ux** | UI/UX tasks, styling | Designer-turned-developer persona. Crafts stunning UI/UX even without design mockups. Emphasizes bold aesthetic direction, distinctive typography, cohesive color palettes. |
|
||||
| **review-work** | "review work", "review my work", "QA my work" | Post-implementation review orchestrator. Launches 5 parallel background sub-agents for comprehensive review: goal verification, code quality, security, hands-on QA, and context mining. All must pass for review to pass. |
|
||||
| **ai-slop-remover**| "remove AI slop", "de-AI", "humanize" | Removes AI-generated code smells from files while preserving functionality. Identifies and eliminates verbose comments, redundant error handling, over-engineered patterns, and generic AI phrasing. |
|
||||
| **$omo:remove-ai-slops**| "remove AI slop", "de-AI", "humanize" | Removes AI-generated code smells from files while preserving functionality. Identifies and eliminates verbose comments, redundant error handling, over-engineered patterns, and generic AI phrasing. |
|
||||
|
||||
#### git-master Core Principles
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Reserving the lazycodex npm name (first-publish playbook)
|
||||
|
||||
`lazycodex` is the npm/bin alias for the Codex CLI Light edition and the Git repository that hosts the native Codex marketplace bundle. It is not the marketplace identity. Codex installs marketplace `sisyphuslabs` and plugin `omo`, enabled as `omo@sisyphuslabs`.
|
||||
|
||||
The `publish.yml` workflow includes `lazycodex` in trusted-publisher preflight, but that check is soft for first publish.
|
||||
If `lazycodex` is not yet claimed on npm, the workflow warns and continues so existing package releases are not blocked.
|
||||
To claim the name, run a one-time manual `npm publish` for `lazycodex` from a trusted environment (for example local shell with `NPM_AUTH_TOKEN`).
|
||||
After the first manual publish, configure GitHub Actions trusted publishing at:
|
||||
https://www.npmjs.com/package/lazycodex/access
|
||||
Set Provider to GitHub Actions, Organization to `code-yeongyu`, Repository to `oh-my-openagent`, and Workflow filename to `publish.yml`.
|
||||
After this setup, subsequent releases from `publish.yml` can publish `lazycodex` automatically.
|
||||
|
||||
The same release workflow also syncs `packages/omo-codex/marketplace.json` and `packages/omo-codex/plugin/` into `code-yeongyu/lazycodex` as `.agents/plugins/marketplace.json` and `plugins/omo/`. That cross-repo push requires the `LAZYCODEX_SYNC_TOKEN` repository secret.
|
||||
@@ -3,7 +3,7 @@
|
||||
Comparison and porting record for the three rule injection implementations
|
||||
maintained out of `/Users/yeongyu/local-workspaces`:
|
||||
|
||||
- **codex-rules** — Codex hook plugin (`codex-plugins/plugins/codex-rules`, repo `code-yeongyu/codex-rules`, branch `main`).
|
||||
- **codex-rules** — Codex hook plugin now bundled under the OMO Codex marketplace plugin (`packages/omo-codex/plugin/components/rules`, marketplace `sisyphuslabs`, plugin `omo`). The original standalone repo was `code-yeongyu/codex-rules`, branch `main`.
|
||||
- **pi-rules** — pi-mono extension (`pi-extensions/pi-rules`, repo `code-yeongyu/pi-rules`, branch `main`).
|
||||
- **omo rules-injector** — opencode plugin path (`omo/src/hooks/rules-injector`, repo `code-yeongyu/oh-my-openagent`, branch `dev`).
|
||||
|
||||
@@ -18,7 +18,7 @@ maintained out of `/Users/yeongyu/local-workspaces`:
|
||||
Installation state after the porting round:
|
||||
|
||||
- **omo** — `~/.bun/install/global/node_modules/oh-my-opencode` is a symlink to the local workspace, so `bun run build` immediately publishes the rebuilt `dist/`. Verified via `grep -c transcriptHydration dist/index.js` → 6.
|
||||
- **codex-rules** — `node scripts/install-local.mjs ...` was rerun and the cache at `~/.codex/plugins/cache/code-yeongyu-codex-plugins/codex-rules/0.1.0` was refreshed.
|
||||
- **codex-rules** — now installed through the aggregate OMO Codex plugin cache at `~/.codex/plugins/cache/sisyphuslabs/omo/<version>/components/rules`, enabled by `[plugins."omo@sisyphuslabs"]`.
|
||||
- **pi-rules** — pi-mono consumes the package source directly; no separate install step.
|
||||
|
||||
## 1. Performance baseline
|
||||
|
||||
+19
-4
@@ -15,11 +15,15 @@
|
||||
"packages/comment-checker-core",
|
||||
"packages/hashline-core",
|
||||
"packages/boulder-state",
|
||||
"packages/agents-md-core"
|
||||
"packages/agents-md-core",
|
||||
"packages/shared-skills",
|
||||
"packages/omo-codex"
|
||||
],
|
||||
"bin": {
|
||||
"oh-my-opencode": "bin/oh-my-opencode.js",
|
||||
"oh-my-openagent": "bin/oh-my-opencode.js"
|
||||
"oh-my-openagent": "bin/oh-my-opencode.js",
|
||||
"omo": "bin/oh-my-opencode.js",
|
||||
"lazycodex": "bin/oh-my-opencode.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -30,7 +34,14 @@
|
||||
".agents/command",
|
||||
".agents/skills",
|
||||
"packages/lsp-tools-mcp/dist",
|
||||
"packages/ast-grep-mcp/dist"
|
||||
"packages/ast-grep-mcp/dist",
|
||||
"packages/shared-skills/package.json",
|
||||
"packages/shared-skills/index.mjs",
|
||||
"packages/shared-skills/skills",
|
||||
"packages/omo-codex/marketplace.json",
|
||||
"packages/omo-codex/plugin",
|
||||
"packages/omo-codex/plugin/.codex-plugin",
|
||||
"packages/omo-codex/scripts"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
@@ -54,9 +65,11 @@
|
||||
"prepublishOnly": "bun run clean && bun run build:lsp-tools-mcp && bun run build",
|
||||
"test:model-capabilities": "bun test src/shared/model-capability-aliases.test.ts src/shared/model-capability-guardrails.test.ts src/shared/model-capabilities.test.ts src/cli/doctor/checks/model-resolution.test.ts --bail",
|
||||
"typecheck": "tsgo --noEmit && bun run typecheck:packages",
|
||||
"typecheck:packages": "tsgo --noEmit -p packages/rules-engine/tsconfig.json && tsgo --noEmit -p packages/ast-grep-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-mcp/tsconfig.json && tsgo --noEmit -p packages/utils/tsconfig.json && tsgo --noEmit -p packages/model-core/tsconfig.json && tsgo --noEmit -p packages/prompts-core/tsconfig.json && tsgo --noEmit -p packages/comment-checker-core/tsconfig.json && tsgo --noEmit -p packages/hashline-core/tsconfig.json && tsgo --noEmit -p packages/boulder-state/tsconfig.json && tsgo --noEmit -p packages/agents-md-core/tsconfig.json",
|
||||
"typecheck:packages": "tsgo --noEmit -p packages/rules-engine/tsconfig.json && tsgo --noEmit -p packages/ast-grep-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-mcp/tsconfig.json && tsgo --noEmit -p packages/utils/tsconfig.json && tsgo --noEmit -p packages/model-core/tsconfig.json && tsgo --noEmit -p packages/prompts-core/tsconfig.json && tsgo --noEmit -p packages/comment-checker-core/tsconfig.json && tsgo --noEmit -p packages/hashline-core/tsconfig.json && tsgo --noEmit -p packages/boulder-state/tsconfig.json && tsgo --noEmit -p packages/agents-md-core/tsconfig.json && tsgo --noEmit -p packages/omo-codex/tsconfig.json",
|
||||
"typecheck:script": "tsgo --noEmit -p script/tsconfig.json",
|
||||
"test": "bun test",
|
||||
"test:codex": "bun run build:ast-grep-mcp && bun run build:lsp-tools-mcp && npm --prefix packages/omo-codex/plugin ci && bun run --cwd packages/omo-codex/plugin build && bun test src/cli/cli-installer.platform.test.ts src/cli/install-codex/codex-cache.test.ts src/cli/install-codex/codex-config-agent-cleanup.test.ts src/cli/install-codex/codex-config-toml.test.ts src/cli/install-codex/install-codex.test.ts src/cli/install-codex/link-cached-plugin-agents.test.ts packages/omo-codex/src/**/*.test.ts packages/utils/src/jsonc-parser.test.ts packages/utils/src/frontmatter.test.ts packages/hashline-core/src/hash-computation.test.ts packages/hashline-core/src/smoke-untested-modules.test.ts packages/rules-engine/src/index.test.ts packages/rules-engine/src/security-boundary.test.ts packages/agents-md-core/src/injector.test.ts packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts && node --test packages/omo-codex/plugin/test/*.test.mjs packages/omo-codex/scripts/install-cache-copy.test.mjs packages/omo-codex/scripts/install-config.test.mjs packages/omo-codex/scripts/install-local.test.mjs packages/omo-codex/scripts/install-mcp-runtime.test.mjs packages/omo-codex/scripts/install-agent-links.test.mjs packages/omo-codex/scripts/install-bin-links.test.mjs packages/omo-codex/scripts/sync-telemetry-component.test.mjs",
|
||||
"test:windows-codex": "bun run test:codex",
|
||||
"build:ast-grep-mcp": "bun run --cwd packages/ast-grep-mcp build"
|
||||
},
|
||||
"keywords": [
|
||||
@@ -105,8 +118,10 @@
|
||||
"@oh-my-opencode/comment-checker-core": "workspace:*",
|
||||
"@oh-my-opencode/hashline-core": "workspace:*",
|
||||
"@oh-my-opencode/model-core": "workspace:*",
|
||||
"@oh-my-opencode/omo-codex": "workspace:*",
|
||||
"@oh-my-opencode/prompts-core": "workspace:*",
|
||||
"@oh-my-opencode/rules-engine": "workspace:*",
|
||||
"@oh-my-opencode/shared-skills": "workspace:*",
|
||||
"@oh-my-opencode/utils": "workspace:*",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260518.1",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"bin": {
|
||||
"ast-grep-mcp": "dist/cli.js"
|
||||
"omo-ast-grep": "dist/cli.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
#!/usr/bin/env node
|
||||
import { argv, stderr } from "node:process";
|
||||
import { writeMcpLifecycleLog } from "./mcp-lifecycle-log";
|
||||
import { runMcpStdioServer } from "./mcp";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const [command = "mcp"] = argv.slice(2);
|
||||
if (command === "mcp") {
|
||||
await runMcpStdioServer();
|
||||
await runMcpStdioServer(process.stdin, process.stdout, {}, {
|
||||
log: writeMcpLifecycleLog,
|
||||
onIdleTimeout: () => {
|
||||
process.exit(0);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
stderr.write("Usage: ast-grep-mcp [mcp]\n");
|
||||
stderr.write("Usage: omo-ast-grep [mcp]\n");
|
||||
process.exitCode = 2;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { appendFileSync, renameSync, statSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
type LogFieldValue = boolean | number | string | null;
|
||||
|
||||
const LOG_FILE_NAME = "omo-ast-grep-mcp.log";
|
||||
const MAX_LOG_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
export function mcpLifecycleLogPath(): string {
|
||||
return join(tmpdir(), LOG_FILE_NAME);
|
||||
}
|
||||
|
||||
export function writeMcpLifecycleLog(event: string, fields: Record<string, LogFieldValue> = {}): void {
|
||||
const path = mcpLifecycleLogPath();
|
||||
try {
|
||||
rotateLogIfNeeded(path);
|
||||
appendFileSync(path, `${JSON.stringify({ ts: new Date().toISOString(), event, pid: process.pid, ppid: process.ppid, ...fields })}\n`);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function rotateLogIfNeeded(path: string): void {
|
||||
try {
|
||||
if (statSync(path).size < MAX_LOG_BYTES) return;
|
||||
renameSync(path, `${path}.1`);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return;
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { runMcpStdioServer } from "./mcp";
|
||||
|
||||
describe("ast-grep MCP stdio server", () => {
|
||||
it("#given Codex sends a content-length framed initialize #when stdio server handles it #then responds with a framed initialize result", async () => {
|
||||
const input = new PassThrough();
|
||||
const output = new PassThrough();
|
||||
const received = nextOutput(output);
|
||||
const server = runMcpStdioServer(input, output);
|
||||
|
||||
writeContentLengthFrame(input, {
|
||||
jsonrpc: "2.0",
|
||||
id: 5,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "codex", version: "0.0.0" },
|
||||
},
|
||||
});
|
||||
|
||||
const response = parseContentLengthFrame(await received);
|
||||
input.end();
|
||||
await server;
|
||||
|
||||
expect(response).toEqual({
|
||||
jsonrpc: "2.0",
|
||||
id: 5,
|
||||
result: {
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
serverInfo: { name: "ast_grep", version: "0.1.0" },
|
||||
protocolVersion: "2024-11-05",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function writeContentLengthFrame(input: PassThrough, message: unknown): void {
|
||||
const body = JSON.stringify(message);
|
||||
input.write(`Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`);
|
||||
}
|
||||
|
||||
function nextOutput(output: PassThrough): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
output.once("data", (chunk: Buffer | string) => {
|
||||
resolve(String(chunk));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseContentLengthFrame(raw: string): unknown {
|
||||
const separator = raw.indexOf("\r\n\r\n");
|
||||
expect(separator).toBeGreaterThan(0);
|
||||
const headers = raw.slice(0, separator);
|
||||
const match = /^Content-Length: (\d+)$/im.exec(headers);
|
||||
expect(match).not.toBeNull();
|
||||
if (match === null) throw new TypeError(`Missing Content-Length header: ${raw}`);
|
||||
const lengthValue = match[1];
|
||||
if (lengthValue === undefined) throw new TypeError(`Invalid Content-Length header: ${raw}`);
|
||||
const bodyStart = separator + "\r\n\r\n".length;
|
||||
return JSON.parse(raw.slice(bodyStart, bodyStart + Number(lengthValue)));
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Readable, Writable } from "node:stream";
|
||||
|
||||
import type { AstGrepMcpOptions, JsonRpcResponse } from "./mcp";
|
||||
import { readStdioJsonRpcMessages, writeStdioJsonRpcResponse } from "./mcp-stdio-transport";
|
||||
|
||||
export type McpLifecycleLog = (event: string, fields?: Record<string, boolean | number | string | null>) => void;
|
||||
|
||||
export interface McpStdioServerOptions {
|
||||
readonly idleTimeoutMs?: number;
|
||||
readonly onIdleTimeout?: () => void | Promise<void>;
|
||||
readonly log?: McpLifecycleLog;
|
||||
}
|
||||
|
||||
export type McpRequestHandler = (
|
||||
input: unknown,
|
||||
options: AstGrepMcpOptions,
|
||||
) => Promise<JsonRpcResponse | undefined>;
|
||||
|
||||
const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60_000;
|
||||
const noopLog: McpLifecycleLog = () => {};
|
||||
|
||||
export async function runJsonRpcStdioServer(
|
||||
handler: McpRequestHandler,
|
||||
input: Readable,
|
||||
output: Writable,
|
||||
options: AstGrepMcpOptions,
|
||||
stdioOptions: McpStdioServerOptions = {},
|
||||
): Promise<void> {
|
||||
const log = stdioOptions.log ?? noopLog;
|
||||
const idleTimeoutMs = stdioOptions.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
||||
const idleTimer = createIdleTimer(idleTimeoutMs, log, stdioOptions.onIdleTimeout);
|
||||
|
||||
log("stdio_started", { cwd: process.cwd(), idle_timeout_ms: idleTimeoutMs });
|
||||
idleTimer.arm();
|
||||
try {
|
||||
for await (const message of readStdioJsonRpcMessages(input)) {
|
||||
if (idleTimer.closed()) break;
|
||||
idleTimer.arm();
|
||||
if (message.kind === "parse_error") {
|
||||
log("parse_error", { message: message.message });
|
||||
writeStdioJsonRpcResponse(output, errorResponse(null, -32700, "Parse error", message.message), message.responseMode);
|
||||
continue;
|
||||
}
|
||||
const parsed = message.payload;
|
||||
const id = isRecord(parsed) ? jsonRpcId(parsed.id) : null;
|
||||
const method = isRecord(parsed) && typeof parsed.method === "string" ? parsed.method : null;
|
||||
log("request", { id: id === null ? null : String(id), method });
|
||||
const response = await handler(parsed, options);
|
||||
if (response) {
|
||||
writeStdioJsonRpcResponse(output, response, message.responseMode);
|
||||
log("response", { id: String(response.id), method, is_error: response.error !== undefined });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
idleTimer.clear();
|
||||
log("stdio_stopped");
|
||||
}
|
||||
}
|
||||
|
||||
function createIdleTimer(idleTimeoutMs: number, log: McpLifecycleLog, onIdleTimeout?: () => void | Promise<void>) {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
let isClosed = false;
|
||||
|
||||
return {
|
||||
arm: () => {
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
if (idleTimeoutMs <= 0) return;
|
||||
timer = setTimeout(() => {
|
||||
isClosed = true;
|
||||
log("idle_timeout", { idle_timeout_ms: idleTimeoutMs });
|
||||
void onIdleTimeout?.();
|
||||
}, idleTimeoutMs);
|
||||
timer.unref();
|
||||
},
|
||||
clear: () => {
|
||||
if (timer === null) return;
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
},
|
||||
closed: () => isClosed,
|
||||
};
|
||||
}
|
||||
|
||||
function errorResponse(id: string | number | null, code: number, message: string, data?: unknown): JsonRpcResponse {
|
||||
return { jsonrpc: "2.0", id, error: data === undefined ? { code, message } : { code, message, data } };
|
||||
}
|
||||
|
||||
function jsonRpcId(value: unknown): string | number | null {
|
||||
return typeof value === "string" || typeof value === "number" || value === null ? value : null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { Readable, Writable } from "node:stream";
|
||||
|
||||
export type StdioJsonRpcResponseMode = "line" | "framed";
|
||||
|
||||
export type StdioJsonRpcMessage =
|
||||
| {
|
||||
readonly kind: "request";
|
||||
readonly payload: unknown;
|
||||
readonly responseMode: StdioJsonRpcResponseMode;
|
||||
}
|
||||
| {
|
||||
readonly kind: "parse_error";
|
||||
readonly message: string;
|
||||
readonly responseMode: StdioJsonRpcResponseMode;
|
||||
};
|
||||
|
||||
type ReadResult =
|
||||
| { readonly kind: "incomplete" }
|
||||
| {
|
||||
readonly kind: "complete";
|
||||
readonly message?: StdioJsonRpcMessage;
|
||||
readonly remaining: Buffer<ArrayBufferLike>;
|
||||
};
|
||||
|
||||
const HEADER_SEPARATOR = Buffer.from("\r\n\r\n");
|
||||
|
||||
export async function* readStdioJsonRpcMessages(input: Readable): AsyncGenerator<StdioJsonRpcMessage> {
|
||||
let buffer: Buffer<ArrayBufferLike> = Buffer.alloc(0);
|
||||
|
||||
for await (const chunk of input) {
|
||||
buffer = Buffer.concat([buffer, bufferFromChunk(chunk)]);
|
||||
while (true) {
|
||||
const result = readNextMessage(buffer);
|
||||
if (result.kind === "incomplete") break;
|
||||
buffer = result.remaining;
|
||||
if (result.message) yield result.message;
|
||||
}
|
||||
}
|
||||
|
||||
const trailing = buffer.toString("utf8").trim();
|
||||
if (trailing.length > 0) {
|
||||
yield parseJsonPayload(trailing, "line");
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStdioJsonRpcResponse(output: Writable, response: unknown, responseMode: StdioJsonRpcResponseMode): void {
|
||||
const body = JSON.stringify(response);
|
||||
if (responseMode === "framed") {
|
||||
output.write(`Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`);
|
||||
return;
|
||||
}
|
||||
output.write(`${body}\n`);
|
||||
}
|
||||
|
||||
function readNextMessage(buffer: Buffer<ArrayBufferLike>): ReadResult {
|
||||
if (buffer.length === 0) return { kind: "incomplete" };
|
||||
return startsWithContentLength(buffer) ? readFramedMessage(buffer) : readLineMessage(buffer);
|
||||
}
|
||||
|
||||
function readLineMessage(buffer: Buffer<ArrayBufferLike>): ReadResult {
|
||||
const newlineIndex = buffer.indexOf(0x0a);
|
||||
if (newlineIndex === -1) return { kind: "incomplete" };
|
||||
const line = buffer.subarray(0, newlineIndex).toString("utf8").replace(/\r$/, "");
|
||||
if (line.trim().length === 0) {
|
||||
return {
|
||||
kind: "complete",
|
||||
remaining: buffer.subarray(newlineIndex + 1),
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "complete",
|
||||
message: parseJsonPayload(line, "line"),
|
||||
remaining: buffer.subarray(newlineIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function readFramedMessage(buffer: Buffer<ArrayBufferLike>): ReadResult {
|
||||
const separatorIndex = buffer.indexOf(HEADER_SEPARATOR);
|
||||
if (separatorIndex === -1) return { kind: "incomplete" };
|
||||
|
||||
const headers = buffer.subarray(0, separatorIndex).toString("ascii");
|
||||
const contentLength = parseContentLength(headers);
|
||||
const bodyStart = separatorIndex + HEADER_SEPARATOR.length;
|
||||
if (contentLength === undefined) {
|
||||
return {
|
||||
kind: "complete",
|
||||
message: {
|
||||
kind: "parse_error",
|
||||
message: "Missing or invalid Content-Length header",
|
||||
responseMode: "framed",
|
||||
},
|
||||
remaining: buffer.subarray(bodyStart),
|
||||
};
|
||||
}
|
||||
|
||||
const bodyEnd = bodyStart + contentLength;
|
||||
if (buffer.length < bodyEnd) return { kind: "incomplete" };
|
||||
const body = buffer.subarray(bodyStart, bodyEnd).toString("utf8");
|
||||
return {
|
||||
kind: "complete",
|
||||
message: parseJsonPayload(body, "framed"),
|
||||
remaining: buffer.subarray(bodyEnd),
|
||||
};
|
||||
}
|
||||
|
||||
function startsWithContentLength(buffer: Buffer<ArrayBufferLike>): boolean {
|
||||
const prefix = buffer.subarray(0, "content-length:".length).toString("ascii").toLowerCase();
|
||||
return prefix === "content-length:";
|
||||
}
|
||||
|
||||
function parseContentLength(headers: string): number | undefined {
|
||||
for (const line of headers.split("\r\n")) {
|
||||
const match = /^content-length:\s*(\d+)$/i.exec(line);
|
||||
if (match === null) continue;
|
||||
const value = match[1];
|
||||
if (value === undefined) return undefined;
|
||||
return Number(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseJsonPayload(payload: string, responseMode: StdioJsonRpcResponseMode): StdioJsonRpcMessage {
|
||||
try {
|
||||
return { kind: "request", payload: JSON.parse(payload), responseMode };
|
||||
} catch (error) {
|
||||
return { kind: "parse_error", message: messageFromError(error), responseMode };
|
||||
}
|
||||
}
|
||||
|
||||
function bufferFromChunk(chunk: unknown): Buffer<ArrayBufferLike> {
|
||||
if (Buffer.isBuffer(chunk)) return chunk;
|
||||
if (typeof chunk === "string") return Buffer.from(chunk);
|
||||
throw new TypeError(`Unsupported stdio chunk type: ${typeof chunk}`);
|
||||
}
|
||||
|
||||
function messageFromError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import { afterEach, describe, expect, it } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { handleAstGrepMcpRequest } from "./mcp";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { handleAstGrepMcpRequest, runMcpStdioServer } from "./mcp";
|
||||
import type { RunOptions } from "./runner";
|
||||
import type { SgResult } from "./types";
|
||||
|
||||
@@ -259,4 +260,20 @@ describe("ast-grep MCP", () => {
|
||||
expect(searchTool?.description).toContain("This is NOT regex");
|
||||
expect(searchTool?.description).toContain("Meta-variables");
|
||||
});
|
||||
|
||||
it("#given idle stdio connection #when no request arrives before timeout #then server exits through idle callback", async () => {
|
||||
const input = new PassThrough();
|
||||
const output = new PassThrough();
|
||||
let idleCallCount = 0;
|
||||
|
||||
await runMcpStdioServer(input, output, {}, {
|
||||
idleTimeoutMs: 1,
|
||||
onIdleTimeout: () => {
|
||||
idleCallCount++;
|
||||
input.end();
|
||||
},
|
||||
});
|
||||
|
||||
expect(idleCallCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createInterface } from "node:readline";
|
||||
import type { Readable, Writable } from "node:stream";
|
||||
import { CLI_LANGUAGES } from "./constants";
|
||||
import { runJsonRpcStdioServer, type McpStdioServerOptions } from "./mcp-stdio-server";
|
||||
import { getPatternHint } from "./pattern-hints";
|
||||
import { formatReplaceResult, formatSearchResult } from "./result-formatter";
|
||||
import { runSg, type RunOptions } from "./runner";
|
||||
@@ -116,23 +117,12 @@ export async function handleAstGrepMcpRequest(input: unknown, options: AstGrepMc
|
||||
}
|
||||
|
||||
export async function runMcpStdioServer(
|
||||
input: NodeJS.ReadableStream = process.stdin,
|
||||
output: NodeJS.WritableStream = process.stdout,
|
||||
input: Readable = process.stdin,
|
||||
output: Writable = process.stdout,
|
||||
options: AstGrepMcpOptions = {},
|
||||
stdioOptions: McpStdioServerOptions = {},
|
||||
): Promise<void> {
|
||||
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 handleAstGrepMcpRequest(parsed, options);
|
||||
if (response) output.write(`${JSON.stringify(response)}\n`);
|
||||
}
|
||||
await runJsonRpcStdioServer(handleAstGrepMcpRequest, input, output, options, stdioOptions);
|
||||
}
|
||||
|
||||
async function handleToolCall(id: JsonRpcId, params: unknown, options: AstGrepMcpOptions): Promise<JsonRpcResponse> {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
type PackageJson = {
|
||||
readonly name: string;
|
||||
readonly type: string;
|
||||
readonly bin: Record<string, string>;
|
||||
};
|
||||
|
||||
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 ast-grep MCP files #when validating entrypoints #then package metadata exposes the omo CLI", () => {
|
||||
// given
|
||||
const packageJson = readPackageJson(fileURLToPath(new URL("../package.json", import.meta.url)));
|
||||
const cliSource = readFileSync(fileURLToPath(new URL("cli.ts", import.meta.url)), "utf8");
|
||||
|
||||
// then
|
||||
expect(packageJson.name).toBe("@oh-my-opencode/ast-grep-mcp");
|
||||
expect(packageJson.type).toBe("module");
|
||||
expect(packageJson.bin["omo-ast-grep"]).toBe("dist/cli.js");
|
||||
expect(packageJson.bin["ast-grep-mcp"]).toBeUndefined();
|
||||
expect(cliSource.startsWith("#!/usr/bin/env node")).toBe(true);
|
||||
expect(cliSource).toContain("Usage: omo-ast-grep [mcp]");
|
||||
});
|
||||
});
|
||||
|
||||
function isPackageJson(value: unknown): value is PackageJson {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
value["name"] === "@oh-my-opencode/ast-grep-mcp" &&
|
||||
value["type"] === "module" &&
|
||||
isStringRecord(value["bin"])
|
||||
);
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown): value is Record<string, string> {
|
||||
return isRecord(value) && Object.values(value).every((item) => typeof item === "string");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export {
|
||||
getWorkByPlanName,
|
||||
getWorkForSession,
|
||||
getWorkResumeOptions,
|
||||
normalizeSessionId,
|
||||
readBoulderState,
|
||||
resolveBoulderPlanPath,
|
||||
resolveBoulderPlanPathForWork,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { getBoulderFilePath, resolveBoulderPlanPath, resolveBoulderPlanPathForWork } from "./path"
|
||||
export { findPrometheusPlans, getPlanName, getPlanProgress } from "./plan-progress"
|
||||
export { normalizeSessionId } from "./shared"
|
||||
export {
|
||||
getActiveWorks,
|
||||
getBoulderWorks,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs"
|
||||
import type { BoulderState, BoulderWorkResumeOption, BoulderWorkState, TaskSessionState } from "../types"
|
||||
import { getBoulderFilePath, resolveBoulderPlanPathForWork } from "./path"
|
||||
import { getPlanProgress } from "./plan-progress"
|
||||
import { buildWorkFromMirror, isValidWorkStatus, parseIsoToMs, projectWorkToMirror, selectMirrorWork } from "./shared"
|
||||
import { buildWorkFromMirror, isValidWorkStatus, normalizeSessionId, parseIsoToMs, projectWorkToMirror, selectMirrorWork } from "./shared"
|
||||
|
||||
export function readBoulderState(directory: string): BoulderState | null {
|
||||
const filePath = getBoulderFilePath(directory)
|
||||
@@ -33,8 +33,9 @@ export function readBoulderState(directory: string): BoulderState | null {
|
||||
}
|
||||
|
||||
function normalizeState(state: Record<string, unknown>): void {
|
||||
normalizeSessionFields(state)
|
||||
|
||||
const sessionIds = Array.isArray(state.session_ids) ? state.session_ids : []
|
||||
state.session_ids = sessionIds
|
||||
|
||||
const sessionOrigins = state.session_origins && typeof state.session_origins === "object" && !Array.isArray(state.session_origins)
|
||||
? (state.session_origins as Record<string, unknown>)
|
||||
@@ -55,6 +56,38 @@ function normalizeState(state: Record<string, unknown>): void {
|
||||
if (!state.task_sessions || typeof state.task_sessions !== "object" || Array.isArray(state.task_sessions)) {
|
||||
state.task_sessions = {}
|
||||
}
|
||||
|
||||
normalizeWorkSessionFields(state.works)
|
||||
}
|
||||
|
||||
function normalizeSessionFields(target: Record<string, unknown>): void {
|
||||
const sessionIds = Array.isArray(target.session_ids)
|
||||
? target.session_ids.filter((sessionId): sessionId is string => typeof sessionId === "string").map((sessionId) => normalizeSessionId(sessionId))
|
||||
: []
|
||||
target.session_ids = sessionIds
|
||||
|
||||
const sessionOrigins = target.session_origins && typeof target.session_origins === "object" && !Array.isArray(target.session_origins)
|
||||
? normalizeSessionOrigins(target.session_origins as Record<string, unknown>)
|
||||
: {}
|
||||
target.session_origins = sessionOrigins
|
||||
}
|
||||
|
||||
function normalizeSessionOrigins(sessionOrigins: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(sessionOrigins).map(([sessionId, origin]) => [normalizeSessionId(sessionId), origin]),
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeWorkSessionFields(works: unknown): void {
|
||||
if (!works || typeof works !== "object" || Array.isArray(works)) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const work of Object.values(works)) {
|
||||
if (work && typeof work === "object" && !Array.isArray(work)) {
|
||||
normalizeSessionFields(work as Record<string, unknown>)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getBoulderWorks(state: BoulderState): BoulderWorkState[] {
|
||||
@@ -113,15 +146,16 @@ export function getWorkForSession(directory: string, sessionId: string): Boulder
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedSessionId = normalizeSessionId(sessionId)
|
||||
const works = getBoulderWorks(state)
|
||||
.filter((work) => work.session_ids.includes(sessionId))
|
||||
.filter((work) => work.session_ids.includes(normalizedSessionId))
|
||||
.sort((left, right) => (parseIsoToMs(right.updated_at ?? right.started_at) ?? 0) - (parseIsoToMs(left.updated_at ?? left.started_at) ?? 0))
|
||||
|
||||
if (works.length > 0) {
|
||||
return works[0] ?? null
|
||||
}
|
||||
|
||||
return state.session_ids.includes(sessionId) ? buildWorkFromMirror(state) : null
|
||||
return state.session_ids.includes(normalizedSessionId) ? buildWorkFromMirror(state) : null
|
||||
}
|
||||
|
||||
export function getWorkResumeOptions(directory: string): BoulderWorkResumeOption[] {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BoulderSessionOrigin, BoulderState, BoulderWorkState } from "../types"
|
||||
import { getBoulderWorks, readBoulderState } from "./read-state"
|
||||
import { nowIsoString, projectWorkToMirror } from "./shared"
|
||||
import { normalizeSessionId, nowIsoString, projectWorkToMirror } from "./shared"
|
||||
import { writeBoulderState } from "./write-state"
|
||||
|
||||
export function appendSessionId(
|
||||
@@ -8,9 +8,10 @@ export function appendSessionId(
|
||||
sessionId: string,
|
||||
origin: "direct" | "appended" = "direct",
|
||||
): BoulderState | null {
|
||||
const normalizedSessionId = normalizeSessionId(sessionId)
|
||||
const activeWorkId = readBoulderState(directory)?.active_work_id
|
||||
if (activeWorkId) {
|
||||
return appendSessionIdForWork(directory, activeWorkId, sessionId, origin)
|
||||
return appendSessionIdForWork(directory, activeWorkId, normalizedSessionId, origin)
|
||||
}
|
||||
|
||||
const state = readBoulderState(directory)
|
||||
@@ -22,15 +23,15 @@ export function appendSessionId(
|
||||
state.session_origins = {}
|
||||
}
|
||||
|
||||
if (!state.session_ids?.includes(sessionId)) {
|
||||
if (!state.session_ids?.includes(normalizedSessionId)) {
|
||||
if (!Array.isArray(state.session_ids)) {
|
||||
state.session_ids = []
|
||||
}
|
||||
|
||||
const originalSessionIds = [...state.session_ids]
|
||||
const originalSessionOrigins = { ...state.session_origins }
|
||||
state.session_ids.push(sessionId)
|
||||
state.session_origins[sessionId] = origin
|
||||
state.session_ids.push(normalizedSessionId)
|
||||
state.session_origins[normalizedSessionId] = origin
|
||||
if (writeBoulderState(directory, state)) {
|
||||
return state
|
||||
}
|
||||
@@ -40,8 +41,8 @@ export function appendSessionId(
|
||||
return null
|
||||
}
|
||||
|
||||
if (!state.session_origins[sessionId]) {
|
||||
state.session_origins[sessionId] = origin
|
||||
if (!state.session_origins[normalizedSessionId]) {
|
||||
state.session_origins[normalizedSessionId] = origin
|
||||
if (!writeBoulderState(directory, state)) {
|
||||
return null
|
||||
}
|
||||
@@ -56,6 +57,7 @@ export function appendSessionIdForWork(
|
||||
sessionId: string,
|
||||
origin: BoulderSessionOrigin = "direct",
|
||||
): BoulderState | null {
|
||||
const normalizedSessionId = normalizeSessionId(sessionId)
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) {
|
||||
return null
|
||||
@@ -69,10 +71,10 @@ export function appendSessionIdForWork(
|
||||
|
||||
const updatedWork: BoulderWorkState = {
|
||||
...targetWork,
|
||||
session_ids: targetWork.session_ids.includes(sessionId)
|
||||
session_ids: targetWork.session_ids.includes(normalizedSessionId)
|
||||
? [...targetWork.session_ids]
|
||||
: [...targetWork.session_ids, sessionId],
|
||||
session_origins: { ...(targetWork.session_origins ?? {}), [sessionId]: origin },
|
||||
: [...targetWork.session_ids, normalizedSessionId],
|
||||
session_origins: { ...(targetWork.session_origins ?? {}), [normalizedSessionId]: origin },
|
||||
updated_at: nowIsoString(),
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,18 @@ import type { BoulderState, BoulderWorkState, BoulderWorkStatus } from "../types
|
||||
|
||||
export const RESERVED_KEYS = new Set(["__proto__", "prototype", "constructor"])
|
||||
|
||||
type SessionPlatform = "codex" | "opencode"
|
||||
|
||||
const SESSION_ID_PREFIX_PATTERN = /^(codex|opencode):/
|
||||
|
||||
export function normalizeSessionId(sessionId: string, platform: SessionPlatform = "opencode"): string {
|
||||
if (SESSION_ID_PREFIX_PATTERN.test(sessionId)) {
|
||||
return sessionId
|
||||
}
|
||||
|
||||
return `${platform}:${sessionId}`
|
||||
}
|
||||
|
||||
export function nowIsoString(): string {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BoulderState, BoulderWorkState, TaskSessionState } from "../types"
|
||||
import { getBoulderWorks, readBoulderState } from "./read-state"
|
||||
import { getElapsedMs, nowIsoString, projectWorkToMirror, RESERVED_KEYS } from "./shared"
|
||||
import { getElapsedMs, normalizeSessionId, nowIsoString, projectWorkToMirror, RESERVED_KEYS } from "./shared"
|
||||
import { writeBoulderState } from "./write-state"
|
||||
|
||||
export function upsertTaskSessionState(
|
||||
@@ -24,12 +24,13 @@ export function upsertTaskSessionState(
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedSessionId = normalizeSessionId(input.sessionId)
|
||||
const taskSessions = state.task_sessions ?? {}
|
||||
taskSessions[input.taskKey] = {
|
||||
task_key: input.taskKey,
|
||||
task_label: input.taskLabel,
|
||||
task_title: input.taskTitle,
|
||||
session_id: input.sessionId,
|
||||
session_id: normalizedSessionId,
|
||||
...(input.agent !== undefined ? { agent: input.agent } : {}),
|
||||
...(input.category !== undefined ? { category: input.category } : {}),
|
||||
updated_at: nowIsoString(),
|
||||
@@ -66,12 +67,13 @@ export function upsertTaskSessionStateForWork(
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedSessionId = normalizeSessionId(input.sessionId)
|
||||
const previousTaskSession = targetWork.task_sessions?.[input.taskKey]
|
||||
const nextTaskSession: TaskSessionState = {
|
||||
task_key: input.taskKey,
|
||||
task_label: input.taskLabel,
|
||||
task_title: input.taskTitle,
|
||||
session_id: input.sessionId,
|
||||
session_id: normalizedSessionId,
|
||||
...(input.agent !== undefined ? { agent: input.agent } : {}),
|
||||
...(input.category !== undefined ? { category: input.category } : {}),
|
||||
...(previousTaskSession?.started_at !== undefined ? { started_at: previousTaskSession.started_at } : {}),
|
||||
@@ -116,7 +118,10 @@ export function startTaskTimer(
|
||||
startedAt?: string
|
||||
},
|
||||
): BoulderState | null {
|
||||
const nextState = upsertTaskSessionStateForWork(directory, workId, input)
|
||||
const nextState = upsertTaskSessionStateForWork(directory, workId, {
|
||||
...input,
|
||||
sessionId: normalizeSessionId(input.sessionId),
|
||||
})
|
||||
if (!nextState) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { BoulderState, BoulderWorkState } from "../types"
|
||||
import { getBoulderFilePath } from "./path"
|
||||
import { getPlanName } from "./plan-progress"
|
||||
import { getBoulderWorks, readBoulderState } from "./read-state"
|
||||
import { getElapsedMs, nowIsoString, projectWorkToMirror } from "./shared"
|
||||
import { getElapsedMs, normalizeSessionId, nowIsoString, projectWorkToMirror } from "./shared"
|
||||
|
||||
export function writeBoulderState(directory: string, state: BoulderState): boolean {
|
||||
const filePath = getBoulderFilePath(directory)
|
||||
@@ -67,6 +67,7 @@ export function generateWorkId(planName: string): string {
|
||||
|
||||
export function createBoulderState(planPath: string, sessionId: string, agent?: string, worktreePath?: string): BoulderState {
|
||||
const startedAt = nowIsoString()
|
||||
const normalizedSessionId = normalizeSessionId(sessionId)
|
||||
const workId = generateWorkId(getPlanName(planPath))
|
||||
const work: BoulderWorkState = {
|
||||
work_id: workId,
|
||||
@@ -75,8 +76,8 @@ export function createBoulderState(planPath: string, sessionId: string, agent?:
|
||||
status: "active",
|
||||
started_at: startedAt,
|
||||
updated_at: startedAt,
|
||||
session_ids: [sessionId],
|
||||
session_origins: { [sessionId]: "direct" },
|
||||
session_ids: [normalizedSessionId],
|
||||
session_origins: { [normalizedSessionId]: "direct" },
|
||||
...(agent !== undefined ? { agent } : {}),
|
||||
...(worktreePath !== undefined ? { worktree_path: worktreePath } : {}),
|
||||
task_sessions: {},
|
||||
@@ -90,8 +91,8 @@ export function createBoulderState(planPath: string, sessionId: string, agent?:
|
||||
started_at: startedAt,
|
||||
status: "active",
|
||||
updated_at: startedAt,
|
||||
session_ids: [sessionId],
|
||||
session_origins: { [sessionId]: "direct" },
|
||||
session_ids: [normalizedSessionId],
|
||||
session_origins: { [normalizedSessionId]: "direct" },
|
||||
plan_name: getPlanName(planPath),
|
||||
task_sessions: {},
|
||||
...(agent !== undefined ? { agent } : {}),
|
||||
@@ -132,6 +133,7 @@ export function addBoulderWork(
|
||||
|
||||
const workId = generateWorkId(getPlanName(input.planPath))
|
||||
const startedAt = input.startedAt ?? nowIsoString()
|
||||
const normalizedSessionId = normalizeSessionId(input.sessionId)
|
||||
const nextWork: BoulderWorkState = {
|
||||
work_id: workId,
|
||||
active_plan: input.planPath,
|
||||
@@ -139,8 +141,8 @@ export function addBoulderWork(
|
||||
status: "active",
|
||||
started_at: startedAt,
|
||||
updated_at: startedAt,
|
||||
session_ids: [input.sessionId],
|
||||
session_origins: { [input.sessionId]: "direct" },
|
||||
session_ids: [normalizedSessionId],
|
||||
session_origins: { [normalizedSessionId]: "direct" },
|
||||
...(input.agent !== undefined ? { agent: input.agent } : {}),
|
||||
...(input.worktreePath !== undefined ? { worktree_path: input.worktreePath } : {}),
|
||||
task_sessions: {},
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/// <reference path="../../../bun-test.d.ts" />
|
||||
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import * as boulderState from "../src"
|
||||
|
||||
describe("normalizeSessionId", () => {
|
||||
test("#given a bare id #when normalized without a platform #then opencode is used by default", () => {
|
||||
// given
|
||||
expect(typeof boulderState.normalizeSessionId).toBe("function")
|
||||
|
||||
// when
|
||||
const normalized = boulderState.normalizeSessionId("sess_abc")
|
||||
|
||||
// then
|
||||
expect(normalized).toBe("opencode:sess_abc")
|
||||
})
|
||||
|
||||
test("#given a bare id and codex platform #when normalized #then codex is used", () => {
|
||||
// given
|
||||
expect(typeof boulderState.normalizeSessionId).toBe("function")
|
||||
|
||||
// when
|
||||
const normalized = boulderState.normalizeSessionId("sess_abc", "codex")
|
||||
|
||||
// then
|
||||
expect(normalized).toBe("codex:sess_abc")
|
||||
})
|
||||
|
||||
test("#given an opencode-prefixed id #when normalized #then the id is unchanged", () => {
|
||||
// given
|
||||
const input = "opencode:sess_abc"
|
||||
expect(typeof boulderState.normalizeSessionId).toBe("function")
|
||||
|
||||
// when
|
||||
const normalized = boulderState.normalizeSessionId(input)
|
||||
|
||||
// then
|
||||
expect(normalized).toBe(input)
|
||||
})
|
||||
|
||||
test("#given a codex-prefixed id and opencode platform #when normalized #then the existing prefix wins", () => {
|
||||
// given
|
||||
expect(typeof boulderState.normalizeSessionId).toBe("function")
|
||||
|
||||
|
||||
// when
|
||||
const normalized = boulderState.normalizeSessionId("codex:sess_abc", "opencode")
|
||||
|
||||
// then
|
||||
expect(normalized).toBe("codex:sess_abc")
|
||||
})
|
||||
|
||||
test("#given an empty id #when normalized #then opencode empty id is preserved", () => {
|
||||
// given
|
||||
expect(typeof boulderState.normalizeSessionId).toBe("function")
|
||||
|
||||
// when
|
||||
const normalized = boulderState.normalizeSessionId("")
|
||||
|
||||
// then
|
||||
expect(normalized).toBe("opencode:")
|
||||
})
|
||||
})
|
||||
+1
-1
Submodule packages/lsp-tools-mcp updated: e7c65b04d0...103e28ea43
@@ -0,0 +1,30 @@
|
||||
# Sisyphus Labs Codex Marketplace
|
||||
|
||||
Native Codex marketplace for the `omo` plugin.
|
||||
|
||||
## 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/ulw-loop`: durable repo-native multi-goal orchestration with embedded success criteria and observable evidence audit (`.omo/ulw-loop/`).
|
||||
- `components/start-work-continuation`: resumes `.omo/boulder.json` start-work plans from stop boundaries.
|
||||
- `components/telemetry`: emits anonymous daily active telemetry when enabled.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bunx lazycodex install
|
||||
```
|
||||
|
||||
The installer builds `omo`, copies a clean versioned cache entry into `~/.codex/plugins/cache/sisyphuslabs/omo`, installs runtime dependencies in the cache, writes stable bundled-agent TOMLs through `~/.codex/.tmp/marketplaces/sisyphuslabs/plugins/omo`, registers the `sisyphuslabs` marketplace from the local built cache, and enables `[plugins."omo@sisyphuslabs"]` 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/sisyphuslabs/omo/0.1.0
|
||||
```
|
||||
@@ -0,0 +1,80 @@
|
||||
# @oh-my-opencode/omo-codex
|
||||
|
||||
Codex harness adapter for **oh-my-openagent**. Brings the OMO experience (rules injection, comment checker, LSP MCP, ultrawork, ulw-loop, start-work continuation, telemetry) into [OpenAI Codex CLI](https://github.com/openai/codex) through Codex's native plugin system.
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `plugin/` | Vendored Codex plugin namespace `omo` with isolated components. Shipped to the user via `~/.codex/plugins/cache/`. |
|
||||
| `marketplace.json` | Codex marketplace manifest. Identifies `omo` as the single installable plugin. |
|
||||
| `scripts/` | Node ESM build scripts for Codex cache installation and marketplace config updates. |
|
||||
| `src/` | TypeScript runtime: installer + telemetry consumed by the omodex CLI. |
|
||||
| `MARKETPLACE.md` | Native Codex marketplace notes for `sisyphuslabs` / `omo`. |
|
||||
|
||||
## Components Vendored
|
||||
|
||||
- `rules` (TypeScript) - injects `AGENTS.md` / `CLAUDE.md` / `.omo/rules/**` into context via `SessionStart`, `UserPromptSubmit`, `PostToolUse`, `PostCompact`.
|
||||
- `comment-checker` (TypeScript) - runs `@code-yeongyu/comment-checker` after `apply_patch` / `edit` / `write` tool use.
|
||||
- `lsp` (TypeScript + LSP MCP) - exposes LSP diagnostics, navigation, symbols, rename via MCP + post-edit hooks.
|
||||
- `ultrawork` (TypeScript) - keyword detector (`ulw` / `ultrawork`) that injects the full ultrawork directive; bundled agent TOML files are installed into `CODEX_HOME/agents`.
|
||||
- `ulw-loop` (TypeScript) - durable multi-goal orchestration backed by `.omo/ulw-loop/` evidence audit.
|
||||
- `start-work-continuation` (TypeScript) - `Stop` / `SubagentStop` continuation hook for `.omo/boulder.json` start-work plans.
|
||||
- `telemetry` (TypeScript) - anonymous daily active telemetry hook.
|
||||
|
||||
## Install
|
||||
|
||||
End users invoke through the omodex CLI. This package is the **Light edition** of omo — install it directly with:
|
||||
|
||||
```bash
|
||||
bunx omo install --platform=codex
|
||||
# or via the shortcut alias (same compiled CLI, defaults --platform=codex):
|
||||
bunx lazycodex install
|
||||
# or the longer package names:
|
||||
bunx oh-my-opencode install --platform=codex
|
||||
bunx oh-my-openagent install --platform=codex
|
||||
```
|
||||
|
||||
To install **both** the Ultimate edition (OpenCode plugin) and the Light edition (this package) at once, use `--platform=both`.
|
||||
|
||||
The installer copies the built plugin into `~/.codex/plugins/cache/sisyphuslabs/omo/<version>/`, writes stable agent TOML links through `~/.codex/.tmp/marketplaces/sisyphuslabs/plugins/omo/`, enables `omo@sisyphuslabs` in `~/.codex/config.toml`, and registers the `sisyphuslabs` marketplace from the local built cache. `lazycodex` is the repo/npm/bin alias; the marketplace identity remains `sisyphuslabs`.
|
||||
|
||||
To install both editions in one command, use `--platform=both`.
|
||||
|
||||
## Telemetry
|
||||
|
||||
Anonymous telemetry uses the same PostHog project as oh-my-openagent but emits the distinct event `omo_codex_daily_active`. The event is sent at most once per UTC day per machine from two sources:
|
||||
|
||||
| Source | Reason | Trigger |
|
||||
|--------|--------|---------|
|
||||
| `install` | `install_completed` | `bunx omo install --platform=codex` or `--platform=both` finishes (handled by `src/cli/install-codex/install-codex.ts`) |
|
||||
| `plugin` | `session_start` | Codex plugin `SessionStart` hook fires (handled by `plugin/components/telemetry/`) |
|
||||
|
||||
Both sources share the same SHA256-hashed installation identifier (`sha256("omo-codex:" + hostname)`), suppress PostHog person profiles, and write the daily dedup state to `~/.local/share/omo-codex/posthog-activity.json`.
|
||||
|
||||
Opt out with:
|
||||
|
||||
```bash
|
||||
# Codex-only
|
||||
export OMO_CODEX_DISABLE_POSTHOG=1
|
||||
export OMO_CODEX_SEND_ANONYMOUS_TELEMETRY=0
|
||||
|
||||
# Globally (also disables oh-my-openagent telemetry)
|
||||
export OMO_DISABLE_POSTHOG=1
|
||||
export OMO_SEND_ANONYMOUS_TELEMETRY=0
|
||||
```
|
||||
|
||||
The identity constants and opt-out behavior are pinned across both sources by `src/telemetry/cross-package-equivalence.test.ts`.
|
||||
|
||||
See `/Users/yeongyu/local-workspaces/omodex/docs/legal/privacy-policy.md` for the full disclosure.
|
||||
|
||||
## Component Sources
|
||||
|
||||
The bundled component implementations come from the Sisyphus Labs Codex plugin family:
|
||||
|
||||
- [code-yeongyu/codex-rules](https://github.com/code-yeongyu/codex-rules)
|
||||
- [code-yeongyu/codex-comment-checker](https://github.com/code-yeongyu/codex-comment-checker)
|
||||
- [code-yeongyu/codex-lsp](https://github.com/code-yeongyu/codex-lsp)
|
||||
- [code-yeongyu/codex-ultrawork](https://github.com/code-yeongyu/codex-ultrawork)
|
||||
- [code-yeongyu/codex-ulw-loop](https://github.com/code-yeongyu/codex-ulw-loop)
|
||||
- [code-yeongyu/codex-start-work-continuation](https://github.com/code-yeongyu/codex-start-work-continuation)
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
export * from "./src/index";
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "sisyphuslabs",
|
||||
"interface": {
|
||||
"displayName": "Sisyphus Labs"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "omo",
|
||||
"source": "./plugins/omo",
|
||||
"category": "Developer Tools",
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@oh-my-opencode/omo-codex",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"description": "Codex harness adapter for oh-my-openagent. Vendored Codex plugin namespace (omo) + TypeScript installer + telemetry.",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./index.d.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./telemetry": "./src/telemetry/index.ts",
|
||||
"./marketplace.json": "./marketplace.json"
|
||||
},
|
||||
"types": "./index.d.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit -p tsconfig.json",
|
||||
"test": "bun test src/**/*.test.ts",
|
||||
"build:plugin": "bun run --cwd plugin build",
|
||||
"sync:skills": "node plugin/scripts/sync-skills.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@oh-my-opencode/utils": "workspace:*",
|
||||
"posthog-node": "^5.34.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "1.3.14"
|
||||
}
|
||||
}
|
||||
@@ -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/sisyphuslabs/omo",
|
||||
"repository": "https://github.com/sisyphuslabs/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 ulw-loop 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/sisyphuslabs/omo",
|
||||
"privacyPolicyURL": "https://github.com/sisyphuslabs/omo#privacy",
|
||||
"termsOfServiceURL": "https://github.com/sisyphuslabs/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": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"ast_grep": {
|
||||
"command": "node",
|
||||
"args": ["../../ast-grep-mcp/dist/cli.js", "mcp"],
|
||||
"cwd": "."
|
||||
},
|
||||
"lsp": {
|
||||
"command": "node",
|
||||
"args": ["../../lsp-tools-mcp/dist/cli.js", "mcp"],
|
||||
"cwd": "."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/ulw-loop`
|
||||
|
||||
The root plugin manifest exports one Codex plugin named `omo`, with aggregate hooks, skills, and the LSP MCP server.
|
||||
@@ -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
|
||||
@@ -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
|
||||
+40
@@ -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
|
||||
+27
@@ -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
|
||||
+45
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
## Summary
|
||||
|
||||
<!-- Brief description, 1-3 bullets -->
|
||||
|
||||
-
|
||||
|
||||
## 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
|
||||
+47
@@ -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
|
||||
+51
@@ -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 }}
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
coverage/
|
||||
.vitest/
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,87 @@
|
||||
# codex-comment-checker
|
||||
|
||||
[](https://github.com/code-yeongyu/codex-comment-checker/actions/workflows/ci.yml) [](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
|
||||
|
||||
```bash
|
||||
bunx lazycodex install
|
||||
```
|
||||
|
||||
The installer builds and copies the plugin into `~/.codex/plugins/cache/sisyphuslabs/omo/0.1.0`, registers the `sisyphuslabs` marketplace from the `lazycodex` Git repository, installs runtime dependencies there, and enables:
|
||||
|
||||
```toml
|
||||
[features]
|
||||
plugins = true
|
||||
plugin_hooks = true
|
||||
|
||||
[plugins."omo@sisyphuslabs"]
|
||||
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<semver>`.
|
||||
- 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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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": "LazyCodex(0.1.1): Checking Comments"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
"omo-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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { runCodexHookCli } from "./codex-hook.js";
|
||||
|
||||
const [command, subcommand] = process.argv.slice(2);
|
||||
|
||||
if (command === "hook" && subcommand === "post-tool-use") {
|
||||
await runCodexHookCli();
|
||||
} else {
|
||||
process.stderr.write("Usage: omo-comment-checker hook post-tool-use\n");
|
||||
process.exitCode = 2;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { stdin as processStdin, stdout as processStdout } from "node:process";
|
||||
|
||||
import {
|
||||
type CommentCheckRequest,
|
||||
extractCommentCheckRequests,
|
||||
isRecord,
|
||||
type ToolResultContent,
|
||||
type ToolResultLike,
|
||||
toHookInput,
|
||||
} from "./core.js";
|
||||
import { type CommentCheckerRunner, runCommentChecker } from "./runner.js";
|
||||
|
||||
export type CodexPostToolUseInput = {
|
||||
session_id: string;
|
||||
turn_id: string;
|
||||
transcript_path: string | null;
|
||||
cwd: string;
|
||||
hook_event_name: "PostToolUse";
|
||||
model: string;
|
||||
permission_mode: string;
|
||||
tool_name: string;
|
||||
tool_input: Record<string, unknown>;
|
||||
tool_response: unknown;
|
||||
tool_use_id: string;
|
||||
};
|
||||
|
||||
export type CodexHookOptions = {
|
||||
run?: CommentCheckerRunner;
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_HOOK_FEEDBACK_CHARS = 8000;
|
||||
const CONTEXT_PRESSURE_MAX_HOOK_FEEDBACK_CHARS = 1200;
|
||||
const CONTEXT_PRESSURE_MARKERS = [
|
||||
"context compacted",
|
||||
"context_length_exceeded",
|
||||
"skill descriptions were shortened",
|
||||
"context_too_large",
|
||||
"codex ran out of room in the model's context window",
|
||||
"your input exceeds the context window",
|
||||
"long threads and multiple compactions",
|
||||
] as const;
|
||||
|
||||
export function extractCodexCommentCheckRequests(input: CodexPostToolUseInput): CommentCheckRequest[] {
|
||||
return extractCommentCheckRequests(toToolResultLike(input));
|
||||
}
|
||||
|
||||
export async function runCommentCheckerPostToolUse(
|
||||
input: CodexPostToolUseInput,
|
||||
options: CodexHookOptions = {},
|
||||
): Promise<string> {
|
||||
const requests = extractCodexCommentCheckRequests(input);
|
||||
if (requests.length === 0) return "";
|
||||
|
||||
const runner = options.run ?? runCommentChecker;
|
||||
const warnings: Array<{ filePath: string; message: string }> = [];
|
||||
|
||||
for (const request of requests) {
|
||||
const context = {
|
||||
sessionId: input.session_id,
|
||||
cwd: input.cwd,
|
||||
...(input.transcript_path === null ? {} : { transcriptPath: input.transcript_path }),
|
||||
};
|
||||
const result = await runner(toHookInput(request, context));
|
||||
if (result.status === "missing" || result.status === "pass") continue;
|
||||
if (result.status === "error") continue;
|
||||
const message = normalizeHookText(result.message);
|
||||
if (message.length > 0) {
|
||||
warnings.push({ filePath: request.filePath, message });
|
||||
}
|
||||
}
|
||||
|
||||
if (warnings.length === 0) return "";
|
||||
|
||||
return JSON.stringify({
|
||||
decision: "block",
|
||||
reason: limitHookText(formatWarnings(warnings), hookFeedbackLimit(input.transcript_path)),
|
||||
});
|
||||
}
|
||||
|
||||
export async function runCodexHookCli(): Promise<void> {
|
||||
const input = await readStdin();
|
||||
if (input.trim().length === 0) return;
|
||||
const parsed = parseCodexPostToolUseInput(input);
|
||||
if (!parsed) return;
|
||||
const output = await runCommentCheckerPostToolUse(parsed);
|
||||
if (output.length > 0) {
|
||||
processStdout.write(output);
|
||||
processStdout.write("\n");
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCodexPostToolUseInput(input: string): CodexPostToolUseInput | undefined {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(input);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return isCodexPostToolUseInput(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function toToolResultLike(input: CodexPostToolUseInput): ToolResultLike {
|
||||
return {
|
||||
toolName: input.tool_name,
|
||||
input: normalizeToolInput(input.tool_name, input.tool_input),
|
||||
content: normalizeToolResponse(input.tool_response),
|
||||
isError: isErrorResponse(input.tool_response),
|
||||
details: isRecord(input.tool_response) ? input.tool_response : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeToolInput(toolName: string, toolInput: Record<string, unknown>): Record<string, unknown> {
|
||||
if (toolName === "apply_patch" && typeof toolInput["command"] === "string") {
|
||||
return {
|
||||
...toolInput,
|
||||
input: toolInput["command"],
|
||||
patch: toolInput["command"],
|
||||
};
|
||||
}
|
||||
return toolInput;
|
||||
}
|
||||
|
||||
function normalizeToolResponse(toolResponse: unknown): ToolResultContent[] {
|
||||
if (typeof toolResponse === "string") {
|
||||
return [{ type: "text", text: toolResponse }];
|
||||
}
|
||||
if (isRecord(toolResponse) && typeof toolResponse["text"] === "string") {
|
||||
return [{ type: "text", text: toolResponse["text"] }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function isErrorResponse(toolResponse: unknown): boolean {
|
||||
return isRecord(toolResponse) && toolResponse["is_error"] === true;
|
||||
}
|
||||
|
||||
function formatWarnings(warnings: Array<{ filePath: string; message: string }>): string {
|
||||
return warnings
|
||||
.map((warning) => `comment-checker found issues in ${warning.filePath}:\n${warning.message}`)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function normalizeHookText(value: string): string {
|
||||
return value.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim();
|
||||
}
|
||||
|
||||
function hookFeedbackLimit(transcriptPath: string | null): number {
|
||||
return isContextPressureTranscript(transcriptPath)
|
||||
? CONTEXT_PRESSURE_MAX_HOOK_FEEDBACK_CHARS
|
||||
: DEFAULT_MAX_HOOK_FEEDBACK_CHARS;
|
||||
}
|
||||
|
||||
function isContextPressureTranscript(transcriptPath: string | null): boolean {
|
||||
if (transcriptPath === null) return false;
|
||||
try {
|
||||
return hasContextPressureMarker(readFileSync(transcriptPath, "utf8"));
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function hasContextPressureMarker(text: string): boolean {
|
||||
const normalizedText = text.toLowerCase();
|
||||
return CONTEXT_PRESSURE_MARKERS.some((marker) => normalizedText.includes(marker));
|
||||
}
|
||||
|
||||
function limitHookText(text: string, maxChars: number): string {
|
||||
if (text.length <= maxChars) return text;
|
||||
const marker = `\n\n[Truncated hook output to ${maxChars} chars to avoid Codex context overflow.]`;
|
||||
if (marker.length >= maxChars) return marker.slice(0, maxChars);
|
||||
const head = text.slice(0, maxChars - marker.length).replace(/[ \t\r\n]+$/, "");
|
||||
return `${head}${marker}`;
|
||||
}
|
||||
|
||||
function isCodexPostToolUseInput(value: unknown): value is CodexPostToolUseInput {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
value["hook_event_name"] === "PostToolUse" &&
|
||||
typeof value["session_id"] === "string" &&
|
||||
typeof value["turn_id"] === "string" &&
|
||||
(typeof value["transcript_path"] === "string" || value["transcript_path"] === null) &&
|
||||
typeof value["cwd"] === "string" &&
|
||||
typeof value["model"] === "string" &&
|
||||
typeof value["permission_mode"] === "string" &&
|
||||
typeof value["tool_name"] === "string" &&
|
||||
isRecord(value["tool_input"]) &&
|
||||
typeof value["tool_use_id"] === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function readStdin(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = "";
|
||||
processStdin.setEncoding("utf-8");
|
||||
processStdin.on("data", (chunk: string) => {
|
||||
data += chunk;
|
||||
});
|
||||
processStdin.once("error", reject);
|
||||
processStdin.once("end", () => {
|
||||
resolve(data);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
export type TextContent = {
|
||||
type: "text";
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type ImageContent = {
|
||||
type: "image";
|
||||
data: string;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
export type CheckerToolName = "Write" | "Edit" | "MultiEdit";
|
||||
|
||||
export type CheckerEdit = {
|
||||
old_string: string;
|
||||
new_string: string;
|
||||
};
|
||||
|
||||
export type CheckerToolInput = {
|
||||
file_path: string;
|
||||
content?: string;
|
||||
old_string?: string;
|
||||
new_string?: string;
|
||||
edits?: CheckerEdit[];
|
||||
};
|
||||
|
||||
export type CommentCheckRequest = {
|
||||
sourceToolName: string;
|
||||
toolName: CheckerToolName;
|
||||
filePath: string;
|
||||
toolInput: CheckerToolInput;
|
||||
};
|
||||
|
||||
export type CommentCheckerHookInput = {
|
||||
session_id: string;
|
||||
tool_name: CheckerToolName;
|
||||
transcript_path: string;
|
||||
cwd: string;
|
||||
hook_event_name: "PostToolUse";
|
||||
tool_input: CheckerToolInput;
|
||||
};
|
||||
|
||||
export type ToolResultContent = TextContent | ImageContent;
|
||||
|
||||
export type ToolResultLike = {
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
content?: ToolResultContent[];
|
||||
isError?: boolean;
|
||||
details?: unknown;
|
||||
};
|
||||
|
||||
type ApplyPatchAccumulator = {
|
||||
operation: "add" | "delete" | "update";
|
||||
filePath: string;
|
||||
movePath?: string;
|
||||
oldLines: string[];
|
||||
newLines: string[];
|
||||
};
|
||||
|
||||
type ApplyPatchFileMetadata = {
|
||||
filePath: string;
|
||||
movePath?: string;
|
||||
before: string;
|
||||
after: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export function extractCommentCheckRequests(event: ToolResultLike): CommentCheckRequest[] {
|
||||
if (event.isError) return [];
|
||||
if (isToolFailureOutput(getContentText(event.content))) return [];
|
||||
|
||||
const toolName = event.toolName.toLowerCase();
|
||||
if (toolName === "write") return extractWriteRequest(event);
|
||||
if (toolName === "edit") return extractEditRequest(event);
|
||||
if (toolName === "multiedit" || toolName === "multi_edit") return extractMultiEditRequest(event);
|
||||
if (toolName === "apply_patch") return extractApplyPatchRequests(event);
|
||||
return [];
|
||||
}
|
||||
|
||||
export function toHookInput(
|
||||
request: CommentCheckRequest,
|
||||
context: {
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
transcriptPath?: string;
|
||||
},
|
||||
): CommentCheckerHookInput {
|
||||
return {
|
||||
session_id: context.sessionId,
|
||||
tool_name: request.toolName,
|
||||
transcript_path: context.transcriptPath ?? "",
|
||||
cwd: context.cwd,
|
||||
hook_event_name: "PostToolUse",
|
||||
tool_input: request.toolInput,
|
||||
};
|
||||
}
|
||||
|
||||
export function isToolFailureOutput(text: string): boolean {
|
||||
const lower = text.trim().toLowerCase();
|
||||
return (
|
||||
lower.startsWith("error") ||
|
||||
lower.includes("error:") ||
|
||||
lower.includes("failed to") ||
|
||||
lower.includes("could not")
|
||||
);
|
||||
}
|
||||
|
||||
function extractWriteRequest(event: ToolResultLike): CommentCheckRequest[] {
|
||||
const filePath = getString(event.input, ["filePath", "file_path", "path"]);
|
||||
const content = getString(event.input, ["content"]);
|
||||
if (!filePath || content === undefined) return [];
|
||||
return [
|
||||
{
|
||||
sourceToolName: event.toolName,
|
||||
toolName: "Write",
|
||||
filePath,
|
||||
toolInput: {
|
||||
file_path: filePath,
|
||||
content,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function extractEditRequest(event: ToolResultLike): CommentCheckRequest[] {
|
||||
const filePath = getString(event.input, ["filePath", "file_path", "path"]);
|
||||
const oldString = getString(event.input, ["oldString", "old_string"]);
|
||||
const newString = getString(event.input, ["newString", "new_string"]);
|
||||
if (!filePath || oldString === undefined || newString === undefined) return [];
|
||||
const toolInput: CheckerToolInput = { file_path: filePath };
|
||||
toolInput.old_string = oldString;
|
||||
toolInput.new_string = newString;
|
||||
return [
|
||||
{
|
||||
sourceToolName: event.toolName,
|
||||
toolName: "Edit",
|
||||
filePath,
|
||||
toolInput,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function extractMultiEditRequest(event: ToolResultLike): CommentCheckRequest[] {
|
||||
const filePath = getString(event.input, ["filePath", "file_path", "path"]);
|
||||
const edits = getEdits(event.input["edits"]);
|
||||
if (!filePath || edits.length === 0) return [];
|
||||
return [
|
||||
{
|
||||
sourceToolName: event.toolName,
|
||||
toolName: "MultiEdit",
|
||||
filePath,
|
||||
toolInput: {
|
||||
file_path: filePath,
|
||||
edits,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function extractApplyPatchRequests(event: ToolResultLike): CommentCheckRequest[] {
|
||||
const metadataRequests = extractApplyPatchMetadataRequests(event.details, event.toolName);
|
||||
if (metadataRequests.length > 0) return metadataRequests;
|
||||
|
||||
const patch = getString(event.input, ["input", "patch", "command"]);
|
||||
if (!patch) return [];
|
||||
return parseApplyPatchRequests(patch, event.toolName);
|
||||
}
|
||||
|
||||
function extractApplyPatchMetadataRequests(details: unknown, sourceToolName: string): CommentCheckRequest[] {
|
||||
const metadataFiles = getApplyPatchMetadataFiles(details);
|
||||
if (metadataFiles.length === 0) return [];
|
||||
|
||||
const requests: CommentCheckRequest[] = [];
|
||||
for (const file of metadataFiles) {
|
||||
if (file.type === "delete") continue;
|
||||
const filePath = file.movePath ?? file.filePath;
|
||||
if (file.before.length === 0) {
|
||||
requests.push({
|
||||
sourceToolName,
|
||||
toolName: "Write",
|
||||
filePath,
|
||||
toolInput: {
|
||||
file_path: filePath,
|
||||
content: file.after,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
requests.push({
|
||||
sourceToolName,
|
||||
toolName: "Edit",
|
||||
filePath,
|
||||
toolInput: {
|
||||
file_path: filePath,
|
||||
old_string: file.before,
|
||||
new_string: file.after,
|
||||
},
|
||||
});
|
||||
}
|
||||
return requests;
|
||||
}
|
||||
|
||||
function getApplyPatchMetadataFiles(details: unknown): ApplyPatchFileMetadata[] {
|
||||
if (!isRecord(details)) return [];
|
||||
const direct = readApplyPatchMetadataFiles(details["files"]);
|
||||
if (direct.length > 0) return direct;
|
||||
const resultDetails = details["result"];
|
||||
const result = isRecord(resultDetails) ? readApplyPatchMetadataFiles(resultDetails["files"]) : [];
|
||||
if (result.length > 0) return result;
|
||||
const metadataDetails = details["metadata"];
|
||||
const metadata = isRecord(metadataDetails) ? readApplyPatchMetadataFiles(metadataDetails["files"]) : [];
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function readApplyPatchMetadataFiles(value: unknown): ApplyPatchFileMetadata[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const files: ApplyPatchFileMetadata[] = [];
|
||||
for (const item of value) {
|
||||
if (!isRecord(item)) continue;
|
||||
const filePath = getString(item, ["filePath", "file_path", "path"]);
|
||||
const movePath = getString(item, ["movePath", "move_path"]);
|
||||
const before = getString(item, ["before", "old", "oldString", "old_string"]);
|
||||
const after = getString(item, ["after", "new", "newString", "new_string"]);
|
||||
const type = getString(item, ["type", "operation"]);
|
||||
if (!filePath || before === undefined || after === undefined) continue;
|
||||
files.push({
|
||||
filePath,
|
||||
before,
|
||||
after,
|
||||
...(movePath === undefined ? {} : { movePath }),
|
||||
...(type === undefined ? {} : { type }),
|
||||
});
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export function parseApplyPatchRequests(patch: string, sourceToolName = "apply_patch"): CommentCheckRequest[] {
|
||||
const requests: CommentCheckRequest[] = [];
|
||||
let current: ApplyPatchAccumulator | undefined;
|
||||
|
||||
const flush = (): void => {
|
||||
if (!current) return;
|
||||
if (current.operation === "add") {
|
||||
const content = joinPatchLines(current.newLines);
|
||||
if (content.length > 0) {
|
||||
requests.push({
|
||||
sourceToolName,
|
||||
toolName: "Write",
|
||||
filePath: current.filePath,
|
||||
toolInput: {
|
||||
file_path: current.filePath,
|
||||
content,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
if (current.operation === "update") {
|
||||
const newString = joinPatchLines(current.newLines);
|
||||
if (newString.length > 0) {
|
||||
const filePath = current.movePath ?? current.filePath;
|
||||
requests.push({
|
||||
sourceToolName,
|
||||
toolName: "Edit",
|
||||
filePath,
|
||||
toolInput: {
|
||||
file_path: filePath,
|
||||
old_string: joinPatchLines(current.oldLines),
|
||||
new_string: newString,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
current = undefined;
|
||||
};
|
||||
|
||||
for (const line of patch.split(/\r?\n/)) {
|
||||
if (line === "*** Begin Patch" || line === "*** End Patch") continue;
|
||||
if (line.startsWith("*** Add File: ")) {
|
||||
flush();
|
||||
current = makeAccumulator("add", line.slice("*** Add File: ".length).trim());
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("*** Update File: ")) {
|
||||
flush();
|
||||
current = makeAccumulator("update", line.slice("*** Update File: ".length).trim());
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("*** Delete File: ")) {
|
||||
flush();
|
||||
current = makeAccumulator("delete", line.slice("*** Delete File: ".length).trim());
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("*** Move to: ")) {
|
||||
if (current?.operation === "update") current.movePath = line.slice("*** Move to: ".length).trim();
|
||||
continue;
|
||||
}
|
||||
if (!current) continue;
|
||||
if (line.startsWith("@@")) continue;
|
||||
if (current.operation === "add") {
|
||||
if (line.startsWith("+")) current.newLines.push(line.slice(1));
|
||||
continue;
|
||||
}
|
||||
if (current.operation === "update") {
|
||||
if (line.startsWith("+")) current.newLines.push(line.slice(1));
|
||||
if (line.startsWith("-")) current.oldLines.push(line.slice(1));
|
||||
}
|
||||
}
|
||||
|
||||
flush();
|
||||
return requests;
|
||||
}
|
||||
|
||||
function makeAccumulator(operation: ApplyPatchAccumulator["operation"], filePath: string): ApplyPatchAccumulator {
|
||||
return {
|
||||
operation,
|
||||
filePath,
|
||||
oldLines: [],
|
||||
newLines: [],
|
||||
};
|
||||
}
|
||||
|
||||
function getEdits(value: unknown): CheckerEdit[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const edits: CheckerEdit[] = [];
|
||||
for (const item of value) {
|
||||
if (!isRecord(item)) continue;
|
||||
const oldString = getString(item, ["oldString", "old_string"]);
|
||||
const newString = getString(item, ["newString", "new_string"]);
|
||||
if (oldString === undefined || newString === undefined) continue;
|
||||
edits.push({
|
||||
old_string: oldString,
|
||||
new_string: newString,
|
||||
});
|
||||
}
|
||||
return edits;
|
||||
}
|
||||
|
||||
function getContentText(content: ToolResultContent[] | undefined): string {
|
||||
if (!content) return "";
|
||||
return content
|
||||
.filter((block): block is TextContent => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function getString(input: Record<string, unknown>, keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = input[key];
|
||||
if (typeof value === "string") return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function joinPatchLines(lines: string[]): string {
|
||||
return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import type { CommentCheckerHookInput } from "./core.js";
|
||||
|
||||
export type ProcessResult = {
|
||||
exitCode: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
export const MAX_PROCESS_OUTPUT_BYTES = 64 * 1024;
|
||||
|
||||
export type ProcessExecutor = (command: string, args: string[], stdin: string) => Promise<ProcessResult>;
|
||||
|
||||
export type RunCommentCheckerOptions = {
|
||||
binaryPath?: string;
|
||||
customPrompt?: string;
|
||||
resolveBinary?: () => string | undefined;
|
||||
executor?: ProcessExecutor;
|
||||
};
|
||||
|
||||
export type CommentCheckerRunResult = {
|
||||
status: "pass" | "warning" | "error" | "missing";
|
||||
message: string;
|
||||
binaryPath?: string;
|
||||
exitCode?: number | null;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
};
|
||||
|
||||
export type CommentCheckerRunner = (input: CommentCheckerHookInput) => Promise<CommentCheckerRunResult>;
|
||||
|
||||
export async function runCommentChecker(
|
||||
input: CommentCheckerHookInput,
|
||||
options: RunCommentCheckerOptions = {},
|
||||
): Promise<CommentCheckerRunResult> {
|
||||
const binaryPath =
|
||||
options.binaryPath ?? (options.resolveBinary ? options.resolveBinary() : resolveCommentCheckerBinary());
|
||||
if (!binaryPath) {
|
||||
return {
|
||||
status: "missing",
|
||||
message: "comment-checker binary not found. Run npm install for the codex-comment-checker plugin.",
|
||||
};
|
||||
}
|
||||
|
||||
const args = ["check"];
|
||||
if (options.customPrompt) {
|
||||
args.push("--prompt", options.customPrompt);
|
||||
}
|
||||
|
||||
const executor = options.executor ?? spawnProcess;
|
||||
const result = await executor(binaryPath, args, JSON.stringify(input));
|
||||
const message = result.stderr || result.stdout;
|
||||
if (result.exitCode === 0) {
|
||||
return {
|
||||
status: "pass",
|
||||
message: "",
|
||||
binaryPath,
|
||||
exitCode: result.exitCode,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
};
|
||||
}
|
||||
if (result.exitCode === 2) {
|
||||
return {
|
||||
status: "warning",
|
||||
message,
|
||||
binaryPath,
|
||||
exitCode: result.exitCode,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "error",
|
||||
message,
|
||||
binaryPath,
|
||||
exitCode: result.exitCode,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCommentCheckerBinary(): string | undefined {
|
||||
const binaryName = process.platform === "win32" ? "comment-checker.exe" : "comment-checker";
|
||||
const fromPackageApi = resolvePackageApiBinary();
|
||||
if (fromPackageApi) return fromPackageApi;
|
||||
const fromPackage = resolvePackageBinary(binaryName);
|
||||
if (fromPackage) return fromPackage;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolvePackageApiBinary(): string | undefined {
|
||||
try {
|
||||
const require = createRequire(import.meta.url);
|
||||
const packageExports: unknown = require("@code-yeongyu/comment-checker");
|
||||
if (!isCommentCheckerPackage(packageExports)) return undefined;
|
||||
const binaryPath = packageExports.getBinaryPath();
|
||||
return existsSync(binaryPath) ? binaryPath : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePackageBinary(binaryName: string): string | undefined {
|
||||
try {
|
||||
const require = createRequire(import.meta.url);
|
||||
const packagePath = require.resolve("@code-yeongyu/comment-checker/package.json");
|
||||
const binaryPath = join(dirname(packagePath), "bin", binaryName);
|
||||
return existsSync(binaryPath) ? binaryPath : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isCommentCheckerPackage(value: unknown): value is { getBinaryPath: () => string } {
|
||||
return isRecord(value) && typeof value["getBinaryPath"] === "function";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
interface OutputAccumulator {
|
||||
text: string;
|
||||
bytes: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
function appendOutput(output: OutputAccumulator, chunk: string, maxOutputBytes: number): void {
|
||||
if (output.truncated) return;
|
||||
|
||||
const remainingBytes = maxOutputBytes - output.bytes;
|
||||
const chunkBytes = Buffer.byteLength(chunk, "utf8");
|
||||
if (chunkBytes <= remainingBytes) {
|
||||
output.text += chunk;
|
||||
output.bytes += chunkBytes;
|
||||
return;
|
||||
}
|
||||
|
||||
if (remainingBytes > 0) {
|
||||
output.text += Buffer.from(chunk, "utf8").subarray(0, remainingBytes).toString("utf8");
|
||||
output.bytes += remainingBytes;
|
||||
}
|
||||
output.truncated = true;
|
||||
}
|
||||
|
||||
function formatOutput(output: OutputAccumulator, streamName: "stdout" | "stderr", maxOutputBytes: number): string {
|
||||
if (!output.truncated) return output.text;
|
||||
return `${output.text}\n[${streamName} truncated after ${maxOutputBytes} bytes]`;
|
||||
}
|
||||
|
||||
export function spawnProcess(
|
||||
command: string,
|
||||
args: string[],
|
||||
stdin: string,
|
||||
maxOutputBytes: number = MAX_PROCESS_OUTPUT_BYTES,
|
||||
): Promise<ProcessResult> {
|
||||
return new Promise((resolve) => {
|
||||
const outputByteLimit = Number.isFinite(maxOutputBytes) && maxOutputBytes > 0 ? Math.floor(maxOutputBytes) : 0;
|
||||
const proc = spawn(command, args, {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
const stdout: OutputAccumulator = { text: "", bytes: 0, truncated: false };
|
||||
const stderr: OutputAccumulator = { text: "", bytes: 0, truncated: false };
|
||||
|
||||
proc.stdout.setEncoding("utf-8");
|
||||
proc.stderr.setEncoding("utf-8");
|
||||
proc.stdout.on("data", (chunk: string) => {
|
||||
appendOutput(stdout, chunk, outputByteLimit);
|
||||
});
|
||||
proc.stderr.on("data", (chunk: string) => {
|
||||
appendOutput(stderr, chunk, outputByteLimit);
|
||||
});
|
||||
proc.once("error", (error) => {
|
||||
appendOutput(stderr, error.message, outputByteLimit);
|
||||
resolve({
|
||||
exitCode: null,
|
||||
stdout: formatOutput(stdout, "stdout", outputByteLimit),
|
||||
stderr: formatOutput(stderr, "stderr", outputByteLimit),
|
||||
});
|
||||
});
|
||||
proc.once("close", (exitCode) => {
|
||||
resolve({
|
||||
exitCode,
|
||||
stdout: formatOutput(stdout, "stdout", outputByteLimit),
|
||||
stderr: formatOutput(stderr, "stderr", outputByteLimit),
|
||||
});
|
||||
});
|
||||
proc.stdin.end(stdin);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { type CodexPostToolUseInput, runCommentCheckerPostToolUse } from "../src/codex-hook.ts";
|
||||
|
||||
function postToolUseInput(): 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",
|
||||
};
|
||||
}
|
||||
|
||||
describe("comment-checker hook newline rendering", () => {
|
||||
it("#given checker warning with CRLF and bare CR #when hook runs #then returns normalized blocking feedback JSON", async () => {
|
||||
// given
|
||||
const output = await runCommentCheckerPostToolUse(postToolUseInput(), {
|
||||
run: async () => ({
|
||||
status: "warning",
|
||||
message: "\r\nfirst warning line\r\n indented detail\rthird warning line\r\n",
|
||||
}),
|
||||
});
|
||||
|
||||
// when
|
||||
const parsed: unknown = JSON.parse(output);
|
||||
|
||||
// then
|
||||
expect(parsed).toEqual({
|
||||
decision: "block",
|
||||
reason:
|
||||
"comment-checker found issues in src/example.ts:\nfirst warning line\n indented detail\nthird warning line",
|
||||
});
|
||||
expect(output).not.toContain("\r");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, 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 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));
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const tempDir of tempDirs.splice(0)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function runHookCli(input: string): Promise<CliResult> {
|
||||
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> = {}): 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("");
|
||||
});
|
||||
|
||||
it("#given Codex canonical context-window transcript and long checker warning #when hook blocks #then it caps feedback", async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "codex-comment-checker-context-pressure-"));
|
||||
tempDirs.push(root);
|
||||
const transcriptPath = path.join(root, "transcript.jsonl");
|
||||
writeFileSync(
|
||||
transcriptPath,
|
||||
[
|
||||
"context_length_exceeded",
|
||||
"Codex ran out of room in the model's context window. Start a new thread before retrying.",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const output = await runCommentCheckerPostToolUse(postToolUseInput({ transcript_path: transcriptPath }), {
|
||||
run: async () => ({
|
||||
status: "warning",
|
||||
message: `comment warning: explain less\n${"x".repeat(10_000)}`,
|
||||
}),
|
||||
});
|
||||
|
||||
const parsed: unknown = JSON.parse(output);
|
||||
if (!isBlockingOutput(parsed)) throw new TypeError("Expected blocking output");
|
||||
|
||||
expect(parsed.reason.length).toBeLessThanOrEqual(1200);
|
||||
expect(parsed.reason).toContain("comment-checker found issues in src/example.ts");
|
||||
expect(parsed.reason).toContain("[Truncated hook output");
|
||||
});
|
||||
});
|
||||
|
||||
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: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
interface BlockingOutput {
|
||||
readonly decision: "block";
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
function isBlockingOutput(value: unknown): value is BlockingOutput {
|
||||
return isRecord(value) && value["decision"] === "block" && typeof value["reason"] === "string";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type PackageJson = {
|
||||
readonly type: string;
|
||||
readonly packageManager: string;
|
||||
readonly bin: Record<string, string>;
|
||||
readonly dependencies?: Record<string, unknown>;
|
||||
readonly optionalDependencies: Record<string, string>;
|
||||
};
|
||||
|
||||
type HookCommand = {
|
||||
readonly command: string;
|
||||
};
|
||||
|
||||
type HookEntry = {
|
||||
readonly hooks: readonly HookCommand[];
|
||||
};
|
||||
|
||||
type HooksJson = {
|
||||
readonly hooks: Record<string, readonly HookEntry[]>;
|
||||
};
|
||||
|
||||
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 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 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["omo-comment-checker"]).toBe("./dist/cli.js");
|
||||
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 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<string, string> {
|
||||
return isRecord(value) && Object.values(value).every((item) => typeof item === "string");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": false,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"noEmit": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["test/**/*"]
|
||||
}
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["test/**/*.test.ts"],
|
||||
environment: "node",
|
||||
pool: "threads",
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
* @code-yeongyu
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
## Summary
|
||||
|
||||
-
|
||||
|
||||
## Validation
|
||||
|
||||
-
|
||||
|
||||
## Notes
|
||||
|
||||
-
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.env
|
||||
.DS_Store
|
||||
coverage/
|
||||
.vitest/
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"lsp": {
|
||||
"command": "node",
|
||||
"args": ["../../../../lsp-tools-mcp/dist/cli.js", "mcp"],
|
||||
"cwd": "."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Reuse the repository-level `packages/lsp-tools-mcp` package instead of carrying a second copy under `components/lsp/packages`.
|
||||
|
||||
## 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.
|
||||
@@ -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.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user