From 3f661c7b547f8c787f5e0e06b4fc25dcc6cd0d08 Mon Sep 17 00:00:00 2001 From: Inzimam Date: Sat, 13 Jun 2026 11:03:40 +0000 Subject: [PATCH 1/2] Phase 0: actor-critic loop engine core Model-agnostic engine that runs the plan-review and per-task actor-critic loops. No vendor/model names in code; backends plug in via a generic AgentRunner abstraction (CliRunner/HttpRunner/MockRunner). - domain/stateMachine: 8-state task lifecycle with a legal-transition table - schemas: plan, critic JSON contract (severity rubric), task artifact bundle - engine/criticParser: JSON extraction + validation; retry-once policy - engine/mechanicalGate: injectable build/test/lint gate (fail-fast) - engine/redaction: secret/PII redaction before anything leaves the engine - engine/loop: per-task loop + plan-review loop with cycle caps, quota fallback (UNVERIFIED_BY_CRITIC), and rubric enforcement (nits never loop) - adapters: vendor-neutral Actor/Critic roles + scriptable mocks - demo + 34 passing tests (vitest), clean tsc --- .gitignore | 5 + package-lock.json | 1973 ++++++++++++++++++++++++++++++++++ package.json | 25 + src/adapters/agents.ts | 67 ++ src/adapters/mocks.ts | 144 +++ src/config.ts | 71 ++ src/demo.ts | 151 +++ src/domain/stateMachine.ts | 97 ++ src/engine/criticParser.ts | 77 ++ src/engine/loop.ts | 409 +++++++ src/engine/mechanicalGate.ts | 93 ++ src/engine/redaction.ts | 84 ++ src/runners/agentRunner.ts | 51 + src/schemas/artifact.ts | 33 + src/schemas/critic.ts | 98 ++ src/schemas/plan.ts | 25 + test/critic.test.ts | 76 ++ test/loop.test.ts | 166 +++ test/mechanicalGate.test.ts | 37 + test/redaction.test.ts | 43 + test/stateMachine.test.ts | 42 + tsconfig.json | 22 + 22 files changed, 3789 insertions(+) create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/adapters/agents.ts create mode 100644 src/adapters/mocks.ts create mode 100644 src/config.ts create mode 100644 src/demo.ts create mode 100644 src/domain/stateMachine.ts create mode 100644 src/engine/criticParser.ts create mode 100644 src/engine/loop.ts create mode 100644 src/engine/mechanicalGate.ts create mode 100644 src/engine/redaction.ts create mode 100644 src/runners/agentRunner.ts create mode 100644 src/schemas/artifact.ts create mode 100644 src/schemas/critic.ts create mode 100644 src/schemas/plan.ts create mode 100644 test/critic.test.ts create mode 100644 test/loop.test.ts create mode 100644 test/mechanicalGate.test.ts create mode 100644 test/redaction.test.ts create mode 100644 test/stateMachine.test.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..efd29f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.log +.DS_Store +sessions.db diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0da5a2c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1973 @@ +{ + "name": "loopwright", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "loopwright", + "version": "0.0.0", + "dependencies": { + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^22.19.21", + "tsx": "^4.19.2", + "typescript": "^5.6.3", + "vitest": "^2.1.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", + "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", + "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", + "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", + "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", + "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", + "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", + "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", + "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", + "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", + "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", + "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", + "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", + "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", + "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", + "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", + "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", + "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", + "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", + "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", + "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", + "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", + "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", + "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", + "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", + "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz", + "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "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" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", + "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4ff6be4 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "loopwright", + "version": "0.0.0", + "private": true, + "description": "A model-agnostic actor-critic loop engine for orchestrating autonomous coding agents", + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "demo": "tsx src/demo.ts", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^22.19.21", + "tsx": "^4.19.2", + "typescript": "^5.6.3", + "vitest": "^2.1.8" + } +} diff --git a/src/adapters/agents.ts b/src/adapters/agents.ts new file mode 100644 index 0000000..d985e89 --- /dev/null +++ b/src/adapters/agents.ts @@ -0,0 +1,67 @@ +import type { Plan, TaskSpec } from "../schemas/plan.js"; +import type { Finding } from "../schemas/critic.js"; +import type { + MechanicalGateResult, + TaskArtifactBundle, +} from "../schemas/artifact.js"; + +/** + * Role interfaces the orchestration loop depends on. Roles are vendor-neutral: + * each is backed by an AgentRunner (see ../runners/agentRunner.ts) chosen via + * config, so the engine never knows or cares which provider/model is behind a + * role. Phase 0 ships mock implementations; real implementations wrap a runner + * plus prompt templates. Adapters return RAW text where a model would; the + * engine owns parsing/validation so every backend goes through one contract. + */ + +export interface BuildFeedback { + /** present when the previous attempt failed the mechanical gate */ + mechanicalFailure?: MechanicalGateResult; + /** present when the critic returned blocking findings to fix */ + criticBlockers?: Finding[]; +} + +export interface ActorBuildResult { + /** unified diff produced by this attempt (raw; redacted by the engine) */ + diff: string; + touchedFiles: string[]; + summary: string; +} + +export interface PlanDraftResult { + plan: Plan; + /** notes the actor wants the critic/human to see */ + notes?: string; +} + +/** The ACTOR role: plans, builds, fixes. The high-volume workhorse. */ +export interface Actor { + /** Draft or revise the plan. `feedback` carries critic blockers on revision. */ + draftPlan(goal: string, feedback?: Finding[]): Promise; + + /** Build the task, or fix it given feedback from a previous failed attempt. */ + build(task: TaskSpec, feedback?: BuildFeedback): Promise; + + /** + * Degraded self-review used ONLY when the critic is unavailable (quota dry). + * Returns raw text in the same contract the critic uses, so it parses + * identically -- but the result is recorded as UNVERIFIED_BY_CRITIC. + */ + selfReview(bundle: TaskArtifactBundle): Promise; +} + +export type CriticRequest = + | { kind: "plan"; goal: string; plan: Plan; repairHint?: string } + | { kind: "task"; bundle: TaskArtifactBundle; repairHint?: string }; + +export interface CriticRawResponse { + /** raw model text; the engine parses + validates it */ + text: string; + /** set when the provider reports the quota/rate window is exhausted */ + quotaExhausted?: boolean; +} + +/** The CRITIC role: reviews the plan + each task. The scarce, gating resource. */ +export interface Critic { + review(req: CriticRequest): Promise; +} diff --git a/src/adapters/mocks.ts b/src/adapters/mocks.ts new file mode 100644 index 0000000..de716ca --- /dev/null +++ b/src/adapters/mocks.ts @@ -0,0 +1,144 @@ +import type { Plan, TaskSpec } from "../schemas/plan.js"; +import type { Finding } from "../schemas/critic.js"; +import type { TaskArtifactBundle } from "../schemas/artifact.js"; +import type { + Actor, + ActorBuildResult, + BuildFeedback, + Critic, + CriticRawResponse, + CriticRequest, + PlanDraftResult, +} from "./agents.js"; +import { + type CommandExecutor, + type CommandOutcome, +} from "../engine/mechanicalGate.js"; + +/** + * Deterministic, scriptable mocks. They let us validate the orchestration loop + * end-to-end before any real agent backend exists. Responses are scripted + * as queues consumed in order (the last entry repeats if the queue runs out). + */ + +// ---- critic response builders -------------------------------------------- + +export function criticGreen(summary = "Looks correct.", nits: Finding[] = []): CriticRawResponse { + return { text: JSON.stringify({ verdict: "green", summary, findings: nits }) }; +} + +export function criticBlock(findings: Finding[], summary = "Changes required."): CriticRawResponse { + return { text: JSON.stringify({ verdict: "changes_required", summary, findings }) }; +} + +/** Wrap valid JSON in a markdown fence + prose to exercise the extractor. */ +export function criticFenced(summary = "Done.", findings: Finding[] = []): CriticRawResponse { + const json = JSON.stringify({ verdict: findings.some((f) => f.severity === "blocker") ? "changes_required" : "green", summary, findings }); + return { text: `Here is my review:\n\n\`\`\`json\n${json}\n\`\`\`\nThanks!` }; +} + +export function criticMalformed(text = "Sorry, I cannot produce JSON right now."): CriticRawResponse { + return { text }; +} + +export function criticQuotaExhausted(): CriticRawResponse { + return { text: "", quotaExhausted: true }; +} + +// ---- scripted executor (mechanical gate) ---------------------------------- + +/** + * Build a CommandExecutor from a responder keyed by command + per-command call + * index, so you can simulate "fails first, passes after the fix". + */ +export function scriptedExecutor( + responder: (command: string, callIndex: number) => { exitCode: number; output?: string }, +): CommandExecutor { + const counts = new Map(); + return async (command: string): Promise => { + const idx = counts.get(command) ?? 0; + counts.set(command, idx + 1); + const r = responder(command, idx); + return { exitCode: r.exitCode, output: r.output ?? "", durationMs: 1 }; + }; +} + +// ---- mock actor ----------------------------------------------------------- + +export interface MockActorOptions { + /** plans[0] is the initial draft; plans[n] is returned after the n-th revision */ + plans: Plan[]; + /** optional custom build behavior; defaults to a synthetic diff */ + build?: (task: TaskSpec, feedback: BuildFeedback | undefined, attempt: number) => ActorBuildResult; + selfReview?: CriticRawResponse; +} + +export class MockActor implements Actor { + private planCalls = 0; + private buildCounts = new Map(); + + constructor(private readonly opts: MockActorOptions) {} + + async draftPlan(_goal: string, _feedback?: Finding[]): Promise { + const idx = Math.min(this.planCalls, this.opts.plans.length - 1); + this.planCalls++; + return { plan: this.opts.plans[idx] as Plan }; + } + + async build(task: TaskSpec, feedback?: BuildFeedback): Promise { + const attempt = this.buildCounts.get(task.id) ?? 0; + this.buildCounts.set(task.id, attempt + 1); + if (this.opts.build) return this.opts.build(task, feedback, attempt); + const reason = feedback?.criticBlockers + ? "addressing critic blockers" + : feedback?.mechanicalFailure + ? "fixing failed checks" + : "initial build"; + return { + diff: `--- a/src/${task.id}.ts\n+++ b/src/${task.id}.ts\n@@ attempt ${attempt} (${reason}) @@`, + touchedFiles: [`src/${task.id}.ts`], + summary: `${task.id}: ${reason} (attempt ${attempt})`, + }; + } + + async selfReview(_bundle: TaskArtifactBundle): Promise { + return ( + this.opts.selfReview ?? + criticGreen("Actor self-review: no blockers spotted (UNVERIFIED).") + ); + } +} + +// ---- mock critic ---------------------------------------------------------- + +export interface MockCriticOptions { + planResponses?: CriticRawResponse[]; + taskResponses?: Record; + fallback?: CriticRawResponse; +} + +export class MockCritic implements Critic { + private planIdx = 0; + private taskIdx = new Map(); + + constructor(private readonly opts: MockCriticOptions = {}) {} + + private take(queue: CriticRawResponse[] | undefined, idx: number): CriticRawResponse { + if (queue && queue.length > 0) { + return queue[Math.min(idx, queue.length - 1)] as CriticRawResponse; + } + return this.opts.fallback ?? criticGreen(); + } + + async review(req: CriticRequest): Promise { + if (req.kind === "plan") { + const r = this.take(this.opts.planResponses, this.planIdx); + this.planIdx++; + return r; + } + const id = req.bundle.task.id; + const idx = this.taskIdx.get(id) ?? 0; + this.taskIdx.set(id, idx + 1); + return this.take(this.opts.taskResponses?.[id], idx); + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..30a09a5 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,71 @@ +import { z } from "zod"; +import os from "node:os"; +import path from "node:path"; + +/** + * Loopwright (actor-critic) configuration. Validated with zod so a bad env + * fails fast with a clear message. + * + * Model identifiers and backends are intentionally NOT hard-coded here -- they + * come from runner profiles (see runners/agentRunner.ts), so the engine stays + * decoupled from any specific provider or model. + */ + +const CriticFallbackSchema = z.enum(["actor_self_review", "pause"]); +export type CriticFallback = z.infer; + +const ENV_PREFIX = "LOOPWRIGHT_"; + +function defaultDbPath(): string { + return path.join( + os.homedir(), + "Library", + "Application Support", + "Loopwright", + "sessions.db", + ); +} + +const RawConfigSchema = z.object({ + // role -> runner binding. These reference runner-profile ids / model strings; + // there is no vendor default so nothing is locked to a specific provider. + actorModel: z.string().default(""), + criticModel: z.string().default(""), + + // loop caps + planReviewMax: z.coerce.number().int().min(0).default(2), + taskReviewCyclesMax: z.coerce.number().int().min(1).default(3), + /** how many times the actor may re-attempt after a mechanical-gate failure */ + mechanicalFixMax: z.coerce.number().int().min(1).default(3), + + mechanicalGate: z.coerce.boolean().default(true), + criticFallback: CriticFallbackSchema.default("actor_self_review"), + + // execution + maxParallel: z.coerce.number().int().min(1).default(2), + stuckThresholdMs: z.coerce.number().int().min(1000).default(120_000), + useWorktrees: z.coerce.boolean().default(true), + + dbPath: z.string().default(defaultDbPath()), +}); + +export type LoopwrightConfig = z.infer; + +/** Build config from a record of env-style values (defaults to process.env). */ +export function loadConfig( + env: Record = process.env, +): LoopwrightConfig { + return RawConfigSchema.parse({ + actorModel: env[`${ENV_PREFIX}ACTOR_MODEL`], + criticModel: env[`${ENV_PREFIX}CRITIC_MODEL`], + planReviewMax: env[`${ENV_PREFIX}PLAN_REVIEW_MAX`], + taskReviewCyclesMax: env[`${ENV_PREFIX}TASK_REVIEW_CYCLES_MAX`], + mechanicalFixMax: env[`${ENV_PREFIX}MECHANICAL_FIX_MAX`], + mechanicalGate: env[`${ENV_PREFIX}MECHANICAL_GATE`], + criticFallback: env[`${ENV_PREFIX}CRITIC_FALLBACK`], + maxParallel: env[`${ENV_PREFIX}MAX_PARALLEL`], + stuckThresholdMs: env[`${ENV_PREFIX}STUCK_THRESHOLD_MS`], + useWorktrees: env[`${ENV_PREFIX}USE_WORKTREES`], + dbPath: env[`${ENV_PREFIX}DB_PATH`], + }); +} diff --git a/src/demo.ts b/src/demo.ts new file mode 100644 index 0000000..a807cee --- /dev/null +++ b/src/demo.ts @@ -0,0 +1,151 @@ +/** + * Fake end-to-end run with mocked actor/critic. + * + * Validates the orchestration (plan review -> per-task build/gate/review loop, + * cycle caps, nits-don't-loop, quota fallback) WITHOUT any real agent backend. + * Run with: npm run demo + */ +import { loadConfig } from "./config.js"; +import type { Plan } from "./schemas/plan.js"; +import type { Finding } from "./schemas/critic.js"; +import { runPlanReview, runTask, type TaskOutcome } from "./engine/loop.js"; +import { + MockActor, + MockCritic, + criticBlock, + criticFenced, + criticGreen, + criticQuotaExhausted, + scriptedExecutor, +} from "./adapters/mocks.js"; + +const GOAL = "Add a rate limiter to the public API"; + +// The actor's first draft is missing a verify command on task-b; after the +// critic blocks the plan, the revised draft adds it. +const draftPlan: Plan = { + goal: GOAL, + tasks: [ + { + id: "task-a", + title: "Implement token-bucket limiter", + description: "Core limiter module", + acceptanceCriteria: ["Requests beyond the limit get HTTP 429"], + verifyCommands: ["npm test -- limiter"], + dependencies: [], + }, + { + id: "task-b", + title: "Wire limiter into middleware", + description: "Apply limiter to all /api routes", + acceptanceCriteria: ["All /api routes are rate limited"], + verifyCommands: [], // <-- missing DoD; critic should block the plan + dependencies: ["task-a"], + }, + { + id: "task-c", + title: "Add limiter metrics", + description: "Expose counters for throttled requests", + acceptanceCriteria: ["A counter increments on each 429"], + verifyCommands: ["npm run build:metrics"], + dependencies: ["task-a"], + }, + ], +}; + +const revisedPlan: Plan = { + ...draftPlan, + tasks: draftPlan.tasks.map((t) => + t.id === "task-b" ? { ...t, verifyCommands: ["npm run build:mw"] } : t, + ), +}; + +const correctnessBlocker: Finding = { + severity: "blocker", + category: "correctness", + detail: "Limiter resets the bucket on every request, so it never throttles.", + location: "src/task-a.ts:42", +}; + +const styleNit: Finding = { + severity: "nit", + category: "style", + detail: "Prefer a named constant over the magic number 100.", + location: "src/task-b.ts:10", +}; + +const planRequirementsBlocker: Finding = { + severity: "blocker", + category: "requirements", + detail: "task-b has no verifyCommands, so its done-state isn't machine-checkable.", + location: "plan.tasks[task-b]", +}; + +async function main(): Promise { + const config = loadConfig({}); // all defaults + + const actor = new MockActor({ plans: [draftPlan, revisedPlan] }); + + const critic = new MockCritic({ + // round 1 blocks the plan; round 2 (after revision) approves it + planResponses: [criticBlock([planRequirementsBlocker], "Plan needs a DoD for task-b."), criticGreen("Plan approved.")], + taskResponses: { + // build fails the gate once, then a correctness blocker, then green + "task-a": [criticBlock([correctnessBlocker]), criticGreen("Throttling now correct.")], + // passes immediately with only a style nit (must NOT trigger another cycle) + "task-b": [criticFenced("Looks good.", [styleNit])], + // critic out of quota -> degraded fallback (UNVERIFIED_BY_CRITIC) + "task-c": [criticQuotaExhausted()], + }, + }); + + // task-a's test command fails on the first run, passes after the fix. + const executor = scriptedExecutor((command, callIndex) => { + if (command.includes("npm test -- limiter") && callIndex === 0) { + return { exitCode: 1, output: "FAIL limiter.test.ts: expected 429, got 200" }; + } + return { exitCode: 0, output: "ok" }; + }); + + const log = (line: string) => console.log(" " + line); + + console.log(`\n=== GOAL: ${GOAL} ===\n`); + + // ---- Plan review loop ---- + console.log("--- Plan review ---"); + const planOutcome = await runPlanReview(GOAL, { actor, critic, config, cwd: ".", executor, log }); + console.log( + `Plan: approved=${planOutcome.approved} revisions=${planOutcome.revisions} ` + + `openItems=${planOutcome.openItems.length}\n`, + ); + + // ---- Per-task loops ---- + const outcomes: TaskOutcome[] = []; + for (const task of planOutcome.plan.tasks) { + console.log(`--- Task ${task.id}: ${task.title} ---`); + const outcome = await runTask(task, { actor, critic, config, cwd: ".", executor, log }); + outcomes.push(outcome); + console.log( + `Result: ${outcome.finalState} verified=${outcome.verified} ` + + `builds=${outcome.buildAttempts} reviewCycles=${outcome.reviewCycles} ` + + `nits=${outcome.nits.length}` + + (outcome.degradedReason ? `\n degraded: ${outcome.degradedReason}` : "") + + (outcome.unresolvedBlockers.length + ? `\n unresolved blockers: ${outcome.unresolvedBlockers.length}` + : "") + + "\n", + ); + } + + // ---- Summary ---- + console.log("=== SUMMARY ==="); + for (const o of outcomes) { + const badge = o.verified ? "GREEN (verified)" : o.finalState; + console.log(` ${o.taskId.padEnd(8)} ${badge}`); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/domain/stateMachine.ts b/src/domain/stateMachine.ts new file mode 100644 index 0000000..69f3863 --- /dev/null +++ b/src/domain/stateMachine.ts @@ -0,0 +1,97 @@ +/** + * Task state machine for the actor-critic loop. + * + * The machine only enforces *legal transitions*. Decisions about WHEN to fire + * an event (cycle caps, quota, etc.) live in the orchestrator loop, so this + * module stays a pure, testable description of the lifecycle. + */ + +export const TASK_STATES = [ + "PLANNED", // task accepted from the plan, not yet built + "BUILDING", // actor is building or fixing + "MECHANICAL_FAILED", // build/test/lint gate failed + "CRITIC_REVIEWING", // passed mechanical gate, awaiting critic verdict + "CHANGES_REQUIRED", // critic returned blocking findings + "GREEN", // critic green pass (or nits only) -- success (terminal) + "NEEDS_HUMAN", // exceeded caps / unrecoverable -- terminal + "UNVERIFIED_BY_CRITIC", // critic unavailable, degraded fallback -- terminal +] as const; + +export type TaskState = (typeof TASK_STATES)[number]; + +export const TASK_EVENTS = [ + "BUILD_STARTED", + "MECHANICAL_PASSED", + "MECHANICAL_FAILED", + "RETRY_BUILD", + "CRITIC_GREEN", + "CRITIC_CHANGES_REQUIRED", + "CRITIC_UNAVAILABLE", + "MALFORMED_GIVEUP", + "EXCEEDED_LIMIT", +] as const; + +export type TaskEvent = (typeof TASK_EVENTS)[number]; + +const TERMINAL_STATES = new Set([ + "GREEN", + "NEEDS_HUMAN", + "UNVERIFIED_BY_CRITIC", +]); + +/** Legal (state, event) -> nextState transitions. Anything absent is illegal. */ +const TRANSITIONS: Record>> = { + PLANNED: { + BUILD_STARTED: "BUILDING", + }, + BUILDING: { + MECHANICAL_PASSED: "CRITIC_REVIEWING", + MECHANICAL_FAILED: "MECHANICAL_FAILED", + }, + MECHANICAL_FAILED: { + RETRY_BUILD: "BUILDING", + EXCEEDED_LIMIT: "NEEDS_HUMAN", + }, + CRITIC_REVIEWING: { + CRITIC_GREEN: "GREEN", + CRITIC_CHANGES_REQUIRED: "CHANGES_REQUIRED", + CRITIC_UNAVAILABLE: "UNVERIFIED_BY_CRITIC", + MALFORMED_GIVEUP: "NEEDS_HUMAN", + // review-cycle cap hit on the final cycle: stop directly from review + EXCEEDED_LIMIT: "NEEDS_HUMAN", + }, + CHANGES_REQUIRED: { + RETRY_BUILD: "BUILDING", + EXCEEDED_LIMIT: "NEEDS_HUMAN", + }, + GREEN: {}, + NEEDS_HUMAN: {}, + UNVERIFIED_BY_CRITIC: {}, +}; + +export function isTerminal(state: TaskState): boolean { + return TERMINAL_STATES.has(state); +} + +export function canTransition(state: TaskState, event: TaskEvent): boolean { + return TRANSITIONS[state][event] !== undefined; +} + +export class IllegalTransitionError extends Error { + constructor( + public readonly state: TaskState, + public readonly event: TaskEvent, + ) { + super(`Illegal transition: ${state} --(${event})-->`); + this.name = "IllegalTransitionError"; + } +} + +/** Returns the next state for a (state, event) pair, or throws if illegal. */ +export function nextState(state: TaskState, event: TaskEvent): TaskState { + const next = TRANSITIONS[state][event]; + if (next === undefined) { + throw new IllegalTransitionError(state, event); + } + return next; +} diff --git a/src/engine/criticParser.ts b/src/engine/criticParser.ts new file mode 100644 index 0000000..9c601fd --- /dev/null +++ b/src/engine/criticParser.ts @@ -0,0 +1,77 @@ +import { CriticReviewSchema, type CriticReview } from "../schemas/critic.js"; + +/** + * Parses a critic model's raw text into a validated CriticReview. + * + * Models wrap JSON in prose or ```json fences, so we extract the first + * balanced JSON object before validating. Returns a discriminated result; + * the orchestrator decides the retry-once-then-NEEDS_HUMAN policy. + */ + +export type ParseResult = + | { ok: true; review: CriticReview } + | { ok: false; error: string }; + +/** Extracts the first balanced top-level {...} object from arbitrary text. */ +function extractJsonObject(text: string): string | null { + const start = text.indexOf("{"); + if (start === -1) return null; + + let depth = 0; + let inString = false; + let escaped = false; + + for (let i = start; i < text.length; i++) { + const ch = text[i]; + if (inString) { + if (escaped) escaped = false; + else if (ch === "\\") escaped = true; + else if (ch === '"') inString = false; + continue; + } + if (ch === '"') inString = true; + else if (ch === "{") depth++; + else if (ch === "}") { + depth--; + if (depth === 0) return text.slice(start, i + 1); + } + } + return null; +} + +export function parseCriticResponse(raw: string): ParseResult { + const candidate = extractJsonObject(raw); + if (candidate === null) { + return { ok: false, error: "No JSON object found in critic response." }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(candidate); + } catch (e) { + return { + ok: false, + error: `Critic response was not valid JSON: ${(e as Error).message}`, + }; + } + + const result = CriticReviewSchema.safeParse(parsed); + if (!result.success) { + return { + ok: false, + error: `Critic JSON failed schema validation: ${result.error.issues + .map((i) => `${i.path.join(".")}: ${i.message}`) + .join("; ")}`, + }; + } + + return { ok: true, review: result.data }; +} + +/** A short corrective hint sent on the single retry after a malformed response. */ +export const REPAIR_HINT = + 'Your previous response could not be parsed. Reply with ONLY a JSON object ' + + 'matching: {"verdict":"green"|"changes_required","summary":string,' + + '"findings":[{"severity":"blocker"|"nit","category":' + + '"correctness"|"requirements"|"test_integrity"|"breakage"|"security"|"style"|"other",' + + '"detail":string,"location":string}]}. No prose, no markdown fences.'; diff --git a/src/engine/loop.ts b/src/engine/loop.ts new file mode 100644 index 0000000..73e0878 --- /dev/null +++ b/src/engine/loop.ts @@ -0,0 +1,409 @@ +import type { LoopwrightConfig } from "../config.js"; +import type { Plan, TaskSpec } from "../schemas/plan.js"; +import { + blockers as blockersOf, + nits as nitsOf, + normalizeReview, + type Finding, +} from "../schemas/critic.js"; +import type { + MechanicalGateResult, + TaskArtifactBundle, +} from "../schemas/artifact.js"; +import { + isTerminal, + nextState, + type TaskEvent, + type TaskState, +} from "../domain/stateMachine.js"; +import { runMechanicalGate, type CommandExecutor } from "./mechanicalGate.js"; +import { redact, redactAndTruncate } from "./redaction.js"; +import { parseCriticResponse, REPAIR_HINT } from "./criticParser.js"; +import type { + Actor, + BuildFeedback, + Critic, + CriticRequest, + CriticRawResponse, +} from "../adapters/agents.js"; + +export interface LoopDeps { + actor: Actor; + critic: Critic; + config: LoopwrightConfig; + /** working directory the mechanical gate runs in (a worktree, later) */ + cwd: string; + /** injectable command runner for the mechanical gate (tests/build/lint) */ + executor?: CommandExecutor; + log?: (line: string) => void; +} + +export interface HistoryEntry { + at: string; + from: TaskState; + event: TaskEvent; + to: TaskState; + reason: string; +} + +export interface TaskOutcome { + taskId: string; + finalState: TaskState; + /** true ONLY when the real critic gave a green pass. Never true on fallback. */ + verified: boolean; + history: HistoryEntry[]; + buildAttempts: number; + reviewCycles: number; + nits: Finding[]; + /** blocking findings still open when we stopped (NEEDS_HUMAN) */ + unresolvedBlockers: Finding[]; + /** human-readable reason for any non-GREEN terminal state */ + degradedReason?: string; + lastDiff: string; +} + +// --------------------------------------------------------------------------- +// Critic acquisition: handles quota exhaustion + retry-once-on-malformed. +// --------------------------------------------------------------------------- + +type ReviewOutcome = + | { kind: "green"; nits: Finding[] } + | { kind: "changes"; blockers: Finding[]; nits: Finding[] } + | { kind: "unavailable"; selfReviewNotes: Finding[]; reason: string } // -> UNVERIFIED_BY_CRITIC + | { kind: "paused"; reason: string } // -> NEEDS_HUMAN + | { kind: "malformed"; reason: string }; // -> NEEDS_HUMAN + +async function askCritic( + critic: Critic, + baseReq: CriticRequest, +): Promise<{ resp: CriticRawResponse; req: CriticRequest }> { + const resp = await critic.review(baseReq); + return { resp, req: baseReq }; +} + +/** Runs the degraded path when the critic is unavailable (quota dry). */ +async function fallbackUnavailable( + deps: LoopDeps, + bundle: TaskArtifactBundle, +): Promise { + if (deps.config.criticFallback === "pause") { + return { kind: "paused", reason: "Critic unavailable (quota) and fallback=pause." }; + } + // actor_self_review: best-effort, clearly marked as NOT a real critic pass. + try { + const resp = await deps.actor.selfReview(bundle); + const parsed = parseCriticResponse(resp.text); + const notes = parsed.ok ? normalizeReview(parsed.review).review.findings : []; + return { + kind: "unavailable", + selfReviewNotes: notes, + reason: "Critic quota exhausted; result self-reviewed by actor (UNVERIFIED).", + }; + } catch { + return { + kind: "unavailable", + selfReviewNotes: [], + reason: "Critic quota exhausted; self-review unavailable (UNVERIFIED).", + }; + } +} + +async function obtainTaskReview( + deps: LoopDeps, + bundle: TaskArtifactBundle, +): Promise { + // attempt 1 + let { resp } = await askCritic(deps.critic, { kind: "task", bundle }); + if (resp.quotaExhausted) return fallbackUnavailable(deps, bundle); + + let parsed = parseCriticResponse(resp.text); + if (!parsed.ok) { + // single retry with a corrective hint + deps.log?.(`critic response malformed (${parsed.error}); retrying once`); + resp = (await askCritic(deps.critic, { + kind: "task", + bundle, + repairHint: REPAIR_HINT, + })).resp; + if (resp.quotaExhausted) return fallbackUnavailable(deps, bundle); + parsed = parseCriticResponse(resp.text); + if (!parsed.ok) { + return { + kind: "malformed", + reason: `Critic returned unparseable output twice: ${parsed.error}`, + }; + } + } + + const { review } = normalizeReview(parsed.review); + const nits = nitsOf(review); + if (review.verdict === "green") return { kind: "green", nits }; + return { kind: "changes", blockers: blockersOf(review), nits }; +} + +// --------------------------------------------------------------------------- +// Artifact bundle assembly (everything redacted before it leaves the engine). +// --------------------------------------------------------------------------- + +function buildBundle( + task: TaskSpec, + diff: string, + touchedFiles: string[], + gate: MechanicalGateResult, +): TaskArtifactBundle { + return { + task, + diff: redactAndTruncate(diff), + touchedFiles: touchedFiles.map((f) => redact(f)), + mechanicalGate: gate, // step output already redacted by the gate runner + testCommands: task.verifyCommands, + }; +} + +// --------------------------------------------------------------------------- +// Single-task actor-critic loop. +// --------------------------------------------------------------------------- + +export async function runTask( + task: TaskSpec, + deps: LoopDeps, +): Promise { + const { config } = deps; + const history: HistoryEntry[] = []; + + let state: TaskState = "PLANNED"; + let buildAttempts = 0; + let reviewCycles = 0; + let mechFixAttempts = 0; + const accumulatedNits: Finding[] = []; + let unresolvedBlockers: Finding[] = []; + let degradedReason: string | undefined; + + let feedback: BuildFeedback | undefined; + let lastDiff = ""; + let lastTouched: string[] = []; + let lastGate: MechanicalGateResult = { passed: true, steps: [] }; + + const fire = (event: TaskEvent, reason: string): TaskState => { + const from = state; + state = nextState(from, event); + history.push({ at: new Date().toISOString(), from, event, to: state, reason }); + deps.log?.(`[${task.id}] ${from} --(${event})--> ${state} ${reason}`); + return state; + }; + + state = fire("BUILD_STARTED", "initial build"); + + while (!isTerminal(state)) { + switch (state) { + case "BUILDING": { + buildAttempts++; + const built = await deps.actor.build(task, feedback); + feedback = undefined; + lastDiff = built.diff; + lastTouched = built.touchedFiles; + + if (config.mechanicalGate) { + lastGate = await runMechanicalGate(task.verifyCommands, { + cwd: deps.cwd, + ...(deps.executor ? { executor: deps.executor } : {}), + }); + if (lastGate.passed) { + fire("MECHANICAL_PASSED", `gate passed (${lastGate.steps.length} step(s))`); + } else { + const failed = lastGate.steps.find((s) => !s.passed); + fire("MECHANICAL_FAILED", `gate failed: ${failed?.command ?? "unknown"}`); + } + } else { + lastGate = { passed: true, steps: [] }; + fire("MECHANICAL_PASSED", "mechanical gate disabled by config"); + } + break; + } + + case "MECHANICAL_FAILED": { + if (mechFixAttempts >= config.mechanicalFixMax) { + degradedReason = `Mechanical gate still failing after ${mechFixAttempts} fix attempt(s).`; + fire("EXCEEDED_LIMIT", degradedReason); + break; + } + mechFixAttempts++; + feedback = { mechanicalFailure: lastGate }; + fire("RETRY_BUILD", `actor fixing mechanical failure (attempt ${mechFixAttempts})`); + break; + } + + case "CRITIC_REVIEWING": { + const bundle = buildBundle(task, lastDiff, lastTouched, lastGate); + const review = await obtainTaskReview(deps, bundle); + + switch (review.kind) { + case "green": { + accumulatedNits.push(...review.nits); + fire("CRITIC_GREEN", "critic green pass"); + break; + } + case "changes": { + accumulatedNits.push(...review.nits); + reviewCycles++; + if (reviewCycles >= config.taskReviewCyclesMax) { + unresolvedBlockers = review.blockers; + degradedReason = `Critic still blocking after ${reviewCycles} review cycle(s).`; + fire("EXCEEDED_LIMIT", degradedReason); + } else { + feedback = { criticBlockers: review.blockers }; + fire( + "CRITIC_CHANGES_REQUIRED", + `${review.blockers.length} blocker(s) (cycle ${reviewCycles})`, + ); + } + break; + } + case "unavailable": { + accumulatedNits.push(...review.selfReviewNotes); + degradedReason = review.reason; + fire("CRITIC_UNAVAILABLE", review.reason); + break; + } + case "paused": { + degradedReason = review.reason; + fire("MALFORMED_GIVEUP", review.reason); // routes to NEEDS_HUMAN + break; + } + case "malformed": { + degradedReason = review.reason; + fire("MALFORMED_GIVEUP", review.reason); + break; + } + } + break; + } + + case "CHANGES_REQUIRED": { + fire("RETRY_BUILD", "actor addressing critic blockers"); + break; + } + } + } + + return { + taskId: task.id, + finalState: state, + verified: state === "GREEN", + history, + buildAttempts, + reviewCycles, + nits: accumulatedNits, + unresolvedBlockers, + ...(degradedReason ? { degradedReason } : {}), + lastDiff, + }; +} + +// --------------------------------------------------------------------------- +// Plan-review loop (critic gates the DAG before any building starts). +// --------------------------------------------------------------------------- + +export interface PlanHistoryEntry { + at: string; + round: number; + verdict: "green" | "changes_required" | "unavailable" | "malformed"; + blockers: number; + note: string; +} + +export interface PlanOutcome { + plan: Plan; + approved: boolean; + proceededWithOpenItems: boolean; + openItems: Finding[]; + revisions: number; + history: PlanHistoryEntry[]; +} + +async function obtainPlanReview( + deps: LoopDeps, + goal: string, + plan: Plan, +): Promise { + let resp = await deps.critic.review({ kind: "plan", goal, plan }); + if (resp.quotaExhausted) { + return { kind: "unavailable", selfReviewNotes: [], reason: "Critic unavailable for plan review." }; + } + let parsed = parseCriticResponse(resp.text); + if (!parsed.ok) { + resp = await deps.critic.review({ kind: "plan", goal, plan, repairHint: REPAIR_HINT }); + if (resp.quotaExhausted) { + return { kind: "unavailable", selfReviewNotes: [], reason: "Critic unavailable for plan review." }; + } + parsed = parseCriticResponse(resp.text); + if (!parsed.ok) { + return { kind: "malformed", reason: `Plan review unparseable: ${parsed.error}` }; + } + } + const { review } = normalizeReview(parsed.review); + if (review.verdict === "green") return { kind: "green", nits: nitsOf(review) }; + return { kind: "changes", blockers: blockersOf(review), nits: nitsOf(review) }; +} + +export async function runPlanReview( + goal: string, + deps: LoopDeps, +): Promise { + const { config } = deps; + const history: PlanHistoryEntry[] = []; + + let draft = await deps.actor.draftPlan(goal); + let plan = draft.plan; + let revisions = 0; + + for (;;) { + const review = await obtainPlanReview(deps, goal, plan); + const round = revisions + 1; + + if (review.kind === "green") { + history.push({ at: new Date().toISOString(), round, verdict: "green", blockers: 0, note: "plan approved" }); + return { plan, approved: true, proceededWithOpenItems: false, openItems: [], revisions, history }; + } + + if (review.kind === "unavailable" || review.kind === "malformed") { + history.push({ + at: new Date().toISOString(), + round, + verdict: review.kind, + blockers: 0, + note: `${review.reason} -- proceeding with unverified plan`, + }); + return { plan, approved: false, proceededWithOpenItems: true, openItems: [], revisions, history }; + } + + if (review.kind === "paused") { + history.push({ at: new Date().toISOString(), round, verdict: "malformed", blockers: 0, note: review.reason }); + return { plan, approved: false, proceededWithOpenItems: true, openItems: [], revisions, history }; + } + + // changes_required + history.push({ + at: new Date().toISOString(), + round, + verdict: "changes_required", + blockers: review.blockers.length, + note: `${review.blockers.length} blocker(s)`, + }); + + if (revisions >= config.planReviewMax) { + return { + plan, + approved: false, + proceededWithOpenItems: true, + openItems: review.blockers, + revisions, + history, + }; + } + + revisions++; + draft = await deps.actor.draftPlan(goal, review.blockers); + plan = draft.plan; + } +} diff --git a/src/engine/mechanicalGate.ts b/src/engine/mechanicalGate.ts new file mode 100644 index 0000000..85590ee --- /dev/null +++ b/src/engine/mechanicalGate.ts @@ -0,0 +1,93 @@ +import { spawn } from "node:child_process"; +import { + type MechanicalGateResult, + type MechanicalStepResult, +} from "../schemas/artifact.js"; +import { redactAndTruncate } from "./redaction.js"; + +/** + * Runs a task's verify commands (build/test/lint) as the cheap, deterministic + * gate that must pass BEFORE the scarce critic is ever invoked. + * + * The command executor is injectable so the loop and tests can drive the gate + * deterministically without spawning real subprocesses. + */ + +export interface CommandOutcome { + exitCode: number; + /** combined stdout+stderr, raw (redaction happens in the gate) */ + output: string; + durationMs: number; +} + +export type CommandExecutor = ( + command: string, + cwd: string, +) => Promise; + +/** Default executor: runs the command in a shell, capturing combined output. */ +export const defaultExecutor: CommandExecutor = (command, cwd) => + new Promise((resolve) => { + const started = Date.now(); + const child = spawn(command, { cwd, shell: true }); + let output = ""; + const append = (buf: Buffer) => { + output += buf.toString(); + }; + child.stdout.on("data", append); + child.stderr.on("data", append); + child.on("error", (err) => { + resolve({ + exitCode: 127, + output: `${output}\n${err.message}`, + durationMs: Date.now() - started, + }); + }); + child.on("close", (code) => { + resolve({ + exitCode: code ?? 1, + output, + durationMs: Date.now() - started, + }); + }); + }); + +export interface MechanicalGateOptions { + cwd: string; + executor?: CommandExecutor; +} + +/** + * Runs all commands in order. Stops at the first failure (fail-fast) so the + * actor gets a focused signal rather than a wall of cascading errors. + */ +export async function runMechanicalGate( + commands: string[], + opts: MechanicalGateOptions, +): Promise { + const executor = opts.executor ?? defaultExecutor; + const steps: MechanicalStepResult[] = []; + + // No verify commands declared = nothing to mechanically prove. We treat this + // as a (loud) pass; the loop surfaces it so a missing DoD is visible. + if (commands.length === 0) { + return { passed: true, steps: [] }; + } + + for (const command of commands) { + const outcome = await executor(command, opts.cwd); + const passed = outcome.exitCode === 0; + steps.push({ + command, + exitCode: outcome.exitCode, + passed, + durationMs: outcome.durationMs, + output: redactAndTruncate(outcome.output), + }); + if (!passed) { + return { passed: false, steps }; + } + } + + return { passed: true, steps }; +} diff --git a/src/engine/redaction.ts b/src/engine/redaction.ts new file mode 100644 index 0000000..54c8c95 --- /dev/null +++ b/src/engine/redaction.ts @@ -0,0 +1,84 @@ +/** + * Secret/PII redaction. + * + * Diffs and especially test logs routinely leak env vars, tokens, and absolute + * home paths. We redact BEFORE anything is placed in a critic artifact bundle + * or persisted. This is intentionally conservative: false positives (over- + * redaction) are acceptable; leaking a real secret to an external model is not. + */ + +const REDACTED = "[REDACTED]"; + +interface RedactionRule { + name: string; + pattern: RegExp; + replace: (match: string, ...groups: string[]) => string; +} + +const RULES: RedactionRule[] = [ + // Specific token shapes run FIRST so a generic key=value rule can't redact + // only the label and leave the real token exposed (e.g. "Bearer "). + // OpenAI-style keys: sk-..., sk-proj-... + { + name: "openai-key", + pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{16,}\b/g, + replace: () => REDACTED, + }, + // Bearer tokens in headers + { + name: "bearer", + pattern: /\bBearer\s+[A-Za-z0-9._-]{8,}/g, + replace: () => `Bearer ${REDACTED}`, + }, + // AWS access key ids + { + name: "aws-akid", + pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, + replace: () => REDACTED, + }, + // GitHub tokens + { + name: "github-token", + pattern: /\bgh[posru]_[A-Za-z0-9]{20,}\b/g, + replace: () => REDACTED, + }, + // JWTs + { + name: "jwt", + pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, + replace: () => REDACTED, + }, + // KEY=VALUE / KEY: VALUE for secret-looking keys (env files, logs). Runs + // after the specific shapes above. + { + name: "secret-assignment", + pattern: + /\b([A-Z0-9_]*(?:API[_-]?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE[_-]?KEY|ACCESS[_-]?KEY|CLIENT[_-]?SECRET|AUTH)[A-Z0-9_]*)\s*([:=])\s*("?)([^\s"']+)\3/gi, + replace: (_m, key: string, sep: string, q: string) => + `${key}${sep}${q}${REDACTED}${q}`, + }, + // Absolute home paths -> strip the username component + { + name: "home-path", + pattern: /\/(Users|home)\/[^/\s:"']+/g, + replace: (_m, root: string) => `/${root}/[user]`, + }, +]; + +export function redact(input: string): string { + let out = input; + for (const rule of RULES) { + out = out.replace(rule.pattern, rule.replace as (m: string) => string); + } + return out; +} + +/** Redact and cap length so a runaway log can't blow up a critic call. */ +export function redactAndTruncate(input: string, maxChars = 8_000): string { + const redacted = redact(input); + if (redacted.length <= maxChars) return redacted; + const head = redacted.slice(0, Math.floor(maxChars * 0.7)); + const tail = redacted.slice(-Math.floor(maxChars * 0.2)); + const omitted = redacted.length - head.length - tail.length; + return `${head}\n...[${omitted} chars truncated]...\n${tail}`; +} diff --git a/src/runners/agentRunner.ts b/src/runners/agentRunner.ts new file mode 100644 index 0000000..d619640 --- /dev/null +++ b/src/runners/agentRunner.ts @@ -0,0 +1,51 @@ +/** + * Generic, vendor-neutral execution layer. + * + * A "runner" is any backend that can execute a natural-language prompt inside a + * workspace and return a raw result. The orchestration loop never references a + * specific product or model; it talks to ROLES (see ../adapters/agents.ts), + * and each role is backed by a runner chosen via configuration. + * + * Adding a new backend = implement AgentRunner. Concrete runners are named by + * their MECHANISM, never by a product/vendor: + * - CliRunner drives a headless command-line agent as a subprocess + * - HttpRunner calls an OpenAI-compatible HTTP endpoint + * - MockRunner deterministic, for tests + * Supporting another provider is usually just a new RunnerProfile; a genuinely + * new transport is a new runner class. The engine never changes. + */ + +export type RunnerKind = "cli" | "http" | "mock"; + +export interface RunnerProfile { + /** stable id referenced by role bindings, e.g. "primary", "reviewer" */ + id: string; + kind: RunnerKind; + /** model identifier passed through to the backend (opaque to the engine) */ + model: string; + /** backend-specific settings: command template, base URL, headers, etc. */ + options?: Record; +} + +export interface RunRequest { + prompt: string; + /** working directory the backend operates in (an isolated worktree, later) */ + cwd: string; + /** optional system/role framing */ + system?: string; +} + +export interface RunResult { + /** raw assistant text; higher layers parse and validate it */ + text: string; + /** the backend signalled its usage/rate window is exhausted */ + quotaExhausted?: boolean; + /** opaque diagnostics for observability: tokens, duration, exit code, etc. */ + meta?: Record; +} + +/** The single extension point every backend implements. */ +export interface AgentRunner { + readonly profile: RunnerProfile; + run(req: RunRequest): Promise; +} diff --git a/src/schemas/artifact.ts b/src/schemas/artifact.ts new file mode 100644 index 0000000..f5c0d85 --- /dev/null +++ b/src/schemas/artifact.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; +import { TaskSpecSchema } from "./plan.js"; + +/** + * The standardized bundle handed to the critic for every task review. + * Deliberately small: a diff + gate output, not the whole repo, to keep the + * scarce critic calls cheap. All free-text fields are redacted before they + * reach this struct (see engine/redaction). + */ + +export const MechanicalStepResultSchema = z.object({ + command: z.string(), + exitCode: z.number().int(), + passed: z.boolean(), + durationMs: z.number().nonnegative(), + output: z.string(), // already redacted, may be truncated +}); +export type MechanicalStepResult = z.infer; + +export const MechanicalGateResultSchema = z.object({ + passed: z.boolean(), + steps: z.array(MechanicalStepResultSchema), +}); +export type MechanicalGateResult = z.infer; + +export const TaskArtifactBundleSchema = z.object({ + task: TaskSpecSchema, + diff: z.string(), // redacted unified diff + touchedFiles: z.array(z.string()), + mechanicalGate: MechanicalGateResultSchema, + testCommands: z.array(z.string()), +}); +export type TaskArtifactBundle = z.infer; diff --git a/src/schemas/critic.ts b/src/schemas/critic.ts new file mode 100644 index 0000000..8f7f626 --- /dev/null +++ b/src/schemas/critic.ts @@ -0,0 +1,98 @@ +import { z } from "zod"; + +/** + * Critic contract. + * + * The rubric is encoded here, not just in prompts: only the five "hard" + * categories may ever be a `blocker`. Everything else is a `nit` and never + * forces another actor cycle. `normalizeReview` enforces this regardless of + * what the model claims, which is what prevents taste-based ping-pong. + */ + +export const SeveritySchema = z.enum(["blocker", "nit"]); +export type Severity = z.infer; + +export const FindingCategorySchema = z.enum([ + // hard categories -- allowed to block + "correctness", + "requirements", + "test_integrity", + "breakage", + "security", + // soft categories -- always nits + "style", + "other", +]); +export type FindingCategory = z.infer; + +/** Categories that are permitted to carry a `blocker` severity. */ +export const BLOCKER_CATEGORIES: ReadonlySet = new Set([ + "correctness", + "requirements", + "test_integrity", + "breakage", + "security", +]); + +export const FindingSchema = z.object({ + severity: SeveritySchema, + category: FindingCategorySchema, + detail: z.string().min(1), + location: z.string().default(""), +}); +export type Finding = z.infer; + +export const VerdictSchema = z.enum(["green", "changes_required"]); +export type Verdict = z.infer; + +export const CriticReviewSchema = z.object({ + verdict: VerdictSchema, + summary: z.string().default(""), + findings: z.array(FindingSchema).default([]), +}); +export type CriticReview = z.infer; + +/** + * Enforces the rubric on a parsed review: + * - downgrades any `blocker` in a soft category to a `nit` + * - derives the verdict from the (post-downgrade) findings so the model + * cannot say "green" while listing real blockers, or vice versa. + */ +export function normalizeReview(review: CriticReview): { + review: CriticReview; + adjustments: string[]; +} { + const adjustments: string[] = []; + + const findings = review.findings.map((f) => { + if (f.severity === "blocker" && !BLOCKER_CATEGORIES.has(f.category)) { + adjustments.push( + `Downgraded blocker -> nit for soft category "${f.category}": ${f.detail}`, + ); + return { ...f, severity: "nit" as const }; + } + return f; + }); + + const hasBlocker = findings.some((f) => f.severity === "blocker"); + const derivedVerdict: Verdict = hasBlocker ? "changes_required" : "green"; + + if (derivedVerdict !== review.verdict) { + adjustments.push( + `Corrected verdict "${review.verdict}" -> "${derivedVerdict}" based on findings.`, + ); + } + + return { + review: { ...review, verdict: derivedVerdict, findings }, + adjustments, + }; +} + +export function blockers(review: CriticReview): Finding[] { + return review.findings.filter((f) => f.severity === "blocker"); +} + +export function nits(review: CriticReview): Finding[] { + return review.findings.filter((f) => f.severity === "nit"); +} diff --git a/src/schemas/plan.ts b/src/schemas/plan.ts new file mode 100644 index 0000000..1232e48 --- /dev/null +++ b/src/schemas/plan.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +/** + * A single unit of work the actor builds and the critic reviews. + * `verifyCommands` is the machine-checkable definition-of-done for the + * mechanical gate; the task is never GREEN without these passing. + */ +export const TaskSpecSchema = z.object({ + id: z.string().min(1), + title: z.string().min(1), + description: z.string().default(""), + acceptanceCriteria: z.array(z.string().min(1)).min(1), + verifyCommands: z.array(z.string().min(1)).default([]), + dependencies: z.array(z.string()).default([]), +}); + +export type TaskSpec = z.infer; + +/** The decomposition the actor drafts and the critic reviews before any building. */ +export const PlanSchema = z.object({ + goal: z.string().min(1), + tasks: z.array(TaskSpecSchema).min(1), +}); + +export type Plan = z.infer; diff --git a/test/critic.test.ts b/test/critic.test.ts new file mode 100644 index 0000000..c6aebed --- /dev/null +++ b/test/critic.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from "vitest"; +import { parseCriticResponse } from "../src/engine/criticParser.js"; +import { normalizeReview, type CriticReview } from "../src/schemas/critic.js"; + +describe("parseCriticResponse", () => { + it("parses a bare JSON object", () => { + const r = parseCriticResponse('{"verdict":"green","summary":"ok","findings":[]}'); + expect(r.ok).toBe(true); + if (r.ok) expect(r.review.verdict).toBe("green"); + }); + + it("extracts JSON from a markdown fence with surrounding prose", () => { + const raw = 'Sure!\n```json\n{"verdict":"changes_required","findings":[{"severity":"blocker","category":"correctness","detail":"bug","location":"a.ts"}]}\n```\ndone'; + const r = parseCriticResponse(raw); + expect(r.ok).toBe(true); + if (r.ok) expect(r.review.findings).toHaveLength(1); + }); + + it("fills defaults for optional fields", () => { + const r = parseCriticResponse('{"verdict":"green"}'); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.review.summary).toBe(""); + expect(r.review.findings).toEqual([]); + } + }); + + it("fails when no JSON is present", () => { + const r = parseCriticResponse("I can't do that right now."); + expect(r.ok).toBe(false); + }); + + it("fails on invalid JSON syntax", () => { + const r = parseCriticResponse('{"verdict": green}'); + expect(r.ok).toBe(false); + }); + + it("fails schema validation on a bad enum", () => { + const r = parseCriticResponse('{"verdict":"maybe","findings":[]}'); + expect(r.ok).toBe(false); + }); +}); + +describe("normalizeReview (rubric enforcement)", () => { + it("downgrades a blocker in a soft category to a nit", () => { + const review: CriticReview = { + verdict: "changes_required", + summary: "", + findings: [{ severity: "blocker", category: "style", detail: "naming", location: "" }], + }; + const { review: out, adjustments } = normalizeReview(review); + expect(out.findings[0]?.severity).toBe("nit"); + expect(out.verdict).toBe("green"); // no real blockers remain + expect(adjustments.length).toBeGreaterThan(0); + }); + + it("corrects a green verdict that contradicts a real blocker", () => { + const review: CriticReview = { + verdict: "green", + summary: "", + findings: [{ severity: "blocker", category: "security", detail: "leak", location: "" }], + }; + const { review: out } = normalizeReview(review); + expect(out.verdict).toBe("changes_required"); + }); + + it("keeps green when only nits are present", () => { + const review: CriticReview = { + verdict: "green", + summary: "", + findings: [{ severity: "nit", category: "style", detail: "spacing", location: "" }], + }; + const { review: out } = normalizeReview(review); + expect(out.verdict).toBe("green"); + }); +}); diff --git a/test/loop.test.ts b/test/loop.test.ts new file mode 100644 index 0000000..473c0d8 --- /dev/null +++ b/test/loop.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect } from "vitest"; +import { loadConfig } from "../src/config.js"; +import type { Plan, TaskSpec } from "../src/schemas/plan.js"; +import type { Finding } from "../src/schemas/critic.js"; +import { runPlanReview, runTask, type LoopDeps } from "../src/engine/loop.js"; +import { + MockActor, + MockCritic, + criticBlock, + criticGreen, + criticMalformed, + criticQuotaExhausted, + scriptedExecutor, +} from "../src/adapters/mocks.js"; + +const blocker: Finding = { severity: "blocker", category: "correctness", detail: "wrong", location: "x.ts" }; +const nit: Finding = { severity: "nit", category: "style", detail: "naming", location: "x.ts" }; + +function task(overrides: Partial = {}): TaskSpec { + return { + id: "t1", + title: "Task", + description: "", + acceptanceCriteria: ["does the thing"], + verifyCommands: ["check"], + dependencies: [], + ...overrides, + }; +} + +function makeDeps( + partial: { actor: MockActor; critic: MockCritic; exec?: ReturnType; env?: Record }, +): LoopDeps { + return { + actor: partial.actor, + critic: partial.critic, + config: loadConfig(partial.env ?? {}), + cwd: ".", + executor: partial.exec ?? scriptedExecutor(() => ({ exitCode: 0 })), + }; +} + +const onePlan = (t: TaskSpec): Plan => ({ goal: "g", tasks: [t] }); + +describe("single-task loop", () => { + it("reaches GREEN after a mechanical fix and a critic fix cycle", async () => { + const t = task(); + const actor = new MockActor({ plans: [onePlan(t)] }); + const critic = new MockCritic({ taskResponses: { t1: [criticBlock([blocker]), criticGreen()] } }); + const exec = scriptedExecutor((_c, i) => ({ exitCode: i === 0 ? 1 : 0 })); + + const out = await runTask(t, makeDeps({ actor, critic, exec })); + expect(out.finalState).toBe("GREEN"); + expect(out.verified).toBe(true); + expect(out.buildAttempts).toBe(3); // initial + mech fix + critic fix + expect(out.reviewCycles).toBe(1); + }); + + it("treats a nit-only review as green WITHOUT another cycle", async () => { + const t = task(); + const actor = new MockActor({ plans: [onePlan(t)] }); + const critic = new MockCritic({ taskResponses: { t1: [criticGreen("ok", [nit])] } }); + + const out = await runTask(t, makeDeps({ actor, critic })); + expect(out.finalState).toBe("GREEN"); + expect(out.buildAttempts).toBe(1); + expect(out.reviewCycles).toBe(0); + expect(out.nits).toHaveLength(1); + }); + + it("goes to NEEDS_HUMAN after exceeding the review-cycle cap", async () => { + const t = task(); + const actor = new MockActor({ plans: [onePlan(t)] }); + const critic = new MockCritic({ fallback: criticBlock([blocker]) }); // always blocks + + const out = await runTask(t, makeDeps({ actor, critic })); + expect(out.finalState).toBe("NEEDS_HUMAN"); + expect(out.verified).toBe(false); + expect(out.reviewCycles).toBe(3); // taskReviewCyclesMax default + expect(out.unresolvedBlockers).toHaveLength(1); + expect(out.degradedReason).toBeTruthy(); + }); + + it("goes to NEEDS_HUMAN after exceeding the mechanical-fix cap", async () => { + const t = task(); + const actor = new MockActor({ plans: [onePlan(t)] }); + const critic = new MockCritic({ fallback: criticGreen() }); + const exec = scriptedExecutor(() => ({ exitCode: 1 })); // always fails + + const out = await runTask(t, makeDeps({ actor, critic, exec })); + expect(out.finalState).toBe("NEEDS_HUMAN"); + expect(out.buildAttempts).toBe(4); // initial + 3 fix attempts (mechanicalFixMax) + expect(out.reviewCycles).toBe(0); // critic never reached + }); + + it("falls back to UNVERIFIED_BY_CRITIC when critic quota is exhausted", async () => { + const t = task(); + const actor = new MockActor({ plans: [onePlan(t)] }); + const critic = new MockCritic({ taskResponses: { t1: [criticQuotaExhausted()] } }); + + const out = await runTask(t, makeDeps({ actor, critic })); + expect(out.finalState).toBe("UNVERIFIED_BY_CRITIC"); + expect(out.verified).toBe(false); // crucially NOT identical to a real green + expect(out.degradedReason).toContain("UNVERIFIED"); + }); + + it("pauses to NEEDS_HUMAN when fallback=pause and quota is exhausted", async () => { + const t = task(); + const actor = new MockActor({ plans: [onePlan(t)] }); + const critic = new MockCritic({ taskResponses: { t1: [criticQuotaExhausted()] } }); + + const out = await runTask(t, makeDeps({ actor, critic, env: { LOOPWRIGHT_CRITIC_FALLBACK: "pause" } })); + expect(out.finalState).toBe("NEEDS_HUMAN"); + expect(out.verified).toBe(false); + }); + + it("retries once on malformed JSON then recovers to GREEN", async () => { + const t = task(); + const actor = new MockActor({ plans: [onePlan(t)] }); + const critic = new MockCritic({ taskResponses: { t1: [criticMalformed(), criticGreen()] } }); + + const out = await runTask(t, makeDeps({ actor, critic })); + expect(out.finalState).toBe("GREEN"); + expect(out.buildAttempts).toBe(1); + }); + + it("goes to NEEDS_HUMAN when the critic is malformed twice", async () => { + const t = task(); + const actor = new MockActor({ plans: [onePlan(t)] }); + const critic = new MockCritic({ taskResponses: { t1: [criticMalformed(), criticMalformed()] } }); + + const out = await runTask(t, makeDeps({ actor, critic })); + expect(out.finalState).toBe("NEEDS_HUMAN"); + expect(out.degradedReason).toContain("unparseable"); + }); +}); + +describe("plan-review loop", () => { + it("approves after one revision", async () => { + const draft = onePlan(task({ verifyCommands: [] })); + const revised = onePlan(task({ verifyCommands: ["check"] })); + const actor = new MockActor({ plans: [draft, revised] }); + const critic = new MockCritic({ + planResponses: [criticBlock([{ severity: "blocker", category: "requirements", detail: "no DoD", location: "" }]), criticGreen()], + }); + + const out = await runPlanReview("g", makeDeps({ actor, critic })); + expect(out.approved).toBe(true); + expect(out.revisions).toBe(1); + expect(out.proceededWithOpenItems).toBe(false); + }); + + it("proceeds with open items when the plan can't be approved within the cap", async () => { + const actor = new MockActor({ plans: [onePlan(task())] }); + const critic = new MockCritic({ + planResponses: [criticBlock([{ severity: "blocker", category: "requirements", detail: "x", location: "" }])], + fallback: criticBlock([{ severity: "blocker", category: "requirements", detail: "x", location: "" }]), + }); + + const out = await runPlanReview("g", makeDeps({ actor, critic })); + expect(out.approved).toBe(false); + expect(out.proceededWithOpenItems).toBe(true); + expect(out.revisions).toBe(2); // planReviewMax default + expect(out.openItems.length).toBeGreaterThan(0); + }); +}); diff --git a/test/mechanicalGate.test.ts b/test/mechanicalGate.test.ts new file mode 100644 index 0000000..1fc5856 --- /dev/null +++ b/test/mechanicalGate.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { runMechanicalGate } from "../src/engine/mechanicalGate.js"; +import { scriptedExecutor } from "../src/adapters/mocks.js"; + +describe("mechanical gate", () => { + it("passes when all commands succeed", async () => { + const exec = scriptedExecutor(() => ({ exitCode: 0, output: "ok" })); + const r = await runMechanicalGate(["npm run build", "npm test"], { cwd: ".", executor: exec }); + expect(r.passed).toBe(true); + expect(r.steps).toHaveLength(2); + }); + + it("fails fast at the first failing command", async () => { + const exec = scriptedExecutor((cmd) => ({ exitCode: cmd === "npm test" ? 1 : 0, output: cmd })); + const r = await runMechanicalGate(["npm run build", "npm test", "npm run lint"], { + cwd: ".", + executor: exec, + }); + expect(r.passed).toBe(false); + // build ran + test failed; lint never ran (fail-fast) + expect(r.steps).toHaveLength(2); + expect(r.steps[1]?.passed).toBe(false); + }); + + it("treats an empty command list as a (recorded) pass", async () => { + const r = await runMechanicalGate([], { cwd: "." }); + expect(r.passed).toBe(true); + expect(r.steps).toHaveLength(0); + }); + + it("redacts secrets in captured output", async () => { + const exec = scriptedExecutor(() => ({ exitCode: 1, output: "FAIL: key=sk-deadbeef0123456789abcd" })); + const r = await runMechanicalGate(["npm test"], { cwd: ".", executor: exec }); + expect(r.steps[0]?.output).not.toContain("sk-deadbeef"); + expect(r.steps[0]?.output).toContain("[REDACTED]"); + }); +}); diff --git a/test/redaction.test.ts b/test/redaction.test.ts new file mode 100644 index 0000000..25854d9 --- /dev/null +++ b/test/redaction.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from "vitest"; +import { redact, redactAndTruncate } from "../src/engine/redaction.js"; + +describe("redaction", () => { + it("redacts secret-looking env assignments but keeps the key", () => { + const out = redact("OPENAI_API_KEY=sk-abcdef0123456789abcdef\nFOO=bar"); + expect(out).toContain("OPENAI_API_KEY=[REDACTED]"); + expect(out).toContain("FOO=bar"); + expect(out).not.toContain("sk-abcdef"); + }); + + it("redacts standalone OpenAI keys", () => { + const out = redact("token is sk-proj-ABCDEFGHIJKLMNOP1234 in logs"); + expect(out).not.toContain("sk-proj-ABCDEFGHIJKLMNOP1234"); + expect(out).toContain("[REDACTED]"); + }); + + it("redacts bearer tokens and AWS keys without leaking the token", () => { + const out = redact("Authorization: Bearer abcdef12345678"); + expect(out).not.toContain("abcdef12345678"); + expect(out).toContain("[REDACTED]"); + expect(redact("AKIAIOSFODNN7EXAMPLE")).toContain("[REDACTED]"); + }); + + it("strips usernames from absolute home paths", () => { + expect(redact("/Users/alice/project/src/x.ts")).toBe("/Users/[user]/project/src/x.ts"); + expect(redact("/home/bob/repo")).toBe("/home/[user]/repo"); + }); + + it("leaves benign content untouched", () => { + const benign = "function add(a, b) { return a + b; }"; + expect(redact(benign)).toBe(benign); + }); + + it("truncates overly long output while keeping head and tail", () => { + const big = "START" + "x".repeat(20_000) + "END"; + const out = redactAndTruncate(big, 1_000); + expect(out.length).toBeLessThan(2_000); + expect(out).toContain("START"); + expect(out).toContain("END"); + expect(out).toContain("truncated"); + }); +}); diff --git a/test/stateMachine.test.ts b/test/stateMachine.test.ts new file mode 100644 index 0000000..ba6f8f0 --- /dev/null +++ b/test/stateMachine.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { + canTransition, + isTerminal, + nextState, + IllegalTransitionError, +} from "../src/domain/stateMachine.js"; + +describe("state machine", () => { + it("follows the happy path PLANNED -> BUILDING -> CRITIC_REVIEWING -> GREEN", () => { + expect(nextState("PLANNED", "BUILD_STARTED")).toBe("BUILDING"); + expect(nextState("BUILDING", "MECHANICAL_PASSED")).toBe("CRITIC_REVIEWING"); + expect(nextState("CRITIC_REVIEWING", "CRITIC_GREEN")).toBe("GREEN"); + }); + + it("routes mechanical failure and recovery", () => { + expect(nextState("BUILDING", "MECHANICAL_FAILED")).toBe("MECHANICAL_FAILED"); + expect(nextState("MECHANICAL_FAILED", "RETRY_BUILD")).toBe("BUILDING"); + expect(nextState("MECHANICAL_FAILED", "EXCEEDED_LIMIT")).toBe("NEEDS_HUMAN"); + }); + + it("routes critic outcomes", () => { + expect(nextState("CRITIC_REVIEWING", "CRITIC_CHANGES_REQUIRED")).toBe("CHANGES_REQUIRED"); + expect(nextState("CRITIC_REVIEWING", "CRITIC_UNAVAILABLE")).toBe("UNVERIFIED_BY_CRITIC"); + expect(nextState("CRITIC_REVIEWING", "MALFORMED_GIVEUP")).toBe("NEEDS_HUMAN"); + expect(nextState("CHANGES_REQUIRED", "RETRY_BUILD")).toBe("BUILDING"); + }); + + it("identifies terminal states", () => { + expect(isTerminal("GREEN")).toBe(true); + expect(isTerminal("NEEDS_HUMAN")).toBe(true); + expect(isTerminal("UNVERIFIED_BY_CRITIC")).toBe(true); + expect(isTerminal("BUILDING")).toBe(false); + expect(isTerminal("CRITIC_REVIEWING")).toBe(false); + }); + + it("rejects illegal transitions", () => { + expect(canTransition("GREEN", "RETRY_BUILD")).toBe(false); + expect(() => nextState("PLANNED", "CRITIC_GREEN")).toThrow(IllegalTransitionError); + expect(() => nextState("BUILDING", "BUILD_STARTED")).toThrow(IllegalTransitionError); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..225d557 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "exactOptionalPropertyTypes": false, + "verbatimModuleSyntax": false, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "outDir": "dist", + "rootDir": "." + }, + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["node_modules", "dist"] +} From e6c45df40b3551e682defee0305e4cc93afb854d Mon Sep 17 00:00:00 2001 From: Inzimam Date: Sat, 13 Jun 2026 11:35:25 +0000 Subject: [PATCH 2/2] Address PR review: harden parser, gate, redaction, config, fallback - config: env booleans via explicit preprocess (z.coerce.boolean treated "false"/"0" as true, so toggles couldn't be disabled) - criticParser: scan all balanced JSON candidates and prefer the last valid one, so an earlier non-JSON brace in prose no longer fails the parse - mechanicalGate: createShellExecutor with a hard timeout (SIGKILL, exit 124) and bounded output capture to prevent hangs/unbounded memory - redaction: case-insensitive bearer matching + Windows home-path rule - loop: fallback self-review carries only non-blocker findings into nits Skipped: forcing verifyCommands>=1 at the schema level (missing DoD is caught by the critic during plan review; some tasks legitimately have none). Adds 10 tests (44 total); typecheck clean. --- src/config.ts | 18 ++++++- src/engine/criticParser.ts | 87 +++++++++++++++++++++------------- src/engine/loop.ts | 7 ++- src/engine/mechanicalGate.ts | 92 ++++++++++++++++++++++++++---------- src/engine/redaction.ts | 10 +++- test/config.test.ts | 28 +++++++++++ test/critic.test.ts | 19 ++++++++ test/loop.test.ts | 14 ++++++ test/mechanicalGate.test.ts | 21 +++++++- test/redaction.test.ts | 14 ++++++ 10 files changed, 244 insertions(+), 66 deletions(-) create mode 100644 test/config.test.ts diff --git a/src/config.ts b/src/config.ts index 30a09a5..cb1e4f9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,6 +16,20 @@ export type CriticFallback = z.infer; const ENV_PREFIX = "LOOPWRIGHT_"; +/** + * Env-string boolean. `z.coerce.boolean()` is unsafe here: it uses JS + * `Boolean(str)`, so "false"/"0" (non-empty strings) coerce to `true`, which + * would make boolean toggles impossible to disable via env. This maps common + * truthy/falsy spellings explicitly and leaves real booleans untouched. + */ +const EnvBoolean = z.preprocess((v) => { + if (typeof v !== "string") return v; + const n = v.trim().toLowerCase(); + if (["1", "true", "yes", "on"].includes(n)) return true; + if (["0", "false", "no", "off"].includes(n)) return false; + return v; // anything else falls through to z.boolean() validation +}, z.boolean()); + function defaultDbPath(): string { return path.join( os.homedir(), @@ -38,13 +52,13 @@ const RawConfigSchema = z.object({ /** how many times the actor may re-attempt after a mechanical-gate failure */ mechanicalFixMax: z.coerce.number().int().min(1).default(3), - mechanicalGate: z.coerce.boolean().default(true), + mechanicalGate: EnvBoolean.default(true), criticFallback: CriticFallbackSchema.default("actor_self_review"), // execution maxParallel: z.coerce.number().int().min(1).default(2), stuckThresholdMs: z.coerce.number().int().min(1000).default(120_000), - useWorktrees: z.coerce.boolean().default(true), + useWorktrees: EnvBoolean.default(true), dbPath: z.string().default(defaultDbPath()), }); diff --git a/src/engine/criticParser.ts b/src/engine/criticParser.ts index 9c601fd..e506ccd 100644 --- a/src/engine/criticParser.ts +++ b/src/engine/criticParser.ts @@ -3,25 +3,26 @@ import { CriticReviewSchema, type CriticReview } from "../schemas/critic.js"; /** * Parses a critic model's raw text into a validated CriticReview. * - * Models wrap JSON in prose or ```json fences, so we extract the first - * balanced JSON object before validating. Returns a discriminated result; - * the orchestrator decides the retry-once-then-NEEDS_HUMAN policy. + * Models wrap JSON in prose or ```json fences and sometimes include an + * illustrative brace snippet before the real answer, so we scan for ALL + * balanced top-level {...} substrings, validate each, and prefer the last one + * that satisfies the schema (the model's concluding answer). Returns a + * discriminated result; the orchestrator owns the retry-then-NEEDS_HUMAN policy. */ export type ParseResult = | { ok: true; review: CriticReview } | { ok: false; error: string }; -/** Extracts the first balanced top-level {...} object from arbitrary text. */ -function extractJsonObject(text: string): string | null { - const start = text.indexOf("{"); - if (start === -1) return null; - +/** Returns every balanced top-level {...} substring, in order of appearance. */ +function extractJsonCandidates(text: string): string[] { + const candidates: string[] = []; let depth = 0; + let start = -1; let inString = false; let escaped = false; - for (let i = start; i < text.length; i++) { + for (let i = 0; i < text.length; i++) { const ch = text[i]; if (inString) { if (escaped) escaped = false; @@ -29,43 +30,61 @@ function extractJsonObject(text: string): string | null { else if (ch === '"') inString = false; continue; } - if (ch === '"') inString = true; - else if (ch === "{") depth++; - else if (ch === "}") { - depth--; - if (depth === 0) return text.slice(start, i + 1); + if (ch === '"') { + inString = true; + } else if (ch === "{") { + if (depth === 0) start = i; + depth++; + } else if (ch === "}") { + if (depth > 0) { + depth--; + if (depth === 0 && start !== -1) { + candidates.push(text.slice(start, i + 1)); + start = -1; + } + } } } - return null; + return candidates; } export function parseCriticResponse(raw: string): ParseResult { - const candidate = extractJsonObject(raw); - if (candidate === null) { + const candidates = extractJsonCandidates(raw); + if (candidates.length === 0) { return { ok: false, error: "No JSON object found in critic response." }; } - let parsed: unknown; - try { - parsed = JSON.parse(candidate); - } catch (e) { - return { - ok: false, - error: `Critic response was not valid JSON: ${(e as Error).message}`, - }; - } + let schemaError: string | null = null; + let lastValid: CriticReview | null = null; - const result = CriticReviewSchema.safeParse(parsed); - if (!result.success) { - return { - ok: false, - error: `Critic JSON failed schema validation: ${result.error.issues + for (const candidate of candidates) { + let parsed: unknown; + try { + parsed = JSON.parse(candidate); + } catch { + continue; // e.g. "{foo}" in prose -- not JSON, try the next candidate + } + const result = CriticReviewSchema.safeParse(parsed); + if (result.success) { + lastValid = result.data; // keep the latest valid object + } else { + schemaError = result.error.issues .map((i) => `${i.path.join(".")}: ${i.message}`) - .join("; ")}`, - }; + .join("; "); + } + } + + if (lastValid !== null) { + return { ok: true, review: lastValid }; } - return { ok: true, review: result.data }; + return { + ok: false, + error: + schemaError !== null + ? `Critic JSON failed schema validation: ${schemaError}` + : "No parseable JSON object found in critic response.", + }; } /** A short corrective hint sent on the single retry after a malformed response. */ diff --git a/src/engine/loop.ts b/src/engine/loop.ts index 73e0878..ea54c27 100644 --- a/src/engine/loop.ts +++ b/src/engine/loop.ts @@ -89,11 +89,14 @@ async function fallbackUnavailable( if (deps.config.criticFallback === "pause") { return { kind: "paused", reason: "Critic unavailable (quota) and fallback=pause." }; } - // actor_self_review: best-effort, clearly marked as NOT a real critic pass. + // opus/actor self-review: best-effort, clearly marked as NOT a real critic pass. try { const resp = await deps.actor.selfReview(bundle); const parsed = parseCriticResponse(resp.text); - const notes = parsed.ok ? normalizeReview(parsed.review).review.findings : []; + // Only carry NON-blocking findings forward: in the degraded path these + // become informational nits, and a blocker mislabeled as a nit would be + // misleading (the result is already surfaced as UNVERIFIED_BY_CRITIC). + const notes = parsed.ok ? nitsOf(normalizeReview(parsed.review).review) : []; return { kind: "unavailable", selfReviewNotes: notes, diff --git a/src/engine/mechanicalGate.ts b/src/engine/mechanicalGate.ts index 85590ee..40a6099 100644 --- a/src/engine/mechanicalGate.ts +++ b/src/engine/mechanicalGate.ts @@ -25,32 +25,72 @@ export type CommandExecutor = ( cwd: string, ) => Promise; -/** Default executor: runs the command in a shell, capturing combined output. */ -export const defaultExecutor: CommandExecutor = (command, cwd) => - new Promise((resolve) => { - const started = Date.now(); - const child = spawn(command, { cwd, shell: true }); - let output = ""; - const append = (buf: Buffer) => { - output += buf.toString(); - }; - child.stdout.on("data", append); - child.stderr.on("data", append); - child.on("error", (err) => { - resolve({ - exitCode: 127, - output: `${output}\n${err.message}`, - durationMs: Date.now() - started, +const DEFAULT_TIMEOUT_MS = 5 * 60_000; +const DEFAULT_MAX_CAPTURED_CHARS = 200_000; +/** exit code used to signal a command was killed for exceeding the timeout */ +export const TIMEOUT_EXIT_CODE = 124; + +export interface ShellExecutorOptions { + /** hard wall-clock limit before the child is SIGKILLed */ + timeoutMs?: number; + /** rolling cap on captured output so a runaway log can't exhaust memory */ + maxCapturedChars?: number; +} + +/** + * Creates a shell CommandExecutor with two safeguards a hung/chatty verify + * command would otherwise breach: a hard timeout (kills the child and returns + * {@link TIMEOUT_EXIT_CODE}) and bounded capture (keeps only the most recent + * tail in memory, well before the post-exit redaction/truncation step). + */ +export function createShellExecutor( + opts: ShellExecutorOptions = {}, +): CommandExecutor { + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const maxChars = opts.maxCapturedChars ?? DEFAULT_MAX_CAPTURED_CHARS; + + return (command, cwd) => + new Promise((resolve) => { + const started = Date.now(); + const child = spawn(command, { cwd, shell: true }); + let output = ""; + let timedOut = false; + + const append = (buf: Buffer) => { + output += buf.toString(); + if (output.length > maxChars) output = output.slice(-maxChars); + }; + + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, timeoutMs); + + child.stdout.on("data", append); + child.stderr.on("data", append); + child.on("error", (err) => { + clearTimeout(timer); + resolve({ + exitCode: 127, + output: `${output}\n${err.message}`, + durationMs: Date.now() - started, + }); }); - }); - child.on("close", (code) => { - resolve({ - exitCode: code ?? 1, - output, - durationMs: Date.now() - started, + child.on("close", (code) => { + clearTimeout(timer); + resolve({ + exitCode: timedOut ? TIMEOUT_EXIT_CODE : (code ?? 1), + output: timedOut + ? `${output}\n[killed: exceeded ${timeoutMs}ms timeout]` + : output, + durationMs: Date.now() - started, + }); }); }); - }); +} + +/** Default executor: runs the command in a shell with timeout + bounded capture. */ +export const defaultExecutor: CommandExecutor = createShellExecutor(); export interface MechanicalGateOptions { cwd: string; @@ -68,8 +108,10 @@ export async function runMechanicalGate( const executor = opts.executor ?? defaultExecutor; const steps: MechanicalStepResult[] = []; - // No verify commands declared = nothing to mechanically prove. We treat this - // as a (loud) pass; the loop surfaces it so a missing DoD is visible. + // No verify commands declared = nothing to mechanically prove, so this is a + // pass at the mechanical layer ONLY. GREEN is still gated by the critic's + // semantic review, and a task with no machine-checkable DoD is expected to be + // caught by the critic during plan review rather than blocked by the schema. if (commands.length === 0) { return { passed: true, steps: [] }; } diff --git a/src/engine/redaction.ts b/src/engine/redaction.ts index 54c8c95..5d3daa2 100644 --- a/src/engine/redaction.ts +++ b/src/engine/redaction.ts @@ -24,10 +24,10 @@ const RULES: RedactionRule[] = [ pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{16,}\b/g, replace: () => REDACTED, }, - // Bearer tokens in headers + // Bearer tokens in headers (any casing: "Bearer", "bearer", "BEARER") { name: "bearer", - pattern: /\bBearer\s+[A-Za-z0-9._-]{8,}/g, + pattern: /\bBearer\s+[A-Za-z0-9._-]{8,}/gi, replace: () => `Bearer ${REDACTED}`, }, // AWS access key ids @@ -63,6 +63,12 @@ const RULES: RedactionRule[] = [ pattern: /\/(Users|home)\/[^/\s:"']+/g, replace: (_m, root: string) => `/${root}/[user]`, }, + // Windows home paths, e.g. C:\Users\Alice\... -> C:\Users\[user]\... + { + name: "windows-home-path", + pattern: /([A-Za-z]:\\Users\\)[^\\\s:"']+/g, + replace: (_m, prefix: string) => `${prefix}[user]`, + }, ]; export function redact(input: string): string { diff --git a/test/config.test.ts b/test/config.test.ts new file mode 100644 index 0000000..80b3dfb --- /dev/null +++ b/test/config.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; +import { loadConfig } from "../src/config.js"; + +describe("config env boolean parsing", () => { + it("interprets falsy strings as false (not JS-truthy)", () => { + const c = loadConfig({ + LOOPWRIGHT_MECHANICAL_GATE: "false", + LOOPWRIGHT_USE_WORKTREES: "0", + }); + expect(c.mechanicalGate).toBe(false); + expect(c.useWorktrees).toBe(false); + }); + + it("interprets truthy strings as true", () => { + const c = loadConfig({ + LOOPWRIGHT_MECHANICAL_GATE: "true", + LOOPWRIGHT_USE_WORKTREES: "on", + }); + expect(c.mechanicalGate).toBe(true); + expect(c.useWorktrees).toBe(true); + }); + + it("defaults booleans to true when unset", () => { + const c = loadConfig({}); + expect(c.mechanicalGate).toBe(true); + expect(c.useWorktrees).toBe(true); + }); +}); diff --git a/test/critic.test.ts b/test/critic.test.ts index c6aebed..208a69f 100644 --- a/test/critic.test.ts +++ b/test/critic.test.ts @@ -74,3 +74,22 @@ describe("normalizeReview (rubric enforcement)", () => { expect(out.verdict).toBe("green"); }); }); + + +describe("parseCriticResponse robustness", () => { + it("ignores an earlier non-JSON brace and uses the valid JSON later", () => { + const raw = 'I might do {something here} first. Final: {"verdict":"green","findings":[]}'; + const r = parseCriticResponse(raw); + expect(r.ok).toBe(true); + if (r.ok) expect(r.review.verdict).toBe("green"); + }); + + it("prefers the last valid JSON object when several are present", () => { + const raw = + 'Draft: {"verdict":"green","findings":[]} ... revised: ' + + '{"verdict":"changes_required","findings":[{"severity":"blocker","category":"correctness","detail":"x","location":""}]}'; + const r = parseCriticResponse(raw); + expect(r.ok).toBe(true); + if (r.ok) expect(r.review.verdict).toBe("changes_required"); + }); +}); diff --git a/test/loop.test.ts b/test/loop.test.ts index 473c0d8..f31f316 100644 --- a/test/loop.test.ts +++ b/test/loop.test.ts @@ -164,3 +164,17 @@ describe("plan-review loop", () => { expect(out.openItems.length).toBeGreaterThan(0); }); }); + + +describe("fallback self-review", () => { + it("never surfaces blocker-severity findings as nits", async () => { + const t = task(); + const actor = new MockActor({ plans: [onePlan(t)], selfReview: criticBlock([blocker, nit]) }); + const critic = new MockCritic({ taskResponses: { t1: [criticQuotaExhausted()] } }); + + const out = await runTask(t, makeDeps({ actor, critic })); + expect(out.finalState).toBe("UNVERIFIED_BY_CRITIC"); + expect(out.nits.every((f) => f.severity === "nit")).toBe(true); + expect(out.nits).toHaveLength(1); // the blocker was filtered out + }); +}); diff --git a/test/mechanicalGate.test.ts b/test/mechanicalGate.test.ts index 1fc5856..f546389 100644 --- a/test/mechanicalGate.test.ts +++ b/test/mechanicalGate.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { runMechanicalGate } from "../src/engine/mechanicalGate.js"; +import { runMechanicalGate, createShellExecutor, TIMEOUT_EXIT_CODE } from "../src/engine/mechanicalGate.js"; import { scriptedExecutor } from "../src/adapters/mocks.js"; describe("mechanical gate", () => { @@ -35,3 +35,22 @@ describe("mechanical gate", () => { expect(r.steps[0]?.output).toContain("[REDACTED]"); }); }); + + +describe("shell executor safeguards", () => { + it("kills a command that exceeds the timeout", async () => { + const exec = createShellExecutor({ timeoutMs: 200 }); + const start = Date.now(); + const r = await exec("sleep 5", "."); + expect(r.exitCode).toBe(TIMEOUT_EXIT_CODE); + expect(r.output).toContain("timeout"); + expect(Date.now() - start).toBeLessThan(4000); + }, 10_000); + + it("bounds captured output to the configured max", async () => { + const exec = createShellExecutor({ maxCapturedChars: 100 }); + const r = await exec("for i in $(seq 1 1000); do echo AAAAAAAAAA; done", "."); + expect(r.exitCode).toBe(0); + expect(r.output.length).toBeLessThanOrEqual(100); + }, 10_000); +}); diff --git a/test/redaction.test.ts b/test/redaction.test.ts index 25854d9..b2bcd21 100644 --- a/test/redaction.test.ts +++ b/test/redaction.test.ts @@ -41,3 +41,17 @@ describe("redaction", () => { expect(out).toContain("truncated"); }); }); + + +describe("redaction hardening", () => { + it("redacts bearer tokens regardless of casing", () => { + expect(redact("authorization: bearer abcdef12345678")).not.toContain("abcdef12345678"); + expect(redact("BEARER ABCDEF12345678")).not.toContain("ABCDEF12345678"); + }); + + it("strips the username from Windows home paths", () => { + const out = redact("C:\\Users\\Alice\\project\\x.ts"); + expect(out).toContain("C:\\Users\\[user]"); + expect(out).not.toContain("Alice"); + }); +});