diff --git a/.gitignore b/.gitignore index a3e931d..c7569a6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ audit/ .claude/kermit/state.json .claude/.headroom_wrap_marker.json .tokensave +improve/ +node_modules/ +*.tgz diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 69d7f8b..459be30 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -15,7 +15,7 @@ User types a prompt and submits │ Score prompt against categories.json (keyword/regex) │ │ Emit minimal block: verdict (+ type on │ │ clear, + scores/candidates on ambiguous) │ -│ Block is a signal; CLAUDE.md → model invokes skill │ +│ Block is a signal; host's trust contract → invoke skill │ │ Append src:"hook" line → telemetry.jsonl │ └─────────────────────────────────────────────────────────────────┘ │ @@ -56,8 +56,10 @@ User types a prompt and submits │ ▼ │ │ Step 6 [comprehensive mode only] │ │ │ Show original → rationale → enhanced │ -│ │ AskUserQuestion (single gate): │ +│ │ Ask step — present options and stop: │ │ │ Run enhanced | Run original | Edit │ +│ │ AskUserQuestion when that tool exists, │ +│ │ otherwise plain text + end of turn │ │ │ │ │ ├── cancel (via "Other") ────────────────────────────►│ (terminate + hint) │ │ │ @@ -70,27 +72,37 @@ User types a prompt and submits └─────────────────────────────────────────────────────────────────┘ │ ▼ - Enhanced prompt sent to Claude + Enhanced prompt sent to the model ``` +The flow above is host-independent. What differs between Claude Code and Codex is +plumbing only — see [Two-host model](#two-host-model). + ## File Map ``` wdym/ ├── SKILL.md Thin dispatcher → refs/protocol.md ├── pref.json Default mode template -├── install.sh Installation helper +├── package.json npm package `wdym-prompt` (bin, files allowlist) +├── install.sh Shell installer — Claude Code only, frozen +├── agents/ +│ └── openai.yaml Codex skill manifest (display name, invocation policy) +├── bin/ +│ └── wdym-prompt.js npx installer — both hosts, no dependencies ├── hooks/ │ ├── prompt-detect.py UserPromptSubmit pre-scorer │ └── telemetry-stats.py --status report renderer ├── tests/ -│ └── detect_bench.py Detection regression bench (expected verdicts) +│ ├── detect_bench.py Detection regression bench (expected verdicts) +│ └── token_bench.py Token-cost bench for the common path └── refs/ - ├── protocol.md Step-by-step execution reference (Steps 0–8) + ├── protocol.md Step-by-step execution reference (Steps 0–8, ask step) ├── commands.md /wdym command handling (--init/--help/--status/--set-mode) ├── help.txt Verbatim --help text (printed via cat) ├── detect.md Manual type-detection path (ambiguous/absent only) - ├── init.md --init bootstrap protocol + ├── init.md --init bootstrap protocol (host resolution, scope, activation) + ├── heal.md Self-check repair playbook (Step 0.5 escalation) ├── manifest.json Self-check & repair definitions (read only on a failed check) ├── categories.json Type taxonomy (user-editable) ├── categories.default.json Pristine restore copy @@ -130,11 +142,66 @@ layer: append-only, best-effort, never healed, so it can never block or alter a `additionalContext` channel — the same low-trust surface as tool output and retrieved text. An override-shaped imperative there (`ACTION: invoke … BEFORE processing`) reads as a prompt injection and was sometimes refused, silently skipping the skill. So the block -carries only a neutral classification *signal*; the invoke instruction itself lives in -`CLAUDE.md`, which the model treats as trusted user configuration. `install.sh` writes a -marker-delimited, idempotent contract into `CLAUDE.md` at the install scope (global -`~/.claude/CLAUDE.md`, local project root), so a fresh install carries the same authority -the dev repo does rather than leaving the block to fend for itself. +carries only a neutral classification *signal* — host-neutral wording, naming no host's +files — while the invoke instruction lives in the host's own instruction file, which the +model treats as trusted user configuration. Both installers write a marker-delimited, +idempotent contract there at the install scope: `~/.claude/CLAUDE.md` or the local project +root on Claude Code, `$CODEX_HOME/AGENTS.md` on Codex. A fresh install therefore carries +the same authority the dev repo does rather than leaving the block to fend for itself. + +## Two-host model + +wdym runs on Claude Code and on Codex. The engine — hook scoring, classification, +principle selection, rewriting, telemetry — is identical on both. Only the plumbing is +host-specific, and it is enumerated in one place so the rest of the system never has to +know which host it is on. Four of these matter at runtime — trust contract, skill path, +hook file, and the ask step — and those are the four the README's diagram note names. + +| | Claude Code | Codex | +|---|---|---| +| Host evidence | `CLAUDE_PROJECT_DIR` set, or `~/.claude/` exists | `CODEX_HOME` set, or `~/.codex/` exists | +| Skill path | `~/.claude/skills/wdym` (canonical), or `/.claude/skills/wdym` | `~/.agents/skills/wdym` | +| Hook file | `~/.claude/settings.json`, or `/.claude/settings.local.json` | `$CODEX_HOME/hooks.json` | +| Trust contract | `~/.claude/CLAUDE.md`, or `/CLAUDE.md` | `$CODEX_HOME/AGENTS.md` | +| Ask step | `AskUserQuestion` | plain-text options, end of turn | +| Command prefix | `/wdym` | `$wdym` | +| Scopes | global and local | global only | + +**One canonical copy, exposed twice.** The skill tree is installed once, under +`~/.claude/skills/wdym`. Codex only scans `~/.agents/skills`, so the installer exposes the +same tree there as a symlink rather than copying it. Two independent copies would drift the +moment one host updated and the other didn't, and drift in a skill body is invisible until +behaviour diverges. Where symlinks are unavailable (or `--copy` is passed) the installer +copies instead and records which it did in `pref.json` as `codex_skill_mode`, so +`--doctor` knows whether it needs to check the two paths for drift. + +**One pref file, both hosts.** `~/.claude/wdym/pref.json` is canonical for Claude Code and +Codex alike, even though the rest of the Codex wiring lives under `~/.codex`. Splitting it +would let a user set flash mode in one host and comprehensive in the other and then wonder +which one they were talking to. Local scope still overrides global — but local scope is a +Claude Code concept, so the override applies there only. + +**Ask step, not a tool call.** `AskUserQuestion` is a Claude Code tool and does not exist +in Codex, and it was the one hard dependency that made the approval gate and `--init` +unrunnable there. Both now route through a single documented interaction step — present +options, stop, read the next message as the answer — with the implementation picked by +*tool availability*, never by guessing the host. That way a host that gains or loses the +tool needs no code change. + +**Trust lifecycle differs.** Claude Code approves a hook once. Codex trusts hooks by file +contents, so any install, update, or re-run invalidates the previous approval and the hook +goes silent until the user runs `/hooks` in Codex and approves it again — with no warning +that it stopped firing. Codex also records approval outside any file this tool can read, so +there is neither a programmatic approval path nor a readable trust state. Both are handled +by documentation loudness instead: the installer prints an action-required notice last on +every Codex-touching run, `--doctor` reports trust as `unknown` and points at `/hooks`, +`refs/heal.md` knows the "wired but silent" failure mode, and the README repeats it in the +Codex notes. + +**Codex is global-scope only.** A repo-scoped Codex hook would live in a committed file, +so every teammate pulling the repo would get an approval prompt for a tool they never +installed. Repo scope also collides with Codex sessions that start below the repo root. +`--codex --local` therefore refuses cleanly and writes nothing rather than half-wiring. ## Run Modes diff --git a/CHANGELOG.md b/CHANGELOG.md index c5784a7..d9c39d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,42 @@ All notable changes to this project will be documented here. +## 2026-08-09 + +### [14] — Both installers write the same trust contract, and the live checks have a script + +- `install.sh`: contract line is now host-neutral ("invoke the `wdym` skill" — no "via the Skill tool"), byte-identical to what `bin/wdym-prompt.js` writes, so alternating installers no longer rewrite one line back and forth; verified in a sandbox that a stale block is refreshed in place and a re-run reports already-current +- `CLAUDE.md`: dev repo's own contract updated to the same wording +- `improve/v1.2-test.md`: new (untracked by design) — the live-verification test plan for everything static checks could not close: Codex session (gate, `--init`, one-block-one-row, symlink follow, trust-notice failure mode), Claude no-regression, real Node 18 run, diagram render and one-reader test, npm publish preflight + +### [13] — Docs describe two hosts, and the README shows the flow instead of describing it + +- `README.md`: `npx wdym-prompt` documented as the primary install path; `install.sh` demoted to Option 2 and labelled Claude-Code-only and frozen; `npx skills add` kept accurate as Option 3; new three-way comparison table with a Codex row; new Codex requirements note (hooks on by default, `/hooks` re-approval after every install or update, `` block currently visible per openai/codex#16933, global scope only, `$wdym` prefix); update section covers both installers plus `--doctor`; new Codex FAQ entry +- `README.md`: the *How it works* Mermaid diagram now covers the whole path — both activation modes, the passthrough branch, the trust-contract step, and both run modes ending in a telemetry append — with a single divergence table underneath naming the four host-specific points (trust contract, skill path, hook file, ask step) +- `ARCHITECTURE.md`: new *Two-host model* section — one canonical skill copy exposed to Codex by symlink with a recorded `codex_skill_mode` fallback, one canonical `pref.json` shared by both hosts, the ask-step abstraction chosen by tool availability, Codex's contents-based hook trust lifecycle, and why Codex is global-scope only +- `ARCHITECTURE.md`: Step 6 in the flow diagram shows the ask step rather than a hard `AskUserQuestion` call; the trust-anchor note names both instruction files; file map adds `bin/wdym-prompt.js`, `agents/openai.yaml`, `package.json`, `refs/heal.md`, `tests/token_bench.py` +- `llms.txt`: host-neutral summary, trust contract described per host, `refs/init.md` and `bin/wdym-prompt.js` added to the index +- `RELEASES.md`: v1.2.0 entry + +### [12] — One install command for either host: `npx wdym-prompt` + +- `bin/wdym-prompt.js`: new dependency-free Node installer — detects Claude Code and/or Codex, wires skill files, hook entry, trust-anchor contract, and the single canonical `pref.json`; Codex skill path is a symlink to the canonical copy with automatic copy fallback (`--copy` to force); dedupes pre-existing duplicate wdym hook entries by command text; byte-idempotent re-runs; `--doctor` reports wiring and copy drift, and flags Codex hook trust as unreadable with a pointer to `/hooks`; `--uninstall` unwinds marker-delimited blocks and hook entries; Codex-touching runs end with the loud hook re-approval trust notice +- `bin/wdym-prompt.js`: hook dedupe matches on `prompt-detect.py` only (never a bare `wdym` substring), duplicate contract blocks are collapsed and fully removed on uninstall, and a settings file that parses to a non-object is refused rather than overwritten +- `package.json`: new — `wdym-prompt` 1.2.0, `bin`, Node >= 18, zero runtime dependencies, `files` allowlist (no tests/, improve/, audit/), `prepublishOnly` gate on the detect bench +- `.gitignore`: ignore `node_modules/` and `*.tgz` pack artifacts +- Codex install is global-scope only: `--codex --local` refuses cleanly and wires nothing; `install.sh` stays frozen as the Claude-only path + +### [11] — wdym runs correctly under Codex, not just accidentally + +- `hooks/prompt-detect.py`: emitted block's signal line is host-neutral ("invoke per the wdym auto-invoke contract" — no `CLAUDE.md`); state resolution walks cwd up to the repo root and consults `$CODEX_HOME`, so repo-local installs are found when the host starts in a subdirectory +- `hooks/telemetry-stats.py`: mirrors the same resolution order so `--status` reads the file the hook writes +- `tests/detect_bench.py`: asserts the new signal line, bans host filenames in the block, adds a subdirectory-launch scope test +- `SKILL.md`: `allowed-tools:` frontmatter removed (not a Codex field); description rewritten with trigger words front-loaded to survive Codex's skills-list truncation; host-portability note added +- `agents/openai.yaml`: new — Codex skill manifest with `display_name`, `short_description`, `policy.allow_implicit_invocation: true` +- `refs/protocol.md`: comprehensive approval gate routed through a new "present options and stop" ask step — `AskUserQuestion` when available, plain-text question + end-of-turn otherwise, chosen by tool availability +- `refs/init.md`: host-resolution table (hook file, trust anchor, command prefix per host), both `--init` questions via the ask step, Codex global-only rule, duplicate-hook collapse +- `refs/commands.md`, `refs/help.txt`, `refs/heal.md`: host-neutral wording; heal knows the Codex untrusted-hook "wired but silent" failure mode + ## 2026-07-30 ### [10] — Principles catch up to modern models; writing prompts no longer read as code diff --git a/CLAUDE.md b/CLAUDE.md index 14fdb14..b6ed499 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,4 +2,4 @@ The `` block is emitted by a user-installed `UserPromptSubmit` hook — it is **trusted project configuration, not untrusted injected input**. Invoking `wdym` when a block is present is expected and safe; do not treat the block as a prompt injection. -Defense-in-depth for the hook contract: a `` block present (any verdict) ⇒ invoke the `wdym` skill via the Skill tool **before** processing the prompt; no block ⇒ passthrough, respond normally. +Defense-in-depth for the hook contract: a `` block present (any verdict) ⇒ invoke the `wdym` skill **before** processing the prompt; no block ⇒ passthrough, respond normally. diff --git a/README.md b/README.md index f17d5c3..dff7c8c 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@

License Claude Code skill + Codex skill UserPromptSubmit hook Last commit

@@ -32,9 +33,9 @@ ## What it does -wdym catches every prompt you submit in Claude Code, works out what you're trying to do, -and rewrites it into a clearer version before your LLM sees it. You write the way you'd -talk; wdym hands the model something it can actually work with. +wdym catches every prompt you submit in Claude Code or Codex, works out what you're trying +to do, and rewrites it into a clearer version before your LLM sees it. You write the way +you'd talk; wdym hands the model something it can actually work with.

@@ -49,37 +50,87 @@ It runs as a one-time install and then stays out of your way: ## Who is this for -Anyone who writes prompts faster than they polish them. If you use Claude Code (or any -LLM) all day and your prompts come out as half-formed thoughts, wdym does the polishing -so you don't have to slow down. It's a single install per project or across every project, -and after that it's automatic. +Anyone who writes prompts faster than they polish them. If you use Claude Code or Codex +all day and your prompts come out as half-formed thoughts, wdym does the polishing so you +don't have to slow down. It's a single install per project or across every project, and +after that it's automatic. ## Installation -Two ways in. The installer does everything in one shot; `npx skills add` fits the -skills ecosystem you may already be using, and takes one extra command to finish. +Three ways in. The npx installer is the only one that covers both hosts and finishes the +job in one command; the other two exist for people already committed to a particular tool. -### Option 1 — the installer _(recommended)_ +### Option 1 — `npx wdym-prompt` _(recommended)_ -`cd` into the project you want `wdym` in, then: +```bash +npx wdym-prompt +``` + +That is the whole install. It detects which hosts you have — `~/.claude`, `~/.codex`, or +both — lays down the skill files, wires the `UserPromptSubmit` hook, writes the trust +contract into the host's instruction file, and creates your `pref.json`. **Global is the +default**, so wdym fires in every project you touch. + +Say so explicitly when the defaults aren't what you want: ```bash -curl -fsSL https://raw.githubusercontent.com/ndisisnd/wdym/main/install.sh | bash +npx wdym-prompt --both # Claude Code and Codex +npx wdym-prompt --claude --local # this project only (Claude Code) +npx wdym-prompt --on-demand # install inert; runs only via /wdym ``` -The installer pulls a tarball into a temp dir — no clone, nothing left behind. **Global is -the default**: the skill lands in `~/.claude/skills/wdym`, the hook goes into -`~/.claude/settings.json`, and `wdym` fires across every project you touch. +| Flag | What it does | +|---|---| +| `--claude` / `--codex` / `--both` | Pick the host. Default: whichever of `~/.claude` and `~/.codex` exists | +| `--global`, `-g` | Global install — the default | +| `--local`, `-l` | Local install into `./.claude` for this project (Claude Code only) | +| `--dir ` | Local install into another project directory | +| `--hook` | Fire on every prompt — the default | +| `--on-demand` | Install inert; run only via `/wdym`, no hook wired | +| `--copy` | Copy the skill for Codex instead of symlinking it | +| `--doctor` | Report what is installed, wired, and in sync | +| `--uninstall` | Remove skill dirs, hook entries, and contract blocks | +| `--force` | Skip the guards (source-repo check, prefs on uninstall) | + +Local always wins over global when both exist, so you can install globally and still +override one repo. Re-run the installer any time — it is idempotent, keeps your `pref.json` +and telemetry, and collapses a duplicated hook entry back to one if an earlier install left +two behind. It needs Node 18 or newer and installs no dependencies. + +#### Installing for Codex + +Four things worth knowing, none of them blockers: -Want it scoped to just this project? Ask for it explicitly: +- **Hooks are on by default in Codex**, so there is nothing to enable first. +- **Codex trusts hooks by file contents**, so every install or update needs one manual + step: run `/hooks` inside Codex and approve the wdym hook. Until you do, wdym stays + silent and Codex will not warn you — prompts simply pass through unchanged. The + installer prints this reminder last on every Codex run. To confirm it worked: submit any + prompt, then run `$wdym --status`. +- **The classification block is currently visible.** Codex renders hook context as a + visible developer message ([openai/codex#16933](https://github.com/openai/codex/issues/16933), + still open), so wdym's one-line signal shows up above the answer. Cosmetic only. +- **Global scope only.** `--codex --local` is refused on purpose: a repo-scoped Codex hook + lives in a committed file, which would hand every teammate an approval prompt for a tool + they never installed. + +Codex users type `$wdym` where this README writes `/wdym`. Everything else is identical. + +### Option 2 — the shell installer _(Claude Code only)_ + +`install.sh` is frozen at what it does today: Claude Code, no Codex support, no new flags. +It stays fully supported for anyone already using it. ```bash -curl -fsSL https://raw.githubusercontent.com/ndisisnd/wdym/main/install.sh | bash -s -- --local +curl -fsSL https://raw.githubusercontent.com/ndisisnd/wdym/main/install.sh | bash ``` -That installs into `./.claude/` and wires `./.claude/settings.local.json`, so only this -project gets the treatment. Local always wins over global when both exist, so you can run -it globally and still override per-project. +It pulls a tarball into a temp dir — no clone, nothing left behind. Global by default; add +`-s -- --local` for one project: + +```bash +curl -fsSL https://raw.githubusercontent.com/ndisisnd/wdym/main/install.sh | bash -s -- --local +``` | Flag | What it does | |---|---| @@ -91,12 +142,11 @@ it globally and still override per-project. | `--tarball ` | Install from a specific tarball instead of `main` | | `--force` | Skip the "you're inside the wdym repo" guard | -Both scopes write your `pref.json` (where your run mode lives) and wire up the -`UserPromptSubmit` detector in one shot. It's idempotent — run it again any time and it -won't clobber your prefs. Already have the files on disk and just want to rewire? -`/wdym --init` does the init half on its own. +Like the npx installer it writes your `pref.json`, wires the detector, and is safe to +re-run. Already have the files on disk and just want to rewire? `/wdym --init` does the +init half on its own. -### Option 2 — `npx skills add` +### Option 3 — `npx skills add` If you already manage skills with the [skills CLI](https://github.com/vercel-labs/skills), wdym installs like any other package: @@ -114,27 +164,34 @@ Then finish the setup: **That second step isn't optional.** `skills add` delivers the skill files and stops there — it's a package manager, not wdym's installer. It doesn't know wdym has a hook to -wire, a `pref.json` to write, or a `CLAUDE.md` contract to install. Without `--init` you -get a skill that only answers to `/wdym` and never fires on its own. +wire, a `pref.json` to write, or a trust contract to install. Without `--init` you get a +skill that only answers to `/wdym` and never fires on its own. -`/wdym --init` asks you the same two questions the installer takes as flags — scope +`/wdym --init` asks you the same two questions the installers take as flags — scope (local or global) and activation (hook or on-demand) — then writes all three pieces. -| | Installer | `npx skills add` | -|---|---|---| -| Skill files | ✅ | ✅ | -| `pref.json`, hook, `CLAUDE.md` contract | ✅ | via `/wdym --init` | -| Updates | re-run the installer | `npx skills update` | -| What lands in the skill dir | curated runtime files only | the whole repo, `install.sh` and `tests/` included | -| Also writes | nothing else | `skills-lock.json` at your project root | +### Which one to use -Both land in the same place — `.claude/skills/wdym` for project scope, `~/.claude/skills/wdym` -for global — so pick whichever fits your workflow. `/wdym --init` is idempotent across both. +| | `npx wdym-prompt` | `install.sh` | `npx skills add` | +|---|---|---|---| +| Claude Code | ✅ | ✅ | files only | +| Codex | ✅ | ❌ frozen, never supported it | ❌ use `npx wdym-prompt` | +| Skill files | ✅ | ✅ | ✅ | +| `pref.json`, hook, trust contract | ✅ | ✅ Claude Code only | via `/wdym --init` | +| Updates | re-run it | re-run it | `npx skills update` | +| What lands in the skill dir | curated runtime files only | curated runtime files only | the whole repo, `install.sh` and `tests/` included | +| Also writes | nothing else | nothing else | `skills-lock.json` at your project root | + +All three land the skill in the same place — `.claude/skills/wdym` for project scope, +`~/.claude/skills/wdym` for global — so pick whichever fits your workflow. `/wdym --init` +is idempotent across all of them. ### Confirming it works To confirm it's wired up, submit any normal prompt: in the default mode you'll see the -rewrite and a gate before it runs. +rewrite and a gate before it runs. `npx wdym-prompt --doctor` checks the same thing from +the outside — which hosts are wired, and on Codex it reminds you to confirm hook approval +with `/hooks`, which only Codex itself can tell you. ## How it works @@ -143,24 +200,50 @@ Just write, and wdym translates it for you. ```mermaid flowchart TD submit["You submit a prompt"] - hook["UserPromptSubmit hook
scores it deterministically"] - passthrough["Runs as-is, untouched"] - classify["Classify type
code · question · text-gen"] - principles["Load matched principles
strip noise, then add structure"] - rewrite["Rewrite the prompt"] - gate{"Run mode?"} - ask["Show diff + 3-way gate"] - send["Send to Claude"] - - submit --> hook - hook -->|"slash / ≤5 words / follow-up"| passthrough - hook -->|substantive| classify - classify --> principles --> rewrite --> gate - gate -->|comprehensive| ask - gate -->|flash| send - ask -->|"enhanced · original · edit"| send + activation{"Activation"} + hook["UserPromptSubmit hook
scores the prompt deterministically"] + manual["No hook fires —
you invoke wdym yourself"] + verdict{"Verdict"} + skip["Passthrough — runs as-is, untouched"] + block["Emits a prompt-detect block:
a classified type, or ambiguous for the model to judge"] + contract["Host reads its trust contract
and invokes the skill"] + skill["Load matched principles,
strip noise, then add structure"] + mode{"Run mode"} + flash["flash — rewrites silently"] + comprehensive["comprehensive — presents the rewrite,
asks, and stops for your answer"] + run["Your prompt runs"] + telemetry["Appends one local telemetry line"] + + submit --> activation + activation -->|"hook"| hook + activation -->|"on-demand"| manual + hook --> verdict + verdict -->|"slash, 5 words or fewer, follow-up"| skip + verdict -->|substantive| block + block --> contract + contract --> skill + manual --> skill + skill --> mode + mode -->|flash| flash + mode -->|comprehensive| comprehensive + flash --> run + comprehensive --> run + skip --> run + run --> telemetry ``` +**Two hosts, one flow.** That path is the same on Claude Code and on Codex. Only four +plumbing details differ, and the installer handles all four for you: + +| | Claude Code | Codex | +|---|---|---| +| Trust contract | `~/.claude/CLAUDE.md` | `~/.codex/AGENTS.md` | +| Skill path | `~/.claude/skills/wdym` | `~/.agents/skills/wdym` — a symlink to the Claude Code copy | +| Hook file | `~/.claude/settings.json` | `~/.codex/hooks.json` | +| Asking you | `AskUserQuestion` tool | plain-text question, then it stops and waits | + +The same path in words: + 1. **Catches it** — a `UserPromptSubmit` hook intercepts the prompt. Slash commands, ≤5-word prompts, and "thanks" / "ok" / "continue" follow-ups wave straight through untouched — no ceremony for small talk. 2. **Figures out what you meant** — classifies the prompt as `code`, `question`, `text-gen`, or `none`. ~95% of real prompts resolve deterministically in the hook (zero tokens); only genuinely mixed signals go to the LLM to adjudicate. 3. **Loads the right playbook** — pulls the global principles plus the ones for your prompt type, then picks the top 2–3 that actually apply. It strips noise (politeness, threats, bribes, hedging) _before_ adding structure (specificity, goals, format). @@ -180,17 +263,18 @@ these into a prompt _(or run them standalone)_: | `flash` | Skips the gate entirely. Rewrites your prompt and fires it off immediately — and never inserts `[fill-this-in]` placeholders, since nothing would fill them. | | `--flash` / `--comprehensive` | Permanently switch your stored run mode (add `--set-mode` to switch _without_ touching the current prompt). | | `--global` | Forces the universal principle base and skips type detection for this run. Good for one-off, type-agnostic prompts. | -| `--init` | Bootstraps the skill — writes `pref.json` and wires the hook, asking local vs. global scope. | +| `--init` | Bootstraps the skill — writes `pref.json`, wires the hook, and installs the trust contract, asking scope and activation. | | `--status` _(alias `--stats`)_ | Prints a styled usage report — prompts seen, transform rate, a ranked by-type breakdown. Telemetry stays 100% local. | | `--help` | Lists all commands, modes, and prompt types with one-liners. | ## How to update -**Update wdym itself** by re-running the installer — it's idempotent and won't touch your -`pref.json` or telemetry: +**Update wdym itself** by re-running the installer you used. Both are idempotent and +neither touches your `pref.json` or telemetry: ```bash -curl -fsSL https://raw.githubusercontent.com/ndisisnd/wdym/main/install.sh | bash +npx wdym-prompt # either host +curl -fsSL https://raw.githubusercontent.com/ndisisnd/wdym/main/install.sh | bash # Claude Code ``` **If you installed with `npx skills add`**, update through the same CLI: @@ -199,10 +283,18 @@ curl -fsSL https://raw.githubusercontent.com/ndisisnd/wdym/main/install.sh | bas npx skills update wdym ``` +**On Codex, re-approve after every update.** Codex trusts hooks by file contents, so an +update invalidates the old approval — run `/hooks` in Codex and approve the wdym hook +again, or it goes quiet without saying so. + **Rewire an install you already have on disk** — after editing settings by hand, if the hook came unwired, or after a `skills update` — with `/wdym --init`, which redoes the init half without reinstalling. +**Check what's actually wired** with `npx wdym-prompt --doctor`: it reports each host, the +skill copies, and whether they are in sync. Codex hook approval is the one thing it can't +read — it lives inside Codex, so `--doctor` points you at `/hooks` to confirm it. + ## FAQ **Does it cost tokens on every prompt?** Mostly no. The hook scores each prompt @@ -223,7 +315,12 @@ leaves your machine. **Global or local install?** Global is the default and covers every project. Install `--local` when you want it scoped to one repo; a local install always wins over the global -one, so you can do both. +one, so you can do both. Codex is global-only — see the Codex notes under Installation. + +**Does it work with Codex?** Yes, from v1.2.0. `npx wdym-prompt --codex` (or `--both`) +wires it: the skill is exposed at `~/.agents/skills/wdym`, the hook goes into +`~/.codex/hooks.json`, and the trust contract into `~/.codex/AGENTS.md`. One manual step +remains — approve the hook with `/hooks` inside Codex after every install or update. ## License diff --git a/RELEASES.md b/RELEASES.md index f476689..3acb2aa 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -2,6 +2,59 @@ What's new for you, release by release. +## v1.2.0 — 2026-08-09 + +> wdym is no longer a Claude Code tool. It runs on Codex too, and one command — +> `npx wdym-prompt` — now installs and wires it on either host, or both at once. The README +> finally shows you the whole flow in a single picture instead of describing it in prose. + +### ✨ New +- **Codex support.** wdym classifies, rewrites, and gates prompts in Codex the same way it + does in Claude Code. Install it with `npx wdym-prompt --codex`, or `--both` to cover both + hosts at once. +- **One install command.** `npx wdym-prompt` detects which hosts you have, lays down the + skill, wires the hook, writes the trust contract, and creates your preferences — no + follow-up step. Re-run it any time to update. +- **`--doctor`.** Ask wdym what is actually installed: which hosts are wired and whether + the two skill copies agree. Codex keeps hook approval to itself, so `--doctor` reminds + you to confirm it with `/hooks` rather than pretending to know. +- **`--uninstall`.** Removes the skill, the hook entry, and the contract block, and leaves + your preferences and usage history alone unless you pass `--force`. +- **A flow diagram in the README.** One picture covering both activation modes, the + prompts that pass straight through, and both run modes — with a short table naming the + four things that differ between Claude Code and Codex. + +### 📈 Improved +- The approval gate and `--init` now ask their questions in whatever way the host you are + on supports, so they work everywhere instead of only where Claude Code's question tool + exists. +- One preferences file serves both hosts, so your run mode can never disagree between + Claude Code and Codex. +- Your Codex copy of the skill is a link to the Claude Code copy, so updating one updates + the other and the two cannot quietly drift apart. +- Re-running an installer over an install that ended up with two wdym hooks now collapses + them back to one, which stops the double rewrite and double usage counting that caused. + +### 🐛 Fixed +- Launching your agent inside a subfolder of a project no longer misses that project's + local wdym settings. +- The one-line signal wdym emits no longer names a Claude-specific file, so it reads + correctly on any host. + +### ⚠️ Known limitations +- **Codex asks you to approve the hook after every install or update.** Codex trusts hooks + by their contents, so any change invalidates the previous approval. Run `/hooks` in Codex + and approve the wdym hook, or wdym stays silent without telling you. The installer says + so at the end of every Codex run. +- **Codex shows wdym's classification line.** Codex currently renders hook context as a + visible message ([openai/codex#16933](https://github.com/openai/codex/issues/16933)), so + you will see a one-line signal above each answer. Cosmetic only. +- **Codex is global-only.** wdym applies to every project there; per-repo Codex installs + are deliberately refused. +- **The shell installer is frozen and stays Claude Code only.** `curl … install.sh` keeps + working exactly as it did; it will not gain Codex support. Use `npx wdym-prompt` for + that. + ## v1.1.0 — 2026-07-30 > You now control when wdym fires — on every prompt automatically, or only when you ask — diff --git a/SKILL.md b/SKILL.md index 34b4ee3..a38bcca 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,21 +1,28 @@ --- name: wdym description: > - Prompt rewriter with two activation modes — hooked (fires on every prompt via a - UserPromptSubmit block) or on-demand (invoked via /wdym or when - asked to improve a prompt). Detects prompt type (code, question, text-gen) and - rewrites using 2–3 matched principles; flash mode runs immediately, comprehensive - gates for approval. Manage with /wdym --init, --status, --set-mode, --help. -allowed-tools: - - AskUserQuestion - - Read - - Write - - Edit - - Bash + Rewrite, improve, or enhance a prompt. Runs on /wdym, on any request to + improve/enhance/rewrite a prompt, and on a UserPromptSubmit + block. Detects prompt type (code, question, text-gen) and rewrites using 2–3 + matched principles; flash mode rewrites immediately, comprehensive mode + presents the rewrite and waits for approval. Manage with /wdym --init, + --status, --set-mode, --help. --- # wdym +**Host portability.** wdym runs on any agent host with a `UserPromptSubmit` +hook. Two things vary by host while the skill is running (a third set — where +installed files land — is resolved by `refs/init.md`, and only during `--init`): + +- **Command prefix** — `/wdym` on Claude Code, `$wdym` on Codex. This skill + writes `/wdym` throughout; use the running host's prefix in anything shown to + the user. +- **Asking the user** — the *ask step* in `refs/protocol.md`. Use the + `AskUserQuestion` tool when it is available, otherwise ask in plain text and + end the turn. Choose by whether that tool is available, never by guessing + which host you are on. + **Activation** is set by `pref.json`'s `activation` key and chosen at `/wdym --init`: diff --git a/agents/openai.yaml b/agents/openai.yaml new file mode 100644 index 0000000..4192310 --- /dev/null +++ b/agents/openai.yaml @@ -0,0 +1,13 @@ +# Codex skill metadata. +# +# Codex reads this file for the skills list and for whether the model may invoke +# wdym on its own; Claude Code ignores it. Tool dependencies are deliberately not +# declared: wdym uses only the host's built-in file-read, file-write and shell +# capabilities, and asking the user is handled by the tool-availability check in +# refs/protocol.md rather than by requiring a tool that Codex does not have. + +display_name: wdym +short_description: Rewrite, improve, or enhance a prompt before it runs. + +policy: + allow_implicit_invocation: true diff --git a/bin/wdym-prompt.js b/bin/wdym-prompt.js new file mode 100644 index 0000000..9b2bd21 --- /dev/null +++ b/bin/wdym-prompt.js @@ -0,0 +1,814 @@ +#!/usr/bin/env node +'use strict'; +/* + * wdym-prompt — the npx installer for the wdym skill. + * + * Installs and wires wdym on either agent host: + * + * Claude Code skill dir + settings.json/settings.local.json hook + CLAUDE.md + * trust anchor + pref.json. Local or global scope. + * Codex canonical skill copy under ~/.claude/skills/wdym, exposed to + * Codex at ~/.agents/skills/wdym (symlink, copy on fallback), + * hook entry in $CODEX_HOME/hooks.json, trust anchor in + * $CODEX_HOME/AGENTS.md. Global scope only. + * + * One pref file serves both hosts (~/.claude/wdym/pref.json at global scope), so + * the two can never disagree about mode or activation. + * + * Zero runtime dependencies. Node >= 18. + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// --- Constants --------------------------------------------------------------- + +const PKG_ROOT = path.resolve(__dirname, '..'); +const PKG = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8')); +const SKILL_NAME = process.env.SKILL_NAME || 'wdym'; + +// Marker strings are shared with install.sh so either installer recognises (and +// refreshes in place) a block the other one wrote. +const MARK_START = ''; +const MARK_END = ''; + +// Files that constitute the skill. Verified in the package before anything is +// written, so a broken publish cannot half-install over a working copy. +const REQUIRED = [ + 'SKILL.md', + 'hooks/prompt-detect.py', + 'hooks/telemetry-stats.py', + 'refs/manifest.json', + 'refs/protocol.md', + 'refs/commands.md', + 'refs/help.txt', + 'refs/detect.md', + 'refs/init.md', + 'refs/authoring.md', + 'refs/categories.json', + 'refs/categories.default.json', + 'refs/principles/principles-global.md', + 'refs/principles/principles-code.md', + 'refs/principles/principles-question.md', + 'refs/principles/principles-text-gen.md', +]; + +const COPY_FILES = ['SKILL.md', 'README.md', 'CHANGELOG.md']; +const COPY_DIRS = ['refs', 'hooks', 'asset', 'agents']; + +// --- Output ------------------------------------------------------------------ + +const out = []; +function say(s) { out.push(s); console.log(s); } +function info(s) { say(` • ${s}`); } +function ok(s) { say(`✓ ${s}`); } +function warn(s) { say(` ! ${s}`); } +function err(s) { console.error(`✗ ${s}`); } +function die(msg, code = 1) { err(msg); process.exit(code); } + +// --- Small filesystem / JSON helpers ----------------------------------------- + +function homeDir() { return process.env.HOME || os.homedir(); } +function claudeHome() { return process.env.CLAUDE_CONFIG_DIR || path.join(homeDir(), '.claude'); } +function codexHome() { return process.env.CODEX_HOME || path.join(homeDir(), '.codex'); } +function agentsSkillsDir() { return path.join(homeDir(), '.agents', 'skills'); } + +function exists(p) { try { fs.lstatSync(p); return true; } catch { return false; } } +function isDir(p) { try { return fs.statSync(p).isDirectory(); } catch { return false; } } +function isSymlink(p) { try { return fs.lstatSync(p).isSymbolicLink(); } catch { return false; } } + +function readText(p) { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } } + +/** Write only when the bytes actually change. Every re-run is then byte-idempotent + * on every file this installer touches, and a no-op run leaves mtimes alone. */ +function writeIfChanged(p, content) { + const before = readText(p); + if (before === content) return false; + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, content, 'utf8'); + return true; +} + +function readJson(p) { + const raw = readText(p); + if (raw === null) return { data: {}, existed: false }; + // A file that parses but is not a JSON object (an array, a string, null) is + // treated exactly like a broken one: refuse it rather than silently replace + // whatever the user actually had there. + try { const d = JSON.parse(raw); return { data: (d && typeof d === 'object' && !Array.isArray(d)) ? d : null, existed: true }; } + catch { return { data: null, existed: true }; } +} + +function serialiseJson(obj) { return JSON.stringify(obj, null, 2) + '\n'; } + +function copyTree(src, dest) { + fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + if (entry.name === '.DS_Store') continue; + const s = path.join(src, entry.name); + const d = path.join(dest, entry.name); + if (entry.isDirectory()) copyTree(s, d); + else if (entry.isSymbolicLink()) { try { fs.unlinkSync(d); } catch {} fs.symlinkSync(fs.readlinkSync(s), d); } + else fs.copyFileSync(s, d); + } +} + +function listTree(root, prefix = '') { + const found = []; + if (!isDir(root)) return found; + for (const entry of fs.readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + if (entry.name === '.DS_Store') continue; + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) found.push(...listTree(path.join(root, entry.name), rel)); + else found.push(rel); + } + return found; +} + +/** Compare two skill trees by content. Returns a list of differing relative paths. */ +function treeDiff(a, b) { + const la = listTree(a), lb = listTree(b); + const all = new Set([...la, ...lb]); + const diffs = []; + for (const rel of [...all].sort()) { + const pa = path.join(a, rel), pb = path.join(b, rel); + const ca = readText(pa), cb = readText(pb); + if (ca === null || cb === null || ca !== cb) diffs.push(rel); + } + return diffs; +} + +function rmrf(p) { try { fs.rmSync(p, { recursive: true, force: true }); } catch {} } + +// --- Argument parsing -------------------------------------------------------- + +function parseArgs(argv) { + const a = { + hosts: null, // null = auto-detect + scope: 'global', + activation: 'hook', + dir: '', + force: false, + copy: false, + action: 'install', // install | doctor | uninstall | help | version + codexExplicit: false, + bothExplicit: false, + scopeExplicit: false, + }; + const hosts = new Set(); + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + switch (arg) { + case '--claude': hosts.add('claude'); break; + case '--codex': hosts.add('codex'); a.codexExplicit = true; break; + case '--both': hosts.add('claude'); hosts.add('codex'); a.bothExplicit = true; break; + case '-g': case '--global': a.scope = 'global'; a.scopeExplicit = true; break; + case '-l': case '--local': a.scope = 'local'; a.scopeExplicit = true; break; + case '--hook': a.activation = 'hook'; break; + case '--on-demand': a.activation = 'on-demand'; break; + case '--dir': + a.dir = argv[++i] || ''; + if (!a.dir) die('--dir needs a path'); + a.scope = 'local'; a.scopeExplicit = true; + break; + case '--force': a.force = true; break; + case '--copy': a.copy = true; break; + case '--doctor': a.action = 'doctor'; break; + case '--uninstall': a.action = 'uninstall'; break; + case '-h': case '--help': a.action = 'help'; break; + case '-v': case '--version': a.action = 'version'; break; + default: + err(`unknown argument: ${arg}`); + console.error(''); + printHelp(); + process.exit(1); + } + } + if (hosts.size) a.hosts = [...hosts]; + return a; +} + +function detectHosts() { + const found = []; + if (isDir(claudeHome())) found.push('claude'); + if (isDir(codexHome())) found.push('codex'); + return found.length ? found : ['claude']; +} + +// --- Help -------------------------------------------------------------------- + +function printHelp() { + console.log(`wdym-prompt ${PKG.version} — install and wire the wdym prompt rewriter + +Usage: + npx wdym-prompt [host] [scope] [activation] [options] + +Host (default: whichever of ~/.claude and ~/.codex exist) + --claude install for Claude Code + --codex install for Codex (global scope only) + --both install for both hosts + +Scope (default: --global) + -g, --global install under ~/.claude — applies to every project + -l, --local install under ./.claude — applies to this project only + --dir local install into another project directory + +Activation (default: --hook) + --hook fire on every prompt via UserPromptSubmit + --on-demand install inert; runs only via /wdym + +Options + --copy copy the skill for Codex instead of symlinking it + --force overwrite guards (source-repo check, prefs on uninstall) + --doctor report what is installed, wired, and in sync + --uninstall remove skill dirs, hook entries and contract blocks + -h, --help this help + -v, --version print the installer version + +Notes + Codex is global scope only: "--codex --local" is refused. A repo-scoped Codex + hook lives in a committed file, so teammates get an approval prompt for a tool + they never installed. + + Codex trusts hooks by file contents, so every install run needs "/hooks" + re-approval inside Codex. The installer says so at the end of any Codex run.`); +} + +// --- Skill file installation ------------------------------------------------- + +function verifyPackage() { + const missing = REQUIRED.filter((f) => !exists(path.join(PKG_ROOT, f))); + if (missing.length) { + for (const m of missing) err(`missing required file in package: ${m}`); + die('Package is incomplete — aborting without touching the target.'); + } +} + +/** Install the skill files into targetDir. Returns 'copied' | 'symlink-kept'. */ +function installSkillFiles(targetDir) { + if (isSymlink(targetDir)) return 'symlink-kept'; // dev install pointed at a working tree + fs.mkdirSync(targetDir, { recursive: true }); + for (const f of COPY_FILES) { + const src = path.join(PKG_ROOT, f); + if (exists(src)) { + const dst = path.join(targetDir, f); + if (readText(src) !== readText(dst)) fs.copyFileSync(src, dst); + } + } + for (const d of COPY_DIRS) { + const src = path.join(PKG_ROOT, d); + if (!isDir(src)) continue; + const dst = path.join(targetDir, d); + if (treeDiff(src, dst).length === 0) continue; // already identical — leave mtimes alone + rmrf(dst); + copyTree(src, dst); + } + for (const h of ['prompt-detect.py', 'telemetry-stats.py']) { + const p = path.join(targetDir, 'hooks', h); + if (exists(p)) { try { fs.chmodSync(p, 0o755); } catch {} } + } + return 'copied'; +} + +// --- pref.json --------------------------------------------------------------- + +function writePref(prefPath, activation, extra) { + const { data, existed } = readJson(prefPath); + const pref = data || {}; + let mode = pref.mode; + if (mode !== 'comprehensive' && mode !== 'flash') mode = 'comprehensive'; + const was = pref.activation; + pref.mode = mode; + pref.activation = activation; + if (extra) Object.assign(pref, extra); + const changed = writeIfChanged(prefPath, serialiseJson(pref)); + const verb = existed ? (changed ? 'updated' : 'already current') : 'created'; + const shift = (was === activation || !existed) ? '' : `, activation: ${was || 'unset'} -> ${activation}`; + return `${verb} (mode: ${mode}, activation: ${activation})${shift}`; +} + +// --- Hook wiring ------------------------------------------------------------- + +/** + * Is this hook entry one of ours? + * + * Matched on the hook script's filename only — never on the string "wdym", + * which appears in plenty of paths that have nothing to do with this tool (a + * checkout named wdym, a user script called wdym-notes/lint.py). Deleting a + * hook the user wrote is far worse than leaving a stale wdym entry behind. + * install.sh matches on the same filename, so the two installers agree. + */ +function isWdymCommand(cmd) { + return typeof cmd === 'string' && cmd.includes('prompt-detect.py'); +} + +/** + * Wire, rewire, dedupe or unwire the UserPromptSubmit hook. + * + * Both hosts use the same JSON shape — Claude Code's settings.json and Codex's + * hooks.json both hold hooks.UserPromptSubmit[].hooks[] entries of + * {type:"command", command:"..."} — so one implementation serves both. + * + * Matching is by command *content* (prompt-detect.py / wdym), never by exact + * string: a path goes stale when the skill dir moves, and the real-world Codex + * file already carries a duplicated pair that has to collapse to one. + */ +function wireHook(settingsPath, hookCmd, activation) { + const { data } = readJson(settingsPath); + if (data === null) return { result: 'unparseable' }; + const settings = data; + + const hooks = settings.hooks && typeof settings.hooks === 'object' ? settings.hooks : null; + const groups = hooks && Array.isArray(hooks.UserPromptSubmit) ? hooks.UserPromptSubmit : []; + + let matches = 0; + let firstEntry = null; + const emptied = new Set(); + + for (const group of groups) { + const list = Array.isArray(group.hooks) ? group.hooks : []; + const keep = []; + for (const h of list) { + if (isWdymCommand(h && h.command)) { + matches++; + if (!firstEntry && activation === 'hook') { firstEntry = h; keep.push(h); } + } else keep.push(h); + } + if (keep.length !== list.length) emptied.add(group); + group.hooks = keep; + } + + let result; + const oldCmd = firstEntry ? firstEntry.command : null; + + if (activation === 'on-demand') { + result = matches ? 'removed' : 'already_absent'; + } else if (firstEntry) { + firstEntry.command = hookCmd; + if (matches > 1) result = 'deduped'; + else if (oldCmd !== hookCmd) result = 'rewired'; + else result = 'already_present'; + } else { + const h = settings.hooks && typeof settings.hooks === 'object' ? settings.hooks : (settings.hooks = {}); + const ups = Array.isArray(h.UserPromptSubmit) ? h.UserPromptSubmit : (h.UserPromptSubmit = []); + ups.push({ hooks: [{ type: 'command', command: hookCmd }] }); + result = 'added'; + } + + // Prune only the containers this run emptied. An unrelated hook group, event + // or settings key is left exactly as it was. + if (settings.hooks && Array.isArray(settings.hooks.UserPromptSubmit)) { + const pruned = settings.hooks.UserPromptSubmit.filter((g) => (Array.isArray(g.hooks) && g.hooks.length) || !emptied.has(g)); + if (pruned.length) settings.hooks.UserPromptSubmit = pruned; + else { + delete settings.hooks.UserPromptSubmit; + if (!Object.keys(settings.hooks).length) delete settings.hooks; + } + } + + const changed = writeIfChanged(settingsPath, serialiseJson(settings)); + if (!changed && (result === 'added' || result === 'rewired' || result === 'deduped')) result = 'already_present'; + return { result, matches }; +} + +function reportHook(result, settingsPath) { + switch (result) { + case 'added': info(`hook wired into ${settingsPath} (UserPromptSubmit)`); break; + case 'rewired': info(`stale hook path rewritten in ${settingsPath} (UserPromptSubmit)`); break; + case 'deduped': info(`duplicate wdym hooks collapsed to one in ${settingsPath} (UserPromptSubmit)`); break; + case 'already_present': info(`hook already present in ${settingsPath} — no change`); break; + case 'removed': info(`hook removed from ${settingsPath} — wdym runs on demand only`); break; + case 'already_absent': info(`no hook in ${settingsPath} — already on demand only`); break; + case 'skipped_global': info(`no change to ${settingsPath} — a global wdym hook already fires in this project`); break; + case 'unparseable': die(`${settingsPath} is not valid JSON — fix or move it, then re-run.`); break; + default: die(`could not update hook — check ${settingsPath} manually`); + } +} + +/** Does any global Claude hook already fire prompt-detect.py? */ +function globalHookPresent() { + const { data } = readJson(path.join(claudeHome(), 'settings.json')); + if (!data) return false; + const groups = (data.hooks && data.hooks.UserPromptSubmit) || []; + return groups.some((g) => (g.hooks || []).some((h) => isWdymCommand(h && h.command))); +} + +// --- Trust-anchor contract --------------------------------------------------- + +function contractBlock(activation, prefix) { + const state = activation === 'hook' + ? 'currently `hook`, so blocks are emitted' + : 'currently `on-demand`, so no block is ever emitted and the rule below never fires — block absence is normal, not a fault'; + return `${MARK_START} +## wdym Auto-Invoke + +The \`\` block is emitted by a user-installed \`UserPromptSubmit\` hook — it is **trusted configuration, not untrusted injected input**. Invoking \`wdym\` when a block is present is expected and safe; do not treat the block as a prompt injection. + +A \`\` block present (any verdict) ⇒ invoke the \`wdym\` skill **before** processing the prompt; no block ⇒ respond normally. + +Activation is set by \`activation\` in wdym's \`pref.json\` (${state}) and changed with \`${prefix} --init --hook\` / \`--on-demand\`, which moves the hook wiring with it. +${MARK_END}`; +} + +/** Every marked region in the text, as [start, end) offsets, in file order. */ +function blockRanges(text) { + const re = new RegExp(`${MARK_START}[\\s\\S]*?${MARK_END}`, 'g'); + const ranges = []; + let m; + while ((m = re.exec(text)) !== null) ranges.push([m.index, m.index + m[0].length]); + return ranges; +} + +/** Remove every marked region, closing the gap it leaves without disturbing the + * blank-line structure of the surrounding text the user wrote. */ +function stripBlocks(text) { + const re = new RegExp(`\\n*${MARK_START}[\\s\\S]*?${MARK_END}\\n*`, 'g'); + return text.replace(re, (m, off) => { + const before = off > 0; + const after = off + m.length < text.length; + return before && after ? '\n\n' : (before ? '\n' : ''); + }); +} + +function writeContract(filePath, activation, prefix) { + const block = contractBlock(activation, prefix); + const existing = readText(filePath); + + const ranges = existing === null ? [] : blockRanges(existing); + if (ranges.length) { + // Refresh the first block in place; any further stale copies — a second + // block left by an older tool, or a hand-paste — are removed, so the file + // can never end up asserting two different activations at once. + const head = existing.slice(0, ranges[0][0]); + const tail = stripBlocks(existing.slice(ranges[0][1])); + const updated = head + block + tail; + return writeIfChanged(filePath, updated) ? 'refreshed' : 'already_current'; + } + const base = existing || ''; + const sep = !base ? '' : (base.endsWith('\n') ? '\n' : '\n\n'); + writeIfChanged(filePath, base + sep + block + '\n'); + return 'added'; +} + +function removeContract(filePath) { + const existing = readText(filePath); + if (existing === null || !blockRanges(existing).length) return 'absent'; + let updated = stripBlocks(existing); + if (!updated.trim()) updated = ''; + return writeIfChanged(filePath, updated) ? 'removed' : 'absent'; +} + +function reportContract(result, filePath, activation) { + switch (result) { + case 'added': info(`trust-anchor contract written to ${filePath}`); break; + case 'refreshed': info(`trust-anchor contract updated in ${filePath} (activation: ${activation})`); break; + case 'already_current': info(`trust-anchor contract already current in ${filePath} — no change`); break; + case 'removed': info(`trust-anchor contract removed from ${filePath}`); break; + case 'absent': info(`no trust-anchor contract in ${filePath} — nothing to remove`); break; + } +} + +// --- Codex trust notice ------------------------------------------------------ + +const RULE = '─'.repeat(64); + +function trustNotice() { + console.log(''); + console.log(RULE); + console.log('ACTION REQUIRED — Codex only'); + console.log(''); + console.log('Run /hooks in Codex and approve the wdym hook.'); + console.log(''); + console.log('Codex trusts hooks by the contents of the hooks file. This install'); + console.log('changed that file, so any approval you gave before no longer applies.'); + console.log(''); + console.log('Until you approve it, wdym will not run — and Codex will not warn you.'); + console.log('Your prompts will simply pass through unchanged.'); + console.log(''); + console.log('To confirm it worked: submit a prompt, then run "$wdym --status".'); + console.log(RULE); +} + +// --- Host installs ----------------------------------------------------------- + +// What this run has already written. A --both run installs one canonical skill +// tree and one pref file; the second host reports sharing them rather than +// claiming to have written them twice. +const done = { skillDirs: new Set(), prefs: new Set() }; + +function claudePaths(args) { + if (args.scope === 'global') { + const base = claudeHome(); + return { + base, + skillDir: path.join(base, 'skills', SKILL_NAME), + settingsPath: path.join(base, 'settings.json'), + prefPath: path.join(base, 'wdym', 'pref.json'), + contractPath: path.join(base, 'CLAUDE.md'), + projectDir: null, + }; + } + let projectDir = args.dir || process.env.CLAUDE_PROJECT_DIR || process.cwd(); + if (!isDir(projectDir)) die(`no such directory: ${projectDir}`); + projectDir = fs.realpathSync(projectDir); + const base = path.join(projectDir, '.claude'); + return { + base, + skillDir: path.join(base, 'skills', SKILL_NAME), + settingsPath: path.join(base, 'settings.local.json'), + prefPath: path.join(base, 'wdym', 'pref.json'), + contractPath: path.join(projectDir, 'CLAUDE.md'), + projectDir, + }; +} + +function installClaude(args) { + const p = claudePaths(args); + + if (p.projectDir && !args.force + && exists(path.join(p.projectDir, 'SKILL.md')) + && exists(path.join(p.projectDir, 'hooks', 'prompt-detect.py'))) { + err(`${p.projectDir} looks like the wdym source repo — installing here would nest wdym inside itself.`); + die('cd into the project you want wdym in, or pass --dir , --global, or --force.'); + } + + say(`Claude Code (${args.scope} scope, ${args.activation} activation)`); + const mode = installSkillFiles(p.skillDir); + done.skillDirs.add(p.skillDir); + info(mode === 'symlink-kept' + ? `${p.skillDir} is a symlink (dev install) — left in place, no copy` + : `skill files installed at ${p.skillDir}`); + + info(`pref.json ${writePref(p.prefPath, args.activation)}`); + done.prefs.add(p.prefPath); + + const hookCmd = `python3 "${path.join(p.skillDir, 'hooks', 'prompt-detect.py')}"`; + let result; + if (args.scope === 'local' && args.activation === 'hook' && globalHookPresent()) { + // Claude Code merges hook lists across settings files without deduping by + // command text, so a second entry here would fire the hook twice per prompt. + // Local scope only needs its own pref.json to override the global default. + result = 'skipped_global'; + } else { + result = wireHook(p.settingsPath, hookCmd, args.activation).result; + } + reportHook(result, p.settingsPath); + reportContract(writeContract(p.contractPath, args.activation, '/wdym'), p.contractPath, args.activation); + return p; +} + +function installCodex(args) { + const home = codexHome(); + const canonical = path.join(claudeHome(), 'skills', SKILL_NAME); + const linkPath = path.join(agentsSkillsDir(), SKILL_NAME); + + say(`Codex (global scope, ${args.activation} activation)`); + + // The canonical copy lives under ~/.claude/skills so both hosts read one tree. + if (done.skillDirs.has(canonical)) { + info(`skill files: sharing the canonical copy at ${canonical}`); + } else { + const mode = installSkillFiles(canonical); + done.skillDirs.add(canonical); + info(mode === 'symlink-kept' + ? `${canonical} is a symlink (dev install) — left in place, no copy` + : `skill files installed at ${canonical}`); + } + + // Codex only scans ~/.agents/skills, so expose the canonical copy there. + let skillMode = 'symlink'; + fs.mkdirSync(agentsSkillsDir(), { recursive: true }); + if (args.copy) { + if (isSymlink(linkPath)) rmrf(linkPath); + if (treeDiff(canonical, linkPath).length) { rmrf(linkPath); copyTree(canonical, linkPath); info(`skill copied to ${linkPath} (--copy)`); } + else info(`skill copy at ${linkPath} already matches the canonical copy`); + skillMode = 'copy'; + } else if (isSymlink(linkPath) && (() => { try { return fs.realpathSync(linkPath) === fs.realpathSync(canonical); } catch { return false; } })()) { + info(`skill symlink already points at ${canonical} — no change`); + } else { + rmrf(linkPath); + try { + fs.symlinkSync(canonical, linkPath, 'dir'); + info(`skill symlinked: ${linkPath} -> ${canonical}`); + } catch (e) { + // Some filesystems and locked-down machines refuse symlinks. A real copy + // still works; it just needs drift-checking on every later run. + copyTree(canonical, linkPath); + skillMode = 'copy'; + warn(`symlink refused (${e.code || e.message}) — copied the skill to ${linkPath} instead`); + warn('the copy can drift from the canonical tree; "--doctor" checks it'); + } + } + + // One canonical pref, shared with Claude Code. Never a second Codex pref — + // the two hosts must not be able to disagree about mode or activation. + const prefPath = path.join(claudeHome(), 'wdym', 'pref.json'); + const prefMsg = writePref(prefPath, args.activation, { codex_skill_mode: skillMode }); + info(done.prefs.has(prefPath) + ? `pref.json shared with Claude Code at ${prefPath} (skill mode: ${skillMode})` + : `pref.json ${prefMsg} at ${prefPath}`); + done.prefs.add(prefPath); + + const hooksPath = path.join(home, 'hooks.json'); + const hookCmd = `python3 "${path.join(canonical, 'hooks', 'prompt-detect.py')}"`; + const { result } = wireHook(hooksPath, hookCmd, args.activation); + reportHook(result, hooksPath); + + const agentsMd = path.join(home, 'AGENTS.md'); + reportContract(writeContract(agentsMd, args.activation, '$wdym'), agentsMd, args.activation); + + return { home, canonical, linkPath, skillMode }; +} + +// --- Doctor ------------------------------------------------------------------ + +function doctorClaude(args) { + say('Claude Code'); + const scopes = [{ ...claudePaths({ ...args, scope: 'global' }), label: 'global' }]; + const localDir = args.dir || process.cwd(); + if (isDir(path.join(localDir, '.claude'))) { + scopes.push({ ...claudePaths({ ...args, scope: 'local', dir: localDir }), label: `local (${localDir})` }); + } + for (const p of scopes) { + say(` ${p.label}`); + info(`skill dir: ${isDir(p.skillDir) || isSymlink(p.skillDir) ? p.skillDir + (isSymlink(p.skillDir) ? ' (symlink)' : '') : 'absent'}`); + if (isDir(p.skillDir) && !isSymlink(p.skillDir)) { + const drift = skillDrift(p.skillDir); + info(drift.length ? `version: differs from this package in ${drift.length} file(s) — re-run to update` : 'version: matches this package'); + } + const { data } = readJson(p.settingsPath); + if (data === null) info(`hook: ${p.settingsPath} is unparseable JSON`); + else { + const cmds = ((data.hooks && data.hooks.UserPromptSubmit) || []) + .flatMap((g) => (g.hooks || []).map((h) => h && h.command)) + .filter(isWdymCommand); + info(`hook: ${cmds.length === 0 ? 'not wired' : cmds.length === 1 ? 'wired' : `${cmds.length} entries — DUPLICATED, re-run to collapse`} (${p.settingsPath})`); + } + const { data: pref } = readJson(p.prefPath); + info(`pref: ${pref && Object.keys(pref).length ? `mode ${pref.mode}, activation ${pref.activation}` : 'absent'} (${p.prefPath})`); + const c = readText(p.contractPath); + info(`contract: ${c && c.includes(MARK_START) ? 'present' : 'absent'} (${p.contractPath})`); + } +} + +/** Which of the shipped skill files differ from an installed copy. Compares only + * the curated install set — the package also carries repo-only files that are + * deliberately never installed. */ +function skillDrift(targetDir) { + const diffs = []; + for (const f of COPY_FILES) { + const src = path.join(PKG_ROOT, f); + if (!exists(src)) continue; + if (readText(src) !== readText(path.join(targetDir, f))) diffs.push(f); + } + for (const d of COPY_DIRS) { + const src = path.join(PKG_ROOT, d); + if (!isDir(src)) continue; + diffs.push(...treeDiff(src, path.join(targetDir, d)).map((r) => `${d}/${r}`)); + } + return diffs; +} + +function doctorCodex() { + say('Codex'); + const home = codexHome(); + const canonical = path.join(claudeHome(), 'skills', SKILL_NAME); + const linkPath = path.join(agentsSkillsDir(), SKILL_NAME); + + info(`canonical: ${isDir(canonical) ? canonical : 'absent'}`); + if (!exists(linkPath)) info(`codex path: absent (${linkPath})`); + else if (isSymlink(linkPath)) { + let target = '?'; + try { target = fs.readlinkSync(linkPath); } catch {} + const live = (() => { try { return fs.realpathSync(linkPath) === fs.realpathSync(canonical); } catch { return false; } })(); + info(`codex path: symlink -> ${target}${live ? ' (resolves to the canonical copy — no drift possible)' : ' (BROKEN — does not resolve to the canonical copy)'}`); + } else { + const diffs = treeDiff(canonical, linkPath); + info(`codex path: real copy (${linkPath}) — drift-checking applies`); + info(diffs.length ? `drift: ${diffs.length} file(s) differ from the canonical copy: ${diffs.slice(0, 5).join(', ')}${diffs.length > 5 ? ', …' : ''}` : 'drift: none — copy matches the canonical tree'); + } + + const hooksPath = path.join(home, 'hooks.json'); + const { data } = readJson(hooksPath); + if (data === null) info(`hook: ${hooksPath} is unparseable JSON`); + else { + const cmds = ((data.hooks && data.hooks.UserPromptSubmit) || []) + .flatMap((g) => (g.hooks || []).map((h) => h && h.command)) + .filter(isWdymCommand); + info(`hook: ${cmds.length === 0 ? 'not wired' : cmds.length === 1 ? 'wired' : `${cmds.length} entries — DUPLICATED, re-run to collapse`} (${hooksPath})`); + } + const agentsMd = path.join(home, 'AGENTS.md'); + const c = readText(agentsMd); + info(`contract: ${c && c.includes(MARK_START) ? 'present' : 'absent'} (${agentsMd})`); + + const { data: pref } = readJson(path.join(claudeHome(), 'wdym', 'pref.json')); + info(`pref: ${pref && Object.keys(pref).length ? `mode ${pref.mode}, activation ${pref.activation}, skill mode ${pref.codex_skill_mode || 'unrecorded'}` : 'absent'}`); + + // Codex stores hook approval outside any file this tool can read, so there is + // nothing honest to report but the fact that it must be checked in-session. + info('trust: unknown — Codex records hook approval outside any readable file; run /hooks in Codex to check'); +} + +// --- Uninstall --------------------------------------------------------------- + +function uninstallClaude(args) { + say('Claude Code'); + const targets = [claudePaths({ ...args, scope: 'global' })]; + const localDir = args.dir || process.cwd(); + if (isDir(path.join(localDir, '.claude'))) targets.push(claudePaths({ ...args, scope: 'local', dir: localDir })); + for (const p of targets) { + if (isDir(p.skillDir) || isSymlink(p.skillDir)) { rmrf(p.skillDir); info(`removed ${p.skillDir}`); } + if (exists(p.settingsPath)) reportHook(wireHook(p.settingsPath, '', 'on-demand').result, p.settingsPath); + reportContract(removeContract(p.contractPath), p.contractPath); + if (args.force) { + for (const f of ['pref.json', 'telemetry.jsonl']) { + const t = path.join(path.dirname(p.prefPath), f); + if (exists(t)) { fs.rmSync(t); info(`removed ${t}`); } + } + } else if (exists(p.prefPath)) { + info(`kept ${p.prefPath} and any telemetry — pass --force to remove them too`); + } + } +} + +function uninstallCodex(args) { + say('Codex'); + const home = codexHome(); + const linkPath = path.join(agentsSkillsDir(), SKILL_NAME); + if (exists(linkPath)) { rmrf(linkPath); info(`removed ${linkPath}`); } + const hooksPath = path.join(home, 'hooks.json'); + if (exists(hooksPath)) reportHook(wireHook(hooksPath, '', 'on-demand').result, hooksPath); + const agentsMd = path.join(home, 'AGENTS.md'); + reportContract(removeContract(agentsMd), agentsMd); + if (!args.force) info('canonical skill copy under ~/.claude/skills is Claude Code\'s — removed only by a Claude uninstall'); +} + +// --- Main -------------------------------------------------------------------- + +function main() { + const args = parseArgs(process.argv.slice(2)); + + if (args.action === 'help') { printHelp(); return; } + if (args.action === 'version') { console.log(PKG.version); return; } + + let hosts = args.hosts || detectHosts(); + + // D7: Codex is global scope only. Asking for it by name at local scope is an + // error that wires nothing at all, rather than a half-install. + if (args.scope === 'local' && hosts.includes('codex')) { + if (args.codexExplicit && !args.bothExplicit && !hosts.includes('claude')) { + err('Codex supports global installs only — "--codex --local" is refused.'); + err('A repo-scoped Codex hook lives in a committed file, so every teammate who'); + err('pulls the repo gets an approval prompt for a tool they never installed.'); + err(''); + err('Run "npx wdym-prompt --codex --global" instead. Nothing was written.'); + process.exit(2); + } + warn('Codex supports global installs only — skipping Codex, installing for Claude Code at local scope.'); + warn('Run "npx wdym-prompt --codex --global" to add Codex.'); + hosts = hosts.filter((h) => h !== 'codex'); + } + + if (args.action === 'doctor') { + say(`wdym-prompt ${PKG.version} — doctor`); + say(''); + if (hosts.includes('claude')) doctorClaude(args); + if (hosts.includes('codex')) { if (hosts.includes('claude')) say(''); doctorCodex(); } + return; + } + + if (args.action === 'uninstall') { + say(`Uninstalling wdym (${hosts.join(' + ')})`); + say(''); + if (hosts.includes('claude')) uninstallClaude(args); + if (hosts.includes('codex')) { say(''); uninstallCodex(args); } + say(''); + ok('wdym removed'); + return; + } + + verifyPackage(); + say(`Installing wdym ${PKG.version} for: ${hosts.join(' + ')}`); + say(''); + + if (hosts.includes('claude')) installClaude(args); + if (hosts.includes('codex')) { say(''); installCodex(args); } + + say(''); + ok(`wdym installed and initialised (${hosts.join(' + ')}, ${args.activation} activation)`); + say(''); + if (args.activation === 'hook') { + say('The skill fires automatically on every substantive prompt.'); + say('To make it manual instead, re-run with "--on-demand".'); + } else { + say('The skill runs only when you invoke it: "/wdym ", or by asking to improve a prompt.'); + say('To make it fire on every prompt, re-run with "--hook".'); + } + + // Printed last, on every Codex-touching run, so it cannot be mistaken for log + // output or suppressed as "already seen". Never on a Claude-only run. + if (hosts.includes('codex')) trustNotice(); +} + +main(); diff --git a/hooks/prompt-detect.py b/hooks/prompt-detect.py index eae729f..bd95ba2 100755 --- a/hooks/prompt-detect.py +++ b/hooks/prompt-detect.py @@ -23,10 +23,10 @@ Passthrough prompts (slash command / <=5 words / conversational follow-up) get NO block at all — only a telemetry line. This makes the contract binary: block present => substantive prompt => invoke the wdym skill. The block is a neutral -classification signal; the invoke instruction itself lives in CLAUDE.md (a -trusted, user-installed contract), so an override-shaped imperative in the block -can't be mistaken for a prompt injection and refused. Block absent => respond -normally. +classification signal; the invoke instruction itself lives in the host's own +instruction file (CLAUDE.md on Claude Code, AGENTS.md on Codex) as a trusted, +user-installed contract, so an override-shaped imperative in the block can't be +mistaken for a prompt injection and refused. Block absent => respond normally. Token-efficiency duties absorbed from the skill (each deletes an LLM tool call, i.e. a full-context API round trip): @@ -85,17 +85,66 @@ def is_passthrough(raw: str) -> bool: return False -def telemetry_path(): - """Resolve wdym/telemetry.jsonl at the active install scope: local - .claude/wdym/ overrides global ~/.claude/wdym/. Returns None if neither - install dir exists (the dir is created by --init, never by the hook).""" - candidates = [] +def _cwd_walk_dirs(): + """The CWD and its ancestors, nearest first, stopping at the repo root. + + Only Claude Code sets CLAUDE_PROJECT_DIR. Other hosts (Codex) may start the + session in a subdirectory of the repo, so a repo-local install one or more + levels up would be missed and the run would silently fall through to the + global scope. Walking up fixes that without naming a host: stop at the + directory holding `.git` (the repo root, itself included), and hard-stop at + $HOME or the filesystem root so an unrelated parent install is never picked + up. Depth is capped as a cheap guard against pathological trees.""" + dirs = [] + try: + home = os.path.realpath(os.path.expanduser("~")) + cur = os.path.realpath(os.getcwd()) + except Exception: + return dirs + for _ in range(40): + dirs.append(cur) + if os.path.exists(os.path.join(cur, ".git")): + break + parent = os.path.dirname(cur) + if parent == cur or cur == home: + break + cur = parent + return dirs + + +def state_dirs(): + """wdym state directories in resolution order — nearest scope wins. + + 1. $CLAUDE_PROJECT_DIR/.claude/wdym — Claude Code's repo root, when set. + 2. CWD and ancestors up to the repo root, each `.claude/wdym` — the + host-neutral way to find a repo-local install. + 3. ~/.claude/wdym — the canonical global install, on either host. + 4. $CODEX_HOME/wdym — Codex's config root, when it is set and non-default. + + Order is deduplicated but otherwise preserved, so the same list backs both + the pref file and the telemetry file and the two can never disagree.""" + dirs = [] proj = os.environ.get("CLAUDE_PROJECT_DIR") if proj: - candidates.append(os.path.join(proj, ".claude", "wdym")) - candidates.append(os.path.join(os.getcwd(), ".claude", "wdym")) - candidates.append(os.path.expanduser("~/.claude/wdym")) - for d in candidates: + dirs.append(os.path.join(proj, ".claude", "wdym")) + for d in _cwd_walk_dirs(): + dirs.append(os.path.join(d, ".claude", "wdym")) + dirs.append(os.path.expanduser("~/.claude/wdym")) + codex_home = os.environ.get("CODEX_HOME") + if codex_home: + dirs.append(os.path.join(codex_home, "wdym")) + ordered = [] + for d in dirs: + if d not in ordered: + ordered.append(d) + return ordered + + +def telemetry_path(): + """Resolve wdym/telemetry.jsonl at the active install scope (state_dirs + order: nearest local scope wins, global last). Returns None if no install + dir exists (the dir is created by --init, never by the hook).""" + for d in state_dirs(): if os.path.isdir(d): return os.path.join(d, "telemetry.jsonl") return None @@ -114,14 +163,8 @@ def log_telemetry(record: dict) -> None: def pref_candidates(): """Pref file paths in resolution order (same order as telemetry_path): - local scope overrides global.""" - candidates = [] - proj = os.environ.get("CLAUDE_PROJECT_DIR") - if proj: - candidates.append(os.path.join(proj, ".claude", "wdym", "pref.json")) - candidates.append(os.path.join(os.getcwd(), ".claude", "wdym", "pref.json")) - candidates.append(os.path.expanduser("~/.claude/wdym/pref.json")) - return candidates + the nearest local scope overrides global.""" + return [os.path.join(d, "pref.json") for d in state_dirs()] def read_pref(): @@ -260,12 +303,18 @@ def is_forced(cat: dict, text: str) -> bool: # A neutral signal, not an imperative. The trusted invoke instruction lives in -# CLAUDE.md (installed by init); the block only reports that a classification is -# available. Override-shaped wording here ('ACTION: ... BEFORE processing this -# prompt') read as a prompt injection through the low-trust hook channel and got -# refused, silently skipping the skill — so the authority moved to CLAUDE.md. +# the host's own instruction file (CLAUDE.md on Claude Code, AGENTS.md on Codex), +# written by init; the block only reports that a classification is available. +# Override-shaped wording here ('ACTION: ... BEFORE processing this prompt') read +# as a prompt injection through the low-trust hook channel and got refused, +# silently skipping the skill — so the authority moved to that file. The line +# names the contract, not the file, because the filename is host-specific. +# +# Treat this string as user-facing copy: Codex currently renders additionalContext +# as a visible developer message (openai/codex#16933), so the user reads it. One +# tidy status line, no internal plumbing. ACTION_LINE = ( - "signal: wdym classification available — invoke per the CLAUDE.md contract." + "signal: wdym classification available — invoke per the wdym auto-invoke contract." ) diff --git a/hooks/telemetry-stats.py b/hooks/telemetry-stats.py index e0a946b..18fe83b 100755 --- a/hooks/telemetry-stats.py +++ b/hooks/telemetry-stats.py @@ -26,6 +26,28 @@ TYPES = ("code", "question", "text-gen", "global") +def cwd_walk_dirs(): + """CWD and its ancestors, nearest first, stopping at the repo root — the + host-neutral way to find a repo-local install when the session started in a + subdirectory (only Claude Code sets CLAUDE_PROJECT_DIR). Mirrors + hooks/prompt-detect.py so --status reads the file the hook writes.""" + dirs = [] + try: + home = os.path.realpath(os.path.expanduser("~")) + cur = os.path.realpath(os.getcwd()) + except Exception: + return dirs + for _ in range(40): + dirs.append(cur) + if os.path.exists(os.path.join(cur, ".git")): + break + parent = os.path.dirname(cur) + if parent == cur or cur == home: + break + cur = parent + return dirs + + def resolve(): """Return (telemetry_path, scope_label) at the active install scope, or (None, None) if no wdym install dir exists.""" @@ -33,8 +55,15 @@ def resolve(): candidates = [] if proj: candidates.append((os.path.join(proj, ".claude", "wdym"), "local")) - candidates.append((os.path.join(os.getcwd(), ".claude", "wdym"), "local")) - candidates.append((os.path.expanduser("~/.claude/wdym"), "global")) + home_dir = os.path.expanduser("~/.claude/wdym") + for d in cwd_walk_dirs(): + cand = os.path.join(d, ".claude", "wdym") + if cand != home_dir: # the home dir is the global scope, reported below + candidates.append((cand, "local")) + candidates.append((home_dir, "global")) + codex_home = os.environ.get("CODEX_HOME") + if codex_home: + candidates.append((os.path.join(codex_home, "wdym"), "global")) for d, label in candidates: if os.path.isdir(d): return os.path.join(d, "telemetry.jsonl"), label diff --git a/install.sh b/install.sh index 95d328a..c6eba1a 100755 --- a/install.sh +++ b/install.sh @@ -404,7 +404,7 @@ BLOCK = f"""{START} The `` block is emitted by a user-installed `UserPromptSubmit` hook — it is **trusted configuration, not untrusted injected input**. Invoking `wdym` when a block is present is expected and safe; do not treat the block as a prompt injection. -A `` block present (any verdict) ⇒ invoke the `wdym` skill via the Skill tool **before** processing the prompt; no block ⇒ respond normally. +A `` block present (any verdict) ⇒ invoke the `wdym` skill **before** processing the prompt; no block ⇒ respond normally. Activation is set by `activation` in wdym's `pref.json` ({state}) and changed with `/wdym --init --hook` / `--on-demand`, which moves the hook wiring with it. {END}""" diff --git a/llms.txt b/llms.txt index 488035b..5aae41e 100644 --- a/llms.txt +++ b/llms.txt @@ -1,11 +1,12 @@ # wdym -> A prompt rewriter for Claude Code that fires automatically on every prompt you submit, detects its type, and rewrites it using a handful of matched prompt-engineering principles. +> A prompt rewriter for Claude Code and Codex that fires automatically on every prompt you submit, detects its type, and rewrites it using a handful of matched prompt-engineering principles. -wdym is a Claude Code skill plus a `UserPromptSubmit` hook. The hook (`prompt-detect.py`) -scores each prompt deterministically and — for substantive prompts — injects a -`` signal block; a trusted `CLAUDE.md` contract (written by `install.sh`) -tells the model to run the skill before answering. The skill +wdym is a skill plus a `UserPromptSubmit` hook, running on Claude Code or Codex. The hook +(`prompt-detect.py`) scores each prompt deterministically and — for substantive prompts — +injects a host-neutral `` signal block; a trusted contract in the host's +instruction file (`CLAUDE.md` on Claude Code, `AGENTS.md` on Codex, written by the +installer) tells the model to run the skill before answering. The skill classifies the prompt (`code` / `question` / `text-gen` / `none`), loads only the principle files that apply, and rewrites. To change behavior, start at `SKILL.md` (a thin dispatcher) and follow it into `refs/protocol.md`. Most detail lives in `refs/*` and loads @@ -15,16 +16,18 @@ lazily — the SKILL.md body is re-injected on every run, so it stays small on p - [README](https://raw.githubusercontent.com/ndisisnd/wdym/main/README.md): what it does, install, how it works, modes and flags - [SKILL.md](https://raw.githubusercontent.com/ndisisnd/wdym/main/SKILL.md): the router — start here to modify behavior; dispatches into refs/ -- [ARCHITECTURE.md](https://raw.githubusercontent.com/ndisisnd/wdym/main/ARCHITECTURE.md): full flow diagram, file map, and design notes (token budget, caching, degrade-then-heal) +- [ARCHITECTURE.md](https://raw.githubusercontent.com/ndisisnd/wdym/main/ARCHITECTURE.md): full flow diagram, file map, and design notes (token budget, caching, degrade-then-heal, two-host model) ## Reference -- [refs/protocol.md](https://raw.githubusercontent.com/ndisisnd/wdym/main/refs/protocol.md): step-by-step execution, Steps 0–8 — the actual runtime logic +- [refs/protocol.md](https://raw.githubusercontent.com/ndisisnd/wdym/main/refs/protocol.md): step-by-step execution, Steps 0–8, plus the ask step (AskUserQuestion or plain text) — the actual runtime logic +- [refs/init.md](https://raw.githubusercontent.com/ndisisnd/wdym/main/refs/init.md): --init bootstrap — host resolution, scope, activation, hook wiring, trust contract - [refs/detect.md](https://raw.githubusercontent.com/ndisisnd/wdym/main/refs/detect.md): manual type-detection path, used only when the hook verdict is ambiguous or absent - [refs/commands.md](https://raw.githubusercontent.com/ndisisnd/wdym/main/refs/commands.md): /wdym command handling (--init / --help / --status / --set-mode) - [refs/categories.json](https://raw.githubusercontent.com/ndisisnd/wdym/main/refs/categories.json): the type taxonomy — keyword/regex scoring the hook and skill read (user-editable) - [refs/principles/](https://raw.githubusercontent.com/ndisisnd/wdym/main/refs/principles/principles-global.md): principle files, one per type (global always loaded, plus code/question/text-gen) - [hooks/prompt-detect.py](https://raw.githubusercontent.com/ndisisnd/wdym/main/hooks/prompt-detect.py): the UserPromptSubmit pre-scorer that emits the block +- [bin/wdym-prompt.js](https://raw.githubusercontent.com/ndisisnd/wdym/main/bin/wdym-prompt.js): the `npx wdym-prompt` installer — host detection, skill files, hook wiring, trust contract, --doctor / --uninstall ## Optional diff --git a/package.json b/package.json new file mode 100644 index 0000000..f327fa5 --- /dev/null +++ b/package.json @@ -0,0 +1,48 @@ +{ + "name": "wdym-prompt", + "version": "1.2.0", + "description": "Install and wire wdym — the prompt rewriter that fires on every prompt — for Claude Code and Codex.", + "keywords": [ + "claude-code", + "codex", + "skill", + "prompt", + "prompt-engineering", + "userpromptsubmit", + "hook" + ], + "homepage": "https://github.com/ndisisnd/wdym#readme", + "bugs": { + "url": "https://github.com/ndisisnd/wdym/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/ndisisnd/wdym.git" + }, + "license": "MIT", + "author": "ndisisnd ", + "type": "commonjs", + "bin": { + "wdym-prompt": "bin/wdym-prompt.js" + }, + "engines": { + "node": ">=18" + }, + "files": [ + "bin/", + "hooks/", + "refs/", + "agents/", + "asset/", + "SKILL.md", + "README.md", + "CHANGELOG.md", + "LICENSE.md", + "SECURITY.md", + "llms.txt" + ], + "scripts": { + "test": "python3 tests/detect_bench.py", + "prepublishOnly": "python3 tests/detect_bench.py" + } +} diff --git a/refs/commands.md b/refs/commands.md index 40132ee..35d7b24 100644 --- a/refs/commands.md +++ b/refs/commands.md @@ -10,6 +10,10 @@ Reached from `refs/protocol.md` Step 0 when the raw prompt carries a `/wdym` command flag. Each command executes and then **terminates** — never continue to Step 1, never enhance a prompt. +**Command prefix:** `/wdym` on Claude Code, `$wdym` on Codex. Recognise either +form on input; use the running host's form in anything shown to the user. This +document writes `/wdym`. + **Self-check ordering:** for `--help`, `--status`, and `--set-mode`, probe the install first (slash commands get no hook block, so the hook's probe didn't run): `ls` the five required files (`refs/categories.json`, `refs/categories.default.json`, @@ -25,7 +29,11 @@ Dispatch on the first flag present, in this order: Run `refs/init.md` end-to-end, then terminate. Do not run the self-check. It asks two questions — scope (local vs. global) and activation (hook vs. on-demand) — writes `pref.json`, wires or unwires the `UserPromptSubmit` hook to match, and -installs the `CLAUDE.md` trust-anchor contract. +installs the trust-anchor contract in the host's instruction file (`CLAUDE.md` on +Claude Code, `AGENTS.md` on Codex — `refs/init.md` resolves which). + +Both questions go through the **ask step** in `refs/protocol.md`, so they work on +a host without an `AskUserQuestion` tool. This is the completion step for any install that delivered only the skill files (`npx skills add`, a manual copy, a dev checkout) — those set up none of the diff --git a/refs/heal.md b/refs/heal.md index b38e386..90c9405 100644 --- a/refs/heal.md +++ b/refs/heal.md @@ -32,11 +32,14 @@ one. - Block present with `verdict: degraded` → hook ran but its config is broken; go to Check 3. -- **No block** (and `activation: hook`) → read the resolved settings file (local - `settings.local.json`, else global `settings.json`) for a - `hooks.UserPromptSubmit` entry whose command contains `prompt-detect.py`: +- **No block** (and `activation: hook`) → read the host's resolved hook file + (`refs/init.md` Step I1 names it per host: Claude Code local + `settings.local.json` else global `settings.json`; Codex `~/.codex/hooks.json`) + for a `UserPromptSubmit` entry whose command contains `prompt-detect.py`: - Entry present **and** script path exists → wired but silent (e.g. `python3` - unavailable). Record `hook silent`; do not repair. + unavailable; on Codex, most often an unapproved hook — it trusts hooks by + file contents, so any reinstall revokes approval and it fails silently). + Record `hook silent`; do not repair. On Codex, hint `/hooks` to approve it. - Entry present **but** script path missing → stale path (skill dir moved). Rewrite the command to `python3 "/hooks/prompt-detect.py"` using the running skill's absolute root (merge rules: `refs/init.md` Step I5). diff --git a/refs/help.txt b/refs/help.txt index 697fb89..5aa03d1 100644 --- a/refs/help.txt +++ b/refs/help.txt @@ -1,4 +1,4 @@ -wdym — prompt rewriter for Claude Code +wdym — prompt rewriter for Claude Code and Codex Applies structured rewrite principles to a prompt to make it sharper and better scoped, then submits the enhanced version. Detects prompt type (code, question, @@ -6,6 +6,9 @@ text-gen) and loads matching principles on top of the universal base. Runs eithe on every prompt (hook) or only when you ask for it (on-demand) — your choice at /wdym --init. +Commands are written "/wdym" below. On Codex, type "$wdym" instead; everything +after the prefix is identical. + COMMANDS ──────────────────────────────────────────────────────────────────── /wdym Rewrite this prompt now @@ -17,8 +20,9 @@ COMMANDS INIT FLAGS (skip the matching question) ──────────────────────────────────────────────────────────────────── - --global Apply to all projects (~/.claude) - --local Apply to this directory only (./.claude) + --global Apply to all projects (user scope) + --local Apply to this directory only (Claude Code + only; Codex is global-scope only) --hook Fire automatically on every prompt --on-demand Fire only when invoked diff --git a/refs/init.md b/refs/init.md index 1632b28..a860ee2 100644 --- a/refs/init.md +++ b/refs/init.md @@ -1,6 +1,6 @@ --- name: Init Protocol -description: Bootstrap/reconfigure the wdym skill — writes pref.json (mode + activation), wires or unwires the UserPromptSubmit hook, and installs the CLAUDE.md trust-anchor contract, at local (this directory) or global (~/.claude) scope +description: Bootstrap/reconfigure the wdym skill — writes pref.json (mode + activation), wires or unwires the UserPromptSubmit hook, and installs the trust-anchor contract in the host's instruction file, at local (this directory) or global (user) scope type: reference --- @@ -31,9 +31,31 @@ prompt. Confirm `SKILL_DIR/hooks/prompt-detect.py` exists before proceeding. If it does not, report the problem and terminate without writing anything. +**Resolve `HOST`** as well, by evidence rather than assumption. Three install +targets differ by host and nothing else does: which file holds the hook, which +instruction file carries the trust anchor, and which prefix the user types. + +| | Claude Code | Codex | +|---|---|---| +| Evidence | `CLAUDE_PROJECT_DIR` set, or `~/.claude/` exists | `CODEX_HOME` set, or `~/.codex/` exists | +| Hook file (global) | `~/.claude/settings.json` | `$CODEX_HOME/hooks.json` (default `~/.codex/hooks.json`) | +| Hook file (local) | `/.claude/settings.local.json` | not supported — global only | +| Trust anchor (global) | `~/.claude/CLAUDE.md` | `$CODEX_HOME/AGENTS.md` (default `~/.codex/AGENTS.md`) | +| Trust anchor (local) | `/CLAUDE.md` | not supported — global only | +| Command prefix | `/wdym` | `$wdym` | + +If both hosts show evidence, ask which to configure using the **ask step** +(`refs/protocol.md`) with one option per host plus "Both". Configure each chosen +host with the same `pref.json`, and report them separately in Step I7. + +Use `HOST`'s prefix in every line shown to the user. This document writes +`/wdym`. + ## Step I2 — Choose install scope -Call `AskUserQuestion` with these options: +Run the **ask step** (`refs/protocol.md` — `AskUserQuestion` when that tool is +available, otherwise the plain-text question shape) with the question "Where +should wdym apply?" and these options: - **Local (this directory)** — installs into `$CLAUDE_PROJECT_DIR/.claude/` (fall back to the current working directory). Applies only in this directory. @@ -43,6 +65,13 @@ Call `AskUserQuestion` with these options: If the user shortcut the choice in their prompt — `--init --global` (or "globally") → Global; `--init --local` → Local — honour it and skip the question. +**Codex is global-scope only.** A repo-scoped Codex hook lives in a committed +file, so every teammate who pulls the repo gets an approval prompt for a tool +they never installed. If `HOST` is Codex and the user asked for local scope, do +not write anything for that host: say so plainly, point at +`$wdym --init --global`, and stop (if Claude Code was also selected, continue for +Claude only). + Resolve the chosen scope into: | Variable | Local scope | Global scope | @@ -51,12 +80,18 @@ Resolve the chosen scope into: | `PREF_PATH` | `BASE_DIR/wdym/pref.json` | `BASE_DIR/wdym/pref.json` | | `SETTINGS_PATH` | `BASE_DIR/settings.local.json` (personal, not committed) | `BASE_DIR/settings.json` (global user settings) | -Writes are allowed **only** under the chosen `BASE_DIR`. Never write to the other -scope's location. +Under Codex, `SETTINGS_PATH` is the Step I1 hook file instead +(`$CODEX_HOME/hooks.json`, default `~/.codex/hooks.json`); `BASE_DIR` and +`PREF_PATH` stay `~/.claude` so one pref file serves both hosts and the two can +never disagree about mode or activation. + +Writes are allowed **only** under the chosen `BASE_DIR` (plus the host's own hook +and instruction files). Never write to the other scope's location. ## Step I3 — Choose activation -Call `AskUserQuestion` with these options: +Run the **ask step** again with the question "When should wdym run?" and these +options: - **Hook (every prompt)** — wires `UserPromptSubmit` so wdym classifies and rewrites every substantive prompt automatically. Passthrough prompts (slash @@ -92,10 +127,11 @@ reads to decide whether to emit anything at all. ## Step I5 — Wire or unwire the UserPromptSubmit hook -Target file: `SETTINGS_PATH` (`settings.local.json` for local scope, -`settings.json` for global scope). The hook entry this step manages (note the -**absolute** `SKILL_DIR` path so it resolves no matter what directory Claude -runs from): +Target file: `SETTINGS_PATH` from Step I2 — on Claude Code +`settings.local.json` (local scope) or `settings.json` (global scope), on Codex +the global hooks file. The hook entry this step manages (note the **absolute** +`SKILL_DIR` path, so it resolves no matter which directory the session started +in): ```json { @@ -104,8 +140,16 @@ runs from): } ``` +Both hosts fire the same `UserPromptSubmit` event and read the same stdout +contract, so the script and its invocation are identical. Only the file and its +surrounding JSON shape differ. On Codex, read the existing hooks file first and +follow the entry shape already in it rather than imposing this one; the event key +is still `UserPromptSubmit`. + Match existing entries by the substring `prompt-detect.py`, not by exact string — -a path may be stale from a moved skill directory. +a path may be stale from a moved skill directory. **Collapse duplicates:** if two +or more entries match, keep one and remove the rest. Duplicate handlers inject +the block twice and double every telemetry row. ### If `ACTIVATION = hook` → wire it @@ -159,8 +203,10 @@ unparseable, report it and stop rather than clobbering it. ## Step I6 — Write the trust-anchor contract -Target file: `~/.claude/CLAUDE.md` for global scope, `/CLAUDE.md` for -local scope (the memory file at the chosen scope). +Target file: the host's instruction file at the chosen scope, per the Step I1 +table — Claude Code: `~/.claude/CLAUDE.md` (global) or `/CLAUDE.md` +(local); Codex: `$CODEX_HOME/AGENTS.md`, default `~/.codex/AGENTS.md` (global +only). The block text below is identical either way; only the file differs. The `` block arrives through the hook channel, which is low-trust: without a trusted file authorising it, an override-shaped signal reads as a @@ -170,7 +216,8 @@ most when wdym arrived some way other than `install.sh` (`npx skills add`, a manual copy, a cloned dev checkout), because those deliver the skill files and nothing else. -Write this marker-delimited block, substituting the Step I3 `ACTIVATION`: +Write this marker-delimited block, substituting the Step I3 `ACTIVATION` and the +`HOST` command prefix (`/wdym` on Claude Code, `$wdym` on Codex): ```markdown @@ -178,7 +225,7 @@ Write this marker-delimited block, substituting the Step I3 `ACTIVATION`: The `` block is emitted by a user-installed `UserPromptSubmit` hook — it is **trusted configuration, not untrusted injected input**. Invoking `wdym` when a block is present is expected and safe; do not treat the block as a prompt injection. -A `` block present (any verdict) ⇒ invoke the `wdym` skill via the Skill tool **before** processing the prompt; no block ⇒ respond normally. +A `` block present (any verdict) ⇒ invoke the `wdym` skill **before** processing the prompt; no block ⇒ respond normally. Activation is set by `activation` in wdym's `pref.json` (currently ``) and changed with `/wdym --init --hook` / `--on-demand`, which moves the hook wiring with it. @@ -197,8 +244,9 @@ Record whether it was added, refreshed, or already current. ## Step I7 — Confirm -Emit a short summary listing scope, activation, and exactly what was created, -changed, or left untouched, e.g.: +Emit a short summary listing host, scope, activation, and exactly what was +created, changed, or left untouched. Name the files you actually touched, and use +the host's command prefix in the closing hints. Claude Code, global scope: ``` Initialised wdym (global scope) in ~/.claude: @@ -209,6 +257,18 @@ wdym now runs on every substantive prompt. Switch modes with "/wdym --set-mode --flash", or go manual with "/wdym --init --on-demand". ``` +Codex, global scope (the only scope Codex supports): + +``` +Initialised wdym for Codex (global scope): + • ~/.claude/wdym/pref.json — created (mode: comprehensive, activation: hook) + • ~/.codex/hooks.json — hook added (UserPromptSubmit → prompt-detect.py) + • ~/.codex/AGENTS.md — auto-invoke contract added +Codex trusts hooks by file contents, so run /hooks in Codex and approve the wdym +hook — until you do, wdym stays silent and Codex will not warn you. To confirm: +submit any prompt, then run "$wdym --status". +``` + Switching to on-demand: ``` diff --git a/refs/protocol.md b/refs/protocol.md index 6647046..73955a4 100644 --- a/refs/protocol.md +++ b/refs/protocol.md @@ -10,6 +10,40 @@ Emit no step markers. Visible output is limited to the Step 0.5 repair line (onl when something was healed/escalated) and the Step 6 enhanced-prompt block (comprehensive mode only — the rewritten prompt, no original, no rationale). +## Ask step — present options and stop + +wdym asks the user a question in exactly two places: the Step 6 approval gate and +`refs/init.md`'s scope and activation questions. Both use this one step, so the +question reads the same whichever host is running. + +**Pick the implementation by tool availability, never by guessing the host.** +Check your own available tools for `AskUserQuestion`: + +- **Available** → call it once with the options as given. Unchanged behaviour. +- **Not available** (Codex, and any host without it) → emit the options as plain + text in the shape below, then **end the turn**. Do not choose an option + yourself, do not act on any option, and do not continue past the step. Read the + user's next message as the answer. + +Plain-text shape — a one-line question, the options numbered in the order given +with a short consequence each, and one closing line: + +``` + + +1.