diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4a36482..ab07315 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -35,3 +35,4 @@ jobs:
cache-dependency-path: app/package-lock.json
- run: npm ci
- run: npx tsc --noEmit
+ - run: npx vitest run
diff --git a/app/package-lock.json b/app/package-lock.json
index 934cd87..d30846e 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -31,13 +31,77 @@
"zustand": "^5.0.14"
},
"devDependencies": {
+ "@testing-library/dom": "^10.4.1",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.0.0",
+ "jsdom": "^29.1.1",
"typescript": "^5.7.0",
- "vite": "^6.0.0"
+ "vite": "^6.0.0",
+ "vitest": "^3.2.6"
}
},
+ "node_modules/@adobe/css-tools": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
+ "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "5.1.11",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
+ "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@csstools/css-calc": "^3.2.0",
+ "@csstools/css-color-parser": "^4.1.0",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
+ "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@asamuzakjp/nwsapi": "^2.3.9",
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/generational-cache": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
+ "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/nwsapi": {
+ "version": "2.3.9",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -544,6 +608,159 @@
}
}
},
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
+ "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
+ "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz",
+ "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.1.0",
+ "@csstools/css-calc": "^3.2.1"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz",
+ "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
"node_modules/@dotenvx/dotenvx": {
"version": "1.75.1",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.75.1.tgz",
@@ -1141,6 +1358,24 @@
"node": ">=18"
}
},
+ "node_modules/@exodus/bytes": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
+ "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
@@ -2317,6 +2552,95 @@
"@tauri-apps/api": "^2.11.0"
}
},
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+ "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
"node_modules/@ts-morph/common": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz",
@@ -2328,6 +2652,13 @@
"path-browserify": "^1.0.1"
}
},
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2373,6 +2704,24 @@
"@babel/types": "^7.28.2"
}
},
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -2426,6 +2775,121 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
+ "node_modules/@vitest/expect": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz",
+ "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "3.2.6",
+ "@vitest/utils": "3.2.6",
+ "chai": "^5.2.0",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz",
+ "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "3.2.6",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.17"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz",
+ "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz",
+ "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "3.2.6",
+ "pathe": "^2.0.3",
+ "strip-literal": "^3.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz",
+ "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.6",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz",
+ "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^4.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz",
+ "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.6",
+ "loupe": "^3.1.4",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
@@ -2505,11 +2969,24 @@
"node": ">=8"
}
},
- "node_modules/argparse": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
- "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
- "license": "Python-2.0"
+ "node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "license": "Python-2.0"
},
"node_modules/aria-hidden": {
"version": "1.2.6",
@@ -2523,6 +3000,26 @@
"node": ">=10"
}
},
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "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/ast-types": {
"version": "0.16.1",
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz",
@@ -2565,6 +3062,16 @@
"node": ">=6.0.0"
}
},
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
"node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
@@ -2683,6 +3190,16 @@
"node": ">= 0.8"
}
},
+ "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/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -2741,6 +3258,23 @@
],
"license": "CC-BY-4.0"
},
+ "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/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
@@ -2753,6 +3287,16 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
+ "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/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
@@ -3015,6 +3559,27 @@
"node": ">= 8"
}
},
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -3034,6 +3599,20 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/debounce-fn": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz",
@@ -3066,6 +3645,13 @@
}
}
},
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/dedent": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@@ -3080,6 +3666,16 @@
}
}
},
+ "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/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -3138,6 +3734,16 @@
"node": ">= 0.8"
}
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -3162,6 +3768,13 @@
"node": ">=0.3.1"
}
},
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/dompurify": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
@@ -3266,6 +3879,19 @@
"node": ">=8.6"
}
},
+ "node_modules/entities": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
+ "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/env-paths": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
@@ -3302,6 +3928,13 @@
"node": ">= 0.4"
}
},
+ "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/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
@@ -3383,6 +4016,16 @@
"node": ">=4"
}
},
+ "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/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@@ -3439,6 +4082,16 @@
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
@@ -3843,6 +4496,19 @@
"node": ">=16.9.0"
}
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -3913,6 +4579,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -4051,6 +4727,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -4163,6 +4846,57 @@
"js-yaml": "bin/js-yaml.js"
}
},
+ "node_modules/jsdom": {
+ "version": "29.1.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
+ "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^5.1.11",
+ "@asamuzakjp/dom-selector": "^7.1.1",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.3",
+ "@exodus/bytes": "^1.15.0",
+ "css-tree": "^3.2.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.3.5",
+ "parse5": "^8.0.1",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.1",
+ "undici": "^7.25.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.1",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsdom/node_modules/lru-cache": {
+ "version": "11.5.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
+ "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -4522,6 +5256,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "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/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -4540,6 +5281,16 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -4571,6 +5322,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
@@ -4678,6 +5436,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
@@ -5006,6 +5774,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/parse5": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -5049,6 +5830,23 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "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",
@@ -5141,6 +5939,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
"node_modules/pretty-ms": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
@@ -5191,6 +6004,16 @@
"node": ">= 0.10"
}
},
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
@@ -5276,6 +6099,13 @@
"react": "^19.2.7"
}
},
+ "node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -5371,6 +6201,20 @@
"node": ">= 4"
}
},
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -5537,6 +6381,19 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -5742,6 +6599,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "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/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -5778,6 +6642,13 @@
"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/state-local": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
@@ -5793,6 +6664,13 @@
"node": ">= 0.8"
}
},
+ "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/stdin-discarder": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz",
@@ -5911,6 +6789,46 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-literal": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
+ "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/strip-literal/node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/systeminformation": {
"version": "5.31.11",
"resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.11.tgz",
@@ -5972,6 +6890,20 @@
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"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/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -5988,6 +6920,56 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "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": "2.0.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
+ "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "7.4.5",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.5.tgz",
+ "integrity": "sha512-RfEzKWcq5fHUOFq7J3rl3Oz6ylKGtcHqUznzj4EcXsxLSIjJcvpbXAQtWGeJQ0xKnimR5e0Cn+cn9TssfMzm+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.5"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.5",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.5.tgz",
+ "integrity": "sha512-pGrwzZDvPwKe+7NNUqAunb6rqTfynr0VOUhCMdqbu5xlvNiszsAJygRzwvpVycdzejlbpY+SWJOn+s75Og7FEA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -6009,6 +6991,32 @@
"node": ">=0.6"
}
},
+ "node_modules/tough-cookie": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
+ "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/ts-morph": {
"version": "26.0.0",
"resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-26.0.0.tgz",
@@ -6312,6 +7320,150 @@
}
}
},
+ "node_modules/vite-node": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+ "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.4.1",
+ "es-module-lexer": "^1.7.0",
+ "pathe": "^2.0.3",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz",
+ "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/expect": "3.2.6",
+ "@vitest/mocker": "3.2.6",
+ "@vitest/pretty-format": "^3.2.6",
+ "@vitest/runner": "3.2.6",
+ "@vitest/snapshot": "3.2.6",
+ "@vitest/spy": "3.2.6",
+ "@vitest/utils": "3.2.6",
+ "chai": "^5.2.0",
+ "debug": "^4.4.1",
+ "expect-type": "^1.2.1",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.2",
+ "std-env": "^3.9.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.2",
+ "tinyglobby": "^0.2.14",
+ "tinypool": "^1.1.1",
+ "tinyrainbow": "^2.0.0",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+ "vite-node": "3.2.4",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/debug": "^4.1.12",
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "@vitest/browser": "3.2.6",
+ "@vitest/ui": "3.2.6",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/debug": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/which": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
@@ -6327,6 +7479,23 @@
"node": "^16.13.0 || >=18.0.0"
}
},
+ "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/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -6349,6 +7518,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
diff --git a/app/package.json b/app/package.json
index 63c8d56..18ab37e 100644
--- a/app/package.json
+++ b/app/package.json
@@ -6,7 +6,9 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "test": "vitest run",
+ "test:watch": "vitest"
},
"dependencies": {
"@base-ui/react": "^1.6.0",
@@ -32,10 +34,16 @@
"zustand": "^5.0.14"
},
"devDependencies": {
+ "@testing-library/dom": "^10.4.1",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.0.0",
+ "jsdom": "^29.1.1",
"typescript": "^5.7.0",
- "vite": "^6.0.0"
+ "vite": "^6.0.0",
+ "vitest": "^3.2.6"
}
}
diff --git a/app/src/App.tsx b/app/src/App.tsx
index 26029a4..ae0368f 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -149,6 +149,7 @@ function App() {
const planOpen = useAppStore((s) => s.planOpen);
const generatePlan = useAppStore((s) => s.generatePlan);
const batchStatus = useAppStore((s) => s.batchStatus);
+ const restackPr = useAppStore((s) => s.restackPr);
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true";
@@ -339,6 +340,13 @@ function App() {
[openReview, navigateToDiff, authoredPrs, reviewRequests, reviews],
);
+ const handleRestack = useCallback(
+ (pr: string) => {
+ void restackPr(pr);
+ },
+ [restackPr],
+ );
+
// Opening a project always routes to its plan gate. When the project has no
// plan yet, PlanView surfaces a "Generate plan" affordance.
const handleOpenProject = useCallback(
@@ -441,6 +449,7 @@ function App() {
key={review.id}
review={review}
onAction={handleReviewAction}
+ onRestack={handleRestack}
/>
))}
diff --git a/app/src/components/CiPanel.test.tsx b/app/src/components/CiPanel.test.tsx
new file mode 100644
index 0000000..0178e78
--- /dev/null
+++ b/app/src/components/CiPanel.test.tsx
@@ -0,0 +1,105 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { render, screen, waitFor, act } from "@testing-library/react";
+import {
+ mockInvoke,
+ emitEvent,
+ listenerCount,
+} from "../test/tauri-mock";
+import { makeCheck } from "../test/fixtures";
+
+// The panel talks to Tauri via the store (`invoke`) and directly via `listen`.
+vi.mock("@tauri-apps/api/core", async () => {
+ const mock = await import("../test/tauri-mock");
+ return { invoke: mock.invoke };
+});
+vi.mock("@tauri-apps/api/event", async () => {
+ const mock = await import("../test/tauri-mock");
+ return { listen: mock.listen };
+});
+// openExternal routes through the opener plugin; stub it so no real IPC fires.
+vi.mock("@tauri-apps/plugin-opener", () => ({
+ openUrl: vi.fn(() => Promise.resolve()),
+}));
+
+const { CiPanel } = await import("./CiPanel");
+const { useAppStore } = await import("../store");
+
+const pristine = useAppStore.getState();
+
+beforeEach(() => {
+ useAppStore.setState(pristine, true);
+});
+
+describe("CiPanel", () => {
+ it("renders the empty state when the PR has no checks", async () => {
+ mockInvoke("list_ci_checks", () => []);
+
+ render();
+
+ expect(await screen.findByText("No CI checks")).toBeInTheDocument();
+ });
+
+ it("groups checks into workflow pipelines after loading", async () => {
+ mockInvoke("list_ci_checks", () => [
+ makeCheck({ name: "build", workflow: "CI", bucket: "pass" }),
+ makeCheck({ name: "e2e", workflow: "Nightly", bucket: "pass" }),
+ ]);
+
+ render();
+
+ // Two distinct workflows -> two pipeline section headers.
+ expect(await screen.findByText("CI")).toBeInTheDocument();
+ expect(screen.getByText("Nightly")).toBeInTheDocument();
+ });
+
+ it("updates live from a matching ci-updated event", async () => {
+ mockInvoke("list_ci_checks", () => []);
+
+ render();
+ expect(await screen.findByText("No CI checks")).toBeInTheDocument();
+
+ // Backend pushes a fresh checks list for this PR.
+ act(() => {
+ emitEvent("ci-updated", [
+ "https://gh/pr/1",
+ [makeCheck({ name: "lint", workflow: "Lint", bucket: "pass" })],
+ ]);
+ });
+
+ expect(await screen.findByText("Lint")).toBeInTheDocument();
+ expect(screen.queryByText("No CI checks")).not.toBeInTheDocument();
+ });
+
+ it("ignores ci-updated events for a different PR", async () => {
+ mockInvoke("list_ci_checks", () => []);
+
+ render();
+ expect(await screen.findByText("No CI checks")).toBeInTheDocument();
+
+ act(() => {
+ emitEvent("ci-updated", [
+ "https://gh/pr/OTHER",
+ [makeCheck({ name: "lint", workflow: "Lint" })],
+ ]);
+ });
+
+ // Still empty: the event was for another PR.
+ expect(screen.getByText("No CI checks")).toBeInTheDocument();
+ expect(screen.queryByText("Lint")).not.toBeInTheDocument();
+ });
+
+ it("unsubscribes from ci-updated on unmount", async () => {
+ mockInvoke("list_ci_checks", () => []);
+
+ const { unmount } = render(
+ ,
+ );
+ await screen.findByText("No CI checks");
+ expect(listenerCount("ci-updated")).toBe(1);
+
+ unmount();
+ await waitFor(() => {
+ expect(listenerCount("ci-updated")).toBe(0);
+ });
+ });
+});
diff --git a/app/src/components/DiffView.tsx b/app/src/components/DiffView.tsx
index 987befd..41abc8c 100644
--- a/app/src/components/DiffView.tsx
+++ b/app/src/components/DiffView.tsx
@@ -59,6 +59,7 @@ import {
XCircle,
Loader2,
Wrench,
+ Layers,
} from "lucide-react";
// ---------------------------------------------------------------------------
@@ -465,6 +466,10 @@ export function DiffView({
const [ciSummary, setCiSummary] = useState(null);
const [fixingCi, setFixingCi] = useState(false);
+ // -- Restack state --
+ const restackPr = useAppStore((s) => s.restackPr);
+ const [restacking, setRestacking] = useState(false);
+
// -- Refs --
const activeFileRef = useRef(null);
const diffEditorRef = useRef(null);
@@ -850,6 +855,15 @@ export function DiffView({
}
}, [prRef]);
+ const handleRestack = useCallback(async () => {
+ setRestacking(true);
+ try {
+ await restackPr(prRef);
+ } finally {
+ setRestacking(false);
+ }
+ }, [restackPr, prRef]);
+
const handleSubmitReview = useCallback(async () => {
const commentCount = review.comments.filter((c) => isLocalOrigin(c.origin)).length;
if (commentCount === 0) return;
@@ -1047,6 +1061,22 @@ export function DiffView({
Agent
+ {/* Restack — explicit user action; only when the review is stale.
+ Operates only on the review's own branch (Invariant 5 / §9). */}
+ {review.stale && (
+
+ )}
+
{/* Fix CI failures — explicit user action; only when CI is failing */}
{ciSummary !== null && ciSummary.failed > 0 && (
+
+ {/* Restack — only for stale reviews; explicit user action on the
+ review's own branch. Disabled while the conflict-resolver runs. */}
+ {review.stale && (
+
+ )}
diff --git a/app/src/components/StateFilter.test.tsx b/app/src/components/StateFilter.test.tsx
new file mode 100644
index 0000000..015d9dd
--- /dev/null
+++ b/app/src/components/StateFilter.test.tsx
@@ -0,0 +1,94 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type { GateState } from "../bindings/GateState";
+import { StateFilter } from "./StateFilter";
+import { makeReview } from "../test/fixtures";
+
+describe("StateFilter", () => {
+ const reviews = [
+ makeReview({ pr: "a", gate_state: "InReview" }),
+ makeReview({ pr: "b", gate_state: "InReview" }),
+ makeReview({ pr: "c", gate_state: "Approved" }),
+ makeReview({ pr: "d", gate_state: "Dispatched", stale: true }),
+ ];
+
+ it("renders per-state counts and the total", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("button", { name: "All (4)" })).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "In Review (2)" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Approved (1)" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Pending (0)" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Stale (1)" }),
+ ).toBeInTheDocument();
+ });
+
+ it("invokes onFilterChange with the chosen gate state", async () => {
+ const user = userEvent.setup();
+ const onFilterChange = vi.fn<(s: GateState | null) => void>();
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "In Review (2)" }));
+ expect(onFilterChange).toHaveBeenCalledWith("InReview");
+
+ await user.click(screen.getByRole("button", { name: "All (4)" }));
+ expect(onFilterChange).toHaveBeenCalledWith(null);
+ });
+
+ it("invokes onToggleStale when the stale chip is clicked", async () => {
+ const user = userEvent.setup();
+ const onToggleStale = vi.fn();
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Stale (1)" }));
+ expect(onToggleStale).toHaveBeenCalledOnce();
+ });
+
+ it("renders zero counts for an empty review list", () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole("button", { name: "All (0)" })).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Stale (0)" }),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/app/src/lib/ci.test.ts b/app/src/lib/ci.test.ts
new file mode 100644
index 0000000..8f9606f
--- /dev/null
+++ b/app/src/lib/ci.test.ts
@@ -0,0 +1,154 @@
+import { describe, it, expect } from "vitest";
+import type { CiCheck } from "../bindings/CiCheck";
+import {
+ checkOutcome,
+ summarizeChecks,
+ ciState,
+ parseCiUpdate,
+} from "./ci";
+
+/** Build a CiCheck with sane defaults; override the fields under test. */
+function check(overrides: Partial): CiCheck {
+ return {
+ name: "build",
+ state: "SUCCESS",
+ bucket: "pass",
+ link: "https://github.com/o/r/runs/1",
+ workflow: "CI",
+ ...overrides,
+ };
+}
+
+describe("checkOutcome", () => {
+ it("classifies bucket 'pass' as pass", () => {
+ expect(checkOutcome(check({ bucket: "pass" }))).toBe("pass");
+ });
+
+ it("classifies bucket 'fail' as fail", () => {
+ expect(checkOutcome(check({ bucket: "fail" }))).toBe("fail");
+ });
+
+ it("classifies bucket 'pending' as pending", () => {
+ expect(checkOutcome(check({ bucket: "pending" }))).toBe("pending");
+ });
+
+ it("treats neutral/skipped/cancelled as pass", () => {
+ for (const signal of ["neutral", "skipped", "cancelled", "canceled"]) {
+ expect(checkOutcome(check({ bucket: signal }))).toBe("pass");
+ }
+ });
+
+ it("falls back to raw state when bucket is empty", () => {
+ // Empty bucket means "use `state`": SUCCESS -> pass, FAILURE -> fail.
+ expect(checkOutcome(check({ bucket: "", state: "SUCCESS" }))).toBe("pass");
+ expect(checkOutcome(check({ bucket: "", state: "FAILURE" }))).toBe("fail");
+ expect(checkOutcome(check({ bucket: "", state: "timed_out" }))).toBe(
+ "fail",
+ );
+ });
+
+ it("is case-insensitive on the signal", () => {
+ expect(checkOutcome(check({ bucket: "PASS" }))).toBe("pass");
+ expect(checkOutcome(check({ bucket: "", state: "Failure" }))).toBe("fail");
+ });
+
+ it("maps unknown signals to pending", () => {
+ expect(checkOutcome(check({ bucket: "in_progress" }))).toBe("pending");
+ expect(checkOutcome(check({ bucket: "", state: "queued" }))).toBe(
+ "pending",
+ );
+ });
+});
+
+describe("summarizeChecks", () => {
+ it("returns all-zero for empty input", () => {
+ expect(summarizeChecks([])).toEqual({
+ passed: 0,
+ failed: 0,
+ pending: 0,
+ total: 0,
+ });
+ });
+
+ it("rolls up a mixed set counting skipped as passed", () => {
+ const summary = summarizeChecks([
+ check({ bucket: "pass" }),
+ check({ bucket: "skipped" }), // counts as passed
+ check({ bucket: "fail" }),
+ check({ bucket: "pending" }),
+ check({ bucket: "queued" }), // unknown -> pending
+ ]);
+ expect(summary).toEqual({ passed: 2, failed: 1, pending: 2, total: 5 });
+ });
+});
+
+describe("ciState", () => {
+ it("is 'none' when there are no checks", () => {
+ expect(ciState({ passed: 0, failed: 0, pending: 0, total: 0 })).toBe(
+ "none",
+ );
+ });
+
+ it("prioritizes fail over pending", () => {
+ expect(ciState({ passed: 1, failed: 1, pending: 3, total: 5 })).toBe(
+ "fail",
+ );
+ });
+
+ it("is 'pending' when only pending remains", () => {
+ expect(ciState({ passed: 2, failed: 0, pending: 1, total: 3 })).toBe(
+ "pending",
+ );
+ });
+
+ it("is 'pass' when everything passed", () => {
+ expect(ciState({ passed: 3, failed: 0, pending: 0, total: 3 })).toBe(
+ "pass",
+ );
+ });
+});
+
+describe("parseCiUpdate", () => {
+ it("parses a well-formed [pr, checks] tuple", () => {
+ const c = check({ name: "lint" });
+ const result = parseCiUpdate(["https://gh/pr/1", [c]]);
+ expect(result).not.toBeNull();
+ // Real guard, not a cast: only proceed once we know it parsed.
+ if (result === null) throw new Error("expected a parsed update");
+ expect(result.pr).toBe("https://gh/pr/1");
+ expect(result.checks).toHaveLength(1);
+ const [only] = result.checks;
+ expect(only?.name).toBe("lint");
+ });
+
+ it("rejects a payload that is not a 2-tuple", () => {
+ expect(parseCiUpdate(null)).toBeNull();
+ expect(parseCiUpdate("nope")).toBeNull();
+ expect(parseCiUpdate([])).toBeNull();
+ expect(parseCiUpdate(["only-one"])).toBeNull();
+ expect(parseCiUpdate(["pr", [], "extra"])).toBeNull();
+ });
+
+ it("rejects when pr is not a string or checks is not an array", () => {
+ expect(parseCiUpdate([1, []])).toBeNull();
+ expect(parseCiUpdate(["pr", "not-an-array"])).toBeNull();
+ });
+
+ it("drops malformed checks but keeps well-formed ones", () => {
+ const good = check({ name: "good" });
+ const result = parseCiUpdate([
+ "pr",
+ [
+ good,
+ { name: "missing-fields" }, // missing state/bucket/link/workflow
+ { name: 1, state: "S", bucket: "b", link: "l", workflow: "w" }, // wrong type
+ null,
+ "string-check",
+ ],
+ ]);
+ if (result === null) throw new Error("expected a parsed update");
+ expect(result.checks).toHaveLength(1);
+ const [only] = result.checks;
+ expect(only?.name).toBe("good");
+ });
+});
diff --git a/app/src/lib/shortcuts.test.tsx b/app/src/lib/shortcuts.test.tsx
new file mode 100644
index 0000000..0e64816
--- /dev/null
+++ b/app/src/lib/shortcuts.test.tsx
@@ -0,0 +1,62 @@
+import { describe, it, expect } from "vitest";
+import { render, screen } from "@testing-library/react";
+import {
+ SHORTCUTS,
+ shortcutById,
+ comboFor,
+ Kbd,
+ type ShortcutId,
+} from "./shortcuts";
+
+describe("shortcut registry", () => {
+ it("has a unique id per entry", () => {
+ const ids = SHORTCUTS.map((s) => s.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it("has a unique combo per entry (no accidental collisions)", () => {
+ const combos = SHORTCUTS.map((s) => s.combo);
+ expect(new Set(combos).size).toBe(combos.length);
+ });
+});
+
+describe("shortcutById", () => {
+ it("resolves a known id to its declaration", () => {
+ const found = shortcutById("command-palette");
+ expect(found).toBeDefined();
+ // Real narrowing, not a cast.
+ if (found === undefined) throw new Error("expected a shortcut");
+ expect(found.combo).toBe("meta+k");
+ expect(found.label).toBe("Command Palette");
+ });
+
+ it("returns undefined for an id not in the registry", () => {
+ // Cast is confined to the test to feed a deliberately bogus id; the
+ // production API only accepts ShortcutId.
+ const bogus = "does-not-exist" as ShortcutId;
+ expect(shortcutById(bogus)).toBeUndefined();
+ });
+});
+
+describe("comboFor", () => {
+ it("returns the combo string for a known id", () => {
+ expect(comboFor("nav-settings")).toBe("meta+5");
+ expect(comboFor("escape")).toBe("escape");
+ });
+});
+
+describe("Kbd", () => {
+ it("renders each combo token as a separate glyph", () => {
+ // navigator.platform in jsdom is not Mac, so meta -> 'Ctrl'.
+ render();
+ const kbd = screen.getByText("Ctrl").closest("kbd");
+ expect(kbd).not.toBeNull();
+ expect(kbd).toHaveTextContent("Ctrl");
+ expect(kbd).toHaveTextContent("1");
+ });
+
+ it("renders single-key combos", () => {
+ render();
+ expect(screen.getByText("Esc")).toBeInTheDocument();
+ });
+});
diff --git a/app/src/store.test.ts b/app/src/store.test.ts
new file mode 100644
index 0000000..6ed26ac
--- /dev/null
+++ b/app/src/store.test.ts
@@ -0,0 +1,207 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import type { Review } from "./bindings/Review";
+import type { CiCheck } from "./bindings/CiCheck";
+import {
+ mockInvoke,
+ mockInvokeReject,
+ callsFor,
+} from "./test/tauri-mock";
+import { makeReview, makeCheck, makeAgentRun } from "./test/fixtures";
+
+// Route the store's Tauri imports through the typed mock. The factory returns
+// the singleton `invoke`/`listen` from the shared mock module so tests can
+// register handlers and inspect calls.
+vi.mock("@tauri-apps/api/core", async () => {
+ const mock = await import("./test/tauri-mock");
+ return { invoke: mock.invoke };
+});
+vi.mock("@tauri-apps/api/event", async () => {
+ const mock = await import("./test/tauri-mock");
+ return { listen: mock.listen };
+});
+
+// Import after the mocks are registered.
+const { useAppStore } = await import("./store");
+
+// Snapshot the pristine store so each test starts clean (Zustand's store is a
+// module singleton).
+const pristine = useAppStore.getState();
+
+beforeEach(() => {
+ useAppStore.setState(pristine, true);
+});
+
+describe("fetchReviews", () => {
+ it("populates reviews and clears loading on success", async () => {
+ const reviews: Review[] = [makeReview({ pr: "pr-a" })];
+ mockInvoke("list_reviews", () => reviews);
+
+ await useAppStore.getState().fetchReviews();
+
+ const state = useAppStore.getState();
+ expect(state.reviews).toEqual(reviews);
+ expect(state.loading).toBe(false);
+ expect(state.error).toBeNull();
+ });
+
+ it("sets error (non-fatal) and clears loading on rejection", async () => {
+ mockInvokeReject("list_reviews", "boom");
+
+ await useAppStore.getState().fetchReviews();
+
+ const state = useAppStore.getState();
+ expect(state.error).toContain("boom");
+ expect(state.loading).toBe(false);
+ expect(state.reviews).toEqual([]);
+ });
+});
+
+describe("restackPr", () => {
+ it("replaces the review across every list and clears stale on success", async () => {
+ const stale = makeReview({ pr: "pr-1", stale: true });
+ const restacked = makeReview({ pr: "pr-1", stale: false });
+ useAppStore.setState({
+ reviews: [stale],
+ frontier: [stale],
+ authoredPrs: [stale],
+ reviewRequests: [],
+ activeReview: stale,
+ });
+ mockInvoke("restack_pr", (args) => {
+ expect(args.pr).toBe("pr-1");
+ return restacked;
+ });
+
+ await useAppStore.getState().restackPr("pr-1");
+
+ const state = useAppStore.getState();
+ expect(state.reviews[0]?.stale).toBe(false);
+ expect(state.frontier[0]?.stale).toBe(false);
+ expect(state.authoredPrs[0]?.stale).toBe(false);
+ expect(state.activeReview?.stale).toBe(false);
+ expect(state.error).toBeNull();
+ });
+
+ it("keeps a conflict-resolver agent on the returned review", async () => {
+ const stale = makeReview({ pr: "pr-1", stale: true, agent: null });
+ const conflicted = makeReview({
+ pr: "pr-1",
+ stale: true,
+ agent: makeAgentRun({ mode: "Restack" }),
+ });
+ useAppStore.setState({ reviews: [stale] });
+ mockInvoke("restack_pr", () => conflicted);
+
+ await useAppStore.getState().restackPr("pr-1");
+
+ // Real narrowing on the nullable agent union, not a cast.
+ const agent = useAppStore.getState().reviews[0]?.agent;
+ expect(agent).not.toBeNull();
+ if (agent == null) throw new Error("expected an active agent");
+ expect(agent.mode).toBe("Restack");
+ });
+
+ it("sets error and does not throw when the backend rejects", async () => {
+ const stale = makeReview({ pr: "pr-1", stale: true });
+ useAppStore.setState({ reviews: [stale] });
+ mockInvokeReject("restack_pr", "rebase exploded");
+
+ await useAppStore.getState().restackPr("pr-1");
+
+ const state = useAppStore.getState();
+ expect(state.error).toContain("rebase exploded");
+ // Non-fatal: the loop is not blocked, the stale review is untouched.
+ expect(state.reviews[0]?.stale).toBe(true);
+ });
+});
+
+describe("listCiChecks", () => {
+ it("returns the checks from the backend", async () => {
+ const checks: CiCheck[] = [makeCheck({ name: "lint" })];
+ mockInvoke("list_ci_checks", () => checks);
+
+ const result = await useAppStore.getState().listCiChecks("pr-1");
+
+ expect(result).toEqual(checks);
+ expect(callsFor("list_ci_checks")[0]?.args.pr).toBe("pr-1");
+ });
+
+ it("returns [] and never throws on gh error (Invariant 1)", async () => {
+ const spy = vi.spyOn(console, "error").mockImplementation(() => {});
+ mockInvokeReject("list_ci_checks", "gh not found");
+
+ const result = await useAppStore.getState().listCiChecks("pr-1");
+
+ expect(result).toEqual([]);
+ // Non-fatal: a CI query must not set the blocking store error.
+ expect(useAppStore.getState().error).toBeNull();
+ spy.mockRestore();
+ });
+});
+
+describe("fixCi", () => {
+ it("updates activeReview + lists to the Dispatched review on success", async () => {
+ const before = makeReview({ pr: "pr-1", gate_state: "InReview" });
+ const dispatched = makeReview({
+ pr: "pr-1",
+ gate_state: "Dispatched",
+ agent: makeAgentRun({ mode: "Fix" }),
+ });
+ useAppStore.setState({
+ activeReview: before,
+ reviews: [before],
+ frontier: [before],
+ });
+ mockInvoke("fix_ci", (args) => {
+ expect(args.pr).toBe("pr-1");
+ return dispatched;
+ });
+
+ await useAppStore.getState().fixCi("pr-1");
+
+ const state = useAppStore.getState();
+ expect(state.activeReview?.gate_state).toBe("Dispatched");
+ expect(state.reviews[0]?.gate_state).toBe("Dispatched");
+ expect(state.frontier[0]?.gate_state).toBe("Dispatched");
+ expect(state.error).toBeNull();
+ });
+
+ it("sets error (non-fatal) when dispatch rejects", async () => {
+ const before = makeReview({ pr: "pr-1", gate_state: "InReview" });
+ useAppStore.setState({ activeReview: before, reviews: [before] });
+ mockInvokeReject("fix_ci", "spawn failed");
+
+ await useAppStore.getState().fixCi("pr-1");
+
+ const state = useAppStore.getState();
+ expect(state.error).toContain("spawn failed");
+ expect(state.activeReview?.gate_state).toBe("InReview");
+ });
+});
+
+describe("requestChanges", () => {
+ it("no-ops when not on the diff view", async () => {
+ useAppStore.setState({ view: { kind: "prs" } });
+ mockInvokeReject("request_changes", "should not be called");
+
+ await useAppStore.getState().requestChanges();
+
+ expect(callsFor("request_changes")).toHaveLength(0);
+ expect(useAppStore.getState().error).toBeNull();
+ });
+
+ it("transitions the active review when on the diff view", async () => {
+ const before = makeReview({ pr: "pr-1", gate_state: "InReview" });
+ const dispatched = makeReview({ pr: "pr-1", gate_state: "Dispatched" });
+ useAppStore.setState({
+ view: { kind: "diff", pr: "pr-1" },
+ activeReview: before,
+ reviews: [before],
+ });
+ mockInvoke("request_changes", () => dispatched);
+
+ await useAppStore.getState().requestChanges();
+
+ expect(useAppStore.getState().activeReview?.gate_state).toBe("Dispatched");
+ });
+});
diff --git a/app/src/store.ts b/app/src/store.ts
index 170b8af..11e03ff 100644
--- a/app/src/store.ts
+++ b/app/src/store.ts
@@ -120,6 +120,15 @@ interface AppStore {
/** Approve a single review by PR ref (explicit user action). */
approveReview: (pr: string) => Promise;
+ /**
+ * Restack a stale review onto its parent's new head (explicit user action).
+ *
+ * A clean rebase clears the stale flag; on conflict the backend spawns the
+ * conflict-resolver agent and returns the review with an active agent run.
+ * Failure is non-fatal: it sets the store `error` and never blocks the loop.
+ */
+ restackPr: (pr: string) => Promise;
+
// -------------------------------------------------------------------------
// Config
// -------------------------------------------------------------------------
@@ -541,6 +550,23 @@ export const useAppStore = create((set, get) => ({
}
},
+ restackPr: async (pr: string) => {
+ try {
+ const review = await invoke("restack_pr", { pr });
+ const replace = (r: Review) => (r.pr === review.pr ? review : r);
+ set({
+ activeReview:
+ get().activeReview?.pr === review.pr ? review : get().activeReview,
+ authoredPrs: get().authoredPrs.map(replace),
+ reviewRequests: get().reviewRequests.map(replace),
+ frontier: get().frontier.map(replace),
+ reviews: get().reviews.map(replace),
+ });
+ } catch (e: unknown) {
+ set({ error: String(e) });
+ }
+ },
+
// -------------------------------------------------------------------------
// Config
// -------------------------------------------------------------------------
diff --git a/app/src/test/fixtures.ts b/app/src/test/fixtures.ts
new file mode 100644
index 0000000..9eb1ad1
--- /dev/null
+++ b/app/src/test/fixtures.ts
@@ -0,0 +1,72 @@
+/**
+ * Fully-typed fixtures for the domain types crossing the IPC boundary. Built
+ * against the generated `bindings/*` types so a schema change breaks the tests
+ * at compile time (the point of ts-rs codegen). Each factory takes a
+ * `Partial` of overrides so a test states only the fields it cares about.
+ */
+import type { Review } from "../bindings/Review";
+import type { GateState } from "../bindings/GateState";
+import type { CiCheck } from "../bindings/CiCheck";
+import type { AgentRun } from "../bindings/AgentRun";
+
+/** A minimal running agent, for the stale/agent-active edges. */
+export function makeAgentRun(overrides: Partial = {}): AgentRun {
+ return {
+ pid: 4242,
+ mode: "Fix",
+ started_at: { secs_since_epoch: 1_700_000_000, nanos_since_epoch: 0 },
+ prompt_hash: "deadbeef",
+ log_path: "/tmp/agent.log",
+ ...overrides,
+ };
+}
+
+/**
+ * A valid `Review`. `pr`/`id`/`issue` are branded newtypes in Rust but plain
+ * strings on the wire; the fixture supplies concrete strings and the factory
+ * signature keeps them typed as `Review`.
+ */
+export function makeReview(overrides: Partial = {}): Review {
+ const base: Review = {
+ id: "rev-1",
+ issue: "NEX-1",
+ pr: "https://github.com/o/r/pull/1",
+ branch: "alejandro/nex-1-do-thing",
+ base: "main",
+ base_sha: "abc123",
+ source: "Authored",
+ worktree: "/tmp/wt/rev-1",
+ gate_state: "InReview",
+ diff: { raw: "" },
+ head_sha: "def456",
+ comments: [],
+ parents: [],
+ children: [],
+ stale: false,
+ agent: null,
+ repo_slug: "o/r",
+ project: null,
+ };
+ return { ...base, ...overrides };
+}
+
+/** A valid `CiCheck` with pass defaults. */
+export function makeCheck(overrides: Partial = {}): CiCheck {
+ return {
+ name: "build",
+ state: "SUCCESS",
+ bucket: "pass",
+ link: "https://github.com/o/r/runs/1",
+ workflow: "CI",
+ ...overrides,
+ };
+}
+
+/** The five gate states, for exhaustive iteration in tests. */
+export const ALL_GATE_STATES = [
+ "Pending",
+ "InReview",
+ "Dispatched",
+ "Reworked",
+ "Approved",
+] as const satisfies readonly GateState[];
diff --git a/app/src/test/setup.ts b/app/src/test/setup.ts
new file mode 100644
index 0000000..68795af
--- /dev/null
+++ b/app/src/test/setup.ts
@@ -0,0 +1,16 @@
+/**
+ * Vitest global setup. Registered via `test.setupFiles` in `vite.config.ts`.
+ *
+ * Adds jest-dom matchers (`toBeInTheDocument`, `toHaveTextContent`, …) and
+ * resets the shared Tauri IPC mocks between tests so state never leaks across
+ * cases.
+ */
+import "@testing-library/jest-dom/vitest";
+import { afterEach } from "vitest";
+import { cleanup } from "@testing-library/react";
+import { resetTauriMocks } from "./tauri-mock";
+
+afterEach(() => {
+ cleanup();
+ resetTauriMocks();
+});
diff --git a/app/src/test/tauri-mock.ts b/app/src/test/tauri-mock.ts
new file mode 100644
index 0000000..e709e77
--- /dev/null
+++ b/app/src/test/tauri-mock.ts
@@ -0,0 +1,116 @@
+/**
+ * Typed test doubles for the Tauri IPC surface used by the store and
+ * components: `@tauri-apps/api/core`'s `invoke` and `@tauri-apps/api/event`'s
+ * `listen`.
+ *
+ * Tests register per-command handlers with {@link mockInvoke} instead of
+ * asserting against a bare `vi.fn()`, so each test states exactly what the
+ * backend would return (or reject with) for the commands it exercises. No
+ * `any`: handlers are typed `(args) => unknown`, and callers cast the awaited
+ * result at the call site where the command's return type is known.
+ *
+ * The `vi.mock` factories below live in this module so a single
+ * `vi.mock("@tauri-apps/api/core", …)` in each test file can delegate here.
+ */
+import { vi } from "vitest";
+
+/** A single command handler: receives the invoke args, returns/throws a value. */
+export type InvokeHandler = (args: Record) => unknown;
+
+/** A registered event listener callback (mirrors Tauri's `EventCallback`). */
+type EventCallback = (event: { readonly payload: unknown }) => void;
+
+/** Command name -> handler registry, consulted by the mocked `invoke`. */
+const handlers = new Map();
+
+/** Event name -> registered listener callbacks, driven by {@link emitEvent}. */
+const listeners = new Map>();
+
+/** Ordered record of every `invoke` call, for asserting call args. */
+export interface InvokeCall {
+ readonly cmd: string;
+ readonly args: Record;
+}
+const calls: InvokeCall[] = [];
+
+/** Register (or replace) the handler for a single command. */
+export function mockInvoke(cmd: string, handler: InvokeHandler): void {
+ handlers.set(cmd, handler);
+}
+
+/**
+ * Register a command whose handler rejects, to exercise error paths (the store
+ * treats a rejected `invoke` as non-fatal and sets `error`).
+ */
+export function mockInvokeReject(cmd: string, reason: unknown): void {
+ handlers.set(cmd, () => {
+ throw reason instanceof Error ? reason : new Error(String(reason));
+ });
+}
+
+/** Every recorded `invoke` call in order. */
+export function invokeCalls(): readonly InvokeCall[] {
+ return calls;
+}
+
+/** The recorded calls for a single command, in order. */
+export function callsFor(cmd: string): readonly InvokeCall[] {
+ return calls.filter((c) => c.cmd === cmd);
+}
+
+/** Fire a Tauri event to every listener registered for `name`. */
+export function emitEvent(name: string, payload: unknown): void {
+ const set = listeners.get(name);
+ if (set === undefined) return;
+ for (const cb of set) cb({ payload });
+}
+
+/** Number of live listeners for an event (asserts unlisten cleanup). */
+export function listenerCount(name: string): number {
+ return listeners.get(name)?.size ?? 0;
+}
+
+/** Clear all handlers, listeners, and recorded calls. Runs in `afterEach`. */
+export function resetTauriMocks(): void {
+ handlers.clear();
+ listeners.clear();
+ calls.length = 0;
+}
+
+/**
+ * The mocked `invoke`. Looks up the registered handler; an unregistered command
+ * rejects loudly so tests never silently pass against a missing stub.
+ */
+export const invoke = vi.fn(
+ (cmd: string, args?: Record): Promise => {
+ const resolvedArgs = args ?? {};
+ calls.push({ cmd, args: resolvedArgs });
+ const handler = handlers.get(cmd);
+ if (handler === undefined) {
+ return Promise.reject(
+ new Error(`no mock registered for invoke("${cmd}")`),
+ );
+ }
+ try {
+ return Promise.resolve(handler(resolvedArgs));
+ } catch (e: unknown) {
+ return Promise.reject(e instanceof Error ? e : new Error(String(e)));
+ }
+ },
+);
+
+/**
+ * The mocked `listen`. Records the callback and returns a Promise of an
+ * unlisten function that removes it, mirroring Tauri's contract so components
+ * can be asserted to clean up on unmount.
+ */
+export const listen = vi.fn(
+ (name: string, cb: EventCallback): Promise<() => void> => {
+ const set = listeners.get(name) ?? new Set();
+ set.add(cb);
+ listeners.set(name, set);
+ return Promise.resolve(() => {
+ set.delete(cb);
+ });
+ },
+);
diff --git a/app/vite.config.ts b/app/vite.config.ts
index 566d75e..f9256cd 100644
--- a/app/vite.config.ts
+++ b/app/vite.config.ts
@@ -1,5 +1,5 @@
import path from "path";
-import { defineConfig } from "vite";
+import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
@@ -15,4 +15,11 @@ export default defineConfig({
port: 5173,
strictPort: true,
},
+ test: {
+ environment: "jsdom",
+ globals: true,
+ setupFiles: ["./src/test/setup.ts"],
+ css: false,
+ include: ["src/**/*.test.{ts,tsx}"],
+ },
});
diff --git a/cockpit-docs/CLAUDE.md b/cockpit-docs/CLAUDE.md
deleted file mode 100644
index c4deb78..0000000
--- a/cockpit-docs/CLAUDE.md
+++ /dev/null
@@ -1,283 +0,0 @@
-# CLAUDE.md — Cockpit
-
-Operating manual for any agent working in this repository. Read this **and** `SPEC.md`
-before writing code. `SPEC.md` is the source of truth for *what* to build; this file is the
-source of truth for *how*. When they conflict, stop and ask.
-
----
-
-## 0. What this project is
-
-Cockpit is a local-first desktop tool (Rust core + Tauri 2 shell) that takes a Linear
-project to merged PRs through one review loop run at two gates: an optional project-level
-**plan gate** and a per-PR **diff gate**. See `SPEC.md` for the model.
-
-### Invariants (never violate these)
-
-1. **The local app is the source of truth.** GitHub PRs are published artifacts. Never make
- the loop block on a GitHub round-trip.
-2. **`cockpit-core` has no UI dependencies.** No `tauri`, no DOM, no framework. If a feature
- can't be driven from `cockpit-cli`, it doesn't belong in core yet.
-3. **One loop, written once.** The review loop is the `Gated` trait. Do not fork it per gate.
-4. **Comments are ephemeral.** A comment lives for one review→rework cycle and is cleared on
- `Reworked`. Do not add durable anchoring or a `resolved` flag.
-5. **Side effects require explicit confirmation.** Merge, force-push semantics, comment
- mirroring, and "approve plan → build the batch" never fire automatically or from agent
- output. See §9.
-6. **Never weaken tests to get green.** No `|| true`, no deleting/skipping tests, no loosening
- assertions to pass. If a test is wrong, say so; don't neuter it.
-
----
-
-## 1. Repository layout
-
-```
-cockpit/
-├── Cargo.toml # workspace
-├── rust-toolchain.toml # pinned toolchain, edition 2024
-├── crates/
-│ ├── cockpit-core/ # headless: domain, Gated loop, adapters, hook server
-│ │ └── src/
-│ │ ├── lib.rs
-│ │ ├── model.rs # Review, ProjectPlan, GateState, Anchor, …
-│ │ ├── gate.rs # the Gated trait + state transitions
-│ │ ├── adapters/ # linear.rs, github.rs, git.rs, agent.rs
-│ │ ├── prompt.rs # deterministic prompt assembly
-│ │ └── hook_server.rs # axum Stop-hook listener
-│ └── cockpit-cli/ # thin binary over core; the validation surface
-└── app/ # Tauri 2 shell
- ├── src-tauri/ # Rust side of the shell
- │ └── src/
- │ ├── main.rs # minimal entry
- │ ├── lib.rs # builder, state + handler registration
- │ ├── commands/ # thin #[tauri::command]s, delegate to cockpit-core
- │ ├── state.rs # AppState (holds core handles)
- │ └── error.rs # Serialize-able command error
- ├── src/ # frontend (Vite + React + TS)
- └── capabilities/ # least-privilege capability files
-```
-
-Both `cockpit-cli` and `app/src-tauri` depend on `cockpit-core`. Nothing depends the other
-way. Phases 0–2 (see `SPEC.md` §15) ship entirely in `cockpit-core` + `cockpit-cli`.
-
----
-
-## 2. Rust
-
-### Tooling (non-negotiable)
-
-- Edition **2024**, toolchain pinned in `rust-toolchain.toml`.
-- `cargo fmt` is law; never hand-format, never fight rustfmt.
-- `cargo clippy --all-targets --all-features -- -D warnings` must pass. Warnings are errors.
-- `uv`-equivalent discipline for deps: add with `cargo add`, keep `Cargo.lock` committed.
-
-### Naming (Rust API Guidelines)
-
-- `PascalCase` types/traits, `snake_case` values/functions/modules, `SCREAMING_SNAKE_CASE`
- consts. Acronyms are one word: `Uuid`, not `UUID`; `id`, not `ID`.
-- No `get_` prefix on getters: `review.head()`, not `review.get_head()`.
-- No module-name stutter: `gate::State`, not `gate::GateState` if it lives in `gate`.
-- Error variants read verb-object-error: `ParseBranchError`, not `BranchParseError`.
-- Conversions follow cost convention: `as_*` (free, borrowed), `to_*` (expensive), `into_*`
- (owned, consuming). Don't call something `as_x` if it allocates or validates.
-- No `-rs`/`-rust` in crate names.
-
-### Error handling — reason about caller intent
-
-The split is **not** "thiserror for libs, anyhow for apps" by rote. The question is whether
-the caller will branch on the failure mode.
-
-- **`cockpit-core` (callers branch on failures):** define typed errors with `thiserror`.
- One error enum per adapter/module, meaningful variants, `#[error("…")]` Display messages,
- `#[from]` for clean `?`. Example:
-
- ```rust
- #[derive(Debug, thiserror::Error)]
- pub enum GitError {
- #[error("worktree already exists at {0}")]
- WorktreeExists(PathBuf),
- #[error("rebase hit conflicts in {0} files")]
- RebaseConflict(usize),
- #[error(transparent)]
- Git2(#[from] git2::Error),
- }
- ```
-
-- **`cockpit-cli` and `src-tauri` (caller just reports and gives up):** use `anyhow::Result`
- with `.context("…")` / `.with_context(|| …)` to add the human-readable trail. Print the
- full chain with `{:#}`.
-
-- **No `unwrap`/`expect` in non-test code** except where you can prove the invariant in a
- comment (`// SAFETY:` / `// INVARIANT:`). Prefer `?`. Never `unwrap` across an `.await` or
- a thread/FFI boundary.
-
-### Types
-
-- **Newtype every ID.** `ReviewId`, `IssueRef`, `PrRef` are distinct types, not `String`s.
- This is the single highest-value habit in this codebase — it makes the DAG impossible to
- wire up wrong. `#[derive(Debug, Clone, PartialEq, Eq, Hash)]` plus `Serialize/Deserialize`.
-- **Enums, not booleans**, for anything with meaning. `GateState`, `AgentMode` — never a
- `bool is_planning`.
-- Derive the common traits eagerly where they make sense (`Debug`, `Clone`, `PartialEq`, and
- `Serialize`/`Deserialize` for anything crossing the IPC boundary).
-- Keep types `Send + Sync` so they move across tasks freely.
-- Make illegal states unrepresentable: prefer `enum Artifact { Plan(..), Diff(..) }` over a
- struct with two `Option`s.
-
-### Async (tokio)
-
-- One runtime. `cockpit-core` exposes async APIs; binaries own the runtime.
-- **Never hold a lock across `.await`.** Take the value out, drop the guard, then await.
-- No blocking calls in async context — shell out to `gh`/`git`/`claude` via
- `tokio::process`, not `std::process`, on hot paths.
-- Mind cancellation safety: anything spawned (agent runs, the hook server) must clean up its
- worktree/pid on drop or shutdown.
-
-### Modules, docs, comments
-
-- `///` doc comments on every public item, with at least one line of intent.
-- Comments explain *why*, not *what*. The code says what.
-- Keep functions small; push logic into core, keep adapters thin.
-
----
-
-## 3. TypeScript / frontend
-
-Stack: Vite + React + TypeScript, Zustand for app state, Monaco's diff editor for the diff
-gate, a markdown/structured renderer for the plan gate. Styling is your call (CSS modules or
-Tailwind) — keep it minimal and hand-understood.
-
-### The agent failure modes to avoid (read this twice)
-
-AI assistants reliably reach for the wrong tool here. In this repo:
-
-- **Never `any`.** Use `unknown` + a type guard. `any` is a design smell, not a fix.
-- **Never `as` to silence the compiler.** Use `satisfies` to validate a value against a type
- without widening it. `as` is reserved for genuinely unavoidable assertions, each with a
- comment justifying it.
-- **Model state with discriminated unions, not optional fields.** A gate is a tagged union
- on its state; the diff result is `{ ok: true; … } | { ok: false; … }`. This mirrors the
- Rust enums exactly and gives exhaustiveness.
-- **No stealth widening.** `as const` on literal tables; preserve literal types.
-
-### Config
-
-- `strict: true` with all strict flags on (TS 6 defaults to this — don't turn any off).
- `noUncheckedIndexedAccess`, `useUnknownInCatchVariables`, `exactOptionalPropertyTypes`
- on. `module: esnext`, ESM only.
-- `import type { … }` for type-only imports.
-- Lint/format: a single tool (Biome, or eslint+prettier) with no per-file disables.
-
-### Patterns
-
-- `interface` for object shapes; `type` for unions, intersections, and utilities.
-- **Branded types for IDs** to match the Rust newtypes:
- ```ts
- type ReviewId = string & { readonly __brand: "ReviewId" };
- ```
- A `ReviewId` must never be assignable from a raw `string`.
-- Exhaustiveness via `never`:
- ```ts
- function assertNever(x: never): never { throw new Error(`unreachable: ${x}`); }
- ```
- Every `switch` over a gate state ends in `default: return assertNever(state)`.
-- `readonly` on props and data that shouldn't mutate.
-- Function components only; obey the rules of hooks; no classes unless `instanceof` is
- genuinely needed.
-
----
-
-## 4. The IPC boundary (Rust ↔ TS)
-
-The Rust domain types and the TS types must not drift. **Generate the TS types from Rust**
-with `ts-rs` (`#[derive(TS)]` on the domain enums/structs); commit the generated `.ts` and
-fail CI if it's stale. A Rust `GateState` enum becomes a TS discriminated union for free,
-which is exactly what the frontend should switch on.
-
-Tauri command rules:
-
-- Commands are **thin**. They parse params, call into `cockpit-core`, and map the result.
- All logic lives in core.
-- `State<'_, Arc>` — the `Arc` is required because the hook server and agent runs
- touch state from background tasks.
-- Register **every** command in a single `generate_handler!` — there is no compile-time
- check that you did.
-- Command errors implement `Serialize` (a dedicated `CommandError` in `app/src-tauri/error.rs`
- that converts from core's `thiserror` enums). Don't leak `anyhow` across IPC.
-- Use Tauri **events** for the push direction: the Stop-hook reconcile emits an event the
- frontend listens for to flip a PR to `reworked`. Don't poll.
-- Keep capabilities least-privilege; don't broaden a capability speculatively.
-- Never block the main thread — long work goes to core/async.
-
----
-
-## 5. Testing
-
-- **Rust:** `cargo test`. The priority target is the **`Gated` state machine and the loop** —
- every transition in `SPEC.md` §7 has a test, including the failure transitions
- (agent-failed → InReview) and the `stale`/restack edges. Adapters get integration tests
- behind a feature flag or a local fixture; don't mock what you can run.
-- **Do not over-mock.** Mocking the thing under test to make a test pass is worse than no
- test. Test real behavior against real (local) git/worktrees where feasible.
-- **TS:** Vitest. Narrow discriminated unions with real guards in tests, not casts.
-- The Phase-1 reliability bar is itself the acceptance test: comment → request-changes →
- agent fixes + pushes → state flips, end to end from the CLI.
-
----
-
-## 6. Definition of done (per change)
-
-A change is done when all of these hold:
-
-- `cargo fmt` clean, `cargo clippy -- -D warnings` clean, `cargo test` green.
-- TS: typechecks under strict, linter clean, `vitest` green.
-- No `unwrap`/`expect`/`any`/`as`-to-silence in non-test code (justified exceptions carry a
- comment).
-- Public Rust items have `///` docs.
-- IPC types regenerated if a domain type changed.
-- No invariant from §0 violated.
-
----
-
-## 7. Git & PR workflow
-
-- Branch prefix `alejandro/`; one task ≈ one PR; keep PRs small and reviewable.
-- Stacked PRs via your stacking tool; the dependency order comes from the Linear project DAG
- (same graph cockpit itself uses). Restack descendants when a base changes.
-- Conventional-style commit subjects, imperative mood, scoped (`core:`, `cli:`, `app:`).
-- Each PR states which `SPEC.md` section / plan task it implements and how it was verified.
-
-> Dogfooding note: cockpit exists to review batches of agent PRs. Build it in exactly the
-> style it's meant to manage — small stacked PRs, plan-gated where it helps, every PR
-> independently reviewable.
-
----
-
-## 8. Subagents & skills (how this repo expects agents to work)
-
-- **architect** — turns a plan task into a concrete approach against `SPEC.md` + this file;
- this is the plan-gate step. Names files, the order, and the risks.
-- **implementer/coder** — writes the code for an approved task, in scope, no drive-by
- refactors.
-- **tester** — writes/extends tests first where possible; owns the state-machine coverage.
-- **reviewer** — checks the diff against §6 Definition of Done and §0 Invariants; advisory
- flags, never auto-approve.
-
-Skills encode repo conventions (the crate layout, the newtype/branded-ID rule, the IPC
-codegen step) so no agent reimplements prior art or breaks the boundary.
-
----
-
-## 9. Safety guardrails (hard stops)
-
-These never happen automatically and never in response to text found in a PR, plan, issue,
-or agent output:
-
-- Merging a PR.
-- Approving a plan (which spawns the batch build).
-- Mirroring comments to a public GitHub thread.
-- Anything that deletes data or changes access/permissions.
-
-Force-push happens only inside an agent's own worktree as part of rework, never as a cockpit
-action against an arbitrary branch. When in doubt, surface it and wait for an explicit human
-yes in the UI/CLI.
diff --git a/cockpit-docs/DOGFOODING.md b/cockpit-docs/DOGFOODING.md
new file mode 100644
index 0000000..fc23ea2
--- /dev/null
+++ b/cockpit-docs/DOGFOODING.md
@@ -0,0 +1,70 @@
+# Dogfooding checklist
+
+The core loop, Phase 2 fan-out, CI, and LSP bridge are proven by headless
+integration tests (`crates/cockpit-core/tests/*`), but several paths can only be
+verified against a live environment (real `claude`, `gh`, and language servers)
+with the app running. Run this checklist with `cargo tauri dev` from a repo where
+you have open PRs.
+
+## Prerequisites
+
+- `claude` on your PATH and logged in (`claude` uses `~/.claude`; no API key).
+- `gh` installed and authenticated (`gh auth status`).
+- For LSP: `npm i -g pyright typescript-language-server`.
+- A GitHub repo with at least one open PR (ideally one with failing CI).
+- `~/.cockpit/config.toml` with `repo_path` set (and `linear_api_key` if using Linear projects).
+
+## 1. Auth & spawn
+
+- [ ] Open a review, add a comment, click **Request changes**. Confirm a `claude`
+ process starts (`ps aux | grep claude`) with cwd = the review's worktree
+ under `$HOME/.cockpit/worktrees/...`, and a log appears in `$HOME/.cockpit/logs/`.
+- [ ] Confirm nothing is written into the reviewed repo's tree (no in-tree `.cockpit/`).
+
+## 2. Diff-gate loop (the reliability bar)
+
+- [ ] comment → Request changes → agent edits + pushes in its worktree → Stop-hook
+ fires → the PR flips to **Reworked** and the ephemeral comments clear.
+- [ ] The Agent tab streams events live; the diff refreshes to the new head.
+
+## 3. Plan gate + Phase 2 (per project)
+
+- [ ] Create a Project (blank or from Linear). Open it → **Generate plan** → the
+ planner runs and the plan doc populates (Pending).
+- [ ] Open for review → add a plan comment → **Request changes** → planner reworks → Reworked.
+- [ ] **Approve & build** (confirm dialog) → N implementers fan out, one worktree
+ each, bounded by `max_parallel_agents`; only THIS project's frontier reviews spawn.
+- [ ] Open a second project; confirm its plan/batch state is independent.
+
+## 4. CI tab
+
+- [ ] On a PR with CI, the **CI** tab lists checks grouped by workflow with pass/fail/pending.
+- [ ] A failing pipeline auto-expands and loads its failed-run logs inline; a passing
+ one stays collapsed and triggers no `gh` call.
+- [ ] For an expired/in-progress run (HTTP 410), the panel shows the "logs unavailable"
+ message and a working **View run on GitHub** link.
+- [ ] **Fix CI failures** (confirm) dispatches the fixer with the CI logs in the prompt.
+
+## 5. External links
+
+- [ ] The check link, and DiffView's PR/issue/repo links, open in your system browser
+ (opener plugin). If a link fails, a visible error appears (not a silent no-op).
+
+## 6. LSP
+
+- [ ] Open a `.py` and a `.ts` file in the diff editor; confirm diagnostics/hover/completion
+ appear (needs pyright / typescript-language-server installed).
+- [ ] Close reviews / quit the app; confirm no orphan `pyright`/`tsserver` processes remain
+ (`ps aux | grep -E 'pyright|typescript-language-server'`).
+
+## 7. Restack
+
+- [ ] For a stale review (parent reworked), the **Restack** button appears; clicking it
+ rebases onto the parent's new head, or spawns the conflict-resolver on conflict.
+
+## Known limitations to watch for
+
+- The plan doc is ingested from `~/.cockpit/plans/.md` written by the planner;
+ if the planner doesn't write there, the plan stays empty (check the log).
+- Skills are read from `~/.cockpit/skills/*/SKILL.md` and filtered by the diff's file
+ extensions; verify a relevant skill actually appears in the agent's prompt (log).
diff --git a/cockpit-docs/IMPLEMENTATION_PLAN.md b/cockpit-docs/IMPLEMENTATION_PLAN.md
deleted file mode 100644
index d39b8dd..0000000
--- a/cockpit-docs/IMPLEMENTATION_PLAN.md
+++ /dev/null
@@ -1,198 +0,0 @@
-# IMPLEMENTATION_PLAN.md — Cockpit
-
-A task graph for building cockpit with the architect → coder → tester → reviewer pipeline.
-Source of truth: `SPEC.md` (what) and `CLAUDE.md` (how). This file decomposes the SPEC's
-phases into tasks sized at roughly one stacked PR each.
-
-## How to run this plan
-
-For every task:
-
-1. **architect** drafts the approach against `SPEC.md` + `CLAUDE.md` (files, order, risks).
- This is the plan-gate step — get it approved before code.
-2. **coder** implements only the approved task. No drive-by refactors, no scope creep.
-3. **tester** writes/extends tests; for state-machine tasks, tests come first.
-4. **reviewer** checks the diff against `CLAUDE.md` §6 (Definition of Done) and §0
- (Invariants). Advisory only; a human approves.
-
-Tasks are stacked in dependency order (the same DAG model cockpit itself manages). A task is
-mergeable only when its acceptance criteria pass. **Build in validation order, not runtime
-order** — the shared loop is proven on the diff gate (Phase 1) before the plan gate reuses it.
-
-Legend: `[deps: …]` lists prerequisite task IDs. Each task names its primary subagent owner.
-
----
-
-## Phase 0 — Core skeleton + adapters (headless)
-
-Goal: `cockpit project ` reads issues, builds the DAG, prints the frontier;
-`cockpit ingest` lists existing PRs. No loop yet.
-
-### T0.1 — Workspace + toolchain scaffold · architect→coder · [deps: —]
-Create the Cargo workspace (`cockpit-core`, `cockpit-cli`), `rust-toolchain.toml` (edition
-2024), CI running `fmt`, `clippy -D warnings`, `test`.
-**Done when:** empty crates build and CI is green on all three checks.
-
-### T0.2 — Domain model + newtypes · coder · [deps: T0.1]
-Implement `model.rs`: `ReviewId`/`IssueRef`/`PrRef` newtypes, `Review`, `ProjectPlan`,
-`GateState`, `Artifact`, `PlanDoc`, `Comment`, `Anchor`, `AgentRun`/`AgentMode` per `SPEC.md`
-§6. Derive the required traits; comments ephemeral (no `resolved`, no durable sha).
-**Done when:** types compile; a unit test constructs a small DAG of `Review`s and asserts the
-parent/child edges.
-
-### T0.3 — `Gated` trait + state transitions · tester→coder · [deps: T0.2]
-Implement `gate.rs`: the `Gated` trait and every transition in `SPEC.md` §7 as explicit
-functions, plus the `stale` flag logic. **Tests first.**
-**Done when:** a test exercises every transition including failure edges (agent-failed →
-InReview) and stale set/clear; illegal transitions are rejected.
-
-### T0.4 — Linear adapter (read-only) · coder · [deps: T0.2]
-`adapters/linear.rs`: read a project's issues + dependency relations via GraphQL; build the
-DAG. `thiserror` error type.
-**Done when:** given a project id, returns issues + edges; integration test against a fixture.
-
-### T0.5 — GitHub adapter (gh shell-out) + branch linkage · coder · [deps: T0.2]
-`adapters/github.rs`: `pr list --json`, `pr diff`, `pr checks` via `gh`. Parse the Linear
-issue id out of each PR head branch to link PR → issue (per `SPEC.md` §16, settled).
-**Done when:** lists PRs with diffs; branch→issue parsing covered by unit tests incl. edge
-cases (no id, malformed).
-
-### T0.6 — Git adapter (worktrees) · coder · [deps: T0.2]
-`adapters/git.rs` with `git2`: `ensure_worktree` (stacked base = parent branch),
-`reconcile`, `prune_worktree`. Restack stubbed (Phase 3).
-**Done when:** can create/reconcile/prune a worktree against a scratch repo in tests.
-
-### T0.7 — `cockpit project` + `cockpit ingest` CLI · coder · [deps: T0.3,T0.4,T0.5]
-Wire the CLI to build the DAG, compute the frontier (`SPEC.md` §5/§8), and print it.
-**Done when:** both commands produce correct frontier/PR output against a real test project.
-
-**Phase 0 exit:** the frontier prints correctly from real data; all adapters tested.
-
----
-
-## Phase 1 — The loop at the diff gate (headless) ★ critical milestone
-
-Goal: prove the shared loop round-trips end to end from the CLI. **This is the product in
-miniature; everything after is leverage on top.**
-
-### T1.1 — Deterministic prompt assembly · architect→coder · [deps: T0.2]
-`prompt.rs`: build the rework prompt per `SPEC.md` §9 — intent, approved plan (diff gate),
-current artifact, anchored comments, scope guard incl. the test-weakening clause. Hash + log
-the assembled prompt.
-**Done when:** golden-file tests assert exact prompt structure for a sample review.
-
-### T1.2 — Agent spawn (PTY) · coder · [deps: T0.6, T1.1]
-`adapters/agent.rs`: spawn `claude` in a worktree via PTY (reuse Plannotator's PTY),
-`AgentMode::Fix`, capture logs, track pid, map `session_id → (object, mode)`.
-**Done when:** spawns a process in a worktree and records the session mapping; covered by a
-test with a stub command.
-
-### T1.3 — Stop-hook listener (axum) · coder · [deps: T0.3, T1.2]
-`hook_server.rs`: axum server on a fixed localhost port; `/hook/stop` maps session → object,
-calls `reconcile` (re-read git, rerun ci/test deltas), clears ephemeral comments, →
-`Reworked`, emits a completion signal.
-**Done when:** a simulated POST drives a `Dispatched` review to `Reworked` and clears its
-comments.
-
-### T1.4 — `comment add` + `request-changes` CLI · coder · [deps: T1.1,T1.2,T1.3]
-CLI verbs: add an anchored comment; `request-changes ` gathers open comments → assembles
-prompt → spawns fixer → `Dispatched`.
-**Done when:** the full verb set works on a real PR.
-
-### T1.5 — End-to-end round-trip test · tester · [deps: T1.4]
-The reliability bar: comment → request-changes → agent fixes → pushes → Stop hook → state
-flips to `Reworked`, comments cleared, ready for re-review — no manual terminal step.
-**Done when:** this runs green against a real (small) PR + agent.
-
-**Phase 1 exit:** the loop round-trips reliably. If it does, the rest is presentation and
-reuse; if it doesn't, fix it here before anything else.
-
----
-
-## Phase 2 — Batch kickoff + optional plan gate
-
-Goal: originate work from a Linear project; reuse the Phase-1 loop on a `ProjectPlan`.
-
-### T2.1 — Plan-doc format + parser · architect→coder · [deps: T0.2]
-Decide and pin a structured plan-output format via the planner subagent's instructions
-(resolve `SPEC.md` §16 open item), parse it into `PlanDoc` (steps + files + risks as
-anchors).
-**Done when:** parser round-trips the pinned format; anchors resolve to steps/files.
-
-### T2.2 — Planner spawn + plan gate via `Gated` · coder · [deps: T2.1, T1.x]
-Run the loop on `ProjectPlan` with `AgentMode::Plan`; reconcile re-parses the plan doc.
-**Done when:** a plan can be commented on and re-planned through the same transitions as a
-diff review.
-
-### T2.3 — `cockpit kickoff ` · coder · [deps: T2.2, T0.7]
-Kick off: optionally produce a plan (→ plan gate) or skip; on approval/skip, spawn the
-implementer for every issue, establishing stacked worktrees (base = parent branch). Each
-build opens a PR → a `Review` at the diff gate.
-**Done when:** a project goes plan→approved→batch-of-PRs (and skip→PRs) with no manual steps.
-
-**Phase 2 exit:** a project can be taken from issues to a batch of diff-gate reviews.
-
----
-
-## Phase 3 — Restack on rework
-
-### T3.1 — Restack algorithm · architect→coder · [deps: T0.6, T0.3]
-Base `Reworked` marks descendants `stale` at dispatch; rebase each descendant in dependency
-order via `git2`; clean rebases are pure git.
-**Done when:** a 3-PR stack reworked at the base auto-restacks the upper two cleanly.
-
-### T3.2 — Conflict-resolver dispatch · coder · [deps: T3.1, T1.2]
-On rebase conflict, spawn the conflict-resolver subagent (`AgentMode::Restack`); on success
-clear `stale`.
-**Done when:** an induced conflict is resolved via agent and the stack settles.
-
-**Phase 3 exit:** stacked rework is hands-off except for genuine conflicts.
-
----
-
-## Phase 4 — Tauri shell
-
-Goal: wrap the proven core. The shell is presentation over working logic.
-
-### T4.1 — Tauri scaffold + state + IPC codegen · architect→coder · [deps: Phase 1]
-`app/src-tauri` with `AppState` holding core handles (`Arc`), `generate_handler!`
-registration, `CommandError` (Serialize) mapping from core errors, `ts-rs` codegen for
-domain types with a CI staleness check. Least-privilege capabilities.
-**Done when:** a trivial command round-trips and generated TS types are committed + checked.
-
-### T4.2 — Frontier view + agent status · coder · [deps: T4.1]
-React + Zustand: the frontier list, per-object agent status, gate controls; subscribe to the
-Stop-hook completion event to flip state live.
-**Done when:** the morning frontier renders and updates on agent completion.
-
-### T4.3 — Diff gate UI (Monaco) · coder · [deps: T4.2]
-Monaco diff editor with inline comment threads + the `ci_delta`/`test_count_delta` flags;
-`request changes` calls the command. Do not hand-roll the diff viewer.
-**Done when:** a real PR is reviewed and reworked entirely from the desktop app.
-
-### T4.4 — Plan gate UI · coder · [deps: T4.2, T2.2]
-Render `PlanDoc` as a commentable document (Plannotator annotation port); approve → build.
-**Done when:** a plan is reviewed and approved from the app, triggering the batch.
-
-**Phase 4 exit:** the full loop is usable from the desktop app, both gates.
-
----
-
-## Phase 5 — Polish
-
-- **T5.1** Batch-approve the clean frontier (size/CI/test-delta heuristics, advisory).
-- **T5.2** Optional GitHub comment mirror (confirmed side effect).
-- **T5.3** Multi-stack view.
-
----
-
-## Critical path & risks
-
-- **Critical path:** T0.1 → T0.2 → T0.3 → (T1.1,T1.2,T1.3) → T1.4 → **T1.5**. Everything
- downstream assumes the loop proven at T1.5.
-- **Top risk:** the Stop-hook → reconcile round-trip (T1.3). It's the difference between a
- loop and babysitting. De-risk it first inside Phase 1; reuse the Plannotator interceptor.
-- **Second risk:** plan-doc parsing (T2.1). Pin the format or anchors will be flaky. Doesn't
- block Phase 1.
-- **Do not** start Phase 4 (Tauri) before T1.5 is green. Building UI over an unproven loop is
- the main way this project would waste a week.
diff --git a/cockpit-docs/SPEC.md b/cockpit-docs/SPEC.md
deleted file mode 100644
index 9161a11..0000000
--- a/cockpit-docs/SPEC.md
+++ /dev/null
@@ -1,301 +0,0 @@
-# SPEC.md — Cockpit
-
-A local-first desktop tool that takes a Linear project to merged PRs. A project may start
-from an existing plan or a kickoff; if a plan exists it is reviewed once at a project-level
-**plan gate**; then the agent implements the whole batch of PRs; then each PR is reviewed
-at a per-PR **diff gate** with a Monaco diff and in-app comments, and `request changes`
-hands the comments back to the agent.
-
-Working name: `cockpit`. Stack: Rust core + Tauri 2 shell. Reuses the PTY + axum
-hook-interception (and, for the plan gate, the annotation surface) from Plannotator.
-
----
-
-## 1. Problem
-
-A Linear project is a graph of dependent issues handed to local Claude Code agents. Two
-things are missing from today's flow and both are expensive:
-
-- **No plan gate.** When there is a project plan, the human doesn't get to confirm the
- approach before the batch is built, so wrong-approach mistakes surface at diff-review
- time (expensive) instead of plan time (free).
-- **No reliable rework loop.** Comments don't reliably reach the agent, the branch
- doesn't reliably update, and re-review is unstructured — worse under a stack, where
- reworking a base invalidates everything above it.
-
-## 2. Goal
-
-One reliable review loop, run on one optional project plan and on each PR diff. "Reliable"
-means every state transition is explicit, the agent reliably picks up comments, the
-artifact reliably updates, and the stack reliably restacks.
-
-Non-goals (v1):
-
-- AI that reviews the code *for* you (CodeRabbit/Greptile do that). cockpit is a human
- review cockpit; a reviewer subagent may pre-flag, advisory only.
-- Cloud execution. Agents run locally in worktrees — the whole advantage.
-- Team/multi-user, auth, sharing. Single-user, local.
-
-## 3. Core principle
-
-**The local app is the source of truth; the GitHub PR is a published artifact.**
-
-Reviewed objects are tied to worktrees on disk, not to GitHub's review API. Comments live
-in the app's local store, anchored to a location in the current artifact so they survive
-the artifact changing. Rework is dispatched in-process to a `claude` run in the worktree.
-GitHub is touched only at the edges (read PRs/diffs, optionally mirror comments, merge).
-
-## 4. One loop, two places it runs
-
-The review loop is a trait, implemented by two kinds of object:
-
-- `ProjectPlan` — **optional, one per project.** Runs the loop once on the plan doc.
-- `Review` — **one per PR.** Runs the loop on the PR diff.
-
-```
-review loop (shared):
- Pending → InReview → (request changes) Dispatched → Reworked → InReview → … → Approved
-```
-
-`ProjectPlan::Approved` triggers implementing the whole batch. `Review::Approved` triggers
-merge. The plan gate can be absent entirely (no plan → skip straight to implementation).
-
-Lifecycle (runtime order):
-
-```
-New project
- ├─ plan exists → PROJECT PLAN GATE (loop on the plan) ─┐
- └─ kick off → (optional plan, else skip) ──────────────┤
- plan approved / skipped
- ▼
- Implement all PRs (agent builds the batch)
- ▼
- per PR: DIFF GATE (loop on the diff)
- ▼
- Merge (+ restack on rework)
-```
-
-## 5. Entry + kickoff
-
-- Read the Linear project's issues and dependency relations → build the DAG (the same
- graph used for restack).
-- **If a plan exists** (Claude produced one, or one is attached): load it into a
- `ProjectPlan` and enter the plan gate.
-- **If kicking off:** optionally spawn the planner to produce a project plan (→ plan
- gate), or skip planning and go straight to implementation.
-- **On plan approval (or skip):** spawn implementation for every issue. Establish a
- worktree per issue; a stacked issue's worktree base is its parent issue's branch, so the
- stack is wired at build time. Each build opens a PR → a `Review` at the diff gate.
-- **Linkage:** branches follow Linear's generated name with the issue id embedded, so
- cockpit maps PR → issue by parsing the branch. This drives both the diff-gate↔issue link
- and the stack edges.
-
-## 6. Data model
-
-```rust
-struct ProjectPlan { // optional, one per project
- project: ProjectRef,
- doc: PlanDoc,
- gate_state: GateState, // shared loop
- comments: Vec,
- agent: Option, // planner
-}
-
-struct Review { // one per PR (diff gate)
- id: ReviewId,
- issue: IssueRef,
- pr: PrRef,
- branch: String,
- base: String, // base branch OR parent's branch (stacked)
- worktree: PathBuf,
- gate_state: GateState, // shared loop
- diff: DiffData,
- head_sha: String,
- comments: Vec,
- parents: Vec, // ancestors in the stack (from Linear deps)
- children: Vec,
- stale: bool, // an ancestor is in rework; don't deep-review yet
- agent: Option, // fixer / restack
-}
-
-/// The shared loop. Both ProjectPlan and Review implement it.
-trait Gated {
- fn gate_state(&self) -> GateState;
- fn comments(&self) -> &[Comment];
- fn dispatch(&mut self) -> Result; // assemble prompt + spawn in worktree
- fn reconcile(&mut self) -> Result<()>; // after Stop hook: re-read artifact
-}
-
-enum GateState { Pending, InReview, Dispatched, Reworked, Approved }
-
-struct PlanDoc { // parsed from plan output
- summary: String,
- steps: Vec, // ordered; comment anchors
- files: Vec, // intended touch set; comment anchors
- risks: Vec, // migrations, new deps, breaking changes
- raw: String,
-}
-
-// Ephemeral: a comment lives for one review→rework cycle, cleared on Reworked.
-struct Comment {
- id: CommentId,
- anchor: Anchor, // points into the *current* artifact only
- body: String,
- origin: CommentOrigin, // Local | GitHubMirror
-}
-
-enum Anchor {
- PlanStep(usize),
- PlanFile(PathBuf),
- DiffLine { path: PathBuf, range: (u32, u32) }, // current head; not durable
-}
-
-struct AgentRun { pid: u32, mode: AgentMode, started_at: Instant, prompt_hash: String, log_path: PathBuf }
-enum AgentMode { Plan, Implement, Fix, Restack }
-```
-
-## 7. State machine
-
-The shared loop (applies to `ProjectPlan` and to each `Review`):
-
-| From | Event | To |
-|------------|----------------------------------|------------|
-| Pending | open in cockpit | InReview |
-| InReview | request changes (≥1 comment) | Dispatched |
-| InReview | approve | Approved |
-| Dispatched | Stop hook + artifact reconciled | Reworked |
-| Dispatched | agent failed / no change | InReview |
-| Reworked | open in cockpit | InReview |
-
-Cross-object / DAG transitions:
-
-| From | Event | To |
-|----------------------------|--------------------------------|--------------------------|
-| ProjectPlan / Approved | spawn implementation (batch) | N Reviews / Pending |
-| (no plan) | skip planning | N Reviews / Pending |
-| Review / Approved | merge succeeds → prune worktree | Merged (terminal) |
-| Review | a parent enters Dispatched | this.stale = true |
-| Review.stale | parent Reworked + restack ok | this.stale = false |
-
-`stale` gates the *frontier* (what's safe to deep-review), not the loop.
-
-## 8. The loop (must not break)
-
-Same steps for the plan gate and each diff gate; only the artifact, `AgentMode`, and the
-reconcile step differ.
-
-1. **Render artifact.** Plan gate → `PlanDoc` as a commentable document (steps + file set
- + risks; Plannotator port). Diff gate → Monaco diff with inline `ci_delta` and
- `test_count_delta` flags.
-2. **Comment.** In-app, stored locally, anchored.
-3. **Request changes.** Gather all open comments → one rework request.
-4. **Assemble prompt.** §9. Deterministic, hashed, logged.
-5. **Spawn.** `claude` in the worktree via PTY. Plan gate → plan mode (planner). Diff gate
- → fixer.
-6. **Agent works.** Plan gate → revised plan. Diff gate → edit, test, commit,
- `git push --force-with-lease`.
-7. **Close the loop.** Claude Code Stop hook POSTs the axum endpoint (§11). cockpit maps
- session → object, calls `reconcile` (re-parse plan, or re-read git + rerun `ci_delta`),
- clears the dispatched comments (they're ephemeral), → `Reworked`.
-8. **Re-review / advance.** Reworked → InReview. Plan approved → implement the batch. Diff
- approved → merge. On any base change with children, restack (§13).
-
-v1 reliability bar: steps 1–7 round-trip on one real PR at the diff gate, from the CLI.
-
-## 9. Prompt assembly
-
-Deterministic, ordered. Prevents the "agent misses the point and loops" failure.
-
-Plan prompt: project intent + issue list + dependency notes + conventions/skills →
-"produce a plan; name the files, the order, and the risks."
-
-Rework prompt (either gate):
-1. Intent (project plan, or the issue's acceptance criteria).
-2. The approved plan (diff gate only — the contract the code was built against).
-3. The current artifact (plan doc, or diff).
-4. Gathered comments, each with its anchor rendered.
-5. Scope guard: "Address only the comments above. Don't refactor unrelated code. Don't
- weaken or delete tests. If a comment is wrong or impossible, stop and say so." The
- test-weakening clause is the highest-ROI line — agents reach for `|| true` and test
- deletion to get green.
-
-## 10. Subagents & skills
-
-cockpit selects a subagent per dispatch by `AgentMode`; definitions live in the repo's
-`.claude/`:
-
-- **planner** — project plan + plan rework (plan mode).
-- **implementer** — initial batch build of all PRs from the approved plan.
-- **fixer** — diff-gate rework (scoped execute).
-- **reviewer** — optional ingest pre-flag, advisory only.
-- **conflict-resolver** — restack conflicts.
-
-Skills encode repo conventions (monorepo layout, "prefer existing util X") so neither the
-planner nor the fixer reimplements prior art.
-
-## 11. Stop-hook listener
-
-`cockpit-core` runs an axum server on a fixed localhost port. The repo's Claude Code config
-registers a Stop hook that POSTs `{ session_id }` to `/hook/stop`. cockpit keeps a
-`session_id → (object, AgentMode)` map populated at spawn; on callback it reconciles the
-right artifact and transitions. Reuse the Plannotator interceptor wholesale.
-
-## 12. Guardrails on side effects
-
-Require explicit UI confirmation; never triggered by ingested content or agent output:
-`gh pr merge`; mirroring local comments to the GitHub thread; approving the plan (which
-spawns the batch build). Force-push happens inside the agent's worktree, not from cockpit.
-
-## 13. Restack-on-rework
-
-When a base `Review` reaches `Reworked`, descendants were already marked `stale` at
-dispatch. Rebase each descendant onto the new base in dependency order: clean rebases via
-`git2`; conflicts spawn the conflict-resolver subagent. Successful restack clears `stale`.
-
-## 14. Adapters
-
-- **linear.rs** — read project issues + dependency relations (DAG). GraphQL. cockpit writes
- nothing to Linear in v1.
-- **github.rs** — shell out to `gh` (`pr list --json`, `pr diff`, `pr checks`, `pr merge`).
- Parses the Linear issue id out of each PR's head branch to link PR → issue.
-- **git.rs** — `git2`: `ensure_worktree` (stacked base = parent branch), `reconcile`,
- `restack`, conflict detection, `prune_worktree` (called on `Merged`).
-- **agent.rs** — PTY spawn of `claude` (plan / implement / fix), plan-doc parsing, prompt
- assembly, log capture, pid tracking.
-
-## 15. Build phases
-
-Validation order, not runtime order — build and prove the shared loop first (on the diff
-gate), then reuse it for the plan gate, because both share it.
-
-- **Phase 0 — core + adapters, headless.** Domain model, `Gated` trait + state machine,
- all adapters. Proof: `cockpit project ` reads issues, builds the DAG, prints the
- frontier; `cockpit ingest` lists existing PRs.
-- **Phase 1 — the loop at the diff gate, headless.** `comment add`, `request-changes`,
- spawn fixer, Stop-hook reconcile to `Reworked`. Proof: comment → dispatch → agent fixes
- + pushes → state flips, all from the CLI. **The product in miniature.**
-- **Phase 2 — batch kickoff + optional plan gate.** Reuse the loop with the planner on a
- `ProjectPlan`; `cockpit kickoff ` plans (or skips), approve, then the
- implementer spawns all PRs. Proof: a project goes plan→approved→batch of PRs (or
- skip→PRs) with no manual terminal steps.
-- **Phase 3 — restack-on-rework.** Base rework marks children stale; auto-rebase; conflict
- resolver only on conflict. Proof: a 3-PR stack reworked at the base.
-- **Phase 4 — Tauri shell.** Wrap the proven core. Frontier list; Monaco diff + in-app
- comment threads (diff gate); plan renderer (Plannotator port, plan gate); per-object
- agent status. Don't hand-roll the diff viewer — Monaco's diff editor.
-- **Phase 5 — polish.** Batch-approve the clean frontier, optional GitHub comment mirror,
- multi-stack view.
-
-## 16. Decisions
-
-- **Issue→PR linkage — settled.** Linear embeds the issue identifier in the branch name it
- generates (e.g. `alejandro/nex-123-...`). github.rs parses the issue id from the PR head
- branch; no PR-body markers or attachment lookups needed.
-- **Comments are ephemeral — settled.** A comment lives for one review→rework cycle. On
- `Reworked` the dispatched comments are cleared; the next cycle starts fresh on the new
- artifact. No fuzzy re-anchoring, no durability across diff churn.
-- **Worktree GC — settled.** On `Merged`, prune the worktree (git.rs `prune_worktree`).
-- **Axum port — open.** Default to a single fixed localhost port (single-user); revisit
- only if it ever needs to be per-project.
-- **Plan-doc parsing — open.** Pin a structured plan-output format via the planner
- subagent, or parse loose markdown? Pinning makes the plan anchors reliable.