diff --git a/Makefile b/Makefile index a6a1b9d..a1fa93f 100644 --- a/Makefile +++ b/Makefile @@ -48,6 +48,7 @@ check-fmt: $(MDTABLEFIX) --check $(MDTABLEFIX_SELECT) $(MDTABLEFIX_RULES) lint: markdownlint workflow-parse + node_modules/.bin/oxlint --config oxlint.config.mjs src # The src tree is TypeScript restricted to erasable syntax; tsc enforces the # restriction (erasableSyntaxOnly, verbatimModuleSyntax in tsconfig.json). diff --git a/bun.lock b/bun.lock index 34b9cb1..ce49aec 100644 --- a/bun.lock +++ b/bun.lock @@ -7,11 +7,14 @@ "devDependencies": { "@aboviq/bun-test-cucumber": "^0.2.0", "@types/bun": "^1.3.14", + "ai-deslop": "0.4.9", "ajv": "8.17.1", "esbuild": "^0.28.1", "fast-check": "^4.8.0", "lemmascript": "^0.5.9", "markdownlint-cli2": "^0.22.1", + "oxlint": "1.82.0", + "oxlint-tsgolint": "7.0.2004", "typedoc": "^0.28.20", "typescript": "^6.0.3", "yaml": "^2.9.0", @@ -85,12 +88,76 @@ "@gerrit0/mini-shiki": ["@gerrit0/mini-shiki@3.23.0", "", { "dependencies": { "@shikijs/engine-oniguruma": "^3.23.0", "@shikijs/langs": "^3.23.0", "@shikijs/themes": "^3.23.0", "@shikijs/types": "^3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@7.0.2004", "", { "os": "darwin", "cpu": "arm64" }, "sha512-uJk3ttju7Lz+Nv8WG4CsLyewoL1qf/Ny8S8OksJ7Bm/F14Tc9gMfW6arM1PCc57IzH/X/I79YsCWTgxX1FKibQ=="], + + "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@7.0.2004", "", { "os": "darwin", "cpu": "x64" }, "sha512-0BFfHTzDxNDFPcu0UZAXT6CyCJHAXM0dZveyDb4KEHa2qF/DSnOKOrmvK/wFuFol9kvijbGM9JfuOsor+zWXRw=="], + + "@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@7.0.2004", "", { "os": "linux", "cpu": "arm64" }, "sha512-TAehQc+qdRXyDN3nl/r/3/oPrBVrbtrDeGMkFyG6mf+XphpoJ2lAMVFLclyvrVZCw02RbKROjyw8h72XwQQHcw=="], + + "@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@7.0.2004", "", { "os": "linux", "cpu": "x64" }, "sha512-LPunZvm6ZbWPfHMAyfZf9ci3HHvNEzLejO8zdqO3GoVGoSEHqqufHUD7+OgeatVSaJP7uXDJ2HaNOHIoH7go4Q=="], + + "@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@7.0.2004", "", { "os": "win32", "cpu": "arm64" }, "sha512-FVWplzDImWhiuawnIZVETaQecv4GWom6Jv4Fx2xj7hhXXDZ5okEgiw1gTCImHTIQsi2ycC5oSxVwrn69bMh7iw=="], + + "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@7.0.2004", "", { "os": "win32", "cpu": "x64" }, "sha512-7PsxuUx11Ry9DYTWcljTzRbmZY9B6O9fFFfZV+aodQEMy6pfRT3fhoQRv9PtGN/89z1pPz3nFKrsFm6WqNtX7w=="], + + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.82.0", "", { "os": "android", "cpu": "arm" }, "sha512-a3LB+C5Dsj5b/qtmG/mv5WrzuiXEpg1KF5nXWcEvaoN5TYAqkIvxPOwTPp3Jy/FoGpRo8zsTFhMElMXfeoOEzA=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.82.0", "", { "os": "android", "cpu": "arm64" }, "sha512-OBlhRgNqFblGpGenno/aqOfJLOkQ2B8Ig3iDAalfn0H8hJGZKXPeexCRTDm6uwv6YUjSA9Xnwt1y/Bgj5ZH8uw=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.82.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dsopxqtY5ZdyT9uLHyGt1SyiLop6hi7hWI3PKpePodkRQOkLaCm+OE4fR9CAz9qdfjiFO8531tX/QDyP/psjFg=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.82.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-94Lu0SgTClKColU66g1VDuigV3HkcbkJBnTtZjGYfE8UPugaWDgKrm2icjC6HJVUYler2OXaHP/X0TBy8+CowQ=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.82.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hne/V06ewhh1i0w8+l7GDNROAGCGPmyFuOwiP7YTRu0JycyStJ4785dmF8xU5p0uUwt2emvIF9vc7Xjis+cJ0g=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.82.0", "", { "os": "linux", "cpu": "arm" }, "sha512-aWY2xtbZf1LneW9Qsv/n2Sp8gOu74JrlQzEtj4coHX2SHFrCfhmAumaU+sI/A5nr+yoTRTSmI/pL2s6ADlNSkw=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.82.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Fe+TtXCXMh/5f7kWlZ2VAwsMumZWtraFlKVk1NJlL52/beGwfDE7ov+/8gVirHzWokzGu7X65hSPq0ucPDskWQ=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.82.0", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-6azCZ6OJudlvipNttXCCQcyeFfcJ/NvUZdSN1z8elo73kCHtyQC7WTiUcSjWYvJ1jaq9KDUyMAoAS/vNzhBomA=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.82.0", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-PLEaSD8IAIIlwW4dwOd9YaxuxeOpwiXL4J24rcnE4iNtyM5j9Q9/3+gti08oXpx0u2ygNjRDx9xjWWpQonuJEw=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.82.0", "", { "os": "linux", "cpu": "ppc64", "libc": "glibc" }, "sha512-D94em/BwknNTn4vqxjHh5wb2oL566eFhArabqKIr0cNZMHOJuiraFp1A8tXpH05bbE5tqwEfLXTI0MWEGtn3Dw=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.82.0", "", { "os": "linux", "cpu": "none", "libc": "glibc" }, "sha512-MOprxBaoYU2D4VgxXCl3ghydThWtx7Um1lL51kGYNeQ5Al7WzsH7/tqGdNtbLrIWnjq3bsm13+nz/gRIxjrOXw=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.82.0", "", { "os": "linux", "cpu": "none", "libc": "musl" }, "sha512-5h55QsfJ/luDXZzC20k6SNOY1Az+dCP9WvntKtcUWh2JhckAdwApY2ZusaBTwLENnReXU+A2fJtSrYvZJNKNPg=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.82.0", "", { "os": "linux", "cpu": "s390x", "libc": "glibc" }, "sha512-IE8NJNLlHr0CaXyGJPGVn0eTkUyoj1I2UfA8x7I4PSOYKsQ/6btVC7Pywrj5onk0cMH25r6Z38SoN3AvE5Zuog=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.82.0", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-XUUUxaBo9XKl+J1B9EmP1cTGQPddzeURvoGkfwh/94PGnbW+hBprDljneoI2M1jzC1bzrIV3ihc7iM9UXl8+tg=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.82.0", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-SWLSFulX9TDuH6yvbPYp4+VNn6jkkIvvI+KiujDM5rWBRHEfkesCC/pCneIIUr6ovkxZ5fRtpi2v5Cz5FrMJZg=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.82.0", "", { "os": "none", "cpu": "arm64" }, "sha512-BQy35f6ZUdNr9a6c7B7orxQTcLjByGT2z3WAgmRovpRwmPYAaJ+NTplmMzhdjdJ4qSchfMNZy/Ukg+qRg6zseQ=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.82.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V4QhSTg5gctZue8RJjsGi7NpQPThr/p1/HfmiMC5kfe1KFEup9SQRVub4A6kijQjdHfxj7bLL1KO3QO7/5bwMQ=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.82.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-TUSCLaKB2yktpFAJ/r3HAUYsaV/3DT7JS4iNKyoh3a9YNwD0UG7Ezh4D8m23654vQcU6P/RQrCAjRPKe4peP/A=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.82.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VTVoRIWJTb+wvUX8EYoPArfFH02whuR10goFXE/LHRRX33ajRrFgqbcONXZMiF4C5rnattfkm87HqYn8jb8hmQ=="], + + "@oxlint/plugins": ["@oxlint/plugins@1.82.0", "", {}, "sha512-IDF4UXBNOaCeLoEpVxf5Bg85KOWdeuBEkbo1rGzS2vXcbD+GT1asBSeeTC+5BgW6NFTHVByd6N5giHsFPGtwAA=="], + + "@pkgr/core": ["@pkgr/core@0.3.6", "", {}, "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA=="], + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], "@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], @@ -103,6 +170,8 @@ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], + "@ts-morph/common": ["@ts-morph/common@0.26.1", "", { "dependencies": { "fast-glob": "^3.3.2", "minimatch": "^9.0.4", "path-browserify": "^1.0.1" } }, "sha512-Sn28TGl/4cFpcM+jwsH1wLncYq3FtN/BIpem+HOygfBWPT5pAeS5dB4VFVzV8FbnOKHpDLZmvAl4AjPEev5idA=="], "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], @@ -119,6 +188,8 @@ "@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "ai-deslop": ["ai-deslop@0.4.9", "", { "dependencies": { "@oxlint/plugins": "1.82.0", "@tailwindcss/node": "^4.3.3", "synckit": "^0.11.13" }, "peerDependencies": { "tailwindcss": "^4.0.0" }, "optionalPeers": ["tailwindcss"] }, "sha512-YiwYxO6mIo31ZtJ8+Mcy8PbfBOX+oGVFC3gP2k8jSXW5cAZlOANEOFBWbpKN2JqfsDfrRNpHfuLe5YXA8Fclzg=="], + "ajv": ["ajv@8.17.1", "", { "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-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], @@ -153,8 +224,12 @@ "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "enhanced-resolve": ["enhanced-resolve@5.26.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw=="], + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], @@ -177,6 +252,8 @@ "globby": ["globby@16.2.0", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "fast-glob": "^3.3.3", "ignore": "^7.0.5", "is-path-inside": "^4.0.0", "slash": "^5.1.0", "unicorn-magic": "^0.4.0" } }, "sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], @@ -195,6 +272,8 @@ "is-path-inside": ["is-path-inside@4.0.0", "", {}, "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA=="], + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], @@ -207,10 +286,36 @@ "lemmascript": ["lemmascript@0.5.9", "", { "dependencies": { "ts-morph": "^25.0.0" }, "bin": { "lsc": "tools/dist/lsc.js" } }, "sha512-Eo095sS81a+LKPhb+7COpsGjqtWuNUuoWO28T0m9zUEYKojjQtXEwL19kx7ewztaXa/Fz1SGKZMqBwWTjpQq6Q=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "linkify-it": ["linkify-it@5.0.2", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q=="], "lunr": ["lunr@2.3.9", "", {}, "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="], "markdownlint": ["markdownlint@0.40.0", "", { "dependencies": { "micromark": "4.0.2", "micromark-core-commonmark": "2.0.3", "micromark-extension-directive": "4.0.0", "micromark-extension-gfm-autolink-literal": "2.1.0", "micromark-extension-gfm-footnote": "2.1.0", "micromark-extension-gfm-table": "2.1.1", "micromark-extension-math": "3.1.0", "micromark-util-types": "2.0.2", "string-width": "8.1.0" } }, "sha512-UKybllYNheWac61Ia7T6fzuQNDZimFIpCg2w6hHjgV1Qu0w1TV0LlSgryUGzM0bkKQCBhy2FDhEELB73Kb0kAg=="], @@ -279,6 +384,10 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "oxlint": ["oxlint@1.82.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.82.0", "@oxlint/binding-android-arm64": "1.82.0", "@oxlint/binding-darwin-arm64": "1.82.0", "@oxlint/binding-darwin-x64": "1.82.0", "@oxlint/binding-freebsd-x64": "1.82.0", "@oxlint/binding-linux-arm-gnueabihf": "1.82.0", "@oxlint/binding-linux-arm-musleabihf": "1.82.0", "@oxlint/binding-linux-arm64-gnu": "1.82.0", "@oxlint/binding-linux-arm64-musl": "1.82.0", "@oxlint/binding-linux-ppc64-gnu": "1.82.0", "@oxlint/binding-linux-riscv64-gnu": "1.82.0", "@oxlint/binding-linux-riscv64-musl": "1.82.0", "@oxlint/binding-linux-s390x-gnu": "1.82.0", "@oxlint/binding-linux-x64-gnu": "1.82.0", "@oxlint/binding-linux-x64-musl": "1.82.0", "@oxlint/binding-openharmony-arm64": "1.82.0", "@oxlint/binding-win32-arm64-msvc": "1.82.0", "@oxlint/binding-win32-ia32-msvc": "1.82.0", "@oxlint/binding-win32-x64-msvc": "1.82.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-+iFM1BGw1ntYJt3QngbJmjbrGxPaKMUADOXOijpWGnYcBPq8YZnQftSS1C+pVcDYy9YxqDVJKQqQkTazTQMboQ=="], + + "oxlint-tsgolint": ["oxlint-tsgolint@7.0.2004", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "7.0.2004", "@oxlint-tsgolint/darwin-x64": "7.0.2004", "@oxlint-tsgolint/linux-arm64": "7.0.2004", "@oxlint-tsgolint/linux-x64": "7.0.2004", "@oxlint-tsgolint/win32-arm64": "7.0.2004", "@oxlint-tsgolint/win32-x64": "7.0.2004" }, "bin": { "tsgolint": "./bin/tsgolint.js" } }, "sha512-vQ9WDF3fiZJvRdSDosNOflerOCOpwol6vWzVDxW76RcU19cjTh7engvkHLvmv+tzedR8fAiJUVUGr6IVZdkzXQ=="], + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], @@ -307,10 +416,18 @@ "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], + "string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="], "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "synckit": ["synckit@0.11.13", "", { "dependencies": { "@pkgr/core": "^0.3.6" } }, "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg=="], + + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "ts-morph": ["ts-morph@25.0.1", "", { "dependencies": { "@ts-morph/common": "~0.26.0", "code-block-writer": "^13.0.3" } }, "sha512-QJEiTdnz1YjrB3JFhd626gX4rKHDLSjSVMvGGG4v7ONc3RBwa0Eei98G9AT9uNFDMtV54JyuXsFeC+OH0n6bXQ=="], diff --git a/docs/adr-004-linting-strategy.md b/docs/adr-004-linting-strategy.md new file mode 100644 index 0000000..03c2ccd --- /dev/null +++ b/docs/adr-004-linting-strategy.md @@ -0,0 +1,89 @@ +# Architectural decision record (ADR) 004: Oxlint rules for ODW source + +## Status + +Accepted. The source lint gate applies the Unicorn and deslop recommended +profiles to the authored workflow module tree. + +## Date + +2026-10-27. + +## Context and problem statement + +The decomposed ODW workflow is authored as TypeScript under +`src/workflows/df12-build-odw/` and bundled into a JavaScript workflow +artefact. The compiler and documentation gates already constrain its types and +module structure. The repository also needs consistent source-level rules for +correctness and maintainability without linting the generated workflow +artefacts as if they were authored source. + +## Decision drivers + +- Pin the linter, its type-aware backend, and the deslop plug-in for repeatable + local and CI results. +- Apply the supported Unicorn recommended rules and the deslop recommended + configuration to `src/`. +- Exclude the object code under `workflows/`; check those artefacts with the + existing workflow parse and freshness gates. +- Keep the ODW runtime contract intact: lint dependencies are build tooling and + do not become runtime imports in the generated workflow. +- Resolve only documented conflicts and correct source findings instead of + suppressing them. + +## Decision outcome + +`make lint` runs Oxlint against `src/` using +[`oxlint.config.mjs`](../oxlint.config.mjs). The configuration enables the +Unicorn plug-in rules supported by the pinned Oxlint release, imports the +recommended `ai-deslop` rules, enables type-aware linting, and turns the other +Oxlint categories off. It excludes `workflows/**`. Tests remain outside this +source lint target and are exercised by `make test`. + +The exact development dependency pins are: + +| Package | Version | Purpose | +| ----------------- | ---------- | --------------------------------- | +| `oxlint` | `1.82.0` | Linter and built-in Unicorn rules | +| `ai-deslop` | `0.4.9` | Recommended `deslop/*` rules | +| `oxlint-tsgolint` | `7.0.2004` | Type-aware rule backend | + +Table 1: pinned Oxlint dependencies. + +Unicorn's `prefer-reflect-apply` recommends `Reflect.apply`, while deslop's +`no-reflect-apply` rejects it. Keep Unicorn's recommendation and disable +`deslop/no-reflect-apply`. + +The ODW module tree has a scoped override for five rules that conflict with its +runtime and input-boundary contracts: + +- `unicorn/no-null` is disabled because ODW uses `null` for failed + `parallel` and `pipeline` slots, and JSON contracts can require explicit + `null` values. Replacing them with `undefined` changes those contracts. +- `deslop/no-unknown-parameters`, `deslop/no-unknown-returns`, and + `deslop/no-unsafe-dictionary-type` are disabled because workflow arguments, + injected primitive results, and filesystem data can be untrusted before + validation. `unknown` and dictionaries of `unknown` preserve that boundary in + the type system. +- `deslop/no-runtime-typeof` is disabled because these boundaries use native + type guards, and the ODW dialect forbids runtime validator-library imports. + Agent outputs remain contracted with JSON Schema at the ODW boundary. + +These exceptions apply only to `src/workflows/df12-build-odw/**/*.ts`. The +remaining selected rules stay enabled, including the requirement to justify +type assertions. + +## Consequences + +- New authored modules under `src/` are included in the Oxlint gate. +- Workflow object code under `workflows/` is checked by `workflow-parse`; the + generated ODW artefact is also checked by `workflow-freshness`. +- Test behaviour continues to be checked by `make test`. +- A lint dependency upgrade requires reviewing the supported Unicorn rule + list, deslop's recommended configuration, and the documented exceptions. + +## References + +- [Oxlint built-in plug-ins](https://oxc.rs/docs/guide/usage/linter/plugins.html) +- [Deslop recommended configuration](https://github.com/maharshi365/deslop#usage) +- [Simulacat Core plug-in adoption](https://github.com/leynos/simulacat-core/pull/121) diff --git a/docs/coderabbit-wire-contract.md b/docs/coderabbit-wire-contract.md index 82b091d..5e60a09 100644 --- a/docs/coderabbit-wire-contract.md +++ b/docs/coderabbit-wire-contract.md @@ -128,6 +128,18 @@ fallback over the error text for older CLI builds), and treats any other `error` event as `error` unless the text matches the auth patterns in `faults.ts`, which classify as `auth`. +## Typed event validation + +The adapter validates fields consumed from `finding`, `error`, and `complete` +events before using them. Optional finding fields (`severity`, `fileName`, +`comment`, and `codegenInstructions`) and error fields (`errorType` and +`message`) must be strings when present; `suggestions` must be an array when +present. A completion requires a string `status`; its optional `findings` count +must be a finite number. A malformed known event produces an `invalid_output` +error outcome, so a later clean completion cannot mask it. A valid error event +remains authoritative, including a rate-limit error. Non-object JSON values and +non-JSON lines remain diagnostic raw lines. + ## Maintenance When a CLI upgrade changes this stream, capture a fresh session diff --git a/docs/contents.md b/docs/contents.md index b16982f..f05c377 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -49,6 +49,9 @@ workflow assets, or understanding why the system is shaped the way it is. - [ADR 003: Correlation-first OpenTelemetry observability](adr-003-opentelemetry-observability.md): records the accepted decision to correlate workflow nodes and agent telemetry through dispatch-time identity, a local OTLP collector, and a SQLite store. +- [ADR 004: Oxlint source rules](adr-004-linting-strategy.md): records the + pinned Unicorn and deslop profiles for authored ODW source and the workflow + artefact exclusion. ## Plans diff --git a/docs/developers-guide.md b/docs/developers-guide.md index c613452..454fd91 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -36,12 +36,12 @@ The repository contains workflow scripts, skill documentation, docs, operator scripts, focused test suites, a project roadmap with ExecPlans, and a small validation `Makefile`. Development dependencies (`esbuild`, `fast-check`, `@aboviq/bun-test-cucumber`, `ajv`, `lemmascript`, `typescript`, `@types/bun`, -`markdownlint-cli2`, `typedoc`, and `yaml`) are managed with `bun` via -`package.json`. `yaml` is at `^2.9.0` and parses the CI workflow in -`tests/modules/ci-workflow-gate.test.ts`, which runs under the module test -gates; that contract must parse the workflow rather than search its text, so -the parser is a direct dependency rather than one reached through another -package's tree. +`markdownlint-cli2`, `oxlint`, `oxlint-tsgolint`, `ai-deslop`, `typedoc`, and +`yaml`) are managed with `bun` via `package.json`. `yaml` is at `^2.9.0` and +parses the CI workflow in `tests/modules/ci-workflow-gate.test.ts`, which runs +under the module test gates; that contract must parse the workflow rather than +search its text, so the parser is a direct dependency rather than one reached +through another package's tree. Relevant paths: @@ -58,9 +58,10 @@ Relevant paths: and CodeScene), `host-review-contracts.ts` (neutral review contracts), and `host-review.ts` (neutral composition facade), `shell-command.ts` (a non-evaluating parser for configured shell-command words and assignment - spans), and `run-task.ts`, with the injected ODW primitives declared in - `odw-globals.d.ts`. TypeScript is restricted to erasable syntax by compiler - flags (`erasableSyntaxOnly`, `verbatimModuleSyntax`). + spans), `run-task.ts`, and `task-outcome.ts` (validated task result fields), + with the injected ODW primitives declared in `odw-globals.d.ts`. TypeScript + is restricted to erasable syntax by compiler flags (`erasableSyntaxOnly`, + `verbatimModuleSyntax`). - `workflows/df12-build-odw.js`: GENERATED ODW/Codex workflow artefact, built by `make workflow-build` (see `scripts/build-workflow.mjs`); this is the single file the sidecar copies and ODW loads. @@ -116,6 +117,10 @@ loader would reject. Within the src tree, keep the ODW script contract intact: - Do not import ODW primitives. `args`, `agent`, `parallel`, `phase`, `log`, and `budget` are injected by ODW. - Use schemas where JavaScript consumes agent output as structured data. +- When routing options for assessment, write-preflight, or task-dispatch agent + calls, retain the call's required schema even if the router replaces the + options or omits `schema`; ODW uses it to validate structured replies at the + agent boundary. - Keep phase and label names explicit enough to make the dashboard useful. - Keep target-project mutation inside agent prompts and real git worktrees; ODW copy isolation is not a persistent handoff mechanism for this workflow. @@ -179,6 +184,34 @@ loader would reject. Within the src tree, keep the ODW script contract intact: Changes to workflow behaviour must update all relevant prompts, schemas, docs, and validation notes in the same branch. +### Runtime input and structured-output boundaries + +`makeConfig` accepts a record of workflow arguments, or `null`/`undefined` to +use defaults. It rejects arrays, primitives, and wrong types for documented +fields with `Invalid workflow argument type`; `writeProbeModelByAdapter` must +be a record of string values. Unknown keys remain accepted for forward +compatibility. The legacy `resumePartialBranches` key is deliberately untyped, +but only literal `true` enables recovery. The user guide lists the documented +field types and the fields that accept numbers or strings. + +Assessment and write-preflight agent calls retain their required JSON Schemas +after adapter option routing. For an in-run assessment reply that is absent or +malformed, `attachAssessment` leaves the task result intact and adds +`assessmentError` with `assessmentEvidence`. At the assessment-module boundary, +`assessRecoveryCandidate` returns `evidence`, `assessment: null`, and +`assessmentError` when no classification is produced. `runRecovery` records +`action: assessment-error`, the error, and the skipped candidate in +`recovery.results`; it does not copy the evidence into that summary entry. +Authentication or provider failures also produce a fatal run result. Assessment +remains report-only. + +The write-preflight schema requires a boolean `ok` and permits an optional +string `detail`. The host checks the probe file itself for the exact token in a +regular, non-symlink file; the agent's `ok` claim is not proof of write access. +A malformed reply is treated as an agent error, and when host verification also +fails its detail appears in the probe failure. A failed host-verified probe +fails the task at `stage: worktree-write`. + ## Submodule architecture and composition `workflows/df12-build-odw.js` is compiled from the module tree under @@ -299,6 +332,17 @@ writes. Never wrap the integration agent in `withInfraRetry` — its push to `origin/` is not idempotent, and a source-invariant test pins both call sites as unwrapped. +`checkedTaskOutcome` in `task-outcome.ts` validates the optional result fields +read by recovery and the worker pool before those consumers use them. The +validator accepts `integration` as `null` or a record, and checks that +`integration.pushed` is boolean when present; `kind` and `assessmentError` are +strings; `proposals` is an array of records; and `assessment` is a record. A +`salvage` value must contain string `classification`, `sha`, and `detail` +fields, `committed` as an array of strings, and `skipped` records with string +`path` and `reason` fields. Unrelated result data is preserved. Invalid shapes +throw `Invalid task outcome`, which the normal and recovery paths turn into a +task failure instead of consuming malformed data. + Recovery assessment carries two distinct evidence channels, typed by `RecoveryAssessmentFields` in `recovery-decision.ts`: blocking `missingEvidence` and advisory `residualRisk` (read via the @@ -426,6 +470,15 @@ burn review quota; the pipeline seam is covered by a fake NDJSON-emitting `coderabbit` on `PATH`, and the between-item gate by an injected `runCoderabbitHostReview`. +The adapter validates consumed fields in known finding, error, and completion +events. It synthesizes an `errorType: invalid_output` event only when a known +event is malformed and no valid error event was parsed; a valid error event is +retained instead of being overwritten by the synthetic error. Rate-limit +classification keeps priority, and recognizable authentication text from a +malformed error event is checked separately. Valid finding events remain +available to the pipeline, which handles blocking findings before an +invalid-output deferral or halt. + Every fix round — gate fix, dual-review fix, and between-item fix — is followed by `verifyWorktreeCommitted`; a fix that leaves the worktree dirty fails the task closed (`FIX DURABILITY`) because uncommitted fix output is unreviewable @@ -732,6 +785,19 @@ changing, so the assertion is that the `if` key is absent rather than that it holds any particular value. The same suite pins the strict Dafny step, which is what stops `make all`'s lenient verification quietly skipping in CI. +`make lint` runs the pinned Oxlint rules over the authored module tree in +`src/`, after Markdown linting and workflow parsing. The configuration in +`oxlint.config.mjs` applies the supported Unicorn recommended rules and the +recommended `ai-deslop` rules with type-aware checks. Its exact dependency +pins, the Unicorn/deslop rule conflict, and scoped ODW rule exceptions are +recorded in `docs/adr-004-linting-strategy.md`. + +The workflow artefacts in `workflows/` are excluded from Oxlint. They are +object code: `make workflow-parse` checks their workflow syntax and +`make workflow-freshness` checks that the generated ODW artefact matches its +TypeScript source. Tests are also outside the application source lint target; +`make test` remains their behavioural gate. + `make markdownlint` is the separate Markdown gate. It runs the pinned `markdownlint-cli2` configuration over maintained Markdown and then runs the en-GB Oxford spelling gate, which regenerates the configuration from the live diff --git a/docs/users-guide.md b/docs/users-guide.md index 807d21e..bbbf646 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -439,6 +439,18 @@ Set project-specific behaviour in `args.json`. The workflow also has matching top-of-file defaults, but the sidecar `args.json` is the normal retuning point for ODW launches. +Arguments must be an object. Missing or `null` arguments use the defaults; +arrays, primitives, and values of the wrong type for a documented field fail at +launch with `Invalid workflow argument type`. String fields require strings, +numeric fields require numbers, and boolean fields require booleans. These +number-or-string fields also accept strings: `stageAttempts`, +`maxWorkItemRounds`, `resumeTaskId`, `resumeMaxCandidates`, +`reviewTimeoutSeconds`, `dakarTimeoutSeconds`, `dakarBudgetGbp`, +`coderabbitAttempts`, `hostReviewAttempts`, and `commitGateTimeoutSeconds`. +Unknown object keys are ignored by this version and remain available for future +options. The legacy `resumePartialBranches` key accepts any value, but only +literal `true` enables recovery. + Common arguments: - `base`: integration branch. Defaults to `main`. @@ -609,7 +621,8 @@ Common arguments: - `worktreeWritePreflight`: when `false`, skip the once-per-run probe that proves the planning and build adapters can write into sibling task worktrees. Defaults to enabled; a failed probe fails the task at stage `worktree-write` - as an environment fault. + as an environment fault. The host verifies the exact token in the probe file; + it does not rely on the agent's reported `ok` value. - `buildAdapter` and `buildModel`: adapter and model for worktree creation, implementation, integration, and remediation agents. - `planAdapter` and `planModel`: adapter and model for planning agents. @@ -676,6 +689,17 @@ CodeScene, then Dakar, and only then dispatches the reviewer agents. Set `reviewTool` to `coderabbit` to select the retained CodeRabbit adapter; its CLI-specific behaviour is documented next. +Dakar output is accepted only when its terminal JSON document matches a known +review shape. A non-skipped `changes-requested` verdict needs at least one +finding with a supported severity and string `path`, `title`, `detail`, and +`evidence` fields; an optional `line` must be a positive integer. With +`ok: true`, either `skipped: true` or `verdict: "pass"` is treated as a clean +result when findings are absent or empty. This means `skipped: true` takes +precedence even if the document also says `verdict: "changes-requested"`; any +non-empty findings on that path are rejected. Unparsable, malformed, or +otherwise unrecognized output becomes a host-review `error` with error category +`invalid-output`, not a clean review. + ## Host-run CodeRabbit review > **Applies to `reviewTool: 'coderabbit'` only.** The host review tool defaults @@ -692,6 +716,14 @@ the CLI's structured findings; `critical` and `major` severities join the reviewers' blocking items and drive the ordinary fix rounds, while lower severities are captured without gating integration. +The adapter validates the consumed fields in known `finding`, `error`, and +`complete` events. Wrong field types make the review an `invalid_output` error +instead of allowing a malformed event stream to count as clean; non-object JSON +values and non-JSON lines remain diagnostic text. Valid error events remain +authoritative, including rate-limit handling, and recognizable authentication +errors still halt the task as `fatal-auth`. Valid blocking findings are +retained and handled even when the same stream also contains malformed output. + Rate limits are absorbed by the host: a rate-limited review waits a deterministic 45–90 minutes (`hostReviewBackoffMinutes`) and retries, up to `hostReviewAttempts` total attempts, costing wall-clock but zero agent tokens. @@ -799,6 +831,24 @@ Assessment is report-only. It never marks roadmap checkboxes, pushes, merges, or cherry-picks. Use it to decide whether to preserve, manually finish, park, or discard the branch before relaunching from `origin/`. +If an in-run assessment agent returns no structured result or a malformed one, +the workflow leaves the task's existing outcome intact and reports +`assessmentError` with host-collected `assessmentEvidence`. For a branch found +during fresh-run recovery, the recovery result records +`action: assessment-error` and the error, and the candidate is marked skipped. +It remains unresolved and blocks the roadmap frontier until an operator handles +it; authentication or provider failures halt the run as fatal errors. No failed +assessment supplies a classification for adoption. + +The worker pool validates optional task-result fields before consuming them: +`integration.pushed` must be boolean when present; `kind` and `assessmentError` +must be strings; `proposals` must be an array of records; `assessment` a +record; and `salvage` must contain string `classification`, `sha`, and `detail` +fields, string `committed` entries, and `skipped` records with string `path` and +`reason`. `integration` may be `null`. A malformed result becomes a failed +task with `stage: error` and detail `Invalid task outcome`; inspect the task +entry in `result.json` and the run's `halted` summary for the detail. + After an `adopt-partial` or `continue-manual` verdict — and also after an infrastructure fault such as schema-retry exhaustion — the workflow commits any dirty `docs/execplans/*.md` artefacts onto the task branch so they survive diff --git a/oxlint.config.mjs b/oxlint.config.mjs new file mode 100644 index 0000000..c0fac25 --- /dev/null +++ b/oxlint.config.mjs @@ -0,0 +1,180 @@ +/** + * Pin Oxlint to Unicorn and deslop recommended rules for authored df12-build code. + * @module + */ +import { defineConfig } from "oxlint"; +import { configs as deslopConfigs } from "ai-deslop"; + +const unicornRecommendedRules = Object.fromEntries( + [ + "catch-error-name", + "consistent-assert", + "consistent-date-clone", + "consistent-empty-array-spread", + "consistent-existence-index-check", + "consistent-function-scoping", + "consistent-template-literal-escape", + "empty-brace-spaces", + "error-message", + "escape-case", + "explicit-length-check", + "explicit-timer-delay", + "filename-case", + "import-style", + "max-nested-calls", + "new-for-builtins", + "no-abusive-eslint-disable", + "no-accessor-recursion", + "no-anonymous-default-export", + "no-array-callback-reference", + "no-array-fill-with-reference-type", + "no-array-for-each", + "no-array-method-this-argument", + "no-array-reduce", + "no-array-reverse", + "no-array-sort", + "no-await-expression-member", + "no-await-in-promise-methods", + "no-confusing-array-with", + "no-console-spaces", + "no-document-cookie", + "no-empty-file", + "no-hex-escape", + "no-immediate-mutation", + "no-instanceof-builtins", + "no-invalid-fetch-options", + "no-invalid-remove-event-listener", + "no-lonely-if", + "no-magic-array-flat-depth", + "no-negated-condition", + "no-negation-in-equality-check", + "no-nested-ternary", + "no-new-array", + "no-new-buffer", + "no-null", + "no-object-as-default-parameter", + "no-process-exit", + "no-single-promise-in-promise-methods", + "no-static-only-class", + "no-thenable", + "no-this-assignment", + "no-typeof-undefined", + "no-unnecessary-array-flat-depth", + "no-unnecessary-array-splice-count", + "no-unnecessary-await", + "no-unnecessary-slice-end", + "no-unreadable-array-destructuring", + "no-unreadable-iife", + "no-useless-collection-argument", + "no-useless-error-capture-stack-trace", + "no-useless-fallback-in-spread", + "no-useless-iterator-to-array", + "no-useless-length-check", + "no-useless-promise-resolve-reject", + "no-useless-spread", + "no-useless-switch-case", + "no-useless-undefined", + "no-zero-fractions", + "number-literal-case", + "numeric-separators-style", + "prefer-add-event-listener", + "prefer-array-find", + "prefer-array-flat", + "prefer-array-flat-map", + "prefer-array-index-of", + "prefer-array-some", + "prefer-at", + "prefer-bigint-literals", + "prefer-blob-reading-methods", + "prefer-class-fields", + "prefer-classlist-toggle", + "prefer-code-point", + "prefer-date-now", + "prefer-default-parameters", + "prefer-dom-node-append", + "prefer-dom-node-remove", + "prefer-dom-node-text-content", + "prefer-event-target", + "prefer-export-from", + "prefer-global-this", + "prefer-includes", + "prefer-keyboard-event-key", + "prefer-logical-operator-over-ternary", + "prefer-math-min-max", + "prefer-math-trunc", + "prefer-modern-dom-apis", + "prefer-modern-math-apis", + "prefer-module", + "prefer-native-coercion-functions", + "prefer-negative-index", + "prefer-node-protocol", + "prefer-number-coercion", + "prefer-number-properties", + "prefer-object-from-entries", + "prefer-optional-catch-binding", + "prefer-prototype-methods", + "prefer-query-selector", + "prefer-reflect-apply", + "prefer-regexp-test", + "prefer-response-static-json", + "prefer-set-has", + "prefer-set-size", + "prefer-single-call", + "prefer-spread", + "prefer-string-raw", + "prefer-string-replace-all", + "prefer-string-slice", + "prefer-string-starts-ends-with", + "prefer-string-trim-start-end", + "prefer-structured-clone", + "prefer-ternary", + "prefer-top-level-await", + "prefer-type-error", + "relative-url-style", + "require-array-join-separator", + "require-module-attributes", + "require-module-specifiers", + "require-number-to-fixed-digits-argument", + "switch-case-braces", + "switch-case-break-position", + "text-encoding-identifier-case", + "throw-new-error", + ].map((rule) => [`unicorn/${rule}`, "error"]), +); + +export default defineConfig({ + plugins: ["typescript", "unicorn"], + jsPlugins: ["ai-deslop"], + ignorePatterns: ["workflows/**"], + categories: { + correctness: "off", + suspicious: "off", + pedantic: "off", + perf: "off", + style: "off", + restriction: "off", + nursery: "off", + }, + options: deslopConfigs.recommended.options, + rules: { + ...deslopConfigs.recommended.rules, + ...unicornRecommendedRules, + // Unicorn prefers Reflect.apply, while deslop rejects it outright. + "deslop/no-reflect-apply": "off", + }, + overrides: [ + { + files: ["src/workflows/df12-build-odw/**/*.ts"], + rules: { + // ODW and JSON contracts use null, including failed parallel slots. + "unicorn/no-null": "off", + // Untrusted values enter through ODW and filesystem boundaries. + "deslop/no-unknown-parameters": "off", + "deslop/no-unknown-returns": "off", + "deslop/no-unsafe-dictionary-type": "off", + // The dialect forbids validator libraries, so guards use typeof. + "deslop/no-runtime-typeof": "off", + }, + }, + ], +}); diff --git a/package.json b/package.json index 2bc44e5..d637fc6 100644 --- a/package.json +++ b/package.json @@ -2,18 +2,20 @@ "name": "df12-build", "private": true, "type": "module", - "scripts": { "docs:check": "typedoc --options typedoc.json" }, "devDependencies": { "@aboviq/bun-test-cucumber": "^0.2.0", "@types/bun": "^1.3.14", + "ai-deslop": "0.4.9", "ajv": "8.17.1", "esbuild": "^0.28.1", "fast-check": "^4.8.0", "lemmascript": "^0.5.9", "markdownlint-cli2": "^0.22.1", + "oxlint": "1.82.0", + "oxlint-tsgolint": "7.0.2004", "typedoc": "^0.28.20", "typescript": "^6.0.3", "yaml": "^2.9.0" diff --git a/src/workflows/df12-build-odw/assessment.ts b/src/workflows/df12-build-odw/assessment.ts index 3b33c0a..dca928e 100644 --- a/src/workflows/df12-build-odw/assessment.ts +++ b/src/workflows/df12-build-odw/assessment.ts @@ -17,7 +17,6 @@ import { } from './faults.ts' import { collectAssessmentEvidence } from './git-evidence.ts' import type { AssessmentEvidence } from './git-evidence.ts' -import type { HostReviewDeferral } from './host-review.ts' import { salvageTaskArtefacts } from './execplan-durability.ts' import type { SalvageOutcome } from './execplan-durability.ts' import type { RecoveryCandidate } from './types.ts' @@ -39,6 +38,12 @@ import type { RecoveryCandidate } from './types.ts' // away, so neither salvages. const SALVAGE_CLASSIFICATIONS = new Set(['continue-manual', 'adopt-partial', 'infra-fault']) +/** Extract a usable message from arbitrary values thrown by adapters. */ +function assessmentErrorMessage(error: unknown): string { + if (error && (typeof error === 'object' || typeof error === 'function') && 'message' in error && typeof error.message === 'string' && error.message) return error.message + return String(error) +} + /** * A salvage record carrying the classification that triggered (or skipped) it, * spread onto the assessment result. The result types extend @@ -70,6 +75,16 @@ export interface SalvageSummaryEntry { detail: string } +/** Aggregate salvage rows and their terminal-summary suffix. */ +export interface SalvageSummary { + /** One row per task that attempted salvage (committed or skipped). */ + salvages: SalvageSummaryEntry[] + /** Count of branches that actually committed artefacts. */ + salvagedBranches: number + /** Terminal-summary suffix text; empty when every attempt was a skip. */ + summarySuffix: string +} + /** * Aggregate per-task salvage records into the terminal run summary. Every task * whose result carries a `salvage` record contributes a row — including a @@ -85,24 +100,19 @@ export interface SalvageSummaryEntry { */ export function summarizeSalvages( results: ReadonlyArray<{ id?: string; salvage?: Partial | null }>, -): { - /** One row per task that attempted salvage (committed or skipped). */ - salvages: SalvageSummaryEntry[] - /** Count of branches that actually committed artefacts. */ - salvagedBranches: number - /** Terminal-summary suffix text; '' when every attempt was a skip. */ - summarySuffix: string -} { - const salvages: SalvageSummaryEntry[] = results - .filter((result) => result.salvage) - .map((result) => ({ +): SalvageSummary { + const salvages: SalvageSummaryEntry[] = results.flatMap((result) => { + const salvage = result.salvage + if (!salvage) return [] + return [{ id: result.id || '', - classification: result.salvage?.classification || '', - committed: result.salvage?.committed || [], - skipped: (result.salvage?.skipped || []).length, - sha: result.salvage?.sha || '', - detail: result.salvage?.detail || '', - })) + classification: salvage.classification || '', + committed: salvage.committed || [], + skipped: (salvage.skipped || []).length, + sha: salvage.sha || '', + detail: salvage.detail || '', + }] + }) const salvagedBranches = salvages.filter((entry) => entry.committed.length > 0).length const summarySuffix = salvagedBranches ? ` | salvaged artefacts on ${salvagedBranches} branch(es)` : '' return { salvages, salvagedBranches, summarySuffix } @@ -146,6 +156,23 @@ export interface AssessmentWorktree { baseSha?: string } +// Assessment helpers retain their fallback behaviour for partial host evidence. +type PartialAssessmentEvidence = { + [Field in keyof AssessmentEvidence]?: AssessmentEvidence[Field] | null +} + +/** Options supplied before the assessment adapter applies its routing. */ +type AssessmentInvocationOptions = { + /** Workflow phase associated with this assessment call. */ + phase: string + /** Label identifying the assessment call. */ + label: string + /** Structured output contract requested for the assessment call. */ + schema: typeof ASSESSMENT_SCHEMA + /** Escalation model used when host evidence warrants it. */ + model?: string +} + /** The run wiring `makeAssessment` binds once: the prompt preamble, the enable switch, adapter routing, the escalation model, and the shared infra-retry wrapper. */ export interface AssessmentDeps { /** Builds the shared prompt preamble for a given worktree path. */ @@ -181,7 +208,7 @@ export function fastAssessmentClassification( reason: string } | null { const collectionErrors = evidence?.collectionErrors || [] - if (collectionErrors.length) { + if (collectionErrors.length > 0) { return { classification: 'continue-manual', reason: `host evidence collection reported error(s) (${collectionErrors.slice(0, 3).join('; ')}); operator judgement required` } } const hasCommittedWork = (evidence?.recentCommits || []).length > 0 @@ -197,30 +224,49 @@ export function fastAssessmentClassification( // Build a schema-shaped assessment object for a deterministic classification, // carrying host evidence and a `classifier: 'deterministic'` marker so // operators can see it was not a model judgement. -function deterministicAssessment( +function deterministicAssessment( classification: string, - evidence: AssessmentEvidence, + evidence: Evidence, reason: string, -): Record { +) { return { + /** Deterministic disposition of the surviving branch. */ classification, + /** Branch name collected by the host. */ branchName: evidence.branchName || '', + /** Worktree path collected by the host. */ worktreePath: evidence.worktreePath || '', + /** Base commit from which the branch diverged. */ baseCommit: evidence.baseCommit || '', + /** Branch commit observed by the host. */ currentCommit: evidence.currentCommit || '', + /** Host-observed worktree cleanliness. */ dirtyState: evidence.dirtyState || 'unknown', + /** Paths observed as changed on the branch or worktree. */ changedFiles: evidence.changedFiles || [], + /** Whether the changed paths were judged task scoped. */ taskScoped: false, + /** ExecPlan assessment; empty when no model judgement was made. */ execPlan: '', + /** Roadmap assessment; empty when no model judgement was made. */ roadmap: '', + /** Validation assessment; empty when no model judgement was made. */ validation: '', + /** Required evidence not established by the assessment. */ missingEvidence: [], + /** Advisory residual risks for later review. */ residualRisk: [], + /** Risks identified during assessment. */ risks: [], + /** Reason for the deterministic disposition. */ rationale: reason, + /** Recommended disposition for the operator. */ recommendation: reason, + /** Follow-up actions identified by the assessment. */ nextActions: [], + /** Identifies the host classifier as the source of the disposition. */ classifier: 'deterministic', + /** Complete evidence collected by the host. */ hostEvidence: evidence, } } @@ -229,8 +275,8 @@ function deterministicAssessment( // collapse whitespace and bound the length so a single operator log line never // dumps an unbounded stderr blob. The full text still rides on the structured // `result.salvage.detail`; only the log line is bounded. -function boundedSalvageLogText(text: unknown, max = 200): string { - const flat = String(text ?? '').replace(/\s+/g, ' ').trim() +function boundedSalvageLogText(text: string, max = 200): string { + const flat = String(text).replaceAll(/\s+/g, ' ').trim() return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat } @@ -244,7 +290,7 @@ function boundedSalvageLogText(text: unknown, max = 200): string { async function salvageAssessmentArtefacts( taskId: string, worktree: string, - evidence: AssessmentEvidence, + evidence: Pick, classification: string, ): Promise { if (!SALVAGE_CLASSIFICATIONS.has(classification)) return null @@ -267,14 +313,14 @@ async function salvageAssessmentArtefacts( // path is diagnosable without scraping result.json. // sha is fixed-length and the counts are bounded, so only the free-text // detail needs collapsing/truncation before it reaches the log line. - if (outcome.committed.length) { - log(`[salvage] task ${taskId} (${classification}): committed ${outcome.committed.length} artefact(s) at ${outcome.sha || ''}${outcome.skipped.length ? `; skipped ${outcome.skipped.length}` : ''}`) + if (outcome.committed.length > 0) { + log(`[salvage] task ${taskId} (${classification}): committed ${outcome.committed.length} artefact(s) at ${outcome.sha || ''}${outcome.skipped.length > 0 ? `; skipped ${outcome.skipped.length}` : ''}`) } else { - log(`[salvage] task ${taskId} (${classification}): nothing committed — ${boundedSalvageLogText(outcome.detail || 'no eligible artefacts')}${outcome.skipped.length ? `; skipped ${outcome.skipped.length}` : ''}`) + log(`[salvage] task ${taskId} (${classification}): nothing committed — ${boundedSalvageLogText(outcome.detail || 'no eligible artefacts')}${outcome.skipped.length > 0 ? `; skipped ${outcome.skipped.length}` : ''}`) } return { classification, ...outcome } } catch (error) { - const detail = `salvage errored: ${((error as Error | null) && (error as Error).message) || String(error)}` + const detail = `salvage errored: ${assessmentErrorMessage(error)}` log(`[salvage] task ${taskId} (${classification}): ${boundedSalvageLogText(detail)}`) return { classification, committed: [], skipped: [], sha: '', detail } } @@ -305,15 +351,15 @@ function isInfraFaultResult(result: AssessableResult | null | undefined): boolea * Whether an open issue text describes a deferred/rate-limited host review * rather than a substantive product defect. * - * @param issue A single open-issue string (or any value, coerced to text). - * @returns True when the text names a host reviewer alongside a specific deferral marker. + * @param issue An untrusted open-issue value. + * @returns True when the value carries a structured host-review deferral. */ export function isDeferredReviewIssue(issue: unknown): boolean { if ( - issue !== null + issue && typeof issue === 'object' - && (issue as Partial).kind === 'host-review-deferral' - && ((issue as Partial).outcome === 'rate-limited' || (issue as Partial).outcome === 'error') + && 'kind' in issue && issue.kind === 'host-review-deferral' + && 'outcome' in issue && (issue.outcome === 'rate-limited' || issue.outcome === 'error') ) return true return false } @@ -327,7 +373,7 @@ export function isDeferredReviewIssue(issue: unknown): boolean { */ export function hasOnlyDeferredReviewIssues(openIssues: readonly unknown[] | null | undefined): boolean { const issues = openIssues || [] - return issues.length > 0 && issues.every(isDeferredReviewIssue) + return issues.length > 0 && issues.every((issue) => isDeferredReviewIssue(issue)) } /** @@ -362,6 +408,13 @@ export function addendumImplementationNeedsManualMerge(impl: ImplementationRepor return Number.isFinite(completed) && Number.isFinite(total) && total > 0 && completed >= total } +// Escalate the single model call when host evidence includes a committed +// ExecPlan: a potential adopt-complete decision has higher stakes. +function assessmentModelTier(evidence: Pick): 'escalated' | 'medium' { + const hasExecplan = (evidence.changedFiles || []).some((entry) => /^docs\/execplans\/.+\.md$/.test(String(entry))) + return hasExecplan ? 'escalated' : 'medium' +} + /** * Bind the ADR 002 assessment run wiring once (preamble, adapter routing, * escalation model, retry, enable switch) and return the assessment surface: @@ -418,12 +471,12 @@ export function makeAssessment({ preamble, assessPartialBranches, assessmentAgen '', 'Host-collected git evidence:', '```json', - JSON.stringify(evidence, null, 2), + JSON.stringify(evidence, undefined, 2), '```', '', contextTitle, '```json', - JSON.stringify(contextValue, null, 2), + JSON.stringify(contextValue, undefined, 2), '```', '', 'Return only the schema-bound assessment object. Free-text recommendations do not drive integration; make the enum classification and evidence fields precise.', @@ -459,15 +512,6 @@ export function makeAssessment({ preamble, assessPartialBranches, assessmentAgen ) } - // A committed ExecPlan on the branch marks a strong adopt-complete candidate, - // so the genuinely high-stakes adopt decision uses the escalation model; - // every other ambiguous branch uses the medium default. One model call - // either way (the tier is chosen from host evidence, not by re-running). - function assessmentModelTier(evidence: AssessmentEvidence): 'escalated' | 'medium' { - const hasExecplan = (evidence.changedFiles || []).some((entry) => /^docs\/execplans\/.+\.md$/.test(String(entry))) - return hasExecplan ? 'escalated' : 'medium' - } - // Run the assessment model on a genuinely-ambiguous branch at the // evidence-chosen tier. Returns the assessment object (tier-tagged) or null. async function runModelAssessment( @@ -477,10 +521,14 @@ export function makeAssessment({ preamble, assessPartialBranches, assessmentAgen evidence: AssessmentEvidence, ): Promise | null> { const tier = assessmentModelTier(evidence) - const options: Record = { phase: phaseName, label, schema: ASSESSMENT_SCHEMA } + const options: AssessmentInvocationOptions = { phase: phaseName, label, schema: ASSESSMENT_SCHEMA } if (tier === 'escalated') options.model = assessmentEscalationModel - const assessment = (await withInfraRetry(() => agent(buildPrompt(), assessmentAgentOptions(options)), label)) as Record | null + const assessment = await withInfraRetry( + () => agent(buildPrompt(), { ...assessmentAgentOptions(options), schema: ASSESSMENT_SCHEMA }), + label, + ) if (!assessment) return null + if (typeof assessment !== 'object' || Array.isArray(assessment)) throw new Error('assessment agent returned a malformed structured output') return { ...assessment, assessmentTier: tier } } @@ -522,7 +570,11 @@ export function makeAssessment({ preamble, assessPartialBranches, assessmentAgen /** Host-collected git evidence for the candidate branch. */ evidence, /** The assessment object (deterministic or model), or null on failure. */ - assessment: { ...assessment, hostEvidence: evidence }, + assessment: { + ...assessment, + /** Complete evidence collected by the host. */ + hostEvidence: evidence, + }, /** Error text when no structured assessment was produced; '' otherwise. */ assessmentError: '', } @@ -533,14 +585,14 @@ export function makeAssessment({ preamble, assessPartialBranches, assessmentAgen /** The assessment object (deterministic or model), or null on failure. */ assessment: null, /** Error text when no structured assessment was produced; '' otherwise. */ - assessmentError: ((error as Error | null) && (error as Error).message) || String(error), + assessmentError: assessmentErrorMessage(error), } } } function shouldAssessFailure(result: AssessableResult | null | undefined, wt: AssessmentWorktree | null | undefined): boolean { if (!assessPartialBranches) return false - if (!wt?.branch || !wt?.worktreePath) return false + if (!wt?.branch || !wt.worktreePath) return false if (!result || !['failed', 'halted'].includes(result.status || '')) return false if (result.stage === 'worktree' || result.stage === 'worktree-write' || result.stage === 'auth' || result.stage === 'provider' || result.stage === 'infrastructure' || result.status === 'fatal-auth' || result.status === 'provider-fault' || result.status === 'infra-fault') return false const detail = [result.detail, ...(result.openIssues || [])].filter(Boolean).join('\n') @@ -557,7 +609,7 @@ export function makeAssessment({ preamble, assessPartialBranches, assessmentAgen // through untouched. async function salvageInfraFaultArtefacts( task: { id: string; title?: string }, - wt: AssessmentWorktree, + wt: AssessmentWorktree | null | undefined, result: T, ): Promise { if (!assessPartialBranches || !wt?.branch) return result @@ -567,7 +619,7 @@ export function makeAssessment({ preamble, assessPartialBranches, assessmentAgen // salvage short-circuits on its no-worktree guard before touching evidence. const evidence = worktree ? await collectAssessmentEvidence(task, wt) - : ({ dirtyChanges: [], stagedChanges: [] } as unknown as AssessmentEvidence) + : { dirtyChanges: [], stagedChanges: [] } const salvage = await salvageAssessmentArtefacts(task.id, worktree, evidence, 'infra-fault') return salvage ? { ...result, salvage } : result } @@ -597,7 +649,9 @@ export function makeAssessment({ preamble, assessPartialBranches, assessmentAgen const salvage: SalvageRecord | null = fast.classification === 'continue-manual' ? { classification: fast.classification, committed: [], skipped: [], sha: '', detail: 'salvage skipped: deterministic continue-manual fires on untrustworthy host evidence' } : null - return { ...result, assessment: deterministicAssessment(fast.classification, evidence, fast.reason), ...(salvage ? { salvage } : {}) } + const assessed = { ...result, assessment: deterministicAssessment(fast.classification, evidence, fast.reason) } + if (salvage) return { ...assessed, salvage } + return assessed } try { const assessment = await runModelAssessment(() => assessmentPrompt(task, wt, result, evidence), 'Assess', `assess:${task.id}`, evidence) @@ -605,11 +659,13 @@ export function makeAssessment({ preamble, assessPartialBranches, assessmentAgen return { ...result, assessmentError: 'assessment agent returned no structured output', assessmentEvidence: evidence } } const salvage = await salvageAssessmentArtefacts(task.id, wt.worktreePath || '', evidence, String(assessment.classification || '')) - return { ...result, assessment: { ...assessment, hostEvidence: evidence }, ...(salvage ? { salvage } : {}) } + const assessed = { ...result, assessment: { ...assessment, hostEvidence: evidence } } + if (salvage) return { ...assessed, salvage } + return assessed } catch (error) { return { ...result, - assessmentError: ((error as Error | null) && (error as Error).message) || String(error), + assessmentError: assessmentErrorMessage(error), assessmentEvidence: evidence, } } diff --git a/src/workflows/df12-build-odw/auth-preflight.ts b/src/workflows/df12-build-odw/auth-preflight.ts index 1583c2a..f78fba7 100644 --- a/src/workflows/df12-build-odw/auth-preflight.ts +++ b/src/workflows/df12-build-odw/auth-preflight.ts @@ -131,10 +131,11 @@ export function makeAuthPreflight(config: AuthPreflightConfig, deps: AuthPreflig if (config.requireHostReviewAuth) { if (config.reviewTool === 'dakar') { const openaiKey = deps.environment.get('OPENAI_API_KEY') + const sensitiveValues = typeof openaiKey === 'string' ? [openaiKey] : [] const dakarWords = config.dakarInvocation.filter((argument) => !ENVIRONMENT_ASSIGNMENT.test(argument)) const dakarExecutable = dakarWords[0] || 'dakar-review' const dakar = await deps.exec(dakarExecutable, [...dakarWords.slice(1), '--version'], { timeoutMs: AUTH_PROBE_TIMEOUT_MS }) - const dakarOutput = redactedDakarStatusDetail(dakar, config.dakarInvocation, typeof openaiKey === 'string' ? [openaiKey] : []) + const dakarOutput = redactedDakarStatusDetail(dakar, config.dakarInvocation, sensitiveValues) if (!dakar.ok) { deps.recordHostReviewAuthFailure() failures.push({ @@ -144,7 +145,7 @@ export function makeAuthPreflight(config: AuthPreflightConfig, deps: AuthPreflig }) } const pi = await deps.exec('pi', ['--version'], { timeoutMs: AUTH_PROBE_TIMEOUT_MS }) - const piOutput = redactedDakarStatusDetail(pi, config.dakarInvocation, typeof openaiKey === 'string' ? [openaiKey] : []) + const piOutput = redactedDakarStatusDetail(pi, config.dakarInvocation, sensitiveValues) if (!pi.ok) { deps.recordHostReviewAuthFailure() failures.push({ tool: 'dakar', command: 'pi --version', detail: piOutput || 'pi is unavailable or its version probe failed' }) @@ -171,7 +172,7 @@ export function makeAuthPreflight(config: AuthPreflightConfig, deps: AuthPreflig } } - if (failures.length) { + if (failures.length > 0) { deps.log(`[auth] fatal preflight failure: ${failures.map((failure) => `${failure.tool}: ${failure.detail.split(/\r?\n/)[0]}`).join('; ')}`) } else { const passed = ['Codex'] diff --git a/src/workflows/df12-build-odw/coderabbit-review.ts b/src/workflows/df12-build-odw/coderabbit-review.ts index d9ae2b3..b32dbe4 100644 --- a/src/workflows/df12-build-odw/coderabbit-review.ts +++ b/src/workflows/df12-build-odw/coderabbit-review.ts @@ -14,13 +14,22 @@ import { type HostReviewConfig, type HostReviewDeps, type HostReviewAttempt, + type HostReviewTraceContext, type ReviewErrorCategory, type ReviewFinding, type ReviewOutcome, } from './host-review-contracts.ts' +/** A value decoded from CodeRabbit's JSON event stream. */ +export type CoderabbitJsonValue = string | number | boolean | null | CoderabbitJsonValue[] | CoderabbitJsonObject + +/** An event object retains additional JSON fields from newer CodeRabbit versions. */ +export interface CoderabbitJsonObject { + [key: string]: CoderabbitJsonValue | undefined +} + /** Terminal CodeRabbit error event retained for outcome classification. */ -export interface CoderabbitError extends Record { +export interface CoderabbitError extends CoderabbitJsonObject { /** CodeRabbit error discriminator such as `rate_limit`. */ errorType?: string /** Reviewer-supplied error description. */ @@ -30,13 +39,13 @@ export interface CoderabbitError extends Record { /** Parsed CodeRabbit NDJSON events and terminal records. */ export interface CoderabbitParsedOutput { /** Parsed NDJSON events in emission order. */ - events: Array> + events: CoderabbitJsonObject[] /** Non-JSON lines retained as bounded diagnostic evidence. */ rawLines: string[] /** Finding events narrowed to the neutral finding shape. */ findings: ReviewFinding[] /** Terminal completion event, or null when no completion was emitted. */ - complete: Record | null + complete: CoderabbitJsonObject | null /** First terminal error event, or null when none was emitted. */ error: CoderabbitError | null } @@ -44,53 +53,137 @@ export interface CoderabbitParsedOutput { /** Success sentinels observed in CodeRabbit terminal completion events. */ export const CODERABBIT_SUCCESS_STATUSES = new Set(['review_completed', 'reviewed']) +/** JSON arrays and scalar values are not NDJSON event objects. */ +function isCoderabbitJsonObject(value: CoderabbitJsonValue): value is CoderabbitJsonObject { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +/** An absent optional field is valid; a present one must be a string. */ +function isOptionalCoderabbitString(value: CoderabbitJsonValue | undefined): value is string | undefined { + return value === undefined || typeof value === 'string' +} + +/** Check the finding fields consumed by the shared host-review surface. */ +function isCoderabbitFinding(event: CoderabbitJsonObject): event is CoderabbitJsonObject & ReviewFinding { + return event.type === 'finding' + && isOptionalCoderabbitString(event.severity) + && isOptionalCoderabbitString(event.fileName) + && isOptionalCoderabbitString(event.comment) + && isOptionalCoderabbitString(event.codegenInstructions) + && (event.suggestions === undefined || Array.isArray(event.suggestions)) +} + +/** Validate the error fields consumed by outcome classification. */ +function isCoderabbitError(event: CoderabbitJsonObject): event is CoderabbitError { + return event.type === 'error' + && isOptionalCoderabbitString(event.errorType) + && isOptionalCoderabbitString(event.message) +} + +/** A terminal completion has a status and, when present, a numeric count. */ +function isCoderabbitCompletion(event: CoderabbitJsonObject): boolean { + return event.type === 'complete' + && typeof event.status === 'string' + && (event.findings === undefined || (typeof event.findings === 'number' && Number.isFinite(event.findings))) +} + /** Parse CodeRabbit's NDJSON event stream; exit status is not its verdict. */ -export function parseCoderabbitAgentOutput(stdout: unknown): CoderabbitParsedOutput { - const events: Array> = [] +export function parseCoderabbitAgentOutput(stdout: string): CoderabbitParsedOutput { + const events: CoderabbitJsonObject[] = [] const rawLines: string[] = [] + const findings: ReviewFinding[] = [] + let complete: CoderabbitJsonObject | null = null + let error: CoderabbitError | null = null + let invalidReason = '' for (const line of String(stdout || '').split(/\r?\n/)) { const trimmed = line.trim() if (!trimmed) continue try { - const event = JSON.parse(trimmed) - if (event && typeof event === 'object') { events.push(event); continue } + const event: CoderabbitJsonValue = JSON.parse(trimmed) + if (isCoderabbitJsonObject(event)) { + events.push(event) + if (typeof event.type !== 'string') { + invalidReason ||= 'Invalid CodeRabbit event type' + } else if (event.type === 'finding') { + if (isCoderabbitFinding(event)) findings.push(event) + else invalidReason ||= 'Invalid CodeRabbit finding event' + } else if (event.type === 'complete') { + if (isCoderabbitCompletion(event)) complete ||= event + else invalidReason ||= 'Invalid CodeRabbit completion event' + } else if (event.type === 'error') { + if (isCoderabbitError(event)) error ||= event + else invalidReason ||= 'Invalid CodeRabbit error event' + } + continue + } } catch { // Keep malformed lines as bounded diagnostic evidence. } rawLines.push(trimmed) } - return { events, rawLines, findings: events.filter((event) => event.type === 'finding') as ReviewFinding[], complete: events.find((event) => event.type === 'complete') || null, error: (events.find((event) => event.type === 'error') as CoderabbitParsedOutput['error']) || null } + if (invalidReason && !error) error = { type: 'error', errorType: 'invalid_output', message: invalidReason } + return { events, rawLines, findings, complete, error } +} + +/** Preserve actionable authentication evidence even when its event is malformed. */ +function malformedCoderabbitAuthEvidence(parsed: CoderabbitParsedOutput): string { + for (const event of parsed.events) { + if (event.type === 'error' && !isCoderabbitError(event) && typeof event.message === 'string') { + const detail = authFailureDetail(event.message) + if (detail) return detail + } + } + return '' } /** Classify parsed CodeRabbit events into the shared outcome vocabulary. */ export function classifyCoderabbitOutcome(execResult: { ok?: boolean; stderr?: string; message?: string }, parsed: CoderabbitParsedOutput): ReviewOutcome { - const errorText = [parsed.error?.message || '', execResult.stderr || '', execResult.message || ''].join('\n') + const errorText = [parsed.error?.message || '', malformedCoderabbitAuthEvidence(parsed), execResult.stderr || '', execResult.message || ''].join('\n') if (parsed.error?.errorType === 'rate_limit' || /\brate.?limit|review limit reached/i.test(errorText)) return 'rate-limited' if (authFailureDetail(errorText)) return 'auth' if (parsed.error || (!execResult.ok && !parsed.complete)) return 'error' - if (parsed.findings.length) return 'findings' + if (parsed.findings.length > 0) return 'findings' return parsed.complete && CODERABBIT_SUCCESS_STATUSES.has(String(parsed.complete.status)) ? 'clean' : 'error' } +/** Classify execution failures without changing the review outcome. */ +function coderabbitErrorCategory(result: ExecStatus, outcome: ReviewOutcome, parsed: CoderabbitParsedOutput): ReviewErrorCategory { + if (result.killed) return 'timeout' + if (outcome === 'rate-limited') return 'deferred' + if (outcome === 'auth') return 'auth' + if (parsed.error?.errorType === 'invalid_output') return 'invalid-output' + if (outcome === 'error') return parsed.error || parsed.complete ? 'execution' : 'invalid-output' + return 'none' +} + +/** Read a bounded elapsed duration from the monotonic clock. */ +function elapsedSince(startedMs: number): number { + const nowMs = Number(process.hrtime.bigint() / 1_000_000n) + return Math.max(0, nowMs - startedMs) +} + /** Bind one CodeRabbit attempt to the shared timeout and base-branch config. */ export function makeCoderabbitAttempt(config: Pick): (worktree: string, exec: NonNullable, deps?: HostReviewDeps) => Promise { return async function runCoderabbitAttempt(worktree, exec, deps = {}) { const tracer = deps.tracer || NOOP_HOST_REVIEW_TRACER const traceContext = deps.traceContext || { runId: 'host-review' } const startedMs = Number(process.hrtime.bigint() / 1_000_000n) - const span = tracer.startSpan('host-review.coderabbit.subprocess', { runId: boundedTail(traceContext.runId || 'host-review', 120), ...(traceContext.taskId ? { taskId: boundedTail(traceContext.taskId, 120) } : {}) }, { reviewer: 'coderabbit', attempt: deps.attempt || 1 }) + const spanContext: HostReviewTraceContext = { runId: boundedTail(traceContext.runId || 'host-review', 120) } + if (traceContext.taskId) spanContext.taskId = boundedTail(traceContext.taskId, 120) + const span = tracer.startSpan('host-review.coderabbit.subprocess', spanContext, { reviewer: 'coderabbit', attempt: deps.attempt || 1 }) let result: ExecStatus try { result = await exec('coderabbit', ['review', '--agent', '--type', 'committed', '--base', config.base], { cwd: worktree, timeoutMs: config.reviewTimeoutSeconds * 1000 }) } catch (error) { - span.end({ reviewer: 'coderabbit', attempt: deps.attempt || 1, outcome: 'error', errorCategory: 'execution', timeout: false, elapsedMs: Math.max(0, Number(process.hrtime.bigint() / 1_000_000n) - startedMs) }) + span.end({ reviewer: 'coderabbit', attempt: deps.attempt || 1, outcome: 'error', errorCategory: 'execution', timeout: false, elapsedMs: elapsedSince(startedMs) }) throw error } const parsed = parseCoderabbitAgentOutput(result.stdout) const outcome = classifyCoderabbitOutcome(result, parsed) - const detail = outcome === 'clean' || outcome === 'findings' ? '' : (parsed.error?.message || result.message || result.stderr || parsed.rawLines.join('; ') || 'coderabbit produced no parsable outcome').trim() - const errorCategory: ReviewErrorCategory = result.killed ? 'timeout' : outcome === 'rate-limited' ? 'deferred' : outcome === 'auth' ? 'auth' : outcome === 'error' ? (parsed.error || parsed.complete ? 'execution' : 'invalid-output') : 'none' - span.end({ reviewer: 'coderabbit', attempt: deps.attempt || 1, outcome, errorCategory, timeout: errorCategory === 'timeout', elapsedMs: Math.max(0, Number(process.hrtime.bigint() / 1_000_000n) - startedMs) }) + const authEvidence = outcome === 'auth' ? malformedCoderabbitAuthEvidence(parsed) : '' + const detail = outcome === 'clean' || outcome === 'findings' ? '' : (authEvidence || parsed.error?.message || result.message || result.stderr || parsed.rawLines.join('; ') || 'coderabbit produced no parsable outcome').trim() + const errorCategory = coderabbitErrorCategory(result, outcome, parsed) + span.end({ reviewer: 'coderabbit', attempt: deps.attempt || 1, outcome, errorCategory, timeout: errorCategory === 'timeout', elapsedMs: elapsedSince(startedMs) }) return { outcome, findings: parsed.findings, detail: boundedTail(detail), errorCategory } } } diff --git a/src/workflows/df12-build-odw/config.ts b/src/workflows/df12-build-odw/config.ts index bf3d232..be50c2e 100644 --- a/src/workflows/df12-build-odw/config.ts +++ b/src/workflows/df12-build-odw/config.ts @@ -168,6 +168,50 @@ export interface RawWorkflowArgs { commitGates?: unknown } +// The compatibility reader must accept legacy keys while the public input +// contract continues to mark those keys as deprecated for callers. +type CompatibilityWorkflowArgs = Omit & { + resumePartialBranches?: unknown + coderabbitBetweenWorkItems?: boolean + coderabbitAttempts?: number | string + coderabbitBackoffMinutes?: unknown + coderabbitFindingsFile?: string +} + +function isConfigRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +// Validate the compatibility boundary before treating dynamic ODW arguments +// as documented options. Unknown keys remain available to future versions. +function isCompatibilityWorkflowArgs(value: Record): value is CompatibilityWorkflowArgs { + const strings = [ + 'projectRoot', 'base', 'roadmap', 'designDocs', 'researchNote', 'taskId', 'resumeMode', + 'writeProbeEffort', 'searchBackend', 'codeSearchBackend', 'grepaiWorkspace', 'grepaiProject', + 'project', 'memtraceRepoId', 'buildAdapter', 'planAdapter', 'reviewAdapter', 'auditAdapter', + 'triageAdapter', 'assessmentAdapter', 'buildModel', 'planModel', 'reviewModel', 'auditModel', + 'auditEffort', 'triageModel', 'triageEffort', 'triageEscalationModel', 'assessmentModel', + 'assessmentEscalationModel', 'reviewTool', 'dakarCommand', 'coderabbitReviewCommand', + 'coderabbitFindingsFile', 'hostReviewFindingsFile', 'csCheckCommand', + ] + const numbers = ['maxTasks', 'maxParallel', 'maxPlanningParallel', 'maxPlanParallel', 'maxBuildParallel', 'maxDesignRounds', 'maxReviewRounds'] + const numericStrings = [ + 'stageAttempts', 'maxWorkItemRounds', 'resumeTaskId', 'resumeMaxCandidates', 'reviewTimeoutSeconds', + 'dakarTimeoutSeconds', 'dakarBudgetGbp', 'coderabbitAttempts', 'hostReviewAttempts', 'commitGateTimeoutSeconds', + ] + const booleans = [ + 'perWorkItemBuild', 'autoMerge', 'documentAudit', 'dryRun', 'authPreflight', 'requireCoderabbitAuth', + 'assessPartialBranches', 'worktreeWritePreflight', 'coderabbitHostReview', + 'coderabbitBetweenWorkItems', 'hostReviewBetweenWorkItems', 'hostCommitGates', 'csCheck', 'hostGatesBetweenWorkItems', + ] + const valid = (keys: string[], types: string[]) => keys.every((key) => value[key] === undefined || types.includes(typeof value[key])) + if (!valid(strings, ['string']) || !valid(numbers, ['number']) || !valid(numericStrings, ['number', 'string']) || !valid(booleans, ['boolean'])) return false + const models = value.writeProbeModelByAdapter + return models === undefined || (models !== null && typeof models === 'object' && !Array.isArray(models) && Object.values(models).every((model) => typeof model === 'string')) +} + /** Supported host-review adapters after configuration normalization. */ export type ReviewTool = 'dakar' | 'coderabbit' const REVIEW_TOOLS = ['dakar', 'coderabbit'] as const @@ -349,8 +393,12 @@ export interface WorkflowConfig { * or missing argument yields the full default configuration. Throws when * `resumeMode` is not one of `assess`, `review`, or `continue`. */ -export function makeConfig(rawArgs: Record | null | undefined): WorkflowConfig { - const cfg = (rawArgs || {}) as RawWorkflowArgs +export function makeConfig(rawArgs: unknown): WorkflowConfig { + if (rawArgs !== null && rawArgs !== undefined && !isConfigRecord(rawArgs)) { + throw new Error('Invalid workflow argument type') + } + const cfg: Record = rawArgs ?? {} + if (!isCompatibilityWorkflowArgs(cfg)) throw new Error('Invalid workflow argument type') const PROJECT_ROOT = cfg.projectRoot || process.cwd() const BASE = cfg.base || 'main' // integration branch: rebase + squash-merge target, roadmap source of truth const ROADMAP = cfg.roadmap || 'docs/roadmap.md' @@ -453,7 +501,8 @@ export function makeConfig(rawArgs: Record | null | undefined): const REVIEW_TOOL = reviewToolInput const HOST_REVIEWER_NAME = reviewerDisplayName(REVIEW_TOOL) const DAKAR_COMMAND = String(cfg.dakarCommand || 'dakar-review') - const REVIEW_TIMEOUT_SECONDS = Math.min(7200, Math.max(60, Math.trunc(Number(cfg.reviewTimeoutSeconds ?? cfg.dakarTimeoutSeconds) || 3600))) + const reviewTimeoutInput = Number(cfg.reviewTimeoutSeconds ?? cfg.dakarTimeoutSeconds) || 3600 + const REVIEW_TIMEOUT_SECONDS = Math.min(7200, Math.max(60, Math.trunc(reviewTimeoutInput))) const DAKAR_BUDGET_GBP_RAW = Number(cfg.dakarBudgetGbp) const DAKAR_BUDGET_GBP = Number.isFinite(DAKAR_BUDGET_GBP_RAW) ? Math.min(10, Math.max(0, DAKAR_BUDGET_GBP_RAW)) : 0 // LEGACY (agent-run) mode ONLY: the command the build/fix prompts tell the @@ -475,7 +524,7 @@ export function makeConfig(rawArgs: Record | null | undefined): const attemptsInput = Number(cfg.hostReviewAttempts ?? cfg.coderabbitAttempts) // NaN deliberately reaches the established default fallback; only infinities reject. if (!Number.isFinite(attemptsInput) && !Number.isNaN(attemptsInput)) { - throw new Error('hostReviewAttempts must be finite') + throw new TypeError('hostReviewAttempts must be finite') } const HOST_REVIEW_ATTEMPTS = Math.min(10, Math.max(1, Math.trunc(attemptsInput || 3))) // total attempts per host review when rate limited const HOST_REVIEW_BACKOFF_MINUTES: [number, number] = (() => { @@ -498,9 +547,9 @@ export function makeConfig(rawArgs: Record | null | undefined): // is the df12 house default, but it is NOT universal: some projects alias // `all` to a release build, so operators must be able to name the authoritative // gate targets (e.g. sequential check-fmt/typecheck/lint/test) via args. - const COMMIT_GATES = (Array.isArray(cfg.commitGates) && cfg.commitGates.length + const COMMIT_GATES = (Array.isArray(cfg.commitGates) && cfg.commitGates.length > 0 ? cfg.commitGates - : ['make all']).map((command) => String(command)) + : ['make all']).map(String) const COMMIT_GATE_TEXT = COMMIT_GATES.map((command) => `\`${command}\``).join(' then ') // Host-run commit gates: the control loop re-runs the configured gate // commands against committed HEAD before review and integration, so a @@ -547,6 +596,15 @@ export function makeConfig(rawArgs: Record | null | undefined): ? `Delegate deterministic gate execution to the \`scrutineer\` sub-agent: ask it to run the repository commit gates/test suites. The scrutineer must not edit tracked files; use its structured failure report to make fixes yourself, then summon it again until the gates are green. ${CODERABBIT_REVIEW_GUIDANCE}` : `Delegate deterministic gate execution and CodeRabbit invocation to the \`scrutineer\` sub-agent: ask it to run the repository commit gates/test suites and, only after those pass, to run \`${CODERABBIT_REVIEW_COMMAND}\` from inside the worktree. The scrutineer must not edit tracked files; use its structured failure report to make fixes yourself, then summon it again until gates and CodeRabbit are green or a documented rate-limit/deferred-review open issue remains. ${CODERABBIT_REVIEW_GUIDANCE}` + // Keep the deprecated output keys for existing consumers while deriving + // their values from the canonical host-review settings. + const legacyHostReviewAliases = { + CODERABBIT_BETWEEN_WORK_ITEMS: HOST_REVIEW_BETWEEN_WORK_ITEMS, + CODERABBIT_ATTEMPTS: HOST_REVIEW_ATTEMPTS, + CODERABBIT_BACKOFF_MINUTES: HOST_REVIEW_BACKOFF_MINUTES, + CODERABBIT_FINDINGS_FILE: HOST_REVIEW_FINDINGS_FILE, + } + return { PROJECT_ROOT, BASE, @@ -618,10 +676,7 @@ export function makeConfig(rawArgs: Record | null | undefined): COMMIT_GATE_GUIDANCE, CS_CHECK_GUIDANCE, CODERABBIT_REVIEW_GUIDANCE, - CODERABBIT_BETWEEN_WORK_ITEMS: HOST_REVIEW_BETWEEN_WORK_ITEMS, - CODERABBIT_ATTEMPTS: HOST_REVIEW_ATTEMPTS, - CODERABBIT_BACKOFF_MINUTES: HOST_REVIEW_BACKOFF_MINUTES, - CODERABBIT_FINDINGS_FILE: HOST_REVIEW_FINDINGS_FILE, + ...legacyHostReviewAliases, SPARK_DELEGATION_GUIDANCE, SCRUTINEER_DELEGATION_GUIDANCE, } diff --git a/src/workflows/df12-build-odw/dakar-review.ts b/src/workflows/df12-build-odw/dakar-review.ts index f2ab0ed..0c832a1 100644 --- a/src/workflows/df12-build-odw/dakar-review.ts +++ b/src/workflows/df12-build-odw/dakar-review.ts @@ -15,6 +15,7 @@ import { type HostReviewConfig, type HostReviewDeps, type HostReviewAttempt, + type HostReviewTraceContext, type ReviewErrorCategory, type ReviewFinding, } from './host-review-contracts.ts' @@ -26,7 +27,7 @@ export interface DakarFinding extends Record { /** Repository-relative path. */ path?: string /** Optional one-based source line. */ - line?: number + line?: number | null /** Short finding title. */ title?: string /** Finding explanation. */ @@ -38,27 +39,49 @@ export interface DakarFinding extends Record { /** Dakar's single-document stdout contract before boundary validation. */ export interface DakarDocument extends Record { /** Whether Dakar completed its review protocol successfully. */ - ok?: boolean + ok?: unknown /** Whether Dakar intentionally skipped review. */ - skipped?: boolean + skipped?: unknown /** Terminal verdict such as `pass` or `changes-requested`. */ - verdict?: string + verdict?: unknown /** Dakar execution stage, including the exact deferred stage. */ - stage?: string + stage?: unknown /** Reviewer-supplied error detail. */ - error?: string + error?: unknown /** Untrusted findings projection validated at the host boundary. */ - findings?: DakarFinding[] + findings?: unknown +} + +/** String-keyed mapping from Dakar severities to the host-review vocabulary. */ +export interface DakarSeverityMap { + /** The host-review severity for a normalized Dakar severity name. */ + [severity: string]: string } /** Map validated Dakar severities onto the retained blocking vocabulary. */ -export const DAKAR_SEVERITY_MAP: Record = { +export const DAKAR_SEVERITY_MAP: DakarSeverityMap = { critical: 'critical', high: 'major', medium: 'minor', low: 'trivial', } const DAKAR_SEVERITIES = new Set(Object.keys(DAKAR_SEVERITY_MAP)) const DAKAR_REQUIRED_FINDING_FIELDS = ['path', 'title', 'detail', 'evidence'] as const -const DAKAR_PARENT_TIMEOUT_GRACE_MS = 5_000 +const DAKAR_PARENT_TIMEOUT_GRACE_MS = 5000 + +/** Read a string property from a thrown value without trusting its shape. */ +function dakarErrorProperty(error: unknown, property: 'message' | 'name'): string | undefined { + if (error && (typeof error === 'object' || typeof error === 'function')) { + const value = property === 'message' + ? ('message' in error ? error.message : undefined) + : ('name' in error ? error.name : undefined) + if (typeof value === 'string' && value) return value + } + return undefined +} + +/** Read a monotonic clock only for bounded span durations. */ +function dakarNowMs(): number { + return Number(process.hrtime.bigint() / 1_000_000n) +} /** Redact explicitly supplied values from untrusted Dakar protocol text. */ function redactDakarDetail(detail: string, sensitiveValues: readonly string[] = []): string { @@ -79,14 +102,14 @@ function dakarDiagnosticRedactions(invocation: readonly string[], extraValues: r const inlineValue = inlineOption?.[2] const assignmentValue = assignment?.[1] if (inlineValue !== undefined) values.push(argument, inlineValue) - else if (assignmentValue !== undefined) values.push(argument, assignmentValue) - else values.push(argument) + else if (assignmentValue === undefined) values.push(argument) + else values.push(argument, assignmentValue) } return values } /** Dakar-only state-root lifecycle used by one isolated reviewer attempt. */ -export interface DakarAttemptDeps { +export interface DakarAttemptDeps extends Pick { /** Optional state-root allocation service used by deterministic tests. */ dakarStateRoots?: DakarStateRoots /** Deprecated cleanup seam retained for compatibility with older callers. */ @@ -145,8 +168,11 @@ export function parseDakarDocument(stdout: unknown): DakarDocument | null { const document = text.slice(index, terminalEnd + 1) if (document.length > 64_000) return null try { - const parsed = JSON.parse(document) - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as DakarDocument : null + const parsed: unknown = JSON.parse(document) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null + // SAFETY: This is a JSON object; its optional document fields remain + // unknown until the outcome-specific checks below validate them. + return parsed as DakarDocument } catch { return null } @@ -156,7 +182,10 @@ export function parseDakarDocument(stdout: unknown): DakarDocument | null { /** Map one validated Dakar finding onto the retained findings contract. */ export function mapDakarFinding(finding: DakarFinding, sensitiveValues: readonly string[] = []): ReviewFinding { - const severity = DAKAR_SEVERITY_MAP[String(finding.severity || '').toLowerCase()] || 'info' + const sourceSeverity = String(finding.severity || '').toLowerCase() + const severity = Object.hasOwn(DAKAR_SEVERITY_MAP, sourceSeverity) + ? DAKAR_SEVERITY_MAP[sourceSeverity] || 'info' + : 'info' const filePath = redactDakarDetail(String(finding.path || ''), sensitiveValues).slice(0, 2000) const title = redactDakarDetail(String(finding.title || ''), sensitiveValues) const detail = redactDakarDetail(String(finding.detail || ''), sensitiveValues) @@ -168,16 +197,20 @@ export function mapDakarFinding(finding: DakarFinding, sensitiveValues: readonly /** Validate a non-empty, structurally complete change-requested collection. */ export function validateChangesRequestedFindings(raw: unknown): DakarFindingValidation | DakarFindingValidationFailure { - const findings = Array.isArray(raw) ? raw : null - if (!findings || findings.length === 0) return { ok: false, detail: 'Dakar returned changes-requested without any findings; refusing to treat a reviewer rejection as non-blocking' } + if (!Array.isArray(raw) || raw.length === 0) return { ok: false, detail: 'Dakar returned changes-requested without any findings; refusing to treat a reviewer rejection as non-blocking' } + const findings = raw for (const [index, finding] of findings.entries()) { - if (finding === null || typeof finding !== 'object' || Array.isArray(finding)) return { ok: false, detail: boundedTail(`Dakar returned a malformed finding at index ${index}; expected an object`) } + if (!finding || typeof finding !== 'object' || Array.isArray(finding)) return { ok: false, detail: boundedTail(`Dakar returned a malformed finding at index ${index}; expected an object`) } + // SAFETY: The preceding guard establishes a non-null, non-array object; + // its untrusted property values are checked individually below. const item = finding as Record if (typeof item.severity !== 'string' || !DAKAR_SEVERITIES.has(item.severity.toLowerCase())) return { ok: false, detail: boundedTail(`Dakar returned an invalid finding at index ${index}; unsupported severity`) } const invalidField = DAKAR_REQUIRED_FINDING_FIELDS.find((field) => typeof item[field] !== 'string') if (invalidField) return { ok: false, detail: boundedTail(`Dakar returned an invalid finding at index ${index}; ${invalidField} must be a string`) } if (item.line !== undefined && (!Number.isInteger(item.line) || Number(item.line) < 1)) return { ok: false, detail: boundedTail(`Dakar returned an invalid finding at index ${index}; line must be a positive integer`) } } + // SAFETY: Every finding is an object with a supported string severity, + // string path, title, detail, and evidence, and an optional positive integer line. return { ok: true, findings: findings as DakarFinding[] } } @@ -228,23 +261,23 @@ export function makeDakarAttempt(config: Pick fs.mkdtempSync(path.join(os.tmpdir(), 'df12-dakar-state-')), remove: deps.removeDakarStateRoot || fs.rmSync } - const tracer = (deps as HostReviewDeps).tracer || NOOP_HOST_REVIEW_TRACER - const traceContext = (deps as HostReviewDeps).traceContext || { runId: 'host-review' } - /** Read a monotonic clock only for bounded span durations. */ - const nowMs = () => Number(process.hrtime.bigint() / 1_000_000n) + const tracer = deps.tracer || NOOP_HOST_REVIEW_TRACER + const traceContext = deps.traceContext || { runId: 'host-review' } /** Start one Dakar boundary span without propagating paths or diagnostics. */ const trace = (name: string) => { - const startedMs = nowMs() - return { startedMs, span: tracer.startSpan(name, { runId: boundedTail(traceContext.runId || 'host-review', 120), ...(traceContext.taskId ? { taskId: boundedTail(traceContext.taskId, 120) } : {}) }, { reviewer: 'dakar', attempt: deps.attempt || 1 }) } + const startedMs = dakarNowMs() + const spanContext: HostReviewTraceContext = { runId: boundedTail(traceContext.runId || 'host-review', 120) } + if (traceContext.taskId) spanContext.taskId = boundedTail(traceContext.taskId, 120) + return { startedMs, span: tracer.startSpan(name, spanContext, { reviewer: 'dakar', attempt: deps.attempt || 1 }) } } let stateRoot: string const createSpan = trace('host-review.dakar.state-root.create') try { stateRoot = stateRoots.create() - createSpan.span.end({ reviewer: 'dakar', attempt: deps.attempt || 1, outcome: 'clean', errorCategory: 'none', timeout: false, elapsedMs: Math.max(0, nowMs() - createSpan.startedMs) }) + createSpan.span.end({ reviewer: 'dakar', attempt: deps.attempt || 1, outcome: 'clean', errorCategory: 'none', timeout: false, elapsedMs: Math.max(0, dakarNowMs() - createSpan.startedMs) }) } catch (error) { - createSpan.span.end({ reviewer: 'dakar', attempt: deps.attempt || 1, outcome: 'error', errorCategory: 'execution', timeout: false, elapsedMs: Math.max(0, nowMs() - createSpan.startedMs) }) - return { outcome: 'error', findings: [], detail: boundedTail((error as Error | null)?.message || String(error)), errorCategory: 'execution' } + createSpan.span.end({ reviewer: 'dakar', attempt: deps.attempt || 1, outcome: 'error', errorCategory: 'execution', timeout: false, elapsedMs: Math.max(0, dakarNowMs() - createSpan.startedMs) }) + return { outcome: 'error', findings: [], detail: boundedTail(dakarErrorProperty(error, 'message') || String(error)), errorCategory: 'execution' } } const args = ['--repo-root', worktree, '--base', config.base, '--state-root', stateRoot, '--timeout', String(config.reviewTimeoutSeconds), ...(config.dakarBudgetGbp > 0 ? ['--budget-gbp', String(config.dakarBudgetGbp)] : [])] const processSpan = trace('host-review.dakar.subprocess') @@ -253,22 +286,22 @@ export function makeDakarAttempt(config: Pick { const { execFile } = process.getBuiltinModule('node:child_process') + const execOptions: ChildProcessExecOptions = { + cwd: options.cwd || process.cwd(), + maxBuffer: 16 * 1024 * 1024, + } + if (options.timeoutMs) execOptions.timeout = options.timeoutMs return await new Promise((resolve, reject) => { - execFile(command, [...commandArgs], { cwd: options.cwd || process.cwd(), maxBuffer: 16 * 1024 * 1024, ...(options.timeoutMs ? { timeout: options.timeoutMs } : {}) }, (error, stdout, stderr) => { + execFile(command, [...commandArgs], execOptions, (error, stdout, stderr) => { if (error) { - const failure = error as ExecError - failure.stdout = stdout - failure.stderr = stderr - reject(failure) + error.stdout = stdout + error.stderr = stderr + reject(error) return } resolve(stdout) @@ -83,6 +97,8 @@ export async function execFileStatus(command: string, commandArgs: readonly stri try { return { ok: true, stdout: await execFileText(command, commandArgs, options), stderr: '' } } catch (error) { + // SAFETY: execFileText rejects with Node child-process errors; the stream + // fields are optional because a synchronous spawn failure may omit them. const failure = error as (ExecError & { killed?: boolean; signal?: string }) | null return { ok: false, @@ -103,7 +119,7 @@ export async function execFileStatus(command: string, commandArgs: readonly stri * `String()` first. */ export function shellQuote(value: unknown): string { - return `'${String(value).replace(/'/g, "'\\''")}'` + return `'${String(value).replaceAll('\'', String.raw`'\''`)}'` } /** @@ -131,6 +147,8 @@ export async function fileState(pathValue: unknown, baseDir: string = process.cw const stat = await fs.lstat(candidate) return { ok: true, exists: stat.isFile(), detail: '' } } catch (error) { + // SAFETY: Node filesystem promises reject with errors whose errno code is + // optional; the null check preserves the fallback for a nullish throw. const failure = error as (Error & { code?: string }) | null if (failure && (failure.code === 'ENOENT' || failure.code === 'ENOTDIR')) { return { ok: true, exists: false, detail: '' } diff --git a/src/workflows/df12-build-odw/execplan-durability.ts b/src/workflows/df12-build-odw/execplan-durability.ts index f3c319f..634e4f1 100644 --- a/src/workflows/df12-build-odw/execplan-durability.ts +++ b/src/workflows/df12-build-odw/execplan-durability.ts @@ -18,6 +18,16 @@ export interface DurabilityVerdict { detail: string } +/** Result of resolving an agent-supplied ExecPlan path within its worktree. */ +export interface ExecplanPathResolution { + /** True when the path is a task-scoped ExecPlan inside the worktree. */ + ok: boolean + /** The path relative to the worktree; empty when the path is rejected. */ + relPath: string + /** Empty when accepted; otherwise why the path was rejected. */ + detail: string +} + /** * The result of a salvage-commit attempt: `committed` paths, `skipped` * `{path,reason}` entries, the commit `sha` (empty when nothing was committed @@ -69,14 +79,7 @@ export function isTaskArtefactPath(candidate: unknown): boolean { * @param planPath The agent-supplied plan path (untrusted). * @returns `{ ok, relPath, detail }`; `ok` is true only for a normalized task-scoped ExecPlan path. */ -export function execplanRelPath(worktree: string, planPath: unknown): { - /** True when the normalized path is a task-scoped `docs/execplans/*.md` path inside the worktree. */ - ok: boolean - /** The path relative to the worktree; empty when `ok` is false. */ - relPath: string - /** Empty when `ok`; otherwise why the path was rejected. */ - detail: string -} { +export function execplanRelPath(worktree: string, planPath: unknown): ExecplanPathResolution { const path = process.getBuiltinModule('node:path') const raw = String(planPath || '') const rel = path.isAbsolute(raw) ? path.relative(worktree, raw) : path.normalize(raw) @@ -156,7 +159,10 @@ export async function commitExecplanApproval(worktree: string, planPath: unknown } } } catch (error) { - return { ok: false, detail: `could not update the plan status: ${((error as Error | null) && (error as Error).message) || String(error)}` } + // SAFETY: the short-circuit excludes nullish values, and JavaScript boxes + // primitives for property reads. The optional message remains unknown. + const message = error && (error as { message?: unknown }).message + return { ok: false, detail: `could not update the plan status: ${message || String(error)}` } } const status = await execFileStatus('git', ['-C', worktree, 'status', '--porcelain=v1', '--', relPath]) if (!status.ok) { @@ -198,9 +204,9 @@ export async function commitExecplanDraft(worktree: string, relPath: string, tag const status = await execFileStatus('git', ['-C', worktree, 'status', '--porcelain=v1']) if (!status.ok) return { ok: false, detail: `git status failed: ${(status.message || status.stderr || '').trim()}` } const lines = String(status.stdout).split(/\r?\n/).filter(Boolean) - if (!lines.length) return { ok: false, detail: 'nothing to commit: the worktree is already clean' } + if (lines.length === 0) return { ok: false, detail: 'nothing to commit: the worktree is already clean' } const foreign = lines.filter((line) => line.slice(3).replace(/^"(.*)"$/, '$1') !== relPath) - if (foreign.length) { + if (foreign.length > 0) { const sample = foreign.slice(0, 8).map((line) => line.trim()).join('; ') return { ok: false, detail: `the worktree holds ${foreign.length} uncommitted path(s) beyond the plan file (${sample}${foreign.length > 8 ? '; …' : ''})` } } @@ -272,7 +278,7 @@ export async function salvageTaskArtefacts(worktree: string, candidatePaths: rea } verified.push(contained.relPath) } - if (!verified.length) { + if (verified.length === 0) { return { committed: [], skipped, sha: '', detail: 'nothing to salvage: no eligible task-scoped artefacts are dirty' } } const add = await execFileStatus('git', ['-C', worktree, 'add', '--', ...verified]) @@ -311,7 +317,7 @@ export async function verifyWorktreeCommitted(worktree: string): Promise line.trim()).join('; ') return { ok: false, detail: `${lines.length} uncommitted path(s): ${sample}${lines.length > 8 ? '; …' : ''}` } } diff --git a/src/workflows/df12-build-odw/faults.ts b/src/workflows/df12-build-odw/faults.ts index 993af39..d1ec410 100644 --- a/src/workflows/df12-build-odw/faults.ts +++ b/src/workflows/df12-build-odw/faults.ts @@ -9,6 +9,15 @@ */ import type { FaultMetrics } from './types.ts' +/** Extract a usable message from a thrown value, including plain objects. */ +export function faultThrownMessage(error: unknown): string { + if (error !== null && (typeof error === 'object' || typeof error === 'function') && 'message' in error) { + const message = error.message + if (typeof message === 'string' && message) return message + } + return String(error) +} + /** * Bounded-cardinality fault counters, surfaced verbatim in the run result so * operators can see retry pressure and terminal fault classes without @@ -113,7 +122,7 @@ export function makeWithInfraRetry(attempts: number, metrics: FaultMetrics = fau try { return await run() } catch (error) { - const message = ((error as Error | null) && (error as Error).message) || String(error) + const message = faultThrownMessage(error) if (attempt >= attempts || !infrastructureFailureDetail(message)) { // Log the terminal boundary distinctly from the retry path so // operators can see where the retry budget actually gave up. diff --git a/src/workflows/df12-build-odw/git-evidence.ts b/src/workflows/df12-build-odw/git-evidence.ts index e90a1c4..f99df00 100644 --- a/src/workflows/df12-build-odw/git-evidence.ts +++ b/src/workflows/df12-build-odw/git-evidence.ts @@ -107,18 +107,30 @@ export function parsePorcelainDirty(output: unknown): NameStatusEntry[] { * trims the raw text; pass `parse` for structured output (e.g. * {@link parseNameStatus}). */ -export async function gitEvidence( +export function gitEvidence( worktreePath: string, commandArgs: readonly string[], - parse: (text: string) => T = ((text: string) => String(text || '').trim()) as unknown as (text: string) => T, -): Promise> { + parse?: undefined, +): Promise> +/** Run a git probe with a parser for structured output. */ +export function gitEvidence( + worktreePath: string, + commandArgs: readonly string[], + parse: (text: string) => T, +): Promise> +export async function gitEvidence( + worktreePath: string, + commandArgs: readonly string[], + parse?: (text: string) => T, +): Promise> { + const parseOutput = parse === undefined ? (text: string) => String(text || '').trim() : parse const result = await execFileStatus('git', ['-C', worktreePath, ...commandArgs]) if (result.ok) { - return { ok: true, value: parse(result.stdout) } + return { ok: true, value: parseOutput(result.stdout) } } return { ok: false, - value: parse(result.stdout), + value: parseOutput(result.stdout), error: [result.message, result.stderr, result.stdout].filter(Boolean).join('\n').trim(), } } @@ -151,12 +163,12 @@ export async function collectAssessmentEvidence( gitEvidence(worktreePath, ['status', '--porcelain=v1']), baseCommit ? gitEvidence(worktreePath, ['diff', '--name-status', `${baseCommit}...HEAD`], parseNameStatus) - : Promise.resolve({ ok: false, value: [] as NameStatusEntry[], error: 'missing base commit' }), + : Promise.resolve>({ ok: false, value: [], error: 'missing base commit' }), gitEvidence(worktreePath, ['diff', '--name-status'], parseNameStatus), gitEvidence(worktreePath, ['diff', '--cached', '--name-status'], parseNameStatus), baseCommit ? gitEvidence(worktreePath, ['log', '--oneline', '--max-count=20', `${baseCommit}..HEAD`], (text) => String(text || '').trim().split(/\r?\n/).filter(Boolean)) - : Promise.resolve({ ok: false, value: [] as string[], error: 'missing base commit' }), + : Promise.resolve>({ ok: false, value: [], error: 'missing base commit' }), ]) if (!current.ok) errors.push(`rev-parse HEAD: ${current.error}`) if (!branch.ok) errors.push(`rev-parse --abbrev-ref HEAD: ${branch.error}`) @@ -187,7 +199,7 @@ export async function collectAssessmentEvidence( baseCommit, currentCommit: current.value || '', dirtyState: status.ok ? (String(status.value || '').trim() ? 'dirty' : 'clean') : 'unknown', - changedFiles: [...allChanged].sort(), + changedFiles: [...allChanged].toSorted(), committedChanges: committed.value, dirtyChanges, stagedChanges: staged.value, @@ -251,6 +263,8 @@ export async function directoryExists(pathValue: unknown): Promise<{ const stat = await fs.stat(String(pathValue)) return { ok: true, exists: stat.isDirectory(), detail: '' } } catch (error) { + // SAFETY: Node filesystem promises reject with errors whose errno code is + // optional; the null check preserves the fallback for a nullish throw. const failure = error as (Error & { code?: string }) | null if (failure && (failure.code === 'ENOENT' || failure.code === 'ENOTDIR')) { return { ok: true, exists: false, detail: '' } diff --git a/src/workflows/df12-build-odw/host-gates.ts b/src/workflows/df12-build-odw/host-gates.ts index 144938c..35e829c 100644 --- a/src/workflows/df12-build-odw/host-gates.ts +++ b/src/workflows/df12-build-odw/host-gates.ts @@ -42,7 +42,7 @@ export interface HostGateConfig { /** Minimal readable process stream needed by the secure gate pump. */ export interface HostGateReadable { /** Subscribe to stream data events. */ - on: (event: 'data', listener: (chunk: unknown) => void) => unknown + on: (event: 'data', listener: (chunk: string | Uint8Array) => void) => void /** Pause a backpressured process pipe. */ pause?: () => void /** Resume a process pipe before group termination. */ @@ -58,25 +58,27 @@ export interface HostGateChild { /** Standard error pump. */ stderr?: HostGateReadable /** Subscribe to close and spawn-error events. */ - on: (event: 'close' | 'error', listener: (value: number | Error | null) => void) => unknown + on: (event: 'close' | 'error', listener: (value: number | Error | null) => void) => void /** Fallback single-process signal operation. */ - kill: (signal: 'SIGTERM' | 'SIGKILL') => unknown + kill: (signal: 'SIGTERM' | 'SIGKILL') => void } /** Secure output stream contract used by the gate log sink. */ export interface HostGateLog { /** Write one process-output chunk and report backpressure. */ - write: (chunk: unknown) => boolean + write: (chunk: string | Uint8Array) => boolean /** Flush and close the stream before settling the gate. */ end: (callback: () => void) => void /** Subscribe to stream errors and drain notifications. */ - on: (event: 'error' | 'drain', listener: (error?: Error) => void) => unknown + on: (event: 'error' | 'drain', listener: (error?: Error) => void) => void } /** Opaque timer handle returned by a host timer implementation. */ export interface HostGateTimer { /** Allow the event loop to exit while this timer remains pending. */ unref?: () => void + /** Cancel a native timer using its disposal operation. */ + [Symbol.dispose]?: () => void } /** @@ -115,6 +117,11 @@ export interface HostGateDeps { /** Maximum retained unterminated output while a gate continues streaming to disk. */ export const GATE_CARRY_LIMIT = 16_384 +/** Restrict task labels to safe path components within the allocated root. */ +function gateLogLabel(value: string): string { + return String(value).replaceAll(/[^A-Za-z0-9._-]+/g, '-').replaceAll(/^-+|-+$/g, '').slice(0, 60) +} + /** Return the executable word of a safely tokenized configured command. */ export function codeSceneExecutable(command: string): string | null { const tokens = tokenizeShellCommand(command) @@ -124,10 +131,8 @@ export function codeSceneExecutable(command: string): string | null { /** Calculate a sanitized private gate-log path within a caller-owned root. */ export function hostGateLogPath(root: string, tag: string, roundLabel: string, index: number): string { - /** Prevent untrusted task labels from escaping the allocated private root. */ - const slug = (value: unknown) => String(value).replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) const path = process.getBuiltinModule('node:path') - return path.join(root, `gate-${slug(tag)}-${slug(roundLabel)}-${index + 1}.out`) + return path.join(root, `gate-${gateLogLabel(tag)}-${gateLogLabel(roundLabel)}-${index + 1}.out`) } /** Build production defaults without exposing Node modules to callers. */ @@ -138,11 +143,25 @@ function productionHostGateDeps(): Required spawn('sh', ['-c', command], { cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] }) as unknown as HostGateChild, + spawnGate: (command, cwd) => { + const child = spawn('sh', ['-c', command], { cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] }) + const gateChild: HostGateChild = { + on: (event, listener) => { + if (event === 'close') child.on('close', listener) + else child.on('error', listener) + }, + kill: (signal) => { child.kill(signal) }, + } + if (child.pid !== undefined) gateChild.pid = child.pid + gateChild.stdout = child.stdout + gateChild.stderr = child.stderr + return gateChild + }, /** Prefer signalling the detached group so background descendants are reaped. */ terminateProcessGroup: (child, signal) => { - if (typeof child.pid === 'number') { - try { process.kill(-child.pid, signal); return } catch { /* A race can reap the group before escalation. */ } + const pid = child.pid + if (pid !== undefined && Number.isSafeInteger(pid)) { + try { process.kill(-pid, signal); return } catch { /* A race can reap the group before escalation. */ } } child.kill(signal) }, @@ -150,18 +169,32 @@ function productionHostGateDeps(): Required { const { O_WRONLY, O_CREAT, O_EXCL, O_NOFOLLOW } = fs.constants const fd = fs.openSync(logFile, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0o600) - return fs.createWriteStream(logFile, { fd, autoClose: true }) as unknown as HostGateLog + const stream = fs.createWriteStream(logFile, { fd, autoClose: true }) + return { + write: (chunk) => stream.write(chunk), + end: (callback) => { stream.end(callback) }, + on: (event, listener) => { + if (event === 'error') stream.on('error', listener) + else stream.on('drain', listener) + }, + } }, createLogRoot: () => fs.mkdtempSync(path.join(os.tmpdir(), 'df12-gates-')), removeLogRoot: (root, options) => fs.rmSync(root, options), setTimer: (callback, delayMs) => setTimeout(callback, delayMs), - clearTimer: (timer) => clearTimeout(timer as ReturnType), + clearTimer: (timer) => { timer[Symbol.dispose]?.() }, nowMs: () => Number(process.hrtime.bigint() / 1_000_000n), } } +/** Private paths and cleanup for one host-gate log root. */ +interface GateLogPaths { + path: (tag: string, roundLabel: string, index: number) => string + dispose: () => void +} + /** Allocate a lazy temporary-root path service with cleanup failure containment. */ -function makeGateLogPaths(lifecycle: HostGateLogRoot | undefined, deps: Required>): { path: (tag: string, roundLabel: string, index: number) => string; dispose: () => void } { +function makeGateLogPaths(lifecycle: HostGateLogRoot | undefined, deps: Required>): GateLogPaths { let gateLogRoot = '' /** Allocate the root on first gate use so disabled gates do not touch disk. */ const root = () => { @@ -174,6 +207,8 @@ function makeGateLogPaths(lifecycle: HostGateLogRoot | undefined, deps: Required dispose: () => { if (!gateLogRoot) return try { (lifecycle?.remove || deps.removeLogRoot)(gateLogRoot, { recursive: true, force: true }) } catch (error) { + // SAFETY: Optional property access tolerates any thrown non-nullish + // value; a missing or falsy message falls back to String(error). log(`[host gates] could not remove temporary log root: ${boundedTail((error as Error | null)?.message || String(error), 500)}`) } gateLogRoot = '' @@ -181,15 +216,24 @@ function makeGateLogPaths(lifecycle: HostGateLogRoot | undefined, deps: Required } } -/** Bind secure gate execution to run-scoped metrics, tracing, and configuration. */ -export function makeHostGates(config: HostGateConfig, metrics: { hostGates: HostGateMetrics; codeScene: CodeSceneMetrics }, injected: HostGateDeps = {}): { +/** Bound operations for deterministic host gates and CodeScene. */ +export interface HostGateSurface { /** Run every configured commit gate in order. */ runHostCommitGates: (worktree: string, tag: string, roundLabel: string) => Promise /** Run CodeScene after commit gates when configured. */ runCodeSceneCheck: (worktree: string, tag: string, label: string) => Promise /** Remove any temporary root allocated by this gate surface. */ disposeHostGateLogs: () => void -} { +} + +/** Active tracing handle with its monotonic start time. */ +interface ActiveGateSpan { + handle: HostReviewSpan + startedMs: number +} + +/** Bind secure gate execution to run-scoped metrics, tracing, and configuration. */ +export function makeHostGates(config: HostGateConfig, metrics: { hostGates: HostGateMetrics; codeScene: CodeSceneMetrics }, injected: HostGateDeps = {}): HostGateSurface { const production = productionHostGateDeps() const deps = { ...production, ...injected } const generatedLogPaths = makeGateLogPaths(config.gateLogRoot, deps) @@ -198,23 +242,46 @@ export function makeHostGates(config: HostGateConfig, metrics: { hostGates: Host const baseContext = deps.traceContext || { runId: 'host-review' } /** Start a bounded gate span and measure elapsed time without payload data. */ - function span(name: string, taskId: string, attributes: Record): { handle: HostReviewSpan; startedMs: number } { + function span(name: string, taskId: string, attributes: Record): ActiveGateSpan { const startedMs = deps.nowMs() - return { handle: tracer.startSpan(name, { runId: boundedTail(baseContext.runId, 120) || 'host-review', ...(taskId ? { taskId: boundedTail(taskId, 120) } : {}) }, { reviewer: deps.reviewer || 'unknown', ...attributes }), startedMs } + const context: HostReviewTraceContext = { runId: boundedTail(baseContext.runId, 120) || 'host-review' } + if (taskId) context.taskId = boundedTail(taskId, 120) + return { handle: tracer.startSpan(name, context, { reviewer: deps.reviewer || 'unknown', ...attributes }), startedMs } + } + + /** Bound the elapsed duration using the injected monotonic clock. */ + function gateSpanElapsedMs(active: ActiveGateSpan): number { + const elapsedMs = deps.nowMs() - active.startedMs + return Math.max(0, Math.trunc(elapsedMs)) } /** Finish a gate span with fixed-category outcome and duration attributes. */ - function endSpan(active: { handle: HostReviewSpan; startedMs: number }, attributes: Record): void { - active.handle.end({ elapsedMs: Math.max(0, Math.trunc(deps.nowMs() - active.startedMs)), ...attributes }) + function endSpan(active: ActiveGateSpan, attributes: Record): void { + active.handle.end({ elapsedMs: gateSpanElapsedMs(active), ...attributes }) } /** Stream one shell gate to its secure log and return its bounded terminal tail. */ function streamGate(command: string, cwd: string, logFile: string): Promise<{ ok: boolean; killed: boolean; tail: string }> { return new Promise((resolve) => { let stream: HostGateLog - try { stream = deps.openSecureLog(logFile) } catch (error) { resolve({ ok: false, killed: false, tail: `gate log write failed: ${boundedTail((error as Error | null)?.message || String(error), 500)}` }); return } + try { + stream = deps.openSecureLog(logFile) + } catch (error) { + // SAFETY: Optional property access tolerates any thrown non-nullish + // value; a missing or falsy message falls back to String(error). + resolve({ ok: false, killed: false, tail: `gate log write failed: ${boundedTail((error as Error | null)?.message || String(error), 500)}` }) + return + } let child: HostGateChild - try { child = deps.spawnGate(command, cwd) } catch (error) { stream.end(() => {}); resolve({ ok: false, killed: false, tail: `spawn failed: ${boundedTail((error as Error | null)?.message || String(error), 500)}` }); return } + try { + child = deps.spawnGate(command, cwd) + } catch (error) { + stream.end(() => {}) + // SAFETY: Optional property access tolerates any thrown non-nullish + // value; a missing or falsy message falls back to String(error). + resolve({ ok: false, killed: false, tail: `spawn failed: ${boundedTail((error as Error | null)?.message || String(error), 500)}` }) + return + } const tail: string[] = [] let carry = '' let killed = false @@ -239,7 +306,7 @@ export function makeHostGates(config: HostGateConfig, metrics: { hostGates: Host if (force) { const timer = deps.setTimer(settle, 100); timer.unref?.() } } /** Stream a chunk while retaining only bounded terminal lines in memory. */ - const record = (chunk: unknown) => { + const record = (chunk: string | Uint8Array) => { if (!stream.write(chunk) && !killed) { child.stdout?.pause?.(); child.stderr?.pause?.() } carry += String(chunk) const lines = carry.split(/\r?\n/) @@ -274,7 +341,11 @@ export function makeHostGates(config: HostGateConfig, metrics: { hostGates: Host child.stderr?.on('data', record) sigterm = deps.setTimer(() => terminate('gate process group did not close after SIGKILL'), config.commitGateTimeoutSeconds * 1000) child.on('close', (code) => finish(code === 0 && !killed, streamFailure)) - child.on('error', (error) => finish(false, streamFailure || `spawn failed: ${boundedTail((error as Error | null)?.message || String(error), 500)}`)) + child.on('error', (error) => { + // SAFETY: Optional property access tolerates the event's numeric, + // error, or null value; missing messages fall back to String(error). + finish(false, streamFailure || `spawn failed: ${boundedTail((error as Error | null)?.message || String(error), 500)}`) + }) }) } @@ -289,7 +360,7 @@ export function makeHostGates(config: HostGateConfig, metrics: { hostGates: Host const logFile = logPath(tag, roundLabel, index) const outcome = await streamGate(command, worktree, logFile) results.push({ command: displayedCommand, ok: outcome.ok, logFile }) - endSpan(active, { outcome: outcome.ok ? 'clean' : 'error', errorCategory: outcome.killed ? 'timeout' : outcome.ok ? 'none' : 'execution', timeout: outcome.killed }) + endSpan(active, { outcome: outcome.ok ? 'clean' : 'error', errorCategory: outcome.killed ? 'timeout' : (outcome.ok ? 'none' : 'execution'), timeout: outcome.killed }) if (!outcome.ok) { metrics.hostGates.failures += 1 const timeout = outcome.killed ? ` (killed after the ${config.commitGateTimeoutSeconds}s gate timeout)` : '' @@ -312,7 +383,7 @@ export function makeHostGates(config: HostGateConfig, metrics: { hostGates: Host return { clean: false, skipped: false, detail, logFile: '' } } const missing = '__DF12_CODESCENE_BINARY_MISSING__' - const probe = await deps.probeCodeScene('sh', ['-c', 'command -v "$1" >/dev/null 2>&1 || { printf "%s\\n" "$2"; exit 127; }', 'sh', bin, missing], { cwd: worktree, timeoutMs: 10_000 }) + const probe = await deps.probeCodeScene('sh', ['-c', String.raw`command -v "$1" >/dev/null 2>&1 || { printf "%s\n" "$2"; exit 127; }`, 'sh', bin, missing], { cwd: worktree, timeoutMs: 10_000 }) if (!probe.ok) { if (probe.stdout.trim() === missing) { metrics.codeScene.skipped += 1 @@ -321,7 +392,10 @@ export function makeHostGates(config: HostGateConfig, metrics: { hostGates: Host return { clean: true, skipped: true, detail: `${bin} not on PATH`, logFile: '' } } metrics.codeScene.probeFailures += 1 - const fault = [probe.message, probe.stderr, probe.signal ? `signal ${probe.signal}` : '', probe.killed ? 'probe killed' : ''].map((part) => String(part || '').trim()).filter(Boolean).join('; ') + const fault = [probe.message, probe.stderr, probe.signal ? `signal ${probe.signal}` : '', probe.killed ? 'probe killed' : ''].flatMap((part) => { + const text = String(part || '').trim() + return text ? [text] : [] + }).join('; ') endSpan(active, { outcome: 'error', errorCategory: probe.killed ? 'timeout' : 'execution', timeout: Boolean(probe.killed), retry: false }) return { clean: false, skipped: false, detail: `CodeScene availability probe for \`${redactedCommand}\` failed: ${boundedTail(fault) || 'unknown probe failure'}`, logFile: '' } } @@ -329,7 +403,7 @@ export function makeHostGates(config: HostGateConfig, metrics: { hostGates: Host const logFile = logPath(tag, `cs-${label}`, 0) log(`[task ${tag}] CodeScene check (${label}): ${redactedCommand}`) const outcome = await streamGate(config.csCheckCommand, worktree, logFile) - endSpan(active, { outcome: outcome.ok ? 'clean' : 'error', errorCategory: outcome.killed ? 'timeout' : outcome.ok ? 'none' : 'execution', timeout: outcome.killed, retry: false }) + endSpan(active, { outcome: outcome.ok ? 'clean' : 'error', errorCategory: outcome.killed ? 'timeout' : (outcome.ok ? 'none' : 'execution'), timeout: outcome.killed, retry: false }) if (outcome.ok) return { clean: true, skipped: false, detail: '', logFile } metrics.codeScene.failures += 1 const timeout = outcome.killed ? ` (killed after the ${config.commitGateTimeoutSeconds}s timeout)` : '' diff --git a/src/workflows/df12-build-odw/host-review-contracts.ts b/src/workflows/df12-build-odw/host-review-contracts.ts index 287fa40..d175675 100644 --- a/src/workflows/df12-build-odw/host-review-contracts.ts +++ b/src/workflows/df12-build-odw/host-review-contracts.ts @@ -298,8 +298,12 @@ export function boundedTail(text: unknown, limit = 2000): string { /** Return the capitalized reviewer name used in operator-facing prose. */ export function reviewerDisplayName(reviewer: HostReviewResult['reviewer']): string { switch (reviewer) { - case 'dakar': return 'Dakar' - case 'coderabbit': return 'CodeRabbit' + case 'dakar': { + return 'Dakar' + } + case 'coderabbit': { + return 'CodeRabbit' + } default: { const unmatched: never = reviewer throw new Error(`Unknown host reviewer: ${unmatched}`) diff --git a/src/workflows/df12-build-odw/host-review.ts b/src/workflows/df12-build-odw/host-review.ts index 6583693..47b2977 100644 --- a/src/workflows/df12-build-odw/host-review.ts +++ b/src/workflows/df12-build-odw/host-review.ts @@ -8,14 +8,12 @@ * @module */ import { execFileStatus } from './exec.ts' -import { makeCoderabbitAttempt, parseCoderabbitAgentOutput, classifyCoderabbitOutcome, type CoderabbitError, type CoderabbitParsedOutput, CODERABBIT_SUCCESS_STATUSES } from './coderabbit-review.ts' -import { makeDakarAttempt, parseDakarDocument, mapDakarFinding, validateChangesRequestedFindings, validateCleanDakarFindings, classifyDakarReview, DAKAR_SEVERITY_MAP, type DakarAttemptDeps, type DakarDocument, type DakarFinding, type DakarFindingValidation, type DakarFindingValidationFailure, type DakarStateRoots } from './dakar-review.ts' -import { makeHostGates, hostGateLogPath, codeSceneExecutable, type HostGateDeps } from './host-gates.ts' +import { makeCoderabbitAttempt } from './coderabbit-review.ts' +import { makeDakarAttempt, type DakarAttemptDeps } from './dakar-review.ts' +import { makeHostGates, type HostGateDeps } from './host-gates.ts' import { DAKAR_COMMAND_VALIDATION_ERROR, dakarInvocationFromCommand, validateDakarInvocation } from './shell-command.ts' import { boundedTail, - HOST_REVIEW_BLOCKING_SEVERITIES, - hostReviewDeferral, makeHostReviewMetrics, NOOP_HOST_REVIEW_TRACER, reviewBlockingItems, @@ -24,49 +22,45 @@ import { type CodeSceneMetrics, type HostReviewAttempt, type HostGateMetrics, - type HostGateResult, type HostGateRun, type HostReviewConfig, - type HostReviewDeferral, type HostReviewDeps, type HostReviewMetrics, type HostReviewMetricsSnapshot, type HostReviewRecordingDeps, type HostReviewResult, type HostReviewTraceContext, - type ReviewErrorCategory, type ReviewFinding, - type ReviewOutcome, } from './host-review-contracts.ts' +export { parseCoderabbitAgentOutput, classifyCoderabbitOutcome, CODERABBIT_SUCCESS_STATUSES } from './coderabbit-review.ts' +export type { CoderabbitError, CoderabbitParsedOutput } from './coderabbit-review.ts' export { - boundedTail, - HOST_REVIEW_BLOCKING_SEVERITIES, - hostGateLogPath, - codeSceneExecutable, - hostReviewDeferral, - mapDakarFinding, - parseCoderabbitAgentOutput, parseDakarDocument, - reviewerDisplayName, + mapDakarFinding, validateChangesRequestedFindings, validateCleanDakarFindings, - classifyCoderabbitOutcome, classifyDakarReview, DAKAR_SEVERITY_MAP, - CODERABBIT_SUCCESS_STATUSES, - reviewBlockingItems, -} +} from './dakar-review.ts' export type { - CodeSceneCheckResult, DakarAttemptDeps, DakarStateRoots, - CoderabbitError, - CoderabbitParsedOutput, DakarDocument, DakarFinding, DakarFindingValidation, DakarFindingValidationFailure, +} from './dakar-review.ts' +export { hostGateLogPath, codeSceneExecutable } from './host-gates.ts' +export { + boundedTail, + HOST_REVIEW_BLOCKING_SEVERITIES, + hostReviewDeferral, + reviewerDisplayName, + reviewBlockingItems, +} from './host-review-contracts.ts' +export type { + CodeSceneCheckResult, HostGateRun, HostGateResult, HostReviewConfig, @@ -78,7 +72,7 @@ export type { ReviewErrorCategory, ReviewFinding, ReviewOutcome, -} +} from './host-review-contracts.ts' /** Optional composition seams retained outside the workflow configuration shape. */ export interface HostReviewCompositionDeps { @@ -86,12 +80,25 @@ export interface HostReviewCompositionDeps { hostGates?: HostGateDeps } +/** + * Runtime input admitted by the compatibility fallback: older JavaScript + * callers may provide the CodeRabbit names without the canonical fields. + */ +interface HostReviewCompatibilityInput { + reviewAttempts?: number + reviewBackoffMinutes?: [number, number] + reviewFindingsFile?: string + coderabbitAttempts?: number + coderabbitBackoffMinutes?: [number, number] + coderabbitFindingsFile?: string +} + /** Compatibility alias for integrations compiled against older names. */ -export type CoderabbitFinding = ReviewFinding +export type { ReviewFinding as CoderabbitFinding } from './host-review-contracts.ts' /** Compatibility alias for the former CodeRabbit-specific outcome type. */ -export type CoderabbitOutcome = ReviewOutcome +export type { ReviewOutcome as CoderabbitOutcome } from './host-review-contracts.ts' /** Compatibility alias for the former CodeRabbit-specific result type. */ -export type CoderabbitReview = HostReviewResult +export type { HostReviewResult as CoderabbitReview } from './host-review-contracts.ts' /** Compatibility blocking helper that labels findings as CodeRabbit output. */ export function coderabbitBlockingItems(findings: readonly ReviewFinding[] | null | undefined): string[] { @@ -100,7 +107,55 @@ export function coderabbitBlockingItems(findings: readonly ReviewFinding[] | nul /** Sleep for a host-side review backoff without consuming agent tokens. */ export async function hostSleepMinutes(minutes: number): Promise { - await new Promise((resolve) => setTimeout(resolve, minutes * 60000)) + await new Promise((resolve) => setTimeout(resolve, minutes * 60_000)) +} + +/** Keep span timing independent from any caller-supplied deterministic clock. */ +function hostReviewSpanNowMs(): number { + return Number(process.hrtime.bigint() / 1_000_000n) +} + +/** Bound a host-review attempt duration to a non-negative whole millisecond. */ +function hostReviewAttemptElapsedMs(startedMs: number): number { + const elapsedMs = hostReviewSpanNowMs() - startedMs + return Math.max(0, Math.trunc(elapsedMs)) +} + +/** Bound a findings-sink duration while retaining the monotonic clock value. */ +function hostReviewSinkElapsedMs(startedMs: number): number { + const elapsedMs = hostReviewSpanNowMs() - startedMs + return Math.max(0, elapsedMs) +} + +/** Bound correlation fields before they cross the tracing boundary. */ +function reviewTraceContext( + override: Partial | undefined, + fallback: Partial | undefined, + label: string, +): HostReviewTraceContext { + const context: HostReviewTraceContext = { + runId: boundedTail(override?.runId || fallback?.runId || 'host-review', 120) || 'host-review', + } + const taskId = override?.taskId || fallback?.taskId || label + if (taskId) context.taskId = boundedTail(taskId, 120) + return context +} + +/** Keep unknown severity values in the bounded metrics vocabulary. */ +function reviewSeverity(value: string): keyof HostReviewMetrics['bySeverity'] { + switch (value) { + case 'critical': + case 'major': + case 'minor': + case 'trivial': + case 'info': + case 'unknown': { + return value + } + default: { + return 'unknown' + } + } } /** Bound host-review operations and retained CodeRabbit compatibility aliases. */ @@ -131,6 +186,7 @@ export interface HostReviewSurface { /** Bind reviewer dispatch, retry, findings recording, and host-gate execution. */ export function makeHostReview(config: HostReviewConfig, composition: HostReviewCompositionDeps = {}): HostReviewSurface { + const compatibility: HostReviewCompatibilityInput = config const dakarCommandInvocation = dakarInvocationFromCommand(config.dakarCommand) const dakarInvocation = config.dakarInvocation === undefined ? dakarCommandInvocation @@ -152,14 +208,14 @@ export function makeHostReview(config: HostReviewConfig, composition: HostReview /** Return the deterministic retry delay for one review label. */ function reviewBackoffMinutes(seed: unknown): number { let hash = 5381 - for (const ch of String(seed)) hash = ((hash * 33) ^ (ch.codePointAt(0) as number)) >>> 0 - const [low, high] = config.reviewBackoffMinutes ?? config.coderabbitBackoffMinutes ?? [45, 90] + for (const ch of String(seed)) hash = ((hash * 33) ^ (ch.codePointAt(0) ?? 0)) >>> 0 + const [low, high] = compatibility.reviewBackoffMinutes ?? compatibility.coderabbitBackoffMinutes ?? [45, 90] return low + (hash % (high - low + 1)) } /** Map an elapsed duration onto a fixed counter bucket with no dynamic labels. */ function recordDuration(elapsedMs: number): void { - if (elapsedMs < 1_000) hostReviewMetrics.durationBuckets.underOneSecond += 1 + if (elapsedMs < 1000) hostReviewMetrics.durationBuckets.underOneSecond += 1 else if (elapsedMs < 10_000) hostReviewMetrics.durationBuckets.oneToTenSeconds += 1 else if (elapsedMs < 60_000) hostReviewMetrics.durationBuckets.tenToSixtySeconds += 1 else hostReviewMetrics.durationBuckets.sixtySecondsOrMore += 1 @@ -169,35 +225,30 @@ export function makeHostReview(config: HostReviewConfig, composition: HostReview async function runHostReview(worktree: string, label: string, deps: HostReviewDeps & DakarAttemptDeps = {}): Promise { const exec = deps.exec || execFileStatus const sleep = deps.sleep || hostSleepMinutes - const nowMs = deps.nowMs || (() => Number(process.hrtime.bigint() / 1_000_000n)) + const nowMs = deps.nowMs || hostReviewSpanNowMs const reviewer = config.reviewTool const displayName = reviewerDisplayName(reviewer) const boundedLabel = boundedTail(label, 120) const startedMs = nowMs() - /** Keep span timing independent from the caller's deterministic result clock. */ - const spanNowMs = () => Number(process.hrtime.bigint() / 1_000_000n) - const traceContext: HostReviewTraceContext = { - runId: boundedTail(deps.traceContext?.runId || config.traceContext?.runId || 'host-review', 120) || 'host-review', - ...(deps.traceContext?.taskId || config.traceContext?.taskId || boundedLabel ? { taskId: boundedTail(deps.traceContext?.taskId || config.traceContext?.taskId || boundedLabel, 120) } : {}), - } + const traceContext = reviewTraceContext(deps.traceContext, config.traceContext, boundedLabel) const operationSpan = (deps.tracer || config.tracer || NOOP_HOST_REVIEW_TRACER).startSpan('host-review.operation', traceContext, { reviewer, retry: false }) let terminalAttempt = 1 try { for (let attempt = 1; ; attempt++) { terminalAttempt = attempt - const attempts = config.reviewAttempts ?? config.coderabbitAttempts ?? 3 + const attempts = compatibility.reviewAttempts ?? compatibility.coderabbitAttempts ?? 3 log(`[${boundedLabel}] ${displayName} host review attempt ${attempt} of ${attempts}`) - const attemptStartedMs = spanNowMs() + const attemptStartedMs = hostReviewSpanNowMs() const attemptSpan = (deps.tracer || config.tracer || NOOP_HOST_REVIEW_TRACER).startSpan('host-review.attempt', traceContext, { reviewer, attempt, retry: attempt > 1 }) const adapterDeps = { ...deps, attempt, tracer: deps.tracer || config.tracer || NOOP_HOST_REVIEW_TRACER, traceContext: { ...traceContext, taskId: boundedLabel } } let single: HostReviewAttempt try { single = reviewer === 'dakar' ? await dakarAttempt(worktree, exec, adapterDeps) : await coderabbitAttempt(worktree, exec, adapterDeps) } catch (error) { - attemptSpan.end({ reviewer, attempt, outcome: 'error', errorCategory: 'execution', retry: false, timeout: false, elapsedMs: Math.max(0, Math.trunc(spanNowMs() - attemptStartedMs)) }) + attemptSpan.end({ reviewer, attempt, outcome: 'error', errorCategory: 'execution', retry: false, timeout: false, elapsedMs: hostReviewAttemptElapsedMs(attemptStartedMs) }) throw error } - attemptSpan.end({ reviewer, attempt, outcome: single.outcome, errorCategory: single.errorCategory, retry: single.outcome === 'rate-limited' && attempt < attempts, timeout: single.errorCategory === 'timeout', elapsedMs: Math.max(0, Math.trunc(spanNowMs() - attemptStartedMs)) }) + attemptSpan.end({ reviewer, attempt, outcome: single.outcome, errorCategory: single.errorCategory, retry: single.outcome === 'rate-limited' && attempt < attempts, timeout: single.errorCategory === 'timeout', elapsedMs: hostReviewAttemptElapsedMs(attemptStartedMs) }) if (single.outcome === 'rate-limited' && attempt < attempts) { const minutes = reviewBackoffMinutes(`${boundedLabel}#${attempt}`) log(`[${boundedLabel}] ${displayName} rate limited/deferred; host backs off ${minutes} minutes before attempt ${attempt + 1} of ${attempts} (wall-clock only, no agent tokens)`) @@ -233,15 +284,12 @@ export function makeHostReview(config: HostReviewConfig, composition: HostReview for (const finding of review.findings) { hostReviewMetrics.findings += 1 const rawSeverity = String(finding.severity || 'unknown').toLowerCase() - const severity = Object.hasOwn(hostReviewMetrics.bySeverity, rawSeverity) ? rawSeverity as keyof typeof hostReviewMetrics.bySeverity : 'unknown' + const severity = reviewSeverity(rawSeverity) hostReviewMetrics.bySeverity[severity] += 1 } - const findingsFile = config.reviewFindingsFile ?? config.coderabbitFindingsFile ?? '' - if (!findingsFile || !review.findings.length) return - const traceContext: HostReviewTraceContext = { - runId: boundedTail(deps.traceContext?.runId || config.traceContext?.runId || 'host-review', 120) || 'host-review', - ...(deps.traceContext?.taskId || config.traceContext?.taskId || label ? { taskId: boundedTail(deps.traceContext?.taskId || config.traceContext?.taskId || label, 120) } : {}), - } + const findingsFile = compatibility.reviewFindingsFile ?? compatibility.coderabbitFindingsFile ?? '' + if (!findingsFile || review.findings.length === 0) return + const traceContext = reviewTraceContext(deps.traceContext, config.traceContext, label) const sinkStartedMs = Number(process.hrtime.bigint() / 1_000_000n) const sinkSpan = (config.tracer || NOOP_HOST_REVIEW_TRACER).startSpan('host-review.findings-sink', traceContext, { reviewer: review.reviewer }) /** Serialize one bounded JSONL batch without changing review success on sink failure. */ @@ -258,16 +306,18 @@ export function makeHostReview(config: HostReviewConfig, composition: HostReview await fs.appendFile(path, data, 'utf8') }) await appendFile(findingsFile, `${lines.join('\n')}\n`) - sinkSpan.end({ reviewer: review.reviewer, outcome: 'clean', errorCategory: 'none', timeout: false, elapsedMs: Math.max(0, Number(process.hrtime.bigint() / 1_000_000n) - sinkStartedMs) }) + sinkSpan.end({ reviewer: review.reviewer, outcome: 'clean', errorCategory: 'none', timeout: false, elapsedMs: hostReviewSinkElapsedMs(sinkStartedMs) }) } catch (error) { hostReviewMetrics.sinkFailures += 1 + // SAFETY: Optional property access tolerates any thrown non-nullish + // value; a missing or falsy message falls back to String(error). hostReviewMetrics.sinkError = boundedTail((error as Error | null)?.message || String(error), 500) log(`[${boundedTail(label, 120)}] could not append ${reviewerDisplayName(review.reviewer)} host-review findings to ${findingsFile}: ${hostReviewMetrics.sinkError}`) - sinkSpan.end({ reviewer: review.reviewer, outcome: 'error', errorCategory: 'execution', timeout: false, elapsedMs: Math.max(0, Number(process.hrtime.bigint() / 1_000_000n) - sinkStartedMs) }) + sinkSpan.end({ reviewer: review.reviewer, outcome: 'error', errorCategory: 'execution', timeout: false, elapsedMs: hostReviewSinkElapsedMs(sinkStartedMs) }) } } const pending = findingsSinkTail.then(append, append) - findingsSinkTail = pending.then(() => undefined, () => undefined) + findingsSinkTail = pending.then(() => {}, () => {}) await pending } diff --git a/src/workflows/df12-build-odw/main.ts b/src/workflows/df12-build-odw/main.ts index 5cfc065..323d6d7 100644 --- a/src/workflows/df12-build-odw/main.ts +++ b/src/workflows/df12-build-odw/main.ts @@ -49,6 +49,7 @@ import { makeWithInfraRetry, providerFailureDetail, resultFromUnhandledAgentError, + faultThrownMessage, } from './faults.ts' import { collectAssessmentEvidence } from './git-evidence.ts' import { @@ -61,7 +62,7 @@ import { import { makeConfig } from './config.ts' import { makeAuthPreflight } from './auth-preflight.ts' import { makePrompts, worktreeSafetyNet } from './prompts.ts' -import { makeWritePreflight } from './write-preflight.ts' +import { makeWritePreflight, type WriteProbeTarget } from './write-preflight.ts' import { addendumImplementationNeedsManualMerge, hasOnlyDeferredReviewIssues, @@ -69,7 +70,6 @@ import { isDeferredReviewIssue, makeAssessment, summarizeSalvages, - type SalvageRecord, } from './assessment.ts' import { TRIAGE_SCHEMA, makeRemediation, stepOf } from './remediation.ts' import { @@ -83,28 +83,13 @@ import { DAKAR_COMMAND_VALIDATION_ERROR, dakarInvocationFromCommand, redactedShe import type { AssessmentEvidence } from './git-evidence.ts' import type { ExecplanState, RecoveryAssessmentFields } from './recovery-decision.ts' import type { SelectionResult } from './roadmap.ts' -import type { StagePlan, StageResult } from './run-task.ts' +import type { StagePlan } from './run-task.ts' import type { RecoveryCandidate, SelectedTask } from './types.ts' +import { checkedTaskOutcome, type TaskOutcome } from './task-outcome.ts' type AnyRecord = Record type MergeLockFn = ((fn: () => Promise) => Promise) | null -// The result record a drained task contributes to the run summary. The -// integration slice is typed because the control loop keys processed-ness on -// `integration?.pushed`. -interface TaskOutcome extends AnyRecord { - id: string - status: string - stage?: string - detail?: string - integration?: { pushed?: boolean } | null - kind?: string - proposals?: AnyRecord[] - assessment?: AnyRecord - assessmentError?: string - salvage?: SalvageRecord -} - interface RecoveryRunSummary { enabled: boolean mode: string @@ -202,7 +187,7 @@ if (PROJECT_ROOT !== process.cwd()) { try { projectRootStat = fs.statSync(PROJECT_ROOT) } catch (error) { - throw new Error(`Configured projectRoot is not accessible: ${PROJECT_ROOT} (${((error as Error | null) && (error as Error).message) || String(error)})`) + throw new Error(`Configured projectRoot is not accessible: ${PROJECT_ROOT} (${faultThrownMessage(error)})`) } if (!projectRootStat.isDirectory()) { throw new Error(`Configured projectRoot is not a directory: ${PROJECT_ROOT}`) @@ -365,16 +350,19 @@ const runAuthPreflight = makeAuthPreflight( // Deterministic roadmap selection // --------------------------------------------------------------------------- +/** Derive a stable branch slug, distinguishing an addendum pass from its parent. */ function slugForTask(task: SelectedTask): string { return `roadmap-${roadmapIdSlug(task.id)}${task.isAddendum ? '-addendum' : ''}` } +/** Locate the sibling directory used for isolated task worktrees. */ function worktreeParentPath() { const path = process.getBuiltinModule('node:path') const cwd = process.cwd() return path.join(path.dirname(cwd), `${path.basename(cwd)}.worktrees`) } +/** Prepare an isolated task worktree and return setup failures as task evidence. */ async function createWorktree(task: SelectedTask) { const fs = process.getBuiltinModule('node:fs/promises') const path = process.getBuiltinModule('node:path') @@ -384,10 +372,12 @@ async function createWorktree(task: SelectedTask) { try { await execFileText('git', ['fetch', 'origin', BASE]) - const baseSha = (await execFileText('git', ['rev-parse', `origin/${BASE}`])).trim() + const baseShaOutput = await execFileText('git', ['rev-parse', `origin/${BASE}`]) + const baseSha = baseShaOutput.trim() await fs.mkdir(path.dirname(worktreePath), { recursive: true }) await execFileText('git', ['worktree', 'add', '-b', branch, worktreePath, `origin/${BASE}`]) - const worktreeSha = (await execFileText('git', ['-C', worktreePath, 'rev-parse', 'HEAD'])).trim() + const worktreeShaOutput = await execFileText('git', ['-C', worktreePath, 'rev-parse', 'HEAD']) + const worktreeSha = worktreeShaOutput.trim() if (worktreeSha !== baseSha) { return { ok: false, @@ -407,6 +397,8 @@ async function createWorktree(task: SelectedTask) { notes: 'created deterministically by the ODW control loop; no setup agent required', } } catch (error) { + // SAFETY: These host operations fail with Node errors; execFileText + // attaches captured text streams to child-process errors. const failure = error as (Error & { stderr?: string; stdout?: string }) | null const details = [ (failure && failure.message) || String(error), @@ -424,6 +416,7 @@ async function createWorktree(task: SelectedTask) { } } +/** Read the canonical roadmap ref, reporting failures without using local state. */ async function readRoadmapForSelection(root: string = process.cwd()) { const canonicalRef = `origin/${BASE}:${ROADMAP}` try { @@ -433,6 +426,8 @@ async function readRoadmapForSelection(root: string = process.cwd()) { fallbackReason: '', } } catch (error) { + // SAFETY: execFileText rejects with a Node child-process error carrying + // its captured text streams. const failure = error as (Error & { stderr?: string; stdout?: string }) | null const details = [ (failure && failure.message) || String(error), @@ -450,11 +445,31 @@ interface ResumeContext { stage: string residualRisk: string[] } + +interface RecoveryDispatchDecision { + classification?: string + action: string + stage?: string | null + reason?: string + skip?: boolean +} + +type RecoveryReportedResult = { + id: string + branchName: string + classification: string | undefined + planStatus?: string + action: 'reported' + reason?: string + assessment?: AnyRecord +} + +/** Re-enter a surviving task at its selected stage and classify pipeline failures. */ async function executeResume( task: SelectedTask, resume: ResumeContext, mergeLock: MergeLockFn, -): Promise { +): Promise { const { candidate, enriched, evidence, stage, residualRisk } = resume const worktree = candidate.worktreePath const extra = { kind: 'recovery-resume' } @@ -464,22 +479,22 @@ async function executeResume( return { id: candidate.taskId, status: 'failed', stage: 'worktree-write', detail, worktree, proposals: [], ...extra } } try { - let plan: StagePlan | undefined - let impl: AnyRecord | undefined - if (stage === 'plan') { - const planned = await runPlanDesignLoop(task, worktree, { resume: true, extra }) - if (planned.kind === 'failure') return planned.fail - plan = planned.plan - } else if (stage === 'implement') { - plan = { - execplanPath: enriched.execplanPath, - workItems: [], - summary: 'Resumed from the committed ExecPlan on the surviving branch.', - } - } + let plan: StagePlan + let impl: AnyRecord if (stage === 'plan' || stage === 'implement') { - const built = await runImplementationStage(task, worktree, plan as StagePlan, { resume: stage === 'implement', extra }) - if (built.kind === 'failure') return built.fail + if (stage === 'plan') { + const planned = await runPlanDesignLoop(task, worktree, { resume: true, extra }) + if (planned.kind === 'failure') return checkedTaskOutcome(planned.fail) + plan = planned.plan + } else { + plan = { + execplanPath: enriched.execplanPath, + workItems: [], + summary: 'Resumed from the committed ExecPlan on the surviving branch.', + } + } + const built = await runImplementationStage(task, worktree, plan, { resume: stage === 'implement', extra }) + if (built.kind === 'failure') return checkedTaskOutcome(built.fail) impl = built.impl } else { // Carry the ADR 002 assessment's advisory residual risk forward into the @@ -489,10 +504,10 @@ async function executeResume( impl = synthetic plan = { execplanPath: synthetic.execplanPath, workItems: [], summary: synthetic.summary } } - return await runDualReviewAndIntegration(task, candidate.worktreePath, plan as StagePlan, impl as AnyRecord, mergeLock, { kind: 'recovery-resume' }) + return checkedTaskOutcome(await runDualReviewAndIntegration(task, candidate.worktreePath, plan, impl, mergeLock, { kind: 'recovery-resume' })) } catch (error) { - const detail = `unhandled agent error: ${((error as Error | null) && (error as Error).message) || String(error)}` - return resultFromUnhandledAgentError(candidate.taskId, detail, { worktree, kind: 'recovery-resume' }, runFaultMetrics) + const detail = `unhandled agent error: ${faultThrownMessage(error)}` + return checkedTaskOutcome(resultFromUnhandledAgentError(candidate.taskId, detail, { worktree, kind: 'recovery-resume' }, runFaultMetrics)) } } @@ -531,7 +546,7 @@ async function runRecovery(root: string, mergeLock: MergeLockFn = null): Promise try { roadmap = await readRoadmapForSelection(root) } catch (error) { - summary.errors.push(((error as Error | null) && (error as Error).message) || String(error)) + summary.errors.push(faultThrownMessage(error)) log('[recovery] cannot read the canonical roadmap; skipping recovery discovery') return { summary, taskResults, held, fatal: null } } @@ -562,11 +577,11 @@ async function runRecovery(root: string, mergeLock: MergeLockFn = null): Promise } const resumeWt = { branch: candidate.branchName, worktreePath: candidate.worktreePath, baseSha: candidate.baseCommit } - let decision: { classification?: string; action: string; stage?: string | null; reason?: string; skip?: boolean } + let decision: RecoveryDispatchDecision let evidence: AssessmentEvidence | AnyRecord | undefined let enriched: RecoveryCandidate - let assessment: AnyRecord | null = null - let planState: ExecplanState | null = null + let assessment: AnyRecord | undefined + let planState: ExecplanState | undefined if (RESUME_MODE === 'continue') { // Deterministic dispatch: host git evidence plus the committed ExecPlan // Status. No judgement agent — the downstream gates ARE the judgement. @@ -597,46 +612,53 @@ async function runRecovery(root: string, mergeLock: MergeLockFn = null): Promise // Infrastructure faults during recovery poison every later agent // call too — halt the run instead of pretending branches were // assessed. + // SAFETY: Empty extra cannot override the task fields or the empty + // proposals returned by resultFromUnhandledAgentError. return { summary, taskResults, held, fatal: resultFromUnhandledAgentError(candidate.taskId, assessed.assessmentError, {}, runFaultMetrics) as TaskOutcome } } continue } assessment = assessed.assessment summary.assessed += 1 - evidence = assessment.hostEvidence as AssessmentEvidence + evidence = assessed.evidence // Resolve the durable ExecPlan before deciding: resume eligibility // requires it, and its absence must stay visible as missing-execplan. // A stat FAULT is neither present nor absent — report it distinctly // rather than resuming or classifying on unverifiable evidence. const resolved = await recoveryExecplanPath(candidate) enriched = { ...candidate, execplanPath: resolved.execplanPath } - // The agent assessment is untrusted JSON, so assert it into the typed - // decision shape at this boundary (via unknown): the decision helpers - // read each field defensively, and advisory residualRisk is re-read - // through advisoryResidualRisk, not this cast. - const assessmentFields = assessment as unknown as RecoveryAssessmentFields + const assessmentFields: RecoveryAssessmentFields = { + classification: typeof assessment.classification === 'string' ? assessment.classification : undefined, + taskScoped: typeof assessment.taskScoped === 'boolean' ? assessment.taskScoped : undefined, + validation: typeof assessment.validation === 'string' ? assessment.validation : undefined, + missingEvidence: Array.isArray(assessment.missingEvidence) + ? assessment.missingEvidence.map(String) + : undefined, + residualRisk: advisoryResidualRisk(assessment), + } decision = resolved.error - ? { classification: '', action: 'report', stage: null, reason: 'execplan-stat-error', skip: true } + ? { classification: '', action: 'report', reason: 'execplan-stat-error', skip: true } : { stage: 'review', ...recoveryDecision(enriched, evidence, assessmentFields, RESUME_MODE, { dryRun: DRY_RUN }) } } - const resultBase = { + const resultBase: Omit = { id: candidate.taskId, branchName: candidate.branchName, classification: decision.classification, - ...(planState ? { planStatus: planState.status } : {}), } + if (planState) resultBase.planStatus = planState.status if (decision.action !== 'resume') { if (decision.skip) { summary.skipped.push({ id: candidate.taskId, branchName: candidate.branchName, reason: decision.reason || '' }) } - summary.results.push({ + const reported: RecoveryReportedResult = { ...resultBase, action: 'reported', - ...(decision.reason ? { reason: decision.reason } : {}), - ...(assessment ? { assessment } : {}), - }) + } + if (decision.reason) reported.reason = decision.reason + if (assessment) reported.assessment = assessment + summary.results.push(reported) log(`[recovery] ${candidate.branchName}: ${decision.classification || planState?.status || 'reported'} (reported${decision.reason ? `; ${decision.reason}` : ''})`) continue } @@ -652,7 +674,7 @@ async function runRecovery(root: string, mergeLock: MergeLockFn = null): Promise const residualRisk = advisoryResidualRisk(assessment) log(`[recovery] resuming ${candidate.branchName} at the ${stage} stage through the ordinary pipeline (advisory residualRisk: ${residualRisk.length})`) const resume = { candidate, enriched, evidence, stage, residualRisk } - const outcome = (await executeResume(task, resume, mergeLock)) as TaskOutcome + const outcome = await executeResume(task, resume, mergeLock) if (outcome.status === 'fatal-auth' || outcome.status === 'provider-fault' || outcome.status === 'infra-fault') { summary.results.push({ ...resultBase, resumeStage: stage, action: 'resume-failed', reason: outcome.detail || outcome.status, residualRisk }) return { summary, taskResults, held, fatal: outcome } @@ -676,24 +698,21 @@ async function runRecovery(root: string, mergeLock: MergeLockFn = null): Promise return { summary, taskResults, held, fatal: null } } +/** Build write-probe targets for each adapter used by the task pipeline. */ function writeProbeTargets() { // The probe keeps the real adapter (it tests THAT adapter's launch/sandbox // write permission) but right-sizes the model: minimal effort, an optional // cheap per-adapter probe model, and deliberately NO PLAN_MODEL/BUILD_MODEL // inheritance — writing an exact token to an exact path needs no reasoning. - const probeOptions = (realAdapter: string) => (options: Record) => ({ - adapter: realAdapter, - ...(WRITE_PROBE_MODEL_BY_ADAPTER[String(realAdapter).toLowerCase()] - ? { model: WRITE_PROBE_MODEL_BY_ADAPTER[String(realAdapter).toLowerCase()] } - : {}), - effort: WRITE_PROBE_EFFORT, - ...options, - }) - // Normalize each adapter once (lowercase, matching AUTH_REQUIRED_ADAPTERS) - // and reuse the single value for both the dedup key and the agent() adapter, - // so a mixed-case config cannot make them diverge. - const planAdapter = String(PLAN_ADAPTER).toLowerCase() - const buildAdapter = String(BUILD_ADAPTER).toLowerCase() + const probeOptions = (realAdapter: string): WriteProbeTarget['options'] => (options) => { + const model = WRITE_PROBE_MODEL_BY_ADAPTER[realAdapter.toLowerCase()] + const routing = model ? { adapter: realAdapter, model } : { adapter: realAdapter } + return { ...routing, effort: WRITE_PROBE_EFFORT, ...options } + } + // Probe the exact adapter names used by the task agents; only model lookup + // is case-insensitive. Distinct spellings may route to different adapters. + const planAdapter = String(PLAN_ADAPTER) + const buildAdapter = String(BUILD_ADAPTER) const targets = [ { role: 'plan', adapter: planAdapter, options: probeOptions(planAdapter) }, { role: 'build', adapter: buildAdapter, options: probeOptions(buildAdapter) }, @@ -709,6 +728,7 @@ function writeProbeTargets() { // --------------------------------------------------------------------------- // Post-step audit // --------------------------------------------------------------------------- +/** Audit a completed task for follow-up roadmap work. */ async function runAudit(task: { id: string }) { phase('Audit') const audit = await agent(auditPrompt(task, null), auditAgentOptions({ phase: 'Audit', label: `audit:after-${task.id}`, schema: AUDIT_SCHEMA })) @@ -764,28 +784,32 @@ function mutex() { } const mergeLock = mutex() +/** Bound concurrent stage calls while preserving each call’s result or rejection. */ function semaphore(limit: number) { const max = Math.max(1, limit) - const queue: Array<{ fn: () => Promise; resolve: (value: unknown) => void; reject: (reason: unknown) => void }> = [] + const queue: Array<() => void> = [] let active = 0 const drain = () => { - while (active < max && queue.length) { - const item = queue.shift() - if (!item) break + while (active < max && queue.length > 0) { + const start = queue.shift() + if (!start) break active += 1 + start() + } + } + + return (fn: () => Promise): Promise => new Promise((resolve, reject) => { + // Each closure keeps the result and resolver paired with their own T. + queue.push(() => { Promise.resolve() - .then(item.fn) - .then(item.resolve, item.reject) + .then(fn) + .then(resolve, reject) .finally(() => { active -= 1 drain() }) - } - } - - return (fn: () => Promise): Promise => new Promise((resolve, reject) => { - queue.push({ fn, resolve: resolve as (value: unknown) => void, reject }) + }) drain() }) } @@ -847,6 +871,7 @@ const { }) let selectSeq = 0 +/** Select the next unclaimed roadmap task using the current canonical roadmap. */ async function doSelect(taken: { normal: string[]; addendum: string[] }) { phase('Select') const label = `select#${++selectSeq}` @@ -855,7 +880,7 @@ async function doSelect(taken: { normal: string[]; addendum: string[] }) { log(`[${label}] using working-tree ${ROADMAP}; origin/${BASE} read failed: ${roadmap.fallbackReason}`) } const selection = selectRoadmapTask(roadmap.text, taken, ONLY_TASK) - if (selection?.hasTask && selection.task) { + if (selection.hasTask && selection.task) { log(`[${label}] selected ${selection.task.isAddendum ? 'addendum pass' : 'normal task'} ${selection.task.id} from ${roadmap.source}`) } else { log(`[${label}] no unblocked roadmap task found from ${roadmap.source}`) @@ -871,41 +896,42 @@ function takenSnapshot() { } function isAlreadyTaken(task: SelectedTask) { - const processedSet = task?.isAddendum ? processedAddendum : processedNormal - const manualMergeReadySet = task?.isAddendum ? manualMergeReadyAddendum : manualMergeReadyNormal - const dryRunSet = task?.isAddendum ? dryRunAddendum : dryRunNormal - const recoveryHeldSet = task?.isAddendum ? recoveryHeldAddendum : recoveryHeldNormal + const processedSet = task.isAddendum ? processedAddendum : processedNormal + const manualMergeReadySet = task.isAddendum ? manualMergeReadyAddendum : manualMergeReadyNormal + const dryRunSet = task.isAddendum ? dryRunAddendum : dryRunNormal + const recoveryHeldSet = task.isAddendum ? recoveryHeldAddendum : recoveryHeldNormal return processedSet.has(task.id) || manualMergeReadySet.has(task.id) || dryRunSet.has(task.id) || recoveryHeldSet.has(task.id) || inflightNormal.has(task.id) || inflightAddendum.has(task.id) } function markInflight(task: SelectedTask) { - const inflightSet = task?.isAddendum ? inflightAddendum : inflightNormal + const inflightSet = task.isAddendum ? inflightAddendum : inflightNormal inflightSet.add(task.id) } function unmarkInflight(task: SelectedTask) { - const inflightSet = task?.isAddendum ? inflightAddendum : inflightNormal + const inflightSet = task.isAddendum ? inflightAddendum : inflightNormal inflightSet.delete(task.id) } function markProcessed(task: SelectedTask) { - const processedSet = task?.isAddendum ? processedAddendum : processedNormal + const processedSet = task.isAddendum ? processedAddendum : processedNormal processedSet.add(task.id) processed.push(task.id) } function markManualMergeReady(task: SelectedTask) { - const manualMergeReadySet = task?.isAddendum ? manualMergeReadyAddendum : manualMergeReadyNormal + const manualMergeReadySet = task.isAddendum ? manualMergeReadyAddendum : manualMergeReadyNormal manualMergeReadySet.add(task.id) } function markDryRun(task: SelectedTask) { - const dryRunSet = task?.isAddendum ? dryRunAddendum : dryRunNormal + const dryRunSet = task.isAddendum ? dryRunAddendum : dryRunNormal dryRunSet.add(task.id) } +/** Accumulate proposals until their roadmap step is ready for triage. */ function addPending(step: string, items: AnyRecord[] | undefined) { - if (!items || !items.length) return + if (!items || items.length === 0) return if (!pendingByStep.has(step)) pendingByStep.set(step, []) pendingByStep.get(step)!.push(...items) } @@ -917,16 +943,24 @@ function addPending(step: string, items: AnyRecord[] | undefined) { // the next refill — fix-debt-first within the step. async function flushSettledSteps() { if (!canFlush) return - const inflightSteps = new Set([...inflight.keys()].map(stepOf)) - for (const [step, items] of [...pendingByStep.entries()]) { - if (!items.length || inflightSteps.has(step)) continue + const inflightSteps = new Set([...inflight.keys()].map((taskId) => stepOf(taskId))) + // Snapshot the entries before awaiting triage, which can let other tasks + // append proposals to the pending map. + const pendingEntries = [...pendingByStep.entries()] + for (const [step, items] of pendingEntries) { + if (items.length === 0 || inflightSteps.has(step)) continue + // SAFETY: ODW validates triage replies against TRIAGE_SCHEMA; the + // no-proposals branch returns compatible fields. Keep null for test doubles. const tr = (await mergeLock(() => runTriage(step, items))) as { ok?: boolean; pushed?: boolean; decisions?: Array<{ lane: string }> } | null - triages.push({ step, ...(tr || {}) }) + triages.push({ step, ...tr }) if (!tr?.ok || !tr.pushed) { log(`[step ${step}] triage did not land; keeping ${items.length} proposal(s) pending`) continue } - const lanes = (tr?.decisions || []).reduce((m: Record, d) => ((m[d.lane] = (m[d.lane] || 0) + 1), m), {}) + const lanes: Record = {} + for (const decision of tr.decisions || []) { + lanes[decision.lane] = (lanes[decision.lane] || 0) + 1 + } log(`[step ${step}] triaged ${items.length} proposal(s): ${Object.entries(lanes).map(([k, v]) => `${v} ${k}`).join(', ') || 'none recorded'}`) pendingByStep.delete(step) } @@ -944,13 +978,13 @@ async function fillPool() { let sel try { sel = await doSelect(takenSnapshot()) - } catch (err) { - log(`[pool] select agent failed (${((err as Error | null) && (err as Error).message) || String(err)}); stop opening new work, drain in-flight`) - if (!halted) halted = `select agent error: ${((err as Error | null) && (err as Error).message) || String(err)}` + } catch (error) { + log(`[pool] select agent failed (${faultThrownMessage(error)}); stop opening new work, drain in-flight`) + if (!halted) halted = `select agent error: ${faultThrownMessage(error)}` return } - if (!sel || !sel.hasTask || !sel.task) { - if (inflight.size === 0) log(sel?.blockedSummary ? `No unblocked task: ${sel.blockedSummary}` : 'No unblocked roadmap tasks remain.') + if (!sel.hasTask || !sel.task) { + if (inflight.size === 0) log(sel.blockedSummary ? `No unblocked task: ${sel.blockedSummary}` : 'No unblocked roadmap tasks remain.') return } const task = sel.task @@ -965,17 +999,18 @@ async function fillPool() { // A thrown agent error (e.g. a subagent that completes without emitting // structured output) must NOT reject through Promise.race and crash the // whole run — convert it to a failed result the control loop drains. - runTask(task, mergeLock).then( - (result) => ({ id: task.id, task, result: result as TaskOutcome }), - (err) => { - const detail = `unhandled agent error: ${((err as Error | null) && (err as Error).message) || String(err)}` + runTask(task, mergeLock) + .then((result) => ({ id: task.id, task, result: checkedTaskOutcome(result) })) + .catch((error) => { + const detail = `unhandled agent error: ${faultThrownMessage(error)}` + // SAFETY: Empty extra cannot override the task fields or the empty + // proposals returned by resultFromUnhandledAgentError. return { id: task.id, task, result: resultFromUnhandledAgentError(task.id, detail, {}, runFaultMetrics) as TaskOutcome, } - }, - ), + }), ) } } @@ -1000,7 +1035,7 @@ async function runWorkflowMain() { // remediation triage run here in the control loop (serialized), so their // worktrees and BASE writes never collide with each other. const authPreflight = await runAuthPreflight() -if (authPreflight.length) { +if (authPreflight.length > 0) { halted = `fatal auth preflight failed: ${authPreflight.map((failure) => `${failure.tool} (${failure.command})`).join(', ')}` } @@ -1041,18 +1076,18 @@ if (RESUME_PARTIAL_BRANCHES && !halted) { stop = true } } catch (error) { - const detail = ((error as Error | null) && (error as Error).message) || String(error) + const detail = faultThrownMessage(error) recovery.errors.push(`recovery pass failed: ${detail}`) log(`[recovery] failed (${detail}); continuing with normal roadmap selection`) } } -while (true) { +for (;;) { if (!stop && !halted) { try { await flushSettledSteps() - } catch (err) { - log(`[triage] failed (${((err as Error | null) && (err as Error).message) || String(err)}); proposals stay pending for a later sweep`) + } catch (error) { + log(`[triage] failed (${faultThrownMessage(error)}); proposals stay pending for a later sweep`) } await fillPool() } @@ -1070,11 +1105,13 @@ while (true) { // Addendum passes deliberately generate no audit and no proposals — that // is what breaks the remediation-of-remediation recursion. addPending(stepOf(done.id), result.proposals) - let audit: (AnyRecord & { proposedRoadmapItems?: AnyRecord[] }) | null = null + let audit: (AnyRecord & { proposedRoadmapItems?: AnyRecord[] }) | null | undefined try { + // SAFETY: runAudit passes AUDIT_SCHEMA to ODW, which validates any + // proposed roadmap items as objects. Null remains for test doubles. audit = (await mergeLock(() => runAudit({ id: done.id }))) as (AnyRecord & { proposedRoadmapItems?: AnyRecord[] }) | null - } catch (err) { - log(`[audit ${done.id}] failed (${((err as Error | null) && (err as Error).message) || String(err)}); skipping (task already merged)`) + } catch (error) { + log(`[audit ${done.id}] failed (${faultThrownMessage(error)}); skipping (task already merged)`) } if (audit) { audits.push({ afterTask: done.id, ...audit }) @@ -1114,8 +1151,8 @@ while (true) { if (canFlush && !providerFaultHalt) { try { await flushSettledSteps() - } catch (err) { - log(`[triage:end] failed (${((err as Error | null) && (err as Error).message) || String(err)}); ${[...pendingByStep.values()].flat().length} proposal(s) left pending`) + } catch (error) { + log(`[triage:end] failed (${faultThrownMessage(error)}); ${[...pendingByStep.values()].flat().length} proposal(s) left pending`) } } // Recovery survivors the run reported but did not integrate still hold their @@ -1124,27 +1161,33 @@ if (canFlush && !providerFaultHalt) { // with such survivors has NOT stopped cleanly, even when the selector reports // no unblocked tasks — surface it as an explicit operator-recovery terminal // state instead of `halted: null` (issue #25). -const unresolvedRecovery = [ - ...[...recoveryHeldNormal] - .filter((id) => !processedNormal.has(id) && !manualMergeReadyNormal.has(id)) - .map((id) => ({ id, isAddendum: false })), - ...[...recoveryHeldAddendum] - .filter((id) => !processedAddendum.has(id) && !manualMergeReadyAddendum.has(id)) - .map((id) => ({ id, isAddendum: true })), -].sort((left, right) => left.id.localeCompare(right.id, 'en', { numeric: true }) || Number(left.isAddendum) - Number(right.isAddendum)) +const heldRecovery: Array<{ id: string; isAddendum: boolean }> = [] +for (const id of recoveryHeldNormal) { + if (!processedNormal.has(id) && !manualMergeReadyNormal.has(id)) { + heldRecovery.push({ id, isAddendum: false }) + } +} +for (const id of recoveryHeldAddendum) { + if (!processedAddendum.has(id) && !manualMergeReadyAddendum.has(id)) { + heldRecovery.push({ id, isAddendum: true }) + } +} +const unresolvedRecovery = heldRecovery.toSorted( + (left, right) => left.id.localeCompare(right.id, 'en', { numeric: true }) || Number(left.isAddendum) - Number(right.isAddendum), +) recovery.unresolved = unresolvedRecovery.map((entry) => { - const reported = (recovery.results || []).filter((result) => result.id === entry.id) - const last = reported[reported.length - 1] - return { + const reported = recovery.results.filter((result) => result.id === entry.id) + const last = reported.at(-1) + const unresolved = { id: entry.id, isAddendum: entry.isAddendum, branchName: last?.branchName || '', classification: last?.classification || '', action: last?.action || 'held', - ...(last?.reason ? { reason: last.reason } : {}), } + return last?.reason ? { ...unresolved, reason: last.reason } : unresolved }) -if (!halted && unresolvedRecovery.length) { +if (!halted && unresolvedRecovery.length > 0) { halted = `needs-operator-recovery: ${unresolvedRecovery.length} recovery survivor branch(es) still block the roadmap frontier ` + `(${unresolvedRecovery.map((entry) => entry.id + (entry.isAddendum ? ' (addendum)' : '')).join(', ')}); ` + @@ -1152,16 +1195,18 @@ if (!halted && unresolvedRecovery.length) { } const pendingProposals = [...pendingByStep.values()].flat() -const assessments = results - .filter((result) => result.assessment || result.assessmentError) - .map((result) => ({ - id: result.id, - stage: result.stage, - status: result.status, - classification: result.assessment?.classification || '', - recommendation: result.assessment?.recommendation || '', - assessmentError: result.assessmentError || '', - })) +const assessments = results.flatMap((result) => + result.assessment || result.assessmentError + ? [{ + id: result.id, + stage: result.stage, + status: result.status, + classification: result.assessment?.classification || '', + recommendation: result.assessment?.recommendation || '', + assessmentError: result.assessmentError || '', + }] + : [], +) // Salvage rides on individual task results (assessment.ts); surface it in the // terminal summary so an operator sees which branches had docs/execplans/*.md // artefacts committed (or why salvage was skipped) without opening result.json. @@ -1235,9 +1280,9 @@ return { `Processed ${processed.length} roadmap task(s) (pool width ${MAX_PARALLEL}): ` + results.map((r) => `${r.id}=${r.status}`).join(', ') + (recovery.enabled ? ` | recovery(${recovery.mode}): ${recovery.assessed} assessed, ${recovery.resumed} resumed, ${recovery.skipped.length} skipped` : '') + - (assessments.length ? ` | assessed ${assessments.length} failed/halted branch(es)` : '') + + (assessments.length > 0 ? ` | assessed ${assessments.length} failed/halted branch(es)` : '') + salvageSummarySuffix + - (triages.length ? ` | triaged ${triages.reduce((n, t) => n + (t.decisions ? t.decisions.length : 0), 0)} proposal(s) across ${triages.length} step(s)` : '') + + (triages.length > 0 ? ` | triaged ${triages.reduce((n, t) => n + (t.decisions ? t.decisions.length : 0), 0)} proposal(s) across ${triages.length} step(s)` : '') + (halted ? ` | halted: ${halted}` : ' | clean stop (no more unblocked tasks).'), } } @@ -1250,7 +1295,7 @@ async function workflowMain() { try { disposeHostGateLogs() } catch (error) { - log(`[host gates] could not dispose temporary log roots: ${String((error as Error | null)?.message || error).slice(-500)}`) + log(`[host gates] could not dispose temporary log roots: ${faultThrownMessage(error).slice(-500)}`) } } } diff --git a/src/workflows/df12-build-odw/prompts.ts b/src/workflows/df12-build-odw/prompts.ts index 5fbab82..c76e117 100644 --- a/src/workflows/df12-build-odw/prompts.ts +++ b/src/workflows/df12-build-odw/prompts.ts @@ -49,7 +49,7 @@ export interface PromptImpl { // escapes quotes, backslashes, and ordinary line breaks; escape ECMAScript's // two additional line separators as well so an item cannot forge the fence. function encodeUntrustedLine(value: string): string { - return JSON.stringify(value).replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029') + return JSON.stringify(value).replaceAll('\u2028', String.raw`\u2028`).replaceAll('\u2029', String.raw`\u2029`) } // Residual risk is advisory data from an assessment agent, not downstream @@ -57,7 +57,7 @@ function encodeUntrustedLine(value: string): string { // creating a prompt-injection path. Emit nothing when no caveats were reported. function residualRiskLines(impl: PromptImpl | null | undefined): string[] { const items = impl?.residualRisk || [] - if (!items.length) return [] + if (items.length === 0) return [] return [ '', 'Advisory residual risk (non-blocking — weigh during review, do not treat as an automatic block).', diff --git a/src/workflows/df12-build-odw/recovery-decision.ts b/src/workflows/df12-build-odw/recovery-decision.ts index 1658570..c9018c9 100644 --- a/src/workflows/df12-build-odw/recovery-decision.ts +++ b/src/workflows/df12-build-odw/recovery-decision.ts @@ -81,19 +81,22 @@ export interface RecoveryAssessmentFields { /** * Read the ADVISORY residual risk off an assessment record, tolerating the * continue-mode case where there is no assessment at all. Defensively reads - * untrusted agent-produced JSON: an absent or non-array `residualRisk` field - * yields an empty list rather than throwing. Kept here beside the - * `residualRisk` contract so the resume caller stays a plain field read + * untrusted agent-produced JSON: an absent, non-array, or non-string array + * `residualRisk` field yields an empty list rather than throwing. Kept beside + * the `residualRisk` contract so the resume caller stays a plain field read * rather than repeating the shape guard inline (issue #23). * * @param assessment - The (possibly absent or malformed) assessment record. * @returns The reported residual-risk strings, or an empty array when the - * field is missing or not an array. + * field is missing, not an array, or contains a non-string entry. */ export function advisoryResidualRisk( assessment: { residualRisk?: unknown } | null | undefined, ): string[] { - return Array.isArray(assessment?.residualRisk) ? (assessment.residualRisk as string[]) : [] + const residualRisk = assessment?.residualRisk + if (!Array.isArray(residualRisk)) return [] + const entries: unknown[] = residualRisk + return entries.every((entry): entry is string => typeof entry === 'string') ? entries : [] } /** @@ -205,10 +208,10 @@ export function branchToRoadmapId(branch: unknown): { isAddendum: boolean } | null { const match = TASK_BRANCH_RE.exec(String(branch || '')) - if (!match) return null + if (!match) return match const id = match[1] if (id === undefined) return null - return { id: id.replace(/-/g, '.'), isAddendum: Boolean(match[2]) } + return { id: id.replaceAll('-', '.'), isAddendum: Boolean(match[2]) } } /** @@ -221,11 +224,11 @@ export function branchToRoadmapId(branch: unknown): { */ export function parseWorktreeList(output: unknown): WorktreeEntry[] { const entries: WorktreeEntry[] = [] - let current: WorktreeEntry | null = null + let current: WorktreeEntry | undefined for (const line of String(output || '').split(/\r?\n/)) { if (!line.trim()) { if (current) entries.push(current) - current = null + current = undefined continue } const spaceIndex = line.indexOf(' ') @@ -292,9 +295,9 @@ export function recoveryResumeEligibility( assessment: RecoveryAssessmentFields | null | undefined, ): string { if (candidate?.isAddendum) return 'addendum-branch' - if ((evidence?.collectionErrors || []).length) return 'evidence-collection-error' + if ((evidence?.collectionErrors || []).length > 0) return 'evidence-collection-error' if (evidence?.dirtyState !== 'clean') return 'dirty-worktree' - if (!(evidence?.recentCommits || []).length) return 'no-committed-work' + if ((evidence.recentCommits || []).length === 0) return 'no-committed-work' if (assessment?.taskScoped !== true) return 'not-task-scoped' // `missingEvidence` is the SOLE evidence-based downgrade gate (issue #23): // callers represent genuinely missing validation or review evidence through @@ -302,7 +305,7 @@ export function recoveryResumeEligibility( // own. Advisory `residualRisk` is deliberately NOT consulted here — it is // carried forward to the resumed reviewer/integrator instead of downgrading // adopt-complete. - if ((assessment?.missingEvidence || []).length) return 'missing-validation-evidence' + if ((assessment.missingEvidence || []).length > 0) return 'missing-validation-evidence' if (!candidate?.execplanPath) return 'missing-execplan' return '' } @@ -363,13 +366,18 @@ export function recoveryDecision( * Status field text into an {@link ExecplanStatus}; any value absent from * this map (or an unfilled skeleton line) parses as `'unknown'`. */ -export const EXECPLAN_STATUS_MAP: Record = { +export const EXECPLAN_STATUS_MAP = { + /** The plan is still being drafted. */ draft: 'draft', + /** The plan is approved for implementation. */ approved: 'approved', + /** Implementation is in progress. */ 'in progress': 'in-progress', + /** Work on the plan is blocked. */ blocked: 'blocked', + /** Implementation is complete and ready for review. */ complete: 'complete', -} +} satisfies Record /** * Parse the durable state out of a committed ExecPlan: the `Status` field @@ -387,8 +395,8 @@ export function parseExecplanState(text: unknown): ExecplanState { let status: ExecplanStatus = 'unknown' const statusMatch = source.match(/^Status:\s*([A-Za-z ]+?)\s*$/m) if (statusMatch?.[1] !== undefined) { - const value = statusMatch[1].trim().toLowerCase().replace(/\s+/g, ' ') - status = EXECPLAN_STATUS_MAP[value] || 'unknown' + const value = statusMatch[1].trim().toLowerCase().replaceAll(/\s+/g, ' ') + status = Object.entries(EXECPLAN_STATUS_MAP).find(([key]) => key === value)?.[1] ?? 'unknown' } let ticked = 0 let unticked = 0 @@ -408,6 +416,11 @@ export function parseExecplanState(text: unknown): ExecplanState { return { status, ticked, unticked, items } } +/** Report a recovery candidate without dispatching it. */ +function reportContinueDecision(reason: string): ContinueDecision { + return { action: 'report', stage: null, reason, skip: true } +} + /** * The continue-mode decision table. Purely deterministic: hygiene checks * from host-collected evidence, then a {@link RecoveryStage} keyed on the @@ -427,19 +440,18 @@ export function recoveryContinueDecision( planState: { status: string; ticked?: number; unticked?: number; error?: string }, flags: { dryRun?: boolean } = {}, ): ContinueDecision { - const report = (reason: string): ContinueDecision => ({ action: 'report', stage: null, reason, skip: true }) - if (candidate?.isAddendum) return report('addendum-branch') - if ((evidence?.collectionErrors || []).length) return report('evidence-collection-error') - if (evidence?.dirtyState !== 'clean') return report('dirty-worktree') - if (planState.status === 'unreadable') return report('plan-unreadable') - if (planState.status === 'blocked') return report('plan-blocked') + if (candidate?.isAddendum) return reportContinueDecision('addendum-branch') + if ((evidence?.collectionErrors || []).length > 0) return reportContinueDecision('evidence-collection-error') + if (evidence?.dirtyState !== 'clean') return reportContinueDecision('dirty-worktree') + if (planState.status === 'unreadable') return reportContinueDecision('plan-unreadable') + if (planState.status === 'blocked') return reportContinueDecision('plan-blocked') const stage: RecoveryStage = planState.status === 'approved' || planState.status === 'in-progress' ? 'implement' - : planState.status === 'complete' + : (planState.status === 'complete' ? 'review' - : 'plan' // missing, draft, or unknown: (re-)enter planning - if (stage === 'review' && !(evidence?.recentCommits || []).length) return report('no-committed-work') + : 'plan') // missing, draft, or unknown: (re-)enter planning + if (stage === 'review' && (evidence.recentCommits || []).length === 0) return reportContinueDecision('no-committed-work') if (flags.dryRun) return { action: 'report', stage, reason: 'dry-run', skip: true } return { action: 'resume', stage, reason: '', skip: false } } diff --git a/src/workflows/df12-build-odw/recovery-discovery.ts b/src/workflows/df12-build-odw/recovery-discovery.ts index f2d57cc..84915aa 100644 --- a/src/workflows/df12-build-odw/recovery-discovery.ts +++ b/src/workflows/df12-build-odw/recovery-discovery.ts @@ -117,7 +117,7 @@ export function makeRecoveryDiscovery(limits: RecoveryDiscoveryLimits) { const mapped: RecoveryCandidate[] = [] for (const branch of branches) { const parsed = branchToRoadmapId(branch) - const task = parsed ? byId.get(parsed.id) : null + const task = parsed ? byId.get(parsed.id) : undefined if (!parsed || !task) { skipped.push({ id: parsed?.id || '', branchName: branch, reason: 'unmapped-branch' }) continue @@ -201,19 +201,21 @@ export async function readExecplanState( const text = await readFileText(path.join(candidate.worktreePath || '', candidate.execplanPath), candidate.worktreePath || undefined) return parseExecplanState(text) } catch (error) { - const failure = error as (Error & { code?: string }) | null - if (failure && (failure.code === 'ENOENT' || failure.code === 'ENOTDIR')) { + const fields = error && (typeof error === 'object' || typeof error === 'function') ? error : undefined + const code = fields && 'code' in fields ? fields.code : undefined + if (code === 'ENOENT' || code === 'ENOTDIR') { return { status: 'missing', ticked: 0, unticked: 0, items: [] } } // A plan that cannot be read is NOT a missing plan: dispatching to the // planning stage on an I/O or permission fault could overwrite durable // work. Surface the fault; the continue boundary reports it. + const message = fields && 'message' in fields ? fields.message : undefined return { status: 'unreadable', ticked: 0, unticked: 0, items: [], - error: `${candidate.execplanPath}: ${(failure && failure.message) || String(error)}`, + error: `${candidate.execplanPath}: ${message || String(error)}`, } } } diff --git a/src/workflows/df12-build-odw/remediation.ts b/src/workflows/df12-build-odw/remediation.ts index c7e0031..a96bdcf 100644 --- a/src/workflows/df12-build-odw/remediation.ts +++ b/src/workflows/df12-build-odw/remediation.ts @@ -93,6 +93,18 @@ export const TRIAGE_SCHEMA = { required: ['ok', 'decisions', 'summary'], } +/** Agent options assembled for a remediation triage request. */ +type TriageAgentCallOptions = { + /** Workflow phase in which the agent runs. */ + phase: 'Remediation' + /** Task-specific label shown in the workflow dashboard. */ + label: string + /** Schema enforced on the agent's structured response. */ + schema: typeof TRIAGE_SCHEMA + /** Escalated model when proposals span multiple sources. */ + model?: string +} + /** * Derives the step prefix (`"."`) a roadmap id belongs to, so * proposals and new tasks can be grouped and filed under the right step @@ -106,18 +118,23 @@ export const stepOf = (id: unknown): string => String(id).split('.').slice(0, 2) * obvious repeats (multiple reviews/audits raising the same item). Order * and first-seen metadata are preserved; the deduped source tags are * unioned so a later multi-source escalation check still sees every origin. + * A missing proposal list is empty; a missing entry has no valid title and + * is rejected with the same error as any other titleless proposal. */ -export function dedupeProposals(proposals: readonly Record[]): DedupedRemediationProposal[] { +export function dedupeProposals( + proposals: readonly (Record | null | undefined)[] | null | undefined, +): DedupedRemediationProposal[] { const byKey = new Map() for (const proposal of proposals || []) { - const title = typeof proposal?.title === 'string' ? proposal.title.trim() : '' - const key = title.toLowerCase().replace(/\s+/g, ' ') + if (!proposal) throw new TypeError('remediation proposal title must be a non-blank string') + const title = typeof proposal.title === 'string' ? proposal.title.trim() : '' + const key = title.toLowerCase().replaceAll(/\s+/g, ' ') if (!key) throw new TypeError('remediation proposal title must be a non-blank string') // Canonical origin is `source` (the tag run-task.ts stamps); rationale is a // legacy fallback only. - const fallback = String(proposal?.source || proposal?.rationale || '') + const fallback = String(proposal.source || proposal.rationale || '') const sources = Array.isArray(proposal.sources) - ? proposal.sources.map((source) => String(source)).filter(Boolean) + ? proposal.sources.map(String).filter(Boolean) : [] if (fallback && !sources.includes(fallback)) sources.push(fallback) const uniqueSources = [...new Set(sources)] @@ -143,7 +160,7 @@ export function triageNeedsEscalation(deduped: readonly (RemediationProposal & { for (const source of proposal.sources || []) sources.add(source) // Fall back to the proposal's own source tag (rationale only as a last // resort for legacy inputs) when the merged `sources` was not threaded. - if (!(proposal.sources || []).length) { + if ((proposal.sources || []).length === 0) { const fallback = String(proposal.source || proposal.rationale || '') if (fallback) sources.add(fallback) } @@ -200,8 +217,8 @@ export function makeRemediation({ preamble, worktreeSafetyNet, base, roadmap, tr // spans multiple audit/review sources (potential cross-phase / conflicting // routing). const deduped = dedupeProposals(proposals) - if (!deduped.length) return { ok: true, decisions: [], summary: 'no proposals to triage after de-duplication' } - const options: Record = { phase: 'Remediation', label: `triage:${stepPrefix}`, schema: TRIAGE_SCHEMA } + if (deduped.length === 0) return { ok: true, decisions: [], summary: 'no proposals to triage after de-duplication' } + const options: TriageAgentCallOptions = { phase: 'Remediation', label: `triage:${stepPrefix}`, schema: TRIAGE_SCHEMA } if (triageNeedsEscalation(deduped)) options.model = triageEscalationModel return await agent(triagePrompt(stepPrefix, deduped), triageAgentOptions(options)) } diff --git a/src/workflows/df12-build-odw/roadmap.ts b/src/workflows/df12-build-odw/roadmap.ts index 724851c..10ea435 100644 --- a/src/workflows/df12-build-odw/roadmap.ts +++ b/src/workflows/df12-build-odw/roadmap.ts @@ -36,7 +36,7 @@ export const ROADMAP_ID_RE = /\b\d+(?:\.\d+)+\b/g * integrate-* temp branches). */ export function roadmapIdSlug(id: unknown): string { - return String(id).replace(/[^0-9a-zA-Z]+/g, '-') + return String(id).replaceAll(/[^0-9a-zA-Z]+/g, '-') } /** The parent id of a dotted roadmap id (`1.2.3` -> `1.2`), or `''` for a top-level id. */ @@ -62,7 +62,7 @@ export function extractRoadmapIds(text: string): string[] { const end = match[2] if (start === undefined || end === undefined) continue const expanded = expandStepRange(start, end) - if (expanded.length) { + if (expanded.length > 0) { ids.delete(start) ids.delete(end) for (const id of expanded) ids.add(id) @@ -78,7 +78,7 @@ export function extractRoadmapIds(text: string): string[] { * 1,000-id maximum is inclusive. */ export function expandStepRange(start: string, end: string): string[] { - const MAX_STEP_RANGE_LENGTH = 1_000 + const MAX_STEP_RANGE_LENGTH = 1000 if (!/^\d+\.\d+$/.test(start) || !/^\d+\.\d+$/.test(end)) return [] const [startPhase, startStep] = start.split('.') const [endPhase, endStep] = end.split('.') @@ -94,6 +94,14 @@ export function expandStepRange(start: string, end: string): string[] { return Array.from({ length: rangeLength }, (_, index) => `${phaseId}.${firstStep + index}`) } +/** Parsed roadmap tasks and the identifiers already complete. */ +interface ParsedRoadmap { + /** Top-level tasks in roadmap order; addendum sub-tasks are nested under their parent, not listed here. */ + tasks: RoadmapTask[] + /** IDs treated as complete: fully-ticked tasks, ticked addendum sub-tasks, and prefix ids whose whole subtree is complete. */ + completed: Set +} + /** * Parse the roadmap markdown into an ordered task list and the set of * completed ids. An addendum sub-task is nested under its parent's @@ -101,15 +109,10 @@ export function expandStepRange(start: string, end: string): string[] { * complete and the sub-task line is indented deeper than it — the convention * for post-completion follow-up work. */ -export function parseRoadmap(text: string): { - /** Top-level tasks in roadmap order; addendum sub-tasks are nested under their parent, not listed here. */ - tasks: RoadmapTask[] - /** IDs treated as complete: fully-ticked tasks, ticked addendum sub-tasks, and prefix ids whose whole subtree is complete. */ - completed: Set -} { +export function parseRoadmap(text: string): ParsedRoadmap { const tasks: RoadmapTask[] = [] const byId = new Map() - let currentTask: RoadmapTask | null = null + let currentTask: RoadmapTask | undefined for (const [index, line] of text.split(/\r?\n/).entries()) { const taskMatch = line.match(TASK_LINE_RE) @@ -171,19 +174,19 @@ export function completedIds(tasks: readonly RoadmapTask[]): Set { const postOrder: RoadmapTask[] = [] const pending = [...tasks] - while (pending.length) { + while (pending.length > 0) { const task = pending.pop()! postOrder.push(task) pending.push(...task.subtasks) } - for (const task of postOrder.reverse()) { + for (const task of postOrder.toReversed()) { completionByTask.set(task, isComplete(task) && task.subtasks.every((subtask) => completionByTask.get(subtask))) } const recordCompletedSubtasks = (task: RoadmapTask) => { const pendingSubtasks = [task] - while (pendingSubtasks.length) { + while (pendingSubtasks.length > 0) { const subtask = pendingSubtasks.pop()! if (completionByTask.get(subtask)) completed.add(subtask.id) pendingSubtasks.push(...subtask.subtasks) @@ -201,7 +204,7 @@ export function completedIds(tasks: readonly RoadmapTask[]): Set { } for (const [prefix, groupedTasks] of prefixes.entries()) { - if (groupedTasks.length && groupedTasks.every((task) => completionByTask.get(task))) completed.add(prefix) + if (groupedTasks.length > 0 && groupedTasks.every((task) => completionByTask.get(task))) completed.add(prefix) } return completed @@ -210,7 +213,7 @@ export function completedIds(tasks: readonly RoadmapTask[]): Set { /** A task is fully complete only when its own checkbox and every addendum descendant are ticked. */ export function isTaskFullyComplete(task: RoadmapTask): boolean { const pending = [task] - while (pending.length) { + while (pending.length > 0) { const current = pending.pop()! if (!isComplete(current)) return false pending.push(...current.subtasks) @@ -218,13 +221,24 @@ export function isTaskFullyComplete(task: RoadmapTask): boolean { return true } +/** The identity and optional addendum IDs consulted by the task filter. */ +export interface OnlyTaskCandidate { + /** The selected task's identity and optional addendum IDs. */ + task: { + /** The task's roadmap identifier. */ + id: string + /** Addendum identifiers, when supplied by the caller. */ + subtasks?: readonly string[] | null + } +} + /** * Whether a candidate matches the `--only` task filter: no filter selects * everything; otherwise the candidate's own id or one of its addendum * subtask ids must match, so `--only` on a parent still surfaces its open * addendum work. */ -export function taskMatchesOnlyTask(candidate: { task: SelectedTask }, onlyTask: string | null): boolean { +export function taskMatchesOnlyTask(candidate: OnlyTaskCandidate, onlyTask: string | null): boolean { if (!onlyTask) return true if (candidate.task.id === onlyTask) return true return Boolean(candidate.task.subtasks?.includes(onlyTask)) @@ -232,7 +246,7 @@ export function taskMatchesOnlyTask(candidate: { task: SelectedTask }, onlyTask: /** Render up to five blocked-task reasons plus an overflow count; `''` when nothing is blocked. */ export function blockedSummary(blocked: readonly string[]): string { - if (!blocked.length) return '' + if (blocked.length === 0) return '' const sample = blocked.slice(0, 5).join('; ') const suffix = blocked.length > 5 ? `; ${blocked.length - 5} more` : '' return `${blocked.length} blocked task(s): ${sample}${suffix}` @@ -258,7 +272,7 @@ export function selectRoadmapTask( for (const task of tasks) { const openSubtasks = task.subtasks.filter((subtask) => !isTaskFullyComplete(subtask)) - if (isComplete(task) && openSubtasks.length && !addendumTaken.has(task.id)) { + if (isComplete(task) && openSubtasks.length > 0 && !addendumTaken.has(task.id)) { candidates.push({ order: task.line, kind: 'addendum', @@ -275,7 +289,7 @@ export function selectRoadmapTask( if (!isComplete(task) && !normalTaken.has(task.id)) { const missing = task.requires.filter((id) => !completed.has(id)) - if (missing.length) { + if (missing.length > 0) { blocked.push(`${task.id} requires ${missing.join(', ')}`) } else { candidates.push({ @@ -285,7 +299,7 @@ export function selectRoadmapTask( id: task.id, title: task.title, requires: task.requires, - rationale: task.requires.length + rationale: task.requires.length > 0 ? `Every declared dependency is complete: ${task.requires.join(', ')}.` : 'The task has no declared dependencies.', isAddendum: false, @@ -298,7 +312,7 @@ export function selectRoadmapTask( const matchingCandidates = candidates .filter((candidate) => taskMatchesOnlyTask(candidate, onlyTask)) - .sort((left, right) => left.order - right.order) + .toSorted((left, right) => left.order - right.order) const selected = matchingCandidates[0] if (!selected) { const reason = onlyTask @@ -321,7 +335,7 @@ export function roadmapTaskIndex(roadmapText: string): Map const byId = new Map() for (const task of tasks) { byId.set(task.id, task) - for (const subtask of task.subtasks || []) byId.set(subtask.id, subtask) + for (const subtask of task.subtasks) byId.set(subtask.id, subtask) } return byId } diff --git a/src/workflows/df12-build-odw/run-task.ts b/src/workflows/df12-build-odw/run-task.ts index 948caae..23d3855 100644 --- a/src/workflows/df12-build-odw/run-task.ts +++ b/src/workflows/df12-build-odw/run-task.ts @@ -67,6 +67,8 @@ export interface StageImpl extends Record { summary?: string /** Issues the implementation left unresolved. */ openIssues?: string[] + /** Commit subjects reported by the schema-validated implementation agent. */ + commits?: string[] // Advisory, non-blocking residual risk carried forward from an ADR 002 // recovery assessment; rendered as review/integration context only (#23). /** Advisory, non-blocking risks carried into review and integration. */ @@ -81,7 +83,19 @@ interface StageReview extends Record { verdict?: string blocking?: string[] summary?: string - proposedRoadmapItems?: Array> + proposedRoadmapItems?: ProposedRoadmapItem[] +} + +/** A reviewer-proposed follow-up, validated by REVIEW_SCHEMA. */ +interface ProposedRoadmapItem { + title: string + rationale: string + severity?: string +} + +/** A follow-up proposal annotated with the originating review. */ +interface TaskProposal extends ProposedRoadmapItem { + source: string } interface StageIntegration extends Record { @@ -123,6 +137,48 @@ type Lock = (fn: () => Promise) => Promise type MergeLock = ((fn: () => Promise) => Promise) | null type AgentOptions = (options: Record) => Record +/** Preserve message properties from thrown values, including non-Error objects. */ +function thrownMessage(error: unknown): unknown { + const message = error != null && (typeof error === 'object' || typeof error === 'function') && 'message' in error + ? error.message + : undefined + return message || String(error) +} + +/** Additional caller-provided fields copied onto a task result. */ +interface ExtraResultFields { + [key: string]: unknown +} + +/** Accept additional result fields only from an object-shaped options value. */ +function isExtraResultFields(value: unknown): value is ExtraResultFields { + return value != null && typeof value === 'object' +} + +function extraResultFields(value: unknown): ExtraResultFields { + return isExtraResultFields(value) ? value : {} +} + +type TaskAgentSchema = + | typeof PLAN_SCHEMA + | typeof DESIGN_VERDICT_SCHEMA + | typeof FIX_SCHEMA + | typeof IMPL_SCHEMA + | typeof INTEGRATE_SCHEMA + | typeof REVIEW_SCHEMA + +/** + * Preserve the schema at the agent boundary even if an injected router + * removes or replaces it while choosing an adapter or model. + */ +function routeTaskAgentOptions( + router: AgentOptions, + options: { phase: string; label: string }, + schema: TaskAgentSchema, +) { + return { ...router({ ...options, schema }), schema } +} + /** * The dependency contract injected into {@link makeTaskPipeline}: the * config caps and feature flags, the prompt builders for every stage, the @@ -266,6 +322,40 @@ export interface FixReportSummary { summary: unknown } +/** Fields accepted from a fix report before its bounded summary is prepared. */ +interface FixReportInput { + commits?: unknown + gatesGreen?: unknown + hostReviewRuns?: unknown + coderabbitRuns?: unknown + resolved?: unknown + openIssues?: unknown + summary?: unknown +} + +/** Evidence recorded for one bounded dual-review round. */ +interface ReviewRound { + round: number + codeReview: ReviewVerdictSummary | null + expertReview: ReviewVerdictSummary | null + blocking: string[] + hostGates?: HostGateRun['results'] + fix: FixReportSummary | null +} + +/** Include host gate evidence only when those gates ran in this round. */ +function withHostGateEvidence(record: ReviewRound, gates: HostGateRun | undefined): ReviewRound { + if (!gates) return record + const { fix, ...review } = record + return { ...review, hostGates: gates.results, fix } +} + +/** Preserve the omission of deferred-review evidence when no review was deferred. */ +function withDeferredHostReviews(result: StageResult, deferrals: HostReviewDeferral[]): StageResult { + if (deferrals.length === 0) return result + return { ...result, deferredHostReviews: deferrals } +} + /** Successful plan-stage product, mutually exclusive with other variants. */ export interface PipelinePlanProduct { /** Runtime discriminator for a successful planning stage. */ @@ -351,7 +441,7 @@ export function summarizeReviewVerdict(review: StageReview | null | undefined): * @returns `{ summary }` when `fix` is a string, a trimmed structured * record when `fix` is an object, or `null` when `fix` is absent. */ -export function summarizeFixReport(fix: Record | string | null | undefined): FixReportSummary | null { +export function summarizeFixReport(fix: FixReportInput | string | null | undefined): FixReportSummary | null { if (!fix) return null if (typeof fix === 'string') return { summary: fix } return { @@ -412,6 +502,45 @@ export function integrationHaltDetail(integration: StageIntegration | null): str return integration?.conflicts || integration?.summary || INTEGRATION_INCOMPLETE_DETAIL } +// Shared tail of the implementation stage: the committed-worktree durability +// gate and the plan-status advisory, identical for both build modes. +async function finishImplementationStage(task: SelectedTask, worktree: string, plan: StagePlan, impl: StageImpl, extra: ExtraResultFields): Promise> { + const tag = task.id + // Host-verified durability gate: ok=true with a dirty worktree is a + // contract violation — uncommitted work is unreviewable and would be lost + // at the squash merge, so fail fast with the exact paths. + const committed = await verifyWorktreeCommitted(worktree) + if (!committed.ok) { + return { + kind: 'failure', + fail: { + id: tag, + status: 'failed', + stage: 'implement', + detail: `implementation returned ok but left uncommitted state in the worktree (${committed.detail}); every work item must be committed before returning`, + openIssues: impl.openIssues || [], + worktree, + proposals: [], + ...extra, + }, + } + } + // Stale plan status costs a resumed run one redundant stage, never + // correctness — log it rather than failing a green implementation. + if (plan.execplanPath) { + const contained = execplanRelPath(worktree, plan.execplanPath) + if (contained.ok) { + const planState = await readExecplanState({ worktreePath: worktree, execplanPath: contained.relPath }) + if (planState.status !== 'complete') { + log(`[task ${tag}] implementation returned ok but the committed ExecPlan status is '${planState.status}' (expected COMPLETE)${planState.error ? `: ${planState.error}` : ''}`) + } + } else { + log(`[task ${tag}] skipping the post-implementation plan-status check: ${contained.detail}`) + } + } + return { kind: 'impl', impl } +} + /** * Factory that binds the run wiring — config caps, prompt builders, * adapter options, stage locks, retry, assessment, the write gate, and @@ -473,16 +602,16 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { async function runPlanDesignLoop(task: SelectedTask, worktree: string, opts: Record = {}): Promise> { const tag = task.id - const extra = (opts.extra as Record) || {} + const extra = extraResultFields(opts.extra) let plan: StagePlan | null = null let designVerdict: DesignVerdict | null = null for (let round = 1; round <= MAX_DESIGN_ROUNDS; round++) { phase('Plan') - plan = (await planningLock(() => withInfraRetry(() => agent(planPrompt(task, worktree, designVerdict, round, opts), planAgentOptions({ + // SAFETY: ODW validates the structured reply against PLAN_SCHEMA. + plan = (await planningLock(() => withInfraRetry(() => agent(planPrompt(task, worktree, designVerdict, round, opts), routeTaskAgentOptions(planAgentOptions, { phase: 'Plan', label: `plan:${tag} r${round}`, - schema: PLAN_SCHEMA, - })), `plan:${tag} r${round}`))) as StagePlan | null + }, PLAN_SCHEMA)), `plan:${tag} r${round}`))) as StagePlan | null if (!plan) return { kind: 'failure', fail: { id: tag, status: 'failed', stage: 'plan', detail: 'planner returned nothing', worktree, proposals: [], ...extra } } // Containment before any filesystem access: the planner's path is // untrusted data, and an escape fails the task closed. @@ -538,11 +667,12 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { } phase('Design Review') - designVerdict = (await planningLock(() => withInfraRetry(() => agent(designReviewPrompt(task, worktree, plan as StagePlan, round), reviewAgentOptions({ + const reviewedPlan = plan + // SAFETY: ODW validates the structured reply against DESIGN_VERDICT_SCHEMA. + designVerdict = (await planningLock(() => withInfraRetry(() => agent(designReviewPrompt(task, worktree, reviewedPlan, round), routeTaskAgentOptions(reviewAgentOptions, { phase: 'Design Review', label: `design-review:${tag} r${round}`, - schema: DESIGN_VERDICT_SCHEMA, - })), `design-review:${tag} r${round}`))) as DesignVerdict | null + }, DESIGN_VERDICT_SCHEMA)), `design-review:${tag} r${round}`))) as DesignVerdict | null if (designVerdict?.satisfied) { log(`[task ${tag}] design approved in round ${round}`) // Deterministic bookkeeping owned by the control loop: record the @@ -607,9 +737,10 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { blocking: string[], label: string, round: number, - ): Promise<{ report: Record | null; dirtyDetail: string | null }> { + ): Promise<{ report: FixReportInput | null; dirtyDetail: string | null }> { phase('Implement') - const report = (await buildLock(() => withInfraRetry(() => agent(fixPrompt(task, worktree, plan, blocking, round), buildAgentOptions({ phase: 'Implement', label, schema: FIX_SCHEMA })), label))) as Record | null + // SAFETY: ODW validates the structured reply against FIX_SCHEMA. + const report = (await buildLock(() => withInfraRetry(() => agent(fixPrompt(task, worktree, plan, blocking, round), routeTaskAgentOptions(buildAgentOptions, { phase: 'Implement', label }, FIX_SCHEMA)), label))) as FixReportInput | null const committed = await verifyWorktreeCommitted(worktree) return { report, dirtyDetail: committed.ok ? null : committed.detail } } @@ -619,7 +750,7 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { worktree: string, plan: StagePlan, itemLabel: string, - extra: Record, + extra: ExtraResultFields, ): Promise<{ ok: true } | { fail: StageResult }> { const tag = task.id // Bounded fix loop shared by the two deterministic checks. The commit gates @@ -675,7 +806,7 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { worktree: string, plan: StagePlan, itemLabel: string, - extra: Record, + extra: ExtraResultFields, ): Promise<{ ok: true; hostReviewRuns: number } | { fail: StageResult }> { const tag = task.id let runs = 0 @@ -688,11 +819,13 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { return { fail: { id: tag, status: 'fatal-auth', stage: 'auth', detail: `${hostReviewerDisplayName} host review is not authenticated: ${review.detail}`, worktree, proposals: [], ...extra } } } if (review.outcome === 'rate-limited' || review.outcome === 'error') { - return { fail: { id: tag, status: 'halted', stage: 'code-review', detail: `${hostReviewerDisplayName} between-item review could not complete for ${itemLabel} (${review.outcome} after ${review.attempts} attempt(s), ${review.errorCategory}): ${review.detail}; the work is committed but unreviewed — resolve the host-review fault and relaunch with resumeMode: "continue"`, worktree, proposals: [], ...extra } } + const blocking = reviewBlockingItems(hostReviewerDisplayName, review.findings) + const findingsDetail = blocking.length > 0 ? `; blocking findings: ${blocking.join('; ')}` : '' + return { fail: { id: tag, status: 'halted', stage: 'code-review', detail: `${hostReviewerDisplayName} between-item review could not complete for ${itemLabel} (${review.outcome} after ${review.attempts} attempt(s), ${review.errorCategory}): ${review.detail}${findingsDetail}; the work is committed but unreviewed — resolve the host-review fault and relaunch with resumeMode: "continue"`, worktree, proposals: [], ...extra } } } const blocking = reviewBlockingItems(hostReviewerDisplayName, review.findings) log(`[task ${tag}] between-item ${hostReviewerDisplayName} ${itemLabel} attempt ${attempt}: ${review.findings.length} finding(s), ${blocking.length} blocking`) - if (!blocking.length) return { ok: true, hostReviewRuns: runs } + if (blocking.length === 0) return { ok: true, hostReviewRuns: runs } if (attempt === MAX_REVIEW_ROUNDS) { return { fail: { id: tag, status: 'failed', stage: 'code-review', detail: `${hostReviewerDisplayName} between-item review left blocking finding(s) unresolved after ${MAX_REVIEW_ROUNDS} fix attempt(s) on ${itemLabel}: ${blocking.join('; ')}`, worktree, proposals: [], ...extra } } } @@ -706,7 +839,7 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { async function runWorkItemBuildLoop(task: SelectedTask, worktree: string, plan: StagePlan, opts: Record = {}): Promise | null> { const tag = task.id - const extra = (opts.extra as Record) || {} + const extra = extraResultFields(opts.extra) const fail = (detail: string, openIssues: string[] = []) => ({ kind: 'failure' as const, fail: { id: tag, status: 'failed', stage: 'implement', detail, openIssues, worktree, proposals: [], ...extra } }) const contained = execplanRelPath(worktree, plan.execplanPath) if (!contained.ok) return fail(contained.detail) @@ -714,7 +847,7 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { const initial = await readExecplanState(planRef) if (initial.status === 'unreadable') return fail(`could not read the committed ExecPlan: ${initial.error}`) if (initial.status === 'missing') return fail(`the ExecPlan disappeared before the build: ${contained.relPath}`) - if (!(initial.items || []).length) return null + if (initial.items.length === 0) return null const commits: string[] = [] const openIssues: string[] = [] let hostReviewRuns = 0 @@ -725,14 +858,14 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { const before = await readExecplanState(planRef) if (before.status === 'unreadable') return fail(`could not read the committed ExecPlan: ${before.error}`, openIssues) if (before.status === 'missing') return fail(`the committed ExecPlan disappeared mid-build: ${contained.relPath}`, openIssues) - const item = (before.items || []).find((entry) => !entry.ticked) + const item = before.items.find((entry) => !entry.ticked) if (!item) break const label = `implement:${tag} wi${round}` - const impl = (await buildLock(() => withInfraRetry(() => agent(implementWorkItemPrompt(task, worktree, plan, item, { ...opts, noProgressNote }), buildAgentOptions({ + // SAFETY: ODW validates the structured reply against IMPL_SCHEMA. + const impl = (await buildLock(() => withInfraRetry(() => agent(implementWorkItemPrompt(task, worktree, plan, item, { ...opts, noProgressNote }), routeTaskAgentOptions(buildAgentOptions, { phase: 'Implement', label, - schema: IMPL_SCHEMA, - })), label))) as StageImpl | null + }, IMPL_SCHEMA)), label))) as StageImpl | null lastImpl = impl const authDetail = implementationAuthFailureDetail(impl) if (authDetail) { @@ -742,7 +875,7 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { if (!impl || !impl.ok || !impl.gatesGreen) { return fail(impl?.summary || `work item did not reach a green state: ${item.text}`, impl?.openIssues || []) } - if (Array.isArray(impl.commits)) commits.push(...(impl.commits as string[])) + if (Array.isArray(impl.commits)) commits.push(...impl.commits) openIssues.push(...(impl.openIssues || [])) hostReviewRuns += Number(impl.hostReviewRuns ?? impl.coderabbitRuns) || 0 // Host-verified durability per turn: a dirty worktree or an uncommitted @@ -786,8 +919,8 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { const final = await readExecplanState(planRef) if (final.status === 'unreadable') return fail(`could not read the committed ExecPlan after the build: ${final.error}`, openIssues) if (final.status === 'missing') return fail(`the committed ExecPlan is absent after the build: ${contained.relPath}`, openIssues) - const remaining = (final.items || []).filter((entry) => !entry.ticked) - if (remaining.length) { + const remaining = final.items.filter((entry) => !entry.ticked) + if (remaining.length > 0) { return fail(`the work-item round cap (maxWorkItemRounds=${MAX_WORK_ITEM_ROUNDS}) was reached with ${remaining.length} Progress item(s) still unticked; the first is: ${remaining[0]?.text ?? 'unknown'}`, openIssues) } return { @@ -808,7 +941,7 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { async function runImplementationStage(task: SelectedTask, worktree: string, plan: StagePlan, opts: Record = {}): Promise> { const tag = task.id - const extra = (opts.extra as Record) || {} + const extra = extraResultFields(opts.extra) phase('Implement') if (PER_WORK_ITEM_BUILD) { const itemised = await runWorkItemBuildLoop(task, worktree, plan, opts) @@ -820,11 +953,11 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { } log(`[task ${tag}] the committed ExecPlan has no Progress checklist; falling back to the single-turn build`) } - const impl = (await buildLock(() => withInfraRetry(() => agent(implementPrompt(task, worktree, plan, opts), buildAgentOptions({ + // SAFETY: ODW validates the structured reply against IMPL_SCHEMA. + const impl = (await buildLock(() => withInfraRetry(() => agent(implementPrompt(task, worktree, plan, opts), routeTaskAgentOptions(buildAgentOptions, { phase: 'Implement', label: `implement:${tag}`, - schema: IMPL_SCHEMA, - })), `implement:${tag}`))) as StageImpl | null + }, IMPL_SCHEMA)), `implement:${tag}`))) as StageImpl | null const authDetail = implementationAuthFailureDetail(impl) if (authDetail) { faultMetrics.authFaults += 1 @@ -848,45 +981,6 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { return finishImplementationStage(task, worktree, plan, impl, extra) } - // Shared tail of the implementation stage: the committed-worktree durability - // gate and the plan-status advisory, identical for both build modes. - async function finishImplementationStage(task: SelectedTask, worktree: string, plan: StagePlan, impl: StageImpl, extra: Record): Promise> { - const tag = task.id - // Host-verified durability gate: ok=true with a dirty worktree is a - // contract violation — uncommitted work is unreviewable and would be lost - // at the squash merge, so fail fast with the exact paths. - const committed = await verifyWorktreeCommitted(worktree) - if (!committed.ok) { - return { - kind: 'failure', - fail: { - id: tag, - status: 'failed', - stage: 'implement', - detail: `implementation returned ok but left uncommitted state in the worktree (${committed.detail}); every work item must be committed before returning`, - openIssues: impl?.openIssues || [], - worktree, - proposals: [], - ...extra, - }, - } - } - // Stale plan status costs a resumed run one redundant stage, never - // correctness — log it rather than failing a green implementation. - if (plan?.execplanPath) { - const contained = execplanRelPath(worktree, plan.execplanPath) - if (!contained.ok) { - log(`[task ${tag}] skipping the post-implementation plan-status check: ${contained.detail}`) - } else { - const planState = await readExecplanState({ worktreePath: worktree, execplanPath: contained.relPath }) - if (planState.status !== 'complete') { - log(`[task ${tag}] implementation returned ok but the committed ExecPlan status is '${planState.status}' (expected COMPLETE)${planState.error ? `: ${planState.error}` : ''}`) - } - } - } - return { kind: 'impl', impl } - } - // One integration implementation for the normal pipeline and the addendum // lane (the two copies had already drifted once). Deliberately NOT wrapped // in withInfraRetry: integration pushes to origin/BASE, so a hidden-success @@ -904,7 +998,7 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { // point stays a readable command rather than a long positional list. context: { worktree: string - proposals: Array> + proposals: TaskProposal[] kindExtra: Record impl?: StageImpl | null }, @@ -913,12 +1007,13 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { const tag = task.id const doIntegrate = () => { phase('Integrate') - return buildLock(() => agent(integratePrompt(task, worktree, impl), buildAgentOptions({ phase: 'Integrate', label: `integrate:${tag}`, schema: INTEGRATE_SCHEMA }))) + return buildLock(() => agent(integratePrompt(task, worktree, impl), routeTaskAgentOptions(buildAgentOptions, { phase: 'Integrate', label: `integrate:${tag}` }, INTEGRATE_SCHEMA))) } try { + // SAFETY: ODW validates the structured reply against INTEGRATE_SCHEMA. return { integration: (mergeLock ? await mergeLock(doIntegrate) : await doIntegrate()) as StageIntegration | null } } catch (error) { - const message = ((error as Error | null) && (error as Error).message) || String(error) + const message = thrownMessage(error) if (!infrastructureFailureDetail(message)) throw error faultMetrics.infraFaults += 1 return { @@ -938,15 +1033,15 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { async function runDualReviewAndIntegration(task: SelectedTask, worktree: string, plan: StagePlan, impl: StageImpl, mergeLock: MergeLock, options: Record = {}): Promise { const tag = task.id const kindExtra = options.kind ? { kind: options.kind } : {} - const proposals: Array> = [] - const reviewRounds: Array> = [] + const proposals: TaskProposal[] = [] + const reviewRounds: ReviewRound[] = [] let reviewsPass = false const deferredHostReviews: HostReviewDeferral[] = [] for (let round = 1; round <= MAX_REVIEW_ROUNDS; round++) { // Host-verified gates FIRST: deterministic, zero tokens, and a red // branch must not spend reviewer agents. A red gate goes straight to a // fix round carrying the host log evidence. - let hostGates: HostGateRun | null = null + let hostGates: HostGateRun | undefined if (HOST_COMMIT_GATES) { hostGates = await hostGateLock(() => runHostCommitGates(worktree, tag, `r${round}`)) if (!hostGates.green) { @@ -977,7 +1072,7 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { if (!cs.clean) { log(`[task ${tag}] CodeScene check red in round ${round}`) const csBlocking = [`CODESCENE RED: ${cs.detail} Clear these code-health regressions by refactoring, or — only where further refinement would be deleterious — suppress the specific smell with a justified @codescene(disable:"...") comment, then re-run the check to green and commit.`] - reviewRounds.push({ round, codeReview: null, expertReview: null, blocking: csBlocking, ...(hostGates ? { hostGates: hostGates.results } : {}), fix: null }) + reviewRounds.push(withHostGateEvidence({ round, codeReview: null, expertReview: null, blocking: csBlocking, fix: null }, hostGates)) if (round === MAX_REVIEW_ROUNDS) break const csFix = await dispatchFixAndVerify(task, worktree, plan, csBlocking, `fix:${tag} cs r${round}`, round) const csRound = reviewRounds.at(-1) @@ -1000,19 +1095,19 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { faultMetrics.authFaults += 1 return { id: tag, status: 'fatal-auth', stage: 'review', detail: `${hostReviewerDisplayName} host review is not authenticated: ${hostReview.detail}`, reviewRounds, worktree, proposals, ...kindExtra } } - if (hostReview.outcome === 'rate-limited' || hostReview.outcome === 'error') { + const hostReviewBlocking = reviewBlockingItems(hostReviewerDisplayName, hostReview.findings) + if ((hostReview.outcome === 'rate-limited' || hostReview.outcome === 'error') && hostReviewBlocking.length === 0) { // Deferred: the host reviewer could not complete, so fall through to the // reviewer agents — they remain the decisive review. const deferral = hostReviewDeferral(hostReview) if (deferral) deferredHostReviews.push(deferral) log(`[task ${tag}] ${hostReviewerDisplayName} host review deferred in round ${round}: ${hostReview.outcome} (${hostReview.errorCategory}: ${hostReview.detail})`) } else { - const hostReviewBlocking = reviewBlockingItems(hostReviewerDisplayName, hostReview.findings) log(`[task ${tag}] ${hostReviewerDisplayName} host review round ${round}: ${hostReview.findings.length} finding(s), ${hostReviewBlocking.length} blocking`) - if (hostReviewBlocking.length) { + if (hostReviewBlocking.length > 0) { // Short-circuit before the reviewer agents: fix the host-review // blockers first, spending zero agent tokens this round. - reviewRounds.push({ round, codeReview: null, expertReview: null, blocking: hostReviewBlocking, ...(hostGates ? { hostGates: hostGates.results } : {}), fix: null }) + reviewRounds.push(withHostGateEvidence({ round, codeReview: null, expertReview: null, blocking: hostReviewBlocking, fix: null }, hostGates)) if (round === MAX_REVIEW_ROUNDS) break const hostReviewFix = await dispatchFixAndVerify(task, worktree, plan, hostReviewBlocking, `fix:${tag} r${round}`, round) const hostReviewRound = reviewRounds.at(-1) @@ -1032,13 +1127,14 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { // each reviewer retries infra faults and records any residual one here. const reviewInfraFaults: string[] = [] const runReviewAgent = (promptText: string, reviewPhase: string, label: string) => () => - withInfraRetry(() => agent(promptText, reviewAgentOptions({ phase: reviewPhase, label, schema: REVIEW_SCHEMA })), label) + withInfraRetry(() => agent(promptText, routeTaskAgentOptions(reviewAgentOptions, { phase: reviewPhase, label }, REVIEW_SCHEMA)), label) .catch((error) => { - const message = ((error as Error | null) && (error as Error).message) || String(error) + const message = thrownMessage(error) if (!infrastructureFailureDetail(message)) throw error reviewInfraFaults.push(`${label}: ${message}`) return null }) + // SAFETY: Each reply is validated against REVIEW_SCHEMA; parallel maps failed slots to null. const [codeReview, expertReview] = (await parallel([ runReviewAgent(codeReviewPrompt(task, worktree, plan, impl), 'Code Review', `code-review:${tag} r${round}`), runReviewAgent(expertReviewPrompt(task, worktree, plan, impl), 'Expert Review', `expert-review:${tag} r${round}`), @@ -1047,12 +1143,12 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { if (r?.proposedRoadmapItems?.length) proposals.push(...r.proposedRoadmapItems.map((p) => ({ ...p, source: `review:${tag}` }))) } if (!codeReview || !expertReview) { - const missing = [ - !codeReview ? 'code review' : null, - !expertReview ? 'expert review' : null, - ].filter(Boolean).join(' and ') + const missingReviews: string[] = [] + if (!codeReview) missingReviews.push('code review') + if (!expertReview) missingReviews.push('expert review') + const missing = missingReviews.join(' and ') reviewRounds.push({ round, codeReview: summarizeReviewVerdict(codeReview), expertReview: summarizeReviewVerdict(expertReview), blocking: [], fix: null }) - if (reviewInfraFaults.length) { + if (reviewInfraFaults.length > 0) { faultMetrics.infraFaults += 1 return { id: tag, @@ -1080,9 +1176,9 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { ...(codeReview.blocking || []), ...(expertReview.blocking || []), ] - const roundRecord: Record = { round, codeReview: summarizeReviewVerdict(codeReview), expertReview: summarizeReviewVerdict(expertReview), blocking, ...(hostGates ? { hostGates: hostGates.results } : {}), fix: null } + const roundRecord = withHostGateEvidence({ round, codeReview: summarizeReviewVerdict(codeReview), expertReview: summarizeReviewVerdict(expertReview), blocking, fix: null }, hostGates) reviewRounds.push(roundRecord) - if (blocking.length === 0 && codeReview?.verdict === 'pass' && expertReview?.verdict === 'pass') { + if (blocking.length === 0 && codeReview.verdict === 'pass' && expertReview.verdict === 'pass') { reviewsPass = true log(`[task ${tag}] dual review passed in round ${round}`) break @@ -1100,7 +1196,7 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { } if (!reviewsPass) { - const lastRound = reviewRounds[reviewRounds.length - 1] as { blocking?: string[] } | undefined + const lastRound = reviewRounds.at(-1) const finalBlocking = (lastRound?.blocking || []).slice(0, 6).join('; ') return { id: tag, @@ -1127,10 +1223,10 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { return { id: tag, status: 'halted', stage: 'integrate', detail: integrationHaltDetail(integration), worktree, proposals, ...kindExtra } } } else { - return { id: tag, status: 'manual-merge-ready', plan, impl, integration, worktree, proposals, ...(deferredHostReviews.length ? { deferredHostReviews } : {}), ...kindExtra } + return { ...withDeferredHostReviews({ id: tag, status: 'manual-merge-ready', plan, impl, integration, worktree, proposals }, deferredHostReviews), ...kindExtra } } - return { id: tag, status: 'done', plan, impl, integration, worktree, proposals, ...(deferredHostReviews.length ? { deferredHostReviews } : {}), ...kindExtra } + return { ...withDeferredHostReviews({ id: tag, status: 'done', plan, impl, integration, worktree, proposals }, deferredHostReviews), ...kindExtra } } async function runTask(task: SelectedTask, mergeLock: MergeLock): Promise { @@ -1187,7 +1283,8 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { // remediation from spawning more remediation. if (task.isAddendum) { phase('Implement') - const impl = (await buildLock(() => withInfraRetry(() => agent(implementAddendumPrompt(task, worktree), buildAgentOptions({ phase: 'Implement', label: `addendum:${tag}`, schema: IMPL_SCHEMA })), `addendum:${tag}`))) as StageImpl | null + // SAFETY: ODW validates the structured reply against IMPL_SCHEMA. + const impl = (await buildLock(() => withInfraRetry(() => agent(implementAddendumPrompt(task, worktree), routeTaskAgentOptions(buildAgentOptions, { phase: 'Implement', label: `addendum:${tag}` }, IMPL_SCHEMA)), `addendum:${tag}`))) as StageImpl | null const authDetail = implementationAuthFailureDetail(impl) if (authDetail) { faultMetrics.authFaults += 1 @@ -1205,7 +1302,7 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { const openIssues = impl?.openIssues || [] const onlyDeferredReviewIssues = hasOnlyDeferredReviewIssues(openIssues) if (addendumImplementationNeedsManualMerge(impl)) { - const deferredEvidence = openIssues.length + const deferredEvidence = openIssues.length > 0 ? ` Outstanding deferred review evidence: ${openIssues.join('; ')}` : '' return { @@ -1214,7 +1311,7 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { stage: 'addendum', detail: `addendum implementation reported completed work and green gates but did not set ok=true` + - `${openIssues.length ? ' and left only deferred/recoverable review issues open' : ' and no open issues'}; ` + + `${openIssues.length > 0 ? ' and left only deferred/recoverable review issues open' : ' and no open issues'}; ` + `branch left for operator verification before integration.${deferredEvidence}`, openIssues, impl, @@ -1233,7 +1330,7 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { if (!committed.ok) { return await attachAssessment(task, wt, { id: tag, status: 'failed', stage: 'addendum', detail: `addendum implementation returned ok but left uncommitted state in the worktree (${committed.detail}); every sub-task must be committed before returning`, openIssues, worktree, proposals: [], kind: 'addendum' }) } - const proposals: Array> = [] + const proposals: TaskProposal[] = [] const addendumDeferredReviews: HostReviewDeferral[] = [] // Host-verified gates: addenda have no review rounds, so a gatesGreen // claim the host cannot reproduce fails here, before any review spend. @@ -1264,7 +1361,7 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { return { id: tag, status: 'fatal-auth', stage: 'auth', detail: `${hostReviewerDisplayName} host review is not authenticated: ${hostReview.detail}`, worktree, proposals, kind: 'addendum' } } const blockingFindings = reviewBlockingItems(hostReviewerDisplayName, hostReview.findings) - if (blockingFindings.length) { + if (blockingFindings.length > 0) { return await attachAssessment(task, wt, { id: tag, status: 'halted', stage: 'addendum-review', detail: `${hostReviewerDisplayName} host review found blocking issue(s): ${blockingFindings.join('; ')}`, impl, worktree, proposals, kind: 'addendum' }) } const deferredReview = hostReviewDeferral(hostReview) @@ -1278,7 +1375,8 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { || addendumDeferredReviews.length > 0 if (needsFallbackReview) { phase('Code Review') - addendumReview = (await withInfraRetry(() => agent(addendumReviewPrompt(task, worktree, impl), reviewAgentOptions({ phase: 'Code Review', label: `addendum-review:${tag}`, schema: REVIEW_SCHEMA })), `addendum-review:${tag}`)) as StageReview | null + // SAFETY: ODW validates the structured reply against REVIEW_SCHEMA. + addendumReview = (await withInfraRetry(() => agent(addendumReviewPrompt(task, worktree, impl), routeTaskAgentOptions(reviewAgentOptions, { phase: 'Code Review', label: `addendum-review:${tag}` }, REVIEW_SCHEMA)), `addendum-review:${tag}`)) as StageReview | null if (addendumReview?.proposedRoadmapItems?.length) { proposals.push(...addendumReview.proposedRoadmapItems.map((p) => ({ ...p, source: `review:${tag}` }))) } @@ -1297,9 +1395,9 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { return await attachAssessment(task, wt, { id: tag, status: 'halted', stage: 'integrate', detail: integrationHaltDetail(integration), worktree, proposals, kind: 'addendum' }) } } else { - return { id: tag, status: 'manual-merge-ready', impl, addendumReview, integration, worktree, proposals, ...(addendumDeferredReviews.length ? { deferredHostReviews: addendumDeferredReviews } : {}), kind: 'addendum' } + return { ...withDeferredHostReviews({ id: tag, status: 'manual-merge-ready', impl, addendumReview, integration, worktree, proposals }, addendumDeferredReviews), kind: 'addendum' } } - return { id: tag, status: 'done', impl, addendumReview, integration, worktree, proposals, ...(addendumDeferredReviews.length ? { deferredHostReviews: addendumDeferredReviews } : {}), kind: 'addendum' } + return { ...withDeferredHostReviews({ id: tag, status: 'done', impl, addendumReview, integration, worktree, proposals }, addendumDeferredReviews), kind: 'addendum' } } // --- Plan <-> Design review (adversarial loop) -------------------------- @@ -1321,7 +1419,7 @@ export function makeTaskPipeline(deps: TaskPipelineDeps): TaskPipelineSurface { } return outcome } catch (error) { - const detail = `unhandled agent error: ${((error as Error | null) && (error as Error).message) || String(error)}` + const detail = `unhandled agent error: ${thrownMessage(error)}` const result = resultFromUnhandledAgentError(tag, detail, { worktree }, faultMetrics) return await attachAssessment(task, wt, result) } diff --git a/src/workflows/df12-build-odw/shell-command.ts b/src/workflows/df12-build-odw/shell-command.ts index 9530bfe..244bfcb 100644 --- a/src/workflows/df12-build-odw/shell-command.ts +++ b/src/workflows/df12-build-odw/shell-command.ts @@ -40,13 +40,13 @@ export interface ShellCommandTokens { } /** Return the exclusive end offset of a balanced shell command substitution. */ -function commandSubstitutionEnd(command: string, start: number): number | null { +function commandSubstitutionEnd(command: string, start: number): number | undefined { let cursor = start + 2 let quote = '' let depth = 1 while (cursor < command.length) { const character = command[cursor] - if (character === undefined) return null + if (character === undefined) return if (character === '\\' && quote !== "'") { cursor += 2 continue @@ -74,22 +74,25 @@ function commandSubstitutionEnd(command: string, start: number): number | null { } cursor += 1 } - return null + return } -/** Return an unquoted, unescaped assignment name, or null for an executable word. */ -function assignmentName(command: string, word: ShellCommandWord): string | null { +/** Return an unquoted, unescaped assignment name, or undefined for an executable word. */ +function assignmentName(command: string, word: ShellCommandWord): string | undefined { let cursor = word.start let name = '' while (cursor < word.end) { const character = command[cursor] - if (character === undefined) return null - if (character === '=') return name && /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? name : null - if (!/[A-Za-z0-9_]/.test(character)) return null + if (character === undefined) return + if (character === '=') { + if (name && /^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return name + return + } + if (!/[A-Za-z0-9_]/.test(character)) return name += character cursor += 1 } - return null + return } /** @@ -133,7 +136,7 @@ export function tokenizeShellCommand(command: string): ShellCommandTokens | null } if (character === '$' && command[cursor + 1] === '(' && quote !== "'") { const end = commandSubstitutionEnd(command, cursor) - if (end === null) return null + if (end === undefined) return null value += command.slice(cursor, end) hasWord = true cursor = end diff --git a/src/workflows/df12-build-odw/task-outcome.ts b/src/workflows/df12-build-odw/task-outcome.ts new file mode 100644 index 0000000..f6604cc --- /dev/null +++ b/src/workflows/df12-build-odw/task-outcome.ts @@ -0,0 +1,50 @@ +/** Validate pipeline results before the pool reads optional result fields. + * @module + */ +import type { StageResult } from './run-task.ts' +import type { SalvageRecord } from './assessment.ts' + +/** A pipeline result with the optional fields consumed by the worker pool. */ +export interface TaskOutcome extends StageResult { + /** Integration details used to determine whether the task was pushed. */ + integration?: { + /** Whether integration pushed the task to the target branch. */ + pushed?: boolean + } | null + /** Task kind, including the addendum marker used to skip recursive audit. */ + kind?: string + /** Proposed roadmap items awaiting triage. */ + proposals?: Record[] + /** Assessment details attached to a failed task. */ + assessment?: Record + /** Error encountered while assessing the task. */ + assessmentError?: string + /** Evidence of artefacts salvaged from the task branch. */ + salvage?: SalvageRecord +} + +function isOutcomeRecord(value: T): value is T & Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +/** Reject malformed optional fields without dropping unrelated result data. */ +export function checkedTaskOutcome(result: StageResult): TaskOutcome { + const { integration, kind, proposals, assessment, assessmentError, salvage } = result + const validIntegration = integration === undefined || integration === null || + (isOutcomeRecord(integration) && (integration.pushed === undefined || typeof integration.pushed === 'boolean')) + const validProposals = proposals === undefined || (Array.isArray(proposals) && proposals.every((item: unknown) => isOutcomeRecord(item))) + const validSalvage = salvage === undefined || (isOutcomeRecord(salvage) && + typeof salvage.classification === 'string' && typeof salvage.sha === 'string' && typeof salvage.detail === 'string' && + Array.isArray(salvage.committed) && salvage.committed.every((item: unknown) => typeof item === 'string') && + Array.isArray(salvage.skipped) && salvage.skipped.every((item: unknown) => + isOutcomeRecord(item) && typeof item.path === 'string' && typeof item.reason === 'string')) + if (!validIntegration || !validProposals || !validSalvage || + (kind !== undefined && typeof kind !== 'string') || + (assessment !== undefined && !isOutcomeRecord(assessment)) || + (assessmentError !== undefined && typeof assessmentError !== 'string')) { + throw new Error('Invalid task outcome') + } + // SAFETY: The checks above validate every optional field read by the pool; + // StageResult already guarantees the required id and status fields. + return result as TaskOutcome +} diff --git a/src/workflows/df12-build-odw/write-preflight.ts b/src/workflows/df12-build-odw/write-preflight.ts index 91e3028..a363e46 100644 --- a/src/workflows/df12-build-odw/write-preflight.ts +++ b/src/workflows/df12-build-odw/write-preflight.ts @@ -60,14 +60,46 @@ export const WRITE_PROBE_SCHEMA = { required: ['ok'], } +/** The fields consumed from a schema-validated probe reply. */ +interface WriteProbeReply { + ok: boolean + detail?: string +} + +// Agent replies cross a runtime boundary even when a schema was requested. +function isWriteProbeReply(value: unknown): value is WriteProbeReply { + return value !== null + && typeof value === 'object' + && !Array.isArray(value) + && 'ok' in value + && typeof value.ok === 'boolean' + && (!('detail' in value) || typeof value.detail === 'string') +} + +// JavaScript permits throwing any value, including plain objects and strings. +function writeProbeErrorMessage(error: unknown): string { + if (error !== null && (typeof error === 'object' || typeof error === 'function') && 'message' in error) { + const message = error.message + if (message) return String(message) + } + return String(error) +} + +function isProbeSymlinkError(error: unknown): boolean { + if (error === null || (typeof error !== 'object' && typeof error !== 'function') || !('code' in error)) return false + return error.code === 'ELOOP' || error.code === 'EMLINK' +} + /** * Path for one adapter's probe file inside a task worktree. The adapter - * name is sanitized so it can never escape the intended directory or - * collide with an unrelated dotfile. + * name is sanitized and paired with a digest of its exact bytes, keeping + * case-distinct and differently sanitized names separate on every filesystem. */ export function writeProbePath(worktree: string, adapter: string): string { const path = process.getBuiltinModule('node:path') - return path.join(worktree, `.df12-write-probe-${String(adapter).replace(/[^0-9a-zA-Z._-]+/g, '-')}`) + const digest = process.getBuiltinModule('node:crypto').createHash('sha256').update(adapter).digest('hex') + const readable = adapter.replaceAll(/[^0-9a-zA-Z._-]+/g, '-').slice(0, 40) + return path.join(worktree, `.df12-write-probe-${readable}-${digest}`) } /** @@ -128,8 +160,8 @@ export async function verifyWriteProbe(probeFile: string, token: string): Promis // Open once with O_NOFOLLOW|O_NONBLOCK and verify/read through the handle: // the check and read target one inode, symlinks cannot redirect the read, // and a FIFO cannot block before the regular-file check. - let handle = null - let content: string | null = null + let handle: Awaited> | undefined + let content: string | undefined try { handle = await fs.open(probeFile, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK) const stat = await handle.stat() @@ -137,18 +169,17 @@ export async function verifyWriteProbe(probeFile: string, token: string): Promis content = await handle.readFile({ encoding: 'utf8' }) } } catch (error) { - const failure = error as (Error & { code?: string }) | null // Linux reports ELOOP for O_NOFOLLOW on a symlink; FreeBSD uses EMLINK. - if (failure && (failure.code === 'ELOOP' || failure.code === 'EMLINK')) { + if (isProbeSymlinkError(error)) { await fs.rm(probeFile, { force: true, recursive: true }) return { ok: false, detail: 'probe path is not a regular file (symlink or special file rejected)' } } - return { ok: false, detail: `probe file missing or unreadable: ${(failure && failure.message) || String(error)}` } + return { ok: false, detail: `probe file missing or unreadable: ${writeProbeErrorMessage(error)}` } } finally { if (handle) await handle.close() } await fs.rm(probeFile, { force: true, recursive: true }) - if (content === null) { + if (content === undefined) { return { ok: false, detail: 'probe path is not a regular file (symlink or special file rejected)' } } if (content.trim() === token) return { ok: true, detail: '' } @@ -179,7 +210,7 @@ export async function hostWriteProbe(worktree: string): Promise<{ await fs.rm(hostProbe, { force: true }) return { ok: true, detail: '' } } catch (error) { - return { ok: false, detail: ((error as Error | null) && (error as Error).message) || String(error) } + return { ok: false, detail: writeProbeErrorMessage(error) } } } @@ -204,32 +235,41 @@ export function makeWritePreflight({ enabled, targets }: { enabled: boolean; tar // pre-existing file (or symlink) at the probe path must never be able to // satisfy — or redirect — the verification that follows. await clearProbeArtifact(probeFile) - let reply: { ok?: boolean; detail?: string } | null = null + let reply: WriteProbeReply | undefined let agentError = '' try { - reply = (await agent(writeProbePrompt(probeFile, token), target.options({ - phase: 'Worktree', - label: `write-probe:${target.adapter}`, + const response = await agent(writeProbePrompt(probeFile, token), { + ...target.options({ + phase: 'Worktree', + label: `write-probe:${target.adapter}`, + schema: WRITE_PROBE_SCHEMA, + }), + // Keep the reply contract if routing omitted or replaced it. schema: WRITE_PROBE_SCHEMA, - }))) as { ok?: boolean; detail?: string } | null + }) + if (isWriteProbeReply(response)) { + reply = response + } else { + agentError = 'write probe agent reply has invalid fields' + } } catch (error) { - agentError = ((error as Error | null) && (error as Error).message) || String(error) + agentError = writeProbeErrorMessage(error) } const verified = await verifyWriteProbe(probeFile, token) - if (verified.ok) return null + if (verified.ok) return const detail = [verified.detail, reply && reply.ok === false ? reply.detail : '', agentError] .filter(Boolean) .join('; ') return { adapter: target.adapter, detail } })) - const failures = outcomes.filter((outcome): outcome is { adapter: string; detail: string } => outcome !== null) + const failures = outcomes.filter((outcome): outcome is { adapter: string; detail: string } => outcome !== undefined) return { ok: failures.length === 0, failures } } // One probe per run: sandbox scope does not vary between sibling worktrees, // so every task shares the first task's verdict (concurrent tasks await the // same promise and fail fast together when the environment is broken). - let taskAgentWritePreflight: Promise | null = null + let taskAgentWritePreflight: Promise | undefined function ensureTaskAgentWriteAccess(worktree: string, tag: string): Promise { if (!enabled) return Promise.resolve({ ok: true, skipped: true, failures: [] }) if (!taskAgentWritePreflight) taskAgentWritePreflight = runTaskAgentWritePreflight(worktree, tag) diff --git a/tests/df12-build-odw-recovery.test.mjs b/tests/df12-build-odw-recovery.test.mjs index d06d894..8c8527f 100644 --- a/tests/df12-build-odw-recovery.test.mjs +++ b/tests/df12-build-odw-recovery.test.mjs @@ -108,6 +108,7 @@ return { runHostReview, runDualReviewAndIntegration, runRecovery, + writeProbeTargets, } `, ) @@ -154,6 +155,19 @@ test('recovery configuration accepts explicit operator overrides', async () => { assert.equal(surface.RESUME_MAX_CANDIDATES, 2) }) +test('write probes preserve configured adapter spelling and deduplicate exact names', async () => { + const surface = await loadRecoverySurface({ + planAdapter: 'Claude', buildAdapter: 'claude', + writeProbeModelByAdapter: { CLAUDE: 'probe-model' }, + }) + const targets = surface.writeProbeTargets() + assert.deepEqual(targets.map((target) => target.adapter), ['Claude', 'claude']) + assert.deepEqual(targets.map((target) => target.options({}).adapter), ['Claude', 'claude']) + assert.deepEqual(targets.map((target) => target.options({}).model), ['probe-model', 'probe-model']) + const shared = await loadRecoverySurface({ planAdapter: 'Claude', buildAdapter: 'Claude' }) + assert.equal(shared.writeProbeTargets().length, 1) +}) + test('recovery discovery is opt-in: truthy but non-true values stay disabled', async () => { const surface = await loadRecoverySurface({ resumePartialBranches: 'yes' }) @@ -837,6 +851,26 @@ test('review-mode resume routes an eligible branch through review and integratio ) }) +test('malformed integration outcome is reported as a recovery failure', async () => { + const calls = [] + const surface = await loadRecoverySurface( + { resumePartialBranches: true, resumeMode: 'review' }, + reviewModeAgent(calls, { + integrate: async () => ({ + ok: true, roadmapMarkedDone: true, rebased: true, squashMerged: true, + mergeSha: 'feedfeed', pushed: 'yes', conflicts: '', summary: 'invalid push flag', + }), + }), + ) + const repo = makeRecoveryRepo() + const outcome = await surface.runRecovery(repo.dir) + + assert.equal(outcome.summary.resumed, 0) + assert.equal(outcome.summary.results[0].action, 'resume-failed') + assert.match(outcome.summary.results[0].reason, /Invalid task outcome/) + assert.equal(outcome.taskResults[0].result.status, 'failed') +}) + test('review-mode resume carries advisory residualRisk into the review and integration prompts', async () => { const calls = [] const prompts = { codeReview: '', expertReview: '', integrate: '' } diff --git a/tests/modules/assessment.test.ts b/tests/modules/assessment.test.ts index 6791b60..e358c84 100644 --- a/tests/modules/assessment.test.ts +++ b/tests/modules/assessment.test.ts @@ -4,7 +4,7 @@ // fixture repo. import { describe, expect, spyOn, test } from 'bun:test' import { execFileSync } from 'node:child_process' -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' @@ -18,6 +18,7 @@ import { summarizeSalvages, } from '../../src/workflows/df12-build-odw/assessment.ts' import * as durability from '../../src/workflows/df12-build-odw/execplan-durability.ts' +import { ASSESSMENT_SCHEMA } from '../../src/workflows/df12-build-odw/schemas.ts' const globals = globalThis as Record globals.log = () => {} @@ -239,6 +240,49 @@ describe('assessment model tier', () => { }) }) +async function assertAssessmentSchemaRouting( + route: (options: Record) => Record, +): Promise { + const { dir, baseSha } = makeRepo() + const previousAgent = globals.agent + const calls: Array> = [] + try { + globals.agent = async (_prompt: string, options: Record) => { + calls.push(options) + return { classification: 'continue-manual', taskScoped: true } + } + const assessment = subject({ assessmentAgentOptions: route }) + await assessment.attachAssessment( + { id: '1.2.3', title: 'Schema boundary' }, + { branch: 'roadmap-1-2-3', worktreePath: dir, baseSha }, + { status: 'failed', stage: 'review', detail: 'gates red' }, + ) + expect(calls).toHaveLength(1) + expect(calls[0]?.schema).toBe(ASSESSMENT_SCHEMA) + expect(calls[0]?.adapter).toBe('recording-adapter') + expect(calls[0]?.model).toBe('recording-model') + } finally { + globals.agent = previousAgent + rmSync(dir, { recursive: true, force: true }) + } +} + +test('assessment agent receives its schema when routing omits it', async () => { + await assertAssessmentSchemaRouting(() => ({ + adapter: 'recording-adapter', + model: 'recording-model', + })) +}) + +test('assessment agent receives its schema when routing replaces it', async () => { + await assertAssessmentSchemaRouting((options) => ({ + ...options, + adapter: 'recording-adapter', + model: 'recording-model', + schema: { type: 'string' }, + })) +}) + // A branch whose docs/execplans directory is already tracked, so a fresh // artefact under it surfaces as an individual untracked (??) path rather than // a collapsed untracked directory — mirroring a real task branch. @@ -536,6 +580,21 @@ describe('recovery assessment entry points', () => { expect(threw.assessmentError).toMatch(/exited with code 137/) expect(threw.evidence).toBeDefined() }) + + test('normalizes a malformed thrown assessment message', async () => { + const { dir, baseSha } = makeRepo() + globals.agent = async () => { throw { message: 42 } } + const assessed = await subject().assessRecoveryCandidate(candidate(dir, baseSha)) + expect(assessed.assessmentError).toBe('[object Object]') + }) + + test('rejects a non-object assessment result from an injected agent', async () => { + const { dir, baseSha } = makeRepo() + globals.agent = async () => 'invalid assessment' + const assessed = await subject().assessRecoveryCandidate(candidate(dir, baseSha)) + expect(assessed.assessment).toBeNull() + expect(assessed.assessmentError).toContain('malformed structured output') + }) }) describe('attachAssessment', () => { diff --git a/tests/modules/compile-time-contract.test.ts b/tests/modules/compile-time-contract.test.ts index 76c7298..5ec43d2 100644 --- a/tests/modules/compile-time-contract.test.ts +++ b/tests/modules/compile-time-contract.test.ts @@ -96,6 +96,20 @@ function typecheckHostReview(body: string): { ok: boolean; output: string } { const FAULT_METRICS_PRELUDE = `import type { FaultMetrics } from '${path.join(ODW_SRC, 'types.ts')}'` +const CHANGED_EXPORTS_PRELUDE = [ + `import type { SalvageSummary, summarizeSalvages } from '${path.join(ODW_SRC, 'assessment.ts')}'`, + `import type { CoderabbitJsonObject, CoderabbitParsedOutput, parseCoderabbitAgentOutput } from '${path.join(ODW_SRC, 'coderabbit-review.ts')}'`, + `import type { HostGateSurface } from '${path.join(ODW_SRC, 'host-gates.ts')}'`, + `import type { OnlyTaskCandidate, taskMatchesOnlyTask } from '${path.join(ODW_SRC, 'roadmap.ts')}'`, + `import type { TaskOutcome, checkedTaskOutcome } from '${path.join(ODW_SRC, 'task-outcome.ts')}'`, + `import type { makeConfig, WorkflowConfig } from '${path.join(ODW_SRC, 'config.ts')}'`, + `import type { execplanRelPath, ExecplanPathResolution } from '${path.join(ODW_SRC, 'execplan-durability.ts')}'`, + `import type { gitEvidence, GitEvidenceValue } from '${path.join(ODW_SRC, 'git-evidence.ts')}'`, + `import type { parseRoadmap } from '${path.join(ODW_SRC, 'roadmap.ts')}'`, + `import type { summarizeFixReport, FixReportSummary } from '${path.join(ODW_SRC, 'run-task.ts')}'`, + `import type { DakarDocument, DakarFinding, DakarFindingValidation, parseDakarDocument } from '${path.join(ODW_SRC, 'dakar-review.ts')}'`, +].join('\n') + function typecheckFaultMetrics(body: string): { ok: boolean; output: string } { return typecheckWithPrelude(FAULT_METRICS_PRELUDE, body, 'cs-fault-metrics-') } @@ -125,6 +139,67 @@ describe('compile-time contract', () => { }, 30_000) }) +describe('changed workflow consumer contracts', () => { + test('exported shapes and signatures accept valid consumers and reject invalid ones', () => { + const result = typecheckWithPrelude(CHANGED_EXPORTS_PRELUDE, ` + const summary: SalvageSummary = { salvages: [], salvagedBranches: 0, summarySuffix: '' } + const summaryResult: SalvageSummary = (null as unknown as typeof summarizeSalvages)([]) + // @ts-expect-error salvagedBranches is a count, not a string + const badSummary: SalvageSummary = { salvages: [], salvagedBranches: 'zero', summarySuffix: '' } + const event: CoderabbitJsonObject = { type: 'finding', nested: [true, { count: 1 }] } + const parsed: CoderabbitParsedOutput = (null as unknown as typeof parseCoderabbitAgentOutput)('') + const events: CoderabbitJsonObject[] = parsed.events + // @ts-expect-error event payloads must be JSON values + const badEvent: CoderabbitJsonObject = { value: () => 1 } + // @ts-expect-error raw lines are strings + const badLines: string[] = parsed.events + const surface = null as unknown as HostGateSurface + const gateResult = surface.runHostCommitGates('/work', 'task', 'round') + surface.disposeHostGateLogs() + // @ts-expect-error gate arguments are strings + surface.runHostCommitGates(1, 'task', 'round') + const candidate: OnlyTaskCandidate = { task: { id: '1.2', subtasks: ['1.2.1'] } } + const matches: boolean = (null as unknown as typeof taskMatchesOnlyTask)(candidate, null) + // @ts-expect-error task identity must be a string + const badCandidate: OnlyTaskCandidate = { task: { id: 12 } } + const outcome: TaskOutcome = { id: '1.2', status: 'done', integration: { pushed: true } } + const checked: TaskOutcome = (null as unknown as typeof checkedTaskOutcome)(outcome) + // @ts-expect-error pushed is a boolean + const badOutcome: TaskOutcome = { id: '1.2', status: 'done', integration: { pushed: 'yes' } } + const config: WorkflowConfig = (null as unknown as typeof makeConfig)({ maxTasks: 2 }) + const rawConfig: Parameters[0] = 42 + // @ts-expect-error workflow config has a numeric task limit + const badLimit: string = config.MAX_TASKS + const pathResult: ExecplanPathResolution = (null as unknown as typeof execplanRelPath)('/work', 42) + // @ts-expect-error worktree path must be a string + ;(null as unknown as typeof execplanRelPath)(42, 'docs/execplans/a.md') + const git = null as unknown as typeof gitEvidence + const plain: Promise> = git('/work', ['status']) + const structured: Promise> = git('/work', ['status'], (text) => text.length) + // @ts-expect-error the parser result is a number, not a string + const wrongParsed: Promise> = git('/work', ['status'], (text) => text.length) + // @ts-expect-error git command arguments must be strings + git('/work', [42]) + const roadmap = (null as unknown as typeof parseRoadmap)('') + const completed: Set = roadmap.completed + // @ts-expect-error roadmap parser requires text + ;(null as unknown as typeof parseRoadmap)(42) + const fix: FixReportSummary | null = (null as unknown as typeof summarizeFixReport)({ gatesGreen: true }) + // @ts-expect-error a number is not a fix report + ;(null as unknown as typeof summarizeFixReport)(42) + const document: DakarDocument | null = (null as unknown as typeof parseDakarDocument)('{}') + const finding: DakarFinding = { severity: 'high', line: null } + const validated: DakarFindingValidation = { ok: true, findings: [finding] } + // @ts-expect-error Dakar finding lines are numeric or null + const badFinding: DakarFinding = { line: '12' } + // @ts-expect-error successful validation requires findings + const badValidation: DakarFindingValidation = { ok: true } + void [summary, summaryResult, badSummary, event, parsed, events, badEvent, badLines, gateResult, candidate, matches, badCandidate, outcome, checked, badOutcome, config, rawConfig, badLimit, pathResult, plain, structured, wrongParsed, roadmap, completed, fix, document, validated, badFinding, badValidation] + `, 'cs-changed-exports-') + expect(result.ok, result.output).toBe(true) + }, HOST_REVIEW_TYPECHECK_TIMEOUT_MS) +}) + describe('fault metrics public type contract', () => { test('the complete fixed-key metric shape type-checks', () => { const result = typecheckFaultMetrics( diff --git a/tests/modules/config.test.ts b/tests/modules/config.test.ts index 5fc4ea7..1827a15 100644 --- a/tests/modules/config.test.ts +++ b/tests/modules/config.test.ts @@ -268,3 +268,20 @@ describe('makeConfig overrides and clamps', () => { expect(config.WRITE_PROBE_MODEL_BY_ADAPTER).toEqual({ claude: 'probe-model' }) }) }) + + test('rejects malformed workflow argument types', () => { + expect(() => makeConfig({ maxParallel: 'many' })).toThrow('Invalid workflow argument type') + expect(() => makeConfig({ writeProbeModelByAdapter: { codex: 42 } })).toThrow('Invalid workflow argument type') +}) + +test('recovery discovery requires literal true even for truthy legacy input', () => { + expect(makeConfig({ resumePartialBranches: 'yes' }).RESUME_PARTIAL_BRANCHES).toBe(false) + expect(makeConfig({ resumePartialBranches: true }).RESUME_PARTIAL_BRANCHES).toBe(true) +}) + +test('rejects non-object top-level arguments', () => { + for (const input of [[], 'text', 0, false]) { + expect(() => makeConfig(input)).toThrow('Invalid workflow argument type') + } + expect(makeConfig(null).BASE).toBe('main') +}) diff --git a/tests/modules/faults.test.ts b/tests/modules/faults.test.ts index f4ea0e1..a74b49a 100644 --- a/tests/modules/faults.test.ts +++ b/tests/modules/faults.test.ts @@ -10,6 +10,7 @@ import { faultMetrics, infrastructureFailureDetail, makeWithInfraRetry, + faultThrownMessage, providerFailureDetail, resultFromUnhandledAgentError, } from '../../src/workflows/df12-build-odw/faults.ts' @@ -115,3 +116,14 @@ describe('resultFromUnhandledAgentError', () => { }) }) }) + + +describe('faultThrownMessage', () => { + test('uses string messages and safely normalizes other thrown values', () => { + expect(faultThrownMessage(new Error('broken'))).toBe('broken') + expect(faultThrownMessage({ message: 'plain failure' })).toBe('plain failure') + expect(faultThrownMessage({ message: 42 })).toBe('[object Object]') + expect(faultThrownMessage(null)).toBe('null') + expect(faultThrownMessage('failure')).toBe('failure') + }) +}) diff --git a/tests/modules/host-review.coderabbit-validation.test.ts b/tests/modules/host-review.coderabbit-validation.test.ts new file mode 100644 index 0000000..ebbeb78 --- /dev/null +++ b/tests/modules/host-review.coderabbit-validation.test.ts @@ -0,0 +1,120 @@ +/** @file Tests CodeRabbit event validation at the host-review boundary. */ +import { describe, expect, test } from 'bun:test' + +import { + classifyCoderabbitOutcome, + makeCoderabbitAttempt, + parseCoderabbitAgentOutput, +} from '../../src/workflows/df12-build-odw/coderabbit-review.ts' + +const successfulCompletion = { type: 'complete', status: 'review_completed', findings: 0 } + +describe('CodeRabbit event validation', () => { + test('retains valid finding fields and additional JSON metadata', () => { + const finding = { + type: 'finding', + severity: 'major', + fileName: 'src/example.ts', + comment: 'A concrete defect', + codegenInstructions: 'Fix the boundary', + suggestions: [{ replacement: 'fixed' }], + metadata: { source: 'review' }, + } + const stdout = [finding, successfulCompletion].map((event) => JSON.stringify(event)).join('\n') + const parsed = parseCoderabbitAgentOutput(stdout) + + expect(parsed.events).toEqual([finding, successfulCompletion]) + expect(parsed.findings).toEqual([finding]) + expect(parsed.complete).toEqual(successfulCompletion) + expect(parsed.error).toBeNull() + expect(classifyCoderabbitOutcome({ ok: true }, parsed)).toBe('findings') + }) + + const malformedEvents = [ + ['event type', { type: 1 }], + ['missing event type', { severity: 'major' }], + ['finding severity', { type: 'finding', severity: 3 }], + ['finding file name', { type: 'finding', fileName: false }], + ['finding comment', { type: 'finding', comment: { text: 'bad' } }], + ['finding instructions', { type: 'finding', codegenInstructions: 4 }], + ['finding suggestions', { type: 'finding', suggestions: 'not an array' }], + ['error type', { type: 'error', errorType: 7 }], + ['error message', { type: 'error', message: ['not prose'] }], + ['completion status', { type: 'complete', status: false }], + ['completion count', { type: 'complete', status: 'reviewed', findings: '0' }], + ] as const + + for (const [description, malformed] of malformedEvents) { + test(`rejects an invalid ${description} even with a successful completion`, () => { + const stdout = [malformed, successfulCompletion].map((event) => JSON.stringify(event)).join('\n') + const parsed = parseCoderabbitAgentOutput(stdout) + + expect(parsed.error?.errorType).toBe('invalid_output') + expect(classifyCoderabbitOutcome({ ok: true }, parsed)).toBe('error') + }) + } + + test('rejects a non-finite completion count', () => { + const parsed = parseCoderabbitAgentOutput('{"type":"complete","status":"reviewed","findings":1e400}') + expect(parsed.error?.errorType).toBe('invalid_output') + expect(classifyCoderabbitOutcome({ ok: true }, parsed)).toBe('error') + }) + + test('retains an actual rate-limit error when another event is invalid', () => { + const stdout = [ + '{"type":"finding","severity":4}', + '{"type":"error","errorType":"rate_limit","message":"Review limit reached"}', + JSON.stringify(successfulCompletion), + ].join('\n') + const parsed = parseCoderabbitAgentOutput(stdout) + + expect(parsed.error?.errorType).toBe('rate_limit') + expect(classifyCoderabbitOutcome({ ok: true }, parsed)).toBe('rate-limited') + }) + + test('retains malformed authentication evidence regardless of event order', async () => { + const malformed = { type: 'error', errorType: 7, message: 'Run coderabbit auth login' } + const otherError = { type: 'error', errorType: 'execution', message: 'Review failed' } + const attempt = makeCoderabbitAttempt({ base: 'main', reviewTimeoutSeconds: 60 }) + for (const events of [[malformed, otherError], [otherError, malformed], [malformed]]) { + const stdout = events.map((event) => JSON.stringify(event)).join('\n') + const parsed = parseCoderabbitAgentOutput(stdout) + const result = await attempt('/worktree', async () => ({ ok: true, stdout, stderr: '' })) + + expect(parsed.error?.errorType).toBe(events.length === 1 ? 'invalid_output' : 'execution') + expect(result.outcome).toBe('auth') + expect(result.errorCategory).toBe('auth') + expect(result.detail).toContain('Run coderabbit auth login') + } + }) + + test('retains validated findings in order when other events are malformed', () => { + const first = { type: 'finding', severity: 'major', comment: 'First' } + const second = { type: 'finding', severity: 'critical', comment: 'Second' } + const malformed = { type: 'finding', severity: 3 } + for (const events of [[first, malformed, second], [malformed, first, second], [first, second, malformed]]) { + const parsed = parseCoderabbitAgentOutput(events.map((event) => JSON.stringify(event)).join('\n')) + expect(parsed.error?.errorType).toBe('invalid_output') + expect(parsed.findings).toEqual([first, second]) + expect(classifyCoderabbitOutcome({ ok: true }, parsed)).toBe('error') + } + }) + + test('retains non-object JSON and non-JSON lines as diagnostics', () => { + const stdout = ['[]', '42', 'noise', JSON.stringify(successfulCompletion)].join('\n') + const parsed = parseCoderabbitAgentOutput(stdout) + + expect(parsed.rawLines).toEqual(['[]', '42', 'noise']) + expect(classifyCoderabbitOutcome({ ok: true }, parsed)).toBe('clean') + }) + + test('reports malformed events as invalid output at the adapter boundary', async () => { + const stdout = ['{"type":"finding","severity":4}', JSON.stringify(successfulCompletion)].join('\n') + const attempt = makeCoderabbitAttempt({ base: 'main', reviewTimeoutSeconds: 60 }) + const result = await attempt('/worktree', async () => ({ ok: true, stdout, stderr: '' })) + + expect(result.outcome).toBe('error') + expect(result.errorCategory).toBe('invalid-output') + expect(result.detail).toContain('Invalid CodeRabbit finding event') + }) +}) diff --git a/tests/modules/host-review.dakar-outcomes.test.ts b/tests/modules/host-review.dakar-outcomes.test.ts index 5f33a45..94bdab7 100644 --- a/tests/modules/host-review.dakar-outcomes.test.ts +++ b/tests/modules/host-review.dakar-outcomes.test.ts @@ -3,10 +3,25 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { existsSync, rmSync } from 'node:fs' import { hostReview, recordingExec, required } from '../fixtures/host-review.ts' +import { mapDakarFinding } from '../../src/workflows/df12-build-odw/host-review.ts' import type { ReviewOutcome } from '../../src/workflows/df12-build-odw/host-review.ts' const g = globalThis as Record +test('maps only own Dakar severity names and falls back for unknown names', () => { + for (const [severity, expected] of [ + ['critical', 'critical'], + ['HIGH', 'major'], + ['medium', 'minor'], + ['low', 'trivial'], + ['constructor', 'info'], + ['__proto__', 'info'], + ['unrecognized', 'info'], + ]) { + expect(mapDakarFinding({ severity }).severity).toBe(expected) + } +}) + describe('Dakar outcome mapping', () => { const junk: string[] = [] let previousLog: unknown @@ -70,6 +85,13 @@ describe('Dakar outcome mapping', () => { expect(review.detail).toContain('changes-requested') }) + test('a passing document with a malformed findings value fails closed', async () => { + const { exec } = recordingExec({ stdout: dakarJson({ ok: true, verdict: 'pass', findings: 'invalid' }) }) + const review = await hostReview({ reviewTool: 'dakar', reviewAttempts: 1 }).runHostReview('/w', 'l', { exec }) + expect(review.outcome).toBe('error') + expect(review.errorCategory).toBe('invalid-output') + }) + test('normalizes an uppercase Dakar severity before validating it', async () => { const { exec } = recordingExec({ stdout: dakarJson({ ok: true, verdict: 'changes-requested', findings: [{ severity: 'HIGH', path: 'a.ts', title: 't', detail: 'd', evidence: 'e' }] }) }) const review = await hostReview({ reviewTool: 'dakar', reviewAttempts: 1 }).runHostReview('/w', 'l', { exec }) diff --git a/tests/modules/host-review.dakar.test.ts b/tests/modules/host-review.dakar.test.ts index 5154347..cd9b614 100644 --- a/tests/modules/host-review.dakar.test.ts +++ b/tests/modules/host-review.dakar.test.ts @@ -190,6 +190,18 @@ describe('runDakarHostReview', () => { expect(removed).toBe(false) }) + test('a malformed state-root error is converted to a string', async () => { + const { runHostReview } = hostReview({ reviewTool: 'dakar', reviewAttempts: 1 }) + const review = await runHostReview('/work/tree', 'label', { + dakarStateRoots: { + create: () => { throw { message: 42 } }, + remove: () => {}, + }, + }) + expect(review.outcome).toBe('error') + expect(review.detail).toContain('[object Object]') + }) + test('a cleanup failure does not replace a successful review result', async () => { const logs: string[] = [] g.log = (message: unknown) => logs.push(String(message)) diff --git a/tests/modules/host-review.property.test.ts b/tests/modules/host-review.property.test.ts index 7c97279..70a26c8 100644 --- a/tests/modules/host-review.property.test.ts +++ b/tests/modules/host-review.property.test.ts @@ -1,5 +1,9 @@ -/** @file Property tests for the host-review protocol adapters. Generated - * terminal documents pin classifier precedence and Dakar severity translation. */ +/** + * Property tests for host-review protocol adapters, including CodeRabbit event + * ordering and Dakar severity translation. + * + * @module + */ import { describe, expect, test } from 'bun:test' import fc from 'fast-check' @@ -26,6 +30,77 @@ const dakarSeverity = fc.constantFrom( const findingText = fc.string({ maxLength: 80 }) +type CodeRabbitStreamItem = + | { kind: 'finding'; event: { type: 'finding'; severity: string; fileName: string; comment: string } } + | { kind: 'completion'; event: { type: 'complete'; status: string; sequence: number; findings: number } } + | { kind: 'error'; event: { type: 'error'; errorType: 'rate_limit' | 'execution'; message: string; sequence: number } } + | { kind: 'progress'; event: { type: 'progress'; sequence: number } } + | { kind: 'invalid'; event: Record; reason: string } + | { kind: 'diagnostic'; line: string } + +type FindingItem = Extract +type CompletionItem = Extract +type ErrorItem = Extract +type InvalidItem = Extract +type DiagnosticItem = Extract + +const codeRabbitFinding: fc.Arbitrary = fc.tuple(fc.integer(), findingText) + .map(([id, comment]): FindingItem => ({ + kind: 'finding', + event: { type: 'finding', severity: 'major', fileName: `src/file-${id}.ts`, comment }, + })) + +const codeRabbitCompletion: fc.Arbitrary = fc.tuple( + fc.integer(), + fc.constantFrom('review_completed', 'reviewed', 'cancelled'), +).map(([sequence, status]): CompletionItem => ({ + kind: 'completion', + event: { type: 'complete', status, sequence, findings: 0 }, +})) + +const codeRabbitError: fc.Arbitrary = fc.tuple(fc.integer(), fc.boolean()) + .map(([sequence, rateLimited]): ErrorItem => ({ + kind: 'error', + event: { + type: 'error', + errorType: rateLimited ? 'rate_limit' : 'execution', + message: 'review failed', + sequence, + }, + })) + +const invalidCodeRabbitEvent: fc.Arbitrary = fc.constantFrom( + { kind: 'invalid', event: { type: 'finding', severity: 3 }, reason: 'Invalid CodeRabbit finding event' }, + { kind: 'invalid', event: { type: 'complete', status: false }, reason: 'Invalid CodeRabbit completion event' }, + { kind: 'invalid', event: { type: 'error', errorType: 3 }, reason: 'Invalid CodeRabbit error event' }, + { kind: 'invalid', event: { type: 3 }, reason: 'Invalid CodeRabbit event type' }, +) + +const codeRabbitDiagnostic: fc.Arbitrary = fc.oneof( + fc.integer().map((value) => JSON.stringify(value)), + fc.boolean().map((value) => JSON.stringify(value)), + findingText.map((value) => JSON.stringify(value)), + fc.integer().map((value) => JSON.stringify([value])), + fc.constant('null'), + fc.integer().map((value) => `not-json-${value}`), +).map((line): DiagnosticItem => ({ kind: 'diagnostic', line })) + +const codeRabbitStreamItem: fc.Arbitrary = fc.oneof( + codeRabbitFinding, + codeRabbitCompletion, + codeRabbitError, + invalidCodeRabbitEvent, + codeRabbitDiagnostic, + fc.integer().map((sequence) => ({ + kind: 'progress' as const, + event: { type: 'progress' as const, sequence }, + })), +) + +function codeRabbitLine(item: CodeRabbitStreamItem): string { + return item.kind === 'diagnostic' ? item.line : JSON.stringify(item.event) +} + describe('Dakar outcome properties', () => { test('every deferred failure maps to the shared rate-limited state', () => { fc.assert( @@ -68,6 +143,137 @@ describe('Dakar outcome properties', () => { }) describe('CodeRabbit outcome properties', () => { + test('retains emitted objects, findings, first terminals and diagnostic lines in order', () => { + fc.assert( + fc.property(fc.array(codeRabbitStreamItem, { maxLength: 30 }), (items) => { + const parsed = parseCoderabbitAgentOutput(items.map(codeRabbitLine).join('\n')) + const objects = items.filter((item) => item.kind !== 'diagnostic').map((item) => item.event) + const findings = items.filter((item) => item.kind === 'finding').map((item) => item.event) + const diagnostics = items.filter((item) => item.kind === 'diagnostic').map((item) => item.line) + const firstCompletion = items.find((item) => item.kind === 'completion') + const firstError = items.find((item) => item.kind === 'error') + const firstInvalid = items.find((item) => item.kind === 'invalid') + + expect(parsed.events).toEqual(objects) + expect(parsed.findings).toEqual(findings) + expect(parsed.rawLines).toEqual(diagnostics) + expect(parsed.complete).toEqual(firstCompletion?.event ?? null) + if (firstError) { + expect(parsed.error).toEqual(firstError.event) + } else if (firstInvalid) { + expect(parsed.error).toEqual({ + type: 'error', + errorType: 'invalid_output', + message: firstInvalid.reason, + }) + } else { + expect(parsed.error).toBeNull() + } + + const expectedOutcome = firstError + ? (firstError.event.errorType === 'rate_limit' ? 'rate-limited' : 'error') + : firstInvalid + ? 'error' + : findings.length > 0 + ? 'findings' + : firstCompletion && ['review_completed', 'reviewed'].includes(firstCompletion.event.status) + ? 'clean' + : 'error' + expect(classifyCoderabbitOutcome({ ok: true }, parsed)).toBe(expectedOutcome) + }), + ) + }) + + test('an invalid event cannot be hidden by a clean terminal in either order', () => { + fc.assert( + fc.property( + invalidCodeRabbitEvent, + fc.boolean(), + fc.array(codeRabbitFinding, { maxLength: 8 }), + (invalid, invalidFirst, findings) => { + const complete: CompletionItem = { + kind: 'completion', + event: { type: 'complete', status: 'review_completed', sequence: 0, findings: 0 }, + } + const terminals = invalidFirst ? [invalid, complete] : [complete, invalid] + const parsed = parseCoderabbitAgentOutput([...findings, ...terminals].map(codeRabbitLine).join('\n')) + + expect(parsed.findings).toEqual(findings.map((finding) => finding.event)) + expect(parsed.complete).toEqual(complete.event) + expect(parsed.error?.errorType).toBe('invalid_output') + expect(parsed.error?.message).toBe(invalid.reason) + expect(classifyCoderabbitOutcome({ ok: true }, parsed)).toBe('error') + }, + ), + ) + }) + + test('the first valid terminal of each kind survives later terminals and invalid events', () => { + fc.assert( + fc.property( + invalidCodeRabbitEvent, + fc.boolean(), + fc.boolean(), + fc.boolean(), + (invalid, successFirst, rateLimitFirst, invalidFirst) => { + const success: CompletionItem = { + kind: 'completion', + event: { type: 'complete', status: 'reviewed', sequence: 1, findings: 0 }, + } + const cancelled: CompletionItem = { + kind: 'completion', + event: { type: 'complete', status: 'cancelled', sequence: 2, findings: 0 }, + } + const rateLimit: ErrorItem = { + kind: 'error', + event: { type: 'error', errorType: 'rate_limit', message: 'review failed', sequence: 3 }, + } + const execution: ErrorItem = { + kind: 'error', + event: { type: 'error', errorType: 'execution', message: 'review failed', sequence: 4 }, + } + const firstCompletion = successFirst ? success : cancelled + const laterCompletion = successFirst ? cancelled : success + const firstError = rateLimitFirst ? rateLimit : execution + const laterError = rateLimitFirst ? execution : rateLimit + const events = invalidFirst + ? [invalid, firstCompletion, firstError, laterCompletion, laterError] + : [firstCompletion, firstError, invalid, laterCompletion, laterError] + const parsed = parseCoderabbitAgentOutput(events.map(codeRabbitLine).join('\n')) + + expect(parsed.complete).toEqual(firstCompletion.event) + expect(parsed.error).toEqual(firstError.event) + expect(classifyCoderabbitOutcome({ ok: true }, parsed)) + .toBe(rateLimitFirst ? 'rate-limited' : 'error') + }, + ), + ) + }) + + test('retains scalar, array and malformed diagnostics without changing a clean outcome', () => { + fc.assert( + fc.property( + fc.integer(), + fc.array(fc.integer(), { maxLength: 8 }), + fc.integer(), + fc.boolean(), + (scalar, array, noise, reverse) => { + const diagnostics = [JSON.stringify(scalar), JSON.stringify(array), `not-json-${noise}`] + const ordered = reverse ? diagnostics.toReversed() : diagnostics + const completion = { type: 'complete', status: 'reviewed', findings: 0 } + const lines = [...ordered.map((line) => ` ${line} `), JSON.stringify(completion)] + const parsed = parseCoderabbitAgentOutput(lines.join('\n')) + + expect(parsed.rawLines).toEqual(ordered) + expect(parsed.events).toEqual([completion]) + expect(parsed.complete).toEqual(completion) + expect(parsed.error).toBeNull() + expect(classifyCoderabbitOutcome({ ok: true }, parsed)).toBe('clean') + }, + ), + ) + }) + test('successful terminal states are clean exactly when findings are absent', () => { fc.assert( fc.property( diff --git a/tests/modules/oxlint-gate.test.ts b/tests/modules/oxlint-gate.test.ts new file mode 100644 index 0000000..a375bcc --- /dev/null +++ b/tests/modules/oxlint-gate.test.ts @@ -0,0 +1,120 @@ +/** + * Exercise the committed Oxlint profiles and generated-workflow exclusion. + * + * @module + */ +import { describe, expect, test } from 'bun:test' +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const REPO = fileURLToPath(new URL('../../', import.meta.url)) +const OXLINT = path.join(REPO, 'node_modules', '.bin', 'oxlint') +const CONFIG = path.join(REPO, 'oxlint.config.mjs') + +describe('Oxlint gate', () => { + test('runs Unicorn, deslop and type-aware TypeScript rules', () => { + const fixture = mkdtempSync(path.join(tmpdir(), 'df12-oxlint-')) + try { + writeFileSync(path.join(fixture, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { strict: true, noEmit: true, target: 'esnext' }, + include: ['fixture.ts'], + })) + writeFileSync(path.join(fixture, 'fixture.ts'), ` +export async function example(value: string, enabled: boolean): Promise { + Promise.resolve(1) + const options = { ...(enabled ? { enabled } : {}) } + const nested = JSON.stringify(String(Number(value.trim()))) + return value.replace(/x/g, String(options.enabled)) + nested +} +`) + const result = Bun.spawnSync( + [OXLINT, '--config', CONFIG, '--format', 'unix', path.join(fixture, 'fixture.ts')], + { cwd: REPO, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 }, + ) + const output = `${result.stdout.toString()}\n${result.stderr.toString()}` + + expect(result.exitedDueToTimeout).toBe(false) + expect(result.exitCode, output).not.toBe(0) + expect(output).toContain('unicorn(prefer-string-replace-all)') + expect(output).toContain('unicorn(max-nested-calls)') + expect(output).toContain('deslop(no-conditional-empty-object-spread)') + expect(output).toContain('typescript(no-floating-promises)') + } finally { + rmSync(fixture, { recursive: true, force: true }) + } + }, 40_000) + + test('includes authored source and excludes workflow object code', () => { + const result = Bun.spawnSync( + [OXLINT, '--config', CONFIG, '--debug', 'files', 'src', 'workflows'], + { cwd: REPO, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 }, + ) + const output = `${result.stdout.toString()}\n${result.stderr.toString()}` + const files = result.stdout.toString().split('\n') + + expect(result.exitedDueToTimeout).toBe(false) + expect(result.exitCode, output).toBe(0) + expect(files).toContain('src/workflows/df12-build-odw/main.ts') + expect(files.some((file) => file.startsWith('workflows/'))).toBe(false) + }, 40_000) + + test('limits ODW boundary exceptions to the workflow source tree', () => { + const fixture = mkdtempSync(path.join(tmpdir(), 'df12-oxlint-scope-')) + try { + const workflowSource = path.join(fixture, 'src', 'workflows', 'df12-build-odw') + const otherSource = path.join(fixture, 'src', 'other') + mkdirSync(workflowSource, { recursive: true }) + mkdirSync(otherSource, { recursive: true }) + symlinkSync(path.join(REPO, 'node_modules'), path.join(fixture, 'node_modules'), 'dir') + const fixtureConfig = path.join(fixture, 'oxlint.config.mjs') + copyFileSync(CONFIG, fixtureConfig) + writeFileSync(path.join(fixture, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { strict: true, noEmit: true, target: 'esnext' }, + include: ['src/**/*.ts'], + })) + const source = ` +export function inspect(value: unknown, fields: Record): unknown { + if (typeof value === 'string') return value as string + return fields.fallback ?? null +} +` + const workflowFile = path.join(workflowSource, 'contract.ts') + const otherFile = path.join(otherSource, 'contract.ts') + writeFileSync(workflowFile, source) + writeFileSync(otherFile, source) + + function lint(file: string) { + return Bun.spawnSync( + [OXLINT, '--config', fixtureConfig, '--format', 'unix', file], + { cwd: fixture, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 }, + ) + } + const workflow = lint(workflowFile) + const other = lint(otherFile) + const workflowOutput = `${workflow.stdout.toString()}\n${workflow.stderr.toString()}` + const otherOutput = `${other.stdout.toString()}\n${other.stderr.toString()}` + const boundaryRules = [ + 'unicorn(no-null)', + 'deslop(no-unknown-parameters)', + 'deslop(no-unknown-returns)', + 'deslop(no-unsafe-dictionary-type)', + 'deslop(no-runtime-typeof)', + ] + + expect(workflow.exitedDueToTimeout).toBe(false) + expect(other.exitedDueToTimeout).toBe(false) + expect(workflow.exitCode, workflowOutput).not.toBe(0) + expect(other.exitCode, otherOutput).not.toBe(0) + for (const rule of boundaryRules) { + expect(workflowOutput).not.toContain(rule) + expect(otherOutput).toContain(rule) + } + expect(workflowOutput).toContain('deslop(require-safety-comment-for-type-assertion)') + expect(otherOutput).toContain('deslop(require-safety-comment-for-type-assertion)') + } finally { + rmSync(fixture, { recursive: true, force: true }) + } + }, 70_000) +}) diff --git a/tests/modules/recovery-decision.property.test.ts b/tests/modules/recovery-decision.property.test.ts index f1a04d6..c06bdd3 100644 --- a/tests/modules/recovery-decision.property.test.ts +++ b/tests/modules/recovery-decision.property.test.ts @@ -8,6 +8,7 @@ import fc from 'fast-check' import { EXECPLAN_STATUS_MAP, TASK_BRANCH_RE, + advisoryResidualRisk, branchToRoadmapId, parseExecplanState, parseWorktreeList, @@ -41,6 +42,16 @@ const assessmentArb = fc.record({ const flagsArb = fc.record({ dryRun: fc.boolean() }) +describe('advisoryResidualRisk', () => { + test('preserves valid strings and rejects malformed arrays', () => { + const valid = ['reviewed API', 'pending deployment'] + expect(advisoryResidualRisk({ residualRisk: valid })).toBe(valid) + expect(advisoryResidualRisk({ residualRisk: ['reviewed API', 42] })).toEqual([]) + expect(advisoryResidualRisk({ residualRisk: 'not an array' })).toEqual([]) + expect(advisoryResidualRisk(undefined)).toEqual([]) + }) +}) + describe('branchToRoadmapId', () => { test('parses every well-formed task branch back to its roadmap id', () => { fc.assert( @@ -92,6 +103,12 @@ describe('parseWorktreeList', () => { }) describe('parseExecplanState', () => { + test('treats inherited prototype property names as unknown statuses', () => { + for (const status of ['constructor', 'CONSTRUCTOR', 'toString', 'valueOf']) { + expect(parseExecplanState(`Status: ${status}\n`).status).toBe('unknown') + } + }) + test('maps every documented status token regardless of case and padding', () => { fc.assert( fc.property( diff --git a/tests/modules/recovery-discovery.test.ts b/tests/modules/recovery-discovery.test.ts index cf822fb..20a478c 100644 --- a/tests/modules/recovery-discovery.test.ts +++ b/tests/modules/recovery-discovery.test.ts @@ -4,9 +4,10 @@ import { chmodSync } from 'node:fs' import { join } from 'node:path' -import { describe, expect, test } from 'bun:test' +import { describe, expect, spyOn, test } from 'bun:test' import { makeRecoveryRepo, RECOVERY_ROADMAP } from '../fixtures/recovery-repo.mjs' +import * as gitEvidence from '../../src/workflows/df12-build-odw/git-evidence.ts' import { RECOVERY_HOLD_REASONS, makeRecoveryDiscovery, @@ -90,6 +91,24 @@ describe('makeRecoveryDiscovery', () => { }) describe('readExecplanState', () => { + test('classifies error records and reports non-object failures', async () => { + const cases = [ + { failure: { code: 'ENOENT', message: 'not found' }, status: 'missing', error: undefined }, + { failure: { code: 'EACCES', message: 'permission denied' }, status: 'unreadable', error: 'docs/plan.md: permission denied' }, + { failure: 'unexpected failure', status: 'unreadable', error: 'docs/plan.md: unexpected failure' }, + ] as const + for (const { failure, status, error } of cases) { + const spy = spyOn(gitEvidence, 'readFileText').mockImplementation(async () => { throw failure }) + try { + const result = await readExecplanState({ worktreePath: '/worktree', execplanPath: 'docs/plan.md' }) + expect(result.status).toBe(status) + expect(result.error).toBe(error) + } finally { + spy.mockRestore() + } + } + }) + test('reads the committed plan status from the worktree', async () => { const repo = makeRecoveryRepo({ parserExecplanStatus: 'IN PROGRESS' }) try { diff --git a/tests/modules/remediation.test.ts b/tests/modules/remediation.test.ts index 938c1a7..7c519b8 100644 --- a/tests/modules/remediation.test.ts +++ b/tests/modules/remediation.test.ts @@ -63,6 +63,14 @@ describe('dedupeProposals', () => { expect(() => dedupeProposals([proposal({ title: '' })])).toThrow(/non-blank string/) }) + test('preserves the nullish input contract', () => { + expect(dedupeProposals(null)).toEqual([]) + expect(dedupeProposals(undefined)).toEqual([]) + for (const entry of [null, undefined]) { + expect(() => dedupeProposals([entry])).toThrow('remediation proposal title must be a non-blank string') + } + }) + test('preserves aggregated sources across repeated deduplication', () => { const first = dedupeProposals([ proposal({ source: 'audit:1.2.3' }), diff --git a/tests/modules/roadmap.property.test.ts b/tests/modules/roadmap.property.test.ts index ad93405..bd14a12 100644 --- a/tests/modules/roadmap.property.test.ts +++ b/tests/modules/roadmap.property.test.ts @@ -13,8 +13,18 @@ import { isTaskFullyComplete, parseRoadmap, selectRoadmapTask, + taskMatchesOnlyTask, } from '../../src/workflows/df12-build-odw/roadmap.ts' +describe('taskMatchesOnlyTask', () => { + test('accepts missing or null addendum lists without matching another task', () => { + for (const subtasks of [undefined, null]) { + expect(taskMatchesOnlyTask({ task: { id: '1.2.3', subtasks } }, '1.2.4')).toBe(false) + expect(taskMatchesOnlyTask({ task: { id: '1.2.3', subtasks } }, '1.2.3')).toBe(true) + } + }) +}) + describe('expandStepRange', () => { test('expands an in-phase ascending range to every step, inclusive', () => { fc.assert( diff --git a/tests/modules/run-task.test.ts b/tests/modules/run-task.test.ts index 623978c..3f18caf 100644 --- a/tests/modules/run-task.test.ts +++ b/tests/modules/run-task.test.ts @@ -4,9 +4,10 @@ // back the durability gates the pipeline consults. import { beforeEach, describe, expect, test } from 'bun:test' import { execFileSync } from 'node:child_process' -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' +import Ajv2020 from 'ajv/dist/2020' import { makeTaskPipeline, @@ -14,6 +15,14 @@ import { summarizeReviewVerdict, } from '../../src/workflows/df12-build-odw/run-task.ts' import type { FaultMetrics } from '../../src/workflows/df12-build-odw/types.ts' +import { + DESIGN_VERDICT_SCHEMA, + FIX_SCHEMA, + IMPL_SCHEMA, + INTEGRATE_SCHEMA, + PLAN_SCHEMA, + REVIEW_SCHEMA, +} from '../../src/workflows/df12-build-odw/schemas.ts' import type { HostReviewResult } from '../../src/workflows/df12-build-odw/host-review-contracts.ts' @@ -373,6 +382,46 @@ describe('runTask', () => { expect(runFaultMetrics.authFaults).toBe(1) }) + test('malformed dual review retains valid blocking findings', async () => { + const worktree = makeWorktree() + try { + scriptAgent(happyScript()) + const outcome = await subject(worktree, { + HOST_REVIEW_ENABLED: true, + MAX_REVIEW_ROUNDS: 1, + runHostReview: async () => hostReviewResult({ + outcome: 'error', errorCategory: 'invalid-output', detail: 'malformed event', + findings: [{ severity: 'major', comment: 'Do not lose this finding' }], + }), + }).runDualReviewAndIntegration(task, worktree, { execplanPath: PLAN_PATH }, greenImpl, null) + expect(outcome.status).not.toBe('done') + expect(JSON.stringify(outcome)).toContain('Do not lose this finding') + expect(labels.some((label) => label.startsWith('integrate:'))).toBe(false) + } finally { + rmSync(worktree, { recursive: true, force: true }) + } + }) + + test('malformed between-item review retains valid blocking findings', async () => { + const worktree = makeWorktree() + try { + prepareSingleWorkItem(worktree) + scriptAgent((label, prompt) => label.startsWith('implement:') ? completeSingleWorkItem(worktree) : happyScript()(label, prompt)) + const outcome = await subject(worktree, { + PER_WORK_ITEM_BUILD: true, HOST_REVIEW_ENABLED: true, HOST_REVIEW_BETWEEN_WORK_ITEMS: true, + runHostReview: async () => hostReviewResult({ + outcome: 'error', errorCategory: 'invalid-output', detail: 'malformed event', + findings: [{ severity: 'major', comment: 'Do not lose this finding' }], + }), + }).runTask(task, null) + expect(outcome.status).not.toBe('done') + expect(JSON.stringify(outcome)).toContain('Do not lose this finding') + expect(labels.some((label) => label.startsWith('integrate:'))).toBe(false) + } finally { + rmSync(worktree, { recursive: true, force: true }) + } + }) + test('an addendum implementation auth failure increments authFaults', async () => { const worktree = makeWorktree() const addendum = { ...task, isAddendum: true, subtasks: ['1.2.3.1'] } @@ -903,3 +952,158 @@ describe('runTask', () => { expect(outcome.kind).toBe('recovery-resume') }) }) + +type SchemaTampering = 'omit' | 'replace' +type SchemaDispatch = { label: string; options: Record } +type ExpectedSchemaDispatch = { + prefix: string + schema: object + adapter: string + phase: string +} + +function schemaRoutingHarness(mode: SchemaTampering, script: Script) { + const routed: SchemaDispatch[] = [] + const dispatched: SchemaDispatch[] = [] + const validator = new Ajv2020({ strict: false }) + const router = (adapter: string) => (options: Record) => { + routed.push({ label: String(options.label || ''), options: { ...options } }) + const { schema: originalSchema, ...rest } = options + void originalSchema + const result = { ...rest, adapter, model: 'routed-model' } + return mode === 'replace' ? { ...result, schema: { type: 'string' } } : result + } + globals.agent = async (prompt: string, options: Record = {}) => { + const label = String(options.label || '') + dispatched.push({ label, options: { ...options } }) + const reply = script(label, prompt) + if (!options.schema) return JSON.stringify(reply) + if (!validator.validate(options.schema as object, reply)) { + throw new Error(`Agent reply did not match the dispatched schema: ${validator.errorsText()}`) + } + return reply + } + return { + routed, + dispatched, + overrides: { + planAgentOptions: router('routed-plan'), + reviewAgentOptions: router('routed-review'), + buildAgentOptions: router('routed-build'), + }, + } +} + +function expectSchemaDispatches( + harness: ReturnType, + expected: ExpectedSchemaDispatch[], +) { + expect(harness.dispatched.length).toBe(harness.routed.length) + for (const entry of expected) { + const routed = harness.routed.filter((call) => call.label.startsWith(entry.prefix)) + const dispatched = harness.dispatched.filter((call) => call.label.startsWith(entry.prefix)) + expect(dispatched.length, entry.prefix).toBeGreaterThan(0) + expect(dispatched.length, entry.prefix).toBe(routed.length) + for (const call of routed) { + expect(call.options.schema, call.label).toEqual(entry.schema) + } + for (const call of dispatched) { + expect(call.options.schema, call.label).toEqual(entry.schema) + expect(call.options.adapter, call.label).toBe(entry.adapter) + expect(call.options.model, call.label).toBe('routed-model') + expect(call.options.phase, call.label).toBe(entry.phase) + } + } + expect(harness.dispatched.every((call) => + expected.some((entry) => call.label.startsWith(entry.prefix)), + )).toBe(true) +} + +describe('schema-bearing task agent dispatch', () => { + for (const mode of ['omit', 'replace'] as const) { + test(`preserves schemas through a router that may ${mode} them in the normal and fix lanes`, async () => { + const worktree = makeWorktree() + try { + let codeReviewRounds = 0 + const harness = schemaRoutingHarness(mode, (label, prompt) => { + if (label.startsWith('code-review:')) { + codeReviewRounds += 1 + return codeReviewRounds === 1 + ? { verdict: 'changes-requested', blocking: ['fix this'], summary: 'needs a fix' } + : { ...passReview, summary: 'passed' } + } + if (label.startsWith('expert-review:')) return { ...passReview, summary: 'passed' } + if (label.startsWith('fix:')) return { gatesGreen: true, summary: 'fixed' } + if (label.startsWith('integrate:')) return { ...cleanIntegration, summary: 'landed' } + return happyScript()(label, prompt) + }) + const outcome = await subject(worktree, harness.overrides).runTask(task, null) + expect(outcome.status).toBe('done') + expectSchemaDispatches(harness, [ + { prefix: 'plan:', schema: PLAN_SCHEMA, adapter: 'routed-plan', phase: 'Plan' }, + { prefix: 'design-review:', schema: DESIGN_VERDICT_SCHEMA, adapter: 'routed-review', phase: 'Design Review' }, + { prefix: 'implement:', schema: IMPL_SCHEMA, adapter: 'routed-build', phase: 'Implement' }, + { prefix: 'code-review:', schema: REVIEW_SCHEMA, adapter: 'routed-review', phase: 'Code Review' }, + { prefix: 'expert-review:', schema: REVIEW_SCHEMA, adapter: 'routed-review', phase: 'Expert Review' }, + { prefix: 'fix:', schema: FIX_SCHEMA, adapter: 'routed-build', phase: 'Implement' }, + { prefix: 'integrate:', schema: INTEGRATE_SCHEMA, adapter: 'routed-build', phase: 'Integrate' }, + ]) + } finally { + rmSync(worktree, { recursive: true, force: true }) + } + }) + + test(`preserves the work-item schema through a router that may ${mode} it`, async () => { + const worktree = makeWorktree() + try { + prepareSingleWorkItem(worktree) + const harness = schemaRoutingHarness(mode, (label) => { + if (label.startsWith('implement:')) { + return { ...completeSingleWorkItem(worktree), execplanPath: PLAN_PATH } + } + throw new Error(`unscripted label: ${label}`) + }) + const pipeline = subject(worktree, { ...harness.overrides, PER_WORK_ITEM_BUILD: true }) + const outcome = await pipeline.runWorkItemBuildLoop(task, worktree, { execplanPath: PLAN_PATH }) + expect(outcome?.kind).toBe('impl') + expectSchemaDispatches(harness, [ + { prefix: 'implement:', schema: IMPL_SCHEMA, adapter: 'routed-build', phase: 'Implement' }, + ]) + } finally { + rmSync(worktree, { recursive: true, force: true }) + } + }) + + test(`preserves addendum schemas through a router that may ${mode} them`, async () => { + const worktree = makeWorktree() + try { + const harness = schemaRoutingHarness(mode, (label) => { + if (label.startsWith('addendum:')) return { ...greenAddendum, execplanPath: PLAN_PATH } + if (label.startsWith('addendum-review:')) return { ...passReview, summary: 'passed' } + if (label.startsWith('integrate:')) return { ...cleanIntegration, summary: 'landed' } + throw new Error(`unscripted label: ${label}`) + }) + const pipeline = subject(worktree, { + ...harness.overrides, + HOST_REVIEW_ENABLED: true, + runHostReview: async () => hostReviewResult({ + outcome: 'rate-limited', + attempts: 3, + errorCategory: 'deferred', + detail: 'Dakar review deferred', + }), + }) + const addendum = { ...task, isAddendum: true, subtasks: ['1.2.3.1'] } + const outcome = await pipeline.runTask(addendum, null) + expect(outcome.status).toBe('done') + expectSchemaDispatches(harness, [ + { prefix: 'addendum:', schema: IMPL_SCHEMA, adapter: 'routed-build', phase: 'Implement' }, + { prefix: 'addendum-review:', schema: REVIEW_SCHEMA, adapter: 'routed-review', phase: 'Code Review' }, + { prefix: 'integrate:', schema: INTEGRATE_SCHEMA, adapter: 'routed-build', phase: 'Integrate' }, + ]) + } finally { + rmSync(worktree, { recursive: true, force: true }) + } + }) + } +}) diff --git a/tests/modules/task-outcome.test.ts b/tests/modules/task-outcome.test.ts new file mode 100644 index 0000000..f7fe4b4 --- /dev/null +++ b/tests/modules/task-outcome.test.ts @@ -0,0 +1,66 @@ +/** + * Verify the worker-pool boundary for optional task-result fields. + * + * @module + */ +import { expect, test } from 'bun:test' +import { checkedTaskOutcome } from '../../src/workflows/df12-build-odw/task-outcome.ts' + +const validSalvage = { + classification: 'continue-manual', + sha: 'abc123', + detail: 'salvaged', + committed: ['docs/execplans/plan.md'], + skipped: [{ path: 'README.md', reason: 'outside task scope' }], +} + +test('returns the original valid outcome with all optional fields intact', () => { + const result = { + id: '1', + status: 'done', + kind: 'addendum', + integration: { pushed: true }, + proposals: [{ title: 'item' }], + assessment: { classification: 'continue-manual', evidence: { source: 'git' } }, + assessmentError: 'assessment was retried', + salvage: validSalvage, + extra: { retained: true }, + } + + expect(checkedTaskOutcome(result)).toBe(result) +}) + +test('accepts a null integration result', () => { + const result = { id: '1', status: 'done', integration: null } + expect(checkedTaskOutcome(result)).toBe(result) +}) + +const invalidCases = [ + { name: 'a non-boolean integration push flag', fields: { integration: { pushed: 'yes' } } }, + { name: 'a null proposal', fields: { proposals: [null] } }, + { name: 'a non-string task kind', fields: { kind: 1 } }, + { name: 'a null task kind', fields: { kind: null } }, + { name: 'a null assessment', fields: { assessment: null } }, + { name: 'an array assessment', fields: { assessment: [] } }, + { name: 'a non-string assessment error', fields: { assessmentError: { message: 'failed' } } }, + { name: 'a null assessment error', fields: { assessmentError: null } }, + { name: 'a null salvage record', fields: { salvage: null } }, + { name: 'an array salvage record', fields: { salvage: [] } }, + { name: 'a non-string salvage classification', fields: { salvage: { ...validSalvage, classification: 1 } } }, + { name: 'a non-string salvage commit SHA', fields: { salvage: { ...validSalvage, sha: null } } }, + { name: 'a non-string salvage detail', fields: { salvage: { ...validSalvage, detail: false } } }, + { name: 'a non-array committed field', fields: { salvage: { ...validSalvage, committed: 'plan.md' } } }, + { name: 'a non-string committed path', fields: { salvage: { ...validSalvage, committed: ['plan.md', 1] } } }, + { name: 'a non-array skipped field', fields: { salvage: { ...validSalvage, skipped: 'README.md' } } }, + { name: 'a null skipped entry', fields: { salvage: { ...validSalvage, skipped: [null] } } }, + { name: 'a skipped entry without a path', fields: { salvage: { ...validSalvage, skipped: [{ reason: 'out of scope' }] } } }, + { name: 'a skipped entry without a reason', fields: { salvage: { ...validSalvage, skipped: [{ path: 'README.md' }] } } }, + { name: 'a non-string skipped path', fields: { salvage: { ...validSalvage, skipped: [{ path: 1, reason: 'out of scope' }] } } }, + { name: 'a non-string skipped reason', fields: { salvage: { ...validSalvage, skipped: [{ path: 'README.md', reason: 1 }] } } }, +] + +for (const { name, fields } of invalidCases) { + test(`rejects ${name}`, () => { + expect(() => checkedTaskOutcome({ id: '1', status: 'done', ...fields })).toThrow('Invalid task outcome') + }) +} diff --git a/tests/modules/write-preflight.test.ts b/tests/modules/write-preflight.test.ts index b9de121..f091125 100644 --- a/tests/modules/write-preflight.test.ts +++ b/tests/modules/write-preflight.test.ts @@ -3,7 +3,7 @@ // claimed ok, and the worktree is untrusted content — so the symlink and // decoy cases are the load-bearing ones. import { beforeEach, describe, expect, test } from 'bun:test' -import { mkdtempSync, symlinkSync, writeFileSync, readFileSync, mkdirSync } from 'node:fs' +import { mkdtempSync, symlinkSync, writeFileSync, readFileSync, mkdirSync, rmSync } from 'node:fs' import { execFileSync } from 'node:child_process' import { tmpdir } from 'node:os' import path from 'node:path' @@ -38,8 +38,14 @@ const TARGETS = () => [ ] describe('probe construction', () => { + test('probe paths distinguish adapter names on case-insensitive filesystems', () => { + for (const [left, right] of [['Claude', 'claude'], ['codex medium', 'codex!medium']] as const) { + expect(writeProbePath('/wt', left).toLowerCase()).not.toBe(writeProbePath('/wt', right).toLowerCase()) + } + }) + test('paths sanitize the adapter and prompts carry the file and token markers', () => { - expect(writeProbePath('/wt', 'codex medium!')).toBe('/wt/.df12-write-probe-codex-medium-') + expect(writeProbePath('/wt', 'codex medium!')).toMatch(/^\/wt\/\.df12-write-probe-codex-medium--[0-9a-f]{64}$/) const token = writeProbeToken('1.2.3', 'codex') expect(token).toBe('df12-write-probe v1 task=1.2.3 adapter=codex') const prompt = writeProbePrompt('/wt/.probe', token) @@ -117,6 +123,56 @@ describe('hostWriteProbe', () => { }) describe('probe options right-sizing', () => { + for (const schemaAction of ['omit', 'replace'] as const) { + test(`the probe retains its schema when routing options ${schemaAction} it`, async () => { + const dir = tmp() + const tag = '1.2.3' + const adapter = 'codex' + const routerInputs: Array> = [] + const agentOptions: Array> = [] + try { + globals.agent = async (_prompt: string, options: Record) => { + agentOptions.push(options) + writeFileSync(writeProbePath(dir, adapter), writeProbeToken(tag, adapter)) + return { ok: true } + } + const targets = () => [{ + role: 'build', + adapter, + options: (options: Record) => { + routerInputs.push(options) + const routed = { + adapter, + model: 'codex-cheap', + effort: 'minimal', + phase: options.phase, + label: options.label, + } + return schemaAction === 'omit' + ? routed + : { ...routed, schema: { type: 'string' } } + }, + }] + const { runTaskAgentWritePreflight } = makeWritePreflight({ enabled: true, targets }) + + expect(await runTaskAgentWritePreflight(dir, tag)).toEqual({ ok: true, failures: [] }) + expect(routerInputs).toHaveLength(1) + expect(routerInputs[0]?.schema).toBe(WRITE_PROBE_SCHEMA) + expect(agentOptions).toHaveLength(1) + expect(agentOptions[0]?.schema).toBe(WRITE_PROBE_SCHEMA) + expect(agentOptions[0]).toMatchObject({ + adapter, + model: 'codex-cheap', + effort: 'minimal', + phase: 'Worktree', + label: 'write-probe:codex', + }) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + } + test('the probe keeps the adapter, uses minimal effort, and passes no reasoning model', async () => { const seen: Array> = [] globals.agent = async (_prompt: string, opts: Record = {}) => { @@ -146,6 +202,61 @@ describe('probe options right-sizing', () => { }) describe('makeWritePreflight', () => { + for (const [label, thrown, expected] of [ + ['error', new Error('adapter failed'), 'adapter failed'], + ['string', 'adapter disconnected', 'adapter disconnected'], + ['object', { message: 'adapter unavailable' }, 'adapter unavailable'], + ['numeric message', { message: 42 }, '42'], + ['null', null, 'null'], + ] as const) { + test(`reports an agent failure thrown as ${label}`, async () => { + const dir = tmp() + try { + globals.agent = async () => { throw thrown } + const targets = () => [{ role: 'build', adapter: 'codex', options: (options: Record) => options }] + const { runTaskAgentWritePreflight } = makeWritePreflight({ enabled: true, targets }) + + const outcome = await runTaskAgentWritePreflight(dir, '1.2.3') + expect(outcome.ok).toBe(false) + expect(outcome.failures).toHaveLength(1) + expect(outcome.failures[0]?.detail).toContain(expected) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + } + + test('reports a malformed agent reply when the host probe is missing', async () => { + const dir = tmp() + try { + globals.agent = async () => ({ ok: false, detail: 42 }) + const targets = () => [{ role: 'build', adapter: 'codex', options: (options: Record) => options }] + const { runTaskAgentWritePreflight } = makeWritePreflight({ enabled: true, targets }) + + const outcome = await runTaskAgentWritePreflight(dir, '1.2.3') + expect(outcome.ok).toBe(false) + expect(outcome.failures[0]?.detail).toContain('agent reply has invalid fields') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + test('accepts a verified probe even when the agent reply is malformed', async () => { + const dir = tmp() + try { + globals.agent = async () => { + writeFileSync(writeProbePath(dir, 'codex'), writeProbeToken('1.2.3', 'codex')) + return { ok: 'yes' } + } + const targets = () => [{ role: 'build', adapter: 'codex', options: (options: Record) => options }] + const { runTaskAgentWritePreflight } = makeWritePreflight({ enabled: true, targets }) + + expect(await runTaskAgentWritePreflight(dir, '1.2.3')).toEqual({ ok: true, failures: [] }) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + test('passes when every adapter really writes its token', async () => { const dir = tmp() globals.agent = async (prompt: string) => { diff --git a/workflows/df12-build-odw.js b/workflows/df12-build-odw.js index 979f1ff..9a7fd04 100644 --- a/workflows/df12-build-odw.js +++ b/workflows/df12-build-odw.js @@ -34,23 +34,26 @@ export const meta = { // src/workflows/df12-build-odw/recovery-decision.ts function advisoryResidualRisk(assessment) { - return Array.isArray(assessment?.residualRisk) ? assessment.residualRisk : []; + const residualRisk = assessment?.residualRisk; + if (!Array.isArray(residualRisk)) return []; + const entries = residualRisk; + return entries.every((entry) => typeof entry === "string") ? entries : []; } var TASK_BRANCH_RE = /^roadmap-((?:\d+-)*\d+)(-addendum)?$/; function branchToRoadmapId(branch) { const match = TASK_BRANCH_RE.exec(String(branch || "")); - if (!match) return null; + if (!match) return match; const id = match[1]; if (id === void 0) return null; - return { id: id.replace(/-/g, "."), isAddendum: Boolean(match[2]) }; + return { id: id.replaceAll("-", "."), isAddendum: Boolean(match[2]) }; } function parseWorktreeList(output) { const entries = []; - let current = null; + let current; for (const line of String(output || "").split(/\r?\n/)) { if (!line.trim()) { if (current) entries.push(current); - current = null; + current = void 0; continue; } const spaceIndex = line.indexOf(" "); @@ -89,11 +92,11 @@ var RECOVERY_SKIP_REASONS = [ ]; function recoveryResumeEligibility(candidate, evidence, assessment) { if (candidate?.isAddendum) return "addendum-branch"; - if ((evidence?.collectionErrors || []).length) return "evidence-collection-error"; + if ((evidence?.collectionErrors || []).length > 0) return "evidence-collection-error"; if (evidence?.dirtyState !== "clean") return "dirty-worktree"; - if (!(evidence?.recentCommits || []).length) return "no-committed-work"; + if ((evidence.recentCommits || []).length === 0) return "no-committed-work"; if (assessment?.taskScoped !== true) return "not-task-scoped"; - if ((assessment?.missingEvidence || []).length) return "missing-validation-evidence"; + if ((assessment.missingEvidence || []).length > 0) return "missing-validation-evidence"; if (!candidate?.execplanPath) return "missing-execplan"; return ""; } @@ -112,10 +115,15 @@ function recoveryDecision(candidate, evidence, assessment, mode, flags = {}) { return { action: "resume", classification, reason: "", skip: false }; } var EXECPLAN_STATUS_MAP = { + /** The plan is still being drafted. */ draft: "draft", + /** The plan is approved for implementation. */ approved: "approved", + /** Implementation is in progress. */ "in progress": "in-progress", + /** Work on the plan is blocked. */ blocked: "blocked", + /** Implementation is complete and ready for review. */ complete: "complete" }; function parseExecplanState(text) { @@ -123,8 +131,8 @@ function parseExecplanState(text) { let status = "unknown"; const statusMatch = source.match(/^Status:\s*([A-Za-z ]+?)\s*$/m); if (statusMatch?.[1] !== void 0) { - const value = statusMatch[1].trim().toLowerCase().replace(/\s+/g, " "); - status = EXECPLAN_STATUS_MAP[value] || "unknown"; + const value = statusMatch[1].trim().toLowerCase().replaceAll(/\s+/g, " "); + status = Object.entries(EXECPLAN_STATUS_MAP).find(([key]) => key === value)?.[1] ?? "unknown"; } let ticked = 0; let unticked = 0; @@ -143,15 +151,17 @@ function parseExecplanState(text) { } return { status, ticked, unticked, items }; } +function reportContinueDecision(reason) { + return { action: "report", stage: null, reason, skip: true }; +} function recoveryContinueDecision(candidate, evidence, planState, flags = {}) { - const report = (reason) => ({ action: "report", stage: null, reason, skip: true }); - if (candidate?.isAddendum) return report("addendum-branch"); - if ((evidence?.collectionErrors || []).length) return report("evidence-collection-error"); - if (evidence?.dirtyState !== "clean") return report("dirty-worktree"); - if (planState.status === "unreadable") return report("plan-unreadable"); - if (planState.status === "blocked") return report("plan-blocked"); + if (candidate?.isAddendum) return reportContinueDecision("addendum-branch"); + if ((evidence?.collectionErrors || []).length > 0) return reportContinueDecision("evidence-collection-error"); + if (evidence?.dirtyState !== "clean") return reportContinueDecision("dirty-worktree"); + if (planState.status === "unreadable") return reportContinueDecision("plan-unreadable"); + if (planState.status === "blocked") return reportContinueDecision("plan-blocked"); const stage = planState.status === "approved" || planState.status === "in-progress" ? "implement" : planState.status === "complete" ? "review" : "plan"; - if (stage === "review" && !(evidence?.recentCommits || []).length) return report("no-committed-work"); + if (stage === "review" && (evidence.recentCommits || []).length === 0) return reportContinueDecision("no-committed-work"); if (flags.dryRun) return { action: "report", stage, reason: "dry-run", skip: true }; return { action: "resume", stage, reason: "", skip: false }; } @@ -347,7 +357,7 @@ var REQUIRES_LINE_RE = /^\s*-\s+Requires\s+(.+?)\.?\s*$/; var STEP_RANGE_RE = /\bsteps?\s+(\d+\.\d+)\s*-\s*(\d+\.\d+)\b/gi; var ROADMAP_ID_RE = /\b\d+(?:\.\d+)+\b/g; function roadmapIdSlug(id) { - return String(id).replace(/[^0-9a-zA-Z]+/g, "-"); + return String(id).replaceAll(/[^0-9a-zA-Z]+/g, "-"); } function parentIdOf(id) { const parts = id.split("."); @@ -363,7 +373,7 @@ function extractRoadmapIds(text) { const end = match[2]; if (start === void 0 || end === void 0) continue; const expanded = expandStepRange(start, end); - if (expanded.length) { + if (expanded.length > 0) { ids.delete(start); ids.delete(end); for (const id of expanded) ids.add(id); @@ -390,7 +400,7 @@ function expandStepRange(start, end) { function parseRoadmap(text) { const tasks = []; const byId = /* @__PURE__ */ new Map(); - let currentTask = null; + let currentTask; for (const [index, line] of text.split(/\r?\n/).entries()) { const taskMatch = line.match(TASK_LINE_RE); if (taskMatch) { @@ -440,17 +450,17 @@ function completedIds(tasks) { const completionByTask = /* @__PURE__ */ new Map(); const postOrder = []; const pending = [...tasks]; - while (pending.length) { + while (pending.length > 0) { const task = pending.pop(); postOrder.push(task); pending.push(...task.subtasks); } - for (const task of postOrder.reverse()) { + for (const task of postOrder.toReversed()) { completionByTask.set(task, isComplete(task) && task.subtasks.every((subtask) => completionByTask.get(subtask))); } const recordCompletedSubtasks = (task) => { const pendingSubtasks = [task]; - while (pendingSubtasks.length) { + while (pendingSubtasks.length > 0) { const subtask = pendingSubtasks.pop(); if (completionByTask.get(subtask)) completed.add(subtask.id); pendingSubtasks.push(...subtask.subtasks); @@ -466,13 +476,13 @@ function completedIds(tasks) { } } for (const [prefix, groupedTasks] of prefixes.entries()) { - if (groupedTasks.length && groupedTasks.every((task) => completionByTask.get(task))) completed.add(prefix); + if (groupedTasks.length > 0 && groupedTasks.every((task) => completionByTask.get(task))) completed.add(prefix); } return completed; } function isTaskFullyComplete(task) { const pending = [task]; - while (pending.length) { + while (pending.length > 0) { const current = pending.pop(); if (!isComplete(current)) return false; pending.push(...current.subtasks); @@ -485,7 +495,7 @@ function taskMatchesOnlyTask(candidate, onlyTask) { return Boolean(candidate.task.subtasks?.includes(onlyTask)); } function blockedSummary(blocked) { - if (!blocked.length) return ""; + if (blocked.length === 0) return ""; const sample = blocked.slice(0, 5).join("; "); const suffix = blocked.length > 5 ? `; ${blocked.length - 5} more` : ""; return `${blocked.length} blocked task(s): ${sample}${suffix}`; @@ -498,7 +508,7 @@ function selectRoadmapTask(roadmapText, taken, onlyTask) { const blocked = []; for (const task of tasks) { const openSubtasks = task.subtasks.filter((subtask) => !isTaskFullyComplete(subtask)); - if (isComplete(task) && openSubtasks.length && !addendumTaken.has(task.id)) { + if (isComplete(task) && openSubtasks.length > 0 && !addendumTaken.has(task.id)) { candidates.push({ order: task.line, kind: "addendum", @@ -514,7 +524,7 @@ function selectRoadmapTask(roadmapText, taken, onlyTask) { } if (!isComplete(task) && !normalTaken.has(task.id)) { const missing = task.requires.filter((id) => !completed.has(id)); - if (missing.length) { + if (missing.length > 0) { blocked.push(`${task.id} requires ${missing.join(", ")}`); } else { candidates.push({ @@ -524,7 +534,7 @@ function selectRoadmapTask(roadmapText, taken, onlyTask) { id: task.id, title: task.title, requires: task.requires, - rationale: task.requires.length ? `Every declared dependency is complete: ${task.requires.join(", ")}.` : "The task has no declared dependencies.", + rationale: task.requires.length > 0 ? `Every declared dependency is complete: ${task.requires.join(", ")}.` : "The task has no declared dependencies.", isAddendum: false, subtasks: [] } @@ -532,7 +542,7 @@ function selectRoadmapTask(roadmapText, taken, onlyTask) { } } } - const matchingCandidates = candidates.filter((candidate) => taskMatchesOnlyTask(candidate, onlyTask)).sort((left, right) => left.order - right.order); + const matchingCandidates = candidates.filter((candidate) => taskMatchesOnlyTask(candidate, onlyTask)).toSorted((left, right) => left.order - right.order); const selected = matchingCandidates[0]; if (!selected) { const reason = onlyTask ? `Task ${onlyTask} is not currently unblocked as a normal task or addendum pass. ${blockedSummary(blocked)}` : blockedSummary(blocked); @@ -550,7 +560,7 @@ function roadmapTaskIndex(roadmapText) { const byId = /* @__PURE__ */ new Map(); for (const task of tasks) { byId.set(task.id, task); - for (const subtask of task.subtasks || []) byId.set(subtask.id, subtask); + for (const subtask of task.subtasks) byId.set(subtask.id, subtask); } return byId; } @@ -562,13 +572,17 @@ function candidateRoadmapComplete(task, isAddendum) { // src/workflows/df12-build-odw/exec.ts async function execFileText(command, commandArgs, options = {}) { const { execFile } = process.getBuiltinModule("node:child_process"); + const execOptions = { + cwd: options.cwd || process.cwd(), + maxBuffer: 16 * 1024 * 1024 + }; + if (options.timeoutMs) execOptions.timeout = options.timeoutMs; return await new Promise((resolve, reject) => { - execFile(command, [...commandArgs], { cwd: options.cwd || process.cwd(), maxBuffer: 16 * 1024 * 1024, ...options.timeoutMs ? { timeout: options.timeoutMs } : {} }, (error, stdout, stderr) => { + execFile(command, [...commandArgs], execOptions, (error, stdout, stderr) => { if (error) { - const failure = error; - failure.stdout = stdout; - failure.stderr = stderr; - reject(failure); + error.stdout = stdout; + error.stderr = stderr; + reject(error); return; } resolve(stdout); @@ -593,7 +607,7 @@ async function execFileStatus(command, commandArgs, options = {}) { } } function shellQuote(value) { - return `'${String(value).replace(/'/g, "'\\''")}'`; + return `'${String(value).replaceAll("'", String.raw`'\''`)}'`; } async function fileState(pathValue, baseDir = process.cwd()) { if (!pathValue) return { ok: true, exists: false, detail: "" }; @@ -613,6 +627,13 @@ async function fileState(pathValue, baseDir = process.cwd()) { } // src/workflows/df12-build-odw/faults.ts +function faultThrownMessage(error) { + if (error !== null && (typeof error === "object" || typeof error === "function") && "message" in error) { + const message = error.message; + if (typeof message === "string" && message) return message; + } + return String(error); +} function createFaultMetrics() { return { infraRetries: 0, infraFaults: 0, providerFaults: 0, authFaults: 0 }; } @@ -665,7 +686,7 @@ function makeWithInfraRetry(attempts, metrics = faultMetrics) { try { return await run(); } catch (error) { - const message = error && error.message || String(error); + const message = faultThrownMessage(error); if (attempt >= attempts || !infrastructureFailureDetail(message)) { if (infrastructureFailureDetail(message)) { log(`[${label}] infrastructure fault persisted after ${attempt} of ${attempts} attempt(s); giving up: ${message}`); @@ -744,14 +765,15 @@ function parsePorcelainDirty(output) { return []; }); } -async function gitEvidence(worktreePath, commandArgs, parse = ((text) => String(text || "").trim())) { +async function gitEvidence(worktreePath, commandArgs, parse) { + const parseOutput = parse === void 0 ? (text) => String(text || "").trim() : parse; const result = await execFileStatus("git", ["-C", worktreePath, ...commandArgs]); if (result.ok) { - return { ok: true, value: parse(result.stdout) }; + return { ok: true, value: parseOutput(result.stdout) }; } return { ok: false, - value: parse(result.stdout), + value: parseOutput(result.stdout), error: [result.message, result.stderr, result.stdout].filter(Boolean).join("\n").trim() }; } @@ -795,7 +817,7 @@ async function collectAssessmentEvidence(task, wt) { baseCommit, currentCommit: current.value || "", dirtyState: status.ok ? String(status.value || "").trim() ? "dirty" : "clean" : "unknown", - changedFiles: [...allChanged].sort(), + changedFiles: [...allChanged].toSorted(), committedChanges: committed.value, dirtyChanges, stagedChanges: staged.value, @@ -861,7 +883,7 @@ function makeRecoveryDiscovery(limits) { const mapped = []; for (const branch of branches) { const parsed = branchToRoadmapId(branch); - const task = parsed ? byId.get(parsed.id) : null; + const task = parsed ? byId.get(parsed.id) : void 0; if (!parsed || !task) { skipped.push({ id: parsed?.id || "", branchName: branch, reason: "unmapped-branch" }); continue; @@ -919,16 +941,18 @@ async function readExecplanState(candidate) { const text = await readFileText(path.join(candidate.worktreePath || "", candidate.execplanPath), candidate.worktreePath || void 0); return parseExecplanState(text); } catch (error) { - const failure = error; - if (failure && (failure.code === "ENOENT" || failure.code === "ENOTDIR")) { + const fields = error && (typeof error === "object" || typeof error === "function") ? error : void 0; + const code = fields && "code" in fields ? fields.code : void 0; + if (code === "ENOENT" || code === "ENOTDIR") { return { status: "missing", ticked: 0, unticked: 0, items: [] }; } + const message = fields && "message" in fields ? fields.message : void 0; return { status: "unreadable", ticked: 0, unticked: 0, items: [], - error: `${candidate.execplanPath}: ${failure && failure.message || String(error)}` + error: `${candidate.execplanPath}: ${message || String(error)}` }; } } @@ -978,7 +1002,7 @@ function commandSubstitutionEnd(command, start) { let depth = 1; while (cursor < command.length) { const character = command[cursor]; - if (character === void 0) return null; + if (character === void 0) return; if (character === "\\" && quote !== "'") { cursor += 2; continue; @@ -1006,20 +1030,23 @@ function commandSubstitutionEnd(command, start) { } cursor += 1; } - return null; + return; } function assignmentName(command, word) { let cursor = word.start; let name = ""; while (cursor < word.end) { const character = command[cursor]; - if (character === void 0) return null; - if (character === "=") return name && /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? name : null; - if (!/[A-Za-z0-9_]/.test(character)) return null; + if (character === void 0) return; + if (character === "=") { + if (name && /^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return name; + return; + } + if (!/[A-Za-z0-9_]/.test(character)) return; name += character; cursor += 1; } - return null; + return; } function tokenizeShellCommand(command) { const words = []; @@ -1057,7 +1084,7 @@ function tokenizeShellCommand(command) { } if (character === "$" && command[cursor + 1] === "(" && quote !== "'") { const end = commandSubstitutionEnd(command, cursor); - if (end === null) return null; + if (end === void 0) return null; value += command.slice(cursor, end); hasWord = true; cursor = end; @@ -1145,10 +1172,12 @@ function boundedTail(text, limit = 2e3) { } function reviewerDisplayName(reviewer) { switch (reviewer) { - case "dakar": + case "dakar": { return "Dakar"; - case "coderabbit": + } + case "coderabbit": { return "CodeRabbit"; + } default: { const unmatched = reviewer; throw new Error(`Unknown host reviewer: ${unmatched}`); @@ -1170,12 +1199,92 @@ function makeHostReviewMetrics() { } // src/workflows/df12-build-odw/config.ts +function isConfigRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} +function isCompatibilityWorkflowArgs(value) { + const strings = [ + "projectRoot", + "base", + "roadmap", + "designDocs", + "researchNote", + "taskId", + "resumeMode", + "writeProbeEffort", + "searchBackend", + "codeSearchBackend", + "grepaiWorkspace", + "grepaiProject", + "project", + "memtraceRepoId", + "buildAdapter", + "planAdapter", + "reviewAdapter", + "auditAdapter", + "triageAdapter", + "assessmentAdapter", + "buildModel", + "planModel", + "reviewModel", + "auditModel", + "auditEffort", + "triageModel", + "triageEffort", + "triageEscalationModel", + "assessmentModel", + "assessmentEscalationModel", + "reviewTool", + "dakarCommand", + "coderabbitReviewCommand", + "coderabbitFindingsFile", + "hostReviewFindingsFile", + "csCheckCommand" + ]; + const numbers = ["maxTasks", "maxParallel", "maxPlanningParallel", "maxPlanParallel", "maxBuildParallel", "maxDesignRounds", "maxReviewRounds"]; + const numericStrings = [ + "stageAttempts", + "maxWorkItemRounds", + "resumeTaskId", + "resumeMaxCandidates", + "reviewTimeoutSeconds", + "dakarTimeoutSeconds", + "dakarBudgetGbp", + "coderabbitAttempts", + "hostReviewAttempts", + "commitGateTimeoutSeconds" + ]; + const booleans = [ + "perWorkItemBuild", + "autoMerge", + "documentAudit", + "dryRun", + "authPreflight", + "requireCoderabbitAuth", + "assessPartialBranches", + "worktreeWritePreflight", + "coderabbitHostReview", + "coderabbitBetweenWorkItems", + "hostReviewBetweenWorkItems", + "hostCommitGates", + "csCheck", + "hostGatesBetweenWorkItems" + ]; + const valid = (keys, types) => keys.every((key) => value[key] === void 0 || types.includes(typeof value[key])); + if (!valid(strings, ["string"]) || !valid(numbers, ["number"]) || !valid(numericStrings, ["number", "string"]) || !valid(booleans, ["boolean"])) return false; + const models = value.writeProbeModelByAdapter; + return models === void 0 || models !== null && typeof models === "object" && !Array.isArray(models) && Object.values(models).every((model) => typeof model === "string"); +} var REVIEW_TOOLS = ["dakar", "coderabbit"]; function isReviewTool(value) { return REVIEW_TOOLS.some((tool) => tool === value); } function makeConfig(rawArgs) { - const cfg = rawArgs || {}; + if (rawArgs !== null && rawArgs !== void 0 && !isConfigRecord(rawArgs)) { + throw new Error("Invalid workflow argument type"); + } + const cfg = rawArgs ?? {}; + if (!isCompatibilityWorkflowArgs(cfg)) throw new Error("Invalid workflow argument type"); const PROJECT_ROOT2 = cfg.projectRoot || process.cwd(); const BASE2 = cfg.base || "main"; const ROADMAP2 = cfg.roadmap || "docs/roadmap.md"; @@ -1249,7 +1358,8 @@ function makeConfig(rawArgs) { const REVIEW_TOOL2 = reviewToolInput; const HOST_REVIEWER_NAME = reviewerDisplayName(REVIEW_TOOL2); const DAKAR_COMMAND2 = String(cfg.dakarCommand || "dakar-review"); - const REVIEW_TIMEOUT_SECONDS2 = Math.min(7200, Math.max(60, Math.trunc(Number(cfg.reviewTimeoutSeconds ?? cfg.dakarTimeoutSeconds) || 3600))); + const reviewTimeoutInput = Number(cfg.reviewTimeoutSeconds ?? cfg.dakarTimeoutSeconds) || 3600; + const REVIEW_TIMEOUT_SECONDS2 = Math.min(7200, Math.max(60, Math.trunc(reviewTimeoutInput))); const DAKAR_BUDGET_GBP_RAW = Number(cfg.dakarBudgetGbp); const DAKAR_BUDGET_GBP2 = Number.isFinite(DAKAR_BUDGET_GBP_RAW) ? Math.min(10, Math.max(0, DAKAR_BUDGET_GBP_RAW)) : 0; const CODERABBIT_REVIEW_COMMAND2 = cfg.coderabbitReviewCommand || "coderabbit review --agent"; @@ -1257,7 +1367,7 @@ function makeConfig(rawArgs) { const HOST_REVIEW_BETWEEN_WORK_ITEMS2 = (cfg.hostReviewBetweenWorkItems ?? cfg.coderabbitBetweenWorkItems) !== false; const attemptsInput = Number(cfg.hostReviewAttempts ?? cfg.coderabbitAttempts); if (!Number.isFinite(attemptsInput) && !Number.isNaN(attemptsInput)) { - throw new Error("hostReviewAttempts must be finite"); + throw new TypeError("hostReviewAttempts must be finite"); } const HOST_REVIEW_ATTEMPTS2 = Math.min(10, Math.max(1, Math.trunc(attemptsInput || 3))); const HOST_REVIEW_BACKOFF_MINUTES2 = (() => { @@ -1273,7 +1383,7 @@ function makeConfig(rawArgs) { return [low, high]; })(); const HOST_REVIEW_FINDINGS_FILE2 = String(cfg.hostReviewFindingsFile ?? cfg.coderabbitFindingsFile ?? ""); - const COMMIT_GATES2 = (Array.isArray(cfg.commitGates) && cfg.commitGates.length ? cfg.commitGates : ["make all"]).map((command) => String(command)); + const COMMIT_GATES2 = (Array.isArray(cfg.commitGates) && cfg.commitGates.length > 0 ? cfg.commitGates : ["make all"]).map(String); const COMMIT_GATE_TEXT2 = COMMIT_GATES2.map((command) => `\`${command}\``).join(" then "); const HOST_COMMIT_GATES2 = cfg.hostCommitGates !== false; const HOST_GATES_BETWEEN_WORK_ITEMS2 = cfg.hostGatesBetweenWorkItems !== false; @@ -1293,6 +1403,12 @@ function makeConfig(rawArgs) { const CODERABBIT_REVIEW_GUIDANCE = CODERABBIT_HOST_REVIEW2 ? HOST_REVIEW_GUIDANCE : `Use \`coderabbit review --agent\` as the per-work-item AI review after deterministic gates are green, and clear all actionable concerns before advancing to the next work item or declaring the fix round complete. CodeRabbit is a shared, rate-limited quota: do not ask it to find errors that the project commit gates, markdown gates, linting, typechecking, or tests can catch locally. If the CodeRabbit rate limit is exceeded, treat the backoff as expected and sleep (use the \`vsleep\` command) for \`$(shuf -i ${HOST_REVIEW_BACKOFF_MINUTES2[0]}-${HOST_REVIEW_BACKOFF_MINUTES2[1]} -n 1)\` minutes before trying again; never shorten this backoff. You are not in any rush, and there is no wallclock time limit for this task. Retry at most three times after the initial CodeRabbit attempt, then record the deferred review with the exact error/output as an open issue so the supervisor can decide whether to relaunch, fallback-review, or wait for the quota to recover.`; const SPARK_DELEGATION_GUIDANCE = "You are free to delegate to the `wyvern` fast Codex subagent for bounded read-only tasks on known surfaces as needed; use 5.4-mini in place of 5.3 Codex Spark when Spark quota is unavailable. Quick surface maps, candidate-file recon, targeted consistency searches, and medium-grain 'what changed / where is the seam' checks."; const SCRUTINEER_DELEGATION_GUIDANCE = CODERABBIT_HOST_REVIEW2 ? `Delegate deterministic gate execution to the \`scrutineer\` sub-agent: ask it to run the repository commit gates/test suites. The scrutineer must not edit tracked files; use its structured failure report to make fixes yourself, then summon it again until the gates are green. ${CODERABBIT_REVIEW_GUIDANCE}` : `Delegate deterministic gate execution and CodeRabbit invocation to the \`scrutineer\` sub-agent: ask it to run the repository commit gates/test suites and, only after those pass, to run \`${CODERABBIT_REVIEW_COMMAND2}\` from inside the worktree. The scrutineer must not edit tracked files; use its structured failure report to make fixes yourself, then summon it again until gates and CodeRabbit are green or a documented rate-limit/deferred-review open issue remains. ${CODERABBIT_REVIEW_GUIDANCE}`; + const legacyHostReviewAliases = { + CODERABBIT_BETWEEN_WORK_ITEMS: HOST_REVIEW_BETWEEN_WORK_ITEMS2, + CODERABBIT_ATTEMPTS: HOST_REVIEW_ATTEMPTS2, + CODERABBIT_BACKOFF_MINUTES: HOST_REVIEW_BACKOFF_MINUTES2, + CODERABBIT_FINDINGS_FILE: HOST_REVIEW_FINDINGS_FILE2 + }; return { PROJECT_ROOT: PROJECT_ROOT2, BASE: BASE2, @@ -1364,10 +1480,7 @@ function makeConfig(rawArgs) { COMMIT_GATE_GUIDANCE: COMMIT_GATE_GUIDANCE2, CS_CHECK_GUIDANCE: CS_CHECK_GUIDANCE2, CODERABBIT_REVIEW_GUIDANCE, - CODERABBIT_BETWEEN_WORK_ITEMS: HOST_REVIEW_BETWEEN_WORK_ITEMS2, - CODERABBIT_ATTEMPTS: HOST_REVIEW_ATTEMPTS2, - CODERABBIT_BACKOFF_MINUTES: HOST_REVIEW_BACKOFF_MINUTES2, - CODERABBIT_FINDINGS_FILE: HOST_REVIEW_FINDINGS_FILE2, + ...legacyHostReviewAliases, SPARK_DELEGATION_GUIDANCE, SCRUTINEER_DELEGATION_GUIDANCE }; @@ -1438,10 +1551,11 @@ function makeAuthPreflight(config, deps) { if (config.requireHostReviewAuth) { if (config.reviewTool === "dakar") { const openaiKey = deps.environment.get("OPENAI_API_KEY"); + const sensitiveValues = typeof openaiKey === "string" ? [openaiKey] : []; const dakarWords = config.dakarInvocation.filter((argument) => !ENVIRONMENT_ASSIGNMENT.test(argument)); const dakarExecutable = dakarWords[0] || "dakar-review"; const dakar = await deps.exec(dakarExecutable, [...dakarWords.slice(1), "--version"], { timeoutMs: AUTH_PROBE_TIMEOUT_MS }); - const dakarOutput = redactedDakarStatusDetail(dakar, config.dakarInvocation, typeof openaiKey === "string" ? [openaiKey] : []); + const dakarOutput = redactedDakarStatusDetail(dakar, config.dakarInvocation, sensitiveValues); if (!dakar.ok) { deps.recordHostReviewAuthFailure(); failures.push({ @@ -1451,7 +1565,7 @@ function makeAuthPreflight(config, deps) { }); } const pi = await deps.exec("pi", ["--version"], { timeoutMs: AUTH_PROBE_TIMEOUT_MS }); - const piOutput = redactedDakarStatusDetail(pi, config.dakarInvocation, typeof openaiKey === "string" ? [openaiKey] : []); + const piOutput = redactedDakarStatusDetail(pi, config.dakarInvocation, sensitiveValues); if (!pi.ok) { deps.recordHostReviewAuthFailure(); failures.push({ tool: "dakar", command: "pi --version", detail: piOutput || "pi is unavailable or its version probe failed" }); @@ -1477,7 +1591,7 @@ function makeAuthPreflight(config, deps) { } } } - if (failures.length) { + if (failures.length > 0) { deps.log(`[auth] fatal preflight failure: ${failures.map((failure) => `${failure.tool}: ${failure.detail.split(/\r?\n/)[0]}`).join("; ")}`); } else { const passed = ["Codex"]; @@ -1494,11 +1608,11 @@ function makeAuthPreflight(config, deps) { // src/workflows/df12-build-odw/prompts.ts function encodeUntrustedLine(value) { - return JSON.stringify(value).replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029"); + return JSON.stringify(value).replaceAll("\u2028", String.raw`\u2028`).replaceAll("\u2029", String.raw`\u2029`); } function residualRiskLines(impl) { const items = impl?.residualRisk || []; - if (!items.length) return []; + if (items.length === 0) return []; return [ "", "Advisory residual risk (non-blocking \u2014 weigh during review, do not treat as an automatic block).", @@ -1868,9 +1982,25 @@ var WRITE_PROBE_SCHEMA = { }, required: ["ok"] }; +function isWriteProbeReply(value) { + return value !== null && typeof value === "object" && !Array.isArray(value) && "ok" in value && typeof value.ok === "boolean" && (!("detail" in value) || typeof value.detail === "string"); +} +function writeProbeErrorMessage(error) { + if (error !== null && (typeof error === "object" || typeof error === "function") && "message" in error) { + const message = error.message; + if (message) return String(message); + } + return String(error); +} +function isProbeSymlinkError(error) { + if (error === null || typeof error !== "object" && typeof error !== "function" || !("code" in error)) return false; + return error.code === "ELOOP" || error.code === "EMLINK"; +} function writeProbePath(worktree, adapter) { const path = process.getBuiltinModule("node:path"); - return path.join(worktree, `.df12-write-probe-${String(adapter).replace(/[^0-9a-zA-Z._-]+/g, "-")}`); + const digest = process.getBuiltinModule("node:crypto").createHash("sha256").update(adapter).digest("hex"); + const readable = adapter.replaceAll(/[^0-9a-zA-Z._-]+/g, "-").slice(0, 40); + return path.join(worktree, `.df12-write-probe-${readable}-${digest}`); } function writeProbeToken(tag, adapter) { return `df12-write-probe v1 task=${tag} adapter=${adapter}`; @@ -1899,8 +2029,8 @@ async function clearProbeArtifact(probeFile) { async function verifyWriteProbe(probeFile, token) { const fs = process.getBuiltinModule("node:fs/promises"); const { constants } = process.getBuiltinModule("node:fs"); - let handle = null; - let content = null; + let handle; + let content; try { handle = await fs.open(probeFile, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); const stat = await handle.stat(); @@ -1908,17 +2038,16 @@ async function verifyWriteProbe(probeFile, token) { content = await handle.readFile({ encoding: "utf8" }); } } catch (error) { - const failure = error; - if (failure && (failure.code === "ELOOP" || failure.code === "EMLINK")) { + if (isProbeSymlinkError(error)) { await fs.rm(probeFile, { force: true, recursive: true }); return { ok: false, detail: "probe path is not a regular file (symlink or special file rejected)" }; } - return { ok: false, detail: `probe file missing or unreadable: ${failure && failure.message || String(error)}` }; + return { ok: false, detail: `probe file missing or unreadable: ${writeProbeErrorMessage(error)}` }; } finally { if (handle) await handle.close(); } await fs.rm(probeFile, { force: true, recursive: true }); - if (content === null) { + if (content === void 0) { return { ok: false, detail: "probe path is not a regular file (symlink or special file rejected)" }; } if (content.trim() === token) return { ok: true, detail: "" }; @@ -1934,7 +2063,7 @@ async function hostWriteProbe(worktree) { await fs.rm(hostProbe, { force: true }); return { ok: true, detail: "" }; } catch (error) { - return { ok: false, detail: error && error.message || String(error) }; + return { ok: false, detail: writeProbeErrorMessage(error) }; } } function makeWritePreflight({ enabled, targets }) { @@ -1947,26 +2076,35 @@ function makeWritePreflight({ enabled, targets }) { const probeFile = writeProbePath(worktree, target.adapter); const token = writeProbeToken(tag, target.adapter); await clearProbeArtifact(probeFile); - let reply = null; + let reply; let agentError = ""; try { - reply = await agent(writeProbePrompt(probeFile, token), target.options({ - phase: "Worktree", - label: `write-probe:${target.adapter}`, + const response = await agent(writeProbePrompt(probeFile, token), { + ...target.options({ + phase: "Worktree", + label: `write-probe:${target.adapter}`, + schema: WRITE_PROBE_SCHEMA + }), + // Keep the reply contract if routing omitted or replaced it. schema: WRITE_PROBE_SCHEMA - })); + }); + if (isWriteProbeReply(response)) { + reply = response; + } else { + agentError = "write probe agent reply has invalid fields"; + } } catch (error) { - agentError = error && error.message || String(error); + agentError = writeProbeErrorMessage(error); } const verified = await verifyWriteProbe(probeFile, token); - if (verified.ok) return null; + if (verified.ok) return; const detail = [verified.detail, reply && reply.ok === false ? reply.detail : "", agentError].filter(Boolean).join("; "); return { adapter: target.adapter, detail }; })); - const failures = outcomes.filter((outcome) => outcome !== null); + const failures = outcomes.filter((outcome) => outcome !== void 0); return { ok: failures.length === 0, failures }; } - let taskAgentWritePreflight = null; + let taskAgentWritePreflight; function ensureTaskAgentWriteAccess2(worktree, tag) { if (!enabled) return Promise.resolve({ ok: true, skipped: true, failures: [] }); if (!taskAgentWritePreflight) taskAgentWritePreflight = runTaskAgentWritePreflight2(worktree, tag); @@ -2039,7 +2177,8 @@ Status: APPROVED } } } catch (error) { - return { ok: false, detail: `could not update the plan status: ${error && error.message || String(error)}` }; + const message = error && error.message; + return { ok: false, detail: `could not update the plan status: ${message || String(error)}` }; } const status = await execFileStatus("git", ["-C", worktree, "status", "--porcelain=v1", "--", relPath]); if (!status.ok) { @@ -2068,9 +2207,9 @@ async function commitExecplanDraft(worktree, relPath, tag) { const status = await execFileStatus("git", ["-C", worktree, "status", "--porcelain=v1"]); if (!status.ok) return { ok: false, detail: `git status failed: ${(status.message || status.stderr || "").trim()}` }; const lines = String(status.stdout).split(/\r?\n/).filter(Boolean); - if (!lines.length) return { ok: false, detail: "nothing to commit: the worktree is already clean" }; + if (lines.length === 0) return { ok: false, detail: "nothing to commit: the worktree is already clean" }; const foreign = lines.filter((line) => line.slice(3).replace(/^"(.*)"$/, "$1") !== relPath); - if (foreign.length) { + if (foreign.length > 0) { const sample = foreign.slice(0, 8).map((line) => line.trim()).join("; "); return { ok: false, detail: `the worktree holds ${foreign.length} uncommitted path(s) beyond the plan file (${sample}${foreign.length > 8 ? "; \u2026" : ""})` }; } @@ -2120,7 +2259,7 @@ async function salvageTaskArtefacts(worktree, candidatePaths, tag) { } verified.push(contained.relPath); } - if (!verified.length) { + if (verified.length === 0) { return { committed: [], skipped, sha: "", detail: "nothing to salvage: no eligible task-scoped artefacts are dirty" }; } const add = await execFileStatus("git", ["-C", worktree, "add", "--", ...verified]); @@ -2151,29 +2290,37 @@ async function verifyWorktreeCommitted(worktree) { return { ok: false, detail: `git status failed: ${(status.message || status.stderr || "").trim()}` }; } const lines = String(status.stdout).split(/\r?\n/).filter(Boolean); - if (!lines.length) return { ok: true, detail: "" }; + if (lines.length === 0) return { ok: true, detail: "" }; const sample = lines.slice(0, 8).map((line) => line.trim()).join("; "); return { ok: false, detail: `${lines.length} uncommitted path(s): ${sample}${lines.length > 8 ? "; \u2026" : ""}` }; } // src/workflows/df12-build-odw/assessment.ts var SALVAGE_CLASSIFICATIONS = /* @__PURE__ */ new Set(["continue-manual", "adopt-partial", "infra-fault"]); +function assessmentErrorMessage(error) { + if (error && (typeof error === "object" || typeof error === "function") && "message" in error && typeof error.message === "string" && error.message) return error.message; + return String(error); +} function summarizeSalvages(results2) { - const salvages = results2.filter((result) => result.salvage).map((result) => ({ - id: result.id || "", - classification: result.salvage?.classification || "", - committed: result.salvage?.committed || [], - skipped: (result.salvage?.skipped || []).length, - sha: result.salvage?.sha || "", - detail: result.salvage?.detail || "" - })); + const salvages = results2.flatMap((result) => { + const salvage = result.salvage; + if (!salvage) return []; + return [{ + id: result.id || "", + classification: salvage.classification || "", + committed: salvage.committed || [], + skipped: (salvage.skipped || []).length, + sha: salvage.sha || "", + detail: salvage.detail || "" + }]; + }); const salvagedBranches = salvages.filter((entry) => entry.committed.length > 0).length; const summarySuffix = salvagedBranches ? ` | salvaged artefacts on ${salvagedBranches} branch(es)` : ""; return { salvages, salvagedBranches, summarySuffix }; } function fastAssessmentClassification(evidence) { const collectionErrors = evidence?.collectionErrors || []; - if (collectionErrors.length) { + if (collectionErrors.length > 0) { return { classification: "continue-manual", reason: `host evidence collection reported error(s) (${collectionErrors.slice(0, 3).join("; ")}); operator judgement required` }; } const hasCommittedWork = (evidence?.recentCommits || []).length > 0; @@ -2185,29 +2332,48 @@ function fastAssessmentClassification(evidence) { } function deterministicAssessment(classification, evidence, reason) { return { + /** Deterministic disposition of the surviving branch. */ classification, + /** Branch name collected by the host. */ branchName: evidence.branchName || "", + /** Worktree path collected by the host. */ worktreePath: evidence.worktreePath || "", + /** Base commit from which the branch diverged. */ baseCommit: evidence.baseCommit || "", + /** Branch commit observed by the host. */ currentCommit: evidence.currentCommit || "", + /** Host-observed worktree cleanliness. */ dirtyState: evidence.dirtyState || "unknown", + /** Paths observed as changed on the branch or worktree. */ changedFiles: evidence.changedFiles || [], + /** Whether the changed paths were judged task scoped. */ taskScoped: false, + /** ExecPlan assessment; empty when no model judgement was made. */ execPlan: "", + /** Roadmap assessment; empty when no model judgement was made. */ roadmap: "", + /** Validation assessment; empty when no model judgement was made. */ validation: "", + /** Required evidence not established by the assessment. */ missingEvidence: [], + /** Advisory residual risks for later review. */ residualRisk: [], + /** Risks identified during assessment. */ risks: [], + /** Reason for the deterministic disposition. */ rationale: reason, + /** Recommended disposition for the operator. */ recommendation: reason, + /** Follow-up actions identified by the assessment. */ nextActions: [], + /** Identifies the host classifier as the source of the disposition. */ classifier: "deterministic", + /** Complete evidence collected by the host. */ hostEvidence: evidence }; } function boundedSalvageLogText(text, max = 200) { - const flat = String(text ?? "").replace(/\s+/g, " ").trim(); + const flat = String(text).replaceAll(/\s+/g, " ").trim(); return flat.length > max ? `${flat.slice(0, max - 1)}\u2026` : flat; } async function salvageAssessmentArtefacts(taskId, worktree, evidence, classification) { @@ -2223,14 +2389,14 @@ async function salvageAssessmentArtefacts(taskId, worktree, evidence, classifica ...evidence.stagedChanges || [] ].map((entry) => entry.path); const outcome = await salvageTaskArtefacts(worktree, candidates, taskId); - if (outcome.committed.length) { - log(`[salvage] task ${taskId} (${classification}): committed ${outcome.committed.length} artefact(s) at ${outcome.sha || ""}${outcome.skipped.length ? `; skipped ${outcome.skipped.length}` : ""}`); + if (outcome.committed.length > 0) { + log(`[salvage] task ${taskId} (${classification}): committed ${outcome.committed.length} artefact(s) at ${outcome.sha || ""}${outcome.skipped.length > 0 ? `; skipped ${outcome.skipped.length}` : ""}`); } else { - log(`[salvage] task ${taskId} (${classification}): nothing committed \u2014 ${boundedSalvageLogText(outcome.detail || "no eligible artefacts")}${outcome.skipped.length ? `; skipped ${outcome.skipped.length}` : ""}`); + log(`[salvage] task ${taskId} (${classification}): nothing committed \u2014 ${boundedSalvageLogText(outcome.detail || "no eligible artefacts")}${outcome.skipped.length > 0 ? `; skipped ${outcome.skipped.length}` : ""}`); } return { classification, ...outcome }; } catch (error) { - const detail = `salvage errored: ${error && error.message || String(error)}`; + const detail = `salvage errored: ${assessmentErrorMessage(error)}`; log(`[salvage] task ${taskId} (${classification}): ${boundedSalvageLogText(detail)}`); return { classification, committed: [], skipped: [], sha: "", detail }; } @@ -2244,12 +2410,12 @@ function isInfraFaultResult(result) { return Boolean(infrastructureFailureDetail(detail)); } function isDeferredReviewIssue(issue) { - if (issue !== null && typeof issue === "object" && issue.kind === "host-review-deferral" && (issue.outcome === "rate-limited" || issue.outcome === "error")) return true; + if (issue && typeof issue === "object" && "kind" in issue && issue.kind === "host-review-deferral" && "outcome" in issue && (issue.outcome === "rate-limited" || issue.outcome === "error")) return true; return false; } function hasOnlyDeferredReviewIssues(openIssues) { const issues = openIssues || []; - return issues.length > 0 && issues.every(isDeferredReviewIssue); + return issues.length > 0 && issues.every((issue) => isDeferredReviewIssue(issue)); } function implementationAuthFailureDetail(impl) { const detail = [impl?.summary, ...impl?.openIssues || []].filter(Boolean).join("\n"); @@ -2263,6 +2429,10 @@ function addendumImplementationNeedsManualMerge(impl) { const total = Number(impl.workItemsTotal); return Number.isFinite(completed) && Number.isFinite(total) && total > 0 && completed >= total; } +function assessmentModelTier(evidence) { + const hasExecplan = (evidence.changedFiles || []).some((entry) => /^docs\/execplans\/.+\.md$/.test(String(entry))); + return hasExecplan ? "escalated" : "medium"; +} function makeAssessment({ preamble: preamble2, assessPartialBranches, assessmentAgentOptions: assessmentAgentOptions2, assessmentEscalationModel, withInfraRetry: withInfraRetry2 }) { function assessmentPromptLines(taskHeader, worktreePath, evidence, contextTitle, contextValue) { return [ @@ -2299,12 +2469,12 @@ function makeAssessment({ preamble: preamble2, assessPartialBranches, assessment "", "Host-collected git evidence:", "```json", - JSON.stringify(evidence, null, 2), + JSON.stringify(evidence, void 0, 2), "```", "", contextTitle, "```json", - JSON.stringify(contextValue, null, 2), + JSON.stringify(contextValue, void 0, 2), "```", "", "Return only the schema-bound assessment object. Free-text recommendations do not drive integration; make the enum classification and evidence fields precise." @@ -2328,16 +2498,16 @@ function makeAssessment({ preamble: preamble2, assessPartialBranches, assessment candidate ); } - function assessmentModelTier(evidence) { - const hasExecplan = (evidence.changedFiles || []).some((entry) => /^docs\/execplans\/.+\.md$/.test(String(entry))); - return hasExecplan ? "escalated" : "medium"; - } async function runModelAssessment(buildPrompt, phaseName, label, evidence) { const tier = assessmentModelTier(evidence); const options = { phase: phaseName, label, schema: ASSESSMENT_SCHEMA }; if (tier === "escalated") options.model = assessmentEscalationModel; - const assessment = await withInfraRetry2(() => agent(buildPrompt(), assessmentAgentOptions2(options)), label); + const assessment = await withInfraRetry2( + () => agent(buildPrompt(), { ...assessmentAgentOptions2(options), schema: ASSESSMENT_SCHEMA }), + label + ); if (!assessment) return null; + if (typeof assessment !== "object" || Array.isArray(assessment)) throw new Error("assessment agent returned a malformed structured output"); return { ...assessment, assessmentTier: tier }; } async function assessRecoveryCandidate2(candidate) { @@ -2373,7 +2543,11 @@ function makeAssessment({ preamble: preamble2, assessPartialBranches, assessment /** Host-collected git evidence for the candidate branch. */ evidence, /** The assessment object (deterministic or model), or null on failure. */ - assessment: { ...assessment, hostEvidence: evidence }, + assessment: { + ...assessment, + /** Complete evidence collected by the host. */ + hostEvidence: evidence + }, /** Error text when no structured assessment was produced; '' otherwise. */ assessmentError: "" }; @@ -2384,13 +2558,13 @@ function makeAssessment({ preamble: preamble2, assessPartialBranches, assessment /** The assessment object (deterministic or model), or null on failure. */ assessment: null, /** Error text when no structured assessment was produced; '' otherwise. */ - assessmentError: error && error.message || String(error) + assessmentError: assessmentErrorMessage(error) }; } } function shouldAssessFailure2(result, wt) { if (!assessPartialBranches) return false; - if (!wt?.branch || !wt?.worktreePath) return false; + if (!wt?.branch || !wt.worktreePath) return false; if (!result || !["failed", "halted"].includes(result.status || "")) return false; if (result.stage === "worktree" || result.stage === "worktree-write" || result.stage === "auth" || result.stage === "provider" || result.stage === "infrastructure" || result.status === "fatal-auth" || result.status === "provider-fault" || result.status === "infra-fault") return false; const detail = [result.detail, ...result.openIssues || []].filter(Boolean).join("\n"); @@ -2411,7 +2585,9 @@ function makeAssessment({ preamble: preamble2, assessPartialBranches, assessment const fast = fastAssessmentClassification(evidence); if (fast) { const salvage = fast.classification === "continue-manual" ? { classification: fast.classification, committed: [], skipped: [], sha: "", detail: "salvage skipped: deterministic continue-manual fires on untrustworthy host evidence" } : null; - return { ...result, assessment: deterministicAssessment(fast.classification, evidence, fast.reason), ...salvage ? { salvage } : {} }; + const assessed = { ...result, assessment: deterministicAssessment(fast.classification, evidence, fast.reason) }; + if (salvage) return { ...assessed, salvage }; + return assessed; } try { const assessment = await runModelAssessment(() => assessmentPrompt2(task, wt, result, evidence), "Assess", `assess:${task.id}`, evidence); @@ -2419,11 +2595,13 @@ function makeAssessment({ preamble: preamble2, assessPartialBranches, assessment return { ...result, assessmentError: "assessment agent returned no structured output", assessmentEvidence: evidence }; } const salvage = await salvageAssessmentArtefacts(task.id, wt.worktreePath || "", evidence, String(assessment.classification || "")); - return { ...result, assessment: { ...assessment, hostEvidence: evidence }, ...salvage ? { salvage } : {} }; + const assessed = { ...result, assessment: { ...assessment, hostEvidence: evidence } }; + if (salvage) return { ...assessed, salvage }; + return assessed; } catch (error) { return { ...result, - assessmentError: error && error.message || String(error), + assessmentError: assessmentErrorMessage(error), assessmentEvidence: evidence }; } @@ -2474,11 +2652,12 @@ var stepOf = (id) => String(id).split(".").slice(0, 2).join("."); function dedupeProposals(proposals) { const byKey = /* @__PURE__ */ new Map(); for (const proposal of proposals || []) { - const title = typeof proposal?.title === "string" ? proposal.title.trim() : ""; - const key = title.toLowerCase().replace(/\s+/g, " "); + if (!proposal) throw new TypeError("remediation proposal title must be a non-blank string"); + const title = typeof proposal.title === "string" ? proposal.title.trim() : ""; + const key = title.toLowerCase().replaceAll(/\s+/g, " "); if (!key) throw new TypeError("remediation proposal title must be a non-blank string"); - const fallback = String(proposal?.source || proposal?.rationale || ""); - const sources = Array.isArray(proposal.sources) ? proposal.sources.map((source) => String(source)).filter(Boolean) : []; + const fallback = String(proposal.source || proposal.rationale || ""); + const sources = Array.isArray(proposal.sources) ? proposal.sources.map(String).filter(Boolean) : []; if (fallback && !sources.includes(fallback)) sources.push(fallback); const uniqueSources = [...new Set(sources)]; const existing = byKey.get(key); @@ -2494,7 +2673,7 @@ function triageNeedsEscalation(deduped) { const sources = /* @__PURE__ */ new Set(); for (const proposal of deduped) { for (const source of proposal.sources || []) sources.add(source); - if (!(proposal.sources || []).length) { + if ((proposal.sources || []).length === 0) { const fallback = String(proposal.source || proposal.rationale || ""); if (fallback) sources.add(fallback); } @@ -2539,7 +2718,7 @@ function makeRemediation({ preamble: preamble2, worktreeSafetyNet: worktreeSafet async function runTriage2(stepPrefix, proposals) { phase("Remediation"); const deduped = dedupeProposals(proposals); - if (!deduped.length) return { ok: true, decisions: [], summary: "no proposals to triage after de-duplication" }; + if (deduped.length === 0) return { ok: true, decisions: [], summary: "no proposals to triage after de-duplication" }; const options = { phase: "Remediation", label: `triage:${stepPrefix}`, schema: TRIAGE_SCHEMA }; if (triageNeedsEscalation(deduped)) options.model = triageEscalationModel; return await agent(triagePrompt2(stepPrefix, deduped), triageAgentOptions2(options)); @@ -2558,50 +2737,106 @@ function makeRemediation({ preamble: preamble2, worktreeSafetyNet: worktreeSafet // src/workflows/df12-build-odw/coderabbit-review.ts var CODERABBIT_SUCCESS_STATUSES = /* @__PURE__ */ new Set(["review_completed", "reviewed"]); +function isCoderabbitJsonObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} +function isOptionalCoderabbitString(value) { + return value === void 0 || typeof value === "string"; +} +function isCoderabbitFinding(event) { + return event.type === "finding" && isOptionalCoderabbitString(event.severity) && isOptionalCoderabbitString(event.fileName) && isOptionalCoderabbitString(event.comment) && isOptionalCoderabbitString(event.codegenInstructions) && (event.suggestions === void 0 || Array.isArray(event.suggestions)); +} +function isCoderabbitError(event) { + return event.type === "error" && isOptionalCoderabbitString(event.errorType) && isOptionalCoderabbitString(event.message); +} +function isCoderabbitCompletion(event) { + return event.type === "complete" && typeof event.status === "string" && (event.findings === void 0 || typeof event.findings === "number" && Number.isFinite(event.findings)); +} function parseCoderabbitAgentOutput(stdout) { const events = []; const rawLines = []; + const findings = []; + let complete = null; + let error = null; + let invalidReason = ""; for (const line of String(stdout || "").split(/\r?\n/)) { const trimmed = line.trim(); if (!trimmed) continue; try { const event = JSON.parse(trimmed); - if (event && typeof event === "object") { + if (isCoderabbitJsonObject(event)) { events.push(event); + if (typeof event.type !== "string") { + invalidReason ||= "Invalid CodeRabbit event type"; + } else if (event.type === "finding") { + if (isCoderabbitFinding(event)) findings.push(event); + else invalidReason ||= "Invalid CodeRabbit finding event"; + } else if (event.type === "complete") { + if (isCoderabbitCompletion(event)) complete ||= event; + else invalidReason ||= "Invalid CodeRabbit completion event"; + } else if (event.type === "error") { + if (isCoderabbitError(event)) error ||= event; + else invalidReason ||= "Invalid CodeRabbit error event"; + } continue; } } catch { } rawLines.push(trimmed); } - return { events, rawLines, findings: events.filter((event) => event.type === "finding"), complete: events.find((event) => event.type === "complete") || null, error: events.find((event) => event.type === "error") || null }; + if (invalidReason && !error) error = { type: "error", errorType: "invalid_output", message: invalidReason }; + return { events, rawLines, findings, complete, error }; +} +function malformedCoderabbitAuthEvidence(parsed) { + for (const event of parsed.events) { + if (event.type === "error" && !isCoderabbitError(event) && typeof event.message === "string") { + const detail = authFailureDetail(event.message); + if (detail) return detail; + } + } + return ""; } function classifyCoderabbitOutcome(execResult, parsed) { - const errorText = [parsed.error?.message || "", execResult.stderr || "", execResult.message || ""].join("\n"); + const errorText = [parsed.error?.message || "", malformedCoderabbitAuthEvidence(parsed), execResult.stderr || "", execResult.message || ""].join("\n"); if (parsed.error?.errorType === "rate_limit" || /\brate.?limit|review limit reached/i.test(errorText)) return "rate-limited"; if (authFailureDetail(errorText)) return "auth"; if (parsed.error || !execResult.ok && !parsed.complete) return "error"; - if (parsed.findings.length) return "findings"; + if (parsed.findings.length > 0) return "findings"; return parsed.complete && CODERABBIT_SUCCESS_STATUSES.has(String(parsed.complete.status)) ? "clean" : "error"; } +function coderabbitErrorCategory(result, outcome, parsed) { + if (result.killed) return "timeout"; + if (outcome === "rate-limited") return "deferred"; + if (outcome === "auth") return "auth"; + if (parsed.error?.errorType === "invalid_output") return "invalid-output"; + if (outcome === "error") return parsed.error || parsed.complete ? "execution" : "invalid-output"; + return "none"; +} +function elapsedSince(startedMs) { + const nowMs = Number(process.hrtime.bigint() / 1000000n); + return Math.max(0, nowMs - startedMs); +} function makeCoderabbitAttempt(config) { return async function runCoderabbitAttempt(worktree, exec, deps = {}) { const tracer = deps.tracer || NOOP_HOST_REVIEW_TRACER; const traceContext = deps.traceContext || { runId: "host-review" }; const startedMs = Number(process.hrtime.bigint() / 1000000n); - const span = tracer.startSpan("host-review.coderabbit.subprocess", { runId: boundedTail(traceContext.runId || "host-review", 120), ...traceContext.taskId ? { taskId: boundedTail(traceContext.taskId, 120) } : {} }, { reviewer: "coderabbit", attempt: deps.attempt || 1 }); + const spanContext = { runId: boundedTail(traceContext.runId || "host-review", 120) }; + if (traceContext.taskId) spanContext.taskId = boundedTail(traceContext.taskId, 120); + const span = tracer.startSpan("host-review.coderabbit.subprocess", spanContext, { reviewer: "coderabbit", attempt: deps.attempt || 1 }); let result; try { result = await exec("coderabbit", ["review", "--agent", "--type", "committed", "--base", config.base], { cwd: worktree, timeoutMs: config.reviewTimeoutSeconds * 1e3 }); } catch (error) { - span.end({ reviewer: "coderabbit", attempt: deps.attempt || 1, outcome: "error", errorCategory: "execution", timeout: false, elapsedMs: Math.max(0, Number(process.hrtime.bigint() / 1000000n) - startedMs) }); + span.end({ reviewer: "coderabbit", attempt: deps.attempt || 1, outcome: "error", errorCategory: "execution", timeout: false, elapsedMs: elapsedSince(startedMs) }); throw error; } const parsed = parseCoderabbitAgentOutput(result.stdout); const outcome = classifyCoderabbitOutcome(result, parsed); - const detail = outcome === "clean" || outcome === "findings" ? "" : (parsed.error?.message || result.message || result.stderr || parsed.rawLines.join("; ") || "coderabbit produced no parsable outcome").trim(); - const errorCategory = result.killed ? "timeout" : outcome === "rate-limited" ? "deferred" : outcome === "auth" ? "auth" : outcome === "error" ? parsed.error || parsed.complete ? "execution" : "invalid-output" : "none"; - span.end({ reviewer: "coderabbit", attempt: deps.attempt || 1, outcome, errorCategory, timeout: errorCategory === "timeout", elapsedMs: Math.max(0, Number(process.hrtime.bigint() / 1000000n) - startedMs) }); + const authEvidence = outcome === "auth" ? malformedCoderabbitAuthEvidence(parsed) : ""; + const detail = outcome === "clean" || outcome === "findings" ? "" : (authEvidence || parsed.error?.message || result.message || result.stderr || parsed.rawLines.join("; ") || "coderabbit produced no parsable outcome").trim(); + const errorCategory = coderabbitErrorCategory(result, outcome, parsed); + span.end({ reviewer: "coderabbit", attempt: deps.attempt || 1, outcome, errorCategory, timeout: errorCategory === "timeout", elapsedMs: elapsedSince(startedMs) }); return { outcome, findings: parsed.findings, detail: boundedTail(detail), errorCategory }; }; } @@ -2616,6 +2851,16 @@ var DAKAR_SEVERITY_MAP = { var DAKAR_SEVERITIES = new Set(Object.keys(DAKAR_SEVERITY_MAP)); var DAKAR_REQUIRED_FINDING_FIELDS = ["path", "title", "detail", "evidence"]; var DAKAR_PARENT_TIMEOUT_GRACE_MS = 5e3; +function dakarErrorProperty(error, property) { + if (error && (typeof error === "object" || typeof error === "function")) { + const value = property === "message" ? "message" in error ? error.message : void 0 : "name" in error ? error.name : void 0; + if (typeof value === "string" && value) return value; + } + return void 0; +} +function dakarNowMs() { + return Number(process.hrtime.bigint() / 1000000n); +} function redactDakarDetail(detail, sensitiveValues = []) { let redacted = detail; for (const value of sensitiveValues) { @@ -2632,8 +2877,8 @@ function dakarDiagnosticRedactions(invocation, extraValues = []) { const inlineValue = inlineOption?.[2]; const assignmentValue = assignment?.[1]; if (inlineValue !== void 0) values.push(argument, inlineValue); - else if (assignmentValue !== void 0) values.push(argument, assignmentValue); - else values.push(argument); + else if (assignmentValue === void 0) values.push(argument); + else values.push(argument, assignmentValue); } return values; } @@ -2663,7 +2908,8 @@ function parseDakarDocument(stdout) { if (document.length > 64e3) return null; try { const parsed = JSON.parse(document); - return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + return parsed; } catch { return null; } @@ -2671,7 +2917,8 @@ function parseDakarDocument(stdout) { return null; } function mapDakarFinding(finding, sensitiveValues = []) { - const severity = DAKAR_SEVERITY_MAP[String(finding.severity || "").toLowerCase()] || "info"; + const sourceSeverity = String(finding.severity || "").toLowerCase(); + const severity = Object.hasOwn(DAKAR_SEVERITY_MAP, sourceSeverity) ? DAKAR_SEVERITY_MAP[sourceSeverity] || "info" : "info"; const filePath = redactDakarDetail(String(finding.path || ""), sensitiveValues).slice(0, 2e3); const title = redactDakarDetail(String(finding.title || ""), sensitiveValues); const detail = redactDakarDetail(String(finding.detail || ""), sensitiveValues); @@ -2682,10 +2929,10 @@ function mapDakarFinding(finding, sensitiveValues = []) { Evidence: ${evidence}`.slice(0, 2e3), suggestions: [] }; } function validateChangesRequestedFindings(raw) { - const findings = Array.isArray(raw) ? raw : null; - if (!findings || findings.length === 0) return { ok: false, detail: "Dakar returned changes-requested without any findings; refusing to treat a reviewer rejection as non-blocking" }; + if (!Array.isArray(raw) || raw.length === 0) return { ok: false, detail: "Dakar returned changes-requested without any findings; refusing to treat a reviewer rejection as non-blocking" }; + const findings = raw; for (const [index, finding] of findings.entries()) { - if (finding === null || typeof finding !== "object" || Array.isArray(finding)) return { ok: false, detail: boundedTail(`Dakar returned a malformed finding at index ${index}; expected an object`) }; + if (!finding || typeof finding !== "object" || Array.isArray(finding)) return { ok: false, detail: boundedTail(`Dakar returned a malformed finding at index ${index}; expected an object`) }; const item = finding; if (typeof item.severity !== "string" || !DAKAR_SEVERITIES.has(item.severity.toLowerCase())) return { ok: false, detail: boundedTail(`Dakar returned an invalid finding at index ${index}; unsupported severity`) }; const invalidField = DAKAR_REQUIRED_FINDING_FIELDS.find((field) => typeof item[field] !== "string"); @@ -2737,19 +2984,20 @@ function makeDakarAttempt(config) { const stateRoots = deps.dakarStateRoots || { create: () => fs.mkdtempSync(path.join(os.tmpdir(), "df12-dakar-state-")), remove: deps.removeDakarStateRoot || fs.rmSync }; const tracer = deps.tracer || NOOP_HOST_REVIEW_TRACER; const traceContext = deps.traceContext || { runId: "host-review" }; - const nowMs = () => Number(process.hrtime.bigint() / 1000000n); const trace = (name) => { - const startedMs = nowMs(); - return { startedMs, span: tracer.startSpan(name, { runId: boundedTail(traceContext.runId || "host-review", 120), ...traceContext.taskId ? { taskId: boundedTail(traceContext.taskId, 120) } : {} }, { reviewer: "dakar", attempt: deps.attempt || 1 }) }; + const startedMs = dakarNowMs(); + const spanContext = { runId: boundedTail(traceContext.runId || "host-review", 120) }; + if (traceContext.taskId) spanContext.taskId = boundedTail(traceContext.taskId, 120); + return { startedMs, span: tracer.startSpan(name, spanContext, { reviewer: "dakar", attempt: deps.attempt || 1 }) }; }; let stateRoot; const createSpan = trace("host-review.dakar.state-root.create"); try { stateRoot = stateRoots.create(); - createSpan.span.end({ reviewer: "dakar", attempt: deps.attempt || 1, outcome: "clean", errorCategory: "none", timeout: false, elapsedMs: Math.max(0, nowMs() - createSpan.startedMs) }); + createSpan.span.end({ reviewer: "dakar", attempt: deps.attempt || 1, outcome: "clean", errorCategory: "none", timeout: false, elapsedMs: Math.max(0, dakarNowMs() - createSpan.startedMs) }); } catch (error) { - createSpan.span.end({ reviewer: "dakar", attempt: deps.attempt || 1, outcome: "error", errorCategory: "execution", timeout: false, elapsedMs: Math.max(0, nowMs() - createSpan.startedMs) }); - return { outcome: "error", findings: [], detail: boundedTail(error?.message || String(error)), errorCategory: "execution" }; + createSpan.span.end({ reviewer: "dakar", attempt: deps.attempt || 1, outcome: "error", errorCategory: "execution", timeout: false, elapsedMs: Math.max(0, dakarNowMs() - createSpan.startedMs) }); + return { outcome: "error", findings: [], detail: boundedTail(dakarErrorProperty(error, "message") || String(error)), errorCategory: "execution" }; } const args2 = ["--repo-root", worktree, "--base", config.base, "--state-root", stateRoot, "--timeout", String(config.reviewTimeoutSeconds), ...config.dakarBudgetGbp > 0 ? ["--budget-gbp", String(config.dakarBudgetGbp)] : []]; const processSpan = trace("host-review.dakar.subprocess"); @@ -2758,22 +3006,22 @@ function makeDakarAttempt(config) { cwd: worktree, timeoutMs: config.reviewTimeoutSeconds * 1e3 + DAKAR_PARENT_TIMEOUT_GRACE_MS }), sensitiveValues); - processSpan.span.end({ reviewer: "dakar", attempt: deps.attempt || 1, outcome: review.outcome, errorCategory: review.errorCategory, timeout: review.errorCategory === "timeout", elapsedMs: Math.max(0, nowMs() - processSpan.startedMs) }); + processSpan.span.end({ reviewer: "dakar", attempt: deps.attempt || 1, outcome: review.outcome, errorCategory: review.errorCategory, timeout: review.errorCategory === "timeout", elapsedMs: Math.max(0, dakarNowMs() - processSpan.startedMs) }); return review; } catch (error) { - processSpan.span.end({ reviewer: "dakar", attempt: deps.attempt || 1, outcome: "error", errorCategory: "execution", timeout: false, elapsedMs: Math.max(0, nowMs() - processSpan.startedMs) }); - const message = boundedTail(redactDakarDetail(error?.message || String(error), sensitiveValues)); + processSpan.span.end({ reviewer: "dakar", attempt: deps.attempt || 1, outcome: "error", errorCategory: "execution", timeout: false, elapsedMs: Math.max(0, dakarNowMs() - processSpan.startedMs) }); + const message = boundedTail(redactDakarDetail(dakarErrorProperty(error, "message") || String(error), sensitiveValues)); const redacted = new Error(message); - redacted.name = error?.name || "Error"; + redacted.name = dakarErrorProperty(error, "name") || "Error"; throw redacted; } finally { const removeSpan = trace("host-review.dakar.state-root.remove"); try { stateRoots.remove(stateRoot, { recursive: true, force: true }); - removeSpan.span.end({ reviewer: "dakar", attempt: deps.attempt || 1, outcome: "clean", errorCategory: "none", timeout: false, elapsedMs: Math.max(0, nowMs() - removeSpan.startedMs) }); + removeSpan.span.end({ reviewer: "dakar", attempt: deps.attempt || 1, outcome: "clean", errorCategory: "none", timeout: false, elapsedMs: Math.max(0, dakarNowMs() - removeSpan.startedMs) }); } catch (error) { - removeSpan.span.end({ reviewer: "dakar", attempt: deps.attempt || 1, outcome: "error", errorCategory: "execution", timeout: false, elapsedMs: Math.max(0, nowMs() - removeSpan.startedMs) }); - log(`[Dakar] ${boundedTail(traceContext.taskId || "host-review", 120)} attempt ${deps.attempt || 1} could not remove temporary state root: ${boundedTail(error?.message || String(error), 500)}`); + removeSpan.span.end({ reviewer: "dakar", attempt: deps.attempt || 1, outcome: "error", errorCategory: "execution", timeout: false, elapsedMs: Math.max(0, dakarNowMs() - removeSpan.startedMs) }); + log(`[Dakar] ${boundedTail(traceContext.taskId || "host-review", 120)} attempt ${deps.attempt || 1} could not remove temporary state root: ${boundedTail(dakarErrorProperty(error, "message") || String(error), 500)}`); } } }; @@ -2781,15 +3029,17 @@ function makeDakarAttempt(config) { // src/workflows/df12-build-odw/host-gates.ts var GATE_CARRY_LIMIT = 16384; +function gateLogLabel(value) { + return String(value).replaceAll(/[^A-Za-z0-9._-]+/g, "-").replaceAll(/^-+|-+$/g, "").slice(0, 60); +} function codeSceneExecutable(command) { const tokens = tokenizeShellCommand(command); if (!tokens || tokens.hasUnquotedControlOperator) return null; return tokens.words[tokens.executableWordIndex]?.value || ""; } function hostGateLogPath(root, tag, roundLabel, index) { - const slug = (value) => String(value).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60); const path = process.getBuiltinModule("node:path"); - return path.join(root, `gate-${slug(tag)}-${slug(roundLabel)}-${index + 1}.out`); + return path.join(root, `gate-${gateLogLabel(tag)}-${gateLogLabel(roundLabel)}-${index + 1}.out`); } function productionHostGateDeps() { const fs = process.getBuiltinModule("node:fs"); @@ -2798,12 +3048,28 @@ function productionHostGateDeps() { const { spawn } = process.getBuiltinModule("node:child_process"); return { probeCodeScene: execFileStatus, - spawnGate: (command, cwd) => spawn("sh", ["-c", command], { cwd, detached: true, stdio: ["ignore", "pipe", "pipe"] }), + spawnGate: (command, cwd) => { + const child = spawn("sh", ["-c", command], { cwd, detached: true, stdio: ["ignore", "pipe", "pipe"] }); + const gateChild = { + on: (event, listener) => { + if (event === "close") child.on("close", listener); + else child.on("error", listener); + }, + kill: (signal) => { + child.kill(signal); + } + }; + if (child.pid !== void 0) gateChild.pid = child.pid; + gateChild.stdout = child.stdout; + gateChild.stderr = child.stderr; + return gateChild; + }, /** Prefer signalling the detached group so background descendants are reaped. */ terminateProcessGroup: (child, signal) => { - if (typeof child.pid === "number") { + const pid = child.pid; + if (pid !== void 0 && Number.isSafeInteger(pid)) { try { - process.kill(-child.pid, signal); + process.kill(-pid, signal); return; } catch { } @@ -2814,12 +3080,24 @@ function productionHostGateDeps() { openSecureLog: (logFile) => { const { O_WRONLY, O_CREAT, O_EXCL, O_NOFOLLOW } = fs.constants; const fd = fs.openSync(logFile, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 384); - return fs.createWriteStream(logFile, { fd, autoClose: true }); + const stream = fs.createWriteStream(logFile, { fd, autoClose: true }); + return { + write: (chunk) => stream.write(chunk), + end: (callback) => { + stream.end(callback); + }, + on: (event, listener) => { + if (event === "error") stream.on("error", listener); + else stream.on("drain", listener); + } + }; }, createLogRoot: () => fs.mkdtempSync(path.join(os.tmpdir(), "df12-gates-")), removeLogRoot: (root, options) => fs.rmSync(root, options), setTimer: (callback, delayMs) => setTimeout(callback, delayMs), - clearTimer: (timer) => clearTimeout(timer), + clearTimer: (timer) => { + timer[Symbol.dispose]?.(); + }, nowMs: () => Number(process.hrtime.bigint() / 1000000n) }; } @@ -2852,10 +3130,16 @@ function makeHostGates(config, metrics, injected = {}) { const baseContext = deps.traceContext || { runId: "host-review" }; function span(name, taskId, attributes) { const startedMs = deps.nowMs(); - return { handle: tracer.startSpan(name, { runId: boundedTail(baseContext.runId, 120) || "host-review", ...taskId ? { taskId: boundedTail(taskId, 120) } : {} }, { reviewer: deps.reviewer || "unknown", ...attributes }), startedMs }; + const context = { runId: boundedTail(baseContext.runId, 120) || "host-review" }; + if (taskId) context.taskId = boundedTail(taskId, 120); + return { handle: tracer.startSpan(name, context, { reviewer: deps.reviewer || "unknown", ...attributes }), startedMs }; + } + function gateSpanElapsedMs(active) { + const elapsedMs = deps.nowMs() - active.startedMs; + return Math.max(0, Math.trunc(elapsedMs)); } function endSpan(active, attributes) { - active.handle.end({ elapsedMs: Math.max(0, Math.trunc(deps.nowMs() - active.startedMs)), ...attributes }); + active.handle.end({ elapsedMs: gateSpanElapsedMs(active), ...attributes }); } function streamGate(command, cwd, logFile) { return new Promise((resolve) => { @@ -2948,7 +3232,9 @@ function makeHostGates(config, metrics, injected = {}) { child.stderr?.on("data", record); sigterm = deps.setTimer(() => terminate("gate process group did not close after SIGKILL"), config.commitGateTimeoutSeconds * 1e3); child.on("close", (code) => finish(code === 0 && !killed, streamFailure)); - child.on("error", (error) => finish(false, streamFailure || `spawn failed: ${boundedTail(error?.message || String(error), 500)}`)); + child.on("error", (error) => { + finish(false, streamFailure || `spawn failed: ${boundedTail(error?.message || String(error), 500)}`); + }); }); } async function runHostCommitGates2(worktree, tag, roundLabel) { @@ -2986,7 +3272,7 @@ ${outcome.tail}` }; return { clean: false, skipped: false, detail, logFile: "" }; } const missing = "__DF12_CODESCENE_BINARY_MISSING__"; - const probe = await deps.probeCodeScene("sh", ["-c", 'command -v "$1" >/dev/null 2>&1 || { printf "%s\\n" "$2"; exit 127; }', "sh", bin, missing], { cwd: worktree, timeoutMs: 1e4 }); + const probe = await deps.probeCodeScene("sh", ["-c", String.raw`command -v "$1" >/dev/null 2>&1 || { printf "%s\n" "$2"; exit 127; }`, "sh", bin, missing], { cwd: worktree, timeoutMs: 1e4 }); if (!probe.ok) { if (probe.stdout.trim() === missing) { metrics.codeScene.skipped += 1; @@ -2995,7 +3281,10 @@ ${outcome.tail}` }; return { clean: true, skipped: true, detail: `${bin} not on PATH`, logFile: "" }; } metrics.codeScene.probeFailures += 1; - const fault = [probe.message, probe.stderr, probe.signal ? `signal ${probe.signal}` : "", probe.killed ? "probe killed" : ""].map((part) => String(part || "").trim()).filter(Boolean).join("; "); + const fault = [probe.message, probe.stderr, probe.signal ? `signal ${probe.signal}` : "", probe.killed ? "probe killed" : ""].flatMap((part) => { + const text = String(part || "").trim(); + return text ? [text] : []; + }).join("; "); endSpan(active, { outcome: "error", errorCategory: probe.killed ? "timeout" : "execution", timeout: Boolean(probe.killed), retry: false }); return { clean: false, skipped: false, detail: `CodeScene availability probe for \`${redactedCommand}\` failed: ${boundedTail(fault) || "unknown probe failure"}`, logFile: "" }; } @@ -3020,7 +3309,42 @@ function coderabbitBlockingItems(findings) { async function hostSleepMinutes(minutes) { await new Promise((resolve) => setTimeout(resolve, minutes * 6e4)); } +function hostReviewSpanNowMs() { + return Number(process.hrtime.bigint() / 1000000n); +} +function hostReviewAttemptElapsedMs(startedMs) { + const elapsedMs = hostReviewSpanNowMs() - startedMs; + return Math.max(0, Math.trunc(elapsedMs)); +} +function hostReviewSinkElapsedMs(startedMs) { + const elapsedMs = hostReviewSpanNowMs() - startedMs; + return Math.max(0, elapsedMs); +} +function reviewTraceContext(override, fallback, label) { + const context = { + runId: boundedTail(override?.runId || fallback?.runId || "host-review", 120) || "host-review" + }; + const taskId = override?.taskId || fallback?.taskId || label; + if (taskId) context.taskId = boundedTail(taskId, 120); + return context; +} +function reviewSeverity(value) { + switch (value) { + case "critical": + case "major": + case "minor": + case "trivial": + case "info": + case "unknown": { + return value; + } + default: { + return "unknown"; + } + } +} function makeHostReview(config, composition = {}) { + const compatibility = config; const dakarCommandInvocation = dakarInvocationFromCommand(config.dakarCommand); const dakarInvocation = config.dakarInvocation === void 0 ? dakarCommandInvocation : validateDakarInvocation(config.dakarInvocation); if (!dakarCommandInvocation || !dakarInvocation) throw new Error(DAKAR_COMMAND_VALIDATION_ERROR); @@ -3038,8 +3362,8 @@ function makeHostReview(config, composition = {}) { let findingsSinkTail = Promise.resolve(); function reviewBackoffMinutes2(seed) { let hash = 5381; - for (const ch of String(seed)) hash = (hash * 33 ^ ch.codePointAt(0)) >>> 0; - const [low, high] = config.reviewBackoffMinutes ?? config.coderabbitBackoffMinutes ?? [45, 90]; + for (const ch of String(seed)) hash = (hash * 33 ^ (ch.codePointAt(0) ?? 0)) >>> 0; + const [low, high] = compatibility.reviewBackoffMinutes ?? compatibility.coderabbitBackoffMinutes ?? [45, 90]; return low + hash % (high - low + 1); } function recordDuration(elapsedMs) { @@ -3051,34 +3375,30 @@ function makeHostReview(config, composition = {}) { async function runHostReview2(worktree, label, deps = {}) { const exec = deps.exec || execFileStatus; const sleep = deps.sleep || hostSleepMinutes; - const nowMs = deps.nowMs || (() => Number(process.hrtime.bigint() / 1000000n)); + const nowMs = deps.nowMs || hostReviewSpanNowMs; const reviewer = config.reviewTool; const displayName = reviewerDisplayName(reviewer); const boundedLabel = boundedTail(label, 120); const startedMs = nowMs(); - const spanNowMs = () => Number(process.hrtime.bigint() / 1000000n); - const traceContext = { - runId: boundedTail(deps.traceContext?.runId || config.traceContext?.runId || "host-review", 120) || "host-review", - ...deps.traceContext?.taskId || config.traceContext?.taskId || boundedLabel ? { taskId: boundedTail(deps.traceContext?.taskId || config.traceContext?.taskId || boundedLabel, 120) } : {} - }; + const traceContext = reviewTraceContext(deps.traceContext, config.traceContext, boundedLabel); const operationSpan = (deps.tracer || config.tracer || NOOP_HOST_REVIEW_TRACER).startSpan("host-review.operation", traceContext, { reviewer, retry: false }); let terminalAttempt = 1; try { for (let attempt = 1; ; attempt++) { terminalAttempt = attempt; - const attempts = config.reviewAttempts ?? config.coderabbitAttempts ?? 3; + const attempts = compatibility.reviewAttempts ?? compatibility.coderabbitAttempts ?? 3; log(`[${boundedLabel}] ${displayName} host review attempt ${attempt} of ${attempts}`); - const attemptStartedMs = spanNowMs(); + const attemptStartedMs = hostReviewSpanNowMs(); const attemptSpan = (deps.tracer || config.tracer || NOOP_HOST_REVIEW_TRACER).startSpan("host-review.attempt", traceContext, { reviewer, attempt, retry: attempt > 1 }); const adapterDeps = { ...deps, attempt, tracer: deps.tracer || config.tracer || NOOP_HOST_REVIEW_TRACER, traceContext: { ...traceContext, taskId: boundedLabel } }; let single; try { single = reviewer === "dakar" ? await dakarAttempt(worktree, exec, adapterDeps) : await coderabbitAttempt(worktree, exec, adapterDeps); } catch (error) { - attemptSpan.end({ reviewer, attempt, outcome: "error", errorCategory: "execution", retry: false, timeout: false, elapsedMs: Math.max(0, Math.trunc(spanNowMs() - attemptStartedMs)) }); + attemptSpan.end({ reviewer, attempt, outcome: "error", errorCategory: "execution", retry: false, timeout: false, elapsedMs: hostReviewAttemptElapsedMs(attemptStartedMs) }); throw error; } - attemptSpan.end({ reviewer, attempt, outcome: single.outcome, errorCategory: single.errorCategory, retry: single.outcome === "rate-limited" && attempt < attempts, timeout: single.errorCategory === "timeout", elapsedMs: Math.max(0, Math.trunc(spanNowMs() - attemptStartedMs)) }); + attemptSpan.end({ reviewer, attempt, outcome: single.outcome, errorCategory: single.errorCategory, retry: single.outcome === "rate-limited" && attempt < attempts, timeout: single.errorCategory === "timeout", elapsedMs: hostReviewAttemptElapsedMs(attemptStartedMs) }); if (single.outcome === "rate-limited" && attempt < attempts) { const minutes = reviewBackoffMinutes2(`${boundedLabel}#${attempt}`); log(`[${boundedLabel}] ${displayName} rate limited/deferred; host backs off ${minutes} minutes before attempt ${attempt + 1} of ${attempts} (wall-clock only, no agent tokens)`); @@ -3112,15 +3432,12 @@ function makeHostReview(config, composition = {}) { for (const finding of review.findings) { hostReviewMetrics.findings += 1; const rawSeverity = String(finding.severity || "unknown").toLowerCase(); - const severity = Object.hasOwn(hostReviewMetrics.bySeverity, rawSeverity) ? rawSeverity : "unknown"; + const severity = reviewSeverity(rawSeverity); hostReviewMetrics.bySeverity[severity] += 1; } - const findingsFile = config.reviewFindingsFile ?? config.coderabbitFindingsFile ?? ""; - if (!findingsFile || !review.findings.length) return; - const traceContext = { - runId: boundedTail(deps.traceContext?.runId || config.traceContext?.runId || "host-review", 120) || "host-review", - ...deps.traceContext?.taskId || config.traceContext?.taskId || label ? { taskId: boundedTail(deps.traceContext?.taskId || config.traceContext?.taskId || label, 120) } : {} - }; + const findingsFile = compatibility.reviewFindingsFile ?? compatibility.coderabbitFindingsFile ?? ""; + if (!findingsFile || review.findings.length === 0) return; + const traceContext = reviewTraceContext(deps.traceContext, config.traceContext, label); const sinkStartedMs = Number(process.hrtime.bigint() / 1000000n); const sinkSpan = (config.tracer || NOOP_HOST_REVIEW_TRACER).startSpan("host-review.findings-sink", traceContext, { reviewer: review.reviewer }); const append = async () => { @@ -3137,16 +3454,18 @@ function makeHostReview(config, composition = {}) { }); await appendFile(findingsFile, `${lines.join("\n")} `); - sinkSpan.end({ reviewer: review.reviewer, outcome: "clean", errorCategory: "none", timeout: false, elapsedMs: Math.max(0, Number(process.hrtime.bigint() / 1000000n) - sinkStartedMs) }); + sinkSpan.end({ reviewer: review.reviewer, outcome: "clean", errorCategory: "none", timeout: false, elapsedMs: hostReviewSinkElapsedMs(sinkStartedMs) }); } catch (error) { hostReviewMetrics.sinkFailures += 1; hostReviewMetrics.sinkError = boundedTail(error?.message || String(error), 500); log(`[${boundedTail(label, 120)}] could not append ${reviewerDisplayName(review.reviewer)} host-review findings to ${findingsFile}: ${hostReviewMetrics.sinkError}`); - sinkSpan.end({ reviewer: review.reviewer, outcome: "error", errorCategory: "execution", timeout: false, elapsedMs: Math.max(0, Number(process.hrtime.bigint() / 1000000n) - sinkStartedMs) }); + sinkSpan.end({ reviewer: review.reviewer, outcome: "error", errorCategory: "execution", timeout: false, elapsedMs: hostReviewSinkElapsedMs(sinkStartedMs) }); } }; const pending = findingsSinkTail.then(append, append); - findingsSinkTail = pending.then(() => void 0, () => void 0); + findingsSinkTail = pending.then(() => { + }, () => { + }); await pending; } function metrics() { @@ -3175,6 +3494,28 @@ function makeHostReview(config, composition = {}) { } // src/workflows/df12-build-odw/run-task.ts +function thrownMessage(error) { + const message = error != null && (typeof error === "object" || typeof error === "function") && "message" in error ? error.message : void 0; + return message || String(error); +} +function isExtraResultFields(value) { + return value != null && typeof value === "object"; +} +function extraResultFields(value) { + return isExtraResultFields(value) ? value : {}; +} +function routeTaskAgentOptions(router, options, schema) { + return { ...router({ ...options, schema }), schema }; +} +function withHostGateEvidence(record, gates) { + if (!gates) return record; + const { fix, ...review } = record; + return { ...review, hostGates: gates.results, fix }; +} +function withDeferredHostReviews(result, deferrals) { + if (deferrals.length === 0) return result; + return { ...result, deferredHostReviews: deferrals }; +} function summarizeReviewVerdict(review) { if (!review) return null; return { @@ -3202,6 +3543,37 @@ function integrationIncomplete(integration) { function integrationHaltDetail(integration) { return integration?.conflicts || integration?.summary || INTEGRATION_INCOMPLETE_DETAIL; } +async function finishImplementationStage(task, worktree, plan, impl, extra) { + const tag = task.id; + const committed = await verifyWorktreeCommitted(worktree); + if (!committed.ok) { + return { + kind: "failure", + fail: { + id: tag, + status: "failed", + stage: "implement", + detail: `implementation returned ok but left uncommitted state in the worktree (${committed.detail}); every work item must be committed before returning`, + openIssues: impl.openIssues || [], + worktree, + proposals: [], + ...extra + } + }; + } + if (plan.execplanPath) { + const contained = execplanRelPath(worktree, plan.execplanPath); + if (contained.ok) { + const planState = await readExecplanState({ worktreePath: worktree, execplanPath: contained.relPath }); + if (planState.status !== "complete") { + log(`[task ${tag}] implementation returned ok but the committed ExecPlan status is '${planState.status}' (expected COMPLETE)${planState.error ? `: ${planState.error}` : ""}`); + } + } else { + log(`[task ${tag}] skipping the post-implementation plan-status check: ${contained.detail}`); + } + } + return { kind: "impl", impl }; +} function makeTaskPipeline(deps) { const { faultMetrics: faultMetrics2, @@ -3246,16 +3618,15 @@ function makeTaskPipeline(deps) { const hostReviewerDisplayName = reviewerDisplayName(HOST_REVIEWER); async function runPlanDesignLoop2(task, worktree, opts = {}) { const tag = task.id; - const extra = opts.extra || {}; + const extra = extraResultFields(opts.extra); let plan = null; let designVerdict = null; for (let round = 1; round <= MAX_DESIGN_ROUNDS2; round++) { phase("Plan"); - plan = await planningLock2(() => withInfraRetry2(() => agent(planPrompt2(task, worktree, designVerdict, round, opts), planAgentOptions2({ + plan = await planningLock2(() => withInfraRetry2(() => agent(planPrompt2(task, worktree, designVerdict, round, opts), routeTaskAgentOptions(planAgentOptions2, { phase: "Plan", - label: `plan:${tag} r${round}`, - schema: PLAN_SCHEMA - })), `plan:${tag} r${round}`)); + label: `plan:${tag} r${round}` + }, PLAN_SCHEMA)), `plan:${tag} r${round}`)); if (!plan) return { kind: "failure", fail: { id: tag, status: "failed", stage: "plan", detail: "planner returned nothing", worktree, proposals: [], ...extra } }; const contained = execplanRelPath(worktree, plan.execplanPath); if (!contained.ok) { @@ -3302,11 +3673,11 @@ function makeTaskPipeline(deps) { continue; } phase("Design Review"); - designVerdict = await planningLock2(() => withInfraRetry2(() => agent(designReviewPrompt2(task, worktree, plan, round), reviewAgentOptions2({ + const reviewedPlan = plan; + designVerdict = await planningLock2(() => withInfraRetry2(() => agent(designReviewPrompt2(task, worktree, reviewedPlan, round), routeTaskAgentOptions(reviewAgentOptions2, { phase: "Design Review", - label: `design-review:${tag} r${round}`, - schema: DESIGN_VERDICT_SCHEMA - })), `design-review:${tag} r${round}`)); + label: `design-review:${tag} r${round}` + }, DESIGN_VERDICT_SCHEMA)), `design-review:${tag} r${round}`)); if (designVerdict?.satisfied) { log(`[task ${tag}] design approved in round ${round}`); const approved = await commitExecplanApproval(worktree, plan.execplanPath, tag); @@ -3344,7 +3715,7 @@ function makeTaskPipeline(deps) { } async function dispatchFixAndVerify(task, worktree, plan, blocking, label, round) { phase("Implement"); - const report = await buildLock2(() => withInfraRetry2(() => agent(fixPrompt2(task, worktree, plan, blocking, round), buildAgentOptions2({ phase: "Implement", label, schema: FIX_SCHEMA })), label)); + const report = await buildLock2(() => withInfraRetry2(() => agent(fixPrompt2(task, worktree, plan, blocking, round), routeTaskAgentOptions(buildAgentOptions2, { phase: "Implement", label }, FIX_SCHEMA)), label)); const committed = await verifyWorktreeCommitted(worktree); return { report, dirtyDetail: committed.ok ? null : committed.detail }; } @@ -3394,11 +3765,13 @@ function makeTaskPipeline(deps) { return { fail: { id: tag, status: "fatal-auth", stage: "auth", detail: `${hostReviewerDisplayName} host review is not authenticated: ${review.detail}`, worktree, proposals: [], ...extra } }; } if (review.outcome === "rate-limited" || review.outcome === "error") { - return { fail: { id: tag, status: "halted", stage: "code-review", detail: `${hostReviewerDisplayName} between-item review could not complete for ${itemLabel} (${review.outcome} after ${review.attempts} attempt(s), ${review.errorCategory}): ${review.detail}; the work is committed but unreviewed \u2014 resolve the host-review fault and relaunch with resumeMode: "continue"`, worktree, proposals: [], ...extra } }; + const blocking2 = reviewBlockingItems(hostReviewerDisplayName, review.findings); + const findingsDetail = blocking2.length > 0 ? `; blocking findings: ${blocking2.join("; ")}` : ""; + return { fail: { id: tag, status: "halted", stage: "code-review", detail: `${hostReviewerDisplayName} between-item review could not complete for ${itemLabel} (${review.outcome} after ${review.attempts} attempt(s), ${review.errorCategory}): ${review.detail}${findingsDetail}; the work is committed but unreviewed \u2014 resolve the host-review fault and relaunch with resumeMode: "continue"`, worktree, proposals: [], ...extra } }; } const blocking = reviewBlockingItems(hostReviewerDisplayName, review.findings); log(`[task ${tag}] between-item ${hostReviewerDisplayName} ${itemLabel} attempt ${attempt}: ${review.findings.length} finding(s), ${blocking.length} blocking`); - if (!blocking.length) return { ok: true, hostReviewRuns: runs }; + if (blocking.length === 0) return { ok: true, hostReviewRuns: runs }; if (attempt === MAX_REVIEW_ROUNDS2) { return { fail: { id: tag, status: "failed", stage: "code-review", detail: `${hostReviewerDisplayName} between-item review left blocking finding(s) unresolved after ${MAX_REVIEW_ROUNDS2} fix attempt(s) on ${itemLabel}: ${blocking.join("; ")}`, worktree, proposals: [], ...extra } }; } @@ -3411,7 +3784,7 @@ function makeTaskPipeline(deps) { } async function runWorkItemBuildLoop2(task, worktree, plan, opts = {}) { const tag = task.id; - const extra = opts.extra || {}; + const extra = extraResultFields(opts.extra); const fail = (detail, openIssues2 = []) => ({ kind: "failure", fail: { id: tag, status: "failed", stage: "implement", detail, openIssues: openIssues2, worktree, proposals: [], ...extra } }); const contained = execplanRelPath(worktree, plan.execplanPath); if (!contained.ok) return fail(contained.detail); @@ -3419,7 +3792,7 @@ function makeTaskPipeline(deps) { const initial = await readExecplanState(planRef); if (initial.status === "unreadable") return fail(`could not read the committed ExecPlan: ${initial.error}`); if (initial.status === "missing") return fail(`the ExecPlan disappeared before the build: ${contained.relPath}`); - if (!(initial.items || []).length) return null; + if (initial.items.length === 0) return null; const commits = []; const openIssues = []; let hostReviewRuns = 0; @@ -3430,14 +3803,13 @@ function makeTaskPipeline(deps) { const before = await readExecplanState(planRef); if (before.status === "unreadable") return fail(`could not read the committed ExecPlan: ${before.error}`, openIssues); if (before.status === "missing") return fail(`the committed ExecPlan disappeared mid-build: ${contained.relPath}`, openIssues); - const item = (before.items || []).find((entry) => !entry.ticked); + const item = before.items.find((entry) => !entry.ticked); if (!item) break; const label = `implement:${tag} wi${round}`; - const impl = await buildLock2(() => withInfraRetry2(() => agent(implementWorkItemPrompt2(task, worktree, plan, item, { ...opts, noProgressNote }), buildAgentOptions2({ + const impl = await buildLock2(() => withInfraRetry2(() => agent(implementWorkItemPrompt2(task, worktree, plan, item, { ...opts, noProgressNote }), routeTaskAgentOptions(buildAgentOptions2, { phase: "Implement", - label, - schema: IMPL_SCHEMA - })), label)); + label + }, IMPL_SCHEMA)), label)); lastImpl = impl; const authDetail = implementationAuthFailureDetail(impl); if (authDetail) { @@ -3482,8 +3854,8 @@ function makeTaskPipeline(deps) { const final = await readExecplanState(planRef); if (final.status === "unreadable") return fail(`could not read the committed ExecPlan after the build: ${final.error}`, openIssues); if (final.status === "missing") return fail(`the committed ExecPlan is absent after the build: ${contained.relPath}`, openIssues); - const remaining = (final.items || []).filter((entry) => !entry.ticked); - if (remaining.length) { + const remaining = final.items.filter((entry) => !entry.ticked); + if (remaining.length > 0) { return fail(`the work-item round cap (maxWorkItemRounds=${MAX_WORK_ITEM_ROUNDS2}) was reached with ${remaining.length} Progress item(s) still unticked; the first is: ${remaining[0]?.text ?? "unknown"}`, openIssues); } return { @@ -3503,7 +3875,7 @@ function makeTaskPipeline(deps) { } async function runImplementationStage2(task, worktree, plan, opts = {}) { const tag = task.id; - const extra = opts.extra || {}; + const extra = extraResultFields(opts.extra); phase("Implement"); if (PER_WORK_ITEM_BUILD2) { const itemised = await runWorkItemBuildLoop2(task, worktree, plan, opts); @@ -3513,11 +3885,10 @@ function makeTaskPipeline(deps) { } log(`[task ${tag}] the committed ExecPlan has no Progress checklist; falling back to the single-turn build`); } - const impl = await buildLock2(() => withInfraRetry2(() => agent(implementPrompt2(task, worktree, plan, opts), buildAgentOptions2({ + const impl = await buildLock2(() => withInfraRetry2(() => agent(implementPrompt2(task, worktree, plan, opts), routeTaskAgentOptions(buildAgentOptions2, { phase: "Implement", - label: `implement:${tag}`, - schema: IMPL_SCHEMA - })), `implement:${tag}`)); + label: `implement:${tag}` + }, IMPL_SCHEMA)), `implement:${tag}`)); const authDetail = implementationAuthFailureDetail(impl); if (authDetail) { faultMetrics2.authFaults += 1; @@ -3540,48 +3911,17 @@ function makeTaskPipeline(deps) { } return finishImplementationStage(task, worktree, plan, impl, extra); } - async function finishImplementationStage(task, worktree, plan, impl, extra) { - const tag = task.id; - const committed = await verifyWorktreeCommitted(worktree); - if (!committed.ok) { - return { - kind: "failure", - fail: { - id: tag, - status: "failed", - stage: "implement", - detail: `implementation returned ok but left uncommitted state in the worktree (${committed.detail}); every work item must be committed before returning`, - openIssues: impl?.openIssues || [], - worktree, - proposals: [], - ...extra - } - }; - } - if (plan?.execplanPath) { - const contained = execplanRelPath(worktree, plan.execplanPath); - if (!contained.ok) { - log(`[task ${tag}] skipping the post-implementation plan-status check: ${contained.detail}`); - } else { - const planState = await readExecplanState({ worktreePath: worktree, execplanPath: contained.relPath }); - if (planState.status !== "complete") { - log(`[task ${tag}] implementation returned ok but the committed ExecPlan status is '${planState.status}' (expected COMPLETE)${planState.error ? `: ${planState.error}` : ""}`); - } - } - } - return { kind: "impl", impl }; - } async function integrateTask(task, mergeLock2, context) { const { worktree, proposals, kindExtra, impl } = context; const tag = task.id; const doIntegrate = () => { phase("Integrate"); - return buildLock2(() => agent(integratePrompt2(task, worktree, impl), buildAgentOptions2({ phase: "Integrate", label: `integrate:${tag}`, schema: INTEGRATE_SCHEMA }))); + return buildLock2(() => agent(integratePrompt2(task, worktree, impl), routeTaskAgentOptions(buildAgentOptions2, { phase: "Integrate", label: `integrate:${tag}` }, INTEGRATE_SCHEMA))); }; try { return { integration: mergeLock2 ? await mergeLock2(doIntegrate) : await doIntegrate() }; } catch (error) { - const message = error && error.message || String(error); + const message = thrownMessage(error); if (!infrastructureFailureDetail(message)) throw error; faultMetrics2.infraFaults += 1; return { @@ -3605,7 +3945,7 @@ function makeTaskPipeline(deps) { let reviewsPass = false; const deferredHostReviews = []; for (let round = 1; round <= MAX_REVIEW_ROUNDS2; round++) { - let hostGates = null; + let hostGates; if (HOST_COMMIT_GATES2) { hostGates = await hostGateLock2(() => runHostCommitGates2(worktree, tag, `r${round}`)); if (!hostGates.green) { @@ -3628,7 +3968,7 @@ function makeTaskPipeline(deps) { if (!cs.clean) { log(`[task ${tag}] CodeScene check red in round ${round}`); const csBlocking = [`CODESCENE RED: ${cs.detail} Clear these code-health regressions by refactoring, or \u2014 only where further refinement would be deleterious \u2014 suppress the specific smell with a justified @codescene(disable:"...") comment, then re-run the check to green and commit.`]; - reviewRounds.push({ round, codeReview: null, expertReview: null, blocking: csBlocking, ...hostGates ? { hostGates: hostGates.results } : {}, fix: null }); + reviewRounds.push(withHostGateEvidence({ round, codeReview: null, expertReview: null, blocking: csBlocking, fix: null }, hostGates)); if (round === MAX_REVIEW_ROUNDS2) break; const csFix = await dispatchFixAndVerify(task, worktree, plan, csBlocking, `fix:${tag} cs r${round}`, round); const csRound = reviewRounds.at(-1); @@ -3647,15 +3987,15 @@ function makeTaskPipeline(deps) { faultMetrics2.authFaults += 1; return { id: tag, status: "fatal-auth", stage: "review", detail: `${hostReviewerDisplayName} host review is not authenticated: ${hostReview2.detail}`, reviewRounds, worktree, proposals, ...kindExtra }; } - if (hostReview2.outcome === "rate-limited" || hostReview2.outcome === "error") { + const hostReviewBlocking = reviewBlockingItems(hostReviewerDisplayName, hostReview2.findings); + if ((hostReview2.outcome === "rate-limited" || hostReview2.outcome === "error") && hostReviewBlocking.length === 0) { const deferral = hostReviewDeferral(hostReview2); if (deferral) deferredHostReviews.push(deferral); log(`[task ${tag}] ${hostReviewerDisplayName} host review deferred in round ${round}: ${hostReview2.outcome} (${hostReview2.errorCategory}: ${hostReview2.detail})`); } else { - const hostReviewBlocking = reviewBlockingItems(hostReviewerDisplayName, hostReview2.findings); log(`[task ${tag}] ${hostReviewerDisplayName} host review round ${round}: ${hostReview2.findings.length} finding(s), ${hostReviewBlocking.length} blocking`); - if (hostReviewBlocking.length) { - reviewRounds.push({ round, codeReview: null, expertReview: null, blocking: hostReviewBlocking, ...hostGates ? { hostGates: hostGates.results } : {}, fix: null }); + if (hostReviewBlocking.length > 0) { + reviewRounds.push(withHostGateEvidence({ round, codeReview: null, expertReview: null, blocking: hostReviewBlocking, fix: null }, hostGates)); if (round === MAX_REVIEW_ROUNDS2) break; const hostReviewFix = await dispatchFixAndVerify(task, worktree, plan, hostReviewBlocking, `fix:${tag} r${round}`, round); const hostReviewRound = reviewRounds.at(-1); @@ -3669,8 +4009,8 @@ function makeTaskPipeline(deps) { } } const reviewInfraFaults = []; - const runReviewAgent = (promptText, reviewPhase, label) => () => withInfraRetry2(() => agent(promptText, reviewAgentOptions2({ phase: reviewPhase, label, schema: REVIEW_SCHEMA })), label).catch((error) => { - const message = error && error.message || String(error); + const runReviewAgent = (promptText, reviewPhase, label) => () => withInfraRetry2(() => agent(promptText, routeTaskAgentOptions(reviewAgentOptions2, { phase: reviewPhase, label }, REVIEW_SCHEMA)), label).catch((error) => { + const message = thrownMessage(error); if (!infrastructureFailureDetail(message)) throw error; reviewInfraFaults.push(`${label}: ${message}`); return null; @@ -3683,12 +4023,12 @@ function makeTaskPipeline(deps) { if (r?.proposedRoadmapItems?.length) proposals.push(...r.proposedRoadmapItems.map((p) => ({ ...p, source: `review:${tag}` }))); } if (!codeReview || !expertReview) { - const missing = [ - !codeReview ? "code review" : null, - !expertReview ? "expert review" : null - ].filter(Boolean).join(" and "); + const missingReviews = []; + if (!codeReview) missingReviews.push("code review"); + if (!expertReview) missingReviews.push("expert review"); + const missing = missingReviews.join(" and "); reviewRounds.push({ round, codeReview: summarizeReviewVerdict(codeReview), expertReview: summarizeReviewVerdict(expertReview), blocking: [], fix: null }); - if (reviewInfraFaults.length) { + if (reviewInfraFaults.length > 0) { faultMetrics2.infraFaults += 1; return { id: tag, @@ -3716,9 +4056,9 @@ function makeTaskPipeline(deps) { ...codeReview.blocking || [], ...expertReview.blocking || [] ]; - const roundRecord = { round, codeReview: summarizeReviewVerdict(codeReview), expertReview: summarizeReviewVerdict(expertReview), blocking, ...hostGates ? { hostGates: hostGates.results } : {}, fix: null }; + const roundRecord = withHostGateEvidence({ round, codeReview: summarizeReviewVerdict(codeReview), expertReview: summarizeReviewVerdict(expertReview), blocking, fix: null }, hostGates); reviewRounds.push(roundRecord); - if (blocking.length === 0 && codeReview?.verdict === "pass" && expertReview?.verdict === "pass") { + if (blocking.length === 0 && codeReview.verdict === "pass" && expertReview.verdict === "pass") { reviewsPass = true; log(`[task ${tag}] dual review passed in round ${round}`); break; @@ -3732,7 +4072,7 @@ function makeTaskPipeline(deps) { } } if (!reviewsPass) { - const lastRound = reviewRounds[reviewRounds.length - 1]; + const lastRound = reviewRounds.at(-1); const finalBlocking = (lastRound?.blocking || []).slice(0, 6).join("; "); return { id: tag, @@ -3754,9 +4094,9 @@ function makeTaskPipeline(deps) { return { id: tag, status: "halted", stage: "integrate", detail: integrationHaltDetail(integration), worktree, proposals, ...kindExtra }; } } else { - return { id: tag, status: "manual-merge-ready", plan, impl, integration, worktree, proposals, ...deferredHostReviews.length ? { deferredHostReviews } : {}, ...kindExtra }; + return { ...withDeferredHostReviews({ id: tag, status: "manual-merge-ready", plan, impl, integration, worktree, proposals }, deferredHostReviews), ...kindExtra }; } - return { id: tag, status: "done", plan, impl, integration, worktree, proposals, ...deferredHostReviews.length ? { deferredHostReviews } : {}, ...kindExtra }; + return { ...withDeferredHostReviews({ id: tag, status: "done", plan, impl, integration, worktree, proposals }, deferredHostReviews), ...kindExtra }; } async function runTask2(task, mergeLock2) { const tag = `${task.id}`; @@ -3792,7 +4132,7 @@ function makeTaskPipeline(deps) { } if (task.isAddendum) { phase("Implement"); - const impl2 = await buildLock2(() => withInfraRetry2(() => agent(implementAddendumPrompt2(task, worktree), buildAgentOptions2({ phase: "Implement", label: `addendum:${tag}`, schema: IMPL_SCHEMA })), `addendum:${tag}`)); + const impl2 = await buildLock2(() => withInfraRetry2(() => agent(implementAddendumPrompt2(task, worktree), routeTaskAgentOptions(buildAgentOptions2, { phase: "Implement", label: `addendum:${tag}` }, IMPL_SCHEMA)), `addendum:${tag}`)); const authDetail = implementationAuthFailureDetail(impl2); if (authDetail) { faultMetrics2.authFaults += 1; @@ -3810,12 +4150,12 @@ function makeTaskPipeline(deps) { const openIssues = impl2?.openIssues || []; const onlyDeferredReviewIssues = hasOnlyDeferredReviewIssues(openIssues); if (addendumImplementationNeedsManualMerge(impl2)) { - const deferredEvidence = openIssues.length ? ` Outstanding deferred review evidence: ${openIssues.join("; ")}` : ""; + const deferredEvidence = openIssues.length > 0 ? ` Outstanding deferred review evidence: ${openIssues.join("; ")}` : ""; return { id: tag, status: "manual-merge-ready", stage: "addendum", - detail: `addendum implementation reported completed work and green gates but did not set ok=true${openIssues.length ? " and left only deferred/recoverable review issues open" : " and no open issues"}; branch left for operator verification before integration.${deferredEvidence}`, + detail: `addendum implementation reported completed work and green gates but did not set ok=true${openIssues.length > 0 ? " and left only deferred/recoverable review issues open" : " and no open issues"}; branch left for operator verification before integration.${deferredEvidence}`, openIssues, impl: impl2, worktree, @@ -3853,7 +4193,7 @@ function makeTaskPipeline(deps) { return { id: tag, status: "fatal-auth", stage: "auth", detail: `${hostReviewerDisplayName} host review is not authenticated: ${hostReview2.detail}`, worktree, proposals, kind: "addendum" }; } const blockingFindings = reviewBlockingItems(hostReviewerDisplayName, hostReview2.findings); - if (blockingFindings.length) { + if (blockingFindings.length > 0) { return await attachAssessment2(task, wt, { id: tag, status: "halted", stage: "addendum-review", detail: `${hostReviewerDisplayName} host review found blocking issue(s): ${blockingFindings.join("; ")}`, impl: impl2, worktree, proposals, kind: "addendum" }); } const deferredReview = hostReviewDeferral(hostReview2); @@ -3866,7 +4206,7 @@ function makeTaskPipeline(deps) { const needsFallbackReview = onlyDeferredReviewIssues || addendumDeferredReviews.length > 0; if (needsFallbackReview) { phase("Code Review"); - addendumReview = await withInfraRetry2(() => agent(addendumReviewPrompt2(task, worktree, impl2), reviewAgentOptions2({ phase: "Code Review", label: `addendum-review:${tag}`, schema: REVIEW_SCHEMA })), `addendum-review:${tag}`); + addendumReview = await withInfraRetry2(() => agent(addendumReviewPrompt2(task, worktree, impl2), routeTaskAgentOptions(reviewAgentOptions2, { phase: "Code Review", label: `addendum-review:${tag}` }, REVIEW_SCHEMA)), `addendum-review:${tag}`); if (addendumReview?.proposedRoadmapItems?.length) { proposals.push(...addendumReview.proposedRoadmapItems.map((p) => ({ ...p, source: `review:${tag}` }))); } @@ -3885,9 +4225,9 @@ function makeTaskPipeline(deps) { return await attachAssessment2(task, wt, { id: tag, status: "halted", stage: "integrate", detail: integrationHaltDetail(integration), worktree, proposals, kind: "addendum" }); } } else { - return { id: tag, status: "manual-merge-ready", impl: impl2, addendumReview, integration, worktree, proposals, ...addendumDeferredReviews.length ? { deferredHostReviews: addendumDeferredReviews } : {}, kind: "addendum" }; + return { ...withDeferredHostReviews({ id: tag, status: "manual-merge-ready", impl: impl2, addendumReview, integration, worktree, proposals }, addendumDeferredReviews), kind: "addendum" }; } - return { id: tag, status: "done", impl: impl2, addendumReview, integration, worktree, proposals, ...addendumDeferredReviews.length ? { deferredHostReviews: addendumDeferredReviews } : {}, kind: "addendum" }; + return { ...withDeferredHostReviews({ id: tag, status: "done", impl: impl2, addendumReview, integration, worktree, proposals }, addendumDeferredReviews), kind: "addendum" }; } const planned = await runPlanDesignLoop2(task, worktree); if (planned.kind === "failure") return await attachAssessment2(task, wt, planned.fail); @@ -3903,7 +4243,7 @@ function makeTaskPipeline(deps) { } return outcome; } catch (error) { - const detail = `unhandled agent error: ${error && error.message || String(error)}`; + const detail = `unhandled agent error: ${thrownMessage(error)}`; const result = resultFromUnhandledAgentError(tag, detail, { worktree }, faultMetrics2); return await attachAssessment2(task, wt, result); } @@ -3911,6 +4251,21 @@ function makeTaskPipeline(deps) { return { runPlanDesignLoop: runPlanDesignLoop2, runWorkItemBuildLoop: runWorkItemBuildLoop2, runImplementationStage: runImplementationStage2, runDualReviewAndIntegration: runDualReviewAndIntegration2, runTask: runTask2 }; } +// src/workflows/df12-build-odw/task-outcome.ts +function isOutcomeRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} +function checkedTaskOutcome(result) { + const { integration, kind, proposals, assessment, assessmentError, salvage } = result; + const validIntegration = integration === void 0 || integration === null || isOutcomeRecord(integration) && (integration.pushed === void 0 || typeof integration.pushed === "boolean"); + const validProposals = proposals === void 0 || Array.isArray(proposals) && proposals.every((item) => isOutcomeRecord(item)); + const validSalvage = salvage === void 0 || isOutcomeRecord(salvage) && typeof salvage.classification === "string" && typeof salvage.sha === "string" && typeof salvage.detail === "string" && Array.isArray(salvage.committed) && salvage.committed.every((item) => typeof item === "string") && Array.isArray(salvage.skipped) && salvage.skipped.every((item) => isOutcomeRecord(item) && typeof item.path === "string" && typeof item.reason === "string"); + if (!validIntegration || !validProposals || !validSalvage || kind !== void 0 && typeof kind !== "string" || assessment !== void 0 && !isOutcomeRecord(assessment) || assessmentError !== void 0 && typeof assessmentError !== "string") { + throw new Error("Invalid task outcome"); + } + return result; +} + // src/workflows/df12-build-odw/main.ts var CONFIG = makeConfig(args); var { @@ -3984,7 +4339,7 @@ if (PROJECT_ROOT !== process.cwd()) { try { projectRootStat = fs.statSync(PROJECT_ROOT); } catch (error) { - throw new Error(`Configured projectRoot is not accessible: ${PROJECT_ROOT} (${error && error.message || String(error)})`); + throw new Error(`Configured projectRoot is not accessible: ${PROJECT_ROOT} (${faultThrownMessage(error)})`); } if (!projectRootStat.isDirectory()) { throw new Error(`Configured projectRoot is not a directory: ${PROJECT_ROOT}`); @@ -4132,10 +4487,12 @@ async function createWorktree(task) { const setupCommand = `git worktree add -b ${branch} ${worktreePath} origin/${BASE}`; try { await execFileText("git", ["fetch", "origin", BASE]); - const baseSha = (await execFileText("git", ["rev-parse", `origin/${BASE}`])).trim(); + const baseShaOutput = await execFileText("git", ["rev-parse", `origin/${BASE}`]); + const baseSha = baseShaOutput.trim(); await fs.mkdir(path.dirname(worktreePath), { recursive: true }); await execFileText("git", ["worktree", "add", "-b", branch, worktreePath, `origin/${BASE}`]); - const worktreeSha = (await execFileText("git", ["-C", worktreePath, "rev-parse", "HEAD"])).trim(); + const worktreeShaOutput = await execFileText("git", ["-C", worktreePath, "rev-parse", "HEAD"]); + const worktreeSha = worktreeShaOutput.trim(); if (worktreeSha !== baseSha) { return { ok: false, @@ -4201,30 +4558,30 @@ async function executeResume(task, resume, mergeLock2) { try { let plan; let impl; - if (stage === "plan") { - const planned = await runPlanDesignLoop(task, worktree, { resume: true, extra }); - if (planned.kind === "failure") return planned.fail; - plan = planned.plan; - } else if (stage === "implement") { - plan = { - execplanPath: enriched.execplanPath, - workItems: [], - summary: "Resumed from the committed ExecPlan on the surviving branch." - }; - } if (stage === "plan" || stage === "implement") { + if (stage === "plan") { + const planned = await runPlanDesignLoop(task, worktree, { resume: true, extra }); + if (planned.kind === "failure") return checkedTaskOutcome(planned.fail); + plan = planned.plan; + } else { + plan = { + execplanPath: enriched.execplanPath, + workItems: [], + summary: "Resumed from the committed ExecPlan on the surviving branch." + }; + } const built = await runImplementationStage(task, worktree, plan, { resume: stage === "implement", extra }); - if (built.kind === "failure") return built.fail; + if (built.kind === "failure") return checkedTaskOutcome(built.fail); impl = built.impl; } else { const synthetic = await syntheticRecoveryImpl(enriched, evidence, residualRisk); impl = synthetic; plan = { execplanPath: synthetic.execplanPath, workItems: [], summary: synthetic.summary }; } - return await runDualReviewAndIntegration(task, candidate.worktreePath, plan, impl, mergeLock2, { kind: "recovery-resume" }); + return checkedTaskOutcome(await runDualReviewAndIntegration(task, candidate.worktreePath, plan, impl, mergeLock2, { kind: "recovery-resume" })); } catch (error) { - const detail = `unhandled agent error: ${error && error.message || String(error)}`; - return resultFromUnhandledAgentError(candidate.taskId, detail, { worktree, kind: "recovery-resume" }, runFaultMetrics); + const detail = `unhandled agent error: ${faultThrownMessage(error)}`; + return checkedTaskOutcome(resultFromUnhandledAgentError(candidate.taskId, detail, { worktree, kind: "recovery-resume" }, runFaultMetrics)); } } async function runRecovery(root, mergeLock2 = null) { @@ -4249,7 +4606,7 @@ async function runRecovery(root, mergeLock2 = null) { try { roadmap = await readRoadmapForSelection(root); } catch (error) { - summary.errors.push(error && error.message || String(error)); + summary.errors.push(faultThrownMessage(error)); log("[recovery] cannot read the canonical roadmap; skipping recovery discovery"); return { summary, taskResults, held, fatal: null }; } @@ -4279,8 +4636,8 @@ async function runRecovery(root, mergeLock2 = null) { let decision; let evidence; let enriched; - let assessment = null; - let planState = null; + let assessment; + let planState; if (RESUME_MODE === "continue") { log(`[recovery] dispatching ${candidate.branchName} (task ${candidate.taskId}) from its committed ExecPlan`); evidence = await collectAssessmentEvidence(task, resumeWt); @@ -4307,28 +4664,35 @@ async function runRecovery(root, mergeLock2 = null) { } assessment = assessed.assessment; summary.assessed += 1; - evidence = assessment.hostEvidence; + evidence = assessed.evidence; const resolved = await recoveryExecplanPath(candidate); enriched = { ...candidate, execplanPath: resolved.execplanPath }; - const assessmentFields = assessment; - decision = resolved.error ? { classification: "", action: "report", stage: null, reason: "execplan-stat-error", skip: true } : { stage: "review", ...recoveryDecision(enriched, evidence, assessmentFields, RESUME_MODE, { dryRun: DRY_RUN }) }; + const assessmentFields = { + classification: typeof assessment.classification === "string" ? assessment.classification : void 0, + taskScoped: typeof assessment.taskScoped === "boolean" ? assessment.taskScoped : void 0, + validation: typeof assessment.validation === "string" ? assessment.validation : void 0, + missingEvidence: Array.isArray(assessment.missingEvidence) ? assessment.missingEvidence.map(String) : void 0, + residualRisk: advisoryResidualRisk(assessment) + }; + decision = resolved.error ? { classification: "", action: "report", reason: "execplan-stat-error", skip: true } : { stage: "review", ...recoveryDecision(enriched, evidence, assessmentFields, RESUME_MODE, { dryRun: DRY_RUN }) }; } const resultBase = { id: candidate.taskId, branchName: candidate.branchName, - classification: decision.classification, - ...planState ? { planStatus: planState.status } : {} + classification: decision.classification }; + if (planState) resultBase.planStatus = planState.status; if (decision.action !== "resume") { if (decision.skip) { summary.skipped.push({ id: candidate.taskId, branchName: candidate.branchName, reason: decision.reason || "" }); } - summary.results.push({ + const reported = { ...resultBase, - action: "reported", - ...decision.reason ? { reason: decision.reason } : {}, - ...assessment ? { assessment } : {} - }); + action: "reported" + }; + if (decision.reason) reported.reason = decision.reason; + if (assessment) reported.assessment = assessment; + summary.results.push(reported); log(`[recovery] ${candidate.branchName}: ${decision.classification || planState?.status || "reported"} (reported${decision.reason ? `; ${decision.reason}` : ""})`); continue; } @@ -4356,14 +4720,13 @@ async function runRecovery(root, mergeLock2 = null) { return { summary, taskResults, held, fatal: null }; } function writeProbeTargets() { - const probeOptions = (realAdapter) => (options) => ({ - adapter: realAdapter, - ...WRITE_PROBE_MODEL_BY_ADAPTER[String(realAdapter).toLowerCase()] ? { model: WRITE_PROBE_MODEL_BY_ADAPTER[String(realAdapter).toLowerCase()] } : {}, - effort: WRITE_PROBE_EFFORT, - ...options - }); - const planAdapter = String(PLAN_ADAPTER).toLowerCase(); - const buildAdapter = String(BUILD_ADAPTER).toLowerCase(); + const probeOptions = (realAdapter) => (options) => { + const model = WRITE_PROBE_MODEL_BY_ADAPTER[realAdapter.toLowerCase()]; + const routing = model ? { adapter: realAdapter, model } : { adapter: realAdapter }; + return { ...routing, effort: WRITE_PROBE_EFFORT, ...options }; + }; + const planAdapter = String(PLAN_ADAPTER); + const buildAdapter = String(BUILD_ADAPTER); const targets = [ { role: "plan", adapter: planAdapter, options: probeOptions(planAdapter) }, { role: "build", adapter: buildAdapter, options: probeOptions(buildAdapter) } @@ -4424,18 +4787,20 @@ function semaphore(limit) { const queue = []; let active = 0; const drain = () => { - while (active < max && queue.length) { - const item = queue.shift(); - if (!item) break; + while (active < max && queue.length > 0) { + const start = queue.shift(); + if (!start) break; active += 1; - Promise.resolve().then(item.fn).then(item.resolve, item.reject).finally(() => { - active -= 1; - drain(); - }); + start(); } }; return (fn) => new Promise((resolve, reject) => { - queue.push({ fn, resolve, reject }); + queue.push(() => { + Promise.resolve().then(fn).then(resolve, reject).finally(() => { + active -= 1; + drain(); + }); + }); drain(); }); } @@ -4497,7 +4862,7 @@ async function doSelect(taken) { log(`[${label}] using working-tree ${ROADMAP}; origin/${BASE} read failed: ${roadmap.fallbackReason}`); } const selection = selectRoadmapTask(roadmap.text, taken, ONLY_TASK); - if (selection?.hasTask && selection.task) { + if (selection.hasTask && selection.task) { log(`[${label}] selected ${selection.task.isAddendum ? "addendum pass" : "normal task"} ${selection.task.id} from ${roadmap.source}`); } else { log(`[${label}] no unblocked roadmap task found from ${roadmap.source}`); @@ -4511,50 +4876,54 @@ function takenSnapshot() { }; } function isAlreadyTaken(task) { - const processedSet = task?.isAddendum ? processedAddendum : processedNormal; - const manualMergeReadySet = task?.isAddendum ? manualMergeReadyAddendum : manualMergeReadyNormal; - const dryRunSet = task?.isAddendum ? dryRunAddendum : dryRunNormal; - const recoveryHeldSet = task?.isAddendum ? recoveryHeldAddendum : recoveryHeldNormal; + const processedSet = task.isAddendum ? processedAddendum : processedNormal; + const manualMergeReadySet = task.isAddendum ? manualMergeReadyAddendum : manualMergeReadyNormal; + const dryRunSet = task.isAddendum ? dryRunAddendum : dryRunNormal; + const recoveryHeldSet = task.isAddendum ? recoveryHeldAddendum : recoveryHeldNormal; return processedSet.has(task.id) || manualMergeReadySet.has(task.id) || dryRunSet.has(task.id) || recoveryHeldSet.has(task.id) || inflightNormal.has(task.id) || inflightAddendum.has(task.id); } function markInflight(task) { - const inflightSet = task?.isAddendum ? inflightAddendum : inflightNormal; + const inflightSet = task.isAddendum ? inflightAddendum : inflightNormal; inflightSet.add(task.id); } function unmarkInflight(task) { - const inflightSet = task?.isAddendum ? inflightAddendum : inflightNormal; + const inflightSet = task.isAddendum ? inflightAddendum : inflightNormal; inflightSet.delete(task.id); } function markProcessed(task) { - const processedSet = task?.isAddendum ? processedAddendum : processedNormal; + const processedSet = task.isAddendum ? processedAddendum : processedNormal; processedSet.add(task.id); processed.push(task.id); } function markManualMergeReady(task) { - const manualMergeReadySet = task?.isAddendum ? manualMergeReadyAddendum : manualMergeReadyNormal; + const manualMergeReadySet = task.isAddendum ? manualMergeReadyAddendum : manualMergeReadyNormal; manualMergeReadySet.add(task.id); } function markDryRun(task) { - const dryRunSet = task?.isAddendum ? dryRunAddendum : dryRunNormal; + const dryRunSet = task.isAddendum ? dryRunAddendum : dryRunNormal; dryRunSet.add(task.id); } function addPending(step, items) { - if (!items || !items.length) return; + if (!items || items.length === 0) return; if (!pendingByStep.has(step)) pendingByStep.set(step, []); pendingByStep.get(step).push(...items); } async function flushSettledSteps() { if (!canFlush) return; - const inflightSteps = new Set([...inflight.keys()].map(stepOf)); - for (const [step, items] of [...pendingByStep.entries()]) { - if (!items.length || inflightSteps.has(step)) continue; + const inflightSteps = new Set([...inflight.keys()].map((taskId) => stepOf(taskId))); + const pendingEntries = [...pendingByStep.entries()]; + for (const [step, items] of pendingEntries) { + if (items.length === 0 || inflightSteps.has(step)) continue; const tr = await mergeLock(() => runTriage(step, items)); - triages.push({ step, ...tr || {} }); + triages.push({ step, ...tr }); if (!tr?.ok || !tr.pushed) { log(`[step ${step}] triage did not land; keeping ${items.length} proposal(s) pending`); continue; } - const lanes = (tr?.decisions || []).reduce((m, d) => (m[d.lane] = (m[d.lane] || 0) + 1, m), {}); + const lanes = {}; + for (const decision of tr.decisions || []) { + lanes[decision.lane] = (lanes[decision.lane] || 0) + 1; + } log(`[step ${step}] triaged ${items.length} proposal(s): ${Object.entries(lanes).map(([k, v]) => `${v} ${k}`).join(", ") || "none recorded"}`); pendingByStep.delete(step); } @@ -4568,13 +4937,13 @@ async function fillPool() { let sel; try { sel = await doSelect(takenSnapshot()); - } catch (err) { - log(`[pool] select agent failed (${err && err.message || String(err)}); stop opening new work, drain in-flight`); - if (!halted) halted = `select agent error: ${err && err.message || String(err)}`; + } catch (error) { + log(`[pool] select agent failed (${faultThrownMessage(error)}); stop opening new work, drain in-flight`); + if (!halted) halted = `select agent error: ${faultThrownMessage(error)}`; return; } - if (!sel || !sel.hasTask || !sel.task) { - if (inflight.size === 0) log(sel?.blockedSummary ? `No unblocked task: ${sel.blockedSummary}` : "No unblocked roadmap tasks remain."); + if (!sel.hasTask || !sel.task) { + if (inflight.size === 0) log(sel.blockedSummary ? `No unblocked task: ${sel.blockedSummary}` : "No unblocked roadmap tasks remain."); return; } const task = sel.task; @@ -4589,17 +4958,14 @@ async function fillPool() { // A thrown agent error (e.g. a subagent that completes without emitting // structured output) must NOT reject through Promise.race and crash the // whole run — convert it to a failed result the control loop drains. - runTask(task, mergeLock).then( - (result) => ({ id: task.id, task, result }), - (err) => { - const detail = `unhandled agent error: ${err && err.message || String(err)}`; - return { - id: task.id, - task, - result: resultFromUnhandledAgentError(task.id, detail, {}, runFaultMetrics) - }; - } - ) + runTask(task, mergeLock).then((result) => ({ id: task.id, task, result: checkedTaskOutcome(result) })).catch((error) => { + const detail = `unhandled agent error: ${faultThrownMessage(error)}`; + return { + id: task.id, + task, + result: resultFromUnhandledAgentError(task.id, detail, {}, runFaultMetrics) + }; + }) ); } } @@ -4608,7 +4974,7 @@ function redactedCodeSceneCommand(command) { } async function runWorkflowMain() { const authPreflight = await runAuthPreflight(); - if (authPreflight.length) { + if (authPreflight.length > 0) { halted = `fatal auth preflight failed: ${authPreflight.map((failure) => `${failure.tool} (${failure.command})`).join(", ")}`; } let stop = Boolean(halted); @@ -4640,17 +5006,17 @@ async function runWorkflowMain() { stop = true; } } catch (error) { - const detail = error && error.message || String(error); + const detail = faultThrownMessage(error); recovery.errors.push(`recovery pass failed: ${detail}`); log(`[recovery] failed (${detail}); continuing with normal roadmap selection`); } } - while (true) { + for (; ; ) { if (!stop && !halted) { try { await flushSettledSteps(); - } catch (err) { - log(`[triage] failed (${err && err.message || String(err)}); proposals stay pending for a later sweep`); + } catch (error) { + log(`[triage] failed (${faultThrownMessage(error)}); proposals stay pending for a later sweep`); } await fillPool(); } @@ -4664,11 +5030,11 @@ async function runWorkflowMain() { markProcessed(done.task); if (result.kind !== "addendum") { addPending(stepOf(done.id), result.proposals); - let audit = null; + let audit; try { audit = await mergeLock(() => runAudit({ id: done.id })); - } catch (err) { - log(`[audit ${done.id}] failed (${err && err.message || String(err)}); skipping (task already merged)`); + } catch (error) { + log(`[audit ${done.id}] failed (${faultThrownMessage(error)}); skipping (task already merged)`); } if (audit) { audits.push({ afterTask: done.id, ...audit }); @@ -4698,38 +5064,50 @@ async function runWorkflowMain() { if (canFlush && !providerFaultHalt) { try { await flushSettledSteps(); - } catch (err) { - log(`[triage:end] failed (${err && err.message || String(err)}); ${[...pendingByStep.values()].flat().length} proposal(s) left pending`); + } catch (error) { + log(`[triage:end] failed (${faultThrownMessage(error)}); ${[...pendingByStep.values()].flat().length} proposal(s) left pending`); + } + } + const heldRecovery = []; + for (const id of recoveryHeldNormal) { + if (!processedNormal.has(id) && !manualMergeReadyNormal.has(id)) { + heldRecovery.push({ id, isAddendum: false }); } } - const unresolvedRecovery = [ - ...[...recoveryHeldNormal].filter((id) => !processedNormal.has(id) && !manualMergeReadyNormal.has(id)).map((id) => ({ id, isAddendum: false })), - ...[...recoveryHeldAddendum].filter((id) => !processedAddendum.has(id) && !manualMergeReadyAddendum.has(id)).map((id) => ({ id, isAddendum: true })) - ].sort((left, right) => left.id.localeCompare(right.id, "en", { numeric: true }) || Number(left.isAddendum) - Number(right.isAddendum)); + for (const id of recoveryHeldAddendum) { + if (!processedAddendum.has(id) && !manualMergeReadyAddendum.has(id)) { + heldRecovery.push({ id, isAddendum: true }); + } + } + const unresolvedRecovery = heldRecovery.toSorted( + (left, right) => left.id.localeCompare(right.id, "en", { numeric: true }) || Number(left.isAddendum) - Number(right.isAddendum) + ); recovery.unresolved = unresolvedRecovery.map((entry) => { - const reported = (recovery.results || []).filter((result) => result.id === entry.id); - const last = reported[reported.length - 1]; - return { + const reported = recovery.results.filter((result) => result.id === entry.id); + const last = reported.at(-1); + const unresolved = { id: entry.id, isAddendum: entry.isAddendum, branchName: last?.branchName || "", classification: last?.classification || "", - action: last?.action || "held", - ...last?.reason ? { reason: last.reason } : {} + action: last?.action || "held" }; + return last?.reason ? { ...unresolved, reason: last.reason } : unresolved; }); - if (!halted && unresolvedRecovery.length) { + if (!halted && unresolvedRecovery.length > 0) { halted = `needs-operator-recovery: ${unresolvedRecovery.length} recovery survivor branch(es) still block the roadmap frontier (${unresolvedRecovery.map((entry) => entry.id + (entry.isAddendum ? " (addendum)" : "")).join(", ")}); use recovery.results/recovery.unresolved to close, resume, split, or hoover each branch, then relaunch`; } const pendingProposals = [...pendingByStep.values()].flat(); - const assessments = results.filter((result) => result.assessment || result.assessmentError).map((result) => ({ - id: result.id, - stage: result.stage, - status: result.status, - classification: result.assessment?.classification || "", - recommendation: result.assessment?.recommendation || "", - assessmentError: result.assessmentError || "" - })); + const assessments = results.flatMap( + (result) => result.assessment || result.assessmentError ? [{ + id: result.id, + stage: result.stage, + status: result.status, + classification: result.assessment?.classification || "", + recommendation: result.assessment?.recommendation || "", + assessmentError: result.assessmentError || "" + }] : [] + ); const { salvages, summarySuffix: salvageSummarySuffix } = summarizeSalvages(results); const hostReviewSnapshot = getHostReviewMetrics(); return { @@ -4794,7 +5172,7 @@ async function runWorkflowMain() { remediationTriage: triages, pendingProposals, halted, - summary: `Processed ${processed.length} roadmap task(s) (pool width ${MAX_PARALLEL}): ` + results.map((r) => `${r.id}=${r.status}`).join(", ") + (recovery.enabled ? ` | recovery(${recovery.mode}): ${recovery.assessed} assessed, ${recovery.resumed} resumed, ${recovery.skipped.length} skipped` : "") + (assessments.length ? ` | assessed ${assessments.length} failed/halted branch(es)` : "") + salvageSummarySuffix + (triages.length ? ` | triaged ${triages.reduce((n, t) => n + (t.decisions ? t.decisions.length : 0), 0)} proposal(s) across ${triages.length} step(s)` : "") + (halted ? ` | halted: ${halted}` : " | clean stop (no more unblocked tasks).") + summary: `Processed ${processed.length} roadmap task(s) (pool width ${MAX_PARALLEL}): ` + results.map((r) => `${r.id}=${r.status}`).join(", ") + (recovery.enabled ? ` | recovery(${recovery.mode}): ${recovery.assessed} assessed, ${recovery.resumed} resumed, ${recovery.skipped.length} skipped` : "") + (assessments.length > 0 ? ` | assessed ${assessments.length} failed/halted branch(es)` : "") + salvageSummarySuffix + (triages.length > 0 ? ` | triaged ${triages.reduce((n, t) => n + (t.decisions ? t.decisions.length : 0), 0)} proposal(s) across ${triages.length} step(s)` : "") + (halted ? ` | halted: ${halted}` : " | clean stop (no more unblocked tasks).") }; } // --- Worker-pool control loop ----------------------------------------------- @@ -4805,7 +5183,7 @@ async function workflowMain() { try { disposeHostGateLogs(); } catch (error) { - log(`[host gates] could not dispose temporary log roots: ${String(error?.message || error).slice(-500)}`); + log(`[host gates] could not dispose temporary log roots: ${faultThrownMessage(error).slice(-500)}`); } } }