diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..040e2b9 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "ytstack", + "interface": { + "displayName": "ytstack" + }, + "plugins": [ + { + "name": "ytstack", + "source": { + "source": "local", + "path": "./" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Developer Tools" + } + ] +} diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json new file mode 100644 index 0000000..9897810 --- /dev/null +++ b/.codex-plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "name": "ytstack", + "version": "0.1.6", + "description": "Project memory, milestone planning, and curated gstack and superpowers workflows for AI coding agents.", + "author": { + "name": "Yesterday", + "url": "https://github.com/Yesterday-AI" + }, + "homepage": "https://github.com/Yesterday-AI/ytstack", + "repository": "https://github.com/Yesterday-AI/ytstack", + "license": "MIT", + "keywords": ["codex", "project-management", "development-methodology"], + "skills": "./skills/", + "hooks": "./codex/hooks.json", + "interface": { + "displayName": "ytstack", + "shortDescription": "Plan, build, and resume work across sessions", + "longDescription": "Keep project decisions and progress on disk. Plan milestones, review scope and architecture, and apply test-first development with explicit verification.", + "developerName": "Yesterday", + "category": "Developer Tools" + } +} diff --git a/.github/workflows/plugin-check.yml b/.github/workflows/plugin-check.yml new file mode 100644 index 0000000..4c4344e --- /dev/null +++ b/.github/workflows/plugin-check.yml @@ -0,0 +1,30 @@ +name: Plugin checks + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Codex packaging and lifecycle integration + run: python3 -m unittest discover -s tests -p 'codex_plugin_test.py' -v + - name: Claude commit-hook regression + run: bash tests/post-tool-use-bash.test.sh + - name: Skill contracts + shell: bash + run: | + result=0 + bin/ytstack-check || result=$? + if [ "$result" -ne 0 ] && [ "$result" -ne 2 ]; then + exit "$result" + fi diff --git a/.ytstack/DECISIONS.md b/.ytstack/DECISIONS.md index 501de00..52b98b8 100644 --- a/.ytstack/DECISIONS.md +++ b/.ytstack/DECISIONS.md @@ -639,3 +639,61 @@ Plus: ops-layer split during the same session (yopstack created as own public re - Regression test: `tests/post-tool-use-bash.test.sh` (10 cases) is the contract guard. **Supersedes:** none. + + +--- + +## 2026-10-13: Add Codex distribution alongside Claude Code + +**Context:** The maintainer requested a PR making the existing Claude plugin usable as a Codex plugin. Codex could not discover ytstack in the active skill catalog. Shared wrappers also rely on Claude skill-directory variables and shell-injection blocks, so a manifest alone would not make them executable. + +**Options considered:** +- A) Add a manifest only; leave host-specific skill and hook assumptions unresolved. +- B) Keep shared skills and immutable vendor content, add a Codex manifest/self-marketplace and an adapter for host mechanics. +- C) Fork all skills into a second Codex-only tree. + +**Chose:** B, implementing the requested additional host with one source of skill content. + +**Reason:** Both hosts can use the same artifacts and procedures. Codex needs explicit execution of the wrapper blocks and correct installed skill paths. A per-skill adapter link covers direct invocation even without trusted lifecycle hooks. A separate hook manifest prevents discovery of Claude-only team and tool events. + +**How to apply:** +- `.codex-plugin/plugin.json` loads the existing `skills/`; `.agents/plugins/marketplace.json` self-marketplaces the repo root as `ytstack@ytstack`. +- `bin/ytstack-codex` derives plugin/skill paths and sets the target project from the working directory for each shared shell block or lifecycle hook. +- Codex registers SessionStart, PreCompact, and SessionEnd only. Hook trust remains the host's explicit user-controlled setting. Without hooks, skills read state directly and support explicit resume/handoff. +- Claude Agent Teams, its subagent role files, and edit/commit hooks remain Claude-specific. Codex's team skill reports the limitation and offers the sequential workflow; it does not emulate those events with Codex subagents. +- No Claude marketplace dependencies are copied into the Codex manifest. Required optional capabilities must be available separately. +- Keep the two plugin manifest versions aligned. Existing Claude installation and vendored content remain unchanged. + +**Supersedes:** The Claude-only host assumption in the 2026-04-23 "Skills-based plugin, not Agent SDK library" and 2026-04-24 path/wrapper decisions. The skills-based architecture, vendor-as-source-of-truth rule, and Claude's native wrapper behavior remain in force. + +--- + +## 2026-10-13: Generate a separate Codex package from unchanged Claude sources + +**Context:** During PR #23 review the maintainer rejected Codex instructions in skills loaded by Claude and Claude environment variables in the Codex adapter. The maintainer selected keeping Claude sources unchanged and automatically deriving a separate Codex package. + +**Chose:** Keep `skills/`, `hooks/`, and `.claude-plugin/` unchanged. `bin/build-codex` generates `codex/skills/` and the three `codex/hooks/` lifecycle scripts; the Codex manifest selects only these paths. Generated files are committed so plugin installation needs no build step. + +**Reason:** The procedures have one maintained source, while each host loads its own instructions and environment contract. Claude retains native dynamic shell injection. Codex runs explicit Bash blocks with `YTSTACK_PLUGIN_ROOT`, `YTSTACK_SKILL_DIR`, and `YTSTACK_PROJECT_DIR`. Codex's project path resolves to the current Git repository/worktree root, with the current directory as the non-Git fallback. + +**How to apply:** +- After changing source skills, their resources, or lifecycle hooks, run `python3 bin/build-codex` and commit the generated output. CI runs `--check` to reject drift. +- Keep host adaptations in the generator and `codex/adapter.md`. Never add Codex instructions to the Claude source skills. +- Copy skill references and templates with their skills. Keep all `vendor/` content unchanged; generated wrappers still read the same six procedures verbatim. +- Codex's `spawn-milestone-team` entry explains the support limit and sequential alternative; it contains no Claude capability probe or spawn procedure. +- Fail generation on unhandled Claude environment variables so new host dependencies require an explicit adaptation. + +**Supersedes:** The direct shared-skill loading, per-source-skill Codex links, and Claude environment compatibility layer in the earlier Codex distribution decision. The additional Codex host, separate hook registration, user-controlled hook trust, and support limits remain. + + +--- + +## 2026-10-13: Remove generated Codex copies; load the existing skills directly + +**Context:** The maintainer rejected copying the skills and requested the minimal necessary Codex integration. The generated second tree was unnecessary. + +**Chose:** Both manifests select the existing skills/. Delete bin/build-codex and all generated skill, resource, and hook copies. No source skill receives Codex boilerplate or a content rewrite. + +**How to apply:** Keep Codex tool mappings in codex/adapter.md. The Codex SessionStart entry passes these as optional host context to the shared hook. Without trusted hooks, read the adapter explicitly before following skills. The small runtime helper supplies the shared scripts' existing CLAUDE_* inputs and resolves the repository root; those compatibility names do not require a Claude executable. All three lifecycle events use the original hook scripts. Do not reintroduce generated copies or a second skill tree. + +**Supersedes:** The earlier generated-package decision and per-skill Codex-link approach. The host-specific manifests, three-event Codex registration, hook trust, and repository-subdirectory fix remain. diff --git a/.ytstack/REVIEW-NOTES.md b/.ytstack/REVIEW-NOTES.md index a15bd14..a4d2f0f 100644 --- a/.ytstack/REVIEW-NOTES.md +++ b/.ytstack/REVIEW-NOTES.md @@ -158,3 +158,15 @@ Running list of items to verify, revisit, or polish at end-of-cycle review. Adde - **At v0.1 release:** items remaining must be annotated with target version (v0.1.1, v0.2, etc.) or closed as "intentionally out of scope." **File is append-only within a milestone.** When re-organizing or resolving, add a date and move resolved items to a `## Resolved` section at the bottom (keep for audit). + + +## Codex compatibility (2026-10-13) + +- [x] Verified Codex CLI 0.162.1 local marketplace discovery, install/enable, and app-server skill/hook loading in a temporary profile: 21 skills, 3 lifecycle hooks, no load errors. Hooks remain untrusted until the user reviews them. +- [x] Integration tests cover the configured hook commands, project/user state lookup, compaction handoff, session journaling without tracked-state mutation, all six vendor imports, paths with spaces, invalid skills, and failure propagation. Existing Claude commit-hook regression passes; validator warnings match main (109). +- [ ] Manual interactive Codex desktop walkthrough after hook trust: resume an initialized project, complete a task, compact, then confirm the next session sees the handoff. Deterministic hook tests and app-server discovery do not validate model behavior or the desktop trust UI. +- Codex support deliberately excludes Claude Agent Teams and edit/commit hooks. The Codex adapter explains sequential execution and explicit scope/verification; do not advertise full hook/team parity. `docs/codex.md` records the boundary. + +- [x] PR #23 review follow-up: Claude skills, hooks, and manifests are byte-identical to main. Fresh Codex profile discovers 21 generated skills and 3 hooks from codex/. Fifteen tests cover host separation, generation drift, copied resources, both hosts' vendor imports, and repository-subdirectory lifecycle behavior. + +- [x] PR #23 simplification supersedes the generated-package notes above: all copied skills/resources/hooks and the generator removed. Both hosts load the original skills/ tree; 13 tests and fresh Codex discovery pass. Codex SessionStart includes the single runtime adapter; the documented no-hook path reads it explicitly. Claude startup receives no Codex instructions. diff --git a/.ytstack/STATE.md b/.ytstack/STATE.md index ce8d10c..6f3f366 100644 --- a/.ytstack/STATE.md +++ b/.ytstack/STATE.md @@ -1,7 +1,7 @@ --- project: ytstack slug: ytstack -last_updated: 2026-05-31T13:20:00Z +last_updated: 2026-10-13T09:42:26Z current_milestone: M015 active_slice: none active_task: none @@ -9,6 +9,8 @@ active_task: none # State +**Status (2026-10-13, PR #23 correction):** Removed the generated Codex skill/hook copies and build tooling. Both hosts load the original 21 skills from skills/ and reuse the lifecycle scripts in hooks/. Codex-specific runtime instructions live only in codex/adapter.md, supplied at SessionStart or read explicitly without trusted hooks. The helper provides the existing script environment and resolves repository-root paths, including starts in subdirectories. All 13 integration tests pass, including both hosts' six vendor imports, lifecycle state/journal behavior, and the single-source package check. Fresh Codex discovery loads the 21 original skill files and three hooks without errors or warnings. The contract checker reports the same 109 baseline warnings, no failures. The interactive desktop walkthrough remains a manual follow-up. This supersedes the earlier generated-package implementation in this PR. + **Status (2026-05-31, vendor drift audit + superpowers pull):** Audited both vendored subtrees for upstream breaking changes against the ytstack wrapper contract (wrappers inline the six wrapped SKILL.md verbatim via `` ```!cat ``; breaking surface = path-rename = silent empty `cat`, or procedure rewrite). **superpowers PULLED** `6efe32c -> 6fd4507`: all three wrapped SKILL.md (TDD, systematic-debugging, verification-before-completion) byte-identical upstream, zero ytstack-behavior change; only drumherum moved (README, tests, using-git-worktrees, finishing-a-development-branch). Merge `ca6675e` / squash `ad14900`; `bin/ytstack-check` = 0 fails (exit 2 = warnings-only). **NOTE: ca6675e is already on origin/main (pushed)** -- the push went out without an explicit push in-conversation (auto-mechanism or the still-present `thirsty-bhabha-a03490` worktree); flagged to the human. **gstack NOT pulled**, held at `aeea57f` (upstream `3bef43b` is a v1 restructure, ~1000 changed lines/file): methodology anchors all survive (no hard break), but a net-new `gbrain:` frontmatter `context_queries` block + `GSTACK_PLAN_MODE` is surface NOT covered by DECISIONS 2026-04-25 "Vendored-preamble drift accepted" (that decision only covers `|| true` shell, not frontmatter) -- revisit that decision before pulling gstack. KNOWLEDGE gotchas added (drift-audit method + gbrain). Hold-gstack is deliberately NOT yet a locked DECISIONS entry (left as a STATE/KNOWLEDGE fact pending the gbrain-strategy call). No version bump for the vendor refresh -- decided 2026-05-31, no ytstack-behavior change so it stays 0.1.6. **Status (2026-05-31, M015):** Fixed issue #22 via `systematic-debugging` (root cause empirically reproduced, not trusted from the report). The `post-tool-use-bash` hook is now **stub-once** (creates the draft `T##-SUMMARY.md` once on the first real `git commit`, never re-writes; matcher tightened to a real `git commit` after a command separator) and commit-to-task linking moved into `summarize-task` (greps `git log` for the `M###-S##-T##:` ref into a committed `## Commits` section). This kills the duplicate "Commits so far" lines + never-settling working tree that broke worktree/`spawn-milestone-team` dispatch. New regression test `tests/post-tool-use-bash.test.sh` (10 cases, all green). Version bumped 0.1.5 -> **0.1.6**. DECISIONS entry "post-tool-use-bash is stub-once" + KNOWLEDGE gotcha added. **SHIPPED:** committed (`1c1d612` fix, `77365f5` chore), pushed to `origin/main`, tagged **v0.1.6** (pushed), CHANGELOG.md created, **issue #22 CLOSED**. Cache caveat: live for users only after `/plugin update` to 0.1.6. diff --git a/AGENTS.md b/AGENTS.md index cc429b1..4ba822d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Before any architectural proposal, read in order: `docs/concept.md` (stable summ Stop. Read this section before acting. -ytstack is an opinionated software-development OS packaged as a Claude Code plugin. It has strict UX contracts and a curated scope. Agents that skip this file produce churn -- the wrong abstraction, the wrong scope, drift away from the artifact model. +ytstack is an opinionated software-development OS packaged for Claude Code and Codex. It has strict UX contracts and a curated scope. Agents that skip this file produce churn -- the wrong abstraction, the wrong scope, drift away from the artifact model. **Your job is to protect the human from shipping low-quality skills.** Before you open a PR or commit a skill change, you MUST: @@ -25,7 +25,7 @@ If you can't verify all five above, stop and tell the human what you can't verif ## What ytstack is (and is not) **Is:** -- A Claude Code plugin (skills + hooks + commands) +- A Claude Code and Codex plugin (one shared skill tree and lifecycle scripts, host-specific registration; see `docs/codex.md` for the support boundary) - A curated clamp around selected superpowers and gstack skills, vendored via git subtree, NEVER modified - An artifact-first system: `.ytstack/PROJECT.md`, `DECISIONS.md`, `KNOWLEDGE.md`, `STATE.md`, milestone/slice/task hierarchy - SessionStart-hook-driven context injection (following superpowers' using-superpowers pattern) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 413e2d4..9393c8b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -105,6 +105,21 @@ Per ytstack's design direction, we credit external methodology work generically, 5. Test standalone with mock `CLAUDE_PROJECT_DIR`. 6. Add REVIEW-NOTES entry for integration-test-needed. +## Codex compatibility + +Both hosts load `skills/` and reuse the lifecycle scripts in `hooks/`. +Keep skill content in that one tree. Codex tool mappings live in +`codex/adapter.md`; the Codex SessionStart path supplies them before the shared +directive. `bin/ytstack-codex` supplies the existing script environment, including +repository-root resolution. Register supported events in `codex/hooks.json`. +There is no generation step, skill copy, or per-skill Codex boilerplate. +Keep `.codex-plugin/plugin.json` and `.claude-plugin/plugin.json` versions aligned. + +Run `python3 -m unittest discover -s tests -p 'codex_plugin_test.py' -v` and +`bash tests/post-tool-use-bash.test.sh` alongside `bin/ytstack-check`. CI accepts +the validator's warnings-only exit code (2) and rejects failures. See +[Codex setup](docs/codex.md) for installation and manual discovery checks. + ## How to add a new vendor subtree 1. Verify the license is compatible (MIT / Apache-2.0 / similar permissive). diff --git a/QUICKSTART.md b/QUICKSTART.md index 23518fb..8651568 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -4,6 +4,10 @@ From zero to a tracked, shipped task -- the way you actually use ytstack: by tal ## Install +For Codex, follow [Codex setup](docs/codex.md). The workflow below uses Claude +Code command names; in Codex select the corresponding skill from ytstack with +`$ytstack:`. Claude Agent Teams are not available through the Codex adapter. + ### Local dev (recommended for first try) ```bash diff --git a/README.md b/README.md index ed32847..589e8a8 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,14 @@

An opinionated OS for AI coding agents. Plan like a PM, execute like a senior eng.

- A Claude Code plugin that turns multi-session, multi-week projects into something your agent can actually hold onto. Project memory on disk, curated skill set, parallel execution via native Agent Teams. One install, one namespace, no stack to maintain. + A plugin for Claude Code and Codex that turns multi-session, multi-week projects into something your agent can actually hold onto. Project memory on disk, curated skill set, and native Agent Teams in Claude Code. One install, one namespace, no stack to maintain.

License version Claude Code + Codex skills hooks

@@ -178,6 +179,24 @@ Source: [`docs/ytstack-debugging-flow.excalidraw`](docs/ytstack-debugging-flow.e ## Install +### Codex + +```bash +codex plugin marketplace add Yesterday-AI/ytstack +codex plugin add ytstack@ytstack +``` + +Start a new session. With trusted hooks, ytstack supplies its runtime context +automatically. Without hooks, first ask Codex to read the installed plugin's +`codex/adapter.md`, then select `$ytstack:using-ytstack` in the skill picker. +On local hosts with hook support, review and trust the plugin hooks for automatic +startup context, compaction handoff, and session journaling. Both hosts load the same 21 skill files. Claude Agent Teams and edit/commit hooks remain +Claude-only; Codex uses the sequential task flow and explicit verification. +See [Codex setup and compatibility](docs/codex.md) for local development, +hook-free usage, and validation. + +The installation methods below are for Claude Code. + ### Local dev (recommended for first try) ```bash @@ -272,6 +291,7 @@ Ships: - 8 hooks (SessionStart / PreCompact / SessionEnd / TeammateIdle / TaskCreated / TaskCompleted / PreToolUse-Edit / PostToolUse-Bash). SessionStart-hook now injects the using-ytstack directive + project state. - Full docs (`AGENTS.md`, `CONTRIBUTING.md`, `QUICKSTART.md`, UX contracts, references, methodology) - Plugin manifest + marketplace manifest, published via the `yesterday-public-plugins` catalog +- Codex manifest + self-marketplace, shared skills, and 3 lifecycle hooks; see [host support](docs/codex.md#supported-behavior) See `.ytstack/ROADMAP.md` for the task list, `.ytstack/STATE.md` for progress, `.ytstack/REVIEW-NOTES.md` for deferred items. diff --git a/bin/ytstack-codex b/bin/ytstack-codex new file mode 100755 index 0000000..5c15073 --- /dev/null +++ b/bin/ytstack-codex @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Run the existing skill blocks and hooks with their shared environment contract. +set -euo pipefail + +export CLAUDE_PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="$PWD" +export CLAUDE_PROJECT_DIR="$PROJECT_DIR" +cd "$PROJECT_DIR" + +usage() { + echo 'Usage: ytstack-codex skill [bash arguments...] | hook ' >&2 + exit 2 +} + +[ "$#" -ge 2 ] || usage +MODE="$1" +NAME="$2" +shift 2 + +case "$MODE" in + skill) + [[ "$NAME" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]] || usage + export CLAUDE_SKILL_DIR="$CLAUDE_PLUGIN_ROOT/skills/$NAME" + [ -f "$CLAUDE_SKILL_DIR/SKILL.md" ] || { + echo "ytstack: skill not found: $NAME" >&2 + exit 2 + } + exec bash "$@" + ;; + hook) + [ "$#" -eq 0 ] || usage + case "$NAME" in + session-start|pre-compact|session-end) + if [ "$NAME" = session-start ]; then + export YTSTACK_HOST_CONTEXT="[ytstack plugin root: $CLAUDE_PLUGIN_ROOT] + +$(cat "$CLAUDE_PLUGIN_ROOT/codex/adapter.md")" + fi + exec bash "$CLAUDE_PLUGIN_ROOT/hooks/$NAME" + ;; + *) usage ;; + esac + ;; + *) usage ;; +esac diff --git a/codex/adapter.md b/codex/adapter.md new file mode 100644 index 0000000..b8b7ecf --- /dev/null +++ b/codex/adapter.md @@ -0,0 +1,88 @@ +# Running ytstack skills in Codex + +Read this adapter before following a ytstack skill in Codex. Both hosts use the +same skills and vendored procedures; this file maps their runtime conventions. + +## Load skills and context + +Use Codex's skill loader when available. Otherwise read the selected +`skills//SKILL.md` from this installed plugin and follow it. A reference +to `ytstack:` or `/ytstack:` means that skill, not a shell command. +Users can select it with `$ytstack:` in Codex's skill picker; choose the entry +from ytstack if another plugin provides the same name. + +The plugin root is two directories above the selected `SKILL.md` directory. +For this adapter it is the parent of `codex/`. Resolve it from +the loaded file's actual location, not the project checkout or a guessed cache +version. The Codex SessionStart adapter also reports the installed plugin root. + +If the SessionStart hook did not provide project context, read the +`using-ytstack` skill and the project's `.ytstack/STATE.md`, `DECISIONS.md`, and +`KNOWLEDGE.md` when present. If project state is absent, check +`~/.ytstack/projects//`. No state does not prove an existing +codebase is a new project; follow the user's requested scope. + +## Run shell blocks explicitly + +The shared skills retain their native fenced `!` blocks. In Codex, run the +selected skill's shell preamble explicitly from the target project. +Honor each hard gate before running its vendor `cat` block and following that +procedure. Other shell blocks run only when the procedure calls for them; +examples and output templates are not setup commands. Each block is a separate +process; use its printed values in subsequent prose steps. + +For every block, use the bundled helper with the current skill name: + +```bash +bash "/absolute/plugin/root/bin/ytstack-codex" skill plan-task <<'YTSTACK_BASH' +# Paste this skill's shell block here, without the Markdown fence. +YTSTACK_BASH +``` + +The helper derives `CLAUDE_PLUGIN_ROOT` and `CLAUDE_SKILL_DIR` from its own +installed location and sets `CLAUDE_PROJECT_DIR` to the Git repository root, +falling back to the working directory outside Git. It runs the block there. +Use the helper again for every shell call that needs these variables; exports +from a previous tool call do not persist. These are the existing shared scripts' +input names, not a requirement to install or start Claude. Codex itself provides +plugin-root compatibility aliases for hooks; the helper also supplies the skill +and project paths needed for explicit shell calls. + +Read the entire output of the wrapper's vendor `cat` block, including any +referenced files needed for the task. Vendor files remain unchanged. Keep +ytstack's invocation notes, artifact paths, and terminal-state rules around +the upstream procedure. Missing gstack services or sibling skills are not +installed by this plugin; report an essential missing capability instead of +inventing a tool or installing a dependency silently. + +## Map tools to the host + +| Instruction | Codex behavior | +| --- | --- | +| `Skill` | Load/read the selected skill as described above. | +| `Bash`, `Read`, `Write`, `Edit`, `Grep`, `Glob` | Use available shell, file, patch, and search tools. | +| `TodoWrite` / checklist tasks | Use the host's plan tool if available, otherwise track the checklist in the conversation. | +| `AskUserQuestion` | Use an available user-input tool in its supported mode, otherwise ask in chat and wait. Keep the skill's question format. | +| `allowed-tools` / Claude-specific frontmatter | Capability hints, never grants of tool access or permission in Codex. | + +A missing question tool does not imply non-interactive mode or consent. +Honor authorization already given in the conversation. User instructions and +the host's tool, approval, and sandbox rules take precedence over this adapter +and any shared or vendored skill instructions. + +## Lifecycle and teams + +Codex's manifest selects only SessionStart, PreCompact, and SessionEnd hooks. +They need a local host with hook support and explicit hook trust. Without +trusted hooks, use `resume-session` at startup and `handoff-session` before +leaving; do not claim automatic context injection or journaling ran. + +Claude's edit/commit hooks and Agent Teams events are not registered in Codex. +Check task scope before editing, run the verification command, and use +`summarize-task` to close work; no automatic draft summary is created on commit. + +`spawn-milestone-team` remains a Claude Code workflow. In Codex, stop before its +capability probe or spawn procedure and explain the limitation. +Suggest the sequential `plan-task` -> implementation -> +`verification-before-completion` -> `summarize-task` flow. Do not pretend Codex +subagents provide Claude's shared team/task hooks or start another host. diff --git a/codex/hooks.json b/codex/hooks.json new file mode 100644 index 0000000..27f7afc --- /dev/null +++ b/codex/hooks.json @@ -0,0 +1,40 @@ +{ + "description": "Codex lifecycle hooks. Only the three supported lifecycle events are registered.", + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear|compact", + "hooks": [ + { + "type": "command", + "command": "bash \"${PLUGIN_ROOT}/bin/ytstack-codex\" hook session-start", + "timeout": 10, + "additionalContextLimit": 5000 + } + ] + } + ], + "PreCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"${PLUGIN_ROOT}/bin/ytstack-codex\" hook pre-compact", + "timeout": 5 + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"${PLUGIN_ROOT}/bin/ytstack-codex\" hook session-end", + "timeout": 3 + } + ] + } + ] + } +} diff --git a/docs/codex.md b/docs/codex.md new file mode 100644 index 0000000..271a7e3 --- /dev/null +++ b/docs/codex.md @@ -0,0 +1,99 @@ +# Use ytstack in Codex + +Both plugin manifests load the same `skills/` directory. Skills, references, +templates, vendor files, and lifecycle scripts each have one source. Codex adds +its manifest, a three-event hook configuration, and a small runtime adapter. + +## Install + +With a Codex CLI that supports `codex plugin`: + +```bash +codex plugin marketplace add Yesterday-AI/ytstack +codex plugin add ytstack@ytstack +``` + +Restart Codex or open a new session after installation. In the desktop Plugins +Directory, the marketplace is named **ytstack**. Select its ytstack plugin to +install or enable it. This repository's Codex self-marketplace does not depend +on changes to Yesterday's shared plugin catalog. + +For a local checkout, replace the first command with: + +```bash +codex plugin marketplace add /absolute/path/to/ytstack +codex plugin add ytstack@ytstack +``` + +The catalog at `.agents/plugins/marketplace.json` points to the checkout root. +Codex copies the package into its plugin cache. After changing local files, +refresh/reinstall the plugin and start a new session before testing. + +Use a local macOS/Linux environment with Bash, Git, and either `jq` or Python 3 +for lifecycle hooks. The artifact validator and tests use Python 3. Native +Windows shells and remote environments without these files are not covered. + +## First session + +Start Codex in the project you want to work on. Repository subdirectories work +as well: the helper resolves the Git root. Outside Git it uses the current directory. +On local hosts with hook support, review and trust ytstack's hooks through +Codex's hook controls. SessionStart then supplies the [runtime adapter](../codex/adapter.md), +the shared `using-ytstack` directive, and project state. Installing the plugin +does not grant hook trust. + +Without trusted hooks, ask Codex to read `codex/adapter.md` from the installed +ytstack plugin first, then load `$ytstack:using-ytstack` or the desired skill. +This explicit bootstrap supplies the tool mappings and shell instructions that +the hook would otherwise provide. Use `$ytstack:resume-session` and +`$ytstack:handoff-session` for continuity; no automatic journal is promised. + +`bin/ytstack-codex` runs shared shell blocks and hooks with their existing +`CLAUDE_PLUGIN_ROOT`, `CLAUDE_SKILL_DIR`, and `CLAUDE_PROJECT_DIR` inputs. These +names preserve the existing script contract, not a Claude runtime dependency. +Codex itself supplies plugin-root compatibility aliases for hooks; the helper +also resolves skill paths and the target repository root. No Claude executable +is needed. Codex-specific tool mappings stay in `codex/adapter.md`. + +## Supported behavior + +| Capability | Codex support | +| --- | --- | +| Planning, reviews, TDD, debugging, verification, project artifacts | Existing skills with the runtime adapter. | +| SessionStart, PreCompact, SessionEnd | Registered in `codex/hooks.json`; require a local host with hook support and hook trust. | +| Resume and handoff without hooks | Explicit `resume-session` and `handoff-session` skills. No automatic journal. | +| Claude edit/commit quality hooks | Not registered. Follow task scope and verification explicitly; `summarize-task` owns task closure. | +| Claude Agent Teams and `agents/*.md` roles | Claude-only. The runtime adapter stops this workflow before its capability probe and offers sequential execution. | +| Claude marketplace dependencies | Not installed by Codex. Optional design or gstack sibling tools must be available separately when needed. | + +This is a repository/local plugin distribution. It is not a public OpenAI +directory submission; packages with lifecycle hooks require manual local +installation. Codex subagents are not presented as an equivalent to Claude's +Agent Teams coordination. + +## Verify changes + +Edit `skills/` and `hooks/` directly. There is no generated package or build step. +Test both hosts after changing a shared shell block or lifecycle script. + +```bash +python3 -m unittest discover -s tests -p 'codex_plugin_test.py' -v +bin/ytstack-check +bash tests/post-tool-use-bash.test.sh +``` + +`ytstack-check` returns 2 for warnings without failures. Existing warnings +about vendored sibling skills and gstack preambles are documented separately; +they do not mean those optional tools were installed. + +The tests execute the configured hook commands against temporary projects, +exercise all six vendor imports from paths containing spaces, and verify +project/user state and journal behavior. Also use a clean Codex profile to +confirm marketplace discovery, installation, and the loaded skill list when +changing packaging. + +## Platform references + +- [OpenAI plugin packaging and marketplaces](https://developers.openai.com/plugins/build/plugins) +- [Codex hook events, trust, and payloads](https://learn.chatgpt.com/docs/hooks) +- [Claude plugin compatibility guidance](https://developers.openai.com/plugins/guides/submit-claude-plugin) diff --git a/hooks/pre-compact b/hooks/pre-compact index 6ba63c6..f53bc4c 100644 --- a/hooks/pre-compact +++ b/hooks/pre-compact @@ -1,7 +1,7 @@ #!/usr/bin/env bash # ytstack PreCompact hook # -# Fires before Claude Code compacts the conversation. Writes a handoff snapshot +# Fires before the host compacts the conversation. Writes a handoff snapshot # to .ytstack/HANDOFF.md so the post-compaction session can re-orient quickly # from on-disk state. # @@ -50,7 +50,7 @@ active_task: ${ACTIVE_TASK:-none} # Pre-Compaction Handoff -Claude Code was about to compact the conversation at $TS. This file is a +The agent host was about to compact the conversation at $TS. This file is a breadcrumb so the next session (or post-compact continuation) can re-orient from on-disk state without relying on compressed conversation memory. diff --git a/hooks/session-start b/hooks/session-start index 0f564b1..1ac5d0a 100644 --- a/hooks/session-start +++ b/hooks/session-start @@ -48,6 +48,11 @@ USING_YTSTACK_FILE="${CLAUDE_PLUGIN_ROOT:-$PROJECT_DIR}/skills/using-ytstack/SKI USING_YTSTACK_CONTENT="" if [ -f "$USING_YTSTACK_FILE" ]; then USING_YTSTACK_CONTENT=$(cat "$USING_YTSTACK_FILE") + if [ -n "${YTSTACK_HOST_CONTEXT:-}" ]; then + USING_YTSTACK_CONTENT="$YTSTACK_HOST_CONTEXT + +$USING_YTSTACK_CONTENT" + fi fi # Silent exit if the plugin itself is not available (no CLAUDE_PLUGIN_ROOT diff --git a/tests/codex_plugin_test.py b/tests/codex_plugin_test.py new file mode 100644 index 0000000..6069c6e --- /dev/null +++ b/tests/codex_plugin_test.py @@ -0,0 +1,247 @@ +"""Codex packaging and shared-skill integration, without an LLM or user config.""" + +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +class CodexPluginTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.package_temp = tempfile.TemporaryDirectory(prefix="ytstack package ") + cls.package = Path(cls.package_temp.name).resolve() / "installed plugin" + cls.package.mkdir() + for name in (".codex-plugin", ".agents", "bin", "hooks", "skills", "codex"): + shutil.copytree(ROOT / name, cls.package / name) + # Copy exactly the vendor files that the shared wrappers import. + for skill in (ROOT / "skills").glob("*/SKILL.md"): + for relative in re.findall(r"vendor/[\w/-]+/SKILL\.md", skill.read_text()): + target = cls.package / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(ROOT / relative, target) + + @classmethod + def tearDownClass(cls): + cls.package_temp.cleanup() + + def setUp(self): + self.sandbox = tempfile.TemporaryDirectory(prefix="ytstack project ") + self.addCleanup(self.sandbox.cleanup) + self.project = Path(self.sandbox.name).resolve() / "project with spaces" + self.project.mkdir() + self.user_dir = Path(self.sandbox.name) / "test user" + self.user_dir.mkdir() + self.env = {key: value for key, value in os.environ.items() + if not key.startswith(("CLAUDE_", "YTSTACK_", "PLUGIN_"))} + # Isolate the hooks' supported user-scope lookup from the real account. + self.env["HOME"] = str(self.user_dir) + self.env["PLUGIN_ROOT"] = str(self.package) + self.helper = self.package / "bin/ytstack-codex" + + def run_skill(self, name, script, cwd=None, **kwargs): + return subprocess.run( + ["bash", str(self.helper), "skill", name], input=script, + cwd=cwd or self.project, env=self.env, text=True, capture_output=True, + **kwargs, + ) + + def fire_hook(self, event, cwd=None): + cwd = cwd or self.project + config = json.loads((self.package / "codex/hooks.json").read_text()) + handlers = config["hooks"][event][0]["hooks"] + self.assertEqual(len(handlers), 1) + payload = { + "session_id": 'codex-session-"quoted"', + "cwd": str(cwd), "hook_event_name": event, + "source": "startup", "reason": "other", + } + result = subprocess.run( + ["bash", "-c", handlers[0]["command"]], + input=json.dumps(payload), cwd=cwd, env=self.env, + text=True, capture_output=True, timeout=handlers[0]["timeout"], + ) + self.assertEqual(result.returncode, 0, result.stderr) + return result.stdout + + def write_state(self, user_scope=False): + if user_scope: + directory = self.user_dir / ".ytstack/projects" / self.project.name + else: + directory = self.project / ".ytstack" + directory.mkdir(parents=True) + (directory / "STATE.md").write_text( + "---\nproject: sample\ncurrent_milestone: M123\n" + "active_slice: S02\nactive_task: T03\n---\n\n" + "## Next action\nVerify the import.\n" + ) + return directory + + def test_manifests_resolve_same_package_and_skills(self): + manifest = json.loads((self.package / ".codex-plugin/plugin.json").read_text()) + claude = json.loads((ROOT / ".claude-plugin/plugin.json").read_text()) + for field in ("name", "version", "author", "repository", "license"): + self.assertEqual(manifest[field], claude[field], field) + marketplace = json.loads((self.package / ".agents/plugins/marketplace.json").read_text()) + entry = marketplace["plugins"][0] + self.assertEqual(entry["name"], manifest["name"]) + self.assertEqual((self.package / entry["source"]["path"]).resolve(), self.package) + skill_paths = sorted((self.package / manifest["skills"]).glob("*/SKILL.md")) + self.assertEqual([p.parent.name for p in skill_paths], + sorted(p.parent.name for p in (ROOT / "skills").glob("*/SKILL.md"))) + self.assertEqual((self.package / manifest["skills"]).resolve(), self.package / "skills") + hooks = json.loads((self.package / manifest["hooks"]).read_text())["hooks"] + self.assertEqual(set(hooks), {"SessionStart", "PreCompact", "SessionEnd"}) + self.assertLessEqual(hooks["SessionEnd"][0]["hooks"][0]["timeout"], 3) + + def test_helper_uses_installed_skill_and_target_project(self): + self.env.update(CLAUDE_PLUGIN_ROOT="/wrong-plugin", CLAUDE_PROJECT_DIR="/wrong-project", + CLAUDE_SKILL_DIR="/wrong-skill") + result = self.run_skill("plan-task", 'printf "%s\\n" "$CLAUDE_PLUGIN_ROOT" "$CLAUDE_SKILL_DIR" "$CLAUDE_PROJECT_DIR"\n') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.splitlines(), [ + str(self.package), str(self.package / "skills/plan-task"), + str(self.project), + ]) + + def test_wrapper_preambles_and_vendor_imports(self): + self.write_state() + imported = [] + for skill in (self.package / "skills").glob("*/SKILL.md"): + blocks = re.findall(r"^```!\n(.*?)^```", skill.read_text(), re.M | re.S) + if not blocks: + continue + with self.subTest(skill=skill.parent.name): + context = self.run_skill(skill.parent.name, blocks[0]) + self.assertEqual(context.returncode, 0, context.stderr) + self.assertIn("HAS_YTSTACK: yes", context.stdout) + vendor = re.search(r"vendor/[\w/-]+/SKILL\.md", blocks[-1]).group() + result = self.run_skill(skill.parent.name, blocks[-1]) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, (ROOT / vendor).read_text()) + imported.append(skill.parent.name) + self.assertEqual(len(imported), 6) + + def test_helper_rejects_missing_skill_and_path_traversal(self): + for name in ("missing-skill", "../using-ytstack", "/tmp/skill"): + with self.subTest(name=name): + result = self.run_skill(name, "echo should-not-run") + self.assertEqual(result.returncode, 2) + self.assertNotIn("should-not-run", result.stdout) + + def test_helper_propagates_failure(self): + self.assertEqual(self.run_skill("plan-task", "exit 7").returncode, 7) + + def test_startup_greenfield_and_missing_state(self): + output = json.loads(self.fire_hook("SessionStart"))["hookSpecificOutput"] + self.assertEqual(output["hookEventName"], "SessionStart") + self.assertIn("scope: greenfield", output["additionalContext"]) + self.assertIn(str(self.package), output["additionalContext"]) + self.assertIn((self.package / "codex/adapter.md").read_text(), output["additionalContext"]) + self.assertFalse((self.project / ".ytstack").exists()) + (self.project / ".ytstack").mkdir() + output = json.loads(self.fire_hook("SessionStart"))["hookSpecificOutput"] + self.assertIn("STATE.md is missing", output["additionalContext"]) + + def test_lifecycle_preserves_state_and_records_session(self): + for user_scope in (False, True): + with self.subTest(user_scope=user_scope): + directory = self.write_state(user_scope=user_scope) + state = (directory / "STATE.md").read_bytes() + output = json.loads(self.fire_hook("SessionStart"))["hookSpecificOutput"] + self.assertIn("scope: user" if user_scope else "scope: project", output["additionalContext"]) + self.assertIn("M123 / S02 / T03", output["additionalContext"]) + self.assertIn("Verify the import.", output["additionalContext"]) + self.assertEqual(self.fire_hook("PreCompact"), "") + handoff = (directory / "HANDOFF.md").read_text() + self.assertIn("current_milestone: M123", handoff) + self.assertNotIn("Claude Code was about", handoff) + self.assertEqual(self.fire_hook("SessionEnd"), "") + entry = json.loads((directory / "journal/sessions.jsonl").read_text()) + self.assertEqual(entry["session_id"], 'codex-session-"quoted"') + self.assertEqual(entry["task"], "T03") + self.assertEqual((directory / "STATE.md").read_bytes(), state) + shutil.rmtree(directory) + + def test_side_effect_hooks_noop_without_state(self): + self.assertEqual(self.fire_hook("PreCompact"), "") + self.assertEqual(self.fire_hook("SessionEnd"), "") + self.assertFalse((self.project / ".ytstack").exists()) + + def test_lifecycle_from_repository_subdirectory(self): + subprocess.run(["git", "init", "-q", str(self.project)], check=True, env=self.env) + nested = self.project / "src/nested directory" + nested.mkdir(parents=True) + for user_scope in (False, True): + with self.subTest(user_scope=user_scope): + directory = self.write_state(user_scope=user_scope) + state = (directory / "STATE.md").read_bytes() + context = json.loads(self.fire_hook("SessionStart", cwd=nested))["hookSpecificOutput"]["additionalContext"] + self.assertIn("scope: user" if user_scope else "scope: project", context) + self.assertIn("M123 / S02 / T03", context) + self.fire_hook("PreCompact", cwd=nested) + self.assertIn("current_milestone: M123", (directory / "HANDOFF.md").read_text()) + self.fire_hook("SessionEnd", cwd=nested) + entry = json.loads((directory / "journal/sessions.jsonl").read_text()) + self.assertEqual(entry["task"], "T03") + self.assertEqual((directory / "STATE.md").read_bytes(), state) + self.assertFalse((nested / ".ytstack").exists()) + shutil.rmtree(directory) + + def test_claude_startup_still_works(self): + self.write_state() + self.env.update(CLAUDE_PLUGIN_ROOT=str(self.package), CLAUDE_PROJECT_DIR=str(self.project)) + result = subprocess.run( + ["bash", str(self.package / "hooks/session-start")], + cwd=self.project, env=self.env, text=True, capture_output=True, check=True, + ) + context = json.loads(result.stdout)["hookSpecificOutput"]["additionalContext"] + self.assertIn("M123 / S02 / T03", context) + self.assertIn("via the `Skill` tool", context) + self.assertNotIn("Codex", context) + + def test_package_has_one_skill_and_hook_source(self): + self.assertFalse((self.package / "codex/skills").exists()) + self.assertFalse((self.package / "codex/hooks").exists()) + self.assertFalse((self.package / "bin/build-codex").exists()) + for skill in (self.package / "skills").glob("*/SKILL.md"): + self.assertNotIn("Codex", skill.read_text()) + + def test_claude_wrapper_preambles_and_vendor_imports(self): + self.write_state() + for skill in (self.package / "skills").glob("*/SKILL.md"): + self.assertNotIn("Codex", skill.read_text()) + blocks = re.findall(r"^```!\n(.*?)^```", skill.read_text(), re.M | re.S) + if not blocks: + continue + with self.subTest(skill=skill.parent.name): + env = dict(self.env, CLAUDE_PLUGIN_ROOT=str(self.package), + CLAUDE_SKILL_DIR=str(skill.parent), CLAUDE_PROJECT_DIR=str(self.project)) + context = subprocess.run(["bash"], input=blocks[0], cwd=self.project, + env=env, text=True, capture_output=True, check=True) + self.assertIn("HAS_YTSTACK: yes", context.stdout) + result = subprocess.run(["bash"], input=blocks[-1], cwd=self.project, + env=env, text=True, capture_output=True, check=True) + vendor = re.search(r"vendor/[\w/-]+/SKILL\.md", blocks[-1]).group() + self.assertEqual(result.stdout, (ROOT / vendor).read_text()) + + def test_helper_uses_repository_root_for_relative_skill_writes(self): + subprocess.run(["git", "init", "-q", str(self.project)], check=True, env=self.env) + nested = self.project / "src/nested directory" + nested.mkdir(parents=True) + result = self.run_skill("plan-task", 'printf "%s\\n" "$CLAUDE_PROJECT_DIR" "$PWD"; echo ok > proof.txt', cwd=nested) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.splitlines(), [str(self.project), str(self.project)]) + self.assertEqual((self.project / "proof.txt").read_text(), "ok\n") + self.assertFalse((nested / "proof.txt").exists()) + + +if __name__ == "__main__": + unittest.main()