Files
max-sixty__worktrunk/plugins/worktrunk/CLAUDE.md
Maximilian Roos cfd6f099bc fix(codex): clear activity marker on session end (#3660)
Codex now exposes `SessionEnd`, but Worktrunk’s Codex plugin only
returned the activity marker to idle at turn end. This adds a
main-session exit hook that clears the marker, using Codex’s
three-second maximum hook timeout so cleanup has the best chance to
complete without delaying shutdown.

The CLI help, plugin-layout guidance, user documentation, generated
mirrors, metadata test, and help snapshot now describe and verify the
complete Codex lifecycle.

Tested with `cargo run -- hook pre-merge --yes` (4,564 tests passed; 1
skipped).

> _This was written by Claude Code on behalf of max_.
2026-07-29 20:57:26 -07:00

146 lines
11 KiB
Markdown

# Worktrunk Plugin Guidelines (Claude Code + Codex)
## Directory Layout
This directory (`plugins/worktrunk/`) is the Claude Code + Codex payload. Each
tool hardcodes its loader path with no fallback, so the repo root carries one
pointer per tool: Claude's and Codex's both `source → ./plugins/worktrunk`,
while Gemini resolves its extension at the repo root itself; Gemini's hooks
call the canonical `hooks/wt.sh` below.
```
worktrunk/ ← repo root = marketplace root
├── .claude-plugin/marketplace.json ← Claude pointer (source → ./plugins/worktrunk)
├── .agents/plugins/marketplace.json← Codex pointer (source → ./plugins/worktrunk)
├── gemini-extension.json ← Gemini manifest (extensionPath = repo root)
├── hooks/hooks.json ← Gemini activity hooks (call the wt.sh below)
├── skills/ ← real dir; Gemini reads ${extensionPath}/skills directly
└── plugins/worktrunk/ ← plugin root (Claude + Codex resolve source here)
├── .claude-plugin/plugin.json ← Claude manifest (metadata only — NO `hooks`
│ or `skills` keys; components load by
│ convention, see below)
├── .codex-plugin/plugin.json ← Codex manifest (Codex's required wrapper)
├── hooks/hooks.json ← Claude activity + WorktreeCreate/Remove hooks,
│ discovered by convention at this exact path
│ (#3417; Codex is kept off it by its inline
│ manifest, #3362)
├── hooks/wt.sh ← canonical hook shim; Claude reaches it via
│ $CLAUDE_PLUGIN_ROOT, Codex via $PLUGIN_ROOT,
│ Gemini via
│ ${extensionPath}/plugins/worktrunk/hooks/wt.sh
├── skills/ ← generated real-file mirror of repo-root
│ skills/ (test_docs_are_in_sync; never
│ hand-edit) — real files because Codex's
│ installer drops symlinks, see below
├── CLAUDE.md / README.md
└── (Codex activity hooks live *inline* in .codex-plugin/plugin.json's
`hooks` key — see Known Limitations below)
```
Path resolution differs by tool, all verified end-to-end against the real CLIs:
- **Claude** (claude-cli 2.1.207): `.claude-plugin/marketplace.json` `source:
"./plugins/worktrunk"`. Claude reads
`plugins/worktrunk/.claude-plugin/plugin.json` — the same wrapper convention
as the marketplace root, and the only manifest location `claude plugin
validate` accepts (a bare `plugin.json` at the plugin root loads through an
undocumented fallback but fails validation). The manifest deliberately
carries no `version` field: installs pin the marketplace git SHA, so a
semver here would be a second version to maintain with nothing consuming
it — `claude plugin validate`'s missing-`version` warning is accepted.
Components load by **convention**, and the manifest must not name them:
- Hooks are discovered at `hooks/hooks.json`. The loader does not honor the
string-path `hooks` manifest override for plugin loads, so a renamed file
silently loads nothing (#3417) and a `hooks` key pointing at the
conventional path is dead config that can only mask a mislocated file.
- Skills are auto-discovered by scanning `skills/` for `<dir>/SKILL.md`; a
`skills` manifest array only *adds* directories to that scan, so listing
the defaults is redundant.
`$CLAUDE_PLUGIN_ROOT` is the plugin root.
- **Codex** (codex-cli 0.144.1): `.agents/plugins/marketplace.json` `source`
object `{ "source": "local", "path": "./plugins/worktrunk" }`. Codex reads
`plugins/worktrunk/.codex-plugin/plugin.json`. Skills load by convention —
with no `skills` manifest key, Codex scans `<plugin-root>/skills/`; an
explicit `"skills": "./skills/"` names the same directory, so the manifest
carries no `skills` key. The scanned tree is the real-file mirror ("Plugin
skills are a generated mirror" below).
- **Gemini**: `gemini-extension.json` at the repo root; `${extensionPath}` is
the repo root, so `${extensionPath}/skills/` is the repo-root `skills/`
directly and `hooks/hooks.json` (repo root) calls the canonical shim at
`${extensionPath}/plugins/worktrunk/hooks/wt.sh`. No symlink or copy.
All three tools pick up the whole `skills/` set — Gemini reads the repo-root
directory, Claude and Codex ship the plugin mirror — so a new repo-root skill
ships everywhere once `test_docs_are_in_sync` regenerates the mirror, provided
its directory contains a `SKILL.md` (`test_plugin_layout_is_consolidated`
enforces that; a directory without one is silently ignored). Claude-only
skills reach the other tools too (accepted tradeoff — see Known Limitations
below).
### Plugin skills are a generated mirror
`plugins/worktrunk/skills/` is a real-file mirror of the authored repo-root
`skills/`, regenerated — symlinks dereferenced, stale files deleted — by the
`sync_plugin_skills_mirror` stage of `test_docs_are_in_sync`; never hand-edit
it. Repo-root `skills/` stays the authored home: Gemini reads it directly and
the docs sync writes into it.
The mirror holds real files because of how the plugin ships, verified
end-to-end against codex-cli 0.144.1 (scratch marketplaces through
`codex plugin marketplace add` + `codex plugin add`, skill inventory read with
`codex debug prompt-input`):
- `codex plugin add` copies the plugin into
`$CODEX_HOME/plugins/cache/<marketplace>/<plugin>/<version>` with a copier
that handles only regular files and directories, silently skipping symlink
entries (`copy_dir_recursive` in `codex-rs/core-plugins/src/store.rs`), and
sessions load from that cache copy. A symlink anywhere in the tree — a
top-level `skills` link or a nested one like `reference/README.md` — ships
no content. No manifest value can bridge it: manifest paths must stay within
the plugin root (`..` and absolute paths are rejected,
`resolve_manifest_path` in `codex-rs/core-plugins/src/manifest.rs`).
- Codex's convention scan (`default_skill_roots`, the empty-`skills` branch of
`plugin_skill_roots` in `codex-rs/core-plugins/src/loader.rs`) reads the
mirror like any directory.
- Claude's installer dereferences symlinks, so a symlinked `skills/` worked
for Claude; the mirror serves it identically. A symlink also materializes as
a plain text file on Windows checkouts, which shipped no skills from a
Windows clone to Claude or Codex.
`test_plugin_layout_is_consolidated` pins the no-symlinks invariant;
`test_docs_are_in_sync` pins content equality with repo-root `skills/`.
## Known Limitations
### Status persists after user interrupt (Claude)
The Claude hooks track activity via git config (`worktrunk.state.{branch}.marker`):
- `UserPromptSubmit` → 🤖 (working)
- `Notification`, `PreToolUse`(`AskUserQuestion`), `PermissionRequest`, `Stop` → 💬 (waiting for input)
- `SessionEnd` → clears status
The 💬 transitions overlap deliberately: `Notification` covers the documented permission/idle path, but on platforms where it doesn't fire (VS Code extension, Windows CLI) `PermissionRequest` and `Stop` still mark the wait; `PreToolUse`(`AskUserQuestion`) catches the built-in question picker, which fires no `Notification` on any platform ([claude-code#13024](https://github.com/anthropics/claude-code/issues/13024)). There is currently no transition back to 🤖 once a turn-end/permission marker is set except a fresh `UserPromptSubmit`, so 💬 can persist into resumed work after a permission grant (the original symptom in [#2916](https://github.com/max-sixty/worktrunk/issues/2916)).
**Problem**: If the user interrupts Claude Code (Escape/Ctrl+C), the 🤖 status persists because there's no `UserInterrupt` hook. The `Stop` hook explicitly does not fire on user interrupt.
**Tracking**: [claude-code#9516](https://github.com/anthropics/claude-code/issues/9516)
### Codex activity hooks
Claude's hooks live in the standalone `hooks/hooks.json` its loader discovers by convention (see Directory Layout above); the Codex manifest carries `hooks` as an **inline object**, `{ "hooks": { … } }`, embedding a Codex-tailored hooks file directly. The inline form is deliberate:
- **Why inline for Codex, not a path or an absent key.** Claude and Codex share one payload dir, and Codex *also* auto-discovers `hooks/hooks.json` at the plugin root by convention (`DEFAULT_HOOKS_CONFIG_FILE`, the `None` branch of `load_plugin_hooks`) — which once surfaced Worktrunk's *Claude* events in a Codex session ([#3362](https://github.com/max-sixty/worktrunk/issues/3362)). The Codex manifest carries its own hooks **inline**, taking Codex's `Some(Inline)` branch (`resolve_manifest_hooks` in `codex-rs/core-plugins/src/manifest.rs`), which **overrides** convention discovery. The inline object is both the functional definition of the Codex-native events and the thing that keeps Codex off the shared `hooks/hooks.json`, so the two toolchains coexist on one file: Claude discovers it, Codex ignores it. (Scoping via the filename instead — `hooks/claude-hooks.json` — breaks Claude's discovery, [#3417](https://github.com/max-sixty/worktrunk/issues/3417); the inline override makes it unnecessary.)
- **Why `$PLUGIN_ROOT`, not `$CLAUDE_PLUGIN_ROOT`.** Codex exports both to hook commands (`PLUGIN_ROOT` native, `CLAUDE_PLUGIN_ROOT` as an OOTB-compat alias — `codex-rs/hooks/src/engine/discovery.rs`). The Codex file uses the native `$PLUGIN_ROOT` so nothing Claude-branded appears in a Codex session.
The events (Codex's `HookEventsToml` vocabulary, verified against `codex-rs/config/src/hook_config.rs`):
- `UserPromptSubmit` → 🤖 (working)
- `PermissionRequest`, `Stop` → 💬 (waiting for input)
- `SessionEnd` → clears the marker
`Stop` fires at turn-end, so 🤖 returns to 💬 when a turn completes. `SessionEnd` clears the marker when the main thread ends.
### Accepted tradeoff: shared `skills/` exposes `wt-switch-create`
Codex's mirrored `skills/` and Gemini's `${extensionPath}/skills/` both carry the entire skill set, including `wt-switch-create`, which depends on Claude session-cwd switching (`EnterWorktree`) that neither provides. Accepted: a tool loading a skill it can't act on is harmless, and a single authored `skills/` keeps the `worktrunk` skill single-source across all three tools and the docs sync. Don't add per-tool skills subtrees to exclude it.