From 566a5200aef5882889fd4c93d1f14b6d960f14c3 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sun, 9 Aug 2026 02:51:15 +0000 Subject: [PATCH] feat: add Codex-native React Doctor loop --- .agents/plugins/marketplace.json | 20 ++ .gitignore | 3 + .../.codex-plugin/plugin.json | 18 ++ plugins/react-doctor-loop/hooks/hooks.json | 51 ++++ .../react-doctor-loop/scripts/complete.mjs | 122 ++++++++++ plugins/react-doctor-loop/scripts/hook.mjs | 227 ++++++++++++++++++ .../scripts/run-daytona-eval.mjs | 99 ++++++++ .../skills/react-doctor-loop/SKILL.md | 51 ++++ .../react-doctor-loop/agents/openai.yaml | 4 + plugins/react-doctor-loop/tests/hook.test.mjs | 218 +++++++++++++++++ 10 files changed, 813 insertions(+) create mode 100644 .agents/plugins/marketplace.json create mode 100644 plugins/react-doctor-loop/.codex-plugin/plugin.json create mode 100644 plugins/react-doctor-loop/hooks/hooks.json create mode 100644 plugins/react-doctor-loop/scripts/complete.mjs create mode 100644 plugins/react-doctor-loop/scripts/hook.mjs create mode 100644 plugins/react-doctor-loop/scripts/run-daytona-eval.mjs create mode 100644 plugins/react-doctor-loop/skills/react-doctor-loop/SKILL.md create mode 100644 plugins/react-doctor-loop/skills/react-doctor-loop/agents/openai.yaml create mode 100644 plugins/react-doctor-loop/tests/hook.test.mjs diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 000000000..38b8a4106 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "personal", + "interface": { + "displayName": "Personal" + }, + "plugins": [ + { + "name": "react-doctor-loop", + "source": { + "source": "local", + "path": "./plugins/react-doctor-loop" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/.gitignore b/.gitignore index c62750c0a..ea2f4126c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ review-*.md # Track repository-owned agent skills, but keep other local agent state out. /.agents/* +!/.agents/plugins/ +!/.agents/plugins/marketplace.json !/.agents/skills/ /.agents/skills/* !/.agents/skills/react-doctor/ @@ -51,3 +53,4 @@ review-*.md /scripts/print-batch-input.mjs /scripts/rule-prompts/ /rules.json +/.react-doctor-loop/ diff --git a/plugins/react-doctor-loop/.codex-plugin/plugin.json b/plugins/react-doctor-loop/.codex-plugin/plugin.json new file mode 100644 index 000000000..3a8fd0cca --- /dev/null +++ b/plugins/react-doctor-loop/.codex-plugin/plugin.json @@ -0,0 +1,18 @@ +{ + "name": "react-doctor-loop", + "version": "0.1.0+codex.20260809025047", + "description": "Continuously turn confirmed React Doctor benchmark false positives into validated draft pull requests.", + "author": { + "name": "Million" + }, + "skills": "./skills/", + "interface": { + "displayName": "React Doctor Loop", + "shortDescription": "Fix audited false positives with Codex", + "longDescription": "A single Codex-native loop for auditing one root-cause cohort, implementing the narrow fix, proving it against the benchmark and open-source corpus, and opening a review-only draft pull request.", + "developerName": "Million", + "category": "Developer Tools", + "capabilities": ["skills", "hooks"], + "defaultPrompt": "Use $react-doctor-loop to fix the next confirmed false-positive cohort." + } +} diff --git a/plugins/react-doctor-loop/hooks/hooks.json b/plugins/react-doctor-loop/hooks/hooks.json new file mode 100644 index 000000000..a50e41909 --- /dev/null +++ b/plugins/react-doctor-loop/hooks/hooks.json @@ -0,0 +1,51 @@ +{ + "description": "Dynamically activates the React Doctor audit loop and enforces its safety boundary.", + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear|compact", + "hooks": [ + { + "type": "command", + "command": "node \"$PLUGIN_ROOT/scripts/hook.mjs\"", + "additionalContextLimit": 1200 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"$PLUGIN_ROOT/scripts/hook.mjs\"", + "additionalContextLimit": 1200 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node \"$PLUGIN_ROOT/scripts/hook.mjs\"", + "timeout": 5 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"$PLUGIN_ROOT/scripts/hook.mjs\"", + "timeout": 15 + } + ] + } + ] + } +} diff --git a/plugins/react-doctor-loop/scripts/complete.mjs b/plugins/react-doctor-loop/scripts/complete.mjs new file mode 100644 index 000000000..ac676fc61 --- /dev/null +++ b/plugins/react-doctor-loop/scripts/complete.mjs @@ -0,0 +1,122 @@ +import { execFileSync } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const ALLOWED_PATH_PREFIXES = [ + ".changeset/", + "packages/core/", + "packages/fuzz/", + "packages/oxlint-plugin-react-doctor/", +]; +const REQUIRED_EVIDENCE_KEYS = [ + "focusedTests", + "auditReplay", + "coverageLedger", + "strictFuzz", + "fullFuzz", + "repositoryChecks", + "daytonaParity", +]; + +const fail = (message) => { + process.stderr.write(`${message}\n`); + process.exit(1); +}; + +const run = (command, argumentsList) => + execFileSync(command, argumentsList, { + cwd: process.cwd(), + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + +const [rule, cohort, evidencePath, parityPath] = process.argv.slice(2); +if (![rule, cohort, evidencePath, parityPath].every((value) => value?.length > 0)) { + fail("Usage: complete.mjs "); +} + +const branch = run("git", ["branch", "--show-current"]); +const head = run("git", ["rev-parse", "HEAD"]); +const status = run("git", ["status", "--porcelain=v1"]); +if (!branch.startsWith("loop/")) fail("The current branch must start with loop/."); +if (status.length > 0) fail("Commit the validated cohort before marking it complete."); + +const changedFiles = run("git", ["diff", "--name-only", "origin/main...HEAD"]) + .split("\n") + .filter(Boolean); +const disallowedFile = changedFiles.find( + (filePath) => !ALLOWED_PATH_PREFIXES.some((prefix) => filePath.startsWith(prefix)), +); +if (disallowedFile !== undefined) fail(`Disallowed changed file: ${disallowedFile}`); +if (!changedFiles.some((filePath) => /(?:\.test|\.regressions\.test)\.tsx?$/.test(filePath))) { + fail("The cohort needs a regression test."); +} +if (!changedFiles.some((filePath) => filePath.startsWith("packages/fuzz/corpus/"))) { + fail("The cohort needs a deduplicated fuzz fixture."); +} +if ( + !changedFiles.some((filePath) => filePath.startsWith(".changeset/") && filePath.endsWith(".md")) +) { + fail("The cohort needs a patch changeset."); +} + +const evidence = JSON.parse(await readFile(path.resolve(evidencePath), "utf8")); +if (evidence.rule !== rule || evidence.cohort !== cohort) fail("Evidence identity does not match."); +for (const key of REQUIRED_EVIDENCE_KEYS) { + const expectedExitCode = key === "daytonaParity" ? 1 : 0; + if (evidence[key]?.exitCode !== expectedExitCode || typeof evidence[key]?.command !== "string") { + fail(`Evidence step ${key} is missing or failed.`); + } +} +if ( + !Array.isArray(evidence.coverageLedger.memberships) || + evidence.coverageLedger.memberships.length === 0 +) { + fail("The coverage ledger must list every cohort membership."); +} +if ( + (evidence.coverageLedger.missing?.length ?? 0) > 0 || + (evidence.coverageLedger.extra?.length ?? 0) > 0 +) { + fail("The coverage ledger has missing or extra memberships."); +} + +const parity = JSON.parse(await readFile(path.resolve(parityPath), "utf8")); +if ( + !Array.isArray(parity.added) || + !Array.isArray(parity.removed) || + !Array.isArray(parity.skippedProjects) +) { + fail("Invalid Daytona parity report."); +} +if (parity.skippedProjects.length > 0) fail("Daytona parity excluded failed projects."); +const { added, removed } = parity; +if (added.length > 0) fail("RDE parity introduced diagnostics."); +if (removed.length === 0) fail("RDE parity did not reproduce the intended removal."); +const unrelatedRemoval = removed.find( + (entry) => entry.diagnostic?.rule !== rule && !entry.diagnostic?.rule?.endsWith(`/${rule}`), +); +if (unrelatedRemoval !== undefined) + fail(`Daytona parity changed unrelated rule ${unrelatedRemoval.diagnostic?.rule}.`); + +const pullRequest = JSON.parse( + run("gh", ["pr", "view", "--json", "headRefOid,isDraft,state,title,url"]), +); +if (pullRequest.state !== "OPEN" || pullRequest.isDraft !== true) + fail("The pull request must be an open draft."); +if (!pullRequest.title.startsWith("[loop]")) fail("The pull request title must start with [loop]."); +if (pullRequest.headRefOid !== head) fail("The draft pull request is not at the validated commit."); + +const completion = { + branch, + cohort, + completedAt: new Date().toISOString(), + evidence, + head, + pullRequestUrl: pullRequest.url, + rule, +}; +const completionPath = path.join(process.cwd(), ".react-doctor-loop", "completed.json"); +await mkdir(path.dirname(completionPath), { recursive: true }); +await writeFile(completionPath, `${JSON.stringify(completion, null, 2)}\n`, { mode: 0o600 }); +process.stdout.write(`${pullRequest.url}\n`); diff --git a/plugins/react-doctor-loop/scripts/hook.mjs b/plugins/react-doctor-loop/scripts/hook.mjs new file mode 100644 index 000000000..bcc48a6fe --- /dev/null +++ b/plugins/react-doctor-loop/scripts/hook.mjs @@ -0,0 +1,227 @@ +import { execFileSync } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const LOOP_PROMPT_PATTERN = /\$react-doctor-loop\b|\breact doctor loop\b/i; +const UNSAFE_COMMAND_PATTERNS = [ + /\bgh\s+pr\s+(?:merge|ready)\b/i, + /\bgh\s+release\b/i, + /\bgit\s+tag\b/i, + /\bgit\s+push\b[^\n]*(?:--force|-f\b)/i, + /\bgit\s+push\b[^\n]*\b(?:main|master)\b/i, + /\b(?:npm|pnpm|yarn|bun)\s+publish\b/i, + /\bchangeset\s+publish\b/i, + /\bcodex\s+(?:login|logout)\b/i, + /\bOPENAI_API_KEY\b/, + /(?:cat|sed|awk|rg|grep|head|tail|less|more)[^\n]*\.env\.local/i, +]; +const PROTECTED_EDIT_PATTERNS = [ + /(?:^|\/)\.env\.local$/, + /(?:^|\/)auth\.json$/, + /(?:^|\/)config\.toml$/, + /(?:^|\/)plugins\/react-doctor-loop\//, + /(?:^|\/)\.codex\//, +]; +const REQUIRED_EVIDENCE_KEYS = [ + "focusedTests", + "auditReplay", + "coverageLedger", + "strictFuzz", + "fullFuzz", + "repositoryChecks", + "daytonaParity", +]; + +const readInput = async () => { + let input = ""; + for await (const chunk of process.stdin) input += chunk; + return JSON.parse(input); +}; + +const sessionPath = (sessionId) => { + const safeSessionId = sessionId.replaceAll(/[^a-zA-Z0-9_-]/g, "-"); + return path.join(process.env.PLUGIN_DATA, "sessions", `${safeSessionId}.json`); +}; + +const readSession = async (sessionId) => { + try { + return JSON.parse(await readFile(sessionPath(sessionId), "utf8")); + } catch { + return { active: false, completedPullRequests: 0 }; + } +}; + +const writeSession = async (sessionId, session) => { + const filePath = sessionPath(sessionId); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, `${JSON.stringify(session, null, 2)}\n`, { mode: 0o600 }); +}; + +const outputContext = (eventName, additionalContext) => + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { hookEventName: eventName, additionalContext }, + }), + ); + +const deny = (reason) => + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: reason, + }, + }), + ); + +const runGit = (cwd, argumentsList) => + execFileSync("git", argumentsList, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + +const handlePrompt = async (input, session) => { + if (!LOOP_PROMPT_PATTERN.test(input.prompt)) return; + await writeSession(input.session_id, { + ...session, + active: true, + startedAt: session.startedAt ?? new Date().toISOString(), + }); + outputContext( + "UserPromptSubmit", + "React Doctor Loop is active. Use only ChatGPT/Codex plan inference. The hooks block merge, release, account, secret-reading, and unsafe push operations.", + ); +}; + +const handleSessionStart = (session) => { + if (!session.active) return; + outputContext( + "SessionStart", + "Resume the active React Doctor Loop. Work on one confirmed semantic cohort, preserve exact benchmark evidence, open only a [loop] draft PR, and never merge or publish.", + ); +}; + +const commandFromToolInput = (toolInput) => + typeof toolInput?.command === "string" ? toolInput.command : JSON.stringify(toolInput); + +const editedPaths = (command) => + [...command.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm)].map((match) => match[1]); + +const handlePreToolUse = (input, session) => { + if (!session.active) return; + const command = commandFromToolInput(input.tool_input); + if (/\.env\.local|DAYTONA_API_KEY/.test(command)) { + deny("React Doctor Loop cannot read, print, or forward local secrets."); + return; + } + if (/merge_pull_request|publish|create_release/i.test(input.tool_name)) { + deny(`React Doctor Loop blocks ${input.tool_name}. Draft PRs require maintainer approval.`); + return; + } + if ( + /(?:\bnr\s+(?:--silent\s+)?eval\b|packages\/evals)/i.test(command) && + !/run-daytona-eval\.mjs/.test(command) + ) { + deny("Run Daytona only through the bounded run-daytona-eval.mjs wrapper."); + return; + } + if ( + /create_pull_request/i.test(input.tool_name) && + (input.tool_input?.draft !== true || !input.tool_input?.title?.startsWith("[loop]")) + ) { + deny("React Doctor Loop PRs must be drafts and their title must start with [loop]."); + return; + } + const unsafePattern = UNSAFE_COMMAND_PATTERNS.find((pattern) => pattern.test(command)); + if (unsafePattern !== undefined) { + deny( + "React Doctor Loop blocks merge, release, account, secret-reading, force-push, and main-push operations.", + ); + return; + } + if ( + /\bgh\s+pr\s+create\b/i.test(command) && + (!/--draft\b/.test(command) || !/\[loop\]/.test(command)) + ) { + deny("React Doctor Loop PRs must be drafts and their title must start with [loop]."); + return; + } + const protectedPath = editedPaths(command).find((filePath) => + PROTECTED_EDIT_PATTERNS.some((pattern) => pattern.test(filePath)), + ); + if (protectedPath !== undefined) deny(`React Doctor Loop cannot edit ${protectedPath}.`); +}; + +const readCompletion = async (cwd) => { + try { + return JSON.parse( + await readFile(path.join(cwd, ".react-doctor-loop", "completed.json"), "utf8"), + ); + } catch { + return undefined; + } +}; + +const continueLoop = (reason) => + process.stdout.write(JSON.stringify({ decision: "block", reason })); + +const handleStop = async (input, session) => { + if (!session.active) return; + if (/rate limit|usage limit|quota|plan limit/i.test(input.last_assistant_message ?? "")) return; + let branch; + let head; + let status; + try { + branch = runGit(input.cwd, ["branch", "--show-current"]); + head = runGit(input.cwd, ["rev-parse", "HEAD"]); + status = runGit(input.cwd, ["status", "--porcelain=v1"]); + } catch { + continueLoop( + "Continue from the React Doctor repository and restore the loop worktree before stopping.", + ); + return; + } + if (!branch.startsWith("loop/")) { + continueLoop( + "Fetch origin/main and create the next fresh loop/- branch before continuing.", + ); + return; + } + if (status.length > 0) { + continueLoop( + "The loop branch is dirty. Finish the cohort, validate it, commit it, and open a [loop] draft PR.", + ); + return; + } + const completion = await readCompletion(input.cwd); + const evidencePassed = REQUIRED_EVIDENCE_KEYS.every((key) => + key === "daytonaParity" + ? completion?.evidence?.[key]?.exitCode === 1 + : completion?.evidence?.[key]?.exitCode === 0, + ); + if (completion?.head !== head || completion?.branch !== branch || !evidencePassed) { + continueLoop( + "The cohort is not complete. Finish exact replay, coverage ledger, focused tests, strict and full fuzz, repository checks, exact-parent RDE parity, and a [loop] draft PR; then run the plugin complete.mjs command.", + ); + return; + } + await writeSession(input.session_id, { + ...session, + completedPullRequests: session.completedPullRequests + 1, + lastCompletedHead: head, + lastCompletedAt: new Date().toISOString(), + }); + continueLoop( + `Draft PR ${completion.pullRequestUrl} is complete and remains unmerged. Return to origin/main and start the next confirmed root-cause cohort.`, + ); +}; + +const input = await readInput(); +const session = await readSession(input.session_id); + +if (input.hook_event_name === "UserPromptSubmit") await handlePrompt(input, session); +if (input.hook_event_name === "SessionStart") handleSessionStart(session); +if (input.hook_event_name === "PreToolUse") handlePreToolUse(input, session); +if (input.hook_event_name === "Stop") await handleStop(input, session); diff --git a/plugins/react-doctor-loop/scripts/run-daytona-eval.mjs b/plugins/react-doctor-loop/scripts/run-daytona-eval.mjs new file mode 100644 index 000000000..9933c5295 --- /dev/null +++ b/plugins/react-doctor-loop/scripts/run-daytona-eval.mjs @@ -0,0 +1,99 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { parseEnv } from "node:util"; + +const MAXIMUM_DAILY_RUNS = 2; +const MAXIMUM_REPOSITORIES = 2_000; +const MAXIMUM_CONCURRENCY = 200; +const REQUIRED_REPOSITORIES_PER_SANDBOX = 10; +const MAXIMUM_DURATION_MINUTES = 30; +const SAFE_ENVIRONMENT_KEYS = ["HOME", "LANG", "LC_ALL", "PATH", "SHELL", "TMPDIR"]; +const COMMIT_PATTERN = /^[0-9a-f]{40}$/i; + +const fail = (message) => { + process.stderr.write(`${message}\n`); + process.exit(1); +}; + +const readOptionNumber = (argumentsList, name, fallback) => { + const optionIndex = argumentsList.lastIndexOf(name); + if (optionIndex === -1) return fallback; + const value = Number(argumentsList[optionIndex + 1]); + if (!Number.isInteger(value) || value < 1) fail(`${name} must be a positive integer.`); + return value; +}; + +const evaluatorArguments = process.argv.slice(2); +if (evaluatorArguments.length === 0) + fail("Pass the arguments for the packages/evals eval command."); +const reactDoctorRefIndex = evaluatorArguments.lastIndexOf("--react-doctor-ref"); +const reactDoctorRef = evaluatorArguments[reactDoctorRefIndex + 1]; +if (reactDoctorRefIndex === -1 || !COMMIT_PATTERN.test(reactDoctorRef ?? "")) { + fail("--react-doctor-ref must be an exact 40-character commit."); +} + +const repositoryLimit = readOptionNumber( + evaluatorArguments, + "--repository-limit", + MAXIMUM_REPOSITORIES, +); +const concurrency = readOptionNumber(evaluatorArguments, "--concurrency", MAXIMUM_CONCURRENCY); +const repositoriesPerSandbox = readOptionNumber( + evaluatorArguments, + "--repositories-per-sandbox", + REQUIRED_REPOSITORIES_PER_SANDBOX, +); +const durationMinutes = readOptionNumber( + evaluatorArguments, + "--max-duration-minutes", + MAXIMUM_DURATION_MINUTES, +); +if (repositoryLimit > MAXIMUM_REPOSITORIES) fail("Daytona repository limit exceeds 2,000."); +if (concurrency > MAXIMUM_CONCURRENCY) fail("Daytona concurrency exceeds 200."); +if (repositoriesPerSandbox !== REQUIRED_REPOSITORIES_PER_SANDBOX) { + fail("Daytona runs must reuse each sandbox for exactly 10 repositories."); +} +if (durationMinutes > MAXIMUM_DURATION_MINUTES) fail("Daytona duration exceeds 30 minutes."); + +const repositoryRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", +}).trim(); +const commonGitDirectory = execFileSync("git", ["rev-parse", "--git-common-dir"], { + cwd: repositoryRoot, + encoding: "utf8", +}).trim(); +const primaryRoot = path.dirname(path.resolve(repositoryRoot, commonGitDirectory)); +const localEnvironment = parseEnv(await readFile(path.join(primaryRoot, ".env.local"), "utf8")); +if (!localEnvironment.DAYTONA_API_KEY) fail("DAYTONA_API_KEY is missing from .env.local."); + +const stateDirectory = path.join(primaryRoot, ".react-doctor-loop"); +const usagePath = path.join(stateDirectory, "daytona-usage.json"); +const utcDate = new Date().toISOString().slice(0, 10); +let usage = { date: utcDate, runs: 0 }; +try { + const storedUsage = JSON.parse(await readFile(usagePath, "utf8")); + if (storedUsage.date === utcDate) usage = storedUsage; +} catch {} +if (usage.runs >= MAXIMUM_DAILY_RUNS) fail("The two-run Daytona daily budget is exhausted."); +await mkdir(stateDirectory, { recursive: true }); +await writeFile( + usagePath, + `${JSON.stringify({ date: utcDate, runs: usage.runs + 1 }, null, 2)}\n`, + { + mode: 0o600, + }, +); + +const environment = Object.fromEntries( + SAFE_ENVIRONMENT_KEYS.flatMap((key) => + process.env[key] === undefined ? [] : [[key, process.env[key]]], + ), +); +environment.DAYTONA_API_KEY = localEnvironment.DAYTONA_API_KEY; +const result = spawnSync("nr", ["--silent", "eval", ...evaluatorArguments], { + cwd: path.join(repositoryRoot, "packages", "evals"), + env: environment, + stdio: "inherit", +}); +process.exit(result.status ?? 1); diff --git a/plugins/react-doctor-loop/skills/react-doctor-loop/SKILL.md b/plugins/react-doctor-loop/skills/react-doctor-loop/SKILL.md new file mode 100644 index 000000000..f7a16fe9e --- /dev/null +++ b/plugins/react-doctor-loop/skills/react-doctor-loop/SKILL.md @@ -0,0 +1,51 @@ +--- +name: react-doctor-loop +description: Continuously audit and fix confirmed React Doctor false-positive root-cause cohorts with Codex plan inference, exact benchmark evidence, fuzz regressions, local RDE parity, and review-only draft pull requests. Use when asked to run the React Doctor loop, mine ReactBench for false positives, harden a rule from benchmark evidence, or prepare repeated [loop] draft PRs without merging. +--- + +# React Doctor Loop + +Run one gold path. Codex owns the reasoning; hooks enforce the boundaries. + +## Start + +1. Confirm `codex login status` says ChatGPT. Never use an API key, provider fallback, account rotation, or `codex exec`. +2. Read `AGENTS.md` and these repository skills in full: + - `.agents/skills/benchmark-fp-fn-audit/SKILL.md` + - `.agents/skills/find-similar-functions/SKILL.md` + - `.agents/skills/rule-research/SKILL.md` + - `.agents/skills/rule-writing/SKILL.md` + - `.agents/skills/fuzz/SKILL.md` + - `.agents/skills/run-parity/SKILL.md` + - `.agents/skills/rule-validate/SKILL.md` +3. Read the complete rule catalog at `https://www.react.doctor/docs/rules`. +4. Use the benchmark and RDE paths named by those skills. Do not edit either corpus. +5. Fetch `origin/main`, create a fresh `loop/-` branch from it, and handle one semantic root-cause cohort. + +## Audit + +Recompute from raw artifacts. Treat a cohort as a shared detector mistake, not every changed line or repeated callsite. Reverify every lead against current `origin/main`; close already-fixed or stale findings without editing. + +For a confirmed cohort, record exact trials, files, spans, before/after diagnostics, task behavior, tests, the documented rule contract, and a nearby true-positive counterexample. Prioritize by affected-trial coverage, confidence, and reproducibility. + +## Fix + +Implement the narrowest detector change that explains the entire cohort. Search with truffler before and after. Add exact real-callsite regressions, adversarial true-positive controls, a deduplicated fuzz fixture, and a patch changeset. Do not weaken sibling cases or edit generated, benchmark, action, release, authentication, or loop-plugin files. + +## Prove + +Run in this order: + +1. Focused rule tests while tuning the proof boundary. +2. Exact downloaded-job replay and a coverage ledger proving every cohort membership is represented with no missing or extra entries. +3. `FUZZ_RULE= FUZZ_STRICT=1 FUZZ_ITERATIONS=500 nr fuzz`, the deterministic fuzz tests, and the full fuzz suite. +4. `nr test`, `nr lint`, `nr typecheck`, and `nr format:check`. +5. Daytona parity against the exact PR base and head commits through `.agents/skills/run-parity/SKILL.md`. Reuse only a validated, exact-commit baseline cache; otherwise use its paired path. Inspect every added diagnostic, removed diagnostic, skipped project, and failure. A comparison exit code of `1` is expected for the intended removal. + +Invoke each evaluator run through `node "$PLUGIN_ROOT/scripts/run-daytona-eval.mjs" `. That wrapper reads only `DAYTONA_API_KEY` from the primary checkout's `.env.local`, strips every other credential from the child environment, preserves evaluator cleanup, and allows at most two bounded runs per UTC day. Never read, print, source, or copy `.env.local` yourself. + +## Publish + +Commit only the cohort fix. Push only the `loop/*` branch. Open a draft PR with a `[loop]` title and a body containing the root cause, scope boundary, cohort coverage, exact commands, parity interpretation, and the statement that maintainer approval is required. + +Never mark the PR ready, merge, publish, tag, release, or modify authentication. Run `node "$PLUGIN_ROOT/scripts/complete.mjs" ` after the draft PR exists. The Stop hook continues to the next cohort only after that command verifies the branch, evidence, parity, and draft PR. diff --git a/plugins/react-doctor-loop/skills/react-doctor-loop/agents/openai.yaml b/plugins/react-doctor-loop/skills/react-doctor-loop/agents/openai.yaml new file mode 100644 index 000000000..873e59380 --- /dev/null +++ b/plugins/react-doctor-loop/skills/react-doctor-loop/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "React Doctor Loop" + short_description: "Fix audited React Doctor false positives" + default_prompt: "Use $react-doctor-loop to fix the next confirmed false-positive cohort." diff --git a/plugins/react-doctor-loop/tests/hook.test.mjs b/plugins/react-doctor-loop/tests/hook.test.mjs new file mode 100644 index 000000000..bfece2824 --- /dev/null +++ b/plugins/react-doctor-loop/tests/hook.test.mjs @@ -0,0 +1,218 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const HOOK_PATH = path.resolve(import.meta.dirname, "../scripts/hook.mjs"); + +const runHook = (pluginData, input) => + spawnSync("node", [HOOK_PATH], { + encoding: "utf8", + env: { ...process.env, PLUGIN_DATA: pluginData }, + input: JSON.stringify(input), + }); + +test("activates only for the loop skill and blocks merge", () => { + const pluginData = mkdtempSync(path.join(os.tmpdir(), "react-doctor-loop-hook-")); + const sessionId = "session-1"; + const activation = runHook(pluginData, { + hook_event_name: "UserPromptSubmit", + prompt: "Use $react-doctor-loop for the next cohort", + session_id: sessionId, + }); + assert.equal(activation.status, 0); + assert.match(activation.stdout, /React Doctor Loop is active/); + + const blocked = runHook(pluginData, { + hook_event_name: "PreToolUse", + session_id: sessionId, + tool_input: { command: "gh pr merge 123" }, + tool_name: "Bash", + }); + const output = JSON.parse(blocked.stdout); + assert.equal(output.hookSpecificOutput.permissionDecision, "deny"); +}); + +test("does not affect unrelated Codex sessions", () => { + const pluginData = mkdtempSync(path.join(os.tmpdir(), "react-doctor-loop-hook-")); + const result = runHook(pluginData, { + hook_event_name: "PreToolUse", + session_id: "unrelated", + tool_input: { command: "gh pr merge 123" }, + tool_name: "Bash", + }); + assert.equal(result.status, 0); + assert.equal(result.stdout, ""); +}); + +test("requires draft pull requests through non-shell tools", () => { + const pluginData = mkdtempSync(path.join(os.tmpdir(), "react-doctor-loop-hook-")); + const sessionId = "session-pr"; + runHook(pluginData, { + hook_event_name: "UserPromptSubmit", + prompt: "Start the React Doctor Loop", + session_id: sessionId, + }); + const result = runHook(pluginData, { + hook_event_name: "PreToolUse", + session_id: sessionId, + tool_input: { draft: false, title: "fix: unsafe" }, + tool_name: "mcp__github__create_pull_request", + }); + assert.equal(JSON.parse(result.stdout).hookSpecificOutput.permissionDecision, "deny"); +}); + +test("requires the bounded Daytona wrapper", () => { + const pluginData = mkdtempSync(path.join(os.tmpdir(), "react-doctor-loop-hook-")); + const sessionId = "session-daytona"; + runHook(pluginData, { + hook_event_name: "UserPromptSubmit", + prompt: "Start the React Doctor Loop", + session_id: sessionId, + }); + const result = runHook(pluginData, { + hook_event_name: "PreToolUse", + session_id: sessionId, + tool_input: { command: "cd packages/evals && nr --silent eval --react-doctor-ref abc" }, + tool_name: "Bash", + }); + assert.match(JSON.parse(result.stdout).hookSpecificOutput.permissionDecisionReason, /bounded/); +}); + +test("continues an active loop without a completion receipt", () => { + const pluginData = mkdtempSync(path.join(os.tmpdir(), "react-doctor-loop-hook-")); + const repository = mkdtempSync(path.join(os.tmpdir(), "react-doctor-loop-repo-")); + mkdirSync(path.join(repository, ".react-doctor-loop")); + execFileSync("git", ["init", "-b", "loop/example"], { cwd: repository }); + execFileSync("git", ["config", "user.email", "loop@example.com"], { cwd: repository }); + execFileSync("git", ["config", "user.name", "Loop Test"], { cwd: repository }); + writeFileSync(path.join(repository, "fixture.txt"), "fixture\n"); + writeFileSync(path.join(repository, ".gitignore"), ".react-doctor-loop/\n"); + execFileSync("git", ["add", "fixture.txt", ".gitignore"], { cwd: repository }); + execFileSync("git", ["commit", "-m", "test: initialize"], { cwd: repository }); + + runHook(pluginData, { + hook_event_name: "UserPromptSubmit", + prompt: "Start the React Doctor Loop", + session_id: "session-2", + }); + const result = runHook(pluginData, { + cwd: repository, + hook_event_name: "Stop", + last_assistant_message: "Finished", + session_id: "session-2", + }); + assert.equal(result.status, 0); + assert.match(JSON.parse(result.stdout).reason, /not complete/); +}); + +test("complete command fails closed without arguments", () => { + const completePath = path.resolve(import.meta.dirname, "../scripts/complete.mjs"); + const result = spawnSync("node", [completePath], { encoding: "utf8" }); + assert.equal(result.status, 1); + assert.match(result.stderr, /Usage:/); +}); + +test("complete command verifies evidence, parity, and a draft pull request", () => { + const repository = mkdtempSync(path.join(os.tmpdir(), "react-doctor-loop-complete-")); + execFileSync("git", ["init", "-b", "main"], { cwd: repository }); + execFileSync("git", ["config", "user.email", "loop@example.com"], { cwd: repository }); + execFileSync("git", ["config", "user.name", "Loop Test"], { cwd: repository }); + writeFileSync(path.join(repository, "fixture.txt"), "fixture\n"); + writeFileSync(path.join(repository, ".gitignore"), ".react-doctor-loop/\nbin/\n"); + execFileSync("git", ["add", "fixture.txt", ".gitignore"], { cwd: repository }); + execFileSync("git", ["commit", "-m", "test: initialize"], { cwd: repository }); + execFileSync("git", ["update-ref", "refs/remotes/origin/main", "HEAD"], { cwd: repository }); + execFileSync("git", ["switch", "-c", "loop/test-rule-cohort"], { cwd: repository }); + const changedFiles = [ + ".changeset/test.md", + "packages/fuzz/corpus/regressions/test-rule--cohort.tsx", + "packages/oxlint-plugin-react-doctor/tests/test-rule.test.ts", + ]; + for (const filePath of changedFiles) { + mkdirSync(path.dirname(path.join(repository, filePath)), { recursive: true }); + writeFileSync(path.join(repository, filePath), "fixture\n"); + } + execFileSync("git", ["add", ...changedFiles], { cwd: repository }); + execFileSync("git", ["commit", "-m", "fix: test cohort"], { cwd: repository }); + const head = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: repository, + encoding: "utf8", + }).trim(); + const evidenceStep = { command: "test", exitCode: 0 }; + const evidence = { + auditReplay: evidenceStep, + cohort: "cohort", + coverageLedger: { + ...evidenceStep, + extra: [], + memberships: ["trial"], + missing: [], + }, + daytonaParity: { command: "compare", exitCode: 1 }, + focusedTests: evidenceStep, + fullFuzz: evidenceStep, + repositoryChecks: evidenceStep, + rule: "test-rule", + strictFuzz: evidenceStep, + }; + const evidenceDirectory = path.join(repository, ".react-doctor-loop"); + mkdirSync(evidenceDirectory); + const evidencePath = path.join(evidenceDirectory, "evidence.json"); + const parityPath = path.join(evidenceDirectory, "parity.json"); + writeFileSync(evidencePath, JSON.stringify(evidence)); + writeFileSync( + parityPath, + JSON.stringify({ + added: [], + removed: [{ diagnostic: { rule: "test-rule" } }], + skippedProjects: [], + }), + ); + const binaryDirectory = path.join(repository, "bin"); + mkdirSync(binaryDirectory); + const ghPath = path.join(binaryDirectory, "gh"); + writeFileSync( + ghPath, + `#!/bin/sh\nprintf '%s\\n' '${JSON.stringify({ headRefOid: head, isDraft: true, state: "OPEN", title: "[loop] fix", url: "https://example.com/pr" })}'\n`, + ); + chmodSync(ghPath, 0o700); + const completePath = path.resolve(import.meta.dirname, "../scripts/complete.mjs"); + const result = spawnSync( + "node", + [completePath, "test-rule", "cohort", evidencePath, parityPath], + { + cwd: repository, + encoding: "utf8", + env: { ...process.env, PATH: `${binaryDirectory}:${process.env.PATH}` }, + }, + ); + assert.equal(result.status, 0, result.stderr); + const completion = JSON.parse( + readFileSync(path.join(repository, ".react-doctor-loop", "completed.json"), "utf8"), + ); + assert.equal(completion.head, head); + assert.equal(completion.pullRequestUrl, "https://example.com/pr"); +}); + +test("Daytona wrapper fails before reading credentials when over budget", () => { + const daytonaPath = path.resolve(import.meta.dirname, "../scripts/run-daytona-eval.mjs"); + const result = spawnSync( + "node", + [daytonaPath, "--repository-limit", "2001", "--react-doctor-ref", "a".repeat(40)], + { encoding: "utf8" }, + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /exceeds 2,000/); +}); + +test("Daytona wrapper requires an exact detector commit", () => { + const daytonaPath = path.resolve(import.meta.dirname, "../scripts/run-daytona-eval.mjs"); + const result = spawnSync("node", [daytonaPath, "--react-doctor-ref", "main"], { + encoding: "utf8", + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /exact 40-character commit/); +});