diff --git a/DEPLOY.md b/DEPLOY.md index fe6d244..7e3be51 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -41,6 +41,7 @@ Every knob is a line in `~/.stupify/config.env`, read fresh each sweep. A one-sh | `SCOPE` | `auto` | `auto` reviews every non-draft, non-bot PR; `label` only labelled ones | | `REVIEW_LABEL` | `codex-review` | force-include label: oversized diffs and bot PRs opt in with it | | `DIFF_LINE_CAP` | `20000` | skip bigger diffs unless labelled | +| `MAX_TURNS` | `6`, min 2 | codex turns per review; past it the attempt fails and retries later | | `MAX_PRS` | `15` | reviews per sweep, counted after dedup skips | | `MAX_REVIEWS_PER_DAY` | `0` (off) | hard daily ceiling | | `FAIL_RETRY_MIN` | `60` | wait before retrying a head whose review failed | diff --git a/bun.lock b/bun.lock index c7632fd..049a5fe 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "stupify", "dependencies": { - "@bevyl-ai/agent-tools": "0.13.0", + "@bevyl-ai/agent-tools": "0.14.2", "zod": "^4.4.3", }, "devDependencies": { @@ -18,7 +18,27 @@ }, }, "packages": { - "@bevyl-ai/agent-tools": ["@bevyl-ai/agent-tools@0.13.0", "", { "dependencies": { "zod": "^4.4.3" } }, "sha512-UfmU4uz4oAWll6yME/1Ehf+yJBM63ubC/NZGloS8+qcx2jMvsHjrOKwHutkROKwgiNSaFxRq+y9bcaES0ASFFw=="], + "@bevyl-ai/agent-tools": ["@bevyl-ai/agent-tools@0.14.2", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", "@openai/codex-sdk": "^0.153.4", "zod": "^4.4.3" } }, "sha512-lE/8XyL9LiCz2k2yBQs/zVcJYfYihFu3n7J2YnfqEi/edJgdKk5Lr4pLm1Bipm+PRlbdmV0Zd0P0wwdUXFQO2Q=="], + + "@hono/node-server": ["@hono/node-server@2.1.1", "", { "peerDependencies": { "hono": "^4" } }, "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="], + + "@openai/codex": ["@openai/codex@0.153.4", "", { "optionalDependencies": { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.153.4-darwin-arm64", "@openai/codex-darwin-x64": "npm:@openai/codex@0.153.4-darwin-x64", "@openai/codex-linux-arm64": "npm:@openai/codex@0.153.4-linux-arm64", "@openai/codex-linux-x64": "npm:@openai/codex@0.153.4-linux-x64", "@openai/codex-win32-arm64": "npm:@openai/codex@0.153.4-win32-arm64", "@openai/codex-win32-x64": "npm:@openai/codex@0.153.4-win32-x64" }, "bin": { "codex": "bin/codex.js" } }, "sha512-wbHDmit7S/YvBGVX1DQmk13xtWblZ2cApeJ/pB7xDZ10Cna+DZc5ij7f0F4OxdsXN4FW1oLT48OpogUI1+8Y2w=="], + + "@openai/codex-darwin-arm64": ["@openai/codex@0.153.4-darwin-arm64", "", { "os": "darwin", "cpu": "arm64" }, "sha512-B1qhN3fa1ay0R0wGziXqgwSkB5icpYChNKHhtBHff/0UtSTC7z+l8aTtvMlGjH3E8HEvY3+njIJelM9CAAoVWg=="], + + "@openai/codex-darwin-x64": ["@openai/codex@0.153.4-darwin-x64", "", { "os": "darwin", "cpu": "x64" }, "sha512-vnSbbPzfoDZmmyzsxswsDDXQ06IVFBzkQU7/hroB3ji93Ok2utcsq8Psfk2tjF5r9mEx8RWFJhzuTGHG26/NDA=="], + + "@openai/codex-linux-arm64": ["@openai/codex@0.153.4-linux-arm64", "", { "os": "linux", "cpu": "arm64" }, "sha512-QKdjYLYV4hXIuUQDP3P6F4NXuWFoKo9WUoV4nAREIx55kiUyi8UsYdsVobkeXir5n/maEQgYMCKLHVma4rNPiw=="], + + "@openai/codex-linux-x64": ["@openai/codex@0.153.4-linux-x64", "", { "os": "linux", "cpu": "x64" }, "sha512-x1EcwBlY3AObM1VTUHNM2AzAJQsyreGdagpF+qFiYi/Oa30VBktvvG0C6tLtCzqW6hjZNWkGZQWmeVk7MuJKWg=="], + + "@openai/codex-sdk": ["@openai/codex-sdk@0.153.4", "", { "dependencies": { "@openai/codex": "0.153.4" } }, "sha512-z0rN8WMQxwEHYHpDJyKHZwNNUtM/1pdnxmKopIJ3qMCZyyDestaIhSGDZ0v0RSjm+REujcFyNd95oWs78zWgRg=="], + + "@openai/codex-win32-arm64": ["@openai/codex@0.153.4-win32-arm64", "", { "os": "win32", "cpu": "arm64" }, "sha512-/FBh42976ltF1kxDoPQBg1Q6+hwChRU5/sm5dfeC8kFVQMvOCGoGeY5d8rRZGVJE8XojlXo74VQb0sHowcfgBw=="], + + "@openai/codex-win32-x64": ["@openai/codex@0.153.4-win32-x64", "", { "os": "win32", "cpu": "x64" }, "sha512-lMkB43kJZH0VFr+hoXc11qqR7QtQIbkr07ALgj4urKL1osNyUyuy1iXd3Vzz2iCYvBUCSw7I0l/W1cEPGx9euQ=="], "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.64.0", "", { "os": "android", "cpu": "arm" }, "sha512-o6uzh/jTOQeAY5TdkAeXdqv7MBRcPxiRA08zrcBtkKj5cSu/FMu0Hl7Q6Fi1KCKyCWZ6lJVjBzdsJvsKltUsGQ=="], @@ -140,18 +160,200 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.7.0", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.7", "", {}, "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "hono": ["hono@4.13.7", "", {}, "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.7.0", "", {}, "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jose": ["jose@6.2.12", "", {}, "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.1", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "oxfmt": ["oxfmt@0.64.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.64.0", "@oxfmt/binding-android-arm64": "0.64.0", "@oxfmt/binding-darwin-arm64": "0.64.0", "@oxfmt/binding-darwin-x64": "0.64.0", "@oxfmt/binding-freebsd-x64": "0.64.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.64.0", "@oxfmt/binding-linux-arm-musleabihf": "0.64.0", "@oxfmt/binding-linux-arm64-gnu": "0.64.0", "@oxfmt/binding-linux-arm64-musl": "0.64.0", "@oxfmt/binding-linux-ppc64-gnu": "0.64.0", "@oxfmt/binding-linux-riscv64-gnu": "0.64.0", "@oxfmt/binding-linux-riscv64-musl": "0.64.0", "@oxfmt/binding-linux-s390x-gnu": "0.64.0", "@oxfmt/binding-linux-x64-gnu": "0.64.0", "@oxfmt/binding-linux-x64-musl": "0.64.0", "@oxfmt/binding-openharmony-arm64": "0.64.0", "@oxfmt/binding-win32-arm64-msvc": "0.64.0", "@oxfmt/binding-win32-ia32-msvc": "0.64.0", "@oxfmt/binding-win32-x64-msvc": "0.64.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-XZ4GFBN/PLbXKq+0zrgpQfPKYuJlUuj+nzZJY7UpIbFMNyefNLCdN9EwViycNqnYcv0wrn0jXcQLlqJp8RCKBg=="], "oxlint": ["oxlint@1.79.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.79.0", "@oxlint/binding-android-arm64": "1.79.0", "@oxlint/binding-darwin-arm64": "1.79.0", "@oxlint/binding-darwin-x64": "1.79.0", "@oxlint/binding-freebsd-x64": "1.79.0", "@oxlint/binding-linux-arm-gnueabihf": "1.79.0", "@oxlint/binding-linux-arm-musleabihf": "1.79.0", "@oxlint/binding-linux-arm64-gnu": "1.79.0", "@oxlint/binding-linux-arm64-musl": "1.79.0", "@oxlint/binding-linux-ppc64-gnu": "1.79.0", "@oxlint/binding-linux-riscv64-gnu": "1.79.0", "@oxlint/binding-linux-riscv64-musl": "1.79.0", "@oxlint/binding-linux-s390x-gnu": "1.79.0", "@oxlint/binding-linux-x64-gnu": "1.79.0", "@oxlint/binding-linux-x64-musl": "1.79.0", "@oxlint/binding-openharmony-arm64": "1.79.0", "@oxlint/binding-win32-arm64-msvc": "1.79.0", "@oxlint/binding-win32-ia32-msvc": "1.79.0", "@oxlint/binding-win32-x64-msvc": "1.79.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-hVJ9hq9m2unPS+Of4eJJgCPdIeCC+3DHEUX3tkmrPJr3OK2hz7PhXwgC+ZP71ZcYu8cCDEtQrqLxWNvxBppBVg=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="], + + "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + + "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], } } diff --git a/package.json b/package.json index 35ce19c..0a123d5 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "check": "bun run typecheck && bun run lint && bun run fmt:check && bun run build" }, "dependencies": { - "@bevyl-ai/agent-tools": "0.13.0", + "@bevyl-ai/agent-tools": "0.14.2", "zod": "^4.4.3" }, "devDependencies": { diff --git a/src/review-sweep.ts b/src/review-sweep.ts index 3812fbf..d5f5ab0 100755 --- a/src/review-sweep.ts +++ b/src/review-sweep.ts @@ -1,19 +1,15 @@ #!/usr/bin/env bun -// stupify — one review sweep, top to bottom. A cron runs this file every minute on the reviewer box, with -// config.env beside it (DEPLOY.md). Every non-draft, non-bot open PR under DIFF_LINE_CAP is reviewed against the -// target repo's .review/ (REVIEW-PROMPT.md + RUBRIC.md + CORPUS.md), once per head: a posted review carries a -// hidden `` marker, and a push moves the sha. Each review is fed the PR's existing review -// thread, so it converges instead of repeating. + import { join } from 'node:path' import { acquireLock, releaseLock } from '@bevyl-ai/agent-tools' import { loadConfig, log, refreshRepo } from './sweep/config' -import { type PriorState, prReviews } from './sweep/github' import { runCandidatePool } from './sweep/pool' import { hasMachinery } from './sweep/prompt' import { inScope, listPrs } from './sweep/prs' -import { collectCandidates, loadSweepState } from './sweep/sweep' +import { sweepState } from './sweep/state' +import { collectCandidates } from './sweep/sweep' const cfg = loadConfig() const lockPath = join(cfg.stateDir, 'sweep.lock') @@ -22,14 +18,13 @@ if (!acquireLock(lockPath)) { process.exit(0) } process.on('exit', () => { - releaseLock(lockPath) // only if still ours: a later sweep that judged us crashed and stole it now owns it + releaseLock(lockPath) }) if (!refreshRepo(cfg)) { process.exit(1) } -// The target repo's own .review/ wins; otherwise the global taste under STUPIFY_HOME/.review. Select on the full -// three-file set, so a partial repo .review/ falls back instead of dead-ending at "no machinery". + const repoReview = join(cfg.repoDir, cfg.reviewDir) cfg.reviewDir = hasMachinery(repoReview) ? repoReview : cfg.homeReviewDir if (!hasMachinery(cfg.reviewDir)) { @@ -44,11 +39,7 @@ if (prs === null) { process.exit(1) } const queue = prs.filter((pr) => inScope(pr, cfg)) -const state = loadSweepState(cfg) -const priorByPr = new Map() -for (const pr of queue) { - priorByPr.set(pr.number, prReviews(cfg, pr)) -} -const candidates = collectCandidates(cfg, queue, priorByPr, state) +const state = sweepState(cfg) +const candidates = collectCandidates(cfg, queue, state) const reviewed = await runCandidatePool(cfg, candidates, state) log(`sweep done — scope=${cfg.scope} reviewed=${reviewed}`) diff --git a/src/sweep/codex.ts b/src/sweep/codex.ts index a77d307..1641923 100644 --- a/src/sweep/codex.ts +++ b/src/sweep/codex.ts @@ -1,14 +1,4 @@ -// Running Codex over one PR's diff through the kit's app-server session, and classifying the result. The verdict -// is a `review_verdict` TOOL CALL the kit validates against ReviewOutput mid-turn (a bad shape goes back to the -// model as the tool error); the model's text is never read. -import { - AppServerSession, - isQuotaWall, - isRateLimited, - maybeRotateGateway, - scrubSecrets, - tool, -} from '@bevyl-ai/agent-tools' +import { codexThread, isQuotaWall, isRateLimited, maybeRotateGateway, text } from '@bevyl-ai/agent-tools' import { SECOND_PASS_PROMPT } from '../hand-written-prompts' import { type Config, log, logRaw } from './config' @@ -17,23 +7,10 @@ import { reviewPrompt } from './prompt' import { type Pr } from './prs' import { parseReview, ReviewOutput, type ReviewVerdict } from './verdict' -/** The outcome of running Codex over one PR — classified but NOT acted on; review-pr.ts posts/converges from it. */ -export type ReviewOutcome = - | { kind: 'limit'; reason: string } // plan/credit exhaustion — the caller launches no more reviews this sweep - | { kind: 'fail'; reason: string } // Codex couldn't produce a review (down, timeout, stalled, never submitted) - | ReviewVerdict +export type ReviewOutcome = { kind: 'limit'; reason: string } | { kind: 'fail'; reason: string } | ReviewVerdict const TURN_TIMEOUT_MS = 1_200_000 -function callFailed(raw: string): ReviewOutcome { - const reason = raw.replaceAll('`', ' ').replaceAll(/\s+/g, ' ').trim().slice(0, 220) || 'codex turn failed' - // isQuotaWall covers a 502 'ChatGPT account unavailable' (dead login) — the pool must walk past it too. - if (isRateLimited(raw) || isQuotaWall(raw)) { - return { kind: 'limit', reason } - } - return { kind: 'fail', reason } -} - const nearest = (lines: Set, line: number): string => [...lines] .toSorted((a, b) => Math.abs(a - line) - Math.abs(b - line)) @@ -41,38 +18,6 @@ const nearest = (lines: Set, line: number): string => .toSorted((a, b) => a - b) .join(', ') -// What the schema can't say is thrown here so the MODEL corrects it, instead of the runner demoting the finding -// after the fact: the verdict is only accepted once the second pass is running (a first-turn call would skip -// the ownership challenge), an anchor must be a right-side line this diff touches (the only lines GitHub -// threads on), and a convergence verdict carries no findings (parseReview). -const verdictTool = (diff: string, secondPass: () => boolean, submit: (verdict: ReviewVerdict) => void) => { - const valid = diffRightLines(diff) - return tool( - 'review_verdict', - 'Submit the review verdict. Call once, after the second pass.', - ReviewOutput, - (data) => { - if (!secondPass()) { - throw new Error('not yet: finish the review, do the second pass when asked, then call review_verdict') - } - for (const f of data.findings) { - const lines = valid.get(f.path) - if (lines === undefined) { - throw new Error(`${f.path} is not in this diff`) - } - if (!lines.has(f.line)) { - throw new Error( - `${f.path}:${f.line} is not a line this diff touches; nearest touched lines: ${nearest(lines, f.line)}`, - ) - } - } - submit(parseReview(data)) - return Promise.resolve('noted') - }, - ) -} - -/** Run Codex over one PR's diff and classify the result. Does NO gh I/O and NO posting — the caller owns those. */ export async function runReview( cfg: Config, pr: Pr, @@ -80,60 +25,79 @@ export async function runReview( diff: string, workDir?: string, ): Promise { + const valid = diffRightLines(diff) const got: { verdict: ReviewVerdict | null } = { verdict: null } - const turns = [reviewPrompt(cfg, pr, priorThread, diff), SECOND_PASS_PROMPT] - const session = new AppServerSession( - { - cwd: workDir ?? cfg.repoDir, - title: `#${pr.number}`, - model: cfg.codexModel || undefined, - effort: cfg.codexEffort, - // A reviewer reads. The per-TURN policy is what codex enforces; the kit's turn default is full access, so - // the thread-level string alone would leave both attacker-controlled turns able to write and reach the network. - threadSandbox: 'read-only', - turnSandboxPolicy: { type: 'readOnly' }, - turnTimeoutMs: TURN_TIMEOUT_MS, - }, - [ - verdictTool( - diff, - () => turns.length === 0, // both prompts handed out → the second pass is the running turn - (verdict) => { - got.verdict = verdict - }, - ), - ], - (event) => { - if (event.log) { - logRaw(` codex: ${event.log}\n`) - } - }, - { - scrubEnv: scrubSecrets, - // Self-heal a quota wall: advance ~/.codex/config.toml to the next CODEX_GATEWAY_POOL account (the ring - // bunion and earshot rotate on too). Codex re-reads the file per session, so the next review lands on it. - // The kit walks the ring only on a real wall, never a transient 429. - onTurnError: (error) => { - const rot = maybeRotateGateway({ - reason: String(error), - pool: cfg.gatewayPool, - cooldownMs: cfg.rotateCooldownMs, - }) - if (rot.rotated) { - log(` codex gateway rotated: ${rot.from} → ${rot.to}`) - } - }, - }, - ) + let session: Awaited> | null = null try { - await session.runTurns(() => turns.shift() ?? null) + session = await codexThread({ + workingDirectory: workDir ?? cfg.repoDir, + codexPath: cfg.codexPath, + ...(cfg.codexModel ? { model: cfg.codexModel } : {}), + modelReasoningEffort: cfg.codexEffort, + tools: (server) => + server.registerTool( + 'review_verdict', + { + description: 'Submit your verdict. You may call it again to revise; the last call wins.', + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, + inputSchema: ReviewOutput.shape, + }, + (data) => { + got.verdict = null + for (const f of data.findings) { + const lines = valid.get(f.path) + if (lines === undefined) { + throw new Error(`${f.path} is not in this diff`) + } + if (!lines.has(f.line)) { + throw new Error( + `${f.path}:${f.line} is not a line this diff touches; nearest touched lines: ${nearest(lines, f.line)}`, + ) + } + } + got.verdict = parseReview(data) + return Promise.resolve(text('noted')) + }, + ), + }) + const { thread } = session + const prompts = [ + reviewPrompt(cfg, pr, priorThread, diff), + `${SECOND_PASS_PROMPT}\n\nIf that changes your verdict, call review_verdict again. Otherwise you are done.`, + ] + for (let turn = 1; turn <= cfg.maxTurns; turn++) { + const prompt = + prompts[turn - 1] ?? + `Continuation, turn ${turn} of ${cfg.maxTurns}, same thread. Resume from where you left off; finish by calling review_verdict.` + // oxlint-disable-next-line no-await-in-loop -- turns are sequential on one thread by definition + const { items, usage } = await thread.run(prompt, { signal: AbortSignal.timeout(TURN_TIMEOUT_MS) }) + for (const item of items) { + if (item.type === 'mcp_tool_call') { + logRaw(` codex: ⚙ ${item.tool}${item.error ? ` — ${item.error.message}` : ''}\n`) + } + } + const last = items.findLast((item) => item.type === 'mcp_tool_call' && item.tool === 'review_verdict') + if (last?.type === 'mcp_tool_call' && last.error) { + got.verdict = null + } + if (usage) { + logRaw(` codex: turn ${turn} — ${usage.input_tokens + usage.output_tokens} tokens\n`) + } + if (turn >= 2 && got.verdict !== null) { + break + } + } } catch (error) { - // The kit spawns codex in start() before runTurns' own try/finally, so a failed handshake would leave the - // child alive under a minute cron. Delete this once the kit's start() stops the process it spawned on failure. - session.stop() const raw = error instanceof Error ? error.message : String(error) logRaw(`${raw}\n`) - return callFailed(raw) + const rot = maybeRotateGateway({ reason: raw, pool: cfg.gatewayPool, cooldownMs: cfg.rotateCooldownMs }) + if (rot.rotated) { + log(` codex gateway rotated: ${rot.from} → ${rot.to}`) + } + const reason = raw.replaceAll('`', ' ').replaceAll(/\s+/g, ' ').trim().slice(0, 220) || 'codex turn failed' + return isRateLimited(raw) || isQuotaWall(raw) ? { kind: 'limit', reason } : { kind: 'fail', reason } + } finally { + session?.close() } - return got.verdict ?? { kind: 'fail', reason: 'codex finished without calling review_verdict' } + return got.verdict ?? { kind: 'fail', reason: `no verdict after ${cfg.maxTurns} turns` } } diff --git a/src/sweep/config.ts b/src/sweep/config.ts index 4ad02f0..e0b28a6 100644 --- a/src/sweep/config.ts +++ b/src/sweep/config.ts @@ -1,6 +1,3 @@ -// Sweep configuration: every knob lives in config.env next to the engine bundle (read fresh each run), and a -// one-shot env override wins over the file. Also owns the sweep log, set up before knob parsing so config -// warnings reach sweep.log, not just cron.log. import { appendFileSync, existsSync, mkdirSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -8,30 +5,30 @@ import { fileURLToPath } from 'node:url' import { parseEnvFile, refreshCheckout } from '@bevyl-ai/agent-tools' import { z } from 'zod' -// The deployed engine is a single-file bun bundle, so import.meta.url collapses to the bundle's own location -// (~/.stupify) no matter which source module evaluates it — config.env sits next to the bundle. const KIT_DIR = dirname(fileURLToPath(import.meta.url)) export const Scope = z.enum(['label', 'auto']) export type Scope = z.infer export const Config = z.object({ - repoDir: z.string(), // dedicated checkout we hard-reset — never a working checkout you care about + repoDir: z.string(), slug: z.string(), defaultBranch: z.string(), - reviewDir: z.string(), // resolved later to an absolute path (repo .review/ or homeReviewDir) - homeReviewDir: z.string(), // fallback global taste under STUPIFY_HOME/.review + reviewDir: z.string(), + homeReviewDir: z.string(), scope: Scope, reviewLabel: z.string(), diffLineCap: z.number(), dryRun: z.boolean(), maxPrs: z.number(), + maxTurns: z.number(), maxReviewsPerDay: z.number(), failRetryMs: z.number(), stateDir: z.string(), - codexEffort: z.string(), - codexModel: z.string(), // optional `-c model=...`; empty = codex's default - gatewayPool: z.array(z.string()), // ordered exe-llm gateway hosts to rotate through on a quota wall; empty = off + codexEffort: z.enum(['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra', 'persistent']), + codexModel: z.string(), + codexPath: z.string(), + gatewayPool: z.array(z.string()), rotateCooldownMs: z.number(), codexJobs: z.number(), }) @@ -47,7 +44,6 @@ export function log(message: string): void { console.log(line) } -/** Append raw text (codex transcripts, gh error excerpts) to the sweep log WITHOUT a timestamp or stdout echo. */ export function logRaw(text: string): void { if (LOG.path) { appendFileSync(LOG.path, text) @@ -56,8 +52,7 @@ export function logRaw(text: string): void { export function loadConfig(): Config { const file = parseEnvFile(join(KIT_DIR, 'config.env')) - // A one-shot env override wins over the persisted config.env, so `DRY_RUN=1 bun review-sweep.ts` actually - // previews even when the deployed file says DRY_RUN=0. Cron sets none of these keys, so it falls to the file. + const pick = (key: string, fallback: string): string => process.env[key] ?? file[key] ?? fallback const int = (key: string, fallback: number, min: number): number => { const set = process.env[key] ?? file[key] @@ -88,11 +83,10 @@ export function loadConfig(): Config { return onInvalid } - // Home is where deploy/push.sh put us (~/.stupify) — config.env, state, and the dedicated checkout all live here. const stupifyHome = pick('STUPIFY_HOME', KIT_DIR) const stateDir = join(stupifyHome, 'state') mkdirSync(stateDir, { recursive: true }) - LOG.path = join(stateDir, 'sweep.log') // set before parsing knobs so config warnings reach sweep.log, not just cron.log + LOG.path = join(stateDir, 'sweep.log') const slug = pick('REPO_SLUG', '').trim() if (!slug) { @@ -105,27 +99,29 @@ export function loadConfig(): Config { } return Config.parse({ - repoDir: join(stupifyHome, 'repo'), // HARD-PINNED under STUPIFY_HOME: refreshRepo runs `git reset --hard` here + repoDir: join(stupifyHome, 'repo'), slug, defaultBranch: pick('DEFAULT_BRANCH', 'main'), - reviewDir: pick('REVIEW_DIR', '.review'), // relative name here; main() resolves it to an absolute path (repo's or home's) + reviewDir: pick('REVIEW_DIR', '.review'), homeReviewDir: join(stupifyHome, '.review'), - scope: scopeRaw === 'label' ? 'label' : 'auto', // auto is the default; only the explicit string 'label' opts into per-PR tagging + scope: scopeRaw === 'label' ? 'label' : 'auto', reviewLabel: pick('REVIEW_LABEL', 'codex-review'), - diffLineCap: int('DIFF_LINE_CAP', 20_000, 1), // generous by design — only skips genuinely huge PRs; override via config.env - dryRun: bool('DRY_RUN', false, true), // unset = live (cron's normal mode); garbage = preview (never post on a typo) + diffLineCap: int('DIFF_LINE_CAP', 20_000, 1), + dryRun: bool('DRY_RUN', false, true), maxPrs: int('MAX_PRS', 15, 1), - maxReviewsPerDay: int('MAX_REVIEWS_PER_DAY', 0, 0), // daily cap; 0 = OFF (default). Per-head dedup + MAX_PRS/sweep + the rate-limit early-exit already bound spend; set a number for a hard daily ceiling. - failRetryMs: int('FAIL_RETRY_MIN', 60, 1) * 60_000, // after a failed review, don't re-attempt that head for this long + maxTurns: int('MAX_TURNS', 6, 2), + maxReviewsPerDay: int('MAX_REVIEWS_PER_DAY', 0, 0), + failRetryMs: int('FAIL_RETRY_MIN', 60, 1) * 60_000, stateDir, codexEffort: pick('CODEX_EFFORT', 'high'), codexModel: pick('CODEX_MODEL', ''), + codexPath: pick('CODEX_PATH', Bun.which('codex') ?? 'codex'), gatewayPool: pick('CODEX_GATEWAY_POOL', '') .split(',') .map((h) => h.trim()) .filter(Boolean), rotateCooldownMs: int('CODEX_ROTATE_COOLDOWN_MIN', 10, 0) * 60_000, - codexJobs: int('CODEX_JOBS', 3, 1), // a review session takes minutes; a small pool keeps a busy sweep from serializing them + codexJobs: int('CODEX_JOBS', 3, 1), }) } @@ -134,7 +130,6 @@ function logFail(message: string): false { return false } -/** Refresh the dedicated checkout to origin/main. Returns false on any git failure. */ export function refreshRepo(cfg: Config): boolean { const existed = existsSync(join(cfg.repoDir, '.git')) const ok = refreshCheckout({ repoDir: cfg.repoDir, slug: cfg.slug, defaultBranch: cfg.defaultBranch, log }) diff --git a/src/sweep/diff.ts b/src/sweep/diff.ts index 9935842..c0d3cf9 100644 --- a/src/sweep/diff.ts +++ b/src/sweep/diff.ts @@ -1,26 +1,15 @@ -// Fetching and measuring PR diffs. The RUNNER fetches the diff (not codex) so codex needs no network or gh — -// it reviews the diff straight from the prompt, sandboxed. Stacked PRs diff base..head via the compare API, not main. import { exec } from '@bevyl-ai/agent-tools' import { type Config } from './config' import { type Pr } from './prs' -// GitHub's diff endpoint 406s past EITHER of these, so a big enough PR can't even be MEASURED. They are GitHub's -// limits, not ours — DIFF_LINE_CAP can be set above the line one but never reached. export const GH_DIFF_LIMITS = '20000-line / 300-file' -/** A compare/diff failure that is GitHub's size refusal rather than a transient error — retrying can never fix it. - * Matches on gh's stable `too_large` code first: the prose differs per limit (lines vs files) and can be reworded, - * but both variants carry the code. Missing one variant is what kept #8338/#8241 looping after the first fix. */ export const isDiffTooLarge = (output: string): boolean => /PullRequest\.diff too_large|diff exceeded the maximum number of (?:lines|files)/i.test(output) -// Never treat a failed read as "0 lines" (a silent under-cap that would auto-review something it never -// measured) — and keep the two failure modes apart: 'unreadable' is transient and worth retrying, but -// 'too-large' is terminal, and conflating them is what left oversized PRs re-fetched every 60s forever. type DiffRead = { ok: true; diff: string } | { ok: false; reason: 'unreadable' | 'too-large' } -/** Diff the PR's head against its base (not defaultBranch) — correct for stacked PRs. */ export function getDiff(cfg: Config, pr: Pick): DiffRead { const r = exec('gh', [ 'api', @@ -37,17 +26,13 @@ export function getDiff(cfg: Config, pr: Pick): export const diffLineCount = (diff: string): number => diff ? diff.split('\n').length - (diff.endsWith('\n') ? 1 : 0) : 0 -// Which RIGHT-side (new-file) line numbers a unified diff actually touches, per path — the only lines GitHub lets -// you anchor an inline review comment to. Added (`+`) and context (` `) lines are anchorable; removed (`-`) lines -// are LEFT-only and don't advance the right counter. A finding on a line NOT in here can't be a thread, so the -// runner demotes it into the review body instead of 422-ing the whole review. export function diffRightLines(diff: string): Map> { const byPath = new Map>() const cur = { path: '', right: 0, inHunk: false } for (const line of diff.split('\n')) { if (line.startsWith('+++ ')) { const p = line.slice(4).trim() - cur.path = p.startsWith('b/') ? p.slice(2) : p // b/, or /dev/null for a deletion (no right lines) + cur.path = p.startsWith('b/') ? p.slice(2) : p if (!byPath.has(cur.path)) { byPath.set(cur.path, new Set()) } @@ -65,7 +50,7 @@ export function diffRightLines(diff: string): Map> { } if (line.startsWith('-') || line.startsWith('\\')) { continue - } // left-only line / "no newline" marker — right doesn't advance + } if (line.startsWith('+') || line.startsWith(' ')) { byPath.get(cur.path)?.add(cur.right) cur.right++ diff --git a/src/sweep/github.ts b/src/sweep/github.ts index 68794e8..a999998 100644 --- a/src/sweep/github.ts +++ b/src/sweep/github.ts @@ -1,5 +1,3 @@ -// Posting to GitHub and reading back what stupify has already said. Findings land as ONE COMMENT review with -// inline threads; the reviews/threads connection drives dedup, thread-resolution, and the reviewer's memory. import { exec } from '@bevyl-ai/agent-tools' import { z } from 'zod' @@ -7,16 +5,10 @@ import { type Config, logRaw } from './config' import { type Comment, type Pr, priorReviewThread } from './prs' import { markFor, type ParsedFinding } from './verdict' -// A hidden tag stamped in every inline finding comment, so a later sweep can find stupify's OWN review threads -// (to resolve them) without knowing the bot login — `gh api user` 403s for GitHub-App integrations, so we identify -// our content by marker, not author (same trick as the head marker). const STUPIFY_TAG = '' -// Non-blocking findings carry a tag that does NOT contain STUPIFY_TAG as a substring, so they never -// land in openThreadIds and don't hold the ✅. const STUPIFY_NOTE_TAG = '' -// One non-blocking COMMENT review: `comments` are inline, each anchored to a diff line (a resolvable thread). function submitReview( cfg: Config, pr: Pr, @@ -29,9 +21,6 @@ function submitReview( }) } -// Post findings as ONE COMMENT review: each finding becomes an inline comment anchored to its diff line (a -// resolvable thread); the body carries the opener + the head marker (dedup). Anchors were checked against the -// diff when the verdict was submitted (codex.ts), so every finding goes inline. export function postReview(cfg: Config, pr: Pr, opener: string, findings: ParsedFinding[]): boolean { const inline = findings.map((f) => ({ path: f.path, @@ -44,22 +33,16 @@ export function postReview(cfg: Config, pr: Pr, opener: string, findings: Parsed if (r.ok) { return true } - // GitHub rejects the WHOLE review if any single inline anchor is a line it won't accept (a diff edge - // diffRightLines didn't catch). Don't lose the findings to one bad line: retry body-only so they still land - // (visible, just not inline) instead of failing — and re-failing — every sweep. + logRaw(` postReview #${pr.number} inline rejected, body-only fallback: ${r.combined.slice(0, 200)}\n`) return submitReview(cfg, pr, [head, ...findings.map((f) => f.body), markFor(pr)].filter(Boolean).join('\n\n'), []).ok } -// A bodied-only COMMENT review (no inline comments) — for the one-time `LGTM ✅` on a clean first pass, or to carry -// a review codex wrote without parseable per-line findings. Body still ends with the head marker for dedup. export function postNote(cfg: Config, pr: Pr, note: string): boolean { return submitReview(cfg, pr, `${note}\n\n${markFor(pr)}`, []).ok } -// Resolve stupify's open threads when its findings are fixed — the native "this is handled" signal. export function resolveThreads(threadIds: string[]): boolean { - // Resolve every thread even if one fails — a partial resolve still leaves work for the next sweep. return threadIds .map( (id) => @@ -73,14 +56,11 @@ export function resolveThreads(threadIds: string[]): boolean { .every(Boolean) } -// What stupify has already said on a PR — read from the REVIEWS/THREADS connection (findings are inline threads now, -// not issue comments). Drives dedup (a review body carries the head marker), firstReview, thread-resolution, and the -// memory fed back to codex. gh's GraphQL shape is trusted; navigate leniently and default on anything missing. export interface PriorState { - memory: string // prior findings + the author's replies, fenced for codex (priorReviewThread output) - reviewedHead: boolean // a stupify review for THIS head exists — durable dedup, survives VM recreation - everReviewed: boolean // stupify has reviewed this PR at all → firstReview = !everReviewed - openThreadIds: string[] // stupify's UNRESOLVED threads — resolve these when the findings are fixed + memory: string + reviewedHead: boolean + everReviewed: boolean + openThreadIds: string[] } const GqlAuthor = z.object({ login: z.string().optional() }).nullable() const GqlComment = z.object({ diff --git a/src/sweep/pool.ts b/src/sweep/pool.ts index 86879e2..27cf2fd 100644 --- a/src/sweep/pool.ts +++ b/src/sweep/pool.ts @@ -1,12 +1,8 @@ -// The review pool: up to CODEX_JOBS candidates in flight at once. Workers share a cursor; a quota `limit` from -// any worker stops NEW launches (the rest would fail the same way) while in-flight runs drain. All the -// shared-state mutation happens between awaits on the one JS thread, so it needs no locks. import { type Config, log } from './config' import { reviewPr } from './review-pr' -import { bumpDailyCounter, dailyPath, failuresPath, recordHeadAttempt, recordReviewedHead, reviewedPath } from './state' -import { type Candidate, type SweepState } from './sweep' +import { type SweepState } from './state' +import { type Candidate } from './sweep' -/** Review the candidates; returns how many reviews were posted. */ export async function runCandidatePool(cfg: Config, candidates: Candidate[], state: SweepState): Promise { let reviewed = 0 let next = 0 @@ -18,10 +14,9 @@ export async function runCandidatePool(cfg: Config, candidates: Candidate[], sta return } // oxlint-disable-next-line no-await-in-loop -- each worker awaits serially BY DESIGN; the parallelism is across workers - const used = await reviewPr(cfg, c.pr, c.prior.memory, c.diff, c.firstReview, c.prior.openThreadIds) + const used = await reviewPr(cfg, c.pr, c.prior, c.diff) if (used === 'limit' || used === null) { - // Logged, not posted — throttle this head until the window lapses or the head moves. - recordHeadAttempt(failuresPath(cfg), state.failures, String(c.pr.number), c.pr.headRefOid) + state.failed(c.pr) if (used === 'limit') { limitHit = true log( @@ -30,11 +25,7 @@ export async function runCandidatePool(cfg: Config, candidates: Candidate[], sta } continue } - // codex reached a verdict (findings posted, or a no-op). Record this head so the next sweep doesn't re-run - // codex on it — a SUPPRESSED no-op posts no marker, so local state is what catches it. A no-op still spent - // the tokens, so it counts toward the daily ceiling either way. - recordReviewedHead(reviewedPath(cfg), state.reviewedLocal, String(c.pr.number), c.pr.headRefOid) - bumpDailyCounter(dailyPath(cfg), state.daily) + state.reviewedHead(c.pr) if (typeof used === 'object') { reviewed += 1 } diff --git a/src/sweep/prompt.ts b/src/sweep/prompt.ts index dd4590d..b1f0b13 100644 --- a/src/sweep/prompt.ts +++ b/src/sweep/prompt.ts @@ -1,4 +1,3 @@ -// Prompt construction: point at taste files, then the per-PR tail (intent, prior thread, diff). import { existsSync } from 'node:fs' import { join } from 'node:path' @@ -35,7 +34,7 @@ ${corpus} # This PR Review this pull request against the spec and rubric. - Catch bugs, type-lies, dead code, footguns, and slop. Reuse corpus primitives; don't add LOC. -- Submit the verdict by calling \`review_verdict\` once, after the second pass. Your text is not read. +- Submit the verdict by calling \`review_verdict\`; a second pass follows, and you may call it again to revise. Your text is not read. - \`fixed\`: prior issues resolved, nothing new (runner posts \`${FIXED_NOTE}\`). - \`no_new_issues\`: clean, or prior issues still open (runner posts \`${STILL_NOTE}\` if clean). - \`findings\`: exact path/line for each inline comment, on a line this diff touches.${intent}${memory} @@ -44,7 +43,5 @@ Review this pull request against the spec and rubric. ${diff}` } -// Resolve a `.review/` that has the full taste set (spec + rubric + corpus). A partial dir (e.g. CORPUS without -// the spec) reads as absent so the sweep falls back cleanly. export const hasMachinery = (dir: string): boolean => existsSync(join(dir, 'CORPUS.md')) && existsSync(join(dir, 'REVIEW-PROMPT.md')) && existsSync(join(dir, 'RUBRIC.md')) diff --git a/src/sweep/prs.ts b/src/sweep/prs.ts index c3095d8..116b6d6 100644 --- a/src/sweep/prs.ts +++ b/src/sweep/prs.ts @@ -1,29 +1,21 @@ -// Listing and scoping open PRs (the gh pr list boundary), plus the per-PR MEMORY: the existing review -// conversation read back and defanged so it can be fed to codex as untrusted data. import { exec } from '@bevyl-ai/agent-tools' import { z } from 'zod' import { type Config, log } from './config' -// The gh pr list --json boundary. z.object (not strictObject) STRIPS extra keys gh adds. A non-array or -// unshaped entry throws — no silent skip. export const Pr = z.object({ number: z.number(), headRefOid: z.string(), baseRefOid: z.string(), baseRefName: z.string(), isDraft: z.boolean(), - author: z.object({ login: z.string(), is_bot: z.boolean() }).nullable(), // is_bot flags GitHub App bots (app/dependabot) the [bot] suffix misses + author: z.object({ login: z.string(), is_bot: z.boolean() }).nullable(), labels: z.array(z.object({ name: z.string() })), - title: z.string(), // title + body carry the author's STATED INTENT — fed (untrusted) into the prompt so the reviewer can weigh "I did this on purpose, here's why" instead of flagging a deliberate call as a mistake - body: z.string(), // gh returns "" for an empty description, never absent + title: z.string(), + body: z.string(), }) export type Pr = z.infer -// gh's default --limit is 30, NEWEST-first — on a repo with more open PRs than that, the older ones silently -// fall off the sweep's radar entirely: never re-reviewed, no log line, no skip status. (Observed on a 129-open-PR -// repo: a PR at list position 57 got fresh pushes for two days and the sweep never saw them.) 500 keeps the sweep -// exhaustive on any realistically-sized backlog; per-head dedup keeps the extra listings cheap. const PR_LIST_LIMIT = 500 const ListedPr = Pr.omit({ baseRefOid: true }) @@ -31,11 +23,6 @@ const RestPull = z.object({ number: z.number(), base: z.object({ sha: z.string() const REST_PAGE = 100 -// `gh pr list --json` on Ubuntu's 2.45 gh has headRefOid but not baseRefOid — unknown field → empty -// stdout, which the sweep used to log as "auth/network down". Pull base SHAs from REST instead. -// Page by hand, NOT `--paginate`: gh follows GitHub's Link header verbatim, and that header names -// api.github.com — so behind an exe.dev GH_HOST proxy, page 2 escapes the proxy, goes out unauthenticated, -// and 404s the whole call. Only a repo with >100 open PRs ever has a page 2 (bevyl, 5 days of dead sweeps). function pullBaseOids(slug: string): Map | null { const bases = new Map() for (let page = 1; ; page++) { @@ -55,7 +42,6 @@ function pullBaseOids(slug: string): Map | null { } export function listPrs(cfg: Config): Pr[] | null { - // Filter the PR list directly rather than `gh pr list --label` — that search index lags behind labelling. const fields = 'number,headRefOid,baseRefName,isDraft,author,labels,title,body' const r = exec('gh', [ 'pr', @@ -78,15 +64,13 @@ export function listPrs(cfg: Config): Pr[] | null { if (bases === null) { return null } - const out: Pr[] = [] - for (const pr of listed) { + return listed.map((pr) => { const baseRefOid = bases.get(pr.number) if (!baseRefOid) { throw new Error(`open PR #${pr.number} missing from REST pulls list`) } - out.push({ ...pr, baseRefOid }) - } - return out + return Object.assign(pr, { baseRefOid }) + }) } export function hasReviewLabel(pr: Pr, cfg: Config): boolean { @@ -97,17 +81,14 @@ export function inScope(pr: Pr, cfg: Config): boolean { if (pr.isDraft) { return false } - // Never review bot PRs, in EITHER scope — UNLESS the PR carries REVIEW_LABEL, the explicit force-include: a - // bot-authored PR you deliberately label is opted in (e.g. a factory that authors PRs as a GitHub App and wants - // them reviewed). gh's is_bot catches GitHub App bots (login `app/dependabot`) that the `[bot]` suffix misses; - // keep the suffix check as a belt-and-suspenders fallback. + if ((pr.author?.is_bot === true || (pr.author?.login ?? '').endsWith('[bot]')) && !hasReviewLabel(pr, cfg)) { return false } if (cfg.scope === 'label') { return hasReviewLabel(pr, cfg) } - return true // auto: any non-draft, non-bot PR + return true } export interface Comment { @@ -115,30 +96,23 @@ export interface Comment { body: string } -// The per-PR MEMORY: the existing review conversation — the reviewer's past reviews + the author's replies — -// fed back into the prompt so it stops re-litigating settled points and knows when to converge. The GitHub -// thread IS the durable store (survives restarts, already holds the replies); we just read it back. -const MEMORY_COMMENTS = 20 // recent thread context, bounded so the prompt can't balloon on a chatty PR -const MEMORY_BYTE_CAP = 16_000 // hard backstop: even 20 essays can't blow the prompt (and cached prefix) past this +const MEMORY_COMMENTS = 20 +const MEMORY_BYTE_CAP = 16_000 -// The thread is UNTRUSTED PR-comment content that gets inlined inside a fence. Strip hidden -// markers AND neutralize any literal fence tag in the body, so a comment can't CLOSE the fence early and smuggle -// instructions in as if they were the runner's. This is the HARD boundary; the prompt's SECURITY note is the soft -// one — relying on the model to be obedient is not a security control. export function defang(body: string): string { return body - .replaceAll(//g, '') // hidden markers (incl. our own stupify: markers) - .replaceAll(/<(?\/?)\s*(?prior_reviews|pr_description|dismissed)\s*>/gi, '‹$$›') // can't break out of any untrusted fence + .replaceAll(//g, '') + .replaceAll(/<(?\/?)\s*(?prior_reviews|pr_description|dismissed)\s*>/gi, '‹$$›') .trim() } export function priorReviewThread(comments: Comment[]): string { const thread = comments - .filter((c) => !c.login.endsWith('[bot]')) // drop CI bots; keep prior reviews + human/agent replies + .filter((c) => !c.login.endsWith('[bot]')) .slice(-MEMORY_COMMENTS) .map((c) => ({ login: c.login, body: defang(c.body) })) .filter((c) => c.body.length > 0) .map((c) => `@${c.login}:\n${c.body}`) .join('\n\n---\n\n') - return thread.length > MEMORY_BYTE_CAP ? thread.slice(-MEMORY_BYTE_CAP) : thread // keep the most recent context + return thread.length > MEMORY_BYTE_CAP ? thread.slice(-MEMORY_BYTE_CAP) : thread } diff --git a/src/sweep/review-pr.ts b/src/sweep/review-pr.ts index 2b14688..1ef1634 100644 --- a/src/sweep/review-pr.ts +++ b/src/sweep/review-pr.ts @@ -1,30 +1,13 @@ -// Acting on one sweep review: post findings as an inline-threaded COMMENT review, resolve stupify's open -// threads when its findings are fixed, post the convergence notes, or stay silent while findings stand. import { runReview } from './codex' import { type Config, log } from './config' -import { postNote, postReview, resolveThreads } from './github' +import { postNote, postReview, type PriorState, resolveThreads } from './github' import { type Pr } from './prs' import { FIXED_NOTE, STILL_NOTE } from './verdict' import { prepareHeadWorktree, removeHeadWorktree } from './worktree' -// A posted review carries its blocking-finding count — zero blocking reads as a green status. export type SweepReviewResult = { blocking: number } | 'limit' | 'clean' | 'fixed' | 'open' | null -/** Run one SWEEP review and act on it: post findings as an inline-threaded COMMENT review, RESOLVE stupify's open - * threads when its findings are fixed, post a one-time `LGTM ✅` review on a genuine first-pass clean, post a - * one-line `still ✅` on a clean head with nothing outstanding, or stay SILENT while prior findings remain open. - * Returns {tokens, blocking} on a posted review, 'clean' on a clean outcome, 'open' when prior findings remain unresolved, - * 'fixed' when it resolved prior findings, 'limit' on exhaustion, or null on a failure the caller throttles. - * Every ✅ that posts is honest: it only fires when no stupify finding is open — "nothing new while findings - * still stand" stays silent (those threads remain open); a fix resolves the threads and posts a visible note. */ -export async function reviewPr( - cfg: Config, - pr: Pr, - priorThread: string, - diff: string, - firstReview: boolean, - openThreadIds: string[], -): Promise { +export async function reviewPr(cfg: Config, pr: Pr, prior: PriorState, diff: string): Promise { log(`reviewing PR #${pr.number} @ ${pr.headRefOid.slice(0, 8)} (base ${pr.baseRefName})`) const workDir = prepareHeadWorktree(cfg.repoDir, pr) if (workDir === null) { @@ -33,7 +16,7 @@ export async function reviewPr( } let r try { - r = await runReview(cfg, pr, priorThread, diff, workDir) + r = await runReview(cfg, pr, prior.memory, diff, workDir) } finally { removeHeadWorktree(cfg.repoDir, pr) } @@ -41,49 +24,16 @@ export async function reviewPr( log(` review FAILED for #${pr.number} — ${r.reason}`) return r.kind === 'limit' ? 'limit' : null } - if (r.kind === 'no_new_issues') { - // Clean. A one-time LGTM on a PR stupify has never flagged (so "reviewed + good" is visible). On a PR it HAS - // reviewed: while its own findings are still open, a clean head stays silent (the open threads already say it - // all, and a fresh non-✅ note would fight a reasoned inline pushback) — but with NOTHING outstanding it posts - // the one-line marker-bearing re-approval, so the new head never reads as "unreviewed" to per-head consumers. - if (!firstReview) { - if (openThreadIds.length > 0) { - log(` #${pr.number} nothing new, prior findings still open — staying silent`) - return 'open' - } - if (!postNote(cfg, pr, STILL_NOTE)) { - log(` couldn't post #${pr.number} ${STILL_NOTE} (gh down?) — will retry next sweep`) - return null - } - log(` #${pr.number} nothing new — posted ${STILL_NOTE} for this head`) - return 'clean' - } - if (!postNote(cfg, pr, 'LGTM ✅')) { - log(` couldn't post #${pr.number} LGTM (gh down?) — will retry next sweep`) + const note = (text: string, why: string): SweepReviewResult => { + if (!postNote(cfg, pr, text)) { + log(` couldn't post #${pr.number} ${text} (gh down?) — will retry next sweep`) return null } - log(` #${pr.number} clean first pass — posted LGTM ✅`) + log(` #${pr.number} ${why} — posted ${text}`) return 'clean' } - // Prior findings resolved → resolve the open threads, then post the visible fixed note with the head marker. - // Keep that order: a marker before resolution could make a later sweep skip a still-open thread. Gated on - // actually having open stupify threads, so a stray fixed-signal can't manufacture approval. - if (r.kind === 'fixed') { - if (openThreadIds.length === 0) { - // Nothing left to resolve. On a PR stupify never flagged, a stray fixed-signal must stay silent — it can't - // manufacture an approval. On a PR it HAS reviewed (threads already resolved on an earlier pass), this is - // just "clean at a new head": post the marker-bearing re-approval, same as the no-op path above. - if (firstReview) { - log(` #${pr.number} fixed-signal but never flagged — staying silent`) - return 'clean' - } - if (!postNote(cfg, pr, STILL_NOTE)) { - log(` couldn't post #${pr.number} ${STILL_NOTE} (gh down?) — will retry next sweep`) - return null - } - log(` #${pr.number} prior findings already resolved — posted ${STILL_NOTE} for this head`) - return 'clean' - } + const { openThreadIds } = prior + if (r.kind === 'fixed' && openThreadIds.length > 0) { if (!resolveThreads(openThreadIds)) { log(` couldn't resolve #${pr.number} fixed thread(s) (gh down?) — will retry next sweep`) return null @@ -94,7 +44,20 @@ export async function reviewPr( log(` #${pr.number} prior findings resolved — posted ${FIXED_NOTE}; resolved ${openThreadIds.length} thread(s)`) return 'fixed' } - // A real review: post the validated findings as inline, resolvable threads. (parseReview guarantees ≥1 finding.) + if (r.kind !== 'findings') { + if (!prior.everReviewed) { + if (r.kind === 'fixed') { + log(` #${pr.number} fixed-signal but never flagged — staying silent`) + return 'clean' + } + return note('LGTM ✅', 'clean first pass') + } + if (openThreadIds.length > 0) { + log(` #${pr.number} nothing new, prior findings still open — staying silent`) + return 'open' + } + return note(STILL_NOTE, 'nothing new') + } if (!postReview(cfg, pr, r.opener, r.findings)) { log(` couldn't post #${pr.number} review (gh down?) — next sweep retries`) return null diff --git a/src/sweep/state.ts b/src/sweep/state.ts index 0c2afe5..0e1532a 100644 --- a/src/sweep/state.ts +++ b/src/sweep/state.ts @@ -1,88 +1,66 @@ -// Per-box sweep state: tiny best-effort JSON files (a parse error or a fresh box just re-attempts once). -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' import { z } from 'zod' import { type Config } from './config' +import { type Pr } from './prs' -export const HeadAttempt = z.strictObject({ head: z.string(), at: z.number() }) -export type HeadAttempt = z.infer - -export const DailyCounter = z.strictObject({ date: z.string(), count: z.number() }) -export type DailyCounter = z.infer - -// Whole-file parse-or-{}: these are best-effort caches the sweep itself wrote, so one corrupt entry means the -// file is suspect — re-attempting everything once is the documented failure mode anyway. -const HeadAttempts = z.record(z.string(), HeadAttempt) - -export function loadHeadAttempts(path: string): Record { - try { - return HeadAttempts.parse(JSON.parse(readFileSync(path, 'utf8'))) - } catch { - return {} - } -} - -export function recordHeadAttempt( - path: string, - attempts: Record, - key: string, - head: string, - at = Date.now(), -): void { - attempts[key] = { head, at } - try { - mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, JSON.stringify(attempts)) - } catch { - /* best-effort */ - } -} - +const HeadAttempts = z.record(z.string(), z.strictObject({ head: z.string(), at: z.number() })) const ReviewedHeads = z.record(z.string(), z.string()) +const DailyCounter = z.strictObject({ date: z.string(), count: z.number() }) -export function loadReviewedHeads(path: string): Record { +function save(path: string, value: unknown): void { try { - return ReviewedHeads.parse(JSON.parse(readFileSync(path, 'utf8'))) - } catch { - return {} + writeFileSync(path, JSON.stringify(value)) + } catch (error) { + console.error(`couldn't write ${path} — ${error instanceof Error ? error.message : String(error)}`) } } -export function recordReviewedHead(path: string, reviewed: Record, key: string, head: string): void { - reviewed[key] = head +function load(path: string, schema: z.ZodType, fallback: T): T { try { - mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, JSON.stringify(reviewed)) + return schema.parse(JSON.parse(readFileSync(path, 'utf8'))) } catch { - /* best-effort */ + return fallback } } -export function loadDailyCounter(path: string, now = new Date()): DailyCounter { - const today = now.toISOString().slice(0, 10) - try { - const parsed = DailyCounter.parse(JSON.parse(readFileSync(path, 'utf8'))) - if (parsed.date === today) { - return parsed - } - } catch { - /* missing or corrupt — a new day */ - } - return { date: today, count: 0 } +export interface SweepState { + reviewsToday: () => number + isReviewed: (pr: Pr) => boolean + recentlyFailed: (pr: Pr, withinMs: number) => boolean + failed: (pr: Pr) => void + reviewedHead: (pr: Pr) => void } -export function bumpDailyCounter(path: string, daily: DailyCounter): void { - daily.count += 1 - try { - mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, JSON.stringify(daily)) - } catch { - /* best-effort */ +export function sweepState(cfg: Config): SweepState { + const today = new Date().toISOString().slice(0, 10) + const failuresPath = join(cfg.stateDir, 'failures.json') + const reviewedPath = join(cfg.stateDir, 'reviewed.json') + const dailyPath = join(cfg.stateDir, 'daily.json') + const failures = load(failuresPath, HeadAttempts, {}) + const reviewed = load(reviewedPath, ReviewedHeads, {}) + let daily = load(dailyPath, DailyCounter, { date: today, count: 0 }) + if (daily.date !== today) { + daily = { date: today, count: 0 } + } + return { + reviewsToday: () => daily.count, + isReviewed: (pr) => reviewed[String(pr.number)] === pr.headRefOid, + recentlyFailed: (pr, withinMs) => { + const f = failures[String(pr.number)] + return f !== undefined && f.head === pr.headRefOid && Date.now() - f.at < withinMs + }, + failed: (pr) => { + failures[String(pr.number)] = { head: pr.headRefOid, at: Date.now() } + save(failuresPath, failures) + }, + reviewedHead: (pr) => { + reviewed[String(pr.number)] = pr.headRefOid + save(reviewedPath, reviewed) + daily.count += 1 + save(dailyPath, daily) + }, } } - -export const failuresPath = (cfg: Config): string => join(cfg.stateDir, 'failures.json') -export const reviewedPath = (cfg: Config): string => join(cfg.stateDir, 'reviewed.json') -export const dailyPath = (cfg: Config): string => join(cfg.stateDir, 'daily.json') diff --git a/src/sweep/sweep.ts b/src/sweep/sweep.ts index d88b70f..782be67 100644 --- a/src/sweep/sweep.ts +++ b/src/sweep/sweep.ts @@ -1,87 +1,39 @@ -// The sweep's front half: load the per-box state, then collect the PRs that pass the cheap serial gates -// (dedup, failure throttle, daily/MAX_PRS caps, diff fetch + size cap) into review candidates. import { type Config, log } from './config' import { diffLineCount, getDiff, GH_DIFF_LIMITS } from './diff' -import { type PriorState } from './github' +import { type PriorState, prReviews } from './github' import { hasReviewLabel, type Pr } from './prs' -import { - type DailyCounter, - dailyPath, - failuresPath, - type HeadAttempt, - loadDailyCounter, - loadHeadAttempts, - loadReviewedHeads, - reviewedPath, -} from './state' +import { type SweepState } from './state' export interface Candidate { pr: Pr prior: PriorState diff: string - firstReview: boolean } -// The three per-box state files every sweep loads up front (see state.ts). -export interface SweepState { - failures: Record // PR -> failed head + when; throttles retries without a PR comment - reviewedLocal: Record // PR -> head already run; catches suppressed no-ops - daily: DailyCounter // today's review count vs MAX_REVIEWS_PER_DAY -} - -export function loadSweepState(cfg: Config): SweepState { - return { - failures: loadHeadAttempts(failuresPath(cfg)), - reviewedLocal: loadReviewedHeads(reviewedPath(cfg)), - daily: loadDailyCounter(dailyPath(cfg)), - } -} - -// Count PRs we do real (costly) work on, and cap THAT at MAX_PRS — so a backlog of already-reviewed PRs at -// the front of the list can't consume the budget and starve later ones. Candidates are collected here (all the -// cheap serial gates) and reviewed by pool.ts's CODEX_JOBS concurrent codex sessions. -export function collectCandidates( - cfg: Config, - queue: Pr[], - priorByPr: Map, - state: SweepState, -): Candidate[] { +export function collectCandidates(cfg: Config, queue: Pr[], state: SweepState): Candidate[] { let handled = 0 - // Each candidate is one review session, so the daily ceiling gates collection up front. const dailyBudget = - cfg.maxReviewsPerDay > 0 && !cfg.dryRun ? cfg.maxReviewsPerDay - state.daily.count : Number.POSITIVE_INFINITY + cfg.maxReviewsPerDay > 0 && !cfg.dryRun ? cfg.maxReviewsPerDay - state.reviewsToday() : Number.POSITIVE_INFINITY const candidates: Candidate[] = [] for (const pr of queue) { if (handled >= dailyBudget) { log(`daily cap hit (MAX_REVIEWS_PER_DAY=${cfg.maxReviewsPerDay}) — no more reviews today; resumes tomorrow`) break } - // What stupify has already said here — read from the reviews/threads connection (findings are inline threads). - const prior = priorByPr.get(pr.number) ?? null + const prior = prReviews(cfg, pr) if (prior === null) { log(`skip #${pr.number} — couldn't read its reviews from gh (failed/malformed); will retry next sweep`) continue } - const firstReview = !prior.everReviewed // stupify has never reviewed here → a clean verdict earns a one-time LGTM - // Already reviewed THIS head? A posted review's body carries the head marker (durable, survives VM recreation); - // a SUPPRESSED no-op posts nothing, so it's caught by local state instead. Either way, don't re-run codex. - const reviewedHead = prior.reviewedHead || state.reviewedLocal[String(pr.number)] === pr.headRefOid - // Failures aren't posted, so suppression is local: skip a head we already tried within the retry window. - const f = state.failures[String(pr.number)] - const recentlyFailed = f !== undefined && f.head === pr.headRefOid && Date.now() - f.at < cfg.failRetryMs - if (reviewedHead || recentlyFailed) { + if (prior.reviewedHead || state.isReviewed(pr) || state.recentlyFailed(pr, cfg.failRetryMs)) { continue } - // Past the cheap dedup skip — this PR is a real candidate. Enforce MAX_PRS here, not on the iterated list. if (handled >= cfg.maxPrs) { log(`reached MAX_PRS=${cfg.maxPrs} this sweep — deferring remaining candidates to the next sweep`) break } - - // Fetch the diff once, here in the runner — codex reviews it from the prompt with no network/gh of its own. const read = getDiff(cfg, pr) if (!read.ok) { - // too-large is terminal: gh will never hand us this diff, so there is nothing to retry and nothing to measure. log( read.reason === 'too-large' ? `skip #${pr.number} — diff over GitHub's ${GH_DIFF_LIMITS} API limit — gh can't return it, so it can't be reviewed; split the PR` @@ -89,19 +41,17 @@ export function collectCandidates( ) continue } - const { diff } = read - const lines = diffLineCount(diff) - // auto-scope only: skip oversized diffs UNLESS the PR carries the review label (the documented force-include). + const lines = diffLineCount(read.diff) if (cfg.scope === 'auto' && lines > cfg.diffLineCap && !hasReviewLabel(pr, cfg)) { log(`skip #${pr.number} — diff ${lines} lines > cap ${cfg.diffLineCap} (add '${cfg.reviewLabel}' to force)`) continue } - handled += 1 // count only PRs that pass the gates and actually get a review slot + handled += 1 if (cfg.dryRun) { log(`DRY_RUN would review #${pr.number} @ ${pr.headRefOid.slice(0, 8)} (diff ${lines} lines)`) continue } - candidates.push({ pr, prior, diff, firstReview }) + candidates.push({ pr, prior, diff: read.diff }) } return candidates } diff --git a/src/sweep/verdict.ts b/src/sweep/verdict.ts index 668a587..ea3068f 100644 --- a/src/sweep/verdict.ts +++ b/src/sweep/verdict.ts @@ -1,12 +1,7 @@ -// The review VERDICT contract: Codex submits ONE ReviewOutput through the `review_verdict` tool (codex.ts), the -// kit validates the shape mid-turn, and parseReview is the guard for what the shape can't say. Also the marker / -// convergence-note vocabulary every posted review carries. import { z } from 'zod' import { type Pr } from './prs' -// The output carries path/line (thread anchor), severity (→ blocking), and conf. The runner stamps -// emoji + conf + file pointer onto `body`. Only high/med block; low/note/praise are non-blocking. const BLOCKING = new Set(['high', 'med']) const Severity = z.enum(['high', 'med', 'low', 'note', 'praise']) const EMOJI = { high: '🔴', med: '🟠', low: '🟡', note: '🔵', praise: '🟢' } as const @@ -39,53 +34,29 @@ export type ReviewVerdict = const heading = (severity: z.infer, conf: number, path: string, line: number): string => `${EMOJI[severity]} · conf ${Number(conf.toFixed(2))} · **\`${path}:${line}\`**` -const postedBody = (head: string, body: string): string => `${head} - -${body}` - -/** Stamp headings and split verdicts. Caller already `ReviewOutput.parse`d the model JSON. */ export function parseReview(data: ReviewOutput): ReviewVerdict { if (data.verdict !== 'findings') { - // A convergence verdict that ALSO carries findings is contradictory — fail loud rather than resolve threads - // and post a ✅ while silently dropping what the model found. if (data.findings.length > 0) { throw new Error('review parsed but had no usable findings') } return { kind: data.verdict } } const findings = data.findings - .map((f): ParsedFinding | null => { - const path = f.path.trim() - const body = f.body.trim() - if (!path || !body) { - return null - } - return { - path, - line: f.line, - blocking: BLOCKING.has(f.severity), - body: postedBody(heading(f.severity, f.conf, path, f.line), body), - } - }) - .filter((f) => f !== null) + .filter((f) => f.path.trim() && f.body.trim()) + .map((f) => ({ + path: f.path.trim(), + line: f.line, + blocking: BLOCKING.has(f.severity), + body: `${heading(f.severity, f.conf, f.path.trim(), f.line)}\n\n${f.body.trim()}`, + })) if (findings.length === 0) { throw new Error('review parsed but had no usable findings') } return { kind: 'findings', opener: data.opener, findings } } -// The hidden marker stupify ends every posted review with, keyed to the head SHA — how a later sweep recognizes a -// PR it already reviewed AT THIS HEAD (durable dedup, survives VM recreation). Failures aren't posted, so there's -// no fail marker; they're throttled via local state instead. export const markFor = (pr: Pr): string => `` -// "fixed" is gated on there actually being open findings, so a stray fixed-signal on a never-flagged PR can't -// manufacture approval. Detection is strict parse-or-fail — never infer "clean" from anything looser: a reviewer -// fails toward SURFACING findings (loud, retryable), never toward hiding them behind a silent ✅. export const FIXED_NOTE = 'nice, all fixed ✅' -// The one-line re-approval a clean re-reviewed head gets when nothing is outstanding. Every posted note carries -// the `` marker, so every reviewed head keeps a durable on-PR verdict. Pure silence here made -// the latest push look unreviewed to anything that asks "does a review cover HEAD?" (merge gates, the bunion -// factory's `wait` tool — which timed out and shipped with STUPIFY_FLAKED), and to a sweep whose local -// reviewed-state was lost (VM recreation → codex re-runs on an already-clean head). + export const STILL_NOTE = 'still ✅' diff --git a/src/sweep/worktree.ts b/src/sweep/worktree.ts index 50a0c1f..fbd8f09 100644 --- a/src/sweep/worktree.ts +++ b/src/sweep/worktree.ts @@ -1,5 +1,3 @@ -// Detached worktree at a PR's head SHA so codex reads the same tree the diff describes — required for stacked -// PRs whose base is not main (the shared checkout stays on defaultBranch for refreshRepo). import { rmSync } from 'node:fs' import { dirname, join } from 'node:path' @@ -7,11 +5,10 @@ import { exec } from '@bevyl-ai/agent-tools' import { type Pr } from './prs' -export function headWorktreePath(repoDir: string, pr: Pr): string { +function headWorktreePath(repoDir: string, pr: Pr): string { return join(dirname(repoDir), 'worktrees', `${pr.number}-${pr.headRefOid.slice(0, 8)}`) } -/** Fetch base+head and add a detached worktree at the PR head. Returns null on failure. */ export function prepareHeadWorktree(repoDir: string, pr: Pr): string | null { const dir = headWorktreePath(repoDir, pr) rmSync(dir, { recursive: true, force: true })