Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions plugins/worktrunk/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ worktrunk/ ← repo root = marketplace root
├── plugin.json ← Claude manifest (NO .claude-plugin/ wrapper —
│ the wrapper is marketplace-root-only)
├── .codex-plugin/plugin.json ← Codex manifest (Codex's required wrapper)
├── hooks/hooks.json ← Claude activity + WorktreeCreate/Remove hooks
├── hooks/claude-hooks.json ← Claude activity + WorktreeCreate/Remove hooks
│ (Claude-scoped name — a plain hooks/hooks.json
│ would be auto-discovered by Codex, see #3362)
├── hooks/wt.sh ← canonical hook shim; Claude reaches it via
│ $CLAUDE_PLUGIN_ROOT, Codex via $PLUGIN_ROOT,
│ Gemini via
Expand Down Expand Up @@ -69,9 +71,9 @@ The 💬 transitions overlap deliberately: `Notification` covers the documented

### Codex activity hooks (marker persists after session end)

The Claude manifest carries `hooks: "./hooks/hooks.json"` (a path); the Codex manifest carries `hooks` as an **inline object**, `{ "hooks": { … } }`, embedding a Codex-tailored hooks file directly. The distinction is deliberate:
The Claude manifest carries `hooks: "./hooks/claude-hooks.json"` (a path); the Codex manifest carries `hooks` as an **inline object**, `{ "hooks": { … } }`, embedding a Codex-tailored hooks file directly. The distinction is deliberate:

- **Why inline, not a path or an absent key.** Claude and Codex share one payload dir. If the Codex manifest omitted `hooks`, Codex would auto-discover `hooks/hooks.json` from the plugin root by convention (`DEFAULT_HOOKS_CONFIG_FILE`, the `None` branch of `load_plugin_hooks`) and surface Worktrunk's *Claude* events in a Codex session ([#3362](https://github.com/max-sixty/worktrunk/issues/3362)). An inline object takes Codex's `Some(Inline)` branch (`resolve_manifest_hooks` in `codex-rs/core-plugins/src/manifest.rs`), which overrides discovery — so the shared Claude file is never loaded and only these Codex-native events fire. Inline keeps the definition self-contained and avoids a second file that could collide with `hooks/hooks.json`.
- **Why inline, not a path or an absent key.** Claude and Codex share one payload dir, and Codex auto-discovers a `hooks/hooks.json` at the plugin root by convention (`DEFAULT_HOOKS_CONFIG_FILE`, the `None` branch of `load_plugin_hooks`). Historically the Claude hooks lived at exactly that path, so a Codex session surfaced Worktrunk's *Claude* events ([#3362](https://github.com/max-sixty/worktrunk/issues/3362)). Two independent things now keep a Codex session clean: (1) the Claude file is **Claude-scoped** — `hooks/claude-hooks.json`, not the conventional `hooks/hooks.json` — so Codex's discovery lookup finds nothing at the plugin root; and (2) 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`). The inline object is the functional definition of the Codex-native events *and* overrides discovery, so even if a `hooks/hooks.json` ever reappeared at the plugin root it would not be loaded. The Claude-scoped filename makes the #3362 collision structurally impossible; the inline manifest is what actually drives Codex's markers.
- **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`):
Expand Down
2 changes: 1 addition & 1 deletion plugins/worktrunk/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"author": {
"name": "Worktrunk"
},
"hooks": "./hooks/hooks.json",
"hooks": "./hooks/claude-hooks.json",
"skills": [
"./skills/worktrunk",
"./skills/wt-switch-create"
Expand Down
44 changes: 29 additions & 15 deletions tests/integration_tests/config_show.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3848,13 +3848,16 @@ fn test_codex_plugin_metadata_is_valid_json() {
"plugin.json interface.websiteURL must point at the canonical site"
);
// The Codex plugin ships activity-marker hooks *inline* in its manifest
// (`hooks` object, not a path). This is deliberate: an absent `hooks` key
// makes Codex auto-discover the shared `hooks/hooks.json` (which is the
// *Claude* plugin's) and surface Claude-branded events in a Codex session
// (#3362). An inline object takes Codex's `Some(Inline)` branch, overriding
// discovery so only these Codex-native events fire. Codex added a `Stop`
// turn-end event (post codex-cli 0.130.0), so 🤖 returns to 💬 at turn end.
// See CLAUDE.md → "Plugin Layout".
// (`hooks` object, not a path). This is the functional definition of its
// Codex-native events, and it also overrides Codex's convention discovery
// (an absent `hooks` key makes Codex auto-discover a `hooks/hooks.json` at
// the plugin root). The Claude hooks file is Claude-scoped
// (`hooks/claude-hooks.json`), so that discovery lookup finds nothing — the
// #3362 collision (Codex surfacing the *Claude* file's events) is gone
// structurally, and inline still overrides discovery should any file
// reappear at the conventional path. Codex added a `Stop` turn-end event
// (post codex-cli 0.130.0), so 🤖 returns to 💬 at turn end. See CLAUDE.md
// → "Plugin Layout".
let codex_hooks = plugin
.get("hooks")
.and_then(|h| h.get("hooks"))
Expand All @@ -3881,7 +3884,7 @@ fn test_codex_plugin_metadata_is_valid_json() {
"Codex hooks must not reference the Claude-branded $CLAUDE_PLUGIN_ROOT alias"
);
// Hooks are inline, so the wrapper dir still holds only plugin.json — no
// separate hooks file to collide with the Claude `hooks/hooks.json`.
// separate hooks file to collide with the Claude `hooks/claude-hooks.json`.
assert!(
!project_root
.join("plugins/worktrunk/.codex-plugin/hooks")
Expand Down Expand Up @@ -3940,15 +3943,26 @@ fn test_plugin_layout_is_consolidated() {

// Claude manifest at the plugin root (no wrapper); hooks relative to it.
let claude = json("plugins/worktrunk/plugin.json");
assert_eq!(claude["hooks"], "./hooks/hooks.json");
assert_eq!(claude["hooks"], "./hooks/claude-hooks.json");
assert!(
root.join("plugins/worktrunk/hooks/hooks.json").exists()
root.join("plugins/worktrunk/hooks/claude-hooks.json")
.exists()
&& root.join("plugins/worktrunk/hooks/wt.sh").exists(),
"Claude hooks must live at the plugin root's hooks/"
);
// The Claude hooks file is Claude-scoped (claude-hooks.json), NOT the
// conventional hooks/hooks.json. A file at that conventional plugin-root
// path would be auto-discovered by Codex's convention loader and resurface
// the #3362 collision; the Claude-scoped name makes that impossible
// structurally rather than only overriding it inline in the Codex manifest.
assert!(
!read("plugins/worktrunk/hooks/hooks.json").contains(".claude-plugin/hooks/"),
"hooks.json must reference $CLAUDE_PLUGIN_ROOT/hooks/wt.sh, not the old wrapper path"
!root.join("plugins/worktrunk/hooks/hooks.json").exists(),
"the Claude hooks file must be Claude-scoped (hooks/claude-hooks.json), not the \
conventional hooks/hooks.json that Codex would auto-discover (#3362)"
);
assert!(
!read("plugins/worktrunk/hooks/claude-hooks.json").contains(".claude-plugin/hooks/"),
"claude-hooks.json must reference $CLAUDE_PLUGIN_ROOT/hooks/wt.sh, not the old wrapper path"
);

// The description is duplicated across the Claude marketplace pointer and
Expand Down Expand Up @@ -4024,8 +4038,8 @@ fn test_plugin_layout_is_consolidated() {
// `--force-delete` silently discards committed-but-unpushed work — the only
// recovery path is `git fsck`. The safe default retains unmerged branches
// and prints a `wt remove -D <branch>` hint for the user to act on.
let hooks = read("plugins/worktrunk/hooks/hooks.json");
let hooks_json = json("plugins/worktrunk/hooks/hooks.json");
let hooks = read("plugins/worktrunk/hooks/claude-hooks.json");
let hooks_json = json("plugins/worktrunk/hooks/claude-hooks.json");

// Claude hands each hook command to the user's LOGIN shell before `bash`
// launches, so the outer command line must parse under fish/zsh/bash. fish
Expand Down Expand Up @@ -4103,7 +4117,7 @@ fn test_plugin_layout_is_consolidated() {
fn test_claude_hook_commands_parse_in_all_shells() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let hooks_json: serde_json::Value = serde_json::from_str(
&fs::read_to_string(root.join("plugins/worktrunk/hooks/hooks.json")).unwrap(),
&fs::read_to_string(root.join("plugins/worktrunk/hooks/claude-hooks.json")).unwrap(),
)
.unwrap();

Expand Down
Loading