diff --git a/.agents/skills/verify-open-pstack/features/README.md b/.agents/skills/verify-open-pstack/features/README.md
index f0ebc6d..e089cab 100644
--- a/.agents/skills/verify-open-pstack/features/README.md
+++ b/.agents/skills/verify-open-pstack/features/README.md
@@ -6,6 +6,7 @@ Run the parent skill's Launch and Doctor first. Use one fresh scratch store per
- [Installation and discovery](installation.md): marketplace installation, reload, skill discovery in both parents.
- [Model setup](setup.md): selected-provider checks, confirmation, persistence, native probes.
+- [Startup routing](startup-routing.md): bundled SessionStart hook registration, opt-in trust, and shared routing text.
- [Engineering workflows](workflows.md): invoking poteto-mode and direct focused skills.
- [Routing policy](routing.md): inspecting a saved role through the CLI and observing parent-owned dispatch.
- [Worker ownership and continuation](worker-contract.md): enforced capabilities, parent Git operations, handoffs and bounded recovery.
diff --git a/.agents/skills/verify-open-pstack/features/startup-routing.md b/.agents/skills/verify-open-pstack/features/startup-routing.md
new file mode 100644
index 0000000..72d3c3d
--- /dev/null
+++ b/.agents/skills/verify-open-pstack/features/startup-routing.md
@@ -0,0 +1,41 @@
+# Startup routing
+
+The plugin bundles a `SessionStart` hook for each parent. Claude Code loads it through `hooks/hooks.json`; Codex loads `hooks/codex-hooks.json` through the manifest's explicit `hooks` entry and skips it until the user trusts the exact hook definition in `/hooks`.
+
+## Sub-features
+
+- `startup-descriptor`: each host's registration selects the right sources and routes to the shared emitter.
+- `startup-emission`: the shipped command prints the exact shared routing text on stdout.
+- `startup-opt-in`: Codex routing is inactive until the hook is trusted; disabling a trusted hook turns it off.
+- `startup-routing-only`: a routing-only setup request changes no model bytes.
+
+## How to get to it (user POV)
+
+Install the plugin, open `/hooks` in Codex CLI, review the pstack `SessionStart` entry, and trust it to opt in; disable the same entry to opt out. `pstack:setup-pstack` offers this as an independent optional choice on a Codex parent.
+
+## Driving it with the parent apps and shell
+
+Preconditions: Launch and Doctor passed. `python3 tests/test-session-start.py` covers the packaged contract in CI: it executes the real `command` strings from both descriptors through `run-hook.cmd` and asserts the exact emitted text, matcher coverage, manifest override, repeated emission, and that model files and preference state remain unchanged.
+
+```bash
+capture session-start-tests python3 "$VERIFY_REPO/tests/test-session-start.py"
+```
+
+For the installed candidate, use a dedicated Codex profile and the [installation](installation.md) candidate-identity steps first. Inspect its global and project instructions, custom developer instructions, and other hooks. Remove any standing Pstack routing mandate from this dedicated fixture before testing. Keep any model-only configuration needed to preserve the assigned providers and spending policy. Record those instruction sources with the evidence; do not paste the hook instruction into the user prompt or fixture instructions.
+
+Use a plain request such as "Add a --format json option to this report command and cover both output formats." Seed a small local command for the task. Observe the actual installed skill read or invocation, not only a final claim that Pstack was used. Compare fresh tasks with the hook untrusted, trusted and enabled, and disabled. Keep every other instruction source identical.
+
+- **Contract listing (works unauthenticated):** in the dedicated Codex profile, confirm the pstack `SessionStart` entry resolves to the plugin's `hooks/codex-hooks.json` and reports untrusted state. A listed `enabled` flag alongside an untrusted status means inactive until trusted, not running.
+- **Injection and routing (needs an authenticated parent):** trust the hook in `/hooks`, start a fresh root task, and observe the delivered developer context and the parent's actual `pstack:poteto-mode` skill selection for a non-trivial engineering request. Exercise a pure question and a trivial edit (stay lightweight), an explicit "skip poteto-mode" instruction (overrides), and `resume`, `clear`, and `compact` re-delivery. Disable the hook and confirm a fresh task no longer routes while `$pstack:poteto-mode` still works by name.
+- **Claude regression:** install the same candidate in a dedicated Claude Code profile and confirm `startup`, `clear`, and `compact` still inject the shared text and a non-trivial request still selects `pstack:poteto-mode`.
+- **Model-file invariance:** snapshot `~/.codex/pstack-models.md` and the `pstack:models` block in `~/.codex/AGENTS.md` before and after enabling, disabling, and a routing-only setup request; expect byte-identical files and no probes.
+
+## Gotchas
+
+- Trust is recorded against the exact hook definition hash; a changed descriptor or command marks the hook for review again. Do not treat a previously trusted install as proof for the current candidate.
+- The hook has no persistent session cache: `compact` (including mid-turn auto-compaction) must re-deliver the text.
+- `--dangerously-bypass-hook-trust` runs untrusted hooks for one invocation; it is a deliberate bypass, not normal opt-in evidence.
+- `commandWindows` is a documented schema field, but Windows execution of the packaged command is unverified until exercised on a real Windows host; record it separately.
+- The dedicated Codex test profile may lack authentication. Contract listing and local emission still count; skill selection, context injection, and override behavior then remain unverified and the pull request stays a draft.
+
+Disabling a hook cannot retract context already delivered to an open chat. Test opt-out in a fresh chat and test a direct user override separately. Record unavailable lifecycle actions as unverified instead of substituting synthetic event input.
diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index 690cd2e..f254025 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -9,7 +9,7 @@
"name": "pstack",
"source": "./plugins/pstack",
"description": "if you want to go fast, go deep first. pstack helps you write less, but higher quality code. rigorous agent workflows you can parallelize with confidence.",
- "version": "1.9.1",
+ "version": "1.10.0",
"author": {
"name": "Lauren Tan (original)"
},
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6668758..cf1a243 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -30,6 +30,8 @@ jobs:
run: bun run typecheck
- name: Test fork maintenance
run: python3 tests/test-fork-maintenance.py
+ - name: Test session-start hook
+ run: python3 tests/test-session-start.py
- name: Check documentation
run: |
python3 tests/test-check-docs.py
diff --git a/AGENTS.md b/AGENTS.md
index 899a43b..021800f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -51,6 +51,8 @@ Add or keep a public document only when it answers a question readers ask repeat
| Bug reports and contribution checks | `CONTRIBUTING.md` |
| Install-source policy | this file (the `main` paragraph above) |
| Skill purpose and invocation | each packaged `SKILL.md`; README lists common entry points |
+| Session-start routing behavior | `plugins/pstack/hooks/session-start-context.md` and each host's hook descriptor in `plugins/pstack/hooks/` |
+| Codex startup-routing setup choice | `plugins/pstack/skills/setup-pstack/SKILL.md` (`## Codex startup routing`) |
| Reproducible verification | `.agents/skills/verify-open-pstack/` and `tests/` |
`python3 scripts/check-docs.py` checks that the README provider table matches the runner IDs, that relative links and heading anchors resolve, that the public documentation inventory stays within its approved scope, and that the current `CHANGELOG.md` entry and pinned upstream README agree with the package version and Cursor baseline. Passing proves structure, not that all prose is current.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 00fc1ca..f974050 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,12 @@ This file records what each version of the Open Pstack package changed. Versions
Entries describe package versions. Published release checkpoints have tag links; installation follows `main` unless pinned. Validation belongs to the linked pull requests. Older reports remain available through immutable links.
+## 1.10.0 adds opt-in Codex startup routing
+
+Cursor baseline: [0.15.5](https://github.com/cursor/plugins/tree/12d587dfb20741cafc376c42c696c5f6e2a64487/pstack). [Issue #88](https://github.com/arjitj2/open-pstack/issues/88).
+
+Codex can route engineering requests into Pstack through an optional plugin hook, enabled through its native `/hooks` trust controls. Startup, resume, clear, and compaction reuse the shared routing instruction. A routing-only setup request leaves model assignments and spending permissions unchanged.
+
## 1.9.1 packages license texts and credits the Open Pstack port
Cursor baseline: [0.15.5](https://github.com/cursor/plugins/tree/12d587dfb20741cafc376c42c696c5f6e2a64487/pstack). [Issue #82](https://github.com/arjitj2/open-pstack/issues/82). Tag [v1.9.1](https://github.com/arjitj2/open-pstack/releases/tag/v1.9.1).
diff --git a/README.md b/README.md
index 5e01a56..23136dc 100644
--- a/README.md
+++ b/README.md
@@ -92,6 +92,8 @@ multi_agent = true
Start a new Codex task after installation so it can discover the new skills and setting.
+Pstack also bundles an optional `SessionStart` hook that routes non-trivial engineering tasks into `pstack:poteto-mode` at session startup, resume, clear, and compaction. It is inactive until you review and trust it: open [`/hooks` in Codex CLI](https://developers.openai.com/codex/hooks), inspect the pstack `SessionStart` entry, and trust it to turn routing on. Disable the same entry to turn it off again. Leaving it untrusted keeps ordinary Codex behavior; `$pstack:poteto-mode` and the other skills still work by name.
+
## Get started
Configure model access, establish verification for your repository, then start a task.
@@ -142,7 +144,7 @@ $pstack:poteto-mode Add saved filters to search. Keep the design simple, verify
In this example, poteto-mode first examines the existing search implementation before deciding how to add saved filters. It settles how saved filters are stored before writing code, then implements the smallest complete version. It runs the feature the way a user would, reviews the result, and prepares the pull request.
-The skill name is `poteto-mode`, spelled with an “e”. Claude Code also loads this distribution's startup instruction for non-trivial engineering work. In Codex, select the skill explicitly or add a standing instruction if you want it used by default. Model setup saves routing preferences; it does not install an always-on Codex workflow instruction.
+The skill name is `poteto-mode`, spelled with an “e”. Claude Code loads this distribution's startup instruction for non-trivial engineering work. Codex delivers the same instruction once you trust the plugin's `SessionStart` hook in `/hooks` as described under [Install](#install); without that opt-in, select the skill explicitly. Model setup saves routing preferences; it never changes the hook's trust or enabled state.
Use the verification skill for each change. If you add or change a feature, update its entry in the feature map too. The other skills are there when poteto-mode needs them or when you want to call one directly.
diff --git a/UPSTREAM.md b/UPSTREAM.md
index 663d1cc..233f9ba 100644
--- a/UPSTREAM.md
+++ b/UPSTREAM.md
@@ -12,7 +12,7 @@ This page records the current Cursor baseline and the maintainer procedure for r
| Path | `pstack/` |
| Commit | `12d587dfb20741cafc376c42c696c5f6e2a64487` |
| Upstream version | `0.15.5` |
-| open-pstack version | `1.9.1` |
+| open-pstack version | `1.10.0` |
The table records the packaged version on `main` and the Cursor content it incorporates, minus the exclusions below. Detecting or reviewing a newer Cursor commit does not advance this baseline. Cursor's version identifies the imported content. The open-pstack version identifies the cross-harness package, and its numbers are independent of Cursor and Eric's port.
@@ -29,7 +29,7 @@ Earlier exclusions include Cursor's Benny automation, tutorial and sticky-mode U
## Port adaptations
-Both parent apps load the same `plugins/pstack/skills/` tree through their own plugin manifests. Claude Code also loads a SessionStart instruction that routes engineering tasks into `poteto-mode`. Codex users invoke the skill explicitly or add a standing instruction.
+Both parent apps load the same `plugins/pstack/skills/` tree and `hooks/session-start-context.md`. Claude Code uses `hooks/hooks.json`; the Codex manifest selects `hooks/codex-hooks.json`. See [Codex installation](README.md#codex) for the optional routing and trust controls.
When incorporating Cursor content, translate harness primitives at the existing boundaries:
diff --git a/plugins/pstack/.claude-plugin/plugin.json b/plugins/pstack/.claude-plugin/plugin.json
index d724e76..63d6134 100644
--- a/plugins/pstack/.claude-plugin/plugin.json
+++ b/plugins/pstack/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "pstack",
"displayName": "pstack",
- "version": "1.9.1",
+ "version": "1.10.0",
"description": "if you want to go fast, go deep first. pstack helps you write less, but higher quality code. rigorous agent workflows you can parallelize with confidence. Ported from cursor/plugins/pstack for Claude Code and Codex.",
"author": {
"name": "Lauren Tan"
diff --git a/plugins/pstack/.codex-plugin/plugin.json b/plugins/pstack/.codex-plugin/plugin.json
index 4db2b57..e50dd5b 100644
--- a/plugins/pstack/.codex-plugin/plugin.json
+++ b/plugins/pstack/.codex-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "pstack",
- "version": "1.9.1",
+ "version": "1.10.0",
"description": "if you want to go fast, go deep first. pstack helps you write less, but higher quality code. rigorous agent workflows you can parallelize with confidence. Codex port of the Claude Code plugin; skills are shared, tool names resolve via skills/poteto-mode/references/codex-tools.md.",
"author": {
"name": "Lauren Tan"
@@ -18,6 +18,7 @@
"unslop"
],
"skills": "./skills/",
+ "hooks": "./hooks/codex-hooks.json",
"interface": {
"logo": "./assets/logo.png",
"displayName": "pstack",
diff --git a/plugins/pstack/hooks/codex-hooks.json b/plugins/pstack/hooks/codex-hooks.json
new file mode 100644
index 0000000..d49961f
--- /dev/null
+++ b/plugins/pstack/hooks/codex-hooks.json
@@ -0,0 +1,18 @@
+{
+ "hooks": {
+ "SessionStart": [
+ {
+ "matcher": "^(startup|resume|clear|compact)$",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "\"${PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start",
+ "commandWindows": "\"%PLUGIN_ROOT%\\hooks\\run-hook.cmd\" session-start",
+ "async": false,
+ "statusMessage": "Loading pstack routing"
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/plugins/pstack/hooks/session-start b/plugins/pstack/hooks/session-start
index b160ba6..dce7f57 100755
--- a/plugins/pstack/hooks/session-start
+++ b/plugins/pstack/hooks/session-start
@@ -1,7 +1,4 @@
#!/usr/bin/env bash
-# SessionStart hook for the pstack plugin: prints the poteto-mode dispatch
-# mandate to stdout, which Claude Code injects as session context directly.
-# A missing context file fails the hook cleanly instead of injecting noise.
set -euo pipefail
diff --git a/plugins/pstack/hooks/session-start-context.md b/plugins/pstack/hooks/session-start-context.md
index 208269f..d11d13b 100644
--- a/plugins/pstack/hooks/session-start-context.md
+++ b/plugins/pstack/hooks/session-start-context.md
@@ -1,11 +1,11 @@
You have pstack.
-Before responding to any non-trivial engineering task — a feature, bug fix, refactor, debugging, performance work, or any multi-step code change — invoke the `pstack:poteto-mode` skill with the Skill tool and follow it. It is the default entry point and routes to the specific pstack skills from there. Pure questions and trivial one-line edits don't need it.
+For non-trivial engineering work, invoke the `pstack:poteto-mode` skill through your host's skill-loading mechanism before proceeding and follow it. This includes features, bug fixes, refactors, debugging, performance work, and multi-step code changes. It is the default entry point and routes to the specific pstack skills from there. Pure questions and trivial one-line edits don't need it. If the appropriate Pstack workflow is already active, continue it instead of starting it again.
When the intent is already specific, enter directly: `pstack:tdd` (bug with a reproducible failure), `pstack:architect` (types and module shape before code that crosses a function boundary), `pstack:how` (how a subsystem works), `pstack:why` (why it was built this way), `pstack:arena` (N parallel attempts at one task), `pstack:interrogate` (multi-model diff review).
-If you were dispatched as a subagent to execute a specific task, ignore this block — poteto-mode governs the orchestrating session, and it already shaped your dispatch.
+If you were dispatched as a subagent to execute a specific task, ignore this block. Poteto-mode governs the orchestrating session, and it already shaped your dispatch.
-User instructions (CLAUDE.md, AGENTS.md, direct requests) take precedence over this mandate. Other session-start mandates (such as superpowers) compose with it: their skill-check discipline stands, and poteto-mode is the implementation entry point they route to for non-trivial code work.
+Direct user or project instructions take precedence over this mandate. Other session-start guidance (such as superpowers) composes with it: its skill-check discipline stands, and poteto-mode is the implementation entry point it routes to for non-trivial code work.
diff --git a/plugins/pstack/skills/setup-pstack/SKILL.md b/plugins/pstack/skills/setup-pstack/SKILL.md
index dabab03..578bda5 100644
--- a/plugins/pstack/skills/setup-pstack/SKILL.md
+++ b/plugins/pstack/skills/setup-pstack/SKILL.md
@@ -1,10 +1,12 @@
---
name: setup-pstack
-description: Configure pstack's provider-qualified models, reasoning budget, subscription access, approved API spend, and saved backend fallbacks. Verifies only the assigned native and external model lanes before writing the override sheet. Use for /setup-pstack, "configure pstack models", "pstack budget", subscription routing, or changing pstack's model choices.
+description: Configure pstack's provider-qualified models, reasoning budget, subscription access, approved API spend, and saved backend fallbacks. Verifies only the assigned native and external model lanes before writing the override sheet. Use for /setup-pstack, "configure pstack models", "pstack budget", subscription routing, changing pstack's model choices, or the optional Codex startup-routing hook.
---
# Setup pstack
+For a routing-only request, establish the parent in step 1, then go directly to [Codex startup routing](#codex-startup-routing-optional).
+
Configure one portable model sheet for the current parent harness. Read [`provider-dispatch.md`](../poteto-mode/references/provider-dispatch.md) before probing or writing anything. Its model matrix, descriptor grammar, sheet grammar, and route table are the contract. Choose one requested effort per assigned provider/model pair and, when the operator wants it, an explicit ordered `primary -> fallback` attempt chain per seat plus, when the operator accepts it, one `# fallback` policy line declaring which terminal outcomes may advance the chain (`usage-exhausted` is the only authorized outcome when the line is absent). An optional `# continuation: {"on":["permission","handoff"],"max":2}` line is a separate same-route execution policy; preserve a loaded line or its absence unless the operator explicitly chooses a change. Do not add a second configuration file or an unapproved substitution; a saved chain is the only authorized provider fallback. The installed helper `skills/poteto-mode/scripts/model-policy/pstack-model-policy` validates the rendered sheet (`validate --sheet --parent `) before it is written.
Claude Code writes `~/.claude/pstack-models.md` and loads it from `~/.claude/CLAUDE.md` with:
@@ -166,3 +168,11 @@ Do not copy the model sheet between harnesses without rerunning the parent-speci
Before declaring setup complete, run one small read-only panel from this parent using each distinct assigned descriptor, with distinct output/receipt paths and an independent cross-judge from the configured cross-judge pool. If any roles use aliases, also exercise a native inherited lane; for an alias-only sheet, that native lane is the whole panel. Use a different-provider judge when the configured pool permits it, otherwise use a separate native or configured judge and report the reduced diversity. Never add an unassigned provider merely to make the smoke multi-provider. A smoke failure leaves setup incomplete; report the failing lane and return to step 4 or let the operator repair and retry. Observe available native slots again for the smoke. Launch native candidates in capacity-bounded waves, drain completed handles and release their slots before subsequent waves, and use one native lane at a time if capacity cannot be observed. Launch every external process in the background concurrently with retained handles. Wait for all candidates to finish and verify their results before launching the independent judge; the judge uses the same native capacity and handle-release rules. Never time out a candidate or substitute another route to make room. Verify the native transcript entries and every external receipt. A structural config check or unit test is not a substitute.
Report the sheet path, parent route table, requested-effort probe results, smoke results, and external elapsed/token/cost receipts. Re-running this skill re-probes and updates the same sheet. Do not claim the provider exposed hidden applied-effort observability.
+
+## Codex startup routing (optional)
+
+On a Codex parent only, offer one independent choice after the model flow settles: whether Pstack's bundled `SessionStart` hook should route non-trivial engineering tasks into `pstack:poteto-mode` at session startup, resume, clear, and compaction. Codex owns this preference: the plugin hook is listed in `/hooks` but skipped until the operator reviews and trusts its exact definition, and `/hooks` also owns the per-hook enabled switch. A hook reported as enabled but untrusted is inactive until trusted. Never describe it as already routing or as Pstack-disabled by a saved setting. Leaving the hook untrusted keeps routing off; an already trusted hook can be disabled in `/hooks`, and a changed hook definition needs renewed trust before it runs again.
+
+Setup guides this native choice and never fabricates it: do not write a preference file, copy the routing text into `AGENTS.md`, claim a saved or trusted state, or infer consent from model setup. To enable, the operator opens `/hooks` in Codex CLI, reviews the pstack `SessionStart` entry, and trusts it; to opt out later, they disable the same entry. If the host exposes a read-only hook listing, report its observed trust and enabled state verbatim; otherwise ask the operator to check `/hooks`. An unrelated model-setup rerun preserves the existing hook choice — setup never changes hook trust or enabled state.
+
+A request to enable, disable, or check Codex startup routing without changing models skips steps 2 through 10: no provider discovery, no probes, no sheet render, no writes. Confirm `~/.codex/pstack-models.md` and the ``/`` block in `~/.codex/AGENTS.md` remain byte-identical. On a Claude parent this section does not run; Claude Code already loads the shared instruction through its own plugin hook.
diff --git a/tests/setup-selected-providers.md b/tests/setup-selected-providers.md
index 0ddaf02..be77164 100644
--- a/tests/setup-selected-providers.md
+++ b/tests/setup-selected-providers.md
@@ -21,3 +21,5 @@ A missing selected provider is an intentional failure in case 2, not a reason to
13. **Optional Antigravity route.** Select one exact slug listed by `agy models` for a named role as `antigravity:@default`; retain a non-Antigravity first-run panel elsewhere. Expect one read-only external probe from each supported parent surface, with explicit user-confirmed `# access` and `apiSpend` before execution. Verify that the output is a real one-turn result, that the private agent directory disappears, and that the receipt records pinned-argv evidence even though `init.model` echoes the slug. An unassigned Antigravity provider must trigger no CLI check. Reject `auto`, a separate non-default effort, and a denied API route before model execution. A selected file-only writer must run in a dedicated worktree; the parent runs its tests after the edit.
14. **New provider model IDs.** Assign a real available model ID outside the recommendation matrix, preserve explicit effort and access facts, and invoke the installed setup skill in both parents. Require the exact selected ID in native host events or external runner receipts, real marker output, unchanged targets before confirmation, and confirmed save/readback followed by independent smoke review. Include an exact Devin CLI UID at `@default` and a Claude model without a shipped native agent on the Claude parent. A newly selected unavailable ID must leave both targets unchanged; listing absence must not silently replace the selection. Record unsupported account models as unavailable, not as evidence of Pstack catalog rejection.
+
+15. **Codex routing-only setup.** From a Codex parent with an existing sheet, ask setup only to enable startup routing. Expect no provider discovery, probes, or smoke; `~/.codex/pstack-models.md` and the `pstack:models` AGENTS.md block stay byte-identical. Setup must report the hook's observed `/hooks` trust and enabled state without claiming it saved that state, and must describe an enabled-but-untrusted entry as inactive until trusted. Repeat to disable, then run an unrelated model-only rerun and confirm the existing hook choice is preserved. Setup must never write a routing preference file or copy routing text into `AGENTS.md`.
diff --git a/tests/test-session-start.py b/tests/test-session-start.py
new file mode 100644
index 0000000..c5afcca
--- /dev/null
+++ b/tests/test-session-start.py
@@ -0,0 +1,147 @@
+#!/usr/bin/env python3
+import json
+import os
+import re
+import subprocess
+import tempfile
+import unittest
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parents[1]
+PLUGIN = REPO / 'plugins' / 'pstack'
+HOOKS = PLUGIN / 'hooks'
+CODEX_MANIFEST = PLUGIN / '.codex-plugin' / 'plugin.json'
+CODEX_DESCRIPTOR = HOOKS / 'codex-hooks.json'
+CLAUDE_DESCRIPTOR = HOOKS / 'hooks.json'
+CONTEXT = HOOKS / 'session-start-context.md'
+
+CODEX_SOURCES = {'startup', 'resume', 'clear', 'compact'}
+CLAUDE_SOURCES = {'startup', 'clear', 'compact'}
+
+
+def load_json(path):
+ return json.loads(path.read_text())
+
+
+def single_session_start_handler(descriptor_path):
+ data = load_json(descriptor_path)
+ groups = data['hooks']['SessionStart']
+ assert len(groups) == 1, '%s must register one SessionStart matcher group' % descriptor_path
+ handlers = groups[0]['hooks']
+ assert len(handlers) == 1, '%s must register one SessionStart handler' % descriptor_path
+ return groups[0], handlers[0]
+
+
+def event(source):
+ return json.dumps({
+ 'session_id': 'thr_test',
+ 'transcript_path': None,
+ 'cwd': os.getcwd(),
+ 'hook_event_name': 'SessionStart',
+ 'source': source,
+ 'permission_mode': 'default',
+ 'model': 'gpt-5.6-sol',
+ }).encode()
+
+
+class DescriptorTests(unittest.TestCase):
+ def test_codex_manifest_overrides_default_hook_discovery(self):
+ manifest = load_json(CODEX_MANIFEST)
+ entry = manifest.get('hooks')
+ self.assertEqual(entry, './hooks/codex-hooks.json')
+ resolved = (PLUGIN / entry).resolve()
+ self.assertEqual(resolved, CODEX_DESCRIPTOR.resolve())
+ self.assertTrue(str(resolved).startswith(str(PLUGIN.resolve()) + os.sep))
+ self.assertTrue(CODEX_DESCRIPTOR.is_file())
+
+ def test_codex_registration_matches_all_supported_sources(self):
+ group, handler = single_session_start_handler(CODEX_DESCRIPTOR)
+ matcher = re.compile(group['matcher'])
+ for source in CODEX_SOURCES:
+ self.assertIsNotNone(matcher.search(source), source)
+ for rejected in ('', 'other', 'startupx', 'prestartup', 'resume2', 'subagent'):
+ self.assertIsNone(matcher.search(rejected), rejected)
+ self.assertEqual(handler['type'], 'command')
+ self.assertFalse(handler.get('async', False))
+ self.assertIn('${PLUGIN_ROOT}/hooks/run-hook.cmd', handler['command'])
+ self.assertIn('session-start', handler['command'])
+ self.assertNotIn('CLAUDE_PLUGIN_ROOT', handler['command'])
+ self.assertIn('%PLUGIN_ROOT%', handler.get('commandWindows', ''))
+
+ def test_claude_registration_is_unchanged(self):
+ group, handler = single_session_start_handler(CLAUDE_DESCRIPTOR)
+ matcher = re.compile(group['matcher'])
+ for source in CLAUDE_SOURCES:
+ self.assertIsNotNone(matcher.search(source), source)
+ self.assertIsNone(matcher.search('resume'))
+ self.assertEqual(handler['type'], 'command')
+ self.assertFalse(handler.get('async', False))
+ self.assertIn('${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd', handler['command'])
+
+ def test_descriptors_point_at_the_same_emitter(self):
+ _, codex_handler = single_session_start_handler(CODEX_DESCRIPTOR)
+ _, claude_handler = single_session_start_handler(CLAUDE_DESCRIPTOR)
+ for command in (codex_handler['command'], claude_handler['command']):
+ self.assertIn('hooks/run-hook.cmd', command)
+ self.assertRegex(command, r'session-start"\s*$|session-start\s*$')
+
+
+class EmissionTests(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ self.workdir = Path(self.tmp.name) / 'elsewhere'
+ self.workdir.mkdir()
+ self.expected = CONTEXT.read_bytes()
+
+ def shipped_output(self, descriptor, env_name, stdin_bytes, extra_env=None):
+ _, handler = single_session_start_handler(descriptor)
+ env = dict(os.environ)
+ env.update(extra_env or {})
+ env[env_name] = str(PLUGIN)
+ proc = subprocess.run(
+ ['bash', '-c', handler['command']],
+ cwd=str(self.workdir), env=env, input=stdin_bytes,
+ capture_output=True)
+ self.assertEqual(proc.returncode, 0, proc.stderr)
+ self.assertEqual(proc.stderr, b'')
+ return proc.stdout
+
+ def test_shipped_codex_command_emits_exact_shared_text_for_every_source(self):
+ for source in CODEX_SOURCES:
+ with self.subTest(source=source):
+ out = self.shipped_output(CODEX_DESCRIPTOR, 'PLUGIN_ROOT', event(source))
+ self.assertEqual(out, self.expected)
+
+ def test_shipped_claude_command_emits_the_same_bytes(self):
+ for source in CLAUDE_SOURCES:
+ with self.subTest(source=source):
+ out = self.shipped_output(CLAUDE_DESCRIPTOR, 'CLAUDE_PLUGIN_ROOT', event(source))
+ self.assertEqual(out, self.expected)
+
+ def test_emitter_ignores_event_content_and_repeats_without_dedup(self):
+ first = self.shipped_output(CODEX_DESCRIPTOR, 'PLUGIN_ROOT', event('startup'))
+ again = self.shipped_output(CODEX_DESCRIPTOR, 'PLUGIN_ROOT', event('startup'))
+ garbage = self.shipped_output(CODEX_DESCRIPTOR, 'PLUGIN_ROOT', b'not json\n')
+ empty = self.shipped_output(CODEX_DESCRIPTOR, 'PLUGIN_ROOT', b'')
+ self.assertEqual(first, again)
+ self.assertEqual(first, garbage)
+ self.assertEqual(first, empty)
+
+ def test_hook_has_no_preference_gate_and_touches_no_model_files(self):
+ home = Path(self.tmp.name) / 'home'
+ codex_home = home / '.codex'
+ codex_home.mkdir(parents=True)
+ sheet = codex_home / 'pstack-models.md'
+ agents = codex_home / 'AGENTS.md'
+ sheet.write_text('# sheet\nrole: codex:gpt-5.6-sol@max\n')
+ agents.write_text('\nsheet bytes\n\n')
+ before = {p: p.read_bytes() for p in sorted(home.rglob('*')) if p.is_file()}
+ extra = {'HOME': str(home), 'CODEX_HOME': str(codex_home)}
+ out = self.shipped_output(CODEX_DESCRIPTOR, 'PLUGIN_ROOT', event('compact'), extra_env=extra)
+ self.assertEqual(out, self.expected)
+ after = {p: p.read_bytes() for p in sorted(home.rglob('*')) if p.is_file()}
+ self.assertEqual(before, after)
+
+if __name__ == '__main__':
+ unittest.main()