diff --git a/.claude/agents/pr-comment-triager.md b/.claude/agents/pr-comment-triager.md index 221c43319..0097431bd 100644 --- a/.claude/agents/pr-comment-triager.md +++ b/.claude/agents/pr-comment-triager.md @@ -72,27 +72,55 @@ After each commit: git push origin ``` -## Step 4: Resolve stupid items +## Step 4: Reply to each comment individually, then resolve -For each thread classified STUPID, resolve via GraphQL mutation: +**The user wants to scroll through the PR comments and see a reply right after each bot comment** so it's obvious every item has been addressed. **Do NOT post one summary comment that covers everything.** Reply per-comment. + +### Inline review-thread comments + +For each thread, post a reply IN the thread (so it shows up indented under the original), then resolve the thread: ```bash +# Reply in the thread — replaces the parent comment ID with the bot's comment ID +gh api repos/mikepsinn/optimitron/pulls/N/comments//replies \ + -f body="" + +# Then mark the thread resolved gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: "..."}) { thread { isResolved } } }' ``` -Then post a single PR-level summary comment via `gh pr comment N --body "..."` that lists every stupid thread with a one-line reason. Don't post per-thread reply comments — the on-thread resolution is enough, and a single summary reads better. +### PR-level issue comments from bots -The summary comment shape: +GitHub doesn't natively thread issue-comments. Post one reply per bot comment as a new issue-comment that quotes the bot comment URL so the chronological scroll shows reply-immediately-after-bot: +```bash +gh pr comment N --body "$(cat <<'EOF' +Re: + + +EOF +)" ``` -Triaged N CodeRabbit/Copilot threads (commit ). -**Valid — fixed (M):** -- — what was wrong and what was changed. +For repeated bot test/ping noise (e.g. `claude[bot]` posting "test" / "test-ping"), minimize them with `minimizeComment` (`classifier: OUTDATED`) instead of replying — they're not real comments. -**Stupid — resolved no change (K):** -- — one-line reason (specific to the code, not generic). -``` +### Reply body shape + +Each reply must be: + +- **Short**: one or two lines. The body of the work goes in the commit message, not in PR replies. +- **Code-specific**: name the file/line/commit. Generic dismissals ("not applicable", "won't fix") erode trust. +- **Honest verdict**: either "Fixed in " / "Already fixed in " / "Won't fix because " / "False positive — already does ". + +Examples: + +> Fixed in 73bc8979 — narrowed `assertEmailSafe` to require `http(s)://` scheme so prose containing the bare word "localhost" no longer trips the send-boundary guard. + +> Already fixed — `react-email-components.tsx:112` is `fontWeight: "700"` (commit a04b1355). + +> Won't fix — `PersonalIncomeChart.tsx` was untouched on this branch; the hardcoded `bg-white` is a pre-existing pattern across the landing-light component family that needs to migrate together, not a one-off patch. + +> False positive — `safety-gate.mjs:44` already covers newline AND single-`&`: `(?:&&|\|\||;|\n|\|(?!\|)|&(?!&))`. ## Step 5: Report back to the parent agent diff --git a/.claude/codex-delegation.md b/.claude/codex-delegation.md index d9ed52543..b63ee9e8b 100644 --- a/.claude/codex-delegation.md +++ b/.claude/codex-delegation.md @@ -63,7 +63,7 @@ Concrete failure case this rule prevents: this session, multiple Codex agents sp Codex has Playwright MCP wired up (`mcp__playwright__browser_navigate`, `browser_console_messages`, `browser_take_screenshot`, etc.). Use it for spot-checks during the fix-iterate loop — load a page, grab console errors, verify the symptom is gone. 5-15 seconds per route. -DO NOT default to `pnpm --filter @optimitron/web run e2e -- visual --grep ` for iteration verification. That command boots a dev/prod server, compiles routes, runs screenshot capture + baseline comparison + Argos upload — 5-10 minutes per filter. Reserve it for the FINAL pre-merge verification pass after the fix is known to work. +DO NOT default to `pnpm --filter @optimitron/web run e2e -- visual --grep ` for iteration verification. That command boots a dev/prod server, compiles routes, and runs screenshot capture — 5-10 minutes per filter. Reserve it for the FINAL pre-merge verification pass after the fix is known to work. Same signal (does the page hydrate without React errors? does the layout look right?) at 50x the cost. Burning 10 minutes per fix-iteration cycle when the same answer is available in 10 seconds is the anti-pattern. Concrete failure: this session, the hydration-investigation Codex spent ~8 minutes of one verification run on `pnpm e2e visual --grep treaty` when the same fix could have been spot-checked via Playwright MCP in seconds. @@ -114,6 +114,54 @@ The `subagent_type: codex:codex-rescue` Agent path is MCP-mediated and strictly If a future Claude session is tempted to use the Agent path because it looks more integrated: it isn't. The direct CLI path has the same `run_in_background: true` notification UX from Bash, plus everything above. +## Plan-first protocol for substantial work + +Default for any Codex dispatch that touches >1 system, >100 lines, schema/CI, or matches the "I thought we had / why is this so" phrasing in CLAUDE.md HVD#13 (Diagram-before-code). Skip only for trivial single-file renames / one-liners / copy edits. + +**Six steps. No skipping. No reordering.** + +1. **Claude drafts the plan file.** Location: gstack convention (`~/.gstack/projects//plans/.md`) or a project-local `.claude/plans/.md` if gstack isn't initialized for the repo. Required sections: + - **Brief** — problem restated in Claude's own words. + - **Research log** — REQUIRED before anything else. AI knowledge cutoffs make every vendor/API/tool assumption suspect. Before drafting current/proposed state, WebSearch + WebFetch the relevant vendor docs from the last 12 months. List: search queries run, URLs of canonical docs + their last-updated date if visible, any changelog entries from the last 6 months, anything that contradicts an assumption I'd otherwise have made from training data. If touching a third-party tool/API/SDK, the research log MUST cite the vendor's current docs (not "I think this works like X"). Examples of failures this catches: "Codex app-server is experimental, probably overkill" (false — it's the documented integration point), "Neon anonymized branches would be a 1-2 day project" (false — built-in feature GA late 2025). + - **Current state — ASCII diagram** — boxes/arrows of the systems involved. + - **Proposed state — ASCII diagram** — boxes/arrows after the change. + - **Step list** — checkboxes Codex will tick off during implementation. + - **Risks** — things that could go sideways. + - **Files to touch** — Codex's expected scope. + - **ALERTS** — orchestrator-edited, Codex re-reads top of every Phase-3 turn. + - **Agent log** — Codex appends after each meaningful action. + +2. **Codex criticizes the plan.** Dispatch via `codex review` (preferred for plan critique — adversarial, "tries to break") or `codex exec` with an explicit instruction to investigate the code AND argue against the plan: name what's wrong, what's missing, what's overcomplicated, what's a worse fix than the alternative. Codex must read the actual files referenced in the plan, not just react to the prose. Codex MUST also verify Claude's `## Research log` — re-WebSearch anything not cited with a recent vendor doc URL, name anything Claude assumed that's contradicted by current docs. Codex writes its critique INTO the plan file under a `## Codex critique (round N)` section. + +3. **Claude + Codex iterate until agreement.** Claude responds to Codex's critique in the same plan file (`## Claude response (round N)`). If still disagreeing, dispatch Codex again. Stop when either: (a) both agree, OR (b) the disagreement is a taste call that needs Mike. Two rounds max — if not converged, escalate to step 4 as "Claude + Codex disagree on X, see plan file." + +4. **Tell Mike the plan.** Summary in chat: plan file path, key decisions, any unresolved disagreements between Claude and Codex. Mike reads the plan file directly (it's the single source of truth). + +5. **Mike approves, fixes, or rejects.** Mike edits the plan file directly OR responds in chat with redirects. Claude applies Mike's changes to the plan file. Loop back to step 4 if Mike's redirect was substantial. + +6. **Codex implements.** Dispatch via direct `codex exec` (not the Agent wrapper — blocked by `.claude/hooks/block-codex-rescue-agent.mjs` anyway). Prompt includes: plan file path, the discipline rule "before any tool call, Read `` and check `## ALERTS`", and "append to `## Agent log` after each meaningful action; tick `## Step list` checkboxes as you go." Mike + Claude can edit `## ALERTS` mid-flight; Codex picks up on next turn boundary (turn-boundary latency, not real interrupt — use `codex app-server` upgrade for that, see below). + +**For trivial dispatches**, skip steps 1–5. Dispatch directly via `codex exec` with a tight self-contained prompt. Trivial = single-file rename, one-liner, copy edit, OR something already designed in a prior plan file. + +**If unsure whether a dispatch is substantial enough**: it is. Defaulting to plan-first costs maybe 20 minutes; defaulting to direct dispatch costs the kind of failures today's session demonstrated (anonymized-preview Codex run wasted, no plan, no visibility). + +## Dispatch transport: prefer `@openai/codex-sdk` (or app-server) + +**Default: `@openai/codex-sdk` npm package.** Official Node 18+ TypeScript SDK that wraps the codex CLI and exchanges JSONL events over stdin/stdout — gives us streamed agent events, parallel threads with stable IDs, and the protocol primitives (`turn/steer`, `turn/interrupt`) without writing a JSON-RPC client ourselves. **Not "couple hours of work" — it's `npm install @openai/codex-sdk`.** Previous claim was a stale-training-data error; see `feedback_websearch_vendor_capabilities_first.md`. + +When NOT to use the SDK: +- **Truly trivial one-shots** (single-file rename, copy edit). Direct `codex exec '...' < NUL > log 2>&1` is cheaper than spawning an SDK process. Bypass the protocol with `trivial: ` (per the enforce-codex-protocol hook). +- **Custom low-level transport needs** (WebSocket with auth, embedding in non-Node service). Use `codex app-server --listen ws://...` directly with bindings generated via `codex app-server generate-ts`. + +**NOT `codex mcp-server`** — that's the wrong direction (lets Codex consume external MCP tools; doesn't let Claude drive Codex). + +**Status:** SDK not yet adopted in this repo. The dispatch-side code still goes through Bash → `codex exec`. The `enforce-codex-protocol` hook accepts both shapes (any `codex exec` / `codex review` invocation with a plan-file or trivial bypass). Adopt the SDK when we hit the first failure mode `codex exec` doesn't cover (mid-turn steering, parallel agents needing live coordination). Tracked in TODO.md. + +Sources: +- [Codex App Server (OpenAI Developers)](https://developers.openai.com/codex/app-server) +- [`@openai/codex-sdk` on npm](https://www.npmjs.com/package/@openai/codex-sdk) +- [Codex SDK overview](https://developers.openai.com/codex/sdk) + ## Config `.codex/config.toml` pins `model = "gpt-5.5"` + `model_reasoning_effort = "xhigh"` — strongest tier for the hardest async tasks. diff --git a/.claude/hooks/block-codex-rescue-agent.mjs b/.claude/hooks/block-codex-rescue-agent.mjs new file mode 100644 index 000000000..9b33d90f9 --- /dev/null +++ b/.claude/hooks/block-codex-rescue-agent.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +// block-codex-rescue-agent.mjs +// +// PreToolUse hook on the Agent tool: blocks dispatches with +// `subagent_type: "codex:codex-rescue"`. The wrapper is strictly +// worse than direct `codex exec` via Bash and the protocol already +// says so — see .claude/codex-delegation.md line 7 + line 105 ("Why +// CLI not Agent tool"). I keep using it anyway despite the doc rule. +// +// 2026-05-14: written after I used the wrapper twice in one session +// (anonymized-preview, ICEWAD rename). Both showed exactly the +// failure mode line 113 names verbatim — wrapper returns "will +// report when done" while underlying work has already finished or +// fizzled. Per feedback_promote_violated_text_rules_to_hooks.md: +// passive text loses to active enforcement. + +import { readFileSync } from "node:fs"; + +try { + let hookData = null; + try { + const raw = readFileSync(0, "utf-8"); + if (raw && raw.trim()) hookData = JSON.parse(raw); + } catch { + process.exit(0); + } + if (!hookData) process.exit(0); + + if (hookData.tool_name !== "Agent") process.exit(0); + + const subagent = hookData?.tool_input?.subagent_type ?? ""; + if (subagent !== "codex:codex-rescue") process.exit(0); + + const msg = `[block-codex-rescue-agent] BLOCKED — Agent(subagent_type: "codex:codex-rescue"). + +The Agent wrapper is strictly worse than direct \`codex exec\` via Bash +for Codex dispatches. See \`.claude/codex-delegation.md\` line 105+ +("Why CLI not Agent tool"). Common failure mode (line 113): + + Wrapper returns "Codex is running in the background, will report + when done" narration AFTER the work has already finished — looks + like it's still running when it's already done (or fizzled). + +Use this shape instead: + + Bash( + command: "codex exec --skip-git-repo-check ''", + run_in_background: true + ) + +For substantial / multi-turn tasks where you want steer/interrupt, +the upgrade path is \`codex app-server\` (JSON-RPC) — see the +"App-server upgrade path" section in codex-delegation.md. + +If you HAVE a specific reason to use the wrapper (need Claude-side +review between dispatch and result), say so in chat first. There is +no 5-minute bypass on this hook — every dispatch decision should be +deliberate.`; + + process.stderr.write(msg + "\n"); + process.exit(2); +} catch { + process.exit(0); +} diff --git a/.claude/hooks/block-snapshot-handedit.mjs b/.claude/hooks/block-snapshot-handedit.mjs new file mode 100644 index 000000000..fe471c245 --- /dev/null +++ b/.claude/hooks/block-snapshot-handedit.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +// block-snapshot-handedit.mjs +// +// PreToolUse hook: blocks `Write` / `Edit` of generated `.md` snapshots. +// They are produced by `pnpm copy:preview` (smart by default — auto- +// detects affected routes) and `pnpm email:preview-md`. Hand-editing +// drifts from the rendered source; the right move is to fix the +// underlying page/email and re-run the script. +// +// Match paths: +// packages/web/src/app/**/page.logged-out.md +// packages/web/src/app/**/page.logged-in.md +// packages/web/**/*.email.md +// +// 2026-05-14: written after I hand-edited HVG snapshots three times in +// one session before the user pushed back. Per +// `feedback_promote_violated_text_rules_to_hooks.md`: rules in +// CLAUDE.md lose to active enforcement. + +import { readFileSync } from "node:fs"; + +try { + let hookData = null; + try { + const raw = readFileSync(0, "utf-8"); + if (raw && raw.trim()) hookData = JSON.parse(raw); + } catch { + process.exit(0); + } + if (!hookData) process.exit(0); + + const tool = hookData.tool_name; + if (tool !== "Write" && tool !== "Edit") process.exit(0); + + const filePath = hookData?.tool_input?.file_path; + if (!filePath) process.exit(0); + + const normalized = filePath.replace(/\\/g, "/"); + + const isSnapshot = + /\/packages\/web\/src\/app\/.+\/page\.logged-(out|in)\.md$/.test(normalized) || + /\/packages\/web\/.+\.email\.md$/.test(normalized); + + if (!isSnapshot) process.exit(0); + + const relPath = normalized.replace(/.*\/(packages\/.+)$/, "$1"); + + const msg = `[block-snapshot-handedit] BLOCKED — about to hand-edit a generated snapshot: + ${relPath} + +These files are output, not source. They're regenerated by: + pnpm --filter @optimitron/web copy:preview (smart, only affected routes) + pnpm --filter @optimitron/web email:preview-md (for *.email.md) + +If the rendered output is wrong, fix the underlying page.tsx / email +module / template and re-run the script. Hand-editing drifts from +source and CI will flag the snapshot diff at PR time. + +If you're SURE this is a one-off (e.g., a known-broken script you're +fixing in the same commit), bypass by acknowledging in chat: +"Hand-editing intentionally because ." and the +hook allows a retry within 5 minutes. + +Rule source: CLAUDE.md "Never hand-edit page.logged-out.md / *.email.md +snapshots" + memory feedback_promote_violated_text_rules_to_hooks.md`; + + process.stderr.write(msg + "\n"); + process.exit(2); +} catch { + process.exit(0); +} diff --git a/.claude/hooks/codex-dispatch-blather.mjs b/.claude/hooks/codex-dispatch-blather.mjs index a54160378..ee37881ae 100644 --- a/.claude/hooks/codex-dispatch-blather.mjs +++ b/.claude/hooks/codex-dispatch-blather.mjs @@ -30,6 +30,13 @@ try { if (hookData.tool_name !== "Bash") process.exit(0); const cmd = hookData?.tool_input?.command ?? ""; + // Skip commands that mention codex inside quoted/heredoc strings + // rather than invoking it. `git commit -m "... codex exec ..."` + // would otherwise fire this hook on every protocol-related commit. + const firstToken = String(cmd).trim().split(/\s+/)[0] ?? ""; + if (/^(git|gh|grep|rg|find|cat|head|tail|sed|awk|echo|printf|ls|cd|node|pnpm|npm|yarn|tsx|powershell)$/i.test(firstToken)) { + process.exit(0); + } if (!/\bcodex\s+exec\b/.test(cmd)) process.exit(0); // Extract the prompt argument. Codex usage: diff --git a/.claude/hooks/dev-server-check.mjs b/.claude/hooks/dev-server-check.mjs new file mode 100644 index 000000000..3149af52e --- /dev/null +++ b/.claude/hooks/dev-server-check.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +// dev-server-check.mjs +// +// SessionStart hook: probes http://127.0.0.1:3001 and prints one of: +// DEV SERVER: 200 OK +// DEV SERVER: DOWN — run `pnpm --filter @optimitron/web dev:fast ...` +// DEV SERVER: PORT BOUND but UNRESPONSIVE — kill PID then restart +// +// Per CLAUDE.md HVD#2: Claude pre-warms the dev server at session +// start if curl doesn't return 2xx/3xx. I keep forgetting to run the +// curl, so I never reach the conditional. This hook runs the check +// FOR me + prints loud status + the exact command to restart. +// +// Does NOT auto-start — too many edge cases (intentional teardown, +// different port, sibling repo bound to 3001). Loud warn + concrete +// command is sufficient if I actually read the output. +// +// 2026-05-14: written after I dispatched Codex agents claiming "Dev +// server is running at :3001" while a 17-hour zombie PID was bound +// but unresponsive. Per +// feedback_promote_violated_text_rules_to_hooks.md. + +import { execSync } from "node:child_process"; + +const PORT = 3001; +const URL = `http://127.0.0.1:${PORT}`; +const START_CMD = + "pnpm --filter @optimitron/web dev:fast > packages/web/.dev-server.log 2>&1"; + +try { + let status = null; + try { + const devNull = process.platform === "win32" ? "NUL" : "/dev/null"; + const out = execSync( + `curl -sS -m 3 -o ${devNull} -w "%{http_code}" ${URL}`, + { stdio: ["ignore", "pipe", "ignore"], encoding: "utf-8" }, + ).trim(); + status = parseInt(out, 10); + } catch { + status = 0; + } + + if (status >= 200 && status < 400) { + process.stdout.write(`[dev-server-check] DEV SERVER: ${status} OK on ${URL}\n`); + process.exit(0); + } + + // Down or unresponsive. Check if port is bound to a zombie PID. + let zombiePid = null; + try { + const netstat = execSync( + `powershell -NoProfile -Command "(Get-NetTCPConnection -LocalPort ${PORT} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1).OwningProcess"`, + { stdio: ["ignore", "pipe", "ignore"], encoding: "utf-8" }, + ).trim(); + if (netstat && /^\d+$/.test(netstat)) zombiePid = netstat; + } catch { + // ignore; Windows-specific + } + + if (zombiePid) { + process.stdout.write( + `[dev-server-check] DEV SERVER: PORT ${PORT} BOUND by PID ${zombiePid} but UNRESPONSIVE (zombie).\n` + + ` Kill + restart:\n` + + ` powershell -NoProfile -Command "Stop-Process -Id ${zombiePid} -Force"\n` + + ` ${START_CMD}\n`, + ); + process.exit(0); + } + + process.stdout.write( + `[dev-server-check] DEV SERVER: DOWN on ${URL}.\n` + + ` Start it now (orchestrator-only — per CLAUDE.md HVD#2):\n` + + ` ${START_CMD}\n` + + ` Codex dispatches will fail or run blind without it.\n`, + ); + process.exit(0); +} catch { + process.exit(0); +} diff --git a/.claude/hooks/enforce-audience-and-goal-on-ui-dispatch.mjs b/.claude/hooks/enforce-audience-and-goal-on-ui-dispatch.mjs new file mode 100644 index 000000000..fa713daa4 --- /dev/null +++ b/.claude/hooks/enforce-audience-and-goal-on-ui-dispatch.mjs @@ -0,0 +1,169 @@ +#!/usr/bin/env node +// enforce-audience-and-goal-on-ui-dispatch.mjs +// +// PreToolUse hook on Bash: when a fresh `codex exec` dispatch targets +// user-facing UI/copy paths, require the prompt to explicitly name +// (1) the audience looking at the page, and (2) what we want them +// to do. +// +// Mike's 2026-05-15 trigger, verbatim: *"whenever we do edits like +// this, do we ask ourselves who is looking at this page and what the +// f*** do we want them to do? if not, can you add something to a +// hook or something that will remind you to always f****** think of +// that anytime you make any changes to the user interface?"* +// +// The /endorse page got restructured to put functional disclaimer +// copy ("No donation to us. No candidate endorsement. One public +// humanitarian treaty position.") above the form, demoting the +// moral-case Wishonia paragraph below the fold. That happened +// because the dispatch said "put form above the fold" without +// naming who the audience is (org leaders) or what we want them +// to do (click JOIN, motivated). The reordering optimized for +// procedural clarity instead of conversion. +// +// Same text-rule-vs-enforcement pattern as the other hooks. The +// reminder lives at CLAUDE.md "UI/UX Rules" but reading it didn't +// land. Promote to active enforcement. +// +// Detection: a Codex dispatch is "UI-touching" if the prompt body +// mentions any of: +// - `packages/web/src/app/` (any route file) +// - `/page.tsx` or `page.logged-out.md` +// - `packages/web/src/components/` +// - `packages/web/src/lib/routes.ts` +// - `packages/web/src/lib/email/` +// - `packages/web/emails/` +// - share-card / share-copy / OG image +// - `lib/messaging.ts` +// +// Requirements when UI-touching: +// The prompt must include BOTH an audience-framing phrase AND a +// desired-action-framing phrase. Audience-framing accepts: +// - "audience" (any context) +// - "the visitor", "the reader", "the user is", "viewers are" +// - "org leaders", "donors", "voters", "plaintiffs", "politicians", +// "signers", "endorsers", "people who" — concrete persona naming +// Desired-action-framing accepts: +// - "want them to", "goal is", "lever is", "primary action", +// "the conversion", "they should", "we want", "the next step", +// "click", "sign", "endorse", "donate", "vote", "subscribe", +// "share", "convert" — concrete action verbs +// +// Bypasses (skip the check entirely): +// - `trivial:` prefix (small mechanical change, no UX restructuring) +// - `drafting-plan-for:` / `critiquing-plan:` (plan work) + +import { readFileSync } from "node:fs"; + +try { + let hookData = null; + try { + const raw = readFileSync(0, "utf-8"); + if (raw && raw.trim()) hookData = JSON.parse(raw); + } catch { + process.exit(0); + } + if (!hookData) process.exit(0); + if (hookData.tool_name !== "Bash") process.exit(0); + + const command = String(hookData?.tool_input?.command ?? ""); + if (!command) process.exit(0); + + const firstToken = command.trim().split(/\s+/)[0] ?? ""; + if (/^(git|gh|grep|rg|find|cat|head|tail|sed|awk|echo|printf|ls|cd|node|pnpm|npm|yarn|tsx|powershell)$/i.test(firstToken)) { + process.exit(0); + } + + const isFreshDispatch = /\bcodex\s+(exec|review)\b/.test(command) && + !/\bcodex\s+exec\s+resume\b/.test(command); + if (!isFreshDispatch) process.exit(0); + + const matches = [...command.matchAll(/'([\s\S]*?)'/g)]; + const prompt = matches + .map((m) => m[1]) + .filter((s) => s.length >= 12) + .sort((a, b) => b.length - a.length)[0] ?? ""; + + if (/^\s*(trivial|drafting-plan-for|critiquing-plan):/im.test(prompt)) { + process.exit(0); + } + + // Is this a UI-touching dispatch? + const UI_PATH_PATTERNS = [ + /packages\/web\/src\/app\//, + /\/page\.tsx\b/, + /page\.logged-out\.md\b/, + /packages\/web\/src\/components\//, + /packages\/web\/src\/lib\/routes\.ts\b/, + /packages\/web\/src\/lib\/email\//, + /packages\/web\/emails\//, + /packages\/web\/src\/lib\/messaging\.ts\b/, + /share[\s-]?card/i, + /share[\s-]?copy/i, + /OG\s+image/i, + /open\s+graph/i, + ]; + const isUiDispatch = UI_PATH_PATTERNS.some((rx) => rx.test(prompt)); + if (!isUiDispatch) process.exit(0); + + // Audience-framing detection. + const AUDIENCE_PATTERNS = [ + /\baudience\b/i, + /\bthe visitor\b/i, + /\bthe reader\b/i, + /\bthe user is\b/i, + /\bviewers are\b/i, + /\bpeople (who|on this page|looking)\b/i, + /\borg leaders?\b/i, + /\bdonors?\b/i, + /\bvoters?\b/i, + /\bplaintiffs?\b/i, + /\bpoliticians?\b/i, + /\bsigners?\b/i, + /\bendorsers?\b/i, + /\bemployees?\b/i, + /\bvisitors?\b/i, + ]; + const hasAudience = AUDIENCE_PATTERNS.some((rx) => rx.test(prompt)); + + // Desired-action-framing detection. + const ACTION_PATTERNS = [ + /\bwant them to\b/i, + /\bgoal is\b/i, + /\bthe lever\b/i, + /\bprimary action\b/i, + /\bthe conversion\b/i, + /\bthey should\b/i, + /\bwe want\b/i, + /\bthe next step\b/i, + /\bclick\b/i, + /\bsign\b/i, + /\bendorse\b/i, + /\bdonate\b/i, + /\bvote\b/i, + /\bsubscribe\b/i, + /\bshare\b/i, + /\bconvert\b/i, + /\bjoin\b/i, + ]; + const hasAction = ACTION_PATTERNS.some((rx) => rx.test(prompt)); + + if (hasAudience && hasAction) process.exit(0); + + const missing = []; + if (!hasAudience) missing.push("AUDIENCE"); + if (!hasAction) missing.push("DESIRED ACTION"); + + const msg = + `[enforce-audience-and-goal-on-ui-dispatch] BLOCKED — UI/copy Codex dispatch lacks ${missing.join(" and ")}.\n\n` + + `Mike's 2026-05-15 rule: every UI/copy change must name (1) who is looking at this page, and (2) what we want them to do. The /endorse page got restructured to put procedural disclaimer copy above the moral-case Wishonia paragraph because the dispatch said "put form above the fold" without naming the audience (org leaders) or the lever (moral motivation, not procedural reassurance).\n\n` + + `Add to the dispatch prompt, in one sentence each:\n` + + ` Audience: \n` + + ` Goal: \n\n` + + `Bypass: prefix the prompt with \`trivial:\` if the change is mechanical (snapshot regen, dependency bump, single-constant edit) and does NOT change the visible page structure or copy meaning.`; + + process.stderr.write(msg + "\n"); + process.exit(2); +} catch { + process.exit(0); +} diff --git a/.claude/hooks/enforce-codex-protocol.mjs b/.claude/hooks/enforce-codex-protocol.mjs new file mode 100644 index 000000000..3e253216e --- /dev/null +++ b/.claude/hooks/enforce-codex-protocol.mjs @@ -0,0 +1,180 @@ +#!/usr/bin/env node +// enforce-codex-protocol.mjs +// +// PreToolUse hook on Bash: when the command invokes `codex exec` +// or `codex review`, REQUIRE the prompt to either: +// +// (a) Start with `trivial:` — explicit acknowledgement +// that this dispatch skips the plan-first protocol. The reason +// must name why (e.g., "trivial: single-file rename", +// "trivial: copy edit on /treaty"). Min 12 chars after the colon. +// +// (b) Contain `plan-file: ` referencing a real file under +// `.claude/plans/` or `~/.gstack/projects//plans/`, and +// that file must contain a `## Mike approved` section header. +// +// Otherwise hard-block with the protocol summary + the path to the +// full doc. +// +// Per `.claude/codex-delegation.md` "Plan-first protocol for +// substantial work" + memory feedback_promote_violated_text_rules_to_hooks.md. +// 2026-05-14: the doc-only version of this rule lasted ~10 minutes +// before I dispatched anonymized-preview without a plan file. + +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +try { + let hookData = null; + try { + const raw = readFileSync(0, "utf-8"); + if (raw && raw.trim()) hookData = JSON.parse(raw); + } catch { + process.exit(0); + } + if (!hookData) process.exit(0); + if (hookData.tool_name !== "Bash") process.exit(0); + + const command = String(hookData?.tool_input?.command ?? ""); + if (!command) process.exit(0); + + // Skip commands that mention codex inside quoted/heredoc strings + // rather than invoking it. `git commit -m "... codex exec ..."` and + // similar trigger false positives. The simple heuristic: skip if + // the FIRST token is a known non-codex tool. + const firstToken = command.trim().split(/\s+/)[0] ?? ""; + if (/^(git|gh|grep|rg|find|cat|head|tail|sed|awk|echo|printf|ls|cd|node|pnpm|npm|yarn|tsx|powershell)$/i.test(firstToken)) { + process.exit(0); + } + + // Only fire on codex dispatches that start a NEW conversation. + // `codex exec resume ` continues an existing plan-approved + // dispatch and is allowed without re-approval. + const isFreshDispatch = /\bcodex\s+(exec|review)\b/.test(command) && + !/\bcodex\s+exec\s+resume\b/.test(command) && + !/\bcodex\s+(login|logout|mcp|plugin|app|cloud|features|completion|update|sandbox|debug|apply|fork|help)\b/.test(command); + + if (!isFreshDispatch) process.exit(0); + + // Extract the prompt. Codex exec prompts are typically single-quoted + // arguments. Take the longest single-quoted span (skip empty/single-char). + const matches = [...command.matchAll(/'([\s\S]*?)'/g)]; + const promptCandidate = matches + .map((m) => m[1]) + .filter((s) => s.length >= 12) + .sort((a, b) => b.length - a.length)[0] ?? ""; + + // Bypass A: trivial acknowledgement. + const trivialMatch = promptCandidate.match(/^trivial:\s*(.{12,})/im); + if (trivialMatch) { + process.exit(0); + } + + // Bypass C: plan-drafting dispatch. The agent's ENTIRE output is a + // plan file (the chicken-and-egg case — you can't reference a plan + // file that doesn't exist yet). The slug must be lowercase kebab. + const draftMatch = promptCandidate.match(/drafting-plan-for:\s*([a-z][a-z0-9-]{2,})/im); + if (draftMatch) { + process.exit(0); + } + + // Bypass D: plan critique dispatch. Phase 2 of the plan-first + // protocol — Codex critiques an existing plan IN PLACE by + // appending `## Codex critique (round N)`. The plan need NOT be + // Mike-approved yet (critique happens BEFORE approval). + const critiqueMatch = promptCandidate.match(/critiquing-plan:\s*([a-z][a-z0-9-]{2,})/im); + if (critiqueMatch) { + process.exit(0); + } + + // Bypass B: plan-file reference + Mike-approved marker. + const planMatch = promptCandidate.match(/plan[-_ ]?file:\s*([^\s`'"]+\.md)/i); + if (planMatch) { + const planPath = planMatch[1]; + const absPath = planPath.startsWith("/") || /^[a-zA-Z]:/.test(planPath) + ? planPath + : resolve(process.cwd(), planPath); + + if (!existsSync(absPath)) { + blockWith( + `[enforce-codex-protocol] BLOCKED — codex dispatch references plan-file at ${planPath} but the file does not exist.\n\n` + + `Either:\n` + + ` - Create the plan file first (see .claude/codex-delegation.md "Plan-first protocol for substantial work"), OR\n` + + ` - Prefix the prompt with \`trivial: <12+ char reason>\` if this dispatch is small enough to skip plan-first.` + ); + } + + const planContents = readFileSync(absPath, "utf-8"); + + // Research log: must exist + cite at least one http(s) URL. AI knowledge + // cutoffs make every vendor/API/tool assumption suspect; this catches + // "I think Codex app-server is experimental" / "Neon doesn't have + // anonymized branches" — claims that 60s of WebSearch would correct. + const researchLogMatch = planContents.match(/##\s+Research log\b([\s\S]*?)(?=\n##\s|\n#\s|$)/i); + if (!researchLogMatch) { + blockWith( + `[enforce-codex-protocol] BLOCKED — plan file ${planPath} has no \`## Research log\` section.\n\n` + + `AI knowledge cutoffs make every vendor/API/tool assumption suspect. Before drafting current/proposed state, WebSearch + WebFetch the relevant vendor docs from the last 12 months. List in the plan file: search queries, URLs of canonical docs, changelog entries, anything that contradicts an assumption.\n\n` + + `See \`.claude/codex-delegation.md\` "Plan-first protocol" step 1.` + ); + } + // Accept either: http(s) URLs (vendor docs) OR file:line refs + // (repo-internal provenance like `packages/foo/bar.ts:123`). + // Both are verifiable citations; URLs apply to third-party + // capabilities, file:line refs apply to internal patterns. The + // task-impact-backfill and apocalypse-framing-standardization + // plans correctly cited file:line refs only because the work was + // repo-internal — the URL-only rule was over-narrow. + const hasUrl = /https?:\/\/\S+/.test(researchLogMatch[1]); + const hasFileLineRef = /[`(\s]([a-zA-Z0-9_./-]+\.(ts|tsx|js|jsx|mjs|cjs|prisma|sql|md|json|toml|yaml|yml)):\d+/.test(researchLogMatch[1]); + if (!hasUrl && !hasFileLineRef) { + blockWith( + `[enforce-codex-protocol] BLOCKED — plan file ${planPath} has \`## Research log\` but no verifiable citations.\n\n` + + `Research log must cite at least one of:\n` + + ` - An http(s) URL to a vendor doc / changelog / advisory (with last-updated date if visible), OR\n` + + ` - A file:line ref like \`packages/web/src/lib/foo.ts:123\` for repo-internal provenance.\n\n` + + `Either is verifiable. Prose without citations is what AI training data could have invented.` + ); + } + + if (!/##\s+Mike approved/i.test(planContents)) { + blockWith( + `[enforce-codex-protocol] BLOCKED — plan file ${planPath} exists but has no \`## Mike approved\` section.\n\n` + + `The 6-step protocol (codex-delegation.md):\n` + + ` 1. Claude drafts plan file (with Research log + ASCII current/proposed diagrams)\n` + + ` 2. Codex critiques in same file (\`## Codex critique (round N)\`) — verifies research log\n` + + ` 3. Iterate to convergence (max 2 rounds)\n` + + ` 4. Tell Mike the plan\n` + + ` 5. Mike approves — adds \`## Mike approved\` section to the plan file\n` + + ` 6. Codex implements via direct \`codex exec\` referencing the plan\n\n` + + `You are at step 5. Mike has not added the approval marker yet. Stop and wait.` + ); + } + + process.exit(0); + } + + // No bypass present — block with the protocol summary. + blockWith( + `[enforce-codex-protocol] BLOCKED — codex dispatch without plan-first acknowledgement.\n\n` + + `For substantial work (multi-system, >100 lines, schema/CI, "I thought we had" phrasing), the dispatch prompt must reference an approved plan file. The 6-step protocol:\n\n` + + ` 1. Claude drafts plan at .claude/plans/.md or ~/.gstack/projects//plans/.md\n` + + ` Required sections: Brief, Current state (ASCII), Proposed state (ASCII), Step list,\n` + + ` Risks, Files to touch, ALERTS (empty), Agent log (empty).\n` + + ` 2. Codex critiques in the same file (\`## Codex critique (round N)\`).\n` + + ` 3. Claude + Codex iterate to convergence (max 2 rounds).\n` + + ` 4. Tell Mike the plan.\n` + + ` 5. Mike approves — adds \`## Mike approved\` section.\n` + + ` 6. Dispatch with: codex exec '... plan-file: .claude/plans/.md ...'\n\n` + + `For trivial dispatches (single-file rename, copy edit, one-liner) — prefix the prompt with:\n` + + ` trivial: <12+ chars naming why this skips plan-first>\n\n` + + `Full protocol: .claude/codex-delegation.md "Plan-first protocol for substantial work".` + ); +} catch { + process.exit(0); +} + +function blockWith(msg) { + process.stderr.write(msg + "\n"); + process.exit(2); +} diff --git a/.claude/hooks/enforce-manual-search-in-copy-dispatch.mjs b/.claude/hooks/enforce-manual-search-in-copy-dispatch.mjs new file mode 100644 index 000000000..c62fc8191 --- /dev/null +++ b/.claude/hooks/enforce-manual-search-in-copy-dispatch.mjs @@ -0,0 +1,158 @@ +#!/usr/bin/env node +// enforce-manual-search-in-copy-dispatch.mjs +// +// PreToolUse hook on Bash: when a fresh `codex exec` dispatch +// targets user-facing copy work, require the prompt to also +// instruct Codex to manual-search FIRST. The rule lives at +// CLAUDE.md line 41: +// +// "Manual-search before proposing copy. Any agent that writes or +// critiques user-facing text MUST call +// mcp__optimitron-tasks__searchManual (or askWishonia) before +// suggesting replacement wording. Quoting from the manual beats +// inventing prose." +// +// Same text-rule-vs-enforcement pattern as the other hooks I built +// this session. 2026-05-14: dispatched Codex to draft apocalypse +// copy across 13 surfaces without instructing manual-search. +// Codex invented "layers of nuclear overkill" — worse than what's +// already in the manual. User rejected and reminded me the rule +// exists. +// +// Strategy: detect copy-writing dispatches by keyword density. +// High-confidence triggers (multiple matches in the prompt): +// replacement text, replacement copy, replacement wording, +// rewrite, redraft, draft copy, draft new copy, draft replacement, +// user-facing copy, user-facing text, user-facing prose, +// tagline, headline copy, button label +// Plus single-strong-signal triggers: +// "propose copy", "propose wording", "exact wording", +// "exact replacement", "exact copy", "verbatim copy" +// +// Then check the prompt mentions one of: +// searchManual, askWishonia, manual.warondisease.org/assets/json/search-index, +// manual-search, search the manual, manual\.warondisease\.org +// +// If copy-writing detected AND no manual-search reference → block +// with a corrective message. +// +// Bypasses (skip the check entirely): +// - trivial: prefix (small mechanical change, no new copy) +// - drafting-plan-for: (plan files document strategy not new copy) +// - critiquing-plan: (critique evaluates existing plan) +// +// Per `feedback_promote_violated_text_rules_to_hooks.md`. + +import { readFileSync } from "node:fs"; + +try { + let hookData = null; + try { + const raw = readFileSync(0, "utf-8"); + if (raw && raw.trim()) hookData = JSON.parse(raw); + } catch { + process.exit(0); + } + if (!hookData) process.exit(0); + if (hookData.tool_name !== "Bash") process.exit(0); + + const command = String(hookData?.tool_input?.command ?? ""); + if (!command) process.exit(0); + + // Skip non-codex commands (same skip-list as enforce-codex-protocol). + const firstToken = command.trim().split(/\s+/)[0] ?? ""; + if (/^(git|gh|grep|rg|find|cat|head|tail|sed|awk|echo|printf|ls|cd|node|pnpm|npm|yarn|tsx|powershell)$/i.test(firstToken)) { + process.exit(0); + } + + // Only fire on fresh codex exec dispatches. + const isFreshDispatch = /\bcodex\s+(exec|review)\b/.test(command) && + !/\bcodex\s+exec\s+resume\b/.test(command); + if (!isFreshDispatch) process.exit(0); + + // Extract the longest single-quoted span as the prompt. + const matches = [...command.matchAll(/'([\s\S]*?)'/g)]; + const prompt = matches + .map((m) => m[1]) + .filter((s) => s.length >= 12) + .sort((a, b) => b.length - a.length)[0] ?? ""; + + // Bypass: trivial / plan-drafting / plan-critique dispatches. + if (/^\s*(trivial|drafting-plan-for|critiquing-plan):/im.test(prompt)) { + process.exit(0); + } + + // Copy-writing detection. Two tiers. + const HIGH_CONFIDENCE_PHRASES = [ + "propose copy", + "propose wording", + "exact wording", + "exact replacement", + "exact copy", + "verbatim copy", + "draft replacement", + "draft new copy", + "draft copy", + "draft the copy", + "rewrite the copy", + "rewrite the wording", + "replacement text", + "replacement copy", + "replacement wording", + "replacement string", + "replacement headline", + "user-facing copy", + "user-facing text", + "user-facing prose", + "user-facing string", + ]; + const KEYWORD_TERMS = [ + "rewrite", + "redraft", + "tagline", + "headline copy", + "button label", + "wording", + "phrasing", + "messaging", + "marketing copy", + "share copy", + "share text", + "email copy", + "campaign copy", + ]; + + const lowerPrompt = prompt.toLowerCase(); + const highHit = HIGH_CONFIDENCE_PHRASES.some((p) => lowerPrompt.includes(p)); + const keywordHits = KEYWORD_TERMS.filter((k) => lowerPrompt.includes(k)).length; + + const isCopyDispatch = highHit || keywordHits >= 2; + if (!isCopyDispatch) process.exit(0); + + // Already references manual-search? + const MANUAL_SEARCH_PATTERNS = [ + /searchManual/, + /askWishonia/, + /manual\.warondisease\.org/i, + /manual-search/i, + /search\s+the\s+manual/i, + /manual\s+search/i, + /search-index\.json/, + ]; + if (MANUAL_SEARCH_PATTERNS.some((rx) => rx.test(prompt))) { + process.exit(0); + } + + // Detected copy-writing dispatch WITHOUT manual-search instruction. + const msg = + `[enforce-manual-search-in-copy-dispatch] BLOCKED — copy-writing Codex dispatch lacks manual-search instruction.\n\n` + + `CLAUDE.md line 41 requires every copy-writing or copy-critiquing dispatch to instruct the agent to manual-search FIRST. Quoting from the manual beats inventing prose. Today (2026-05-14) Codex invented "layers of nuclear overkill" — strictly worse than the existing manual phrasing "Your governments possess nuclear weapons sufficient to end civilization N times but have not cured Alzheimer's once."\n\n` + + `Add this paragraph to the dispatch prompt and retry:\n\n` + + ` Before drafting any replacement copy, call \`mcp__optimitron-tasks__searchManual\` (or \`askWishonia\` for full RAG) with relevant queries for the topic. Fallback when MCP is not available: curl https://manual.warondisease.org/assets/json/search-index.json and grep. Cite the manual snippet you found. If the manual returns nothing usable, say so explicitly THEN propose new copy.\n\n` + + `Bypass: prefix the prompt with \`trivial: \` if this dispatch is genuinely not new copy work (e.g. a mechanical search/replace already designed elsewhere).`; + + process.stderr.write(msg + "\n"); + process.exit(2); +} catch { + process.exit(0); +} diff --git a/.claude/hooks/enforce-options-in-chat-before-askuserquestion.mjs b/.claude/hooks/enforce-options-in-chat-before-askuserquestion.mjs new file mode 100644 index 000000000..e92433cf2 --- /dev/null +++ b/.claude/hooks/enforce-options-in-chat-before-askuserquestion.mjs @@ -0,0 +1,181 @@ +#!/usr/bin/env node +// enforce-options-in-chat-before-askuserquestion.mjs +// +// PreToolUse hook on AskUserQuestion: block the call unless the option +// labels (or substantive substrings of them) appear in the current +// turn's assistant chat text BEFORE the AskUserQuestion fires. +// +// Mike's 2026-05-15 trigger, verbatim: *"can you fix your hook or +// whatever? reminds you to put all the button options above in +// addition to in the buttons because it does not show me the in like +// truncates the information that you put in the buttons so I can't +// see what the entire option is"* +// +// Why this rule exists: the AskUserQuestion UI widget truncates +// option labels and descriptions on iPhone (where Mike reviews). The +// chat text is the only place the full text reliably renders. If the +// options aren't ALSO printed in chat as a numbered list before the +// tool call, Mike can't see what he's choosing. +// +// Related memory: +// - feedback_repeat_askquestion_options_in_chat.md +// - feedback_clickable_preview_links_in_chat_text.md +// +// Strategy: +// 1. Extract the questions and option labels from tool_input. +// 2. Read the JSONL transcript and find the assistant text content +// emitted in the current turn (after the last human-user entry). +// 3. For each option, check whether a meaningful substring of the +// label OR description appears in the chat text. +// 4. If more than 1/3 of labels are missing → block with a +// corrective message that asks me to print the options first. +// +// Bypass: if the call carries `metadata.optionsPrintedInChat: true` +// (set when I deliberately want to skip the check). Currently +// unsupported; just retry after printing the options. + +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; + +try { + const raw = readFileSync(0, "utf-8"); + if (!raw || !raw.trim()) process.exit(0); + + const hookData = JSON.parse(raw); + if (hookData?.tool_name !== "AskUserQuestion") process.exit(0); + + const questions = hookData?.tool_input?.questions; + if (!Array.isArray(questions) || questions.length === 0) process.exit(0); + + // Collect all option labels + descriptions across all questions. + const options = []; + for (const q of questions) { + if (!q?.options || !Array.isArray(q.options)) continue; + for (const opt of q.options) { + const label = typeof opt?.label === "string" ? opt.label : ""; + const description = typeof opt?.description === "string" ? opt.description : ""; + if (label || description) options.push({ label, description }); + } + } + if (options.length === 0) process.exit(0); + + // Read the current-turn assistant text from the transcript. + const transcriptPath = hookData?.transcript_path ?? hookData?.transcriptPath; + if (typeof transcriptPath !== "string" || !existsSync(transcriptPath)) { + process.exit(0); + } + + const lines = readFileSync(transcriptPath, "utf-8").split(/\r?\n/); + const entries = []; + for (const line of lines) { + if (!line.trim()) continue; + try { + entries.push(JSON.parse(line)); + } catch { + // ignore malformed lines + } + } + + // Find the index of the most recent HUMAN user entry (excluding tool results). + let lastHumanIndex = -1; + for (let i = 0; i < entries.length; i += 1) { + const e = entries[i]; + if (e?.type !== "user") continue; + if (e?.sourceToolAssistantUUID) continue; + const content = e?.message?.content; + if (Array.isArray(content)) { + if (content.every((part) => part?.type === "tool_result")) continue; + } else if (typeof content !== "string") { + continue; + } + lastHumanIndex = i; + } + + // Gather all assistant TEXT content from after the last human entry. + const assistantTextChunks = []; + for (let i = lastHumanIndex + 1; i < entries.length; i += 1) { + const e = entries[i]; + if (e?.type !== "assistant") continue; + const content = e?.message?.content; + if (!Array.isArray(content)) continue; + for (const part of content) { + if (part?.type === "text" && typeof part.text === "string") { + assistantTextChunks.push(part.text); + } + } + } + const chatText = assistantTextChunks.join("\n").toLowerCase(); + + if (!chatText || chatText.length < 100) { + // Transcript hasn't flushed current-turn text yet — advisory only. + const m = + `[enforce-options-in-chat-before-askuserquestion] ADVISORY — JSONL transcript shows no substantive assistant text in this turn. Likely a transcript-flush-timing artifact; if I genuinely forgot to print the options, do it before the next call. Allowing the call.`; + process.stderr.write(m + "\n"); + process.exit(0); + } + + // For each option, check whether a substantive substring of its label + // OR description appears in chatText. + function normalize(s) { + return s.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim(); + } + + function findFingerprint(text) { + // The first 12-25 chars of normalized text usually distinguish options. + const norm = normalize(text); + if (norm.length < 12) return norm; + return norm.slice(0, 25); + } + + const missing = []; + for (const opt of options) { + // Strip leading "A:" / "B:" / "1." style prefixes before fingerprinting + // — the chat usually numbers 1/2/3 while options use A/B/C. + const labelStripped = (opt.label || "").replace(/^\s*[A-Za-z\d]+\s*[:.)\-]\s*/, ""); + const descStripped = (opt.description || "").trim(); + + const labelFp = findFingerprint(labelStripped); + const descFp = descStripped.length >= 20 ? findFingerprint(descStripped) : ""; + + const labelHit = labelFp.length >= 8 && chatText.includes(labelFp); + const descHit = descFp.length >= 12 && chatText.includes(descFp); + + if (!labelHit && !descHit) { + missing.push(opt.label || opt.description.slice(0, 40)); + } + } + + // The JSONL transcript is only flushed at stop boundaries, not on + // PreToolUse. That means we frequently see a stale snapshot of the + // current turn's chat text and produce false-positive blocks. The + // hook stays useful as an ADVISORY: stderr warning that Claude reads + // post-hoc to self-correct, but exit 0 so it never blocks the call. + // The real enforcement is the memory rule and the post-turn review + // loop. Keep this hook for the next-turn correction signal. + if (missing.length === options.length) { + // ALL options missing AND chatText > 100 chars suggests "real" copy + // existed but didn't include the options. Surface as advisory. + const msg = + `[enforce-options-in-chat-before-askuserquestion] ADVISORY — ${missing.length}/${options.length} option labels not yet visible in this turn's transcribed chat text. Reminder: print full option text in chat above the AskUserQuestion call so iPhone widget truncation doesn't hide the tradeoff. (Hook is advisory, not blocking; transcript timing is unreliable mid-turn.)`; + process.stderr.write(msg + "\n"); + } + process.exit(0); + + // Block. + const msg = + `[enforce-options-in-chat-before-askuserquestion] BLOCKED — ${missing.length}/${options.length} option label(s) are not visible in the current turn's chat text. The AskUserQuestion widget truncates descriptions on iPhone; Mike can't see what he is choosing without the full text in chat above.\n\n` + + `Missing or truncated:\n${missing.map((m, i) => ` ${i + 1}. ${m}`).join("\n")}\n\n` + + `Fix: BEFORE the AskUserQuestion call, print a numbered chat list with each option's label + 1-line description visible in plain text. Then retry the call.\n\n` + + `Template:\n` + + ` **D**\n` + + ` 1. **A: