diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 00000000..ebb94755 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "ralphex", + "interface": { + "displayName": "Ralphex" + }, + "plugins": [ + { + "name": "ralphex", + "source": { + "source": "local", + "path": "./plugins/ralphex" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Developer Tools" + } + ] +} diff --git a/.claude-plugin/README.md b/.claude-plugin/README.md index cfdd554c..813e619f 100644 --- a/.claude-plugin/README.md +++ b/.claude-plugin/README.md @@ -18,7 +18,7 @@ Users can install via the plugin marketplace: ## Versioning -The `version` field in both JSON files is automatically updated during releases by `scripts/internal/update-plugin-version.sh`, triggered by goreleaser. +The plugin version is independent from the ralphex CLI version. When distributed skill payload changes, maintainers bump all Claude, Codex, and portable manifests together with `make bump-plugin-version VERSION=` before merging. Releases do not mutate plugin manifests. ## Marketplace Structure diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a29c1306..287dad8e 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "ralphex", "source": "./", "description": "Autonomous plan execution with Claude Code - task execution, monitoring, and plan creation", - "version": "0.20.0", + "version": "0.20.2", "author": { "name": "umputun" } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 47d24cb7..55700aad 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ralphex", - "version": "0.20.0", + "version": "0.20.2", "description": "Autonomous plan execution with Claude Code - task execution, monitoring, and plan creation", "author": { "name": "umputun", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 484e5361..8e9c087c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ jobs: - name: checkout uses: actions/checkout@v7 with: + fetch-depth: 0 persist-credentials: false - name: set up go 1.26 @@ -25,6 +26,16 @@ jobs: go-version: "1.26" id: go + - name: set up uv + uses: astral-sh/setup-uv@v9.0.0 + with: + version: "0.12.3" + + - name: validate portable plugin and version policy + run: make test-plugin PLUGIN_VERSION_BASE="$PLUGIN_VERSION_BASE" + env: + PLUGIN_VERSION_BASE: ${{ github.event.pull_request.base.sha || github.event.before }} + - name: build and test run: | go test -race -timeout=100s -covermode=atomic -coverprofile=$GITHUB_WORKSPACE/profile.cov_tmp ./... diff --git a/.goreleaser.yml b/.goreleaser.yml index 5082c42e..f138f3e1 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,9 +1,5 @@ version: 2 -before: - hooks: - - ./scripts/internal/update-plugin-version.sh {{ .Tag }} - builds: - id: ralphex main: ./cmd/ralphex diff --git a/CLAUDE.md b/CLAUDE.md index 4f667892..d0debdd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,13 +4,14 @@ Autonomous plan execution with Claude Code - Go rewrite of ralph.py. ## LLM Documentation -See @llms.txt for usage instructions and Claude Code integration commands. +See @llms.txt for usage instructions and Claude Code/Codex integration commands. ## Build Commands ```bash make build # build binary to .bin/ralphex make test # run tests with coverage +make test-plugin # validate portable Codex plugin (requires uv; resolves PyYAML) make lint # run golangci-lint make fmt # format code ``` @@ -33,6 +34,7 @@ pkg/config/ # configuration loading, defaults, prompts, agents pkg/executor/ # claude and codex CLI execution pkg/git/ # git operations (external git CLI) pkg/input/ # terminal input collector (fzf/fallback, draft review) +plugins/ralphex/ # portable Codex plugin and workflow skills pkg/notify/ # notification delivery (telegram, email, slack, webhook, custom) pkg/plan/ # plan file selection, parsing, and manipulation pkg/processor/ # pipeline coordinator, prompt rendering, executor policy, signal wrappers @@ -508,7 +510,7 @@ If you're an AI agent preparing a contribution, complete this checklist: ## Workflow Rules -- **Plugin version**: bump `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json` versions on release if skill files (`assets/claude/`) changed since last plugin version bump +- **Plugin version**: bump `.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`, `plugins/ralphex/.codex-plugin/plugin.json`, and `plugins/ralphex/plugin.json` on release if skill files under `assets/claude/` or `plugins/ralphex/skills/` changed since the last plugin version bump - **CHANGELOG**: Never modify during development - updates are part of release process only - **Version sections**: Never add entries to existing version sections - versions are immutable once released - **Linter warnings**: Add exclusions to `.golangci.yml` instead of `_, _ =` prefixes for fmt.Fprintf/Fprintln diff --git a/Makefile b/Makefile index 8f4aa2a8..93fde333 100644 --- a/Makefile +++ b/Makefile @@ -19,6 +19,18 @@ test: go tool cover -func=coverage_no_mocks.out rm coverage.out coverage_no_mocks.out +test-plugin: + uv run --with pyyaml==6.0.3 python scripts/validate-portable-plugin.py + uv run --with pyyaml==6.0.3 python scripts/validate-portable-plugin_test.py + python3 scripts/validate-skill-contracts_test.py + python3 scripts/internal/check-plugin-version_test.py + ./scripts/internal/update-plugin-version_test.sh + python3 scripts/internal/check-plugin-version.py --base "$(PLUGIN_VERSION_BASE)" + +bump-plugin-version: + @test -n "$(VERSION)" || (echo "Usage: make bump-plugin-version VERSION=" >&2; exit 1) + ./scripts/internal/update-plugin-version.sh "$(VERSION)" + lint: golangci-lint run --max-issues-per-linter=0 --max-same-issues=0 @@ -102,4 +114,4 @@ docker-build-go: docker-build docker-run: ./scripts/ralphex-dk.sh $(ARGS) -.PHONY: all build test lint fmt race version e2e-setup e2e e2e-ui e2e-prep e2e-review e2e-codex prep_site docker-build docker-build-go docker-run +.PHONY: all build test test-plugin bump-plugin-version lint fmt race version e2e-setup e2e e2e-ui e2e-prep e2e-review e2e-codex prep_site docker-build docker-build-go docker-run diff --git a/README.md b/README.md index cb7a2d8d..e0f0fc95 100644 --- a/README.md +++ b/README.md @@ -1434,6 +1434,22 @@ The `/ralphex` command runs ralphex in the background and provides status update > **Note:** ralphex automatically strips the `CLAUDECODE` env var from child processes, allowing it to run from inside Claude Code. However, running from a standalone terminal is still recommended for the best experience. If the nested session error is somehow encountered, ralphex detects it via error pattern matching and exits gracefully. +## Codex Integration (Optional) + +Ralphex also provides a skills-only Codex plugin. The standalone CLI behavior is unchanged. + +```bash +codex plugin marketplace add umputun/ralphex +codex plugin add ralphex@ralphex +``` + +After installation, start a new Codex conversation and use `/skills` to discover the four workflows: + +- `$ralphex` launches and monitors an existing plan. +- `$ralphex-plan` creates a structured implementation plan. +- `$ralphex-adopt` converts an existing specification or task list into a Ralphex plan. +- `$ralphex-update` merges updated defaults into customized Ralphex configuration. + ## For LLMs See [llms.txt](llms.txt) for LLM-optimized documentation. diff --git a/assets/claude/skills/ralphex-adopt/SKILL.md b/assets/claude/skills/ralphex-adopt/SKILL.md index 7b1581de..cfcc1a09 100644 --- a/assets/claude/skills/ralphex-adopt/SKILL.md +++ b/assets/claude/skills/ralphex-adopt/SKILL.md @@ -1,6 +1,7 @@ --- +name: ralphex-adopt description: Convert plans from various source formats (OpenSpec, spec-kit, GitHub/GitLab issues with checklists, generic task-lists, free-form markdown) into ralphex-format plans in docs/plans/. Triggers on "ralphex-adopt", "adopt plan", "convert plan to ralphex", "import plan as ralphex". -allowed-tools: [Bash, Read, Write, Glob, Grep, AskUserQuestion] +allowed-tools: Bash Read Write Glob Grep AskUserQuestion --- # ralphex-adopt - Convert Plans Into ralphex Format diff --git a/assets/claude/skills/ralphex-plan/SKILL.md b/assets/claude/skills/ralphex-plan/SKILL.md index d745a32c..941c7026 100644 --- a/assets/claude/skills/ralphex-plan/SKILL.md +++ b/assets/claude/skills/ralphex-plan/SKILL.md @@ -1,4 +1,5 @@ --- +name: ralphex-plan description: Create structured implementation plan in docs/plans/ --- diff --git a/assets/claude/skills/ralphex-update/SKILL.md b/assets/claude/skills/ralphex-update/SKILL.md index 4ad9bc2b..ba156320 100644 --- a/assets/claude/skills/ralphex-update/SKILL.md +++ b/assets/claude/skills/ralphex-update/SKILL.md @@ -1,6 +1,7 @@ --- +name: ralphex-update description: Smart-merge updated ralphex defaults into customized prompts/agents -allowed-tools: [Bash, Read, Write, Glob, AskUserQuestion] +allowed-tools: Bash Read Write Glob AskUserQuestion --- # ralphex-update - Smart Prompt Merging diff --git a/assets/claude/skills/ralphex/SKILL.md b/assets/claude/skills/ralphex/SKILL.md index db34ee4f..e95c6b80 100644 --- a/assets/claude/skills/ralphex/SKILL.md +++ b/assets/claude/skills/ralphex/SKILL.md @@ -1,7 +1,8 @@ --- +name: ralphex description: Run ralphex autonomous plan execution with progress monitoring argument-hint: 'optional plan file path' -allowed-tools: [Bash, Read, AskUserQuestion, TaskOutput, Glob] +allowed-tools: Bash Read AskUserQuestion TaskOutput Glob --- # ralphex - Autonomous Plan Execution @@ -27,25 +28,44 @@ Use AskUserQuestion to confirm installation method, then guide through it. **Do ## Step 1: Check for Plan Argument Check `$ARGUMENTS` for optional plan file path: -- if argument provided: validate file exists using Read tool, skip plan selection in Step 3 -- if no argument: will ask for plan selection in Step 3 +- if argument provided: validate file exists using Read tool, skip plan selection in Step 4 +- if no argument: will ask for plan selection in Step 4 -## Step 2: Ask Execution Mode +Treat the selected plan as a path, never as an option. If its text begins with `-`, resolve it to an absolute path with safely quoted Bash and validate that exact resolved file again. If safe normalization is unavailable or ambiguous, reject the path and ask for an explicit `./...` or absolute path. + +## Step 2: Ask Executor + +Use AskUserQuestion: +- header: "Executor" +- question: "Which executor should ralphex use?" +- options: + - label: "Configured (Recommended)" + description: "Use ralphex's effective config; Claude Code is the default when executor is unset" + - label: "Codex" + description: "Add --codex; codex runs tasks, internal reviews, and finalize while external review is skipped" + +`--codex` is the first-class Codex executor flag. It is not the same as the deprecated `--codex-only` alias for `--external-only`. Never combine `--codex` with `--external-only` or `--codex-only`. + +## Step 3: Ask Execution Mode Use AskUserQuestion: - header: "Mode" - question: "Which execution mode should ralphex use?" - options: - - label: "Full (Recommended)" - description: "Task execution + Claude review + Codex loop + final Claude review" - - label: "Review" - description: "Skip tasks, run full review pipeline (Claude + Codex + Claude)" - - label: "Codex-only" - description: "Skip tasks and first Claude review, run only Codex loop" + - label: "Full pipeline (Recommended)" + description: "Run tasks, internal reviews, and finalize; configured external review runs only with a non-Codex executor" + - label: "Review pipeline" + description: "Add --review; review current-branch changes and allow agents to fix and commit findings" + - label: "External review" + description: "Add --external-only; skip tasks and first internal review, then fix findings and run the post-external review" + +If Codex executor was selected, do not offer "External review" because first-class `--codex` skips that phase and the flags are incompatible. Offer only "Full pipeline" and "Review pipeline". + +If "Configured" executor and "External review" are selected, inspect the effective ralphex config before proceeding. Respect `RALPHEX_CONFIG_DIR` when set and local `.ralphex/config` overrides. If the effective config contains `executor = codex`, do not launch an incompatible command; ask the user to choose Full/Review with Codex or change the config explicitly. -## Step 3: Plan Selection (if no argument provided) +## Step 4: Plan Selection (if no argument provided) -**If Full mode selected:** +**If Full pipeline selected:** - Use Glob: `docs/plans/*.md` (excludes completed/) - Plan is REQUIRED - **IMPORTANT**: Glob returns oldest-first, so REVERSE the list to get most recent first @@ -53,7 +73,7 @@ Use AskUserQuestion: - First option (most recent) should have "(Recommended)" suffix - User MUST select one -**If Review or Codex-only mode selected:** +**If Review pipeline or External review mode selected:** - Use Glob: `docs/plans/**/*.md` (includes completed/ for context) - Plan is OPTIONAL - **IMPORTANT**: Glob returns oldest-first, so REVERSE the list to get most recent first @@ -62,7 +82,7 @@ Use AskUserQuestion: - "None" option description: "Review existing changes without a plan file" - If user selects "None", run without plan file -## Step 4: Ask Max Iterations +## Step 5: Ask Max Iterations Use AskUserQuestion: - header: "Iterations" @@ -75,36 +95,74 @@ Use AskUserQuestion: - label: "100" description: "Large plans with many tasks" -## Step 5: Launch ralphex in Background +## Step 6: Fail-Closed Launch Preflight + +### Repository-local executable overrides (every mode) + +Read `.ralphex/config` directly when it exists. Reject the launch if the file is unreadable, malformed, changes while being inspected, or contains any active non-empty assignment for: + +- `claude_command` +- `codex_command` +- `custom_review_script` +- `vcs_command` + +These values select executables or scripts that the background run would invoke. Do not offer a proceed/override choice. Report the blocking keys and values, then stop. If the file passes, record its content hash (or an equivalent exact-content snapshot); if it is absent, record that exact absence. Use this baseline for the immediate pre-launch revalidation in Step 7. + +### Review checkout (Review pipeline and External review only) + +Both review modes operate on the current checkout. Their review agents can edit files and create commits while fixing findings. Before any ralphex process starts: + +1. Resolve the named current branch with `git symbolic-ref --quiet --short HEAD`. Detached HEAD or an unresolved/empty branch is a hard failure. +2. Require `git status --porcelain=v1` to be empty, including staged, tracked, and untracked changes. +3. Resolve the exact base ref ralphex will use: repo-local `default_branch`, then global ralphex config, then the repository's remote/default-branch evidence. If the effective base is missing, conflicting, or ambiguous, stop. +4. Verify the base resolves to a commit and the current named branch is not that default branch. +5. Require a non-empty committed `base...HEAD` file diff. An uncommitted diff, an ahead commit with no file delta, or an empty diff does not qualify. +6. On any failure, report the observed branch, base, status, and diff condition, then stop. Do not offer "Proceed anyway", switch branches, stash, commit, or modify the checkout. +7. Record the exact branch, resolved base commit, clean-status result, and committed-diff evidence for the immediate pre-launch revalidation in Step 7. + +## Step 7: Launch ralphex in Background Build and run the command: ```bash ralphex \ - [--review] # if user selected "Review" mode - [--codex-only] # if user selected "Codex-only" mode + [--codex] # only if user selected Codex executor + [--review] # only if user selected Review pipeline + [--external-only] # only if user selected External review [--max-iterations N] # from user selection (25, 50, or 100) - [plan-file] # from argument OR plan selection (omit if "None" selected) + -- '' # append both only when a plan was selected ``` -Run using Bash tool with `run_in_background: true`. **Save the task_id from the response** - needed for status checks later. +The executor and mode flags are alternatives; include only the flags selected above. `--` must immediately precede the positional plan path. Omit both `--` and the plan path when no plan was selected. POSIX-single-quote the normalized plan path, escaping every embedded single quote with the shell sequence `'"'"'`. Never concatenate an unquoted path or use `eval`. **Determine progress filename** based on mode and plan selection: - Full mode + plan: `.ralphex/progress/progress-{plan-stem}.txt` - Review mode + plan: `.ralphex/progress/progress-{plan-stem}-review.txt` -- Codex-only + plan: `.ralphex/progress/progress-{plan-stem}-codex.txt` +- External review + plan: `.ralphex/progress/progress-{plan-stem}-codex.txt` - Full mode + no plan: `.ralphex/progress/progress.txt` - Review mode + no plan: `.ralphex/progress/progress-review.txt` -- Codex-only + no plan: `.ralphex/progress/progress-codex.txt` +- External review + no plan: `.ralphex/progress/progress-codex.txt` Where `{plan-stem}` is the plan filename without extension (e.g., `fix-bugs` from `fix-bugs.md`). -## Step 6: Confirm Launch +Before launch, record whether this progress file exists and capture its current content hash or equivalent file identity/size evidence. Also record the launch time. + +Immediately before spawning the process, repeat every applicable Step 6 check: + +1. Re-read `.ralphex/config`; require the same safe content/hash and no executable-bearing override. +2. For Review pipeline or External review, require the same named branch and resolved base commit, a still-clean status, and a still-non-empty committed `base...HEAD` diff. +3. If anything changed or cannot be revalidated, stop without launching. Do not reuse the earlier result. + +Only after this second gate passes, run using Bash with `run_in_background: true`. **Save the task_id from the response** - needed for status checks later. + +## Step 8: Confirm Launch 1. Wait 10-15 seconds for initialization -2. Read last 20 lines of progress file: `tail -20 [progress-filename]` -3. Confirm ralphex started by checking for "Plan:", "Branch:", "Started:" lines -4. Report launch confirmation: +2. Use TaskOutput with `block: false` to read process liveness or completed exit status. +3. Verify the progress file was created or changed after the recorded launch baseline. Read the new/current last 20 lines with safely quoted Bash: `tail -n 20 -- ''`. +4. Confirm a live launch only when TaskOutput reports it running and the current launch produced fresh progress evidence. Existing `Plan:`, `Branch:`, or `Started:` headers alone are not proof. +5. If it exited non-zero, report launch failure with the exit code and fresh progress tail. If it exited zero before confirmation, report that it already completed rather than saying it is running. If process state or fresh progress cannot be verified, report launch as unconfirmed. +6. For a live confirmed task, report: ``` ralphex started. Task ID: [task_id] @@ -115,8 +173,8 @@ Mode: [mode from progress file] Progress file: [progress-filename] Manual monitoring: - tail -f [progress-filename] # live stream - tail -50 [progress-filename] # recent activity + tail -f -- '' # live stream + tail -n 50 -- '' # recent activity ralphex runs autonomously (can take hours). Process continues if you close this conversation. Ask "check ralphex" to get status update. @@ -124,12 +182,12 @@ Ask "check ralphex" to get status update. **STOP HERE after reporting launch status. Do not continue monitoring automatically.** -## Step 7: Progress Check (only on explicit user request) +## Step 9: Progress Check (only on explicit user request) If user explicitly asks "check ralphex", "ralphex status", or "how is ralphex doing": -1. Use TaskOutput tool with `block: false` to check process status (use task_id from Step 5) -2. Read last 40 lines of progress file (use filename from Step 5) +1. Use TaskOutput tool with `block: false` to check process status (use task_id from Step 7) +2. Read last 40 lines of progress file (use filename from Step 7) **If process still running:** - Report current phase from progress file: diff --git a/llms.txt b/llms.txt index b8836be7..5113f698 100644 --- a/llms.txt +++ b/llms.txt @@ -16,6 +16,17 @@ brew install umputun/apps/ralphex # from releases: https://github.com/umputun/ralphex/releases ``` +### Codex Plugin (Optional) + +The repository also provides a skills-only Codex plugin; it does not change the standalone CLI: + +```bash +codex plugin marketplace add umputun/ralphex +codex plugin add ralphex@ralphex +``` + +Start a new Codex conversation after installation. Use `/skills` to discover `$ralphex`, `$ralphex-plan`, `$ralphex-adopt`, and `$ralphex-update`. These skills are user-invoked and do not activate implicitly. + ## Quick Usage ```bash @@ -381,15 +392,23 @@ When a user asks about autonomous plan execution, implementing features with Cla go install github.com/umputun/ralphex/cmd/ralphex@latest ``` -3. **Check if Claude Code plugin is installed**: Look for ralphex skills with `/plugin` command +3. **Identify the current client and check its optional plugin**: + - **Codex**: Use `/skills` to look for `$ralphex`, `$ralphex-plan`, `$ralphex-adopt`, and `$ralphex-update` + - **Claude Code**: Use `/plugin` to look for the ralphex plugin -4. **If plugin not installed, offer installation**: - - **Recommended**: Add marketplace and install plugin: +4. **If the current client's plugin is not installed, offer the matching installation**: + - **Codex**: + ```bash + codex plugin marketplace add umputun/ralphex + codex plugin add ralphex@ralphex + ``` + Start a new Codex conversation after installation. + - **Claude Code**: ```bash /plugin marketplace add umputun/ralphex /plugin install ralphex@ralphex ``` - - **Alternative**: Manual installation by fetching from URLs (see README) + - **Alternative for Claude Code**: Manual installation by fetching from URLs (see README) 5. **Explain primary usage**: ralphex is a CLI tool - run it directly from terminal: ```bash @@ -398,9 +417,9 @@ When a user asks about autonomous plan execution, implementing features with Cla ralphex --plan "add health endpoint" # interactive plan creation ``` -6. **Claude Code skills are optional**: If user wants convenience commands: - - Check if plugin installed: `/plugin` and look for ralphex - - If not installed and user wants it, offer plugin installation (see step 4) - - With skills: `/ralphex-plan` creates plans, `/ralphex-adopt` converts existing plans into ralphex format, `/ralphex` launches execution, "check ralphex" views progress +6. **Client skills are optional**: If the user wants convenience workflows: + - Codex uses `$ralphex-plan`, `$ralphex-adopt`, `$ralphex-update`, and `$ralphex`; these are user-invoked and do not activate implicitly + - Claude Code uses `/ralphex-plan`, `/ralphex-adopt`, `/ralphex-update`, and `/ralphex`; "check ralphex" views progress + - If the matching plugin is absent and the user wants it, offer the client-specific installation from step 4 7. **Key point**: The CLI is primary - skills are optional convenience wrappers diff --git a/plugins/ralphex/.codex-plugin/plugin.json b/plugins/ralphex/.codex-plugin/plugin.json new file mode 100644 index 00000000..214e9504 --- /dev/null +++ b/plugins/ralphex/.codex-plugin/plugin.json @@ -0,0 +1,38 @@ +{ + "name": "ralphex", + "version": "0.20.2", + "description": "Plan-driven implementation, review, adoption, and configuration workflows for Ralphex", + "author": { + "name": "umputun", + "email": "umputun@gmail.com", + "url": "https://github.com/umputun" + }, + "homepage": "https://ralphex.com/docs/#codex-integration-optional", + "repository": "https://github.com/umputun/ralphex", + "license": "MIT", + "keywords": [ + "automation", + "planning", + "code-review", + "task-execution" + ], + "skills": "./skills/", + "interface": { + "displayName": "Ralphex", + "shortDescription": "Run and maintain Ralphex workflows", + "longDescription": "Create or adopt executable plans, launch autonomous Ralphex runs, monitor progress, and merge updated defaults from Codex.", + "developerName": "umputun", + "category": "Developer Tools", + "capabilities": [ + "Interactive", + "Write" + ], + "websiteURL": "https://ralphex.com", + "defaultPrompt": [ + "Use $ralphex-plan to create an executable implementation plan.", + "Use $ralphex to launch an existing plan.", + "Use $ralphex-adopt to convert an existing specification." + ], + "brandColor": "#D97706" + } +} diff --git a/plugins/ralphex/plugin.json b/plugins/ralphex/plugin.json new file mode 100644 index 00000000..b73f4ea7 --- /dev/null +++ b/plugins/ralphex/plugin.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "ralphex", + "version": "0.20.2", + "description": "Plan-driven implementation, review, adoption, and configuration workflows for Ralphex", + "author": { "name": "umputun", "email": "umputun@gmail.com", "url": "https://github.com/umputun" }, + "homepage": "https://ralphex.com/docs/#codex-integration-optional", + "repository": "https://github.com/umputun/ralphex", + "license": "MIT", + "keywords": ["automation", "planning", "code-review", "task-execution"] +} diff --git a/plugins/ralphex/skills/ralphex-adopt/SKILL.md b/plugins/ralphex/skills/ralphex-adopt/SKILL.md new file mode 100644 index 00000000..73e46fb5 --- /dev/null +++ b/plugins/ralphex/skills/ralphex-adopt/SKILL.md @@ -0,0 +1,366 @@ +--- +name: ralphex-adopt +description: Convert plans from OpenSpec, spec-kit, GitHub or GitLab issues, generic task lists, or free-form Markdown into a reviewed Ralphex plan without modifying the source. Use for "ralphex-adopt", "adopt plan", "convert plan to ralphex", or "import plan as ralphex". +--- + +# ralphex-adopt - Convert Plans Into ralphex Format + +## Interactive Choice Contract + +For every choice below, try Codex's native interactive question tool first. If the tool is unavailable, errors, or does not block for an answer, ask the same question with the same options in chat, end the turn, and wait for the user's reply. Never infer or select a default on the user's behalf. + +**SCOPE**: Read a source plan in some other format and produce a new ralphex-format plan at `docs/plans/YYYYMMDD-.md`. The source is never modified. Existing target files are never silently overwritten. + +Supported source shapes: + +- **OpenSpec change**: directory containing `proposal.md`, `tasks.md`, optional `specs/**/spec.md` +- **spec-kit spec**: directory or file with spec/plan/tasks separation +- **GitHub or GitLab issue**: URL, `#N`, or `owner/repo#N` with body that contains a task checklist +- **Generic task-list**: any structured markdown/text with headings and bullet items +- **Free-form markdown**: prose brain dump with no fixed structure + +This is a single-skill conversion: discover, classify, ask focused questions when in doubt, draft, review, write. Do not modify code, do not run tests, do not commit. Output is the new plan file only. + +## Step 0: Optional CLI Check + +This check is **informational only**. Missing ralphex CLI must NOT break the flow — conversion does not require it. Do NOT block, exit, prompt the user, or wait for installation. Always continue to Step 1 regardless of the result. + +```bash +which ralphex +``` + +If `which ralphex` returns non-zero, briefly mention that ralphex is needed to execute the converted plan later (not now), list install options once, and continue immediately: + +- **macOS (Homebrew)**: `brew install umputun/apps/ralphex` +- **Linux (Debian/Ubuntu)**: download `.deb` from https://github.com/umputun/ralphex/releases +- **Linux (RHEL/Fedora)**: download `.rpm` from https://github.com/umputun/ralphex/releases +- **Any platform with Go**: `go install github.com/umputun/ralphex/cmd/ralphex@latest` + +If `which ralphex` succeeds, say nothing and proceed. + +## Step 1: Resolve Source From Argument Shape + +Inspect the skill invocation argument and pick exactly one source by shape, in this order: + +1. **Full URL** (starts with `http://` or `https://`): + - GitHub issue/PR URL → pass the URL as one argv item to `gh issue view --json title,body,labels` (or `gh pr view`) + - GitLab issue/MR URL → pass the URL as one argv item to `glab issue view ` (or `glab mr view`) + - Other URL → fetch with argv equivalent to `curl -fsSL -- ` only if it points at a raw markdown file; otherwise ask the user to paste the body + +2. **Bare reference** `#N`: + - Use the current git repository's host. Detect with `git remote get-url origin` and choose `gh` or `glab` accordingly. + - If `git remote get-url origin` fails (not a git repo, or no `origin` remote), use the native interactive question tool to disambiguate: "GitHub", "GitLab", "Provide qualified `owner/repo#N` instead", or "Cancel". If all four choices cannot fit in one Codex question, ask sequential questions so every choice remains available. Re-resolve based on the answer. + - GitHub: `gh issue view N --json title,body` (try `gh pr view N` if issue not found) + - GitLab: `glab issue view N` (try `glab mr view N` if not found) + +3. **Qualified reference** `owner/repo#N` or `group/project#N`: + - GitHub: pass both values as opaque argv items equivalent to `gh issue view N --repo owner/repo` + - GitLab: pass both values as opaque argv items equivalent to `glab issue view N --repo group/project` + +4. **Existing path** — first probe the literal invocation argument as one opaque filesystem-path argument (shell fallback: `test -e ''`): + - **File**: read the exact file directly + - **Directory**: list with argv equivalent to `ls -la -- ` and inspect contents + - If contains `proposal.md` AND `tasks.md` → likely OpenSpec, proceed to Step 2 + - If contains a single `*.md` → use that file + - Otherwise ask the user which file inside the directory is the plan + +5. **Bare name** — only if the argument failed every check above (not a URL, not `#N` or `owner/repo#N`, and `test -e` returned false). A bare name has no path separators and contains no path-like characters: + - Search the filesystem for plausible matches (e.g., `**/**.md`, `**/**/proposal.md`) + - If exactly one match → use it + - If multiple matches → ask the user to pick one (use the native interactive question tool with up to 4 most relevant; if four choices cannot fit in one question, split them across sequential questions; if more, summarize and ask the user to paste the path) + - If no matches → ask the user whether they meant a path, an issue number, or something else + +6. **No argument**: + - Use the native interactive question tool: "Where is the source plan?" with options "Paste it", "Provide a file path", "Provide an issue number/URL", and "Cancel". Ask sequential questions if needed so all four choices remain available. + +After resolving, store: source kind (`github-issue`, `gitlab-issue`, `file`, `directory`, `pasted`), source content (full text or directory listing + key files), and source identifier for the slug suggestion. + +### Argument Safety + +Treat every repository/user-controlled URL, repository name, reference, source path, draft path, and target path as opaque data. Prefer process tools that accept an argv array. Never interpolate these values into a shell command, never use `eval`, and never let a leading `-` become an option. When only a POSIX shell is available, place `--` before positional paths/URLs where the command supports it and single-quote each dynamic value, escaping every embedded single quote with the shell sequence `'"'"'`. If a value cannot be represented safely, stop and ask for a safe literal path or use an argv-capable tool. + +## Step 2: Detect Format + +Look at the resolved content and classify it as one of: + +- **OpenSpec**: directory has both `proposal.md` and `tasks.md`. May also have `specs/**/spec.md` deltas. +- **spec-kit**: directory or single file shows the spec-kit shape — separate spec/plan/tasks sections, often with explicit "Specification", "Implementation Plan", "Tasks" headings. +- **Issue with checklist**: source kind is `github-issue` or `gitlab-issue`, and the body contains one or more `- [ ]` items. +- **Generic task-list**: any structured source with headings and bullet items that is not OpenSpec, spec-kit, or an issue. Section heading style and item-marker style may vary. +- **Free-form**: prose-only or near-prose source with no clear task list. Includes brain-dump style text. + +If multiple signals point in different directions (e.g., a directory with both a `proposal.md` and a clearly spec-kit-shaped `plan.md`), ask the user to confirm which format to use before drafting. + +## Step 3: Confidence Guard — Ask Before Drafting + +Before writing any draft, scan the source for items the agent cannot confidently map. For each uncertainty, ask the user **before drafting**, never embed placeholder markers (`???`, `TBD`, `[FIXME]`) into the converted plan. + +Common uncertainties: + +- Which headings should become Task sections vs. Overview/Context vs. Technical Details? +- How should a long flat list be split into Tasks (logical phases vs. fixed groups)? +- A bullet item is vague ("clean up the auth module") — what concrete steps are intended? +- Source mixes intent (feature + refactor + bug fix) — should this become one plan or be flagged as multi-plan? +- Source is in a non-English natural language — ask whether to translate Overview/Context prose or preserve the original (the structural keyword `Task` in headers is always English regardless). +- Source is very large (>1000 lines) or very small (<10 lines) — confirm scope before processing. + +Use the native interactive question tool with concrete options. If the question is genuinely open-ended (more than 4 possibilities), present a numbered list in chat and ask the user to reply with a number. When there are exactly 4 possibilities, preserve all four through sequential native questions. + +Do not draft, then ask. Ask, then draft. + +## Step 4: Convert Per Format + +All converted plans must satisfy ralphex's plan-format rules: + +- File starts with `# ` H1. +- Standard sections in order: `## Overview`, `## Context`, `## Development Approach`, `## Testing Strategy`, `## Progress Tracking`, optional `## Technical Details` (when source has architecture/spec details to preserve), `## Implementation Steps`, optional `## Post-Completion`. +- Task headers use the structural form `### Task : `. The keyword `Task` is **always English**, even when the plan title and task titles are in another natural language. ralphex's plan parser only recognizes English `Task` and `Iteration` keywords; localized variants (`Задача`, `タスク`, `Tarea`, etc.) will not be detected. +- Checkboxes (`- [ ]` / `- [x]`) appear **only inside Task sections**. Do not put checkboxes in Overview, Context, Success criteria, or any other section — they cause the executor to spawn extra iterations. +- Every Task should end with a "write tests" checkbox and a "run project tests" checkbox, phrased generically (project may be in any language). +- The final Task is always `### Task <last>: Verify acceptance criteria` containing items that re-run the test suite, run the project linter, and confirm requirements from Overview were met. + +Per-format mapping rules: + +### OpenSpec + +- `proposal.md` "## Why" or equivalent → `## Overview` (the problem statement and motivation) +- `proposal.md` "## What Changes" → `## Context` (impacted components and constraints) +- `specs/**/spec.md` delta sections (ADDED / MODIFIED / REMOVED requirements) → `## Technical Details` (concrete behavior changes) +- `tasks.md` numbered list → `## Implementation Steps` grouped into `### Task N:` sections. Each top-level numbered group becomes a Task; sub-bullets become checkboxes. +- Add `write tests` and `run project tests` checkboxes to each Task even if absent in source. +- Append a final `### Task <last>: Verify acceptance criteria` Task. + +### spec-kit + +- "Specification" section → `## Overview` and `## Context` +- "Implementation Plan" / architecture section → `## Technical Details` +- "Tasks" section → `## Implementation Steps` with one `### Task N:` per logical phase +- Add `write tests`, `run project tests`, and final `Verify acceptance criteria` Task. + +### GitHub / GitLab Issue with Checklist + +- Issue title → `# <Plan Title>` (drop trailing punctuation, normalize whitespace) +- Issue body prose above the first checklist → `## Overview` +- Issue labels and metadata → `## Context` (e.g., "Reported in repo X, labels: bug, p1, area/auth") +- Top-level `- [ ]` items in body → `## Implementation Steps` + - If the body has H3 sub-headings that group items, preserve those grouping into Tasks. + - Otherwise, group every 5–7 items into one Task; create a synthetic title summarizing the group. +- Preserve `- [x]` checked state from the source. +- Add `write tests` and `run project tests` per Task; append final `Verify acceptance criteria` Task. + +### Generic Task-List + +- Infer the heading style (`#`, `##`, `###`, or numbered headings) from the source. +- Infer the item style (`- [ ]`, `* [ ]`, `1.`, `-`, plain dashes). +- Normalize: + - Top-level grouping headings become `### Task N: <title>` (use English `Task` keyword regardless of the source language). + - Item lines become `- [ ]` checkboxes inside the Task. + - Preserve checked state if the source uses any form of "done" marker. +- If grouping is unclear (single flat list, ambiguous heading hierarchy), ask the user before drafting how to split. +- Add `write tests`, `run project tests`, and final `Verify acceptance criteria` Task. + +### Free-Form Markdown + +- Infer intent from the prose (feature / bug fix / refactor / migration / docs). +- First paragraph or two → `## Overview`. +- Background, constraints, references → `## Context`. +- Decompose the body into 3–7 Task groups by logical phase (read carefully; do not invent steps the source does not imply). +- For each Task, write 3–6 concrete checkboxes that map directly to phrases in the source. Do not embed `[FIXME]` or `???` — if a phrase is too vague, ask the user in Step 3 first. +- Add `write tests`, `run project tests`, and final `Verify acceptance criteria` Task. + +### Output Skeleton (all formats) + +```markdown +# <Plan Title> + +## Overview + +<one or two paragraphs describing what is being built and why> + +## Context + +- <impacted components> +- <relevant constraints> +- <reference to source: e.g., "Adopted from issue #312" or "Adopted from OpenSpec change auth-rework"> + +## Development Approach + +- Testing approach: regular (or TDD if source explicitly calls it out) +- Complete each task fully before moving to the next +- Update this plan when scope changes during implementation + +## Testing Strategy + +- Unit tests required for every code-changing Task +- Run project tests after each Task before proceeding + +## Progress Tracking + +- Mark completed items with `[x]` immediately when done +- Update plan if implementation deviates from original scope + +## Technical Details + +<optional: detailed behavior, data shapes, references to spec sections; omit this section if the source had no such content> + +## Implementation Steps + +### Task 1: <title> + +- [ ] <concrete action> +- [ ] <concrete action> +- [ ] write tests for new functionality +- [ ] run project tests - must pass before next task + +### Task 2: <title> + +- [ ] <concrete action> +- [ ] write tests for new/changed functionality +- [ ] run project tests - must pass before next task + +### Task <last>: Verify acceptance criteria + +- [ ] verify all requirements from Overview are implemented +- [ ] run full project test suite +- [ ] run project linter - all issues must be fixed + +## Post-Completion + +*Items requiring manual intervention - no checkboxes, informational only* + +- <manual verification steps if any> +- <external system updates if any> +``` + +## Step 5: Review Loop With Optional revdiff + +Create a temp file and capture its path. Each shell tool call may run in its own subshell, so shell variables (including `$DRAFT`) do not persist between calls. You must capture the literal path printed by `mktemp` and substitute that exact string into every subsequent tool call (file edit, launcher, cleanup) — do not rely on `$VAR` references across calls. + +Use a portable `mktemp` form. The `-t prefix` form differs between macOS BSD and Linux GNU. A template ending in `XXXXXX` is portable, but a suffix after `XXXXXX` (e.g., `XXXXXX.md`) is silently treated as a literal filename by BSD `mktemp` and would cause concurrent runs to collide on the same path. Generate the random path first, then rename to add the `.md` extension: + +```bash +TMP=$(mktemp "${TMPDIR:-/tmp}/ralphex-adopt-XXXXXX") && mv "$TMP" "$TMP.md" && printf '%s\n' "$TMP.md" +``` + +Read the path from stdout (e.g., `/tmp/ralphex-adopt-aB3xY9.md`) and remember it. Refer to that literal string below as `<draft-path>`. Write the draft content to `<draft-path>` with the available file-editing tool. + +An `EXIT` trap is not used because each shell call may use its own subshell — the trap would fire immediately. Cleanup is explicit at the end of Step 6 (success) and on every cancel path (`rm -f -- '<draft-path>'` with the safely quoted literal path substituted). + +Use revdiff only when both the `revdiff` executable and the installed Codex launcher are already available. Do not ask the user to install it, and do not execute a launcher from the target repository. Substitute the literal `<draft-path>` you captured above: + +```bash +SCRIPT_DIR="${CODEX_HOME:-$HOME/.codex}/skills/revdiff/scripts" +LAUNCHER="$SCRIPT_DIR/launch-revdiff.sh" +REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if test -x "$LAUNCHER" && command -v realpath >/dev/null 2>&1; then + LAUNCHER="$(realpath "$LAUNCHER")" +else + LAUNCHER="" +fi +if test -n "$LAUNCHER" && test -n "$REPO_ROOT"; then + REPO_ROOT="$(realpath "$REPO_ROOT")" + case "$LAUNCHER" in "$REPO_ROOT"/*) LAUNCHER="" ;; esac +fi +command -v revdiff >/dev/null 2>&1 && test -n "$LAUNCHER" && test -x "$LAUNCHER" && "$LAUNCHER" --wrap "--only=<draft-path>" +``` + +If the executable or launcher is unavailable, skip directly to the in-chat fallback below. This is the normal path for users who do not use revdiff. + +Read the launcher's exit code and stdout from the shell tool result directly. Do not assign them to shell variables — variables do not persist between shell tool calls (see Step 5 preamble). Interpret the pair strictly: + +- **Exit `0` with empty stdout** → user reviewed and approved silently. Proceed to Step 6. +- **Exit `10` with non-empty stdout** → user left annotations. Read each annotation, revise the draft accordingly (rewrite the literal `<draft-path>` with the available file-editing tool), then re-run revdiff. +- **Any other result** — including another non-zero exit, timeout, exit `0` with output, or exit `10` without annotations — is not approval. Fall back to the in-chat gate below. + +If the executable or safe launcher path is missing, or the launcher returns any result not accepted above, fall back to in-chat review: + +- Print the draft content in chat. +- Use the native interactive question tool: "Approve draft?" with options "Accept", "Revise" (capture feedback as next message), and "Reject" (cancel the conversion). +- On "Revise", treat the next user message as annotation text and revise; loop until "Accept". + +## Step 6: Write Target File + +Compute the target filename: + +- Date: today's date in `YYYYMMDD` form (no dashes, e.g., `20260430`). +- Slug: derive from the plan title — lowercase, ASCII-only, words joined by `-`, max ~50 characters. Drop articles (a/an/the) and trailing punctuation. + +Use the native interactive question tool to confirm or edit the slug before writing: + +- header: "Filename" +- question: "Use slug `<computed-slug>` for `docs/plans/<date>-<slug>.md`?" +- options: + - label: "Yes, use this slug" + - label: "Edit slug" (capture next user message as the new slug) + - label: "Cancel" + +If the target file already exists: + +- Use the native interactive question tool: "`docs/plans/<filename>` already exists. What should we do?" +- options: + - label: "Bump suffix" — append `-v2`, then `-v3`, ... to the slug; check `docs/plans/` and `docs/plans/completed/` for collisions, increment until both are clear + - label: "Pick a new slug" (capture next message) + - label: "Cancel" +- Never silent-overwrite. + +Sanity-check the draft before writing: + +- The draft must contain at least one `### Task ` line that matches the form `### Task <N>: <title>`. +- The draft must contain at least one `- [ ]` checkbox under a Task section. +- If either check fails, return to Step 4 to revise (do not write the file). + +Once the filename is confirmed and sanity checks pass: + +```bash +mkdir -p docs/plans +``` + +Write the draft content to `docs/plans/<final-name>.md` with the available file-editing tool. Then explicitly clean up the temp file by substituting the literal, safely quoted `<draft-path>` captured in Step 5: + +```bash +rm -f -- '<draft-path>' +``` + +Also run the same `rm -f -- '<draft-path>'` on any cancel path before exiting (Step 1, Step 3, Step 5 reject, Step 6 cancel) — always with the safely quoted literal path substituted, never as `$DRAFT`. + +Report to the user: + +``` +Adopted plan: docs/plans/<final-name>.md + +Source: <source kind and identifier> +Tasks: <N> + +Next: run `ralphex docs/plans/<final-name>.md` to execute. +``` + +## Edge Cases + +- **Missing path**: if user passed a path that does not exist, ask the user to correct or cancel. +- **Ambiguous bare name**: more than one match — ask the user to pick. +- **URL fetch failure**: ask the user to paste body as fallback. +- **Directory with no recognizable structure**: list contents, ask the user to point at the file. +- **Format detection conflict**: multiple signals — ask the user to choose format. +- **Zero task-like content**: source has no items the agent can convert — ask the user whether to infer Tasks from prose or cancel. +- **Mixed localization**: source mixes English and another language — confirm whether to keep the original language for prose. Structural `Task` keyword stays English regardless. +- **Huge source (>1000 lines)**: warn before processing and ask the user whether to proceed, summarize, or split into multiple plans. +- **Tiny source (<10 lines)**: warn that the result will be sparse; ask the user whether to proceed or expand interactively. +- **Output collision**: target file already exists — never silent overwrite (see Step 6). +- **Idempotency**: re-running on the same source uses today's date. Old converted plans in `docs/plans/completed/` are never modified. + +## Tool Fallbacks + +- **revdiff missing**: fall back to the in-chat Accept/Revise/Reject question loop (see Step 5). +- **gh missing** (when source is a GitHub issue/URL): ask the user to paste the issue body manually. +- **glab missing** (when source is a GitLab issue/URL): ask the user to paste the issue body manually. +- **Both gh and glab missing for a `#N` argument**: ask the user to paste the issue body or provide a different reference. + +## Constraints + +- Never modify the source plan or directory. +- Never write to `docs/plans/` without an explicit user-confirmed slug. +- Never silently overwrite an existing target file. +- Never embed placeholder markers (`???`, `TBD`, `[FIXME]`) in the output — ask the user before drafting instead. +- Never assume the target project is a specific language. Test/run-test checkboxes must use generic phrasing such as "write tests" and "run project tests". +- Never cite ralphex internal source files (e.g., `pkg/...`) in the converted plan content. +- Do not run tests, do not run linters, do not commit, do not push. The skill only produces a plan file. diff --git a/plugins/ralphex/skills/ralphex-adopt/agents/openai.yaml b/plugins/ralphex/skills/ralphex-adopt/agents/openai.yaml new file mode 100644 index 00000000..24e477b9 --- /dev/null +++ b/plugins/ralphex/skills/ralphex-adopt/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Ralphex Adopt" + short_description: "Convert an existing specification into a Ralphex plan" + default_prompt: "Use $ralphex-adopt to convert this source into an executable Ralphex plan." + +policy: + allow_implicit_invocation: false diff --git a/plugins/ralphex/skills/ralphex-plan/SKILL.md b/plugins/ralphex/skills/ralphex-plan/SKILL.md new file mode 100644 index 00000000..d88d7024 --- /dev/null +++ b/plugins/ralphex/skills/ralphex-plan/SKILL.md @@ -0,0 +1,319 @@ +--- +name: ralphex-plan +description: Create structured implementation plan in docs/plans/ through grounded Codex exploration and one-at-a-time questions. +--- + +# Implementation Plan Creation + +## Interactive Choice Contract + +For every choice below, try Codex's native interactive question tool first. If the tool is unavailable, errors, or does not block for an answer, ask the same question with the same options in chat, end the turn, and wait for the user's reply. Never infer or select a default on the user's behalf. + +Create an implementation plan in `docs/plans/YYYYMMDD-<task-name>.md` with interactive context gathering. + +## Prerequisites: Verify CLI Installation + +Check if ralphex CLI is installed (needed to execute the plan after creation): +```bash +which ralphex +``` + +**If not found**, inform user they'll need it to execute the plan: +- **macOS (Homebrew)**: `brew install umputun/apps/ralphex` +- **Linux (Debian/Ubuntu)**: download `.deb` from https://github.com/umputun/ralphex/releases +- **Linux (RHEL/Fedora)**: download `.rpm` from https://github.com/umputun/ralphex/releases +- **Any platform with Go**: `go install github.com/umputun/ralphex/cmd/ralphex@latest` + +Proceed with plan creation regardless, but remind user to install before execution. + +## Step 0: Parse Intent and Gather Context + +Before asking questions, understand what the user is working on: + +1. **Parse user's command arguments** to identify intent: + - "add feature Z" / "implement W" → feature development + - "fix bug" / "debug issue" → bug fix plan + - "refactor X" / "improve Y" → refactoring plan + - "migrate to Z" / "upgrade W" → migration plan + - generic request → explore current work + +2. **Launch a Codex `explorer` subagent** with a read-only task to gather relevant context based on intent. If the current surface exposes no subagent tool, or spawning is disabled, do the same exploration directly and disclose the fallback: + + **For feature development:** + - locate related existing code and patterns + - check project structure (README, config files, existing similar implementations) + - identify affected components and dependencies + + **For bug fixing:** + - look for error logs, test failures, or stack traces + - find related code that might be involved + - check recent git changes in problem areas + + **For refactoring/migration:** + - identify all files/components affected + - check test coverage of affected areas + - find dependencies and integration points + + **For generic/unclear requests:** + - check `git status` and recent file activity + - examine current working directory structure + - identify primary language/framework from file extensions and config files + +3. **Synthesize findings** into context summary: + - what work is in progress + - which files/areas are involved + - what the apparent goal is + - relevant patterns or structure discovered + +## Step 1: Present Context and Ask Focused Questions + +Show the discovered context, then ask questions **one at a time** using Codex's native interactive question tool: + +"Based on your request, I found: [context summary]" + +**Ask questions one at a time (do not overwhelm with multiple questions):** + +1. **Plan purpose**: ask "What is the main goal?" + - provide multiple choice with suggested answer based on discovered intent + - wait for response before next question + +2. **Scope**: ask "Which components/files are involved?" + - provide multiple choice with suggested discovered files/areas + - wait for response before next question + +3. **Constraints**: ask "Any specific requirements or limitations?" + - can be open-ended if constraints vary widely + - wait for response before next question + +4. **Testing approach**: ask "Do you prefer TDD or regular approach?" + - options: "TDD (tests first)" and "Regular (code first, then tests)" + - store preference for reference during implementation + - wait for response before next question + +5. **Plan title**: ask "Short descriptive title?" + - provide suggested name based on intent + +After all questions answered, synthesize responses into plan context. + +## Step 1.5: Explore Approaches + +Once the problem is understood, propose implementation approaches: + +1. **Propose 2-3 different approaches** with trade-offs for each +2. **Lead with recommended option** and explain reasoning +3. **Present conversationally** - not a formal document yet + +Example format: +``` +I see three approaches: + +**Option A: [name]** (recommended) +- How it works: ... +- Pros: ... +- Cons: ... + +**Option B: [name]** +- How it works: ... +- Pros: ... +- Cons: ... + +Which direction appeals to you? +``` + +Use the native interactive question tool to let the user select the preferred approach before creating the plan. + +**Skip this step** if: +- the implementation approach is obvious (single clear path) +- user explicitly specified how they want it done +- it's a bug fix with clear solution + +## Step 2: Create Plan File + +Check `docs/plans/` for existing files, then create `docs/plans/YYYYMMDD-<task-name>.md`: + +### Plan Structure + +```markdown +# [Plan Title] + +## Overview +- Clear description of the feature/change being implemented +- Problem it solves and key benefits +- How it integrates with existing system + +## Context (from discovery) +- Files/components involved: [list from step 0] +- Related patterns found: [patterns discovered] +- Dependencies identified: [dependencies] + +## Development Approach +- **Testing approach**: [TDD / Regular - from user preference in planning] +- Complete each task fully before moving to the next +- Make small, focused changes +- **CRITICAL: every task MUST include new/updated tests** for code changes in that task + - tests are not optional - they are a required part of the checklist + - write unit tests for new functions/methods + - write unit tests for modified functions/methods + - add new test cases for new code paths + - update existing test cases if behavior changes + - tests cover both success and error scenarios +- **CRITICAL: all tests must pass before starting next task** - no exceptions +- **CRITICAL: update this plan file when scope changes during implementation** +- Run tests after each change +- Maintain backward compatibility + +## Testing Strategy +- **Unit tests**: required for every task (see Development Approach above) +- **E2E tests**: if project has UI-based e2e tests (Playwright, Cypress, etc.): + - UI changes → add/update e2e tests in same task as UI code + - Backend changes supporting UI → add/update e2e tests in same task + - Treat e2e tests with same rigor as unit tests (must pass before next task) + - Store e2e tests alongside unit tests (or in designated e2e directory) + +## Progress Tracking +- Mark completed items with `[x]` immediately when done +- Add newly discovered tasks with ➕ prefix +- Document issues/blockers with ⚠️ prefix +- Update plan if implementation deviates from original scope +- Keep plan in sync with actual work done + +## What Goes Where +- **Implementation Steps** (`[ ]` checkboxes): tasks achievable within this codebase - code changes, tests, documentation updates +- **Post-Completion** (no checkboxes): items requiring external action - manual testing, changes in consuming projects, deployment configs, third-party verifications +- **Checkbox placement**: Checkboxes belong only in Task sections (`### Task N:` or `### Iteration N:`). Do not put checkboxes in Success criteria, Overview, or Context — they cause extra loop iterations. + +## Implementation Steps + +<!-- +Task structure guidelines: +- Each task = ONE logical unit (one function, one endpoint, one component) +- Use specific descriptive names, not generic "[Core Logic]" or "[Implementation]" +- Aim for ~5 checkboxes per task (more is OK if logically atomic) +- **CRITICAL: Each task MUST end with writing/updating tests before moving to next** + - tests are not optional - they are a required deliverable of every task + - write tests for all NEW code added in this task + - write tests for all MODIFIED code in this task + - include both success and error scenarios in tests + - list tests as SEPARATE checklist items, not bundled with implementation + +Example (NOTICE: tests are separate checklist items): + +### Task 1: Add password hashing utility +- [ ] create `auth/hash` module with HashPassword and VerifyPassword functions +- [ ] implement secure hashing with configurable cost +- [ ] write tests for HashPassword (success + error cases) +- [ ] write tests for VerifyPassword (success + error cases) +- [ ] run project tests - must pass before task 2 + +### Task 2: Add user registration endpoint +- [ ] create `POST /api/users` handler +- [ ] add input validation (email format, password strength) +- [ ] integrate with password hashing utility +- [ ] write tests for handler success case with table-driven cases +- [ ] write tests for handler error cases (invalid input, missing fields) +- [ ] run project tests - must pass before task 3 +--> + +### Task 1: [specific name - what this task accomplishes] +- [ ] [specific action with file reference - code implementation] +- [ ] [specific action with file reference - code implementation] +- [ ] write tests for new/changed functionality (success cases) +- [ ] write tests for error/edge cases +- [ ] run tests - must pass before next task + +### Task N-1: Verify acceptance criteria +- [ ] verify all requirements from Overview are implemented +- [ ] verify edge cases are handled +- [ ] run full test suite (unit tests) +- [ ] run e2e tests if project has them +- [ ] run linter - all issues must be fixed +- [ ] verify test coverage meets project standard (80%+) + +*Note: manual testing, deployment verification, and external checks go in Post-Completion (no checkboxes). Task section checkboxes must be automatable by the agent.* + +### Task N: [Final] Update documentation +- [ ] update README.md if needed +- [ ] update project knowledge docs if new patterns discovered + +*Note: ralphex automatically moves completed plans to `docs/plans/completed/`* + +## Technical Details +- Data structures and changes +- Parameters and formats +- Processing flow + +## Post-Completion +*Items requiring manual intervention or external systems - no checkboxes, informational only* + +**Manual verification** (if applicable): +- Manual UI/UX testing scenarios +- Performance testing under load +- Security review considerations + +**External system updates** (if applicable): +- Consuming projects that need updates after this library change +- Configuration changes in deployment systems +- Third-party service integrations to verify +``` + +## Step 3: Offer to Start + +After creating the file, tell user: + +"Created plan: `docs/plans/YYYYMMDD-<task-name>.md` + +Ready to start implementation?" + +If yes, begin with task 1. + +## Execution Enforcement + +**CRITICAL testing rules during implementation:** + +1. **After completing code changes in a task**: + - STOP before moving to next task + - Add tests for all new functionality + - Update tests for modified functionality + - Run project test command + - Mark completed items with `[x]` in plan file + - **Use the available task tracker to track progress and mark todos completed immediately (do not batch)** + +2. **If tests fail**: + - Fix the failures before proceeding + - Do NOT move to next task with failing tests + - Do NOT skip test writing + +3. **Only proceed to next task when**: + - All task items completed and marked `[x]` + - Tests written/updated + - All tests passing + +4. **Plan tracking during implementation**: + - Update checkboxes immediately when tasks complete + - Add ➕ prefix for newly discovered tasks + - Add ⚠️ prefix for blockers + - Modify plan if scope changes significantly + +5. **On completion**: + - Verify all checkboxes marked + - Run final test suite + - *ralphex automatically moves plan to `docs/plans/completed/`* + +6. **Partial implementation exception**: + - If a task provides partial implementation where tests cannot pass until a later task: + - Still write the tests as part of this task (required) + - Add TODO comment in test code noting the dependency + - Mark the test checkbox as completed with note: `[x] write tests ... (fails until Task X)` + - Do NOT skip test writing or defer until later + - When the dependent task completes, remove the TODO comment and verify tests pass + +This ensures each task is solid before building on top of it. + +## Key Principles + +- **One question at a time** - do not overwhelm user with multiple questions in a single message +- **Multiple choice preferred** - easier to answer than open-ended when possible +- **YAGNI ruthlessly** - remove unnecessary features from all designs, keep scope minimal +- **Lead with recommendation** - have an opinion, explain why, but let user decide +- **Explore alternatives** - always propose 2-3 approaches before settling (unless obvious) +- **Duplication vs abstraction** - when code repeats, ask user: prefer duplication (simpler, no coupling) or abstraction (DRY but adds complexity)? explain trade-offs before deciding diff --git a/plugins/ralphex/skills/ralphex-plan/agents/openai.yaml b/plugins/ralphex/skills/ralphex-plan/agents/openai.yaml new file mode 100644 index 00000000..48550936 --- /dev/null +++ b/plugins/ralphex/skills/ralphex-plan/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Ralphex Plan" + short_description: "Create an executable plan through guided discovery" + default_prompt: "Use $ralphex-plan to explore this request and create a Ralphex implementation plan." + +policy: + allow_implicit_invocation: false diff --git a/plugins/ralphex/skills/ralphex-update/SKILL.md b/plugins/ralphex/skills/ralphex-update/SKILL.md new file mode 100644 index 00000000..7a82a36a --- /dev/null +++ b/plugins/ralphex/skills/ralphex-update/SKILL.md @@ -0,0 +1,181 @@ +--- +name: ralphex-update +description: Smart-merge updated Ralphex defaults into customized prompts and agents one file at a time while preserving user intent. +--- + +# ralphex-update - Smart Prompt Merging + +## Interactive Choice Contract + +For every choice below, try Codex's native interactive question tool first. If the tool is unavailable, errors, or does not block for an answer, ask the same question with the same options in chat, end the turn, and wait for the user's reply. Never infer or select a default on the user's behalf. + +**SCOPE**: Compare current embedded defaults with user's installed config, and intelligently merge updates into customized files. Preserves user intent while incorporating structural changes. + +## Step 0: Verify CLI Installation + +```bash +which ralphex +``` + +**If not found**, guide installation: +- **macOS (Homebrew)**: `brew install umputun/apps/ralphex` +- **Any platform with Go**: `go install github.com/umputun/ralphex/cmd/ralphex@latest` + +**Do not proceed until `which ralphex` succeeds.** + +## Step 1: Extract Current Defaults + +Create temp directory and dump embedded defaults: + +```bash +DUMP_DIR=$(mktemp -d /tmp/ralphex-defaults-XXXX) +ralphex --dump-defaults "$DUMP_DIR" +echo "$DUMP_DIR" +``` + +Save the dump directory path for later use. + +## Step 2: Determine Config Directory + +Resolve the user's config directory: + +```bash +# check environment variable first +echo "${RALPHEX_CONFIG_DIR:-}" +``` + +If `RALPHEX_CONFIG_DIR` is empty, use default: +- **macOS/Linux**: `~/.config/ralphex/` + +Verify the directory exists: +```bash +ls -la <config-dir>/ +``` + +If it doesn't exist, inform user that ralphex hasn't been configured yet and there's nothing to update. + +## How ralphex Config Files Work + +ralphex installs config, prompt, and agent files with all content **commented out** (every line prefixed with `# `). At runtime, `stripComments()` removes these lines, finds nothing, and falls back to **embedded defaults** compiled into the binary. These all-commented files are functionally identical to missing files — they are do-nothing placeholders. + +When ralphex is updated, new embedded defaults take effect **automatically** for every file that hasn't been customized. No file changes are needed. + +A file is **customized** only if it contains at least one uncommented, non-empty line that was intentionally modified by the user. The `--dump-defaults` command produces the raw (uncommented) embedded content for comparison. + +## Step 3: Compare Files + +For each file in the defaults dump (`config`, `prompts/*.txt`, `agents/*.txt`), compare with the corresponding file in the user's config directory. + +**Algorithm to detect customized files**: a file is customized if it contains at least one non-empty line that does NOT start with `#`. Files that are missing, empty, or contain only comment lines (`# ...`) and whitespace are do-nothing defaults. + +**Classify each file into one of these categories:** + +### Skip (do-nothing default) +- File is missing in user's config, OR +- File is empty, OR +- File contains only comments and whitespace (every non-empty line starts with `#`) +- **Action**: no action needed — embedded defaults handle it automatically + +Note: files that exist only in the dump directory (no corresponding user file) are also do-nothing — do NOT offer to install them. Files that exist only in the user's config directory (no corresponding dump file) are user-created custom files — ignore them entirely. + +### Skip (unchanged) +- File has uncommented content that matches the raw dump default +- **Action**: no action needed — user's file already matches current defaults + +**How to compare**: strip all `#`-prefixed lines from BOTH the user's file and the dump file, then compare the remaining non-comment content. This handles the config file where the dump has descriptive comment lines mixed with value lines — only the actual values matter for comparison. + +### Smart merge needed +- File has uncommented content that differs from the raw dump default (after stripping `#`-prefixed lines from both sides) +- **Action**: needs Codex to semantically analyze and propose merge + +## Step 4: Present Summary + +Show the user a summary with two groups: + +``` +ralphex config update summary: + +No changes needed (N files): + prompts/task.txt, prompts/review_first.txt, agents/quality.txt, prompts/codex.txt, ... + +Smart merge needed (N files): + prompts/review_second.txt, agents/implementation.txt +``` + +If nothing needs merging, report "all config files are up to date — no changes needed" and skip to cleanup. + +Otherwise, use Codex's native interactive question tool to confirm proceeding: +- header: "Proceed" +- question: "Review smart merges? Each customized file will be reviewed one by one." +- options: + - label: "Yes, proceed" + description: "Review and merge customized files one at a time" + - label: "Skip, just show details" + description: "Show what changed without modifying anything" + +If user selects "Skip, just show details": for each file needing smart merge, show the diff between the user's file and the new default, then skip to Step 6 (Cleanup) without modifying any files. + +## Step 5: Process Smart Merges + +For each customized file that needs merging: + +1. **Read both versions** - the new default and the user's current version +2. **Analyze the differences semantically**: + - What did the user customize? (added content, changed wording, different instructions) + - What changed in the new default? (structural changes, new template variables, new sections, removed sections) +3. **Propose a merged version** that: + - Preserves user additions not present in defaults + - Applies structural/pattern changes from new defaults + - Updates template variable references (e.g., new `{{VARIABLE}}` usage) + - Preserves user's tone and style choices + - Flags direct conflicts where both changed the same thing +4. **Show the user**: + - Brief summary of what changed in defaults + - Brief summary of what user customized + - The proposed merged version +5. **Use the native interactive question tool** for each file: + - header: "Merge" + - question: "How to handle <filename>?" + - options: + - label: "Accept merge" + description: "Use the proposed merged version" + - label: "Keep mine" + description: "Keep your current version unchanged" + - label: "Use new default" + description: "Replace with new default (discard customizations)" + +6. Apply the user's choice + +## Step 6: Cleanup + +Remove the temp directory: + +```bash +rm -rf <dump-dir> +``` + +Report final summary: +``` +Update complete: + Skipped: N files (no changes needed) + Smart-merged: N files (M accepted, K kept) +``` + +## Merge Principles + +When proposing smart merges, follow these rules: + +- **Preserve user additions**: content the user added that doesn't exist in defaults should be kept +- **Apply structural changes**: if defaults restructured prompts (e.g., changed from sequential to parallel agents), apply the new structure while keeping user's custom content +- **Update template variables**: if new `{{VARIABLE}}` references were added to defaults, include them in the merge +- **Preserve user tone/style**: if user rewrote instructions in a different style, keep their style while incorporating new functionality +- **Flag conflicts clearly**: if both user and defaults changed the same section differently, present both versions and let the user choose +- **Don't lose information**: when in doubt, keep both versions with clear markers + +## Constraints + +- This command is ONLY for updating ralphex configuration files +- Do NOT modify any project source code +- Do NOT run ralphex execution or review +- Do NOT touch files outside the config directory +- Always clean up the temp directory when done diff --git a/plugins/ralphex/skills/ralphex-update/agents/openai.yaml b/plugins/ralphex/skills/ralphex-update/agents/openai.yaml new file mode 100644 index 00000000..f83acb73 --- /dev/null +++ b/plugins/ralphex/skills/ralphex-update/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Ralphex Update" + short_description: "Merge updated Ralphex defaults into local customizations" + default_prompt: "Use $ralphex-update to compare and selectively merge current Ralphex defaults." + +policy: + allow_implicit_invocation: false diff --git a/plugins/ralphex/skills/ralphex/SKILL.md b/plugins/ralphex/skills/ralphex/SKILL.md new file mode 100644 index 00000000..93e632b3 --- /dev/null +++ b/plugins/ralphex/skills/ralphex/SKILL.md @@ -0,0 +1,229 @@ +--- +name: ralphex +description: Run Ralphex autonomous plan execution with progress monitoring. +--- + +# ralphex - Autonomous Plan Execution + +## Interactive Choice Contract + +For every choice below, try Codex's native interactive question tool first. If the tool is unavailable, errors, or does not block for an answer, ask the same question with the same options in chat, end the turn, and wait for the user's reply. Never infer or select a default on the user's behalf. + +**SCOPE**: This skill ONLY launches ralphex, monitors progress, and reports status. Do NOT take any other actions. + +## Step 0: Verify CLI Installation + +Check if ralphex CLI is installed: +```bash +which ralphex +``` + +**If not found**, guide installation based on platform: + +- **macOS (Homebrew)**: `brew install umputun/apps/ralphex` +- **Linux (Debian/Ubuntu)**: download `.deb` from https://github.com/umputun/ralphex/releases +- **Linux (RHEL/Fedora)**: download `.rpm` from https://github.com/umputun/ralphex/releases +- **Any platform with Go**: `go install github.com/umputun/ralphex/cmd/ralphex@latest` + +Use Codex's native interactive question tool to confirm the installation method, then guide through it. If all four methods cannot fit in one question, ask sequential questions so every method remains available. **Do not proceed until `which ralphex` succeeds.** + +## Step 1: Check for Plan Argument + +Check the skill invocation for an optional plan file path: +- if argument provided: validate the file exists with an exact file read, skip plan selection in Step 4 +- if no argument: will ask for plan selection in Step 4 + +Treat the selected plan as a path, never as an option. If its text begins with `-`, resolve it to an absolute path with an argv-safe filesystem operation and validate that exact resolved file again. If safe normalization is unavailable or ambiguous, reject the path and ask for an explicit `./...` or absolute path. + +## Step 2: Ask Executor + +Use Codex's native interactive question tool: +- header: "Executor" +- question: "Which executor should ralphex use?" +- options: + - label: "Configured (Recommended)" + description: "Use ralphex's effective config; Claude Code is the default when executor is unset" + - label: "Codex" + description: "Add --codex; codex runs tasks, internal reviews, and finalize while external review is skipped" + +`--codex` is the first-class Codex executor flag. It is not the same as the deprecated `--codex-only` alias for `--external-only`. Never combine `--codex` with `--external-only` or `--codex-only`. + +## Step 3: Ask Execution Mode + +Use Codex's native interactive question tool: +- header: "Mode" +- question: "Which execution mode should ralphex use?" +- options: + - label: "Full pipeline (Recommended)" + description: "Run tasks, internal reviews, and finalize; configured external review runs only with a non-Codex executor" + - label: "Review pipeline" + description: "Add --review; review current-branch changes and allow agents to fix and commit findings" + - label: "External review" + description: "Add --external-only; skip tasks and first internal review, then fix findings and run the post-external review" + +If Codex executor was selected, do not offer "External review" because first-class `--codex` skips that phase and the flags are incompatible. Offer only "Full pipeline" and "Review pipeline". + +If "Configured" executor and "External review" are selected, inspect the effective ralphex config before proceeding. Respect `RALPHEX_CONFIG_DIR` when set and local `.ralphex/config` overrides. If the effective config contains `executor = codex`, do not launch an incompatible command; ask the user to choose Full/Review with Codex or change the config explicitly. + +## Step 4: Plan Selection (if no argument provided) + +**If Full pipeline selected:** +- Search for `docs/plans/*.md` with exact filesystem tools (excludes completed/) +- Plan is REQUIRED +- Preserve the Claude workflow's oldest-first result handling: REVERSE the list to get most recent first +- Offer up to 4 most recent plans +- First option (most recent) should have "(Recommended)" suffix +- Because a Codex question can show at most 3 options, use sequential questions when needed: offer the first 2 plans plus "More plans", then offer the remaining plans. Do not drop or rename any plan choice. +- User MUST select one + +**If Review pipeline or External review mode selected:** +- Search for `docs/plans/**/*.md` with exact filesystem tools (includes completed/ for context) +- Plan is OPTIONAL +- Preserve the Claude workflow's oldest-first result handling: REVERSE the list to get most recent first +- Offer up to 4 most recent plans PLUS "None" at the end +- First plan option (most recent) should have "(Recommended)" suffix +- "None" option description: "Review existing changes without a plan file" +- Because a Codex question can show at most 3 options, use sequential questions when needed: offer the first 2 plans plus "More choices", then offer the remaining plan choices and "None" across further questions as necessary. Do not drop or rename any choice. +- If user selects "None", run without plan file + +## Step 5: Ask Max Iterations + +Use Codex's native interactive question tool: +- header: "Iterations" +- question: "Maximum number of task iterations?" +- options: + - label: "50 (Recommended)" + description: "Default - suitable for most plans" + - label: "25" + description: "Shorter plans or quick iterations" + - label: "100" + description: "Large plans with many tasks" + +## Step 6: Fail-Closed Launch Preflight + +### Repository-local executable overrides (every mode) + +Read `.ralphex/config` directly when it exists. Reject the launch if the file is unreadable, malformed, changes while being inspected, or contains any active non-empty assignment for: + +- `claude_command` +- `codex_command` +- `custom_review_script` +- `vcs_command` + +These values select executables or scripts that the background run would invoke. Do not offer a proceed/override choice. Report the blocking keys and values, then stop. If the file passes, record its content hash (or an equivalent exact-content snapshot); if it is absent, record that exact absence. Use this baseline for the immediate pre-launch revalidation in Step 7. + +### Review checkout (Review pipeline and External review only) + +Run this step only for Review pipeline or External review mode. Both modes operate on the current checkout. Their review agents can edit files and create commits while fixing findings. + +Before any ralphex process starts: + +1. Resolve the named current branch with `git symbolic-ref --quiet --short HEAD`. Detached HEAD or an unresolved/empty branch is a hard failure. +2. Require `git status --porcelain=v1` to be empty, including staged, tracked, and untracked changes. +3. Resolve the exact base ref ralphex will use: repo-local `default_branch`, then global ralphex config, then the repository's remote/default-branch evidence. If the effective base is missing, conflicting, or ambiguous, stop. +4. Verify the base resolves to a commit and the current named branch is not that default branch. +5. Require a non-empty committed `base...HEAD` file diff. An uncommitted diff, an ahead commit with no file delta, or an empty diff does not qualify. +6. On any failure, report the observed branch, base, status, and diff condition, then stop. Do not offer "Proceed anyway", switch branches, stash, commit, or modify the checkout. +7. Record the exact branch, resolved base commit, clean-status result, and committed-diff evidence for the immediate pre-launch revalidation in Step 7. + +## Step 7: Launch ralphex in Background + +Build the argument vector: + +```text +["ralphex", + "--codex", # only if user selected Codex executor + "--review", # only if user selected Review pipeline + "--external-only", # only if user selected External review + "--max-iterations", N, # from user selection (25, 50, or 100) + "--", plan-file] # append both only when a plan was selected +``` + +The executor and mode flags are alternatives; include only the flags selected above. `--` must immediately precede a positional plan path so even a normalized leading-dash filename cannot become an option. Omit both `--` and the plan item when no plan was selected. + +Prefer a process tool that accepts an argv array so the plan path is passed as one opaque argument. Never concatenate a plan path into a shell command. If only a shell terminal is available, POSIX-single-quote every dynamic argument (including the plan path), escaping every embedded single quote with the shell sequence `'"'"'`; stop if a value cannot be represented safely. Never use `eval`. + +**Determine progress filename** based on mode and plan selection: +- Full mode + plan: `.ralphex/progress/progress-{plan-stem}.txt` +- Review mode + plan: `.ralphex/progress/progress-{plan-stem}-review.txt` +- External review + plan: `.ralphex/progress/progress-{plan-stem}-codex.txt` +- Full mode + no plan: `.ralphex/progress/progress.txt` +- Review mode + no plan: `.ralphex/progress/progress-review.txt` +- External review + no plan: `.ralphex/progress/progress-codex.txt` + +Where `{plan-stem}` is the plan filename without extension (e.g., `fix-bugs` from `fix-bugs.md`). + +Before launch, record whether this progress file exists and capture its current content hash or equivalent file identity/size evidence. Also record the launch time. + +Immediately before spawning the process, repeat every applicable Step 6 check: + +1. Re-read `.ralphex/config`; require the same safe content/hash and no executable-bearing override. +2. For Review pipeline or External review, require the same named branch and resolved base commit, a still-clean status, and a still-non-empty committed `base...HEAD` diff. +3. If anything changed or cannot be revalidated, stop without launching. Do not reuse the earlier result. + +Only after this second gate passes, run with Codex's native background terminal. **Save the returned session ID** - it is the Codex-native equivalent of Claude's background task ID and is needed for status checks later. + +## Step 8: Confirm Launch + +1. Wait 10-15 seconds for initialization +2. Poll the saved background session and read its liveness or completed exit status. +3. Verify the progress file was created or changed after the recorded launch baseline. Read the new/current last 20 lines with an argv-safe file tool; shell fallback is `tail -n 20 -- '<safely-quoted-progress-path>'`. +4. Confirm launch only when both are true: + - the session is still running, or it exited with a known status; and + - the current launch produced fresh progress evidence after the baseline (new file, changed content, a fresh restart marker, or timestamped activity). +5. Existing `Plan:`, `Branch:`, or `Started:` headers alone are not proof; they may belong to an earlier run. If the session exited non-zero, report launch failure with the exit code and fresh progress tail. If it exited zero before confirmation, report that it already completed rather than saying it is running. If session state or fresh progress cannot be verified, report launch as unconfirmed. +6. For a live confirmed session, report: + +``` +ralphex started. Session ID: [session-id] + +Plan: [plan file from progress file] +Branch: [branch from progress file] +Mode: [mode from progress file] +Progress file: [progress-filename] + +Manual monitoring: + tail -f -- '<progress-filename>' # live stream + tail -n 50 -- '<progress-filename>' # recent activity + +ralphex runs autonomously (can take hours). Process continues if you close this conversation. +Ask "check ralphex" to get status update. +``` + +**STOP HERE after reporting launch status. Do not continue monitoring automatically.** + +## Step 9: Progress Check (only on explicit user request) + +If user explicitly asks "check ralphex", "ralphex status", or "how is ralphex doing": + +1. Poll the saved Codex background terminal session without blocking (use the session ID from Step 7) +2. Read last 40 lines of progress file (use filename from Step 7) + +**If process still running:** +- Report current phase from progress file: + - "task iteration N" → Task Execution phase + - "codex iteration N" → Codex External Review phase + - "review pass 1/2" → Claude Review phase +- Show recent activity lines + +**If process exited (the background terminal shows completion):** +- Exit code 0 → success, report "ralphex completed successfully" +- Exit code non-zero → failure, report "ralphex failed" +- Read final lines of progress file for summary + +**After reporting status, STOP. Do not offer to do anything else.** + +## Constraints + +- This skill is ONLY for launching and monitoring ralphex +- Do NOT offer to help with code, commits, PRs, or anything else +- Do NOT make suggestions or recommendations beyond status reporting +- Do NOT take any actions on the codebase +- After launch confirmation: wait for user to explicitly request status check +- After status check: report and stop + +## Nested Claude Code Sessions + +ralphex automatically strips the `CLAUDECODE` env var from child processes, allowing it to run from inside Codex when the configured workflow launches Claude Code. If the nested session error is somehow encountered, ralphex detects it via error pattern matching and exits gracefully instead of looping. + +Running from a standalone terminal is still recommended for the best experience. diff --git a/plugins/ralphex/skills/ralphex/agents/openai.yaml b/plugins/ralphex/skills/ralphex/agents/openai.yaml new file mode 100644 index 00000000..0f65322d --- /dev/null +++ b/plugins/ralphex/skills/ralphex/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Ralphex" + short_description: "Launch and monitor an autonomous Ralphex run" + default_prompt: "Use $ralphex to launch a Ralphex plan and report its startup status." + +policy: + allow_implicit_invocation: false diff --git a/scripts/internal/README.md b/scripts/internal/README.md index 743f11b4..af86ad67 100644 --- a/scripts/internal/README.md +++ b/scripts/internal/README.md @@ -5,4 +5,9 @@ Development and build utility scripts. Not intended for end users. - **init-docker.sh** - Docker container init script. Copies Claude and Codex credentials from mounted volumes into the app user's home directory. Run automatically by the base image on container start. - **prep-toy-test.sh** - Creates a toy Go project at `/tmp/ralphex-test` with buggy code and a plan file for end-to-end testing of ralphex's full execution mode. - **prep-review-test.sh** - Creates a toy Go project at `/tmp/ralphex-review-test` with subtle code issues on a feature branch for testing review-only mode. -- **update-plugin-version.sh** - Updates version in `.claude-plugin/plugin.json` and `marketplace.json`. Called by goreleaser as a pre-release hook. +- **check-plugin-version.py** - Verifies all four manifests agree and, with `--base`, requires a bump when distributed Claude or Codex skill payload differs from that git base. +- **check-plugin-version_test.py** - Covers local consistency, base-aware payload changes, version bumps, and untracked skills. +- **update-plugin-version.sh** - Explicit maintainer helper for the four independently versioned plugin manifests. Pass `--check` to compare them with one expected plugin version. +- **update-plugin-version_test.sh** - Proves the jq and sed-fallback update paths and independently detects corruption in each manifest. + +When distributed skill files change, run `make bump-plugin-version VERSION=<plugin-version>` and commit the four manifest changes. The plugin version is independent of the Ralphex CLI version; `make test-plugin` verifies consistency without mutating the checkout. diff --git a/scripts/internal/check-plugin-version.py b/scripts/internal/check-plugin-version.py new file mode 100644 index 00000000..b37e6ce8 --- /dev/null +++ b/scripts/internal/check-plugin-version.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +import argparse +import json +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +MANIFESTS = ( + (".claude-plugin/plugin.json", ("version",)), + (".claude-plugin/marketplace.json", ("plugins", 0, "version")), + ("plugins/ralphex/.codex-plugin/plugin.json", ("version",)), + ("plugins/ralphex/plugin.json", ("version",)), +) +PAYLOAD_PATHS = ("assets/claude/skills", "plugins/ralphex/skills") + + +def json_version(data, components): + value = data + for component in components: + value = value[component] + if not isinstance(value, str) or not value: + raise ValueError("version must be a non-empty string") + return value + + +def current_versions(root): + versions = {} + for path, components in MANIFESTS: + try: + data = json.loads((root / path).read_text()) + versions[path] = json_version(data, components) + except Exception as exc: + raise ValueError(f"{path}: cannot read plugin version: {exc}") from exc + return versions + + +def base_versions(root, base): + versions = {} + for path, components in MANIFESTS: + exists = subprocess.run( + ["git", "cat-file", "-e", f"{base}:{path}"], + cwd=root, + capture_output=True, + text=True, + check=False, + ) + if exists.returncode != 0: + continue + result = subprocess.run( + ["git", "show", f"{base}:{path}"], + cwd=root, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise ValueError(f"{path}: cannot read version from base {base}: {result.stderr.strip()}") + try: + versions[path] = json_version(json.loads(result.stdout), components) + except Exception as exc: + raise ValueError(f"{path}: invalid version at base {base}: {exc}") from exc + return versions + + +def common_version(versions, label): + unique = set(versions.values()) + if len(unique) != 1: + details = ", ".join(f"{path}={version}" for path, version in versions.items()) + raise ValueError(f"{label} plugin versions do not agree: {details}") + return unique.pop() + + +def payload_changed(root, base): + result = subprocess.run( + ["git", "diff", "--quiet", base, "--", *PAYLOAD_PATHS], + cwd=root, + check=False, + ) + if result.returncode not in (0, 1): + raise ValueError(f"cannot compare distributed skill payload with base {base}") + if result.returncode == 1: + return True + untracked = subprocess.run( + ["git", "ls-files", "--others", "--exclude-standard", "--", *PAYLOAD_PATHS], + cwd=root, + capture_output=True, + text=True, + check=True, + ) + return bool(untracked.stdout.strip()) + + +def validate(root, base): + current = common_version(current_versions(root), "current") + if base and set(base) == {"0"}: + base = "" + if not base: + return f"Plugin manifests agree on version {current}; no git base provided" + + verify = subprocess.run( + ["git", "rev-parse", "--verify", f"{base}^{{commit}}"], + cwd=root, + capture_output=True, + text=True, + check=False, + ) + if verify.returncode != 0: + raise ValueError(f"git base is not available: {base}") + resolved_base = verify.stdout.strip() + baseline_versions = base_versions(root, resolved_base) + baseline = common_version(baseline_versions, f"base {base}") if baseline_versions else None + changed = payload_changed(root, resolved_base) + if changed and baseline is not None and current == baseline: + raise ValueError( + f"distributed skill payload changed from {base}, but plugin version remains {current}" + ) + if changed and baseline is None: + state = "new with no plugin version present at the base" + else: + state = "changed with a version bump" if changed else "unchanged" + return f"Plugin manifests agree on version {current}; payload is {state} from {base}" + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--base", default="", help="git commit to compare distributed skill payload against") + parser.add_argument("--root", type=Path, default=ROOT) + args = parser.parse_args() + try: + message = validate(args.root.resolve(), args.base) + except (OSError, subprocess.SubprocessError, ValueError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + raise SystemExit(1) + print(message) + + +if __name__ == "__main__": + main() diff --git a/scripts/internal/check-plugin-version_test.py b/scripts/internal/check-plugin-version_test.py new file mode 100644 index 00000000..ba3672b1 --- /dev/null +++ b/scripts/internal/check-plugin-version_test.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +import json +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts/internal/check-plugin-version.py" +MANIFESTS = ( + ".claude-plugin/plugin.json", + ".claude-plugin/marketplace.json", + "plugins/ralphex/.codex-plugin/plugin.json", + "plugins/ralphex/plugin.json", +) + + +class PluginVersionGateTest(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory(prefix="ralphex-plugin-version-gate-") + self.root = Path(self.tempdir.name) + for path in MANIFESTS: + target = self.root / path + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(ROOT / path, target) + for path in ("assets/claude/skills", "plugins/ralphex/skills"): + shutil.copytree(ROOT / path, self.root / path) + self.git("init", "-q") + self.set_versions("1.0.0") + self.git("add", ".") + self.git("-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-qm", "base") + self.base = self.git("rev-parse", "HEAD").stdout.strip() + + def tearDown(self): + self.tempdir.cleanup() + + def git(self, *args): + return subprocess.run( + ["git", *args], + cwd=self.root, + capture_output=True, + text=True, + check=True, + ) + + def set_versions(self, version): + for path in MANIFESTS: + manifest = self.root / path + data = json.loads(manifest.read_text()) + if path.endswith("marketplace.json"): + data["plugins"][0]["version"] = version + else: + data["version"] = version + manifest.write_text(json.dumps(data)) + + def run_gate(self, base=None): + command = ["python3", str(GATE), "--root", str(self.root)] + if base is not None: + command.extend(("--base", base)) + return subprocess.run(command, capture_output=True, text=True, check=False) + + def change_payload(self): + path = self.root / "plugins/ralphex/skills/ralphex/SKILL.md" + path.write_text(path.read_text() + "\nchanged\n") + + def test_local_check_without_base_requires_consistent_versions(self): + result = self.run_gate() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("no git base provided", result.stdout) + + def test_zero_event_base_is_treated_as_unavailable(self): + result = self.run_gate("0" * 40) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("no git base provided", result.stdout) + + def test_unchanged_payload_allows_same_version(self): + result = self.run_gate(self.base) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("payload is unchanged", result.stdout) + + def test_changed_payload_requires_version_bump(self): + self.change_payload() + result = self.run_gate(self.base) + self.assertNotEqual(result.returncode, 0) + self.assertIn("plugin version remains 1.0.0", result.stderr) + + def test_changed_payload_accepts_consistent_version_bump(self): + self.change_payload() + self.set_versions("1.0.1") + result = self.run_gate(self.base) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("changed with a version bump", result.stdout) + + def test_base_may_precede_new_distribution_manifests(self): + saved = {} + for path in MANIFESTS[2:]: + manifest = self.root / path + saved[path] = manifest.read_text() + manifest.unlink() + self.git("add", "-u") + self.git("-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-qm", "partial base") + partial_base = self.git("rev-parse", "HEAD").stdout.strip() + for path, text in saved.items(): + (self.root / path).write_text(text) + self.change_payload() + self.set_versions("1.0.1") + + result = self.run_gate(partial_base) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("changed with a version bump", result.stdout) + + def test_untracked_payload_requires_version_bump(self): + path = self.root / "plugins/ralphex/skills/new-skill/SKILL.md" + path.parent.mkdir() + path.write_text("new") + result = self.run_gate(self.base) + self.assertNotEqual(result.returncode, 0) + self.assertIn("plugin version remains 1.0.0", result.stderr) + + def test_rejects_inconsistent_current_versions(self): + path = self.root / "plugins/ralphex/plugin.json" + data = json.loads(path.read_text()) + data["version"] = "2.0.0" + path.write_text(json.dumps(data)) + result = self.run_gate() + self.assertNotEqual(result.returncode, 0) + self.assertIn("current plugin versions do not agree", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/internal/update-plugin-version.sh b/scripts/internal/update-plugin-version.sh index 56f6765e..b0430ad5 100755 --- a/scripts/internal/update-plugin-version.sh +++ b/scripts/internal/update-plugin-version.sh @@ -1,35 +1,102 @@ #!/usr/bin/env bash set -euo pipefail -# Extract version from git tag (removes 'v' prefix) -VERSION="${1#v}" +MODE=write +if [ "${1:-}" = "--check" ]; then + MODE=check + shift +fi + +# Accept a plugin version with an optional v prefix. +VERSION="${1:-}" +VERSION="${VERSION#v}" if [ -z "$VERSION" ]; then - echo "Usage: $0 <version>" + echo "Usage: $0 [--check] <version>" >&2 + exit 1 +fi + +if [ "$#" -ne 1 ]; then + echo "Usage: $0 [--check] <version>" >&2 exit 1 fi -# Update plugin.json -if [ -f ".claude-plugin/plugin.json" ]; then +status=0 + +check_json_version() { + local file="$1" + local python_path="$2" + local actual + + actual=$(python3 - "$file" "$python_path" <<'PY' +import json +import sys + +value = json.load(open(sys.argv[1])) +for component in sys.argv[2].split("."): + value = value[int(component)] if component.isdigit() else value[component] +print(value) +PY + ) + if [ "$actual" != "$VERSION" ]; then + echo "Version mismatch: $file has $actual, expected $VERSION" >&2 + status=1 + fi +} + +update_json_version() { + local file="$1" + local filter="$2" + # Use jq if available, otherwise sed - if command -v jq &> /dev/null; then - jq --arg v "$VERSION" '.version = $v' .claude-plugin/plugin.json > .claude-plugin/plugin.json.tmp - mv .claude-plugin/plugin.json.tmp .claude-plugin/plugin.json + if [ "${RALPHEX_FORCE_SED:-0}" != "1" ] && command -v jq &> /dev/null; then + jq --arg v "$VERSION" "$filter" "$file" > "$file.tmp" + mv "$file.tmp" "$file" else - sed -i.bak "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" .claude-plugin/plugin.json - rm .claude-plugin/plugin.json.bak + # Fallback assumes each supported manifest contains exactly one version + # field. Keep that invariant or replace this with a format-aware updater. + sed -i.bak "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" "$file" + rm "$file.bak" fi - echo "Updated plugin.json to version $VERSION" -fi +} + +update_or_check() { + local file="$1" + local jq_filter="$2" + local python_path="$3" + local label="$4" -# Update marketplace.json -if [ -f ".claude-plugin/marketplace.json" ]; then - if command -v jq &> /dev/null; then - jq --arg v "$VERSION" '.plugins[0].version = $v' .claude-plugin/marketplace.json > .claude-plugin/marketplace.json.tmp - mv .claude-plugin/marketplace.json.tmp .claude-plugin/marketplace.json + if [ "$MODE" = check ]; then + check_json_version "$file" "$python_path" else - sed -i.bak "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" .claude-plugin/marketplace.json - rm .claude-plugin/marketplace.json.bak + update_json_version "$file" "$jq_filter" + echo "Updated $label to version $VERSION" + fi +} + +for file in \ + .claude-plugin/plugin.json \ + .claude-plugin/marketplace.json \ + plugins/ralphex/.codex-plugin/plugin.json \ + plugins/ralphex/plugin.json; do + if [ ! -f "$file" ]; then + echo "Missing version manifest: $file" >&2 + status=1 fi - echo "Updated marketplace.json to version $VERSION" +done +if [ "$status" -ne 0 ]; then + exit "$status" +fi + +update_or_check .claude-plugin/plugin.json ".version = \$v" version "Claude plugin.json" +update_or_check .claude-plugin/marketplace.json ".plugins[0].version = \$v" plugins.0.version "Claude marketplace.json" +update_or_check plugins/ralphex/.codex-plugin/plugin.json ".version = \$v" version "Codex plugin.json" +update_or_check plugins/ralphex/plugin.json ".version = \$v" version "portable plugin.json" + +if [ "$status" -ne 0 ]; then + exit "$status" +fi + +if [ "$MODE" = check ]; then + echo "Plugin manifests match expected version $VERSION" fi diff --git a/scripts/internal/update-plugin-version_test.sh b/scripts/internal/update-plugin-version_test.sh new file mode 100755 index 00000000..dd1c9dc7 --- /dev/null +++ b/scripts/internal/update-plugin-version_test.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +REPO_ROOT=$(cd "$SCRIPT_DIR/../.." && pwd) + +assert_fixture() { + local fixture="$1" + local expected="$2" + + python3 - "$fixture" "$expected" <<'PY' +import json +import pathlib +import sys + +root = pathlib.Path(sys.argv[1]) +expected = sys.argv[2] +claude = json.loads((root / ".claude-plugin/plugin.json").read_text()) +market = json.loads((root / ".claude-plugin/marketplace.json").read_text()) +codex = json.loads((root / "plugins/ralphex/.codex-plugin/plugin.json").read_text()) +portable = json.loads((root / "plugins/ralphex/plugin.json").read_text()) + +assert claude["version"] == expected +assert market["plugins"][0]["version"] == expected +assert codex["version"] == expected +assert portable["version"] == expected +assert claude["description"] == "Autonomous plan execution with Claude Code - task execution, monitoring, and plan creation" +assert market["plugins"][0]["description"] == "Autonomous plan execution with Claude Code - task execution, monitoring, and plan creation" +assert codex["description"] == "Plan-driven implementation, review, adoption, and configuration workflows for Ralphex" +assert codex["interface"]["brandColor"] == "#D97706" +PY +} + +assert_check_fails() { + local fixture="$1" + local expected_version="$2" + local expected_message="$3" + local output + + if output=$(cd "$fixture" && ./scripts/internal/update-plugin-version.sh --check "$expected_version" 2>&1); then + echo "expected version check to fail" >&2 + exit 1 + fi + case "$output" in + *"$expected_message"*) ;; + *) + echo "unexpected version check output: $output" >&2 + exit 1 + ;; + esac +} + +corrupt_version() { + local fixture="$1" + local file="$2" + local python_path="$3" + + python3 - "$fixture/$file" "$python_path" <<'PY' +import json +import pathlib +import sys + +path = pathlib.Path(sys.argv[1]) +data = json.loads(path.read_text()) +target = data +components = sys.argv[2].split(".") +for component in components[:-1]: + target = target[int(component)] if component.isdigit() else target[component] +last = components[-1] +if last.isdigit(): + target[int(last)] = "9.9.9" +else: + target[last] = "9.9.9" +path.write_text(json.dumps(data)) +PY +} + +make_fixture() { + local fixture + fixture=$(mktemp -d "${TMPDIR:-/tmp}/ralphex-version-test-XXXXXX") + mkdir -p "$fixture/plugins/ralphex" "$fixture/scripts/internal" + cp -R "$REPO_ROOT/.claude-plugin" "$fixture/.claude-plugin" + cp -R "$REPO_ROOT/plugins/ralphex/.codex-plugin" "$fixture/plugins/ralphex/.codex-plugin" + cp "$REPO_ROOT/plugins/ralphex/plugin.json" "$fixture/plugins/ralphex/plugin.json" + cp "$REPO_ROOT/scripts/internal/update-plugin-version.sh" "$fixture/scripts/internal/" + printf '%s\n' "$fixture" +} + +jq_fixture=$(make_fixture) +sed_fixture=$(make_fixture) +missing_fixture=$(make_fixture) +claude_fixture=$(make_fixture) +market_fixture=$(make_fixture) +codex_fixture=$(make_fixture) +portable_fixture=$(make_fixture) +trap 'rm -rf "$jq_fixture" "$sed_fixture" "$missing_fixture" "$claude_fixture" "$market_fixture" "$codex_fixture" "$portable_fixture"' EXIT + +( + cd "$jq_fixture" + ./scripts/internal/update-plugin-version.sh v1.2.3 +) +assert_fixture "$jq_fixture" "1.2.3" +( + cd "$jq_fixture" + ./scripts/internal/update-plugin-version.sh --check v1.2.3 +) +assert_fixture "$jq_fixture" "1.2.3" + +( + cd "$sed_fixture" + RALPHEX_FORCE_SED=1 ./scripts/internal/update-plugin-version.sh v2.3.4 +) +assert_fixture "$sed_fixture" "2.3.4" + +rm "$missing_fixture/plugins/ralphex/plugin.json" +missing_version=$(python3 -c 'import json, sys; print(json.load(open(sys.argv[1]))["version"])' "$missing_fixture/.claude-plugin/plugin.json") +assert_check_fails "$missing_fixture" "$missing_version" "Missing version manifest" + +for fixture in "$claude_fixture" "$market_fixture" "$codex_fixture" "$portable_fixture"; do + ( + cd "$fixture" + ./scripts/internal/update-plugin-version.sh 1.2.3 >/dev/null + ) +done + +corrupt_version "$claude_fixture" .claude-plugin/plugin.json version +assert_check_fails "$claude_fixture" "1.2.3" ".claude-plugin/plugin.json has 9.9.9" + +corrupt_version "$market_fixture" .claude-plugin/marketplace.json plugins.0.version +assert_check_fails "$market_fixture" "1.2.3" ".claude-plugin/marketplace.json has 9.9.9" + +corrupt_version "$codex_fixture" plugins/ralphex/.codex-plugin/plugin.json version +assert_check_fails "$codex_fixture" "1.2.3" "plugins/ralphex/.codex-plugin/plugin.json has 9.9.9" + +corrupt_version "$portable_fixture" plugins/ralphex/plugin.json version +assert_check_fails "$portable_fixture" "1.2.3" "plugins/ralphex/plugin.json has 9.9.9" + +echo "update-plugin-version tests passed" diff --git a/scripts/validate-portable-plugin.py b/scripts/validate-portable-plugin.py new file mode 100644 index 00000000..c29f7345 --- /dev/null +++ b/scripts/validate-portable-plugin.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +import re +import stat +import sys +from pathlib import Path + +import yaml + +SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json" +FIELDS = {"$schema", "name", "version", "description", "author", "homepage", "repository", "license", "keywords", "extensions"} +SKILL_FIELDS = {"name", "description", "license", "compatibility", "metadata", "allowed-tools"} +AGENT_FIELDS = {"interface", "policy"} +AGENT_INTERFACE_FIELDS = {"display_name", "short_description", "default_prompt"} +AGENT_POLICY_FIELDS = {"allow_implicit_invocation"} +NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9.-]{0,62}[a-z0-9])?$") + + +def read_regular_text(path, errors): + try: + mode = path.lstat().st_mode + except OSError as exc: + errors.append(f"{path}: cannot inspect file: {exc}") + return None + if not stat.S_ISREG(mode): + errors.append(f"{path}: expected a regular file") + return None + try: + return path.read_text() + except OSError as exc: + errors.append(f"{path}: cannot read file: {exc}") + return None + + +def load_json(path, errors): + text = read_regular_text(path, errors) + if text is None: + return None + try: + data = json.loads(text) + except Exception as exc: + errors.append(f"{path}: invalid JSON: {exc}") + return None + if not isinstance(data, dict): + errors.append(f"{path}: expected a JSON object") + return None + return data + + +def checked_directory(root, base, value, label, errors, expected=None): + if not isinstance(value, str) or not value: + errors.append(f"{label}: path must be a non-empty string") + return None + relative = Path(value) + if relative.is_absolute(): + errors.append(f"{label}: path must be relative: {value}") + return None + + root = Path(os.path.abspath(root)) + candidate = Path(os.path.abspath(base / relative)) + if not candidate.is_relative_to(root): + errors.append(f"{label}: path escapes repository root: {value}") + return None + if expected is not None and candidate != expected: + errors.append(f"{label}: path must resolve to {expected}: {value}") + return None + + current = root + for component in candidate.relative_to(root).parts: + current /= component + try: + mode = current.lstat().st_mode + except OSError as exc: + errors.append(f"{label}: referenced path cannot be inspected: {value}: {exc}") + return None + if stat.S_ISLNK(mode): + errors.append(f"{label}: symlink path component is not allowed: {current}") + return None + if not stat.S_ISDIR(mode): + errors.append(f"{label}: referenced path is not a directory: {value}") + return None + return candidate + + +def marketplace_plugin_roots(root, errors): + path = root / ".agents/plugins/marketplace.json" + marketplace = load_json(path, errors) + if marketplace is None: + return [] + plugins = marketplace.get("plugins") + if not isinstance(plugins, list) or not plugins: + errors.append(f"{path}: plugins must be a non-empty list") + return [] + roots = [] + for index, entry in enumerate(plugins): + label = f"{path}: plugins[{index}]" + if not isinstance(entry, dict): + errors.append(f"{label}: expected an object") + continue + name = entry.get("name") + if not isinstance(name, str) or not NAME_RE.fullmatch(name): + errors.append(f"{label}: invalid name") + source = entry.get("source") + if not isinstance(source, dict) or source.get("source") != "local": + errors.append(f"{label}: source must be a local source object") + continue + plugin_root = checked_directory(root, root, source.get("path"), label, errors) + if plugin_root is not None: + roots.append((name, plugin_root)) + return roots + + +def validate_agent_yaml(agent_path, errors): + text = read_regular_text(agent_path, errors) + if text is None: + return + try: + data = yaml.safe_load(text) + except Exception as exc: + errors.append(f"{agent_path}: invalid YAML: {exc}") + return + if not isinstance(data, dict): + errors.append(f"{agent_path}: expected a mapping") + return + if set(data) - AGENT_FIELDS: + errors.append(f"{agent_path}: unsupported fields {sorted(set(data) - AGENT_FIELDS)}") + interface = data.get("interface") + if not isinstance(interface, dict): + errors.append(f"{agent_path}: interface must be a mapping") + else: + if set(interface) - AGENT_INTERFACE_FIELDS: + errors.append(f"{agent_path}: unsupported interface fields {sorted(set(interface) - AGENT_INTERFACE_FIELDS)}") + for key in AGENT_INTERFACE_FIELDS: + if not isinstance(interface.get(key), str) or not interface[key].strip(): + errors.append(f"{agent_path}: interface.{key} must be a non-empty string") + policy = data.get("policy") + if not isinstance(policy, dict): + errors.append(f"{agent_path}: policy must be a mapping") + else: + if set(policy) - AGENT_POLICY_FIELDS: + errors.append(f"{agent_path}: unsupported policy fields {sorted(set(policy) - AGENT_POLICY_FIELDS)}") + if policy.get("allow_implicit_invocation") is not False: + errors.append(f"{agent_path}: policy.allow_implicit_invocation must be false") + + +def preflight_plugin_tree(plugin_root, errors): + safe = True + pending = [plugin_root] + while pending: + directory = pending.pop() + try: + with os.scandir(directory) as entries: + for entry in entries: + if entry.is_symlink(): + errors.append(f"{entry.path}: symlinks are not allowed in plugin tree") + safe = False + elif entry.is_dir(follow_symlinks=False): + pending.append(Path(entry.path)) + except OSError as exc: + errors.append(f"{directory}: cannot inspect plugin tree: {exc}") + safe = False + return safe + + +def validate_plugin(root, expected_name, plugin_root, errors): + if not preflight_plugin_tree(plugin_root, errors): + return + portable_path = plugin_root / "plugin.json" + legacy_path = plugin_root / ".codex-plugin/plugin.json" + portable = load_json(portable_path, errors) + legacy = load_json(legacy_path, errors) + if portable is None or legacy is None: + return + if portable.get("$schema") != SCHEMA: + errors.append(f"{portable_path}: invalid $schema") + if set(portable) - FIELDS: + errors.append(f"{portable_path}: unsupported fields {sorted(set(portable) - FIELDS)}") + name = portable.get("name") + if not isinstance(name, str) or not NAME_RE.fullmatch(name) or "--" in name or ".." in name: + errors.append(f"{portable_path}: invalid name") + if expected_name != name: + errors.append(f"{plugin_root}: marketplace/plugin name mismatch") + for key in ("name", "version", "description", "author", "homepage", "repository", "license", "keywords"): + if portable.get(key) != legacy.get(key): + errors.append(f"{plugin_root}: portable/Codex metadata mismatch: {key}") + + fixed_skills = plugin_root / "skills" + skills = checked_directory( + root, + plugin_root, + "skills", + f"{plugin_root}: fixed skills directory", + errors, + expected=fixed_skills, + ) + legacy_skills = checked_directory( + root, + plugin_root, + legacy.get("skills"), + f"{legacy_path}: skills", + errors, + expected=fixed_skills, + ) + if skills is None or legacy_skills is None: + return + + skill_files = [] + agent_files = [] + try: + with os.scandir(skills) as iterator: + entries = sorted(iterator, key=lambda entry: entry.name) + except OSError as exc: + errors.append(f"{skills}: cannot inspect skills directory: {exc}") + return + for entry in entries: + if entry.is_symlink(): + errors.append(f"{entry.path}: symlinks are not allowed in skills") + continue + if not entry.is_dir(follow_symlinks=False): + errors.append(f"{entry.path}: expected a skill directory") + continue + skill_dir = Path(entry.path) + skill_file = skill_dir / "SKILL.md" + agent_file = skill_dir / "agents/openai.yaml" + skill_files.append(skill_file) + agent_files.append(agent_file) + + if not skill_files: + errors.append(f"{skills}: no skills found") + for skill_file in skill_files: + text = read_regular_text(skill_file, errors) + if text is None: + continue + match = re.match(r"^---\n(.*?)\n---\n", text, re.S) + if not match: + errors.append(f"{skill_file}: invalid frontmatter") + continue + try: + data = yaml.safe_load(match.group(1)) or {} + except Exception as exc: + errors.append(f"{skill_file}: invalid YAML: {exc}") + continue + if not isinstance(data, dict): + errors.append(f"{skill_file}: frontmatter must be a mapping") + continue + if set(data) - SKILL_FIELDS: + errors.append(f"{skill_file}: unsupported frontmatter fields") + if data.get("name") != skill_file.parent.name: + errors.append(f"{skill_file}: name/directory mismatch") + description = data.get("description") + if not isinstance(description, str) or not 1 <= len(description) <= 1024: + errors.append(f"{skill_file}: invalid description") + if "allowed-tools" in data and not isinstance(data["allowed-tools"], str): + errors.append(f"{skill_file}: allowed-tools must be a string") + metadata = data.get("metadata") + if metadata is not None and (not isinstance(metadata, dict) or any(not isinstance(k, str) or not isinstance(v, str) for k, v in metadata.items())): + errors.append(f"{skill_file}: metadata values must be strings") + if len(text.splitlines()) > 500: + print(f"WARNING: {skill_file}: exceeds recommended 500 lines", file=sys.stderr) + for agent_file in agent_files: + validate_agent_yaml(agent_file, errors) + + +def validate(root): + root = Path(os.path.abspath(root)) + errors = [] + marketplace_roots = marketplace_plugin_roots(root, errors) + seen = set() + for name, plugin_root in marketplace_roots: + if plugin_root in seen: + errors.append(f"{plugin_root}: duplicate marketplace plugin path") + continue + seen.add(plugin_root) + validate_plugin(root, name, plugin_root, errors) + return errors + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--root", type=Path, default=Path(__file__).absolute().parents[1]) + args = parser.parse_args() + errors = validate(args.root) + if errors: + print("\n".join(f"ERROR: {error}" for error in errors), file=sys.stderr) + raise SystemExit(1) + print("Portable plugin validation passed") + + +if __name__ == "__main__": + main() diff --git a/scripts/validate-portable-plugin_test.py b/scripts/validate-portable-plugin_test.py new file mode 100644 index 00000000..54366866 --- /dev/null +++ b/scripts/validate-portable-plugin_test.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +import json +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +VALIDATOR = ROOT / "scripts/validate-portable-plugin.py" + + +class PortablePluginValidatorTest(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory(prefix="ralphex-plugin-validator-") + self.root = Path(self.tempdir.name) + shutil.copytree(ROOT / ".agents", self.root / ".agents") + shutil.copytree(ROOT / "plugins", self.root / "plugins") + + def tearDown(self): + self.tempdir.cleanup() + + def run_validator(self, timeout=5): + return subprocess.run( + ["python3", str(VALIDATOR), "--root", str(self.root)], + capture_output=True, + text=True, + check=False, + timeout=timeout, + ) + + def test_valid_fixture(self): + result = self.run_validator() + self.assertEqual(result.returncode, 0, result.stderr) + + def test_rejects_missing_marketplace_path(self): + path = self.root / ".agents/plugins/marketplace.json" + data = json.loads(path.read_text()) + data["plugins"][0]["source"]["path"] = "./plugins/missing" + path.write_text(json.dumps(data)) + result = self.run_validator() + self.assertNotEqual(result.returncode, 0) + self.assertIn("referenced path cannot be inspected", result.stderr) + + def test_rejects_implicit_invocation(self): + path = self.root / "plugins/ralphex/skills/ralphex/agents/openai.yaml" + data = yaml.safe_load(path.read_text()) + data["policy"]["allow_implicit_invocation"] = True + path.write_text(yaml.safe_dump(data)) + result = self.run_validator() + self.assertNotEqual(result.returncode, 0) + self.assertIn("allow_implicit_invocation must be false", result.stderr) + + def test_rejects_invalid_agent_shape(self): + path = self.root / "plugins/ralphex/skills/ralphex/agents/openai.yaml" + data = yaml.safe_load(path.read_text()) + data["interface"]["default_prompt"] = ["not", "a", "string"] + path.write_text(yaml.safe_dump(data)) + result = self.run_validator() + self.assertNotEqual(result.returncode, 0) + self.assertIn("interface.default_prompt must be a non-empty string", result.stderr) + + def test_rejects_missing_agent_manifest(self): + path = self.root / "plugins/ralphex/skills/ralphex/agents/openai.yaml" + path.unlink() + result = self.run_validator() + self.assertNotEqual(result.returncode, 0) + self.assertIn("cannot inspect file", result.stderr) + + def test_rejects_non_local_marketplace_source(self): + path = self.root / ".agents/plugins/marketplace.json" + data = json.loads(path.read_text()) + data["plugins"][0]["source"]["source"] = "git" + path.write_text(json.dumps(data)) + result = self.run_validator() + self.assertNotEqual(result.returncode, 0) + self.assertIn("source must be a local source object", result.stderr) + + def test_rejects_marketplace_name_mismatch(self): + path = self.root / ".agents/plugins/marketplace.json" + data = json.loads(path.read_text()) + data["plugins"][0]["name"] = "different-name" + path.write_text(json.dumps(data)) + result = self.run_validator() + self.assertNotEqual(result.returncode, 0) + self.assertIn("marketplace/plugin name mismatch", result.stderr) + + def test_rejects_unsupported_agent_field(self): + path = self.root / "plugins/ralphex/skills/ralphex/agents/openai.yaml" + data = yaml.safe_load(path.read_text()) + data["unsupported"] = True + path.write_text(yaml.safe_dump(data)) + result = self.run_validator() + self.assertNotEqual(result.returncode, 0) + self.assertIn("unsupported fields", result.stderr) + + def test_rejects_skills_escape_without_reading_external_fifo(self): + plugin_root = self.root / "plugins/ralphex" + outside = self.root.parent / f"{self.root.name}-outside" + outside.mkdir() + fifo = outside / "SKILL.md" + os.mkfifo(fifo) + shutil.rmtree(plugin_root / "skills") + (plugin_root / "skills").symlink_to(outside, target_is_directory=True) + self.addCleanup(shutil.rmtree, outside) + + result = self.run_validator(timeout=2) + self.assertNotEqual(result.returncode, 0) + self.assertIn("symlinks are not allowed in plugin tree", result.stderr) + + def test_rejects_legacy_skills_pointer_outside_fixed_directory(self): + path = self.root / "plugins/ralphex/.codex-plugin/plugin.json" + data = json.loads(path.read_text()) + data["skills"] = "../ralphex-copy/skills" + path.write_text(json.dumps(data)) + result = self.run_validator() + self.assertNotEqual(result.returncode, 0) + self.assertIn("path must resolve to", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/validate-skill-contracts_test.py b/scripts/validate-skill-contracts_test.py new file mode 100644 index 00000000..01115c06 --- /dev/null +++ b/scripts/validate-skill-contracts_test.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +CODEX_RUN = ROOT / "plugins/ralphex/skills/ralphex/SKILL.md" +CLAUDE_RUN = ROOT / "assets/claude/skills/ralphex/SKILL.md" +CODEX_ADOPT = ROOT / "plugins/ralphex/skills/ralphex-adopt/SKILL.md" + + +class DistributedSkillContractTest(unittest.TestCase): + def setUp(self): + self.codex = CODEX_RUN.read_text() + self.claude = CLAUDE_RUN.read_text() + + def test_executor_modes_match_cli_contract(self): + for text in (self.codex, self.claude): + self.assertIn("`--codex` is the first-class Codex executor flag", text) + self.assertIn("deprecated `--codex-only` alias for `--external-only`", text) + self.assertIn("--external-only", text) + + def test_review_checkout_is_fail_closed(self): + for text in (self.codex, self.claude): + self.assertIn('Do not offer "Proceed anyway"', text) + self.assertIn("Require `git status --porcelain=v1` to be empty", text) + self.assertIn("Require a non-empty committed `base...HEAD` file diff", text) + self.assertIn("repeat every applicable Step 6 check", text) + + def test_repository_executable_overrides_are_blocked(self): + for text in (self.codex, self.claude): + for key in ("claude_command", "codex_command", "custom_review_script", "vcs_command"): + self.assertIn(f"`{key}`", text) + self.assertIn("Do not offer a proceed/override choice", text) + + def test_plan_is_separated_from_options(self): + self.assertIn('"--", plan-file]', self.codex) + self.assertIn("-- '<normalized-plan-file>'", self.claude) + for text in (self.codex, self.claude): + self.assertIn("`--` must immediately precede", text) + + def test_launch_requires_process_and_fresh_progress_evidence(self): + self.assertIn("Poll the saved background session", self.codex) + self.assertIn("Use TaskOutput with `block: false`", self.claude) + for text in (self.codex, self.claude): + self.assertIn("fresh progress evidence", text) + self.assertIn("headers alone are not proof", text) + + def test_adopt_treats_dynamic_arguments_as_opaque(self): + text = CODEX_ADOPT.read_text() + self.assertIn("Treat every repository/user-controlled URL", text) + self.assertIn("Never interpolate these values into a shell command", text) + self.assertIn("never use `eval`", text) + + +if __name__ == "__main__": + unittest.main()