Compare commits

..
687 Commits
Author SHA1 Message Date
github-actions[bot] 36ef01446a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-11-02 09:31:44 +00:00
github-actions[bot] 4c3b906179 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-11-02 06:43:53 +00:00
github-actions[bot] 6c5678feae docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-11-02 03:30:30 +00:00
github-actions[bot] dd27aeb809 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-11-01 18:27:36 +00:00
github-actions[bot] 7b5b1c6638 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-11-01 15:27:29 +00:00
github-actions[bot] feb20d7bc7 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-11-01 12:29:08 +00:00
github-actions[bot] 838996a49d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-11-01 03:30:25 +00:00
github-actions[bot] 3ed49c449f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-31 21:27:14 +00:00
github-actions[bot] 60e562d14d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-31 18:27:13 +00:00
github-actions[bot] ebaa192dee docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-31 12:28:48 +00:00
github-actions[bot] 2e1d4a02fc docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-31 09:27:05 +00:00
github-actions[bot] 2fdd27f677 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-30 21:28:19 +00:00
github-actions[bot] ea04298ad9 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-30 15:30:09 +00:00
github-actions[bot] 4bcb0cb108 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-30 12:39:37 +00:00
github-actions[bot] 20b55ab83a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-30 09:31:08 +00:00
github-actions[bot] ac152963be docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-30 06:40:51 +00:00
github-actions[bot] a4ad7f2bcf docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-30 03:31:57 +00:00
github-actions[bot] 89fe3be501 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-30 00:52:48 +00:00
github-actions[bot] 7314445da4 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-29 15:33:04 +00:00
github-actions[bot] 42b76f3272 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-29 09:32:26 +00:00
github-actions[bot] 73f609bc3e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-29 06:39:37 +00:00
github-actions[bot] 624b4a66c9 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-29 03:32:06 +00:00
github-actions[bot] 515d901e36 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-29 00:51:54 +00:00
github-actions[bot] 534aeaa907 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-28 18:34:35 +00:00
github-actions[bot] 0bc316115c docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-28 03:38:45 +00:00
github-actions[bot] a2a5a265c7 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-28 00:51:55 +00:00
github-actions[bot] e64690aaf7 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-27 18:32:49 +00:00
github-actions[bot] f84138b2a5 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-27 15:39:30 +00:00
github-actions[bot] 57a11e51ea docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-27 06:52:27 +00:00
github-actions[bot] 81b489aad8 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-27 03:40:40 +00:00
github-actions[bot] 102a567977 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-27 01:07:22 +00:00
github-actions[bot] b7d1e3a3e4 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-26 18:51:20 +00:00
github-actions[bot] 537f587b17 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-26 15:48:43 +00:00
github-actions[bot] 5c3504e2e6 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-26 14:17:00 +00:00
github-actions[bot] bfc3bc614b docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-26 09:48:41 +00:00
github-actions[bot] 5fdb3ef97f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-26 03:44:26 +00:00
github-actions[bot] 8bb8eaca5b docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-26 01:07:46 +00:00
github-actions[bot] 9384da3d6f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-25 21:31:28 +00:00
github-actions[bot] 61884f2326 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-25 15:32:25 +00:00
github-actions[bot] d4afb2176f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-25 09:33:10 +00:00
github-actions[bot] b043906f06 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-25 06:46:46 +00:00
github-actions[bot] 793050d04a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-25 03:41:17 +00:00
github-actions[bot] c7d1a75edb docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-24 21:32:03 +00:00
github-actions[bot] 33c1a7ad6e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-24 18:36:04 +00:00
github-actions[bot] af4c9bc632 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-24 15:32:33 +00:00
github-actions[bot] b9a7e1943c docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-24 09:33:22 +00:00
github-actions[bot] 34e975f5b5 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-24 06:45:59 +00:00
github-actions[bot] bd1a47114b docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-24 01:02:21 +00:00
github-actions[bot] cd2e0739d3 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-23 21:32:25 +00:00
github-actions[bot] 52238fc09c docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-23 18:41:02 +00:00
github-actions[bot] f996d9e9c2 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-23 15:37:17 +00:00
github-actions[bot] cc34fe665a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-23 12:45:47 +00:00
github-actions[bot] 83d2c68bf1 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-23 03:40:07 +00:00
github-actions[bot] 7c2ea3ca14 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-22 21:33:06 +00:00
github-actions[bot] 2377ba6ced docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-22 15:37:35 +00:00
github-actions[bot] 6f659d9cc2 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-22 12:48:02 +00:00
github-actions[bot] 532ff00e37 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-22 09:37:31 +00:00
github-actions[bot] 0b78d0b56e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-22 03:39:28 +00:00
github-actions[bot] c267bf01f4 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-22 01:02:55 +00:00
github-actions[bot] 082c6bcf1f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-21 21:33:59 +00:00
github-actions[bot] 84682f4dad docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-21 18:39:51 +00:00
github-actions[bot] 22166b4673 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-21 09:36:59 +00:00
github-actions[bot] b2282b8c85 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-21 06:50:28 +00:00
github-actions[bot] 79e1f29890 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-21 01:04:34 +00:00
github-actions[bot] a5b479f003 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-20 21:33:18 +00:00
github-actions[bot] 6af9e383ff docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-20 12:46:18 +00:00
github-actions[bot] 705dab6ce1 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-20 09:37:00 +00:00
github-actions[bot] 1c3ed3fa8d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-20 03:38:58 +00:00
github-actions[bot] b099fc22f1 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-20 01:03:44 +00:00
github-actions[bot] 6f68216ea4 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-19 21:32:34 +00:00
github-actions[bot] bfdf452235 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-19 18:42:14 +00:00
github-actions[bot] 7d6aae520f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-19 15:35:13 +00:00
github-actions[bot] fc96f5ca35 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-19 12:47:51 +00:00
github-actions[bot] 97d5fa03f2 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-19 09:44:06 +00:00
github-actions[bot] bc051c015f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-19 03:44:46 +00:00
github-actions[bot] db7540718e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-19 01:08:08 +00:00
github-actions[bot] bc9f649176 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-18 21:30:47 +00:00
github-actions[bot] a3d5912298 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-18 18:35:21 +00:00
github-actions[bot] a3fd17f8c5 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-18 15:31:30 +00:00
github-actions[bot] c49b5f7744 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-18 12:40:23 +00:00
github-actions[bot] 9758790810 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-18 09:33:49 +00:00
github-actions[bot] e443ec7909 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-18 06:44:50 +00:00
github-actions[bot] e1187a9b14 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-18 03:37:23 +00:00
github-actions[bot] cd1fe36e3e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-18 01:07:34 +00:00
github-actions[bot] a89ac0fabf docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-17 21:30:57 +00:00
github-actions[bot] c1be2030bc docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-17 18:33:41 +00:00
github-actions[bot] 3b40c6f679 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-17 15:30:22 +00:00
github-actions[bot] 7d0de54f29 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-17 12:38:42 +00:00
github-actions[bot] 4bed0e9cc3 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-17 09:31:54 +00:00
github-actions[bot] faa90a4df6 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-17 06:44:49 +00:00
github-actions[bot] 6e19114bd9 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-17 03:35:35 +00:00
github-actions[bot] d8bcd3d0fc docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-17 01:05:29 +00:00
github-actions[bot] 85f86f7dd9 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-16 21:30:59 +00:00
github-actions[bot] 9807ddecea docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-16 18:38:19 +00:00
github-actions[bot] 802db6db28 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-16 15:32:28 +00:00
github-actions[bot] 265ad9cf3c docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-16 12:45:04 +00:00
github-actions[bot] 991cd9849a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-16 09:35:53 +00:00
github-actions[bot] a3ddd3ef55 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-16 06:50:01 +00:00
github-actions[bot] b8117814e4 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-16 03:38:56 +00:00
github-actions[bot] b07cdec87e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-16 01:01:07 +00:00
github-actions[bot] bc805f9a2a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-15 21:33:15 +00:00
github-actions[bot] bf00490c1d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-15 18:40:21 +00:00
github-actions[bot] 16ef685b7e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-15 15:36:14 +00:00
github-actions[bot] cde57b3748 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-15 12:46:09 +00:00
github-actions[bot] 8f3ec1de33 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-15 09:37:02 +00:00
github-actions[bot] 50e6cc2a0e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-15 06:49:38 +00:00
github-actions[bot] a73d800832 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-15 03:38:41 +00:00
github-actions[bot] f80c9cd374 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-15 01:01:49 +00:00
github-actions[bot] 5a8cae7891 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-14 21:32:35 +00:00
github-actions[bot] 5a5bad9d22 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-14 18:41:38 +00:00
github-actions[bot] 1caa7e9e3a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-14 15:37:16 +00:00
github-actions[bot] ac441a2694 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-14 12:44:36 +00:00
github-actions[bot] 0ab22e6ebb docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-14 09:36:31 +00:00
github-actions[bot] 8347f153c1 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-14 06:49:27 +00:00
github-actions[bot] 4a4e2f710f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-14 03:38:24 +00:00
github-actions[bot] 06f1333bfc docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-14 01:02:55 +00:00
github-actions[bot] 3df41f6a7d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-13 21:31:19 +00:00
github-actions[bot] 929b746b12 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-13 18:45:02 +00:00
github-actions[bot] 11bcb647c9 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-13 15:37:49 +00:00
github-actions[bot] cb811824cd docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-13 12:51:46 +00:00
github-actions[bot] 9bc390b061 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-13 09:43:08 +00:00
github-actions[bot] 155d5c8112 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-13 06:57:57 +00:00
github-actions[bot] 6c64ef6d1f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-13 03:48:55 +00:00
github-actions[bot] d21c8f3f8f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-13 01:17:03 +00:00
github-actions[bot] 210eba65c9 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-12 21:32:49 +00:00
github-actions[bot] fa1300a6cc docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-12 18:43:35 +00:00
github-actions[bot] e1a587862c docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-12 15:38:04 +00:00
github-actions[bot] 3131b72ffb docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-12 13:06:37 +00:00
github-actions[bot] a4b963f0d3 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-12 09:54:06 +00:00
github-actions[bot] 5ff9f643a9 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-12 07:12:45 +00:00
github-actions[bot] f38fefb210 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-12 03:54:11 +00:00
github-actions[bot] 4969f37bb8 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-12 01:21:12 +00:00
github-actions[bot] ce81c05081 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-11 21:33:00 +00:00
github-actions[bot] e5c652b7d2 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-11 18:39:59 +00:00
github-actions[bot] 027c7ea6bf docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-11 15:33:11 +00:00
github-actions[bot] 90fef472e2 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-11 12:45:11 +00:00
github-actions[bot] 3be9c1b242 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-11 09:35:00 +00:00
github-actions[bot] 30bb1ce6ff docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-11 06:50:51 +00:00
github-actions[bot] 96f32ac4be docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-11 03:48:24 +00:00
github-actions[bot] da6aa237f3 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-11 01:18:34 +00:00
github-actions[bot] e985a6285c docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-10 21:32:26 +00:00
github-actions[bot] 994f567dbe docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-10 18:39:12 +00:00
github-actions[bot] b01979b4c7 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-10 15:33:04 +00:00
github-actions[bot] 16220fcd3a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-10 12:43:30 +00:00
github-actions[bot] 3ce7af6c6d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-10 09:35:13 +00:00
github-actions[bot] 0f0c1f508d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-10 06:49:36 +00:00
github-actions[bot] 25595e115c docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-10 03:43:32 +00:00
github-actions[bot] 20cbc69f0a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-10 01:13:27 +00:00
github-actions[bot] 5527248d2d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-09 21:33:52 +00:00
github-actions[bot] 75b375d39e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-09 18:41:06 +00:00
github-actions[bot] 3226567932 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-09 15:35:06 +00:00
github-actions[bot] 15dfddcb81 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-09 12:47:08 +00:00
github-actions[bot] e4c3469f02 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-09 09:41:31 +00:00
github-actions[bot] f4aca68783 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-09 07:10:32 +00:00
github-actions[bot] 5063f35d5a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-09 03:45:06 +00:00
github-actions[bot] 3b27fc2dff docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-09 01:15:35 +00:00
github-actions[bot] 648838f8ef docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-08 21:34:36 +00:00
github-actions[bot] 4b39b90aea docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-08 18:42:34 +00:00
github-actions[bot] 71e401bc18 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-08 15:37:50 +00:00
github-actions[bot] 37a2cbf8d2 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-08 12:49:39 +00:00
github-actions[bot] 89ed725862 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-08 09:41:37 +00:00
github-actions[bot] e19bc0d9a0 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-08 06:55:12 +00:00
github-actions[bot] 29f622e73d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-08 03:44:21 +00:00
github-actions[bot] 60823d124b docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-08 01:13:42 +00:00
github-actions[bot] de9e561824 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-07 21:36:03 +00:00
github-actions[bot] cd5bd28df5 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-07 18:42:41 +00:00
github-actions[bot] b043d61f09 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-07 15:44:19 +00:00
github-actions[bot] f0bb49ab42 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-07 12:49:54 +00:00
github-actions[bot] c00a71e7fa docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-07 09:40:18 +00:00
github-actions[bot] 4e02f285fc docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-07 06:52:47 +00:00
github-actions[bot] 4773cab958 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-07 03:44:08 +00:00
github-actions[bot] 622e09acbc docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-07 01:13:48 +00:00
github-actions[bot] 89e71b1a5d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-06 21:33:22 +00:00
github-actions[bot] 814c83bd43 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-06 18:44:30 +00:00
github-actions[bot] 69f3f29b81 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-06 15:36:42 +00:00
github-actions[bot] f9d642a100 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-06 12:46:59 +00:00
github-actions[bot] ef9b84375b docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-06 09:38:56 +00:00
github-actions[bot] 1c223eb94e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-06 06:52:57 +00:00
github-actions[bot] 31918bf0be docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-06 03:41:10 +00:00
github-actions[bot] 438ff23646 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-06 01:10:50 +00:00
github-actions[bot] 72a83cca9a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-05 21:34:41 +00:00
github-actions[bot] aab7b16b85 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-05 18:43:03 +00:00
github-actions[bot] 1addd51212 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-05 15:37:33 +00:00
github-actions[bot] 5f06f9acf7 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-05 12:49:56 +00:00
github-actions[bot] 60f454bd95 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-05 09:49:53 +00:00
github-actions[bot] afa32f7d91 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-05 07:10:38 +00:00
github-actions[bot] 378addf505 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-05 03:48:36 +00:00
github-actions[bot] d89c40d76e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-05 01:16:49 +00:00
github-actions[bot] 89cae15136 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-04 21:41:45 +00:00
github-actions[bot] 0be1eb3d8d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-04 19:34:01 +00:00
github-actions[bot] 7e73c29402 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-04 16:56:48 +00:00
github-actions[bot] b3db14a522 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-04 14:20:24 +00:00
github-actions[bot] 5b145d2d0d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-04 11:08:15 +00:00
github-actions[bot] eb8c7e80c3 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-04 08:29:09 +00:00
github-actions[bot] 8d08de0033 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-04 04:51:31 +00:00
github-actions[bot] 2c52f9129e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-04 01:39:12 +00:00
github-actions[bot] d250df5b2e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-03 22:19:58 +00:00
github-actions[bot] 13946d2558 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-03 19:09:39 +00:00
github-actions[bot] 4e15df1808 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-03 16:45:13 +00:00
github-actions[bot] 5e9b61802f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-03 14:10:17 +00:00
github-actions[bot] 7ddfa5e35f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-03 09:33:36 +00:00
github-actions[bot] 1795729d5b docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-03 06:57:04 +00:00
github-actions[bot] e406bf2f69 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-03 03:50:45 +00:00
github-actions[bot] bd1e206716 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-03 01:07:44 +00:00
github-actions[bot] 5a46d38e2c docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-02 21:32:50 +00:00
github-actions[bot] 59e4477e05 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-02 18:41:11 +00:00
github-actions[bot] be634d59bb docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-02 15:35:56 +00:00
github-actions[bot] 3a9ff99ff9 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-02 12:47:24 +00:00
github-actions[bot] 655286558f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-02 09:37:41 +00:00
github-actions[bot] 1a66c4e91a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-02 06:52:00 +00:00
github-actions[bot] 3aeee5afb2 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-02 03:40:01 +00:00
github-actions[bot] 8e005b7d97 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-02 01:10:29 +00:00
github-actions[bot] 9637a17923 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-01 21:33:55 +00:00
github-actions[bot] e8a4484541 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-01 18:42:11 +00:00
github-actions[bot] ef3d2176f6 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-01 15:36:07 +00:00
github-actions[bot] 1f170125f8 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-01 12:48:19 +00:00
github-actions[bot] 5ad4233ecc docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-01 09:39:51 +00:00
github-actions[bot] 3675ead50e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-01 06:55:00 +00:00
github-actions[bot] 80783a92dd docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-01 03:45:31 +00:00
github-actions[bot] 503f337faa docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-10-01 01:20:44 +00:00
github-actions[bot] e9dd9df2d4 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-30 21:34:25 +00:00
github-actions[bot] 7e29ce764e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-30 18:40:50 +00:00
github-actions[bot] fc4dd5b763 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-30 15:40:02 +00:00
github-actions[bot] 62bcf9f5bf docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-30 12:52:03 +00:00
github-actions[bot] 1e8093e1c4 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-30 09:38:16 +00:00
github-actions[bot] 0282b0e7e0 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-30 06:52:19 +00:00
github-actions[bot] 4077aa7510 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-30 03:41:17 +00:00
github-actions[bot] ed07096efd docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-30 01:13:11 +00:00
github-actions[bot] 7e659cd67f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-29 21:32:59 +00:00
github-actions[bot] c738f05801 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-29 18:41:15 +00:00
github-actions[bot] 754d8f982c docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-29 15:35:46 +00:00
github-actions[bot] 87df15205e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-29 12:49:04 +00:00
github-actions[bot] f28ef1b839 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-29 09:39:02 +00:00
github-actions[bot] 5687e3dadc docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-29 06:52:54 +00:00
github-actions[bot] dc73f96f45 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-29 03:40:31 +00:00
github-actions[bot] a4f77e07dd docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-29 01:11:49 +00:00
github-actions[bot] f618aaa07e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-28 18:41:11 +00:00
github-actions[bot] be0cf45263 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-28 15:36:03 +00:00
github-actions[bot] 774c47d285 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-28 12:50:00 +00:00
github-actions[bot] 6a44224c09 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-28 09:47:08 +00:00
github-actions[bot] 245f888aed docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-28 07:04:55 +00:00
github-actions[bot] c8d557a36b docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-28 03:45:00 +00:00
github-actions[bot] 826956731e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-28 01:14:15 +00:00
github-actions[bot] dc6f3cc99f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-27 18:36:47 +00:00
github-actions[bot] 16a5ab0dab docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-27 15:31:15 +00:00
github-actions[bot] d9fe125e4f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-27 12:41:48 +00:00
github-actions[bot] e371b8ca0b docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-27 09:33:42 +00:00
github-actions[bot] 454a9b44b1 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-27 06:48:52 +00:00
github-actions[bot] 8215f85fed docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-27 03:40:20 +00:00
github-actions[bot] 38530adfd5 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-27 01:14:10 +00:00
github-actions[bot] e2cb29aae2 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-26 21:30:34 +00:00
github-actions[bot] 52d4b3a978 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-26 18:45:11 +00:00
github-actions[bot] fcc74d7f83 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-26 15:31:43 +00:00
github-actions[bot] f4df2ce618 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-26 12:40:14 +00:00
github-actions[bot] ff7e0101c8 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-26 09:32:02 +00:00
github-actions[bot] 196f906fda docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-26 06:46:09 +00:00
github-actions[bot] 8613333530 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-26 03:36:26 +00:00
github-actions[bot] 20091e485f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-26 01:04:46 +00:00
github-actions[bot] 7510a10da4 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-25 21:31:33 +00:00
github-actions[bot] 943895f38e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-25 18:37:05 +00:00
github-actions[bot] 1a42c5c0a0 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-25 16:26:57 +00:00
github-actions[bot] eab75434d6 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-25 15:38:45 +00:00
github-actions[bot] 8d0b6ba5f6 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-25 12:44:39 +00:00
github-actions[bot] 9daaa884d3 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-25 09:36:15 +00:00
github-actions[bot] c5c8cb9953 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-25 06:50:12 +00:00
github-actions[bot] 48018cab62 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-25 03:36:58 +00:00
github-actions[bot] 5771e21f3a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-25 01:06:03 +00:00
github-actions[bot] 7b0ed8b3b6 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-24 21:31:14 +00:00
github-actions[bot] fc926b2483 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-24 18:38:25 +00:00
github-actions[bot] 4221aac09a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-24 15:35:05 +00:00
github-actions[bot] 862eebf979 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-24 12:45:46 +00:00
github-actions[bot] bc8eecf77d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-24 09:37:49 +00:00
github-actions[bot] f05f178945 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-24 06:51:27 +00:00
github-actions[bot] 15232864b1 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-24 03:40:25 +00:00
github-actions[bot] 4f4a986510 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-24 01:06:50 +00:00
github-actions[bot] bd57e54ce9 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-23 21:30:59 +00:00
github-actions[bot] c1384ef4b5 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-23 18:38:12 +00:00
github-actions[bot] 8dd22fc275 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-23 15:33:40 +00:00
github-actions[bot] 513e1deb15 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-23 12:43:59 +00:00
github-actions[bot] f0ca56700f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-23 09:36:14 +00:00
github-actions[bot] ba441bd2c3 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-23 06:49:13 +00:00
github-actions[bot] c951cef5d4 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-23 03:38:13 +00:00
github-actions[bot] 2e97b4bb14 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-23 01:04:23 +00:00
github-actions[bot] 83ad7fb7de docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-22 21:30:02 +00:00
github-actions[bot] eca3f48030 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-22 15:32:01 +00:00
github-actions[bot] 6bc90af3b6 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-22 12:43:18 +00:00
github-actions[bot] 9405013ebb docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-22 09:33:18 +00:00
github-actions[bot] 3f30dad649 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-22 06:48:43 +00:00
github-actions[bot] 9d2785a127 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-22 03:37:54 +00:00
github-actions[bot] c0861b343d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-22 01:05:24 +00:00
github-actions[bot] 4f64f6d979 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-21 21:31:07 +00:00
github-actions[bot] e017956696 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-21 18:37:53 +00:00
github-actions[bot] 26aca1a451 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-21 15:31:54 +00:00
github-actions[bot] d712f19331 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-21 12:42:02 +00:00
github-actions[bot] 3c25e9e271 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-21 09:40:38 +00:00
github-actions[bot] 3a9e6edcfc docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-21 06:59:57 +00:00
github-actions[bot] d16b7ad3b2 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-21 03:40:37 +00:00
github-actions[bot] 29563d5e20 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-21 01:10:00 +00:00
github-actions[bot] c72855d4cd docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-20 21:29:30 +00:00
github-actions[bot] bd6807a73f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-20 18:34:11 +00:00
github-actions[bot] 70c53db226 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-20 15:29:54 +00:00
github-actions[bot] 32e76de41f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-20 12:39:09 +00:00
github-actions[bot] f7e5ee8aac docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-20 09:31:05 +00:00
github-actions[bot] 41f8f1588c docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-20 06:44:00 +00:00
github-actions[bot] ec55ec40f4 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-20 03:37:05 +00:00
github-actions[bot] 0615514b2d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-20 01:10:47 +00:00
github-actions[bot] e1ba667799 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-19 21:29:19 +00:00
github-actions[bot] 93db761bc2 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-19 18:34:04 +00:00
github-actions[bot] 7716e09c9b docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-19 15:30:57 +00:00
github-actions[bot] 981c0029ca docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-19 12:38:35 +00:00
github-actions[bot] 2e5bcbc71d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-19 09:31:25 +00:00
github-actions[bot] 185715483d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-19 06:43:28 +00:00
github-actions[bot] 1a29e111b9 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-19 03:35:34 +00:00
github-actions[bot] af418b8964 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-19 01:03:38 +00:00
github-actions[bot] 8b6d7185f2 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-18 21:30:03 +00:00
github-actions[bot] 1614853b62 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-18 18:36:07 +00:00
github-actions[bot] efa79b12da docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-18 15:31:50 +00:00
github-actions[bot] 8ef30e4642 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-18 12:42:09 +00:00
github-actions[bot] c666fd907c docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-18 09:34:31 +00:00
github-actions[bot] 7c0a85f39b docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-18 06:47:30 +00:00
github-actions[bot] d99a50ea7d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-18 03:36:15 +00:00
github-actions[bot] 990f50f10e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-18 01:05:25 +00:00
github-actions[bot] 0e9108b716 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-17 21:30:13 +00:00
github-actions[bot] 349ca6efda docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-17 18:38:19 +00:00
github-actions[bot] 79ffe30b6f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-17 15:32:17 +00:00
github-actions[bot] c1bd951963 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-17 12:43:38 +00:00
github-actions[bot] 5fbb86c97c docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-17 09:35:51 +00:00
github-actions[bot] 6961d37b2b docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-17 06:48:57 +00:00
github-actions[bot] e56e534eba docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-17 03:37:11 +00:00
github-actions[bot] 4f84a1ea22 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-17 01:05:56 +00:00
github-actions[bot] 0c02afd5b0 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-16 21:31:02 +00:00
github-actions[bot] ec78ceb96d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-16 18:36:43 +00:00
github-actions[bot] 738b9c222e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-16 15:31:34 +00:00
github-actions[bot] de7025560a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-16 12:44:13 +00:00
github-actions[bot] d56cdfdb6e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-16 09:34:24 +00:00
github-actions[bot] 7d9cc18f38 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-16 06:48:38 +00:00
github-actions[bot] 197757f473 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-16 03:37:28 +00:00
github-actions[bot] 3e0386d50d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-16 01:05:14 +00:00
github-actions[bot] 5be92574ca docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-15 21:29:45 +00:00
github-actions[bot] 1f25cdce24 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-15 18:35:07 +00:00
github-actions[bot] c52fcda6d9 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-15 15:31:31 +00:00
github-actions[bot] 18b21d6e55 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-15 12:42:38 +00:00
github-actions[bot] 2a96053fbf docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-15 09:38:28 +00:00
github-actions[bot] 7bff95b200 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-15 06:48:30 +00:00
github-actions[bot] 3cb63f85ee docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-15 03:37:38 +00:00
github-actions[bot] 9c19f67c78 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-15 01:06:06 +00:00
github-actions[bot] 1194fe2cdf docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-14 21:29:41 +00:00
github-actions[bot] 39f980848a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-14 18:36:37 +00:00
github-actions[bot] 6b3b7b967f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-14 15:33:26 +00:00
github-actions[bot] 73843474c5 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-14 12:44:24 +00:00
github-actions[bot] 6eda573200 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-14 09:39:52 +00:00
github-actions[bot] 478a67466c docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-14 06:58:24 +00:00
github-actions[bot] 9ca8f79a50 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-14 03:40:08 +00:00
github-actions[bot] 10a4ace8d7 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-14 01:10:50 +00:00
github-actions[bot] 23b57cd187 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-13 21:29:27 +00:00
github-actions[bot] 9bdcf1ff5b docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-13 18:33:29 +00:00
github-actions[bot] 58d8a2387d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-13 15:29:10 +00:00
github-actions[bot] f19889e7ce docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-13 12:38:29 +00:00
github-actions[bot] bbac827f3c docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-13 10:01:12 +00:00
github-actions[bot] 6e0b836417 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-13 06:46:52 +00:00
github-actions[bot] b1d9e748c3 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-13 03:36:38 +00:00
github-actions[bot] fd2f47363e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-13 01:10:14 +00:00
github-actions[bot] 1d9011483b docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-12 21:29:12 +00:00
github-actions[bot] 3cb175fe46 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-12 18:32:05 +00:00
github-actions[bot] 6caa951b8a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-12 15:28:57 +00:00
github-actions[bot] 46e54c45e8 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-12 12:37:30 +00:00
github-actions[bot] 9d3d5ed4c7 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-12 09:30:22 +00:00
github-actions[bot] d057b21b2b docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-12 06:42:30 +00:00
github-actions[bot] 51e80c713c docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-12 03:34:59 +00:00
github-actions[bot] 493ba0d488 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-12 01:02:40 +00:00
github-actions[bot] 9cdb9697f3 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-11 21:28:16 +00:00
github-actions[bot] 0dddbe3161 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-11 18:35:43 +00:00
github-actions[bot] face88ce47 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-11 15:31:50 +00:00
github-actions[bot] 125ea28567 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-11 12:42:41 +00:00
github-actions[bot] abe653bc00 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-11 09:34:47 +00:00
github-actions[bot] 8eed9113eb docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-11 06:46:23 +00:00
github-actions[bot] 36ccc4b6e5 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-11 03:36:46 +00:00
github-actions[bot] c6d8c327af docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-11 01:03:17 +00:00
github-actions[bot] 7d22c45cd9 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-10 21:28:37 +00:00
github-actions[bot] 8bcdb3f160 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-10 18:34:44 +00:00
github-actions[bot] e040feae13 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-10 15:32:28 +00:00
github-actions[bot] 3eea1323fa docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-10 12:43:25 +00:00
github-actions[bot] 05092b0bd4 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-10 09:34:37 +00:00
github-actions[bot] d5085446ce docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-10 06:46:47 +00:00
github-actions[bot] 9d1219eb20 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-10 03:36:14 +00:00
github-actions[bot] f356faca5d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-10 01:03:29 +00:00
github-actions[bot] 00e4e00314 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-09 21:29:41 +00:00
github-actions[bot] 025ac33229 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-09 18:37:12 +00:00
github-actions[bot] ccbb16aa13 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-09 15:33:17 +00:00
github-actions[bot] 57d0fb055b docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-09 12:43:04 +00:00
github-actions[bot] a49907449c docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-09 09:35:24 +00:00
github-actions[bot] a32c815702 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-09 06:47:20 +00:00
github-actions[bot] 850bd4adf3 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-09 03:36:27 +00:00
github-actions[bot] dfd56e978e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-09 01:05:09 +00:00
github-actions[bot] c63a2ad573 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-08 21:28:58 +00:00
github-actions[bot] 8cd6f4877c docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-08 18:37:30 +00:00
github-actions[bot] b812a08007 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-08 15:33:47 +00:00
github-actions[bot] 9aca05da67 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-08 12:43:01 +00:00
github-actions[bot] 9271409828 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-08 09:34:11 +00:00
github-actions[bot] e2d2e841cf docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-08 06:46:20 +00:00
github-actions[bot] 9409e929a4 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-08 03:36:25 +00:00
github-actions[bot] 917d141031 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-08 01:05:34 +00:00
github-actions[bot] 388b5a6bf2 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-07 21:29:29 +00:00
github-actions[bot] 2e4b9abb81 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-07 18:35:40 +00:00
github-actions[bot] bad9b10ac0 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-07 15:31:13 +00:00
github-actions[bot] 38daccd862 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-07 12:44:46 +00:00
github-actions[bot] 3c608e2de4 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-07 09:40:31 +00:00
github-actions[bot] d090287814 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-07 06:57:34 +00:00
github-actions[bot] bee555b2e1 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-07 03:38:25 +00:00
github-actions[bot] 3276908916 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-07 01:09:33 +00:00
github-actions[bot] df113e4766 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-06 21:28:03 +00:00
github-actions[bot] e2663c81fc docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-06 18:31:48 +00:00
github-actions[bot] da1127113c docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-06 15:28:43 +00:00
github-actions[bot] 213e507101 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-06 12:37:21 +00:00
github-actions[bot] 243056082c docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-06 09:30:05 +00:00
github-actions[bot] 7b0891bb0d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-06 06:42:28 +00:00
github-actions[bot] 776b96b7e5 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-06 03:35:38 +00:00
github-actions[bot] ecb3bb90ac docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-06 01:10:41 +00:00
github-actions[bot] 7a0a28a0bc docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-05 21:27:59 +00:00
github-actions[bot] a9411e6509 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-05 18:31:35 +00:00
github-actions[bot] c875ffe226 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-05 15:28:51 +00:00
github-actions[bot] 9f6736c359 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-05 12:37:29 +00:00
github-actions[bot] e3220b2d5a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-05 09:29:38 +00:00
github-actions[bot] 484f4b3c70 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-05 06:41:22 +00:00
github-actions[bot] 552d399aa9 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-05 03:34:28 +00:00
github-actions[bot] 68584ae7d3 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-05 01:02:54 +00:00
github-actions[bot] 1dd366e223 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-04 21:29:22 +00:00
github-actions[bot] dcd24665c2 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-04 18:34:25 +00:00
github-actions[bot] 6b31c051c3 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-04 15:31:51 +00:00
github-actions[bot] 354419ceb0 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-04 12:44:50 +00:00
github-actions[bot] fb6e281741 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-04 09:34:03 +00:00
github-actions[bot] 633f1074ad docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-04 06:47:00 +00:00
github-actions[bot] 06e31ec5af docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-04 03:36:09 +00:00
github-actions[bot] 4a0576ca4b docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-04 01:03:07 +00:00
github-actions[bot] b66b8c0f29 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-03 21:29:38 +00:00
github-actions[bot] d0f254f5e3 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-03 18:37:43 +00:00
github-actions[bot] ba7ff89500 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-03 15:33:48 +00:00
github-actions[bot] fda802f884 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-03 12:44:07 +00:00
github-actions[bot] 495f38bb37 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-03 09:35:04 +00:00
github-actions[bot] 21b048bb4c docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-03 06:47:57 +00:00
github-actions[bot] 1b5944c44b docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-03 04:52:47 +00:00
github-actions[bot] 42004449fd docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-03 01:06:30 +00:00
github-actions[bot] 877e1c2e3f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-02 21:29:38 +00:00
github-actions[bot] 1b2496344a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-02 18:37:21 +00:00
github-actions[bot] b1ca93b735 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-02 15:35:55 +00:00
github-actions[bot] 601fef379a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-02 12:43:59 +00:00
github-actions[bot] c878e627ef docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-02 09:34:48 +00:00
github-actions[bot] f186427167 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-02 06:51:10 +00:00
github-actions[bot] 30ad8b9f50 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-02 03:36:20 +00:00
github-actions[bot] d0198ac3c3 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-02 01:05:24 +00:00
github-actions[bot] a51c70b864 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-01 18:37:56 +00:00
github-actions[bot] a8062f8a74 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-01 15:47:18 +00:00
github-actions[bot] c5c2768a38 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-01 12:43:21 +00:00
github-actions[bot] 60f48c6ff6 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-01 09:35:58 +00:00
github-actions[bot] f7cd809ef7 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-01 06:48:24 +00:00
github-actions[bot] 80b94639f6 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-01 03:38:48 +00:00
github-actions[bot] 2dba7933c6 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-09-01 01:16:15 +00:00
github-actions[bot] 1d41888631 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-31 21:29:15 +00:00
github-actions[bot] ccdb1c5b6c docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-31 18:37:01 +00:00
github-actions[bot] 152112d242 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-31 15:31:44 +00:00
github-actions[bot] d75fa60850 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-31 12:43:21 +00:00
github-actions[bot] 033a1c6c7a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-31 09:39:20 +00:00
github-actions[bot] 42818581fa docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-31 06:56:26 +00:00
github-actions[bot] 450e1124d2 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-31 03:38:32 +00:00
github-actions[bot] f492423ffe docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-31 01:09:57 +00:00
github-actions[bot] f189fe59b2 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-30 21:28:09 +00:00
github-actions[bot] c47a16b117 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-30 18:32:09 +00:00
github-actions[bot] 6219fefbc0 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-30 15:28:48 +00:00
github-actions[bot] 76488cf701 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-30 12:37:51 +00:00
github-actions[bot] 6dfc9c412a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-30 09:29:59 +00:00
github-actions[bot] 8cf578dcec docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-30 06:42:04 +00:00
github-actions[bot] fb2fa1210e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-30 03:35:14 +00:00
github-actions[bot] 653d64741d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-30 01:08:16 +00:00
github-actions[bot] 6d68831728 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-29 21:28:05 +00:00
github-actions[bot] e8e299a805 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-29 18:32:10 +00:00
github-actions[bot] 461141b934 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-29 15:28:13 +00:00
github-actions[bot] ddd2451bd9 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-29 12:36:34 +00:00
github-actions[bot] ae51db5306 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-29 09:29:10 +00:00
github-actions[bot] 6420d5d4c4 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-29 06:40:25 +00:00
github-actions[bot] 46b00069e7 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-29 03:32:38 +00:00
github-actions[bot] 916fe3c749 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-29 00:56:57 +00:00
github-actions[bot] 3f6f2f463f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-28 21:39:13 +00:00
github-actions[bot] b95921df69 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-28 16:35:20 +00:00
github-actions[bot] 998141bdc7 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-28 09:37:37 +00:00
github-actions[bot] 0c816e2e3d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-28 03:28:50 +00:00
github-actions[bot] 89291d9858 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-27 21:40:44 +00:00
github-actions[bot] e1f226f9a5 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-27 16:10:35 +00:00
github-actions[bot] 9e177d7608 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-27 09:33:33 +00:00
github-actions[bot] 9b890ef354 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-27 05:11:15 +00:00
github-actions[bot] c4057084fe docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-27 02:05:38 +00:00
github-actions[bot] 69cb4c2052 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-26 22:23:56 +00:00
github-actions[bot] 6f80ca071d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-26 19:07:27 +00:00
github-actions[bot] fd7c18a054 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-26 15:57:32 +00:00
github-actions[bot] 55c9ff1670 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-26 12:39:09 +00:00
github-actions[bot] b762e1fb03 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-26 09:38:22 +00:00
github-actions[bot] eca3425581 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-26 06:38:57 +00:00
github-actions[bot] f46c705db6 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-26 03:43:11 +00:00
github-actions[bot] 6458de2f64 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-26 00:39:46 +00:00
github-actions[bot] 7c62e0da27 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-25 21:36:37 +00:00
github-actions[bot] 820e618ecc docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-25 18:38:20 +00:00
github-actions[bot] cde590c915 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-25 15:39:28 +00:00
github-actions[bot] 16bbcf4d6e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-25 12:38:45 +00:00
github-actions[bot] 340c089ea4 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-25 09:37:55 +00:00
github-actions[bot] 4a8abe39e1 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-25 06:38:22 +00:00
github-actions[bot] 6037ccab3d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-25 03:39:49 +00:00
github-actions[bot] 95fa08793d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-25 00:39:47 +00:00
github-actions[bot] 642cc8abfd docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-24 21:36:49 +00:00
github-actions[bot] a055fa2ee0 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-24 18:38:10 +00:00
github-actions[bot] ca883d98ca docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-24 15:38:42 +00:00
github-actions[bot] cbb8dd0e18 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-24 12:39:26 +00:00
github-actions[bot] 55e8b20566 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-24 09:39:18 +00:00
github-actions[bot] 62d57ef75d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-24 06:38:56 +00:00
github-actions[bot] 28e9c67913 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-24 03:42:12 +00:00
github-actions[bot] 6fd1ef159b docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-24 00:39:39 +00:00
github-actions[bot] ea001c798f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-23 21:33:54 +00:00
github-actions[bot] a851e6acc6 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-23 18:36:39 +00:00
github-actions[bot] 201eb0ac3c docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-23 15:34:21 +00:00
github-actions[bot] 6e20a6eb2e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-23 12:37:58 +00:00
github-actions[bot] e0f839567f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-23 09:35:52 +00:00
github-actions[bot] 9e293b417d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-23 06:37:40 +00:00
github-actions[bot] 3c5343b192 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-23 03:40:12 +00:00
github-actions[bot] 9cd8e864a4 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-23 00:39:42 +00:00
github-actions[bot] 553c013b8f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-22 21:34:26 +00:00
github-actions[bot] 8f53911749 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-22 18:36:47 +00:00
github-actions[bot] 1539520065 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-22 15:34:00 +00:00
github-actions[bot] e148b63e31 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-22 12:38:11 +00:00
github-actions[bot] cf6ae11147 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-22 09:36:05 +00:00
github-actions[bot] b00f482384 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-22 06:38:00 +00:00
github-actions[bot] a62c4181e7 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-22 03:38:43 +00:00
github-actions[bot] 62ec100690 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-22 00:39:49 +00:00
github-actions[bot] ceec2be1d6 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-21 21:35:03 +00:00
github-actions[bot] b786e0a552 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-21 18:37:31 +00:00
github-actions[bot] 209b7272a7 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-21 15:37:25 +00:00
github-actions[bot] ea26e372d6 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-21 12:38:15 +00:00
github-actions[bot] 37db9b4382 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-21 09:37:41 +00:00
github-actions[bot] c4bc59ba99 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-21 06:38:36 +00:00
github-actions[bot] f4ea2eb781 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-21 03:40:47 +00:00
github-actions[bot] c5ed6fcc40 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-21 00:40:29 +00:00
github-actions[bot] 2a33ba895a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-20 21:36:28 +00:00
github-actions[bot] d754b4da73 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-20 19:07:31 +00:00
github-actions[bot] 79803492ce docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-20 15:54:55 +00:00
github-actions[bot] 1f11fc8d8e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-20 13:18:36 +00:00
github-actions[bot] 25dfead6d1 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-20 09:55:56 +00:00
github-actions[bot] 8b5a32a08f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-20 07:12:58 +00:00
github-actions[bot] 42ba76561d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-20 04:05:11 +00:00
github-actions[bot] 8688c233d2 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-20 01:57:34 +00:00
github-actions[bot] 9e204a6d43 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-19 21:47:09 +00:00
github-actions[bot] 50b7174ac2 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-19 18:59:47 +00:00
github-actions[bot] d532a59b33 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-19 15:52:44 +00:00
github-actions[bot] 16c70c6026 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-19 13:16:23 +00:00
github-actions[bot] 99b3862bdc docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-19 09:55:11 +00:00
github-actions[bot] 27e7888f27 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-19 07:12:19 +00:00
github-actions[bot] 92652300f8 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-19 04:06:06 +00:00
github-actions[bot] 5e95a0c810 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-19 01:58:13 +00:00
github-actions[bot] b5c5560fe8 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-18 21:44:31 +00:00
github-actions[bot] d26ffc3238 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-18 19:03:34 +00:00
github-actions[bot] dbca9c31cd docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-18 15:53:02 +00:00
github-actions[bot] 7b374b8bfc docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-18 13:15:14 +00:00
github-actions[bot] e9e7bdf17a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-18 09:55:27 +00:00
github-actions[bot] dc430c1825 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-18 07:11:36 +00:00
github-actions[bot] 120a99eec3 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-18 04:04:24 +00:00
github-actions[bot] f8531403d8 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-18 01:56:42 +00:00
github-actions[bot] 51c7e6ef61 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-17 21:46:57 +00:00
github-actions[bot] 13ea727286 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-17 19:04:38 +00:00
github-actions[bot] feed3a8b31 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-17 13:12:59 +00:00
github-actions[bot] 9314963e7e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-17 10:03:11 +00:00
github-actions[bot] 92e41f883a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-17 07:28:55 +00:00
github-actions[bot] 658a44df10 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-17 04:09:14 +00:00
github-actions[bot] 2b3f088781 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-17 02:00:08 +00:00
github-actions[bot] 68c473e645 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-16 21:39:51 +00:00
github-actions[bot] 4a1e912f3d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-16 18:37:12 +00:00
github-actions[bot] bfc920a1ac docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-16 15:34:09 +00:00
github-actions[bot] a9557ee0fe docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-16 12:38:03 +00:00
github-actions[bot] e79eefb45e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-16 09:35:49 +00:00
github-actions[bot] bcd73c23f0 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-16 06:38:05 +00:00
github-actions[bot] 7f286bbd79 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-16 03:39:45 +00:00
github-actions[bot] 0f1f1d756f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-16 00:39:42 +00:00
github-actions[bot] b1b64e509d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-15 21:33:25 +00:00
github-actions[bot] 911c55887a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-15 18:36:38 +00:00
github-actions[bot] 96c3730912 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-15 15:33:23 +00:00
github-actions[bot] dd743382cd docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-15 12:37:34 +00:00
github-actions[bot] 75dbf343b0 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-15 09:35:30 +00:00
github-actions[bot] 5c4da9693e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-15 06:37:51 +00:00
github-actions[bot] b4cd0d32cb docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-15 03:37:53 +00:00
github-actions[bot] 5fb6ed7a6d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-15 00:39:29 +00:00
github-actions[bot] cc45ee1124 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-14 21:35:40 +00:00
github-actions[bot] 46cdf00614 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-14 18:40:28 +00:00
github-actions[bot] be842f5fa8 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-14 15:40:40 +00:00
github-actions[bot] a3dbe7ed94 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-14 12:39:59 +00:00
github-actions[bot] 78653f2366 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-14 09:45:24 +00:00
github-actions[bot] 8dc141af42 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-14 06:53:59 +00:00
github-actions[bot] 6d422c308b docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-14 04:08:04 +00:00
github-actions[bot] d5e5afaa3f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-14 00:45:20 +00:00
github-actions[bot] d5125ea421 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-13 21:39:05 +00:00
github-actions[bot] 48a7546865 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-13 18:42:39 +00:00
github-actions[bot] f39993ead6 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-13 15:44:10 +00:00
github-actions[bot] ee68fdb707 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-13 12:40:17 +00:00
github-actions[bot] 3c0d9ae8dc docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-13 09:47:26 +00:00
github-actions[bot] bc0c90e21b docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-13 06:54:35 +00:00
github-actions[bot] ab206516ad docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-13 04:08:36 +00:00
github-actions[bot] 53e105cc53 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-13 00:45:39 +00:00
github-actions[bot] 4dd70c9e8f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-12 21:39:08 +00:00
github-actions[bot] 685de4c2ee docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-12 18:43:00 +00:00
github-actions[bot] dd7d09f7d0 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-12 15:43:53 +00:00
github-actions[bot] 387aa955cf docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-12 12:40:08 +00:00
github-actions[bot] fe2051ba91 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-12 09:47:37 +00:00
github-actions[bot] d309ef5463 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-12 06:54:11 +00:00
github-actions[bot] d81d5ed99a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-12 04:07:38 +00:00
github-actions[bot] 99f17090ed docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-12 00:44:48 +00:00
github-actions[bot] c4a83d254a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-11 21:39:21 +00:00
github-actions[bot] a3a1889c8e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-11 18:42:09 +00:00
github-actions[bot] f700afef73 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-11 15:44:47 +00:00
github-actions[bot] 3a6f7d50d6 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-11 12:40:12 +00:00
github-actions[bot] 8c9eb7a6a0 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-11 09:45:20 +00:00
github-actions[bot] 638dc7a996 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-11 06:42:51 +00:00
github-actions[bot] a81eb163f2 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-11 03:58:29 +00:00
github-actions[bot] 61f1116ac9 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-11 00:41:31 +00:00
github-actions[bot] 80454e995e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-10 21:40:56 +00:00
github-actions[bot] 1cdbf86de9 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-10 18:42:58 +00:00
github-actions[bot] 44e3404852 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-10 15:47:16 +00:00
github-actions[bot] 9a0e04567e docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-10 12:43:40 +00:00
github-actions[bot] 86ec5d52a5 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-10 09:58:58 +00:00
github-actions[bot] 51b53c9adf docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-10 06:53:52 +00:00
github-actions[bot] a13a91f890 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-10 04:05:33 +00:00
github-actions[bot] 308ee78499 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-10 00:41:03 +00:00
github-actions[bot] d27d4e8f0b docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-09 21:37:05 +00:00
github-actions[bot] 363ec98435 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-09 18:38:03 +00:00
github-actions[bot] 34d1d8ad4c docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-09 15:36:46 +00:00
github-actions[bot] 8aa2d3315f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-09 12:38:43 +00:00
github-actions[bot] 63ac0e0857 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-09 09:37:46 +00:00
github-actions[bot] a85fcb13a8 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-09 06:39:06 +00:00
github-actions[bot] 99bb19f7c6 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-09 03:56:51 +00:00
github-actions[bot] 0a8c4e3046 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-09 00:40:23 +00:00
github-actions[bot] ed6c91bc28 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-08 21:36:57 +00:00
github-actions[bot] 79b667c5d3 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-08 18:37:47 +00:00
github-actions[bot] db93988df2 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-08 15:36:48 +00:00
github-actions[bot] 2c37004e56 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-08 12:38:14 +00:00
github-actions[bot] bed35d3c0d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-08 09:37:24 +00:00
github-actions[bot] 73b5234365 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-08 06:38:36 +00:00
github-actions[bot] 21e0f17e47 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-08 03:52:52 +00:00
github-actions[bot] 39087ac07d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-08 00:40:25 +00:00
github-actions[bot] fdd06f471c docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-07 21:38:20 +00:00
github-actions[bot] ae729f8c2b docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-07 18:42:10 +00:00
github-actions[bot] df0312fdc4 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-07 15:45:57 +00:00
github-actions[bot] c33eef9145 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-07 12:42:46 +00:00
github-actions[bot] 6c24d80c47 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-07 09:48:19 +00:00
github-actions[bot] 91e660798b docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-07 06:51:35 +00:00
github-actions[bot] 5b8834a8d8 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-07 04:17:05 +00:00
github-actions[bot] 5b93977d55 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-07 01:35:17 +00:00
github-actions[bot] 9c6da867dc docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-06 23:46:49 +00:00
github-actions[bot] 1c308ece5a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-06 13:07:45 +00:00
github-actions[bot] aee3fb89e3 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-06 10:22:07 +00:00
github-actions[bot] 381dff420a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-06 07:24:50 +00:00
github-actions[bot] 51f642d922 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-06 04:33:31 +00:00
github-actions[bot] e25c737901 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-06 00:53:15 +00:00
github-actions[bot] 67ed113216 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-05 21:54:40 +00:00
github-actions[bot] e587eeeed8 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-05 19:02:36 +00:00
github-actions[bot] 5395b4df31 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-05 16:10:11 +00:00
github-actions[bot] 75f41806a7 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-05 13:06:38 +00:00
github-actions[bot] 1ade034692 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-05 10:20:22 +00:00
github-actions[bot] 6589e59adf docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-05 07:23:52 +00:00
github-actions[bot] 13e039e7c4 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-05 04:33:01 +00:00
github-actions[bot] 0197a7e8b8 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-05 00:55:05 +00:00
github-actions[bot] 26704eacb6 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-04 21:55:00 +00:00
github-actions[bot] 494f2e8cd5 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-04 18:58:37 +00:00
github-actions[bot] 6082c188c2 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-04 16:57:37 +00:00
github-actions[bot] a9a9b04861 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-04 16:13:04 +00:00
github-actions[bot] 524b76a0ae docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-04 13:09:16 +00:00
github-actions[bot] 144e359bfd docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-08-04 10:59:56 +00:00
github-actions[bot] 38a28ffc2f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-07-23 10:11:51 +00:00
github-actions[bot] 8c35119676 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-07-23 07:17:27 +00:00
github-actions[bot] 58eace81bb docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-07-23 04:31:42 +00:00
github-actions[bot] c70f58ea6c docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-07-23 00:59:14 +00:00
github-actions[bot] 1adc6d95e7 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-07-22 21:50:31 +00:00
github-actions[bot] 234933caf8 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-07-22 18:48:00 +00:00
github-actions[bot] 05c2d1c16d docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-07-22 15:59:33 +00:00
github-actions[bot] a5bf73890a docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-07-22 12:56:33 +00:00
github-actions[bot] a0c5774f3b docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-07-22 10:12:33 +00:00
github-actions[bot] 3d85d95dbc docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-07-22 07:19:17 +00:00
github-actions[bot] ac00a63e73 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-07-22 04:32:08 +00:00
github-actions[bot] 1f10e15069 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-07-22 00:56:40 +00:00
github-actions[bot] d691e163c8 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-07-21 21:47:09 +00:00
github-actions[bot] 0aa48d2762 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-07-21 18:53:04 +00:00
github-actions[bot] a6513d3d59 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-07-21 16:00:06 +00:00
github-actions[bot] 2e2265895b docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-07-21 12:54:14 +00:00
github-actions[bot] 845cb29030 docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-07-21 10:13:59 +00:00
github-actions[bot] bb26917e2f docs: update Star History chart
Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.

Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data
2026-07-21 06:44:52 +00:00
2146 changed files with 562630 additions and 46556 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# Copy to .env and fill in. Only secrets belong here — a provider's base_url and
# model are configured in reasonix.toml, where api_key_env points at these variables.
DEEPSEEK_API_KEY=
MIMO_API_KEY=
+17
View File
@@ -1,11 +1,28 @@
.gitattributes text eol=lf
.gitignore text eol=lf
.npmrc text eol=lf
*.astro text eol=lf
*.css text eol=lf
*.go text eol=lf
*.html text eol=lf
*.js text eol=lf
*.json text eol=lf
*.md text eol=lf
*.mjs text eol=lf
*.mod text eol=lf
*.nsi text eol=lf
*.plist text eol=lf
*.py text eol=lf
*.rs text eol=lf
*.sh text eol=lf
*.sql text eol=lf
*.sum text eol=lf
*.toml text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.txt text eol=lf
*.xml text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
.githooks/** text eol=lf
benchmarks/**/*.sh text eol=lf
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
# Go-native pre-push gate replacing the retired v1 npm hook (install: make hooks).
# Full tests run in CI; vet is the fast, cross-platform local check.
set -e
go vet ./...
+3
View File
@@ -0,0 +1,3 @@
# Every pull request requests review from the maintainers below.
# Either maintainer can review and merge any change.
* @SivanCola @esengine
+60
View File
@@ -0,0 +1,60 @@
name: Bug report
description: Report a bug in DeepSeek-Reasonix
title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for filing a bug. Pick which version line you're on below — it gets
labeled (`v1`/`v2`) automatically, no maintainer action needed.
- type: dropdown
id: version-line
attributes:
label: Version line
description: Which line are you running?
options:
- "v2 — Go rewrite (1.x), main-v2 (active development)"
- "v1 — Legacy TypeScript (0.x), maintenance only"
validations:
required: true
- type: input
id: version
attributes:
label: Exact version
description: Output of `reasonix --version`.
placeholder: e.g. 1.0.0
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: What happened?
description: A clear description of the bug, and what you expected instead.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
placeholder: |
1. ...
2. ...
3. ...
validations:
required: true
- type: input
id: os
attributes:
label: OS / platform
placeholder: e.g. macOS 15.3 (arm64), Ubuntu 24.04, Windows 11
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant logs or output
description: Paste any error output. Automatically formatted as code.
render: shell
validations:
required: false
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Questions & discussions
url: https://github.com/esengine/DeepSeek-Reasonix/discussions
about: For usage questions, ideas, and general discussion — please use Discussions instead of opening an issue.
@@ -0,0 +1,29 @@
name: Feature request
description: Suggest an idea or improvement
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: dropdown
id: version-line
attributes:
label: Version line
description: Which line is this for?
options:
- "v2 — Go rewrite (1.x), main-v2 (active development)"
- "v1 — Legacy TypeScript (0.x), maintenance only"
validations:
required: true
- type: textarea
id: problem
attributes:
label: What problem does this solve?
description: What are you trying to do, and what gets in the way today?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
description: How would you like it to work?
validations:
required: false
+49
View File
@@ -0,0 +1,49 @@
version: 2
updates:
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
go:
patterns: ["*"]
update-types: ["minor", "patch"]
- package-ecosystem: "gomod"
directory: "/desktop"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
go:
patterns: ["*"]
update-types: ["minor", "patch"]
- package-ecosystem: "npm"
directory: "/desktop/frontend"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
npm:
patterns: ["*"]
update-types: ["minor", "patch"]
- package-ecosystem: "npm"
directory: "/site"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
npm:
patterns: ["*"]
update-types: ["minor", "patch"]
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
actions:
patterns: ["*"]
+51
View File
@@ -0,0 +1,51 @@
# Area labels auto-applied to PRs by .github/workflows/pr-auto-label.yml
# (actions/labeler), based on the changed file paths. Keep the area set in sync
# with issue-auto-label.yml. severity/platform are issue-only — a PR is a change,
# not a bug report — and v1/v2 are handled by pr-version-label.yml.
agent:
- changed-files:
- any-glob-to-any-file:
- 'internal/agent/**'
- 'internal/control/**'
mcp:
- changed-files:
- any-glob-to-any-file:
- 'internal/plugin/**'
- 'internal/codegraph/**'
config:
- changed-files:
- any-glob-to-any-file:
- 'internal/config/**'
- 'internal/boot/**'
provider:
- changed-files:
- any-glob-to-any-file:
- 'internal/provider/**'
skills:
- changed-files:
- any-glob-to-any-file:
- 'internal/skill/**'
- 'internal/tool/**'
tui:
- changed-files:
- any-glob-to-any-file:
- 'internal/cli/**'
desktop:
- changed-files:
- any-glob-to-any-file:
- 'desktop/**'
updater:
- changed-files:
- any-glob-to-any-file:
- 'desktop/cmd/sign/**'
- 'desktop/updater*.go'
- 'scripts/desktop-build.sh'
- '.github/workflows/release*.yml'
+19
View File
@@ -0,0 +1,19 @@
## Summary
-
## Verification
-
## Cache impact
Cache-impact: TODO
Cache-guard: TODO
System-prompt-review: N/A
For cache-sensitive changes, fill these lines before requesting review:
- `Cache-impact`: `none`, `low`, `medium`, or `high`, plus the reason.
- `Cache-guard`: the focused guard test/command added or run, or why an existing guard covers the change.
- `System-prompt-review`: required reviewer/approval note when provider-visible system prompt, memory prefix, output style, or skill index behavior changes.
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

+37
View File
@@ -0,0 +1,37 @@
name: Cache impact guard
on:
pull_request_target:
branches: [main-v2]
types: [opened, synchronize, reopened, edited, ready_for_review]
permissions:
contents: read
pull-requests: read
concurrency:
group: cache-impact-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
cache-impact:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.base.sha }}
- name: Collect PR changed files
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPOSITORY: ${{ github.repository }}
run: |
gh api --paginate "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files" \
--jq '.[].filename' > "$RUNNER_TEMP/cache-impact-files.txt"
- name: Check cache-sensitive PR metadata
env:
PR_BODY: ${{ github.event.pull_request.body }}
CACHE_IMPACT_CHANGED_FILES_FILE: ${{ runner.temp }}/cache-impact-files.txt
run: bash scripts/check-cache-impact.sh
+327
View File
@@ -0,0 +1,327 @@
name: CI
on:
push:
branches: [main-v2]
pull_request:
branches: [main-v2]
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Install and verify Linux sandbox backend
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y bubblewrap
if sysctl -n kernel.unprivileged_userns_clone >/dev/null 2>&1; then
sudo sysctl -w kernel.unprivileged_userns_clone=1
fi
if sysctl -n kernel.apparmor_restrict_unprivileged_userns >/dev/null 2>&1; then
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
fi
bwrap --ro-bind / / --dev /dev --proc /proc -- true || \
echo "::warning::bubblewrap is installed but this runner denies user namespaces; unavailable-backend tests will run fail-closed"
# Skipped on Windows: the runner checks out CRLF, so gofmt -l flags every
# file. gofmt output is OS-independent, so the Unix legs already cover it.
- name: gofmt
if: runner.os != 'Windows'
run: |
# Root module only — desktop/ is a separate module with its own tooling.
unformatted=$(gofmt -l . | grep -v '^desktop/' || true)
if [ -n "$unformatted" ]; then
echo "These files are not gofmt-clean:"
echo "$unformatted"
exit 1
fi
- name: vet
run: go vet ./...
- name: build
run: go build ./...
- name: test
if: runner.os != 'Windows'
env:
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
# regression there silently tanks the cache hit rate the project is
# built around.
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test ./...
- name: test
if: runner.os == 'Windows'
timeout-minutes: 10
env:
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
# regression there silently tanks the cache hit rate the project is
# built around.
REASONIX_RELEASE_CACHE_GUARD: "1"
# Bound sandbox helper children in Windows tests so a failed OS-level
# launch cannot pin the Actions step after Go's package timeout fires.
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -p 4 -timeout=3m ./...
race:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Install and verify Linux sandbox backend
run: |
sudo apt-get update
sudo apt-get install -y bubblewrap
if sysctl -n kernel.unprivileged_userns_clone >/dev/null 2>&1; then
sudo sysctl -w kernel.unprivileged_userns_clone=1
fi
if sysctl -n kernel.apparmor_restrict_unprivileged_userns >/dev/null 2>&1; then
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
fi
bwrap --ro-bind / / --dev /dev --proc /proc -- true || \
echo "::warning::bubblewrap is installed but this runner denies user namespaces; unavailable-backend tests will run fail-closed"
# The matrix never runs -race (it needs cgo); the project's concurrency
# (plugin fan-out, background phase B, jobs Kill/Wait) would otherwise
# ship without race coverage.
- name: test -race
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test -race ./...
desktop:
runs-on: ubuntu-22.04
defaults:
run:
working-directory: desktop
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- uses: pnpm/action-setup@v6
with:
version: 10
run_install: false
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/frontend/pnpm-lock.yaml
- name: Install Wails CLI
run: |
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
go install github.com/wailsapp/wails/v2/cmd/[email protected]
- name: gofmt
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "These files are not gofmt-clean:"
echo "$unformatted"
exit 1
fi
- name: go.mod tidy
run: |
go mod tidy
if ! git diff --quiet -- go.mod go.sum; then
echo "desktop/go.mod or go.sum is stale - run 'cd desktop && go mod tidy' and commit."
git diff -- go.mod go.sum
exit 1
fi
# WebKitGTK 4.0 toolchain (pinned to ubuntu-22.04; no webkit2_41 tag).
- name: Install Linux build deps
run: |
sudo apt-get update
sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.0-dev
- name: Build frontend
run: |
wails generate module
pnpm --dir frontend install --frozen-lockfile
pnpm --dir frontend build
- name: vet
run: go vet ./...
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.12.2
working-directory: desktop
args: --timeout=5m
- name: build
run: go build ./...
- name: test
run: go test ./...
# Desktop project-root matching is case-insensitive only on Windows
# (sameDesktopPath folds case when os.PathSeparator is '\'), so the
# regression tests for that contract are named *OnWindows and can never
# run on the ubuntu leg above.
desktop-windows:
runs-on: windows-latest
defaults:
run:
shell: bash
working-directory: desktop
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- uses: pnpm/action-setup@v6
with:
version: 10
run_install: false
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/frontend/pnpm-lock.yaml
- name: Install Wails CLI
run: |
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
go install github.com/wailsapp/wails/v2/cmd/[email protected]
# go:embed of frontend/dist needs a built frontend before the package
# compiles, same as the ubuntu desktop leg.
- name: Build frontend
run: |
wails generate module
pnpm --dir frontend install --frozen-lockfile
pnpm --dir frontend build
- name: test (Windows desktop and update helper)
timeout-minutes: 15
run: go test ./...
- name: Install NSIS
run: pwsh -NoProfile -File ../scripts/install-nsis.ps1
- name: Build Windows installer and portable archive
timeout-minutes: 20
run: ../scripts/desktop-build.sh windows/amd64 v0.0.0-ci canary
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.12.2
args: --timeout=5m
- name: release workflow contracts
run: |
go run github.com/rhysd/actionlint/cmd/[email protected] \
-ignore 'label "windows-11-arm" is unknown' \
.github/workflows/release-stable.yml \
.github/workflows/release.yml \
.github/workflows/release-npm.yml \
.github/workflows/release-desktop.yml
bash scripts/release-workflows.test.sh
# The site/ auth client has security-sensitive redirect-validation logic
# (safeNext) covered by node:test unit tests. Those tests use only Node
# builtins, so no `npm install` is needed — run them directly on every PR so
# a regression in redirect validation fails the build instead of shipping.
site:
runs-on: ubuntu-latest
defaults:
run:
working-directory: site
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: "24"
- name: test
run: npm test
govulncheck:
runs-on: ubuntu-latest
continue-on-error: true # informational — stdlib vulns need a Go patch release
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: govulncheck
run: govulncheck ./...
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: test with coverage
run: go test -coverprofile=coverage.out -covermode=atomic ./...
- name: upload coverage
uses: actions/upload-artifact@v7
with:
name: coverage-report
path: coverage.out
retention-days: 7
+40
View File
@@ -0,0 +1,40 @@
name: CodeQL
on:
push:
branches: ["main-v2"]
pull_request:
branches: ["main-v2"]
schedule:
- cron: "27 3 * * 1"
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
security-events: write
packages: read
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: go
build-mode: autobuild
- language: javascript-typescript
build-mode: none
- language: actions
build-mode: none
steps:
- uses: actions/checkout@v7
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{ matrix.language }}"
+11 -65
View File
@@ -1,27 +1,30 @@
name: Deploy identity and remote services
name: Deploy accounts worker
on:
push:
branches: [main-v2]
paths:
- 'workers/accounts/**'
- '.github/workflows/deploy-accounts-worker.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-identity-and-remote-services
group: deploy-accounts-worker
cancel-in-progress: false
jobs:
deploy-accounts:
if: github.ref == 'refs/heads/platform'
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
with:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm
@@ -32,9 +35,6 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
pnpm install --frozen-lockfile
pnpm test
pnpm typecheck
pnpm db:apply:remote
npx wrangler deploy
- name: Sync RESEND_API_KEY to the worker
working-directory: workers/accounts
@@ -47,57 +47,3 @@ jobs:
else
echo "RESEND_API_KEY not set in repo secrets; skipping (worker stays in stub email mode)."
fi
- name: Sync remote gateway secret
working-directory: workers/accounts
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
REMOTE_GATEWAY_TOKEN: ${{ secrets.REMOTE_GATEWAY_TOKEN }}
run: |
if [ -z "$REMOTE_GATEWAY_TOKEN" ]; then
echo "REMOTE_GATEWAY_TOKEN must be configured before enabling remote access."
exit 1
fi
printf '%s' "$REMOTE_GATEWAY_TOKEN" | npx wrangler secret put REMOTE_GATEWAY_TOKEN
- name: Sync feedback account secret
working-directory: workers/accounts
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
FEEDBACK_ACCOUNT_SECRET: ${{ secrets.FEEDBACK_ACCOUNT_SECRET }}
run: |
if [ -n "$FEEDBACK_ACCOUNT_SECRET" ]; then
printf '%s' "$FEEDBACK_ACCOUNT_SECRET" | npx wrangler secret put FEEDBACK_ACCOUNT_SECRET
else
echo "FEEDBACK_ACCOUNT_SECRET not set in repo secrets; feedback account linking stays unavailable."
fi
deploy-remote-gateway:
needs: deploy-accounts
if: github.ref == 'refs/heads/platform'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
cache: npm
cache-dependency-path: workers/remote-gateway/package-lock.json
- name: Validate gateway secret
env:
REMOTE_GATEWAY_TOKEN: ${{ secrets.REMOTE_GATEWAY_TOKEN }}
run: test -n "$REMOTE_GATEWAY_TOKEN"
- name: Validate and deploy
working-directory: workers/remote-gateway
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
npm ci
npm test
npm run typecheck
npx wrangler deploy
- name: Sync gateway secret
working-directory: workers/remote-gateway
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
REMOTE_GATEWAY_TOKEN: ${{ secrets.REMOTE_GATEWAY_TOKEN }}
run: printf '%s' "$REMOTE_GATEWAY_TOKEN" | npx wrangler secret put REMOTE_GATEWAY_TOKEN
+10 -212
View File
@@ -1,23 +1,12 @@
name: Deploy crash worker
on:
push:
branches: [main-v2]
paths:
- 'workers/crash-report/**'
- '.github/workflows/deploy-crash-worker.yml'
workflow_dispatch:
inputs:
firebase_data_action:
description: Firebase crash history operation (migration actions do not deploy the Worker)
required: true
default: none
type: choice
options:
- none
- dry-run
- apply
- verify-only
confirmation:
description: Enter APPLY_FIREBASE_CRASH_DATA when selecting apply
required: false
default: ''
type: string
permissions:
contents: read
@@ -28,150 +17,23 @@ concurrency:
jobs:
deploy:
if: github.ref == 'refs/heads/platform' && inputs.firebase_data_action == 'none'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
cache-dependency-path: workers/crash-report/package-lock.json
- name: Validate
working-directory: workers/crash-report
run: |
npm ci
npm run typecheck
npm test
- name: Apply and verify diagnostics v2 D1 migration
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npm run migrate:diagnostics-v2
- name: Apply and verify Firebase crash D1 migration
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npm run migrate:firebase-crash
- name: Apply dashboard performance indexes
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npm run migrate:dashboard-indexes
- name: Apply telemetry queue receipts schema
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npx wrangler d1 execute reasonix-crash --remote --file=migrate-telemetry-queue.sql
- name: Apply and verify feedback D1 migration
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
npx wrangler d1 execute reasonix-crash --remote --file=migrate-feedback.sql
out=$(npx wrangler d1 execute reasonix-crash --remote --json --command "SELECT name FROM sqlite_master WHERE name IN ('feedback','feedback_quota','feedback_status_created','feedback_install_created','feedback_status_version','feedback_quota_day')")
for name in feedback feedback_quota feedback_status_created feedback_install_created feedback_status_version feedback_quota_day; do
printf '%s' "$out" | grep -q "\"$name\"" || { echo "missing $name after migration"; exit 1; }
done
- name: Apply and verify feedback triage D1 migration
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
npx wrangler d1 execute reasonix-crash --remote --file=migrate-feedback-triage.sql
names="feedback_releases feedback_releases_install feedback_replies feedback_blocks feedback_trust feedback_public_images feedback_config feedback_audit feedback_replies_receipt feedback_replies_unhandled feedback_blocks_expires feedback_public_images_receipt feedback_audit_at feedback_install_status_updated feedback_reply_attachments feedback_reply_attachments_receipt"
list=$(for n in $names; do printf "'%s'," "$n"; done | sed 's/,$//')
out=$(npx wrangler d1 execute reasonix-crash --remote --json --command "SELECT name FROM sqlite_master WHERE name IN ($list)")
for name in $names; do
printf '%s' "$out" | grep -q "\"$name\"" || { echo "missing $name after migration"; exit 1; }
done
echo "Trusted installs (hash prefix only) for review:"
npx wrangler d1 execute reasonix-crash --remote --json --command "SELECT substr(install_hash,1,8) AS prefix, created_at, expires_at FROM feedback_trust ORDER BY created_at LIMIT 200"
- name: Apply and verify feedback adoption D1 migration
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
npx wrangler d1 execute reasonix-crash --remote --file=migrate-feedback-adoptions.sql
names="feedback_adoptions feedback_adoptions_install feedback_adoptions_item feedback_level_state"
list=$(for n in $names; do printf "'%s'," "$n"; done | sed 's/,$//')
out=$(npx wrangler d1 execute reasonix-crash --remote --json --command "SELECT name FROM sqlite_master WHERE name IN ($list)")
for name in $names; do
printf '%s' "$out" | grep -q "\"$name\"" || { echo "missing $name after migration"; exit 1; }
done
- name: Apply and verify feedback account link D1 migration
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
npx wrangler d1 execute reasonix-crash --remote --file=migrate-feedback-account-links.sql
names="feedback_account_links feedback_account_links_account"
list=$(for n in $names; do printf "'%s'," "$n"; done | sed 's/,$//')
out=$(npx wrangler d1 execute reasonix-crash --remote --json --command "SELECT name FROM sqlite_master WHERE name IN ($list)")
for name in $names; do
printf '%s' "$out" | grep -q "\"$name\"" || { echo "missing $name after migration"; exit 1; }
done
- name: Apply and verify feedback contribution board D1 migration
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
npx wrangler d1 execute reasonix-crash --remote --file=migrate-feedback-board.sql
names="feedback_board_optin feedback_board_delisted"
list=$(for n in $names; do printf "'%s'," "$n"; done | sed 's/,$//')
out=$(npx wrangler d1 execute reasonix-crash --remote --json --command "SELECT name FROM sqlite_master WHERE name IN ($list)")
for name in $names; do
printf '%s' "$out" | grep -q "\"$name\"" || { echo "missing $name after migration"; exit 1; }
done
- name: Apply Studio telemetry schema
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npx wrangler d1 execute reasonix-crash --remote --file=migrate-studio-telemetry.sql
- name: Apply isolated telemetry database schema
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npm run migrate:telemetry-db
- name: Apply registry performance indexes and install rollup
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npm run migrate:registry-indexes
- name: Sync Firebase crash secrets
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }}
FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }}
FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }}
run: |
configured=0
[ -n "$FIREBASE_DATABASE_URL" ] && configured=$((configured + 1))
[ -n "$FIREBASE_CLIENT_EMAIL" ] && configured=$((configured + 1))
[ -n "$FIREBASE_PRIVATE_KEY" ] && configured=$((configured + 1))
if [ "$configured" -ne 0 ] && [ "$configured" -ne 3 ]; then
echo "Firebase crash secrets must be either all configured or all absent."
exit 1
fi
node -e 'process.stdout.write(JSON.stringify({FIREBASE_DATABASE_URL:process.env.FIREBASE_DATABASE_URL||null,FIREBASE_CLIENT_EMAIL:process.env.FIREBASE_CLIENT_EMAIL||null,FIREBASE_PRIVATE_KEY:process.env.FIREBASE_PRIVATE_KEY||null}))' | npx wrangler secret bulk
- name: Deploy
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
npm ci
npm run typecheck
npm test
npx wrangler deploy
- name: Sync feedback secrets
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
FEEDBACK_TOKEN_SECRET: ${{ secrets.FEEDBACK_TOKEN_SECRET }}
FEEDBACK_ADMIN_TOKEN: ${{ secrets.FEEDBACK_ADMIN_TOKEN }}
OPS_EMIT_TOKEN: ${{ secrets.OPS_EMIT_TOKEN }}
FEEDBACK_ACCOUNT_SECRET: ${{ secrets.FEEDBACK_ACCOUNT_SECRET }}
run: |
node -e 'process.stdout.write(JSON.stringify({FEEDBACK_TOKEN_SECRET:process.env.FEEDBACK_TOKEN_SECRET||null,FEEDBACK_ADMIN_TOKEN:process.env.FEEDBACK_ADMIN_TOKEN||null,OPS_EMIT_TOKEN:process.env.OPS_EMIT_TOKEN||null,FEEDBACK_ACCOUNT_SECRET:process.env.FEEDBACK_ACCOUNT_SECRET||null}))' | npx wrangler secret bulk
# Mirrors the ALERT_WEBHOOK repo secret into the worker so the ingest
# sentinel can push alerts (see runIngestSentinel). Managed here so
# nobody needs local Cloudflare credentials; when the repo secret is
@@ -188,67 +50,3 @@ jobs:
exit 0
fi
printf '%s' "$ALERT_WEBHOOK" | npx wrangler secret put ALERT_WEBHOOK
migrate-firebase-data:
if: github.event_name == 'workflow_dispatch' && inputs.firebase_data_action != 'none'
runs-on: ubuntu-latest
environment: canary
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Require the protected production branch
run: |
if [ "$GITHUB_REF" != "refs/heads/platform" ]; then
echo "Firebase crash data migration must run from platform."
exit 1
fi
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
cache: npm
cache-dependency-path: workers/crash-report/package-lock.json
- name: Validate
working-directory: workers/crash-report
run: |
npm ci
npm run typecheck
npm test
- name: Run Firebase crash history operation
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }}
FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }}
FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }}
FIREBASE_DATA_ACTION: ${{ inputs.firebase_data_action }}
FIREBASE_DATA_CONFIRMATION: ${{ inputs.confirmation }}
run: |
configured=0
[ -n "$CLOUDFLARE_API_TOKEN" ] && configured=$((configured + 1))
[ -n "$FIREBASE_DATABASE_URL" ] && configured=$((configured + 1))
[ -n "$FIREBASE_CLIENT_EMAIL" ] && configured=$((configured + 1))
[ -n "$FIREBASE_PRIVATE_KEY" ] && configured=$((configured + 1))
if [ "$configured" -ne 4 ]; then
echo "Cloudflare and Firebase migration secrets must all be configured."
exit 1
fi
case "$FIREBASE_DATA_ACTION" in
dry-run)
npm run migrate:firebase-data
;;
apply)
if [ "$FIREBASE_DATA_CONFIRMATION" != "APPLY_FIREBASE_CRASH_DATA" ]; then
echo "Apply requires the exact confirmation APPLY_FIREBASE_CRASH_DATA."
exit 1
fi
npm run migrate:firebase-data -- --apply
npm run migrate:firebase-data -- --verify-only
;;
verify-only)
npm run migrate:firebase-data -- --verify-only
;;
*)
echo "Unsupported Firebase crash data action."
exit 1
;;
esac
+8 -8
View File
@@ -1,6 +1,11 @@
name: Deploy forum worker
on:
push:
branches: [main-v2]
paths:
- 'workers/forum/**'
- '.github/workflows/deploy-forum-worker.yml'
workflow_dispatch:
permissions:
@@ -12,16 +17,14 @@ concurrency:
jobs:
deploy:
if: github.ref == 'refs/heads/platform'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
with:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm
@@ -32,7 +35,4 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
pnpm install --frozen-lockfile
pnpm test
pnpm typecheck
npx wrangler d1 execute reasonix-forum --remote --file=migrate-performance-indexes.sql
npx wrangler deploy
-44
View File
@@ -1,44 +0,0 @@
name: Deploy ops events relay
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-ops-events
cancel-in-progress: false
jobs:
deploy:
if: github.ref == 'refs/heads/platform'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
cache: npm
cache-dependency-path: workers/ops-events/package-lock.json
- name: Validate
working-directory: workers/ops-events
run: |
npm ci
npm run typecheck
npm test
- name: Deploy
working-directory: workers/ops-events
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npx wrangler deploy
- name: Sync secrets
working-directory: workers/ops-events
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
GITHUB_WEBHOOK_SECRET: ${{ secrets.OPS_GITHUB_WEBHOOK_SECRET }}
OPS_LISTEN_TOKEN: ${{ secrets.OPS_LISTEN_TOKEN }}
OPS_EMIT_TOKEN: ${{ secrets.OPS_EMIT_TOKEN }}
run: |
node -e 'process.stdout.write(JSON.stringify({GITHUB_WEBHOOK_SECRET:process.env.GITHUB_WEBHOOK_SECRET||null,OPS_LISTEN_TOKEN:process.env.OPS_LISTEN_TOKEN||null,OPS_EMIT_TOKEN:process.env.OPS_EMIT_TOKEN||null}))' | npx wrangler secret bulk
+160
View File
@@ -0,0 +1,160 @@
name: e2e-bot
# Comment "/e2e" (fixed suite) or "/e2e diff" (generate tests for the PR's diff)
# on a pull request to run the e2e benchmark against the real provider and post a
# report back. Gated to trusted authors: the job checks out PR-head code and runs
# it with the provider API key, so only the repo owner, members, and collaborators
# may trigger it.
on:
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: write
concurrency:
group: e2e-bot-${{ github.event.issue.number }}
cancel-in-progress: true
jobs:
e2e:
if: >-
github.event.issue.pull_request &&
contains(github.event.comment.body, '/e2e') &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
runs-on: ubuntu-latest
# Running PR-head code with the provider secret is gated twice: the author_
# association check above, and this deployment environment. Configure the
# `e2e-bot` environment with required reviewers in repo settings to force a
# human approval per run (actions/untrusted-checkout-toctou).
environment: e2e-bot
steps:
- name: Acknowledge
uses: actions/github-script@v9
with:
script: |
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner, repo: context.repo.repo,
comment_id: context.payload.comment.id, content: 'eyes',
});
# Default-branch checkout: this is where the harness (cmd/e2ebench), the
# suite, and a run --metrics-capable agent live.
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Build harness + fallback agent from the default branch
# Harness (e2ebench) and suite always come from main-v2 so a PR can't weaken
# its own grader or tests. The agent is rebuilt from the PR head below; this
# main-v2 build is only the fallback for heads that predate `run --metrics`.
run: |
go build -o "$RUNNER_TEMP/reasonix-base" ./cmd/reasonix
go build -o "$RUNNER_TEMP/e2ebench" ./cmd/e2ebench
cp -r benchmarks/e2e "$RUNNER_TEMP/suite"
- name: Check out the PR head
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Pin to the head commit resolved now and check it out detached, not the
# mutable PR ref: a force-push mid-run can't swap in different code after
# the trusted-author gate passed.
run: |
SHA=$(gh pr view ${{ github.event.issue.number }} --json headRefOid -q .headRefOid)
git fetch -q origin "$SHA"
git checkout -q --detach "$SHA"
- name: Build the agent from the PR head
# The whole point of the bot is to drive the PR's code, not main-v2's. Fall
# back to the main-v2 build only when the PR head can't yield a
# run --metrics-capable binary (build break or predates the flag).
id: agent
run: |
bin="$RUNNER_TEMP/reasonix-base"
src="main-v2 fallback (PR head lacks run --metrics)"
if go build -o "$RUNNER_TEMP/reasonix-pr" ./cmd/reasonix \
&& "$RUNNER_TEMP/reasonix-pr" run -h 2>&1 | grep -q -- '-metrics'; then
bin="$RUNNER_TEMP/reasonix-pr"
src="PR head ($(git rev-parse --short HEAD))"
fi
echo "bin=$bin" >> "$GITHUB_OUTPUT"
echo "src=$src" >> "$GITHUB_OUTPUT"
echo "agent under test: $src"
- name: Write provider config
# User config covers suite tasks (they run in temp dirs); the repo-root copy
# covers diff mode (the agent runs in the repo root, where project config wins).
run: |
mkdir -p ~/.config/reasonix
cat > /tmp/reasonix-e2e.toml <<EOF
default_model = "e2e"
[[providers]]
name = "e2e"
kind = "openai"
base_url = "${{ vars.REASONIX_E2E_BASE_URL || 'https://api.deepseek.com' }}"
model = "${{ vars.REASONIX_E2E_MODEL || 'deepseek-v4-flash' }}"
api_key_env = "DEEPSEEK_API_KEY"
context_window = 20000 # small so the suite actually exercises compaction + cache churn
[providers.price]
cache_hit = ${{ vars.REASONIX_E2E_PRICE_CACHE_HIT || '0.02' }}
input = ${{ vars.REASONIX_E2E_PRICE_INPUT || '1' }}
output = ${{ vars.REASONIX_E2E_PRICE_OUTPUT || '2' }}
currency = "${{ vars.REASONIX_E2E_PRICE_CURRENCY || '¥' }}"
[permissions]
mode = "allow"
[codegraph]
enabled = false
EOF
cp /tmp/reasonix-e2e.toml ~/.config/reasonix/config.toml
cp /tmp/reasonix-e2e.toml ./reasonix.toml
- name: Run e2e
env:
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COMMENT_BODY: ${{ github.event.comment.body }}
run: |
if [ -z "$DEEPSEEK_API_KEY" ]; then
printf 'missing DEEPSEEK_API_KEY secret\n\nAdd a repository secret named DEEPSEEK_API_KEY to enable the e2e bot.\n' > report.md
exit 0
fi
if printf '%s' "$COMMENT_BODY" | grep -q '/e2e[[:space:]]\+diff'; then
ATTEMPTS=$(printf '%s' "$COMMENT_BODY" | sed -nE 's@.*/e2e[[:space:]]+diff[[:space:]]+x([0-9]+).*@\1@p' | head -1)
[ -z "$ATTEMPTS" ] && ATTEMPTS=1
[ "$ATTEMPTS" -gt 5 ] && ATTEMPTS=5
BASE_REF=$(gh pr view ${{ github.event.issue.number }} --json baseRefName -q .baseRefName)
git fetch -q origin "$BASE_REF"
BASE=$(git merge-base "origin/$BASE_REF" HEAD)
"$RUNNER_TEMP/e2ebench" -mode diff -bin "${{ steps.agent.outputs.bin }}" -repo . -base "$BASE" -model e2e -attempts "$ATTEMPTS" -out report.md
else
"$RUNNER_TEMP/e2ebench" -bin "${{ steps.agent.outputs.bin }}" -suite "$RUNNER_TEMP/suite" -model e2e -out report.md -json report.json -budget 400000
fi
- name: Post report
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TRIGGER_USER: ${{ github.event.comment.user.login }}
run: |
printf '\n> agent: %s · triggered by @%s\n' "${{ steps.agent.outputs.src }}" "$TRIGGER_USER" >> report.md
gh pr comment ${{ github.event.issue.number }} --body-file report.md
- name: Report failure
if: failure()
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh pr comment ${{ github.event.issue.number }} --body "🤖 e2e bot failed — see the [run log](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})."
+99
View File
@@ -0,0 +1,99 @@
name: Auto-label issues
# Classify new issues into area / platform / severity labels using the DeepSeek
# API (the project's own model). The model is constrained to a fixed label set —
# anything it returns outside the set is dropped — so it can't invent labels.
# Issues it can't place into any area get `needs-triage` for a human to look at.
# Version (v1/v2) labels are handled separately by issue-version-label.yml.
on:
issues:
types: [opened, reopened]
permissions:
issues: write
concurrency:
group: issue-autolabel-${{ github.event.issue.number }}
cancel-in-progress: true
jobs:
classify:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v9
env:
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
with:
script: |
const AREA = ['agent','mcp','config','updater','provider','desktop','tui','skills','rendering'];
const PLATFORM = ['windows','macos','linux'];
const SEVERITY = ['crash','data-loss','security'];
const ALLOWED = new Set([...AREA, ...PLATFORM, ...SEVERITY]);
const issue = context.payload.issue;
const title = issue.title || '';
const body = (issue.body || '').slice(0, 4000);
const system = [
'You categorize GitHub issues for Reasonix, a Go-based AI coding agent with a Wails desktop app and a terminal UI.',
'Pick labels ONLY from these fixed sets. Never invent labels.',
'area (0-2, the affected subsystem):',
' agent: core agent loop / tool-calling / reasoning',
' mcp: MCP servers, plugins, codegraph',
' config: configuration, setup wizard, .toml/.env',
' updater: auto-update, installer, release packaging',
' provider: model providers, model selection/switching',
' desktop: Wails desktop GUI',
' tui: terminal UI / CLI',
' skills: skills system',
' rendering: terminal rendering / flicker / repaint',
'platform (only if clearly specific to one OS): windows, macos, linux',
'severity (only if clearly applicable):',
' crash: app crashes, hangs, or freezes',
' data-loss: loss of sessions, config, or history',
' security: credential/secret exposure or a security flaw',
'Be conservative: omit a label when unsure. The issue may be in Chinese.',
'Reply with JSON only: {"area":[],"platform":[],"severity":[]}',
].join('\n');
let labels = [];
try {
const res = await fetch('https://api.deepseek.com/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.DEEPSEEK_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'deepseek-chat',
temperature: 0,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: system },
{ role: 'user', content: `Title: ${title}\n\nBody:\n${body}` },
],
}),
});
if (!res.ok) {
core.warning(`DeepSeek API ${res.status}: ${await res.text()}`);
return;
}
const data = await res.json();
const parsed = JSON.parse(data.choices[0].message.content);
labels = [...(parsed.area || []), ...(parsed.platform || []), ...(parsed.severity || [])]
.filter(l => ALLOWED.has(l));
} catch (e) {
core.warning(`Classification failed: ${e.message}`);
return;
}
if (!labels.some(l => AREA.includes(l))) labels.push('needs-triage');
if (labels.length) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: issue.number,
labels,
});
core.info(`Applied: ${labels.join(', ')}`);
}
+48
View File
@@ -0,0 +1,48 @@
name: Label issue version
# Issue forms can't let a reporter set a label directly (that needs triage rights),
# so the bug/feature forms carry a "Version line" dropdown and this workflow reads
# the submitted value and applies the matching v1/v2 label on their behalf.
on:
issues:
types: [opened, edited]
permissions:
issues: write
concurrency:
group: issue-version-${{ github.event.issue.number }}
cancel-in-progress: true
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v9
with:
script: |
const body = context.payload.issue.body || '';
// Pull the "Version line" section out of the rendered issue form.
const section = body.split(/^###\s+/m).find(s => /^Version line/i.test(s)) || '';
const m = section.match(/\bv([12])\b/i);
if (!m) {
core.info('No version line found; nothing to label.');
return;
}
const choice = 'v' + m[1];
const other = choice === 'v2' ? 'v1' : 'v2';
await github.rest.issues.addLabels({
...context.repo,
issue_number: context.issue.number,
labels: [choice],
});
// Keep v1/v2 mutually exclusive in case an edit flipped the choice.
try {
await github.rest.issues.removeLabel({
...context.repo,
issue_number: context.issue.number,
name: other,
});
} catch (e) {
core.info(`No ${other} label to remove.`);
}
+104
View File
@@ -0,0 +1,104 @@
name: MCP catalog
on:
pull_request:
paths:
- "cmd/mcp-catalog/**"
- "internal/mcpcatalog/**"
- "catalog/mcp/**"
- ".github/workflows/mcp-catalog.yml"
push:
branches: [main-v2]
paths:
- "cmd/mcp-catalog/**"
- "internal/mcpcatalog/**"
- "catalog/mcp/**"
- ".github/workflows/mcp-catalog.yml"
workflow_dispatch:
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Validate schema, duplicate entries, reader declarations, and bundled signature
run: |
go test ./internal/mcpcatalog
go run ./cmd/mcp-catalog validate \
--index internal/mcpcatalog/catalog-v1.json \
--signature internal/mcpcatalog/catalog-v1.json.minisig \
--packages catalog/mcp/packages
sign:
# A manually selected feature branch must never be able to publish a new
# trust root payload. Catalog releases only run from the protected branch.
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main-v2'
needs: validate
runs-on: ubuntu-latest
environment: release
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Sign catalog with the dedicated release secret
env:
MCP_CATALOG_MINISIGN_PRIVATE_KEY: ${{ secrets.MCP_CATALOG_MINISIGN_PRIVATE_KEY }}
MCP_CATALOG_MINISIGN_PASSWORD: ${{ secrets.MCP_CATALOG_MINISIGN_PASSWORD }}
run: |
go run ./cmd/mcp-catalog sign \
--index internal/mcpcatalog/catalog-v1.json \
--output index.json.minisig
cp internal/mcpcatalog/catalog-v1.json index.json
- uses: actions/upload-artifact@v7
with:
name: mcp-catalog-v1-${{ github.run_id }}
path: |
index.json
index.json.minisig
if-no-files-found: error
- name: Publish the signed catalog to the Reasonix R2 origin
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
test -n "$AWS_ACCESS_KEY_ID"
test -n "$AWS_SECRET_ACCESS_KEY"
test -n "$R2_ACCOUNT_ID"
test -n "$R2_BUCKET"
endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
sequence="$(jq -er '.sequence' index.json)"
if aws s3 cp "s3://${R2_BUCKET}/plugins/catalog/v1/index.json" current-index.json --endpoint-url "$endpoint"; then
current_sequence="$(jq -er '.sequence' current-index.json)"
if [ "$sequence" -lt "$current_sequence" ]; then
echo "catalog sequence rollback: new=$sequence current=$current_sequence" >&2
exit 1
fi
if [ "$sequence" -eq "$current_sequence" ] && ! cmp -s index.json current-index.json; then
echo "catalog content changed without increasing sequence $sequence" >&2
exit 1
fi
fi
prefix="plugins/catalog/v1/history/${sequence}"
aws s3 cp index.json "s3://${R2_BUCKET}/${prefix}/index.json" \
--endpoint-url "$endpoint" --content-type application/json --cache-control "public, max-age=31536000, immutable"
aws s3 cp index.json.minisig "s3://${R2_BUCKET}/${prefix}/index.json.minisig" \
--endpoint-url "$endpoint" --content-type application/octet-stream --cache-control "public, max-age=31536000, immutable"
# Readers retain their last-known-good copy across the brief pair
# update; a mismatched pair therefore fails closed instead of running.
aws s3 cp index.json "s3://${R2_BUCKET}/plugins/catalog/v1/index.json" \
--endpoint-url "$endpoint" --content-type application/json --cache-control "public, max-age=300"
aws s3 cp index.json.minisig "s3://${R2_BUCKET}/plugins/catalog/v1/index.json.minisig" \
--endpoint-url "$endpoint" --content-type application/octet-stream --cache-control "public, max-age=300"
+47
View File
@@ -0,0 +1,47 @@
name: Deploy site
on:
push:
branches: [main-v2]
paths: ['site/**', 'release-notes/**', '.github/workflows/pages.yml']
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
cache-dependency-path: site/package-lock.json
- name: Build
working-directory: site
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm ci
npm run build
- uses: actions/upload-pages-artifact@v5
with:
path: site/dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
- id: deploy
uses: actions/deploy-pages@v5
-89
View File
@@ -1,89 +0,0 @@
name: Platform CI
on:
push:
branches: [platform]
pull_request:
branches: [platform]
permissions:
contents: read
concurrency:
group: platform-ci-${{ github.ref }}
cancel-in-progress: true
jobs:
accounts:
runs-on: ubuntu-latest
defaults:
run:
working-directory: workers/accounts
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: workers/accounts/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
- run: pnpm test
- run: pnpm typecheck
forum:
runs-on: ubuntu-latest
defaults:
run:
working-directory: workers/forum
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: workers/forum/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
- run: pnpm test
- run: pnpm typecheck
crash-report:
runs-on: ubuntu-latest
defaults:
run:
working-directory: workers/crash-report
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
cache: npm
cache-dependency-path: workers/crash-report/package-lock.json
- run: npm ci
- run: npm run typecheck
- run: npm test
- run: npx wrangler deploy --dry-run
remote-gateway:
runs-on: ubuntu-latest
defaults:
run:
working-directory: workers/remote-gateway
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
cache: npm
cache-dependency-path: workers/remote-gateway/package-lock.json
- run: npm ci
- run: npm test
- run: npm run typecheck
- run: npx wrangler deploy --dry-run
+28
View File
@@ -0,0 +1,28 @@
name: Auto-label PRs
# Apply area labels to PRs based on changed paths (see .github/labeler.yml).
# Complements the AI-based issue labeler (issue-auto-label.yml). Version (v1/v2)
# labels are handled separately by pr-version-label.yml.
#
# pull_request_target runs in the base repo context so it has the write token
# needed to label PRs from forks; actions/labeler only reads the diff file list
# (it never checks out or runs PR code), so this is safe.
on:
pull_request_target:
types: [opened, synchronize, reopened]
permissions:
contents: read
pull-requests: write
concurrency:
group: pr-autolabel-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v6
with:
sync-labels: false
+49
View File
@@ -0,0 +1,49 @@
name: Label PR version
# Contributors (especially from forks) can't set labels themselves — that needs
# triage rights. A PR's version line is fully determined by its base branch, so
# this reads base.ref and applies v1/v2 on their behalf. pull_request_target runs
# in the base repo's context so it has write access even for fork PRs; it only
# reads trusted metadata (the base ref) and never checks out or runs PR code.
on:
pull_request_target:
types: [opened, reopened, edited]
permissions:
pull-requests: write
concurrency:
group: pr-version-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v9
with:
script: |
const base = context.payload.pull_request.base.ref;
const label = base === 'v1' ? 'v1'
: base === 'main-v2' ? 'v2'
: null;
if (!label) {
core.info(`Base branch '${base}' isn't a release line; skipping.`);
return;
}
const other = label === 'v2' ? 'v1' : 'v2';
await github.rest.issues.addLabels({
...context.repo,
issue_number: context.payload.pull_request.number,
labels: [label],
});
// Drop the other line's label if the base was changed after opening.
try {
await github.rest.issues.removeLabel({
...context.repo,
issue_number: context.payload.pull_request.number,
name: other,
});
} catch (e) {
core.info(`No ${other} label to remove.`);
}
@@ -0,0 +1,83 @@
name: Prepare release notes
on:
workflow_dispatch:
inputs:
version:
description: "Release version without a tag prefix (for example 1.18.0)"
required: true
type: string
from_tag:
description: "Previous release tag (defaults to the newest catalog entry)"
required: false
type: string
permissions:
contents: write
pull-requests: write
jobs:
draft:
name: generate reviewable bilingual draft
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: "22"
- name: Prepare the release-notes branch
env:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
branch="release-notes/v${VERSION#v}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch" main-v2
git checkout -b "$branch" --track "origin/$branch"
git merge --no-edit origin/main-v2
else
git checkout -b "$branch"
fi
echo "RELEASE_NOTES_BRANCH=$branch" >> "$GITHUB_ENV"
- name: Generate and validate release notes
env:
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
DEEPSEEK_MODEL: deepseek-v4-pro
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
FROM_TAG: ${{ inputs.from_tag }}
run: |
set -euo pipefail
args=(--version "$VERSION")
if [ -n "$FROM_TAG" ]; then args+=(--from "$FROM_TAG"); fi
node scripts/generate-release-notes.mjs "${args[@]}"
node scripts/release-notes.mjs validate
node --test scripts/release-notes.test.mjs
node scripts/release-notes.mjs render --version "$VERSION" --output /tmp/release-notes.md
- name: Open or update the review PR
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
git add release-notes/releases.json
if ! git diff --cached --quiet; then
git commit -m "docs(release): prepare v${VERSION#v} notes" \
-m "Generate a bilingual, product-focused draft from merged pull request metadata. The reviewed catalog will drive the website, desktop app, and GitHub releases."
fi
git push -u origin "$RELEASE_NOTES_BRANCH"
if ! gh pr list --head "$RELEASE_NOTES_BRANCH" --state open --json number --jq 'length > 0' | grep -q true; then
gh pr create \
--base main-v2 \
--head "$RELEASE_NOTES_BRANCH" \
--title "docs(release): 准备 v${VERSION#v} 中英更新日志" \
--body-file /tmp/release-notes.md
fi
+590
View File
@@ -0,0 +1,590 @@
name: Release desktop
# Desktop (Wails) release line. Stable releases are called by
# release-stable.yml after its single GitHub environment approval. Direct tag
# pushes are retained only for desktop prereleases so they can still ship
# independently.
#
# Wails cannot cross-compile a CGO/WebKit binary, so build/ fans out to one native
# runner per platform. Artifacts are minisign-signed (MINISIGN_* secrets), a
# latest.json manifest is generated, and everything is published to a GitHub
# release and mirrored to R2 (the updater reads R2 first, then the crash worker
# release gateway; stable desktop releases own GitHub's repository-wide "latest").
#
# The same build/sign/manifest pipeline serves two channels. An orchestrated
# `desktop-v*` tag (or manual stable recovery) publishes a GitHub release and
# moves R2's latest/ pointer. A manual `channel: canary` dispatch builds with
# -X main.channel=canary
# and uploads only to R2's canary/ pointer — no GitHub release, so canary never
# shows on the releases page and never touches latest/.
on:
push:
tags: ["desktop-v*-*"]
workflow_dispatch:
inputs:
channel:
description: "Release channel"
type: choice
options: [stable, canary]
default: stable
tag:
description: "stable: tag to publish (e.g. desktop-v1.1.0)"
required: false
type: string
base_version:
description: "canary: intended next stable version, e.g. 1.5.0"
required: false
type: string
workflow_call:
inputs:
channel:
description: "Release channel selected by the approved orchestrator"
required: true
type: string
tag:
description: "Existing desktop tag to publish"
required: false
default: ""
type: string
base_version:
description: "Base version used for canary builds"
required: false
default: ""
type: string
approved_cli_tag:
description: "Stable CLI tag recorded by the approved orchestrator"
required: true
type: string
approved_sha:
description: "Immutable commit recorded by the approved orchestrator"
required: true
type: string
orchestrated:
description: "True only when called by release-stable.yml after approval"
required: false
default: false
type: boolean
permissions:
contents: write # create the release and upload artifacts
jobs:
orchestration-guard:
name: verify approved orchestrator
if: ${{ inputs.orchestrated }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.sha }}
- name: Verify caller and approved release ref
env:
ACTUAL_CALLER_WORKFLOW_REF: ${{ github.workflow_ref }}
EXPECTED_CALLER_WORKFLOW_REF: ${{ format('{0}/.github/workflows/release-stable.yml@{1}', github.repository, github.ref) }}
CALLER_EVENT_NAME: ${{ github.event_name }}
CALLER_REF: ${{ github.ref }}
CALLER_REF_PROTECTED: ${{ github.ref_protected }}
CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }}
CALLER_SHA: ${{ github.sha }}
APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
RELEASE_TAG: ${{ inputs.tag }}
VERIFY_RELEASE_CHECKOUT: false
run: |
bash scripts/verify-release-authorization.sh
bash scripts/verify-release-tag.sh
release-gate:
name: approve standalone desktop release
if: ${{ !inputs.orchestrated && !(github.event_name == 'workflow_dispatch' && inputs.channel == 'canary') }}
runs-on: ubuntu-latest
environment: release
steps:
- env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
run: echo "Approved standalone desktop release $RELEASE_TAG"
cache-guard:
name: cache hit guard
needs: [orchestration-guard, release-gate]
if: ${{ always() && !cancelled() && ((inputs.orchestrated && needs.orchestration-guard.result == 'success') || (!inputs.orchestrated && ((github.event_name == 'workflow_dispatch' && inputs.channel == 'canary') || needs.release-gate.result == 'success'))) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Cache hit guard
run: ./scripts/cache-guard.sh
build:
name: build (${{ matrix.name }})
needs: cache-guard
if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }}
permissions:
contents: read # checkout only; the publish job holds contents: write
actions: read # SignPath reads run details + downloads the unsigned artifact
strategy:
fail-fast: false
matrix:
include:
# One universal macOS build (Intel + Apple Silicon) on a single arm64
# runner — avoids the scarce/slow macos-13 Intel runner.
- { runner: macos-14, platform: darwin/universal, name: darwin-universal }
- { runner: windows-latest, platform: windows/amd64, name: windows-amd64 }
- { runner: windows-11-arm, platform: windows/arm64, name: windows-arm64 }
- { runner: ubuntu-22.04, platform: linux/amd64, name: linux-amd64 }
runs-on: ${{ matrix.runner }}
env:
# Windows Authenticode signing engages only when the SignPath token is set, so
# forks / token-less runs still build (unsigned), mirroring the APPLE_* gate.
HAS_SIGNPATH: ${{ secrets.SIGNPATH_API_TOKEN != '' }}
defaults:
run:
shell: bash # desktop-build.sh is bash; windows runners default to pwsh otherwise
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
- uses: actions/setup-go@v6
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- uses: actions/setup-node@v6
with:
node-version: "22"
- uses: pnpm/action-setup@v6
with:
version: 10
# Linux: WebKitGTK 4.1 toolchain (-tags webkit2_41 in desktop-build.sh).
# 4.1 ships from ubuntu-22.04 on and is the only one present on 24.04+/Fedora 40+.
- name: Install Linux build deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.1-dev
# Linux: nfpm builds the .deb in desktop-build.sh's linux branch. go install
# drops it in ~/go/bin, already on PATH (same place the wails CLI lands below).
- name: Install nfpm
if: runner.os == 'Linux'
run: go install github.com/goreleaser/nfpm/v2/cmd/[email protected]
# Windows: NSIS provides makensis for `wails build -nsis`.
- name: Install NSIS
if: runner.os == 'Windows'
run: pwsh -NoProfile -File scripts/install-nsis.ps1
# macOS: create-dmg packages the .app into a drag-to-Applications .dmg.
- name: Install create-dmg
if: runner.os == 'macOS'
run: brew install create-dmg
# macOS signing: import the Developer ID cert into a throwaway keychain and
# stage the notarization key. No-ops (and the build ad-hoc signs) when the
# APPLE_* secrets aren't set, so forks still build.
- name: Import Apple signing certificate
if: runner.os == 'macOS'
env:
APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }}
APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }}
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
run: |
if [ -z "$APPLE_CERT_P12" ]; then
echo "APPLE_CERT_P12 unset — desktop build will ad-hoc sign (un-notarized)"
exit 0
fi
KEYCHAIN="$RUNNER_TEMP/signing.keychain-db"
KEYCHAIN_PASS="$(uuidgen)"
security create-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN"
echo "$APPLE_CERT_P12" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" -P "$APPLE_CERT_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASS" "$KEYCHAIN" >/dev/null
# Prepend the signing keychain to the search list so codesign / find-identity see it.
existing_keychains=()
while IFS= read -r keychain; do
[ -n "$keychain" ] && existing_keychains+=("$keychain")
done < <(security list-keychains -d user | sed -E 's/^[[:space:]]*"//; s/"[[:space:]]*$//')
security list-keychains -d user -s "$KEYCHAIN" "${existing_keychains[@]}"
echo "$APPLE_API_KEY_P8" | base64 --decode > "$RUNNER_TEMP/notary.p8"
rm -f "$RUNNER_TEMP/cert.p12"
- name: Install Wails CLI
run: go install github.com/wailsapp/wails/v2/cmd/[email protected]
- name: Resolve version
id: ver
run: bash scripts/resolve-desktop-release.sh
env:
EVENT_NAME: ${{ github.event_name }}
IN_CHANNEL: ${{ inputs.channel }}
IN_TAG: ${{ inputs.tag }}
IN_BASE_VERSION: ${{ inputs.base_version }}
REF_NAME: ${{ github.ref_name }}
RUN_NUMBER: ${{ github.run_number }}
- name: Build and package
env:
# macOS Developer ID + notarization path turns on only when all five
# APPLE_* secrets are present; otherwise desktop-build.sh ad-hoc signs.
# Harmless on Windows/Linux runners (only the darwin branch reads these).
HAS_APPLE_CERT: ${{ secrets.APPLE_CERT_P12 != '' && secrets.APPLE_CERT_PASSWORD != '' && secrets.APPLE_API_KEY_P8 != '' && secrets.APPLE_API_KEY_ID != '' && secrets.APPLE_API_ISSUER_ID != '' }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/notary.p8
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
run: scripts/desktop-build.sh "${{ matrix.platform }}" "${{ steps.ver.outputs.version }}" "${{ steps.ver.outputs.channel }}"
# Authenticode-sign the Windows installer through SignPath. Runs BEFORE minisign:
# the signature rewrites the installer's bytes, so the updater's minisign sig and
# the manifest SHA must be computed over the signed build. SignPath pulls the file
# from the Actions artifact API, so it must be uploaded first. Canary signs with the
# test certificate; stable/rc sign with the Foundation release certificate, but only
# once SIGNPATH_RELEASE_SIGNING_READY is set. The release-signing policy verifies the
# trusted GitHub origin and intentionally retains its manual approval process as a
# second-party control. Each Windows architecture can require its own confirmation.
- name: Upload unsigned installer for SignPath
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' && (steps.ver.outputs.channel == 'canary' || vars.SIGNPATH_RELEASE_SIGNING_READY == 'true')
id: unsigned-installer
uses: actions/upload-artifact@v7
with:
name: unsigned-${{ matrix.name }}
path: dist/*installer*.exe
if-no-files-found: error
retention-days: 1
- name: Submit installer for Authenticode signing
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' && (steps.ver.outputs.channel == 'canary' || vars.SIGNPATH_RELEASE_SIGNING_READY == 'true')
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
project-slug: DeepSeek-Reasonix
signing-policy-slug: ${{ steps.ver.outputs.channel == 'canary' && 'test-signing' || 'release-signing' }}
artifact-configuration-slug: windows-installer
github-artifact-id: ${{ steps.unsigned-installer.outputs.artifact-id }}
github-token: ${{ github.token }}
wait-for-completion: true
# Leave headroom for the manual SignPath confirmation, origin verification,
# malware scanning, and signing.
wait-for-completion-timeout-in-seconds: 1800
output-artifact-directory: signed
- name: Replace installer with signed build
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' && (steps.ver.outputs.channel == 'canary' || vars.SIGNPATH_RELEASE_SIGNING_READY == 'true')
run: cp signed/*installer*.exe dist/
- name: Sign artifacts (minisign)
working-directory: desktop
env:
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }}
run: go run ./cmd/sign sign ../dist/*
- uses: actions/upload-artifact@v7
with:
name: dist-${{ matrix.name }}
path: dist/*
if-no-files-found: error
publish:
name: publish release
needs: build
if: ${{ always() && !cancelled() && needs.build.result == 'success' }}
runs-on: ubuntu-latest
# The stable caller has already passed the single GitHub release approval.
# Direct prereleases/manual stable recovery pass release-gate; canaries run
# free. Windows signing retains its separate SignPath confirmations.
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
- uses: actions/setup-go@v6
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- uses: actions/setup-node@v6
with:
node-version: "22"
- uses: actions/download-artifact@v8
with:
path: dist
pattern: dist-*
merge-multiple: true
- name: Resolve version
id: ver
run: bash scripts/resolve-desktop-release.sh
env:
EVENT_NAME: ${{ github.event_name }}
IN_CHANNEL: ${{ inputs.channel }}
IN_TAG: ${{ inputs.tag }}
IN_BASE_VERSION: ${{ inputs.base_version }}
REF_NAME: ${{ github.ref_name }}
RUN_NUMBER: ${{ github.run_number }}
# Generate latest.json with GitHub release download URLs; the mirror step
# rewrites them to R2 afterwards. GITHUB_REPOSITORY is provided by the runner.
- name: Generate manifest
working-directory: desktop
run: go run ./cmd/sign manifest ../dist "${{ steps.ver.outputs.version }}" "${{ steps.ver.outputs.tag }}"
# Canary never appears on the GitHub releases page; the mirror job picks up
# the signed dist via the canary-dist artifact below. Stable publishes a
# GitHub release as usual.
- name: Render reviewed release notes
if: steps.ver.outputs.channel != 'canary'
run: node scripts/release-notes.mjs render --version "${{ steps.ver.outputs.version }}" --output /tmp/release-notes.md
- name: Revalidate approved release ref
if: ${{ inputs.orchestrated }}
env:
RELEASE_TAG: ${{ inputs.tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
run: bash scripts/verify-release-tag.sh
- name: Publish GitHub release
if: steps.ver.outputs.channel != 'canary'
env:
GH_TOKEN: ${{ github.token }}
run: |
# Keep the repository homepage focused on the installable desktop app;
# the CLI release line is configured not to claim repository-wide latest.
args=(--title "Reasonix Desktop ${{ steps.ver.outputs.version }}" --notes-file /tmp/release-notes.md)
if [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then
args+=(--prerelease --latest=false)
else
args+=(--latest)
fi
gh release create "${{ steps.ver.outputs.tag }}" dist/* "${args[@]}"
- name: Upload canary dist for mirror
if: steps.ver.outputs.channel == 'canary'
uses: actions/upload-artifact@v7
with:
name: canary-dist
path: dist/*
if-no-files-found: error
mirror:
name: mirror to R2
needs: publish
runs-on: ubuntu-latest
permissions:
contents: write # gh release download + compatibility manifest upload
actions: write # dispatch pages.yml to re-bake the site version
# Skip cleanly when R2 isn't configured; GitHub release still works as fallback.
if: ${{ always() && !cancelled() && needs.publish.result == 'success' && github.repository_owner == 'esengine' }}
env:
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
- name: Resolve version
id: ver
run: bash scripts/resolve-desktop-release.sh
env:
EVENT_NAME: ${{ github.event_name }}
IN_CHANNEL: ${{ inputs.channel }}
IN_TAG: ${{ inputs.tag }}
IN_BASE_VERSION: ${{ inputs.base_version }}
REF_NAME: ${{ github.ref_name }}
RUN_NUMBER: ${{ github.run_number }}
- name: Revalidate approved release ref
if: ${{ inputs.orchestrated }}
env:
RELEASE_TAG: ${{ inputs.tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
run: bash scripts/verify-release-tag.sh
# Canary has no GitHub release — pull the signed dist from the workflow
# artifact. Stable pulls from the published release.
- name: Download canary dist
if: env.HAS_R2 == 'true' && steps.ver.outputs.channel == 'canary'
uses: actions/download-artifact@v8
with:
name: canary-dist
path: assets
- name: Download release assets
if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary'
env:
GH_TOKEN: ${{ github.token }}
run: |
mkdir -p assets
gh release download "${{ steps.ver.outputs.tag }}" -R "${{ github.repository }}" -D assets
# Rewrite both url and sig inside latest.json from github.com to the R2 CDN,
# so the updater pulls the manifest AND the heavy artifacts from R2.
- name: Rewrite latest.json URLs to R2
if: env.HAS_R2 == 'true'
env:
R2_PUBLIC_BASE: https://dl.reasonix.io
TAG: ${{ steps.ver.outputs.tag }}
run: |
f=assets/latest.json
jq --arg base "$R2_PUBLIC_BASE" --arg tag "$TAG" '
.platforms |= with_entries(
.value.url |= sub("https://github.com/[^/]+/[^/]+/releases/download/[^/]+/"; "\($base)/\($tag)/")
| .value.sig |= sub("https://github.com/[^/]+/[^/]+/releases/download/[^/]+/"; "\($base)/\($tag)/")
)
' "$f" > "$f.new"
mv "$f.new" "$f"
cat "$f"
- name: Configure AWS CLI for R2
if: env.HAS_R2 == 'true'
run: |
aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
aws configure set region auto
- name: Mirror to R2
if: env.HAS_R2 == 'true'
env:
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
TAG: ${{ steps.ver.outputs.tag }}
run: |
ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
aws s3 cp assets/ "s3://${R2_BUCKET}/${TAG}/" --recursive --endpoint-url "$ENDPOINT"
# The pointer a release moves is its channel's alone: canary writes
# canary/, stable writes latest/. A stable prerelease (rc) moves neither.
if [ "${{ steps.ver.outputs.channel }}" = "canary" ]; then
aws s3 cp assets/ "s3://${R2_BUCKET}/canary/" --recursive --endpoint-url "$ENDPOINT"
elif [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then
echo "prerelease — leaving R2 latest/ untouched"
else
aws s3 cp assets/ "s3://${R2_BUCKET}/latest/" --recursive --endpoint-url "$ENDPOINT"
fi
# dl.reasonix.io serves 403 to GitHub Actions egress IPs (Cloudflare bot
# protection), so smoke the mirrored objects over the authenticated S3 API
# instead of the public edge. This verifies the mirror landed; the public
# edge itself is not reachable from CI and is covered by end users' traffic.
- name: Smoke desktop release pointers
if: env.HAS_R2 == 'true'
env:
TAG: ${{ steps.ver.outputs.tag }}
VERSION: ${{ steps.ver.outputs.version }}
CHANNEL: ${{ steps.ver.outputs.channel }}
PRERELEASE: ${{ steps.ver.outputs.prerelease }}
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
f=assets/latest.json
jq -e --arg version "$VERSION" --arg prefix "https://dl.reasonix.io/${TAG}/" '
.version == $version and
.download_page == "https://reasonix.io/?download=desktop#start" and
([.platforms[] | (.url, .sig)] |
all(type == "string" and startswith($prefix) and (contains("/releases/latest/") | not)))
' "$f" >/dev/null
aws s3 cp "s3://${R2_BUCKET}/${TAG}/latest.json" /tmp/reasonix-desktop-tag-latest.json --endpoint-url "$ENDPOINT"
jq -e --arg version "$VERSION" '.version == $version' /tmp/reasonix-desktop-tag-latest.json >/dev/null
if [ "$CHANNEL" = "canary" ]; then
pointer="canary"
elif [ "$PRERELEASE" = "true" ]; then
pointer=""
else
pointer="latest"
fi
if [ -n "$pointer" ]; then
aws s3 cp "s3://${R2_BUCKET}/${pointer}/latest.json" /tmp/reasonix-desktop-pointer-latest.json --endpoint-url "$ENDPOINT"
jq -e --arg version "$VERSION" '.version == $version' /tmp/reasonix-desktop-pointer-latest.json >/dev/null
fi
jq -r '.platforms[] | .url, .sig' "$f" | while IFS= read -r asset; do
key="${asset#https://dl.reasonix.io/}"
aws s3api head-object --bucket "$R2_BUCKET" --key "$key" --endpoint-url "$ENDPOINT" >/dev/null
done
# Best-effort probe of the release gateway — the updater's second
# manifest source — over the same public edge and Go client UA end users
# hit. A 403 here is the known Cloudflare bot-protection gap (#6005:
# datacenter/proxy egress gets blocked before the worker runs) and must
# not fail the release until a WAF skip rule for /v1/desktop/releases/*
# lands; it is surfaced as a warning so the run shows whether the edge
# is open. Anything else unexpected (404, 5xx, wrong version) means the
# gateway route or pointer regressed and fails hard.
- name: Probe public release gateway
if: env.HAS_R2 == 'true'
env:
VERSION: ${{ steps.ver.outputs.version }}
CHANNEL: ${{ steps.ver.outputs.channel }}
PRERELEASE: ${{ steps.ver.outputs.prerelease }}
run: |
set -euo pipefail
if [ "$PRERELEASE" = "true" ]; then
echo "prerelease — no pointer moved; skipping gateway probe"
exit 0
fi
chan="stable"
[ "$CHANNEL" = "canary" ] && chan="canary"
url="https://crash.reasonix.io/v1/desktop/releases/${chan}/latest.json"
# curl already prints 000 for a transport failure; || true keeps -e
# from killing the step so the case below can route it.
code="$(curl -sS -A "Go-http-client/2.0" -o /tmp/gateway-latest.json -w '%{http_code}' "$url" || true)"
case "$code" in
200)
if jq -e --arg version "$VERSION" '.version == $version' /tmp/gateway-latest.json >/dev/null; then
echo "gateway serves $VERSION on $chan"
else
echo "::error::gateway responded 200 but serves $(jq -r '.version // "<none>"' /tmp/gateway-latest.json), want $VERSION — stale or wrong pointer"
exit 1
fi
;;
403)
echo "::warning::gateway returned 403 to CI egress — known bot-protection gap (#6005), not failing the release"
;;
000|"")
echo "::warning::gateway unreachable from CI (transport error), not failing the release"
;;
*)
echo "::error::gateway returned $code for $url — route or pointer regression"
exit 1
;;
esac
- name: Attach desktop manifest to matching CLI release
if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary' && steps.ver.outputs.prerelease != 'true'
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
if gh release view "$VERSION" >/dev/null 2>&1; then
gh release upload "$VERSION" assets/latest.json --clobber
else
echo "CLI release $VERSION does not exist yet; release.yml will attach the compatibility latest.json when it publishes."
fi
# Stable release moved R2 latest/ — rebuild the site so its build-time baked
# version + JSON-LD follow (site.js's runtime .rxv refresh can't touch first paint / SEO).
- name: Refresh site to the new version
if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary' && steps.ver.outputs.prerelease != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: gh workflow run pages.yml --ref main-v2
+26
View File
@@ -0,0 +1,26 @@
name: Release notes
on:
pull_request:
paths:
- "release-notes/**"
- "scripts/release-notes*.mjs"
- "site/src/pages/changelog/**"
- "site/src/components/ChangelogPage.astro"
- "desktop/frontend/src/App.tsx"
- "desktop/frontend/src/components/SettingsPanel.tsx"
- "desktop/frontend/src/components/UpdateBanner.tsx"
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: "22"
- run: node scripts/release-notes.mjs validate
- run: node --test scripts/release-notes.test.mjs
+194
View File
@@ -0,0 +1,194 @@
name: Release npm
# npm line. Stable releases are called by release-stable.yml after its single
# GitHub environment approval. Direct tag pushes are retained only for
# prereleases so npm `next` can still ship independently without touching the
# stable Homebrew cask. Bump a prerelease with:
# git tag npm-vX.Y.Z-rc.N # prerelease -> publishes under `next`
# git push origin <tag>
#
# A manual workflow_dispatch can publish either an opt-in canary from the current
# ref or recover an approved stable version from an existing tag. Canary publishes
# to the `canary` dist-tag (npm i reasonix@canary) and never moves
# `next`/`latest`. build.mjs decides the dist-tag: a `-canary.` build is `canary`,
# any other prerelease is `next`, and a stable version is `latest` (#5822 — the
# old "promote latest by hand" rule left it pinned at 0.53.2 and downgraded
# `npm update -g` users). The verify step below asserts the tag actually landed.
on:
push:
tags: ['npm-v*-*']
workflow_dispatch:
inputs:
channel:
description: "Publish channel"
required: true
default: canary
type: choice
options:
- canary
- stable
base_version:
description: "Version to publish. Canary appends -canary.<run_number>; stable publishes exactly this version."
required: true
type: string
tag:
description: "stable: existing npm tag to publish (for example npm-v1.18.0)"
required: false
type: string
workflow_call:
inputs:
channel:
description: "Publish channel selected by the approved release orchestrator"
required: true
type: string
base_version:
description: "Version to publish"
required: true
type: string
tag:
description: "Existing npm tag to check out for stable publication"
required: false
default: ""
type: string
approved_cli_tag:
description: "Stable CLI tag recorded by the approved orchestrator"
required: true
type: string
approved_sha:
description: "Immutable commit recorded by the approved orchestrator"
required: true
type: string
orchestrated:
description: "True only when called by release-stable.yml after approval"
required: false
default: false
type: boolean
permissions:
contents: read
jobs:
orchestration-guard:
name: verify approved orchestrator
if: ${{ inputs.orchestrated }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.sha }}
- name: Verify caller and approved release ref
env:
ACTUAL_CALLER_WORKFLOW_REF: ${{ github.workflow_ref }}
EXPECTED_CALLER_WORKFLOW_REF: ${{ format('{0}/.github/workflows/release-stable.yml@{1}', github.repository, github.ref) }}
CALLER_EVENT_NAME: ${{ github.event_name }}
CALLER_REF: ${{ github.ref }}
CALLER_REF_PROTECTED: ${{ github.ref_protected }}
CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }}
CALLER_SHA: ${{ github.sha }}
APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
RELEASE_TAG: ${{ inputs.tag }}
VERIFY_RELEASE_CHECKOUT: false
run: |
bash scripts/verify-release-authorization.sh
bash scripts/verify-release-tag.sh
release-gate:
name: approve standalone npm release
if: ${{ !inputs.orchestrated && !(github.event_name == 'workflow_dispatch' && inputs.channel == 'canary') }}
runs-on: ubuntu-latest
environment: release
steps:
- env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
run: echo "Approved standalone npm release $RELEASE_TAG"
cache-guard:
name: cache hit guard
needs: [orchestration-guard, release-gate]
if: ${{ always() && !cancelled() && ((inputs.orchestrated && needs.orchestration-guard.result == 'success') || (!inputs.orchestrated && ((github.event_name == 'workflow_dispatch' && inputs.channel == 'canary') || needs.release-gate.result == 'success'))) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- run: ./scripts/cache-guard.sh
npm:
name: publish npm packages
needs: cache-guard
if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }}
runs-on: ubuntu-latest
# The stable caller has already passed the single GitHub release approval.
# Direct prereleases/manual stable recovery pass release-gate above; canaries
# run free.
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@v6
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
# Tag push uses the npm-v* tag. A canary dispatch synthesizes a
# <base>-canary.<run_number> version; a manual stable dispatch publishes
# the requested semver exactly. build.mjs strips the leading `npm-`/`v`.
- name: Resolve version
id: ver
env:
EVENT_NAME: ${{ github.event_name }}
IN_ORCHESTRATED: ${{ inputs.orchestrated }}
IN_CHANNEL: ${{ inputs.channel }}
IN_BASE_VERSION: ${{ inputs.base_version }}
IN_TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
RUN_NUMBER: ${{ github.run_number }}
run: bash scripts/resolve-npm-release.sh
- name: Revalidate approved release ref
if: ${{ inputs.orchestrated }}
env:
RELEASE_TAG: ${{ inputs.tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
run: bash scripts/verify-release-tag.sh
- env:
VERSION_ARG: ${{ steps.ver.outputs.arg }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: node npm/build.mjs "$VERSION_ARG" --publish
# Assert the dist-tag landed where build.mjs aimed it. This is the guard
# that #5822 lacked: `latest` sat on 0.53.2 for months while stables went
# to `next`, and nothing noticed until users got downgraded. The registry
# applies tags asynchronously after publish, hence the poll.
- name: Verify dist-tags
env:
VERSION_ARG: ${{ steps.ver.outputs.arg }}
run: |
set -euo pipefail
version="$VERSION_ARG"
version="${version#npm-}"; version="${version#v}"
case "$version" in
*-canary.*) tag=canary ;;
*-*) tag=next ;;
*) tag=latest ;;
esac
for attempt in 1 2 3 4 5 6; do
got="$(npm view reasonix dist-tags."$tag" 2>/dev/null || true)"
if [ "$got" = "$version" ]; then
echo "dist-tag $tag -> $got"
exit 0
fi
echo "dist-tag $tag -> ${got:-<unset>}, want $version (attempt $attempt)"
sleep 10
done
echo "::error::npm dist-tag '$tag' never landed on $version"
exit 1
+175
View File
@@ -0,0 +1,175 @@
name: Release stable
run-name: Release stable ${{ inputs.tag || github.ref_name }}
# Stable publication has exactly one GitHub environment gate. Push the CLI,
# npm, and desktop tags atomically; the vX.Y.Z tag starts this workflow,
# preflight verifies that all three tags point to the current main-v2 commit,
# and one GitHub approval releases every channel. A manual recovery runs the
# fixed control plane from protected main-v2 while preserving an older tagged
# candidate on main-v2 history. SignPath intentionally keeps its separate
# manual confirmations. Prerelease and canary workflows remain independently
# callable.
on:
push:
tags:
- 'v*'
- '!v*-*'
workflow_dispatch:
inputs:
tag:
description: "Existing stable CLI tag to recover (for example v1.18.0)"
required: true
type: string
concurrency:
group: stable-release-${{ inputs.tag || github.ref_name }}
cancel-in-progress: false
# Reusable release workflows can only reduce caller permissions, so the
# orchestrator grants the union needed by CLI/Desktop publication.
permissions:
actions: write
contents: write
jobs:
preflight:
name: validate stable release set
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
version: ${{ steps.release.outputs.version }}
cli_tag: ${{ steps.release.outputs.cli_tag }}
npm_tag: ${{ steps.release.outputs.npm_tag }}
desktop_tag: ${{ steps.release.outputs.desktop_tag }}
sha: ${{ steps.release.outputs.sha }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
# A recovery dispatch uses the fixed workflow/scripts from protected
# main-v2. Publishers still check out the immutable approved tag SHA.
ref: ${{ github.sha }}
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@v6
with:
node-version: "22"
- name: Resolve stable release
id: release
env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
ALLOW_STABLE_RECOVERY: ${{ github.event_name == 'workflow_dispatch' }}
run: bash scripts/resolve-stable-release.sh
- name: Validate reviewed release notes
run: node scripts/release-notes.mjs render --version "${{ steps.release.outputs.cli_tag }}" --output /tmp/release-notes.md
- name: Cache hit guard
run: ./scripts/cache-guard.sh
authorize:
name: approve stable release
needs: preflight
runs-on: ubuntu-latest
environment: release
permissions:
contents: read
outputs:
version: ${{ steps.approved.outputs.version }}
cli_tag: ${{ steps.approved.outputs.cli_tag }}
npm_tag: ${{ steps.approved.outputs.npm_tag }}
desktop_tag: ${{ steps.approved.outputs.desktop_tag }}
sha: ${{ steps.approved.outputs.sha }}
steps:
- name: Record approved release
id: approved
env:
VERSION: ${{ needs.preflight.outputs.version }}
CLI_TAG: ${{ needs.preflight.outputs.cli_tag }}
NPM_TAG: ${{ needs.preflight.outputs.npm_tag }}
DESKTOP_TAG: ${{ needs.preflight.outputs.desktop_tag }}
RELEASE_SHA: ${{ needs.preflight.outputs.sha }}
run: |
{
echo "version=$VERSION"
echo "cli_tag=$CLI_TAG"
echo "npm_tag=$NPM_TAG"
echo "desktop_tag=$DESKTOP_TAG"
echo "sha=$RELEASE_SHA"
} >> "$GITHUB_OUTPUT"
echo "Approved stable release $VERSION at $RELEASE_SHA"
cli:
name: publish CLI and Homebrew
needs: authorize
uses: ./.github/workflows/release.yml
with:
tag: ${{ needs.authorize.outputs.cli_tag }}
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
secrets: inherit
npm:
name: publish npm
needs: authorize
uses: ./.github/workflows/release-npm.yml
with:
channel: stable
base_version: ${{ needs.authorize.outputs.version }}
tag: ${{ needs.authorize.outputs.npm_tag }}
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
secrets: inherit
desktop:
name: publish desktop
needs: authorize
uses: ./.github/workflows/release-desktop.yml
with:
channel: stable
tag: ${{ needs.authorize.outputs.desktop_tag }}
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
secrets: inherit
postflight:
name: verify stable release artifacts
needs: [authorize, cli, npm, desktop]
if: ${{ always() && !cancelled() }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Require every publisher to succeed
env:
CLI_RESULT: ${{ needs.cli.result }}
NPM_RESULT: ${{ needs.npm.result }}
DESKTOP_RESULT: ${{ needs.desktop.result }}
run: |
set -euo pipefail
for result in "$CLI_RESULT" "$NPM_RESULT" "$DESKTOP_RESULT"; do
if [ "$result" != "success" ]; then
echo "::error::stable publisher result is $result, expected success"
exit 1
fi
done
- uses: actions/checkout@v7
with:
# Postflight belongs to the trusted control plane, not the old build
# candidate, which may predate this verifier.
ref: ${{ github.sha }}
- uses: actions/setup-node@v6
with:
node-version: "22"
- name: Verify public artifacts and npm latest
env:
GH_TOKEN: ${{ github.token }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_VERSION: ${{ needs.authorize.outputs.version }}
CLI_TAG: ${{ needs.authorize.outputs.cli_tag }}
DESKTOP_TAG: ${{ needs.authorize.outputs.desktop_tag }}
run: bash scripts/verify-stable-release-artifacts.sh
+261
View File
@@ -0,0 +1,261 @@
name: Release
# CLI binary line. Stable releases are called by release-stable.yml after its
# single GitHub `release`-environment approval. Direct tag pushes are retained
# only for CLI prereleases so they can still ship independently; those runs keep
# their own approval gate. goreleaser auto-skips the cask upload for prereleases
# (brew has no prerelease channel — see .goreleaser.yaml).
on:
push:
tags: ['v*-*']
workflow_dispatch:
inputs:
tag:
description: "Existing CLI tag to publish (for example v1.18.0-rc.1)"
required: true
type: string
workflow_call:
inputs:
tag:
description: "Existing CLI tag selected by the approved release orchestrator"
required: true
type: string
approved_cli_tag:
description: "Stable CLI tag recorded by the approved orchestrator"
required: true
type: string
approved_sha:
description: "Immutable commit recorded by the approved orchestrator"
required: true
type: string
orchestrated:
description: "True only when called by release-stable.yml after approval"
required: false
default: false
type: boolean
permissions:
contents: write # create the release and upload archives
jobs:
orchestration-guard:
name: verify approved orchestrator
if: ${{ inputs.orchestrated }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.sha }}
- name: Verify caller and approved release ref
env:
ACTUAL_CALLER_WORKFLOW_REF: ${{ github.workflow_ref }}
EXPECTED_CALLER_WORKFLOW_REF: ${{ format('{0}/.github/workflows/release-stable.yml@{1}', github.repository, github.ref) }}
CALLER_EVENT_NAME: ${{ github.event_name }}
CALLER_REF: ${{ github.ref }}
CALLER_REF_PROTECTED: ${{ github.ref_protected }}
CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }}
CALLER_SHA: ${{ github.sha }}
APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
RELEASE_TAG: ${{ inputs.tag }}
VERIFY_RELEASE_CHECKOUT: false
run: |
bash scripts/verify-release-authorization.sh
bash scripts/verify-release-tag.sh
release-gate:
name: approve standalone CLI release
if: ${{ !inputs.orchestrated }}
runs-on: ubuntu-latest
environment: release
steps:
- env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
run: echo "Approved standalone CLI release $RELEASE_TAG"
cache-guard:
name: cache hit guard
needs: [orchestration-guard, release-gate]
if: ${{ always() && !cancelled() && ((inputs.orchestrated && needs.orchestration-guard.result == 'success') || (!inputs.orchestrated && needs.release-gate.result == 'success')) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- run: ./scripts/cache-guard.sh
goreleaser:
name: archives + checksums + homebrew tap
needs: cache-guard
if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }}
runs-on: ubuntu-latest
# The stable caller has already passed the single GitHub release approval.
# Standalone prereleases pass release-gate above. This job therefore must not
# add a second GitHub environment approval.
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@v6
with:
node-version: "22"
- name: Render reviewed release notes
env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
run: node scripts/release-notes.mjs render --version "$RELEASE_TAG" --output /tmp/release-notes.md
- name: Revalidate approved release ref
if: ${{ inputs.orchestrated }}
env:
RELEASE_TAG: ${{ inputs.tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
run: bash scripts/verify-release-tag.sh
- uses: goreleaser/goreleaser-action@v7
with:
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
# workflow_dispatch recovery runs have a branch-shaped GITHUB_REF even
# though checkout is on the release tag. Pin GoReleaser explicitly, and
# avoid ambiguity from the three release tags sharing one commit.
GORELEASER_CURRENT_TAG: ${{ inputs.tag || github.ref_name }}
- name: Publish product release notes
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag || github.ref_name }}
run: gh release edit "$TAG" --notes-file /tmp/release-notes.md
- name: Attach desktop manifest compatibility asset
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag || github.ref_name }}
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
case "$TAG" in
*-*)
echo "prerelease $TAG — GitHub latest does not move here; skipping desktop manifest compatibility asset"
exit 0
;;
esac
if [ "$HAS_R2" != "true" ]; then
echo "R2 secrets not configured; skipping desktop manifest compatibility asset"
exit 0
fi
# dl.reasonix.io serves 403 to GitHub Actions egress IPs (Cloudflare bot
# protection), so read the manifest over the authenticated S3 API instead
# of the public edge.
aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
aws configure set region auto
aws s3 cp "s3://${R2_BUCKET}/latest/latest.json" latest.raw.json \
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
jq '.download_page = "https://reasonix.io/?download=desktop#start"' latest.raw.json > latest.json
jq -e '
([.platforms[] | (.url, .sig)] |
all(type == "string" and startswith("https://dl.reasonix.io/") and (contains("/releases/latest/") | not)))
' latest.json >/dev/null
gh release upload "$TAG" latest.json --clobber
# The compatibility asset exists for pre-v1.16 desktop updaters that
# still poll GitHub's repository-wide latest URL. Desktop releases now
# own that Latest badge, but this check still exercises the public fallback
# path exactly the way those clients fetch it: anonymously, over the public
# edge, with a Go client UA. Unlike dl.reasonix.io (whose bot protection
# 403s Actions egress — see the R2 note above), GitHub serves its own
# runners, so this can hard-fail. #5826/#5858 shipped a broken update check
# for weeks precisely because nothing exercised the public path. Retries
# cover the release CDN propagating the freshly uploaded asset.
- name: Smoke public compatibility manifest
env:
TAG: ${{ inputs.tag || github.ref_name }}
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
run: |
set -euo pipefail
case "$TAG" in
*-*)
echo "prerelease $TAG — no compatibility asset uploaded; skipping"
exit 0
;;
esac
if [ "$HAS_R2" != "true" ]; then
echo "R2 secrets not configured; no compatibility asset uploaded; skipping"
exit 0
fi
url="https://github.com/${{ github.repository }}/releases/latest/download/latest.json"
for attempt in 1 2 3 4 5 6; do
if curl -fsSL -A "Go-http-client/2.0" -o /tmp/compat-latest.json "$url"; then
jq -e '(.version | type == "string") and (.platforms | type == "object")' /tmp/compat-latest.json >/dev/null
echo "public compatibility manifest OK (desktop version $(jq -r .version /tmp/compat-latest.json))"
exit 0
fi
echo "attempt $attempt failed; retrying in 10s"
sleep 10
done
echo "::error::public compatibility manifest unreachable at $url"
exit 1
# A stable CLI release must never leave the npm line behind: v1.17.5
# shipped as binaries/Homebrew while npm `latest` still pointed at 0.53.2
# (#5822) — every `npm update -g` user was silently downgraded to a
# months-old version, and nothing noticed because the npm line
# (release-npm.yml, `npm-vX.Y.Z` tags) is triggered independently and the
# stable npm tag was simply never pushed. release-npm.yml's own verify
# step only guards runs that happen; this guard catches the run that
# DIDN'T.
#
# Two distinct states, two responses (the approved orchestrator starts the
# CLI and npm reusable workflows concurrently, and npm dist-tags propagate
# asynchronously, so "tag pushed but latest not moved yet" is a NORMAL
# mid-release state, not a failure):
# - npm-v<version> tag missing -> hard fail. This is the #5822 gap:
# nobody pushed the npm release at all.
# - tag pushed, latest lagging -> poll briefly, then WARN and pass.
# The npm job may still be publishing; release-npm.yml's verify step
# owns asserting the dist-tag lands.
- name: Check npm latest dist-tag freshness
env:
TAG: ${{ inputs.tag || github.ref_name }}
run: |
set -euo pipefail
case "$TAG" in
*-*)
echo "prerelease $TAG — npm latest does not move on prereleases; skipping"
exit 0
;;
esac
version="${TAG#v}"
if ! git ls-remote --exit-code origin "refs/tags/npm-v$version" >/dev/null; then
echo "::error::the npm-v$version tag was never pushed — the npm channel is being left behind and 'npm update -g' users will be downgraded to the old 'latest'. Push it: git tag npm-v$version ${TAG} && git push origin npm-v$version (or 'npm dist-tag add reasonix@$version latest' for an already-published version)."
exit 1
fi
for attempt in 1 2 3 4 5 6; do
got="$(npm view reasonix dist-tags.latest 2>/dev/null || true)"
if [ -n "$got" ]; then
newest="$(printf '%s\n%s\n' "$got" "$version" | sort -V | tail -1)"
if [ "$newest" = "$got" ]; then
echo "npm latest -> $got (>= $version) OK"
exit 0
fi
fi
echo "npm latest -> ${got:-<unreadable>}, want >= $version (attempt $attempt)"
sleep 10
done
echo "::warning::npm-v$version is pushed but npm 'latest' is still ${got:-<unreadable>} — the concurrent npm publish is likely still running or propagating. Monitor the npm job; its verify step asserts the dist-tag lands."
exit 0
@@ -0,0 +1,37 @@
name: Update acknowledgments
on:
workflow_dispatch:
schedule:
- cron: '17 3 * * 1'
permissions:
contents: write
pull-requests: write
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: '22'
- name: Update README contributor tables
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/update-acknowledgments.mjs
- name: Open pull request
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
branch: chore/update-acknowledgments
delete-branch: true
title: 'Update acknowledgments contributors / 更新致谢贡献者'
commit-message: 'docs: update acknowledgments contributors'
body: |
Refreshes the README acknowledgments tables from the GitHub contributors API.
- Source: `repos/esengine/DeepSeek-Reasonix/contributors?per_page=20&anon=1`
- Anonymous contributors are shown without email addresses.
labels: documentation
+74
View File
@@ -0,0 +1,74 @@
name: Update Star History chart
on:
workflow_dispatch:
push:
branches:
- main-v2
paths:
- '.github/workflows/update-star-history.yml'
- 'scripts/update-star-history.mjs'
- 'scripts/update-star-history.test.mjs'
schedule:
- cron: '23 */3 * * *'
permissions:
contents: write
concurrency:
group: update-star-history
cancel-in-progress: false
jobs:
update:
if: github.repository == 'esengine/DeepSeek-Reasonix'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: '22'
- name: Test chart generator
run: node --test scripts/update-star-history.test.mjs
- name: Generate chart assets
env:
STAR_HISTORY_GITHUB_TOKEN: ${{ secrets.STAR_HISTORY_GITHUB_TOKEN }}
STAR_HISTORY_OUTPUT_DIR: ${{ runner.temp }}/star-history
run: node scripts/update-star-history.mjs
- name: Publish chart assets
env:
OUTPUT_DIR: ${{ runner.temp }}/star-history
TARGET_BRANCH: star-history
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
if git fetch origin "$TARGET_BRANCH"; then
git switch --force-create "$TARGET_BRANCH" "origin/$TARGET_BRANCH"
else
git switch --create "$TARGET_BRANCH"
fi
mkdir -p assets/star-history
cp "$OUTPUT_DIR/star-history-light.svg" assets/star-history/star-history-light.svg
cp "$OUTPUT_DIR/star-history-dark.svg" assets/star-history/star-history-dark.svg
git add assets/star-history
if git diff --cached --quiet; then
echo "Star History chart is already current."
exit 0
fi
git commit \
-m "docs: update Star History chart" \
-m "Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.
Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data"
git push origin HEAD:"$TARGET_BRANCH"
+65 -8
View File
@@ -1,16 +1,73 @@
# Build artifacts
/bin/
/dist/
/stage/
/npm/.stage/
/reasonix
/reasonix.exe
# Website (Astro) build output
/site/dist/
/site/.astro/
/site/_preview.png
# Go test/build scratch
*.test
*.out
*.coverprofile
coverage.out
cpu.prof
mem.prof
# Wails desktop artifacts
/desktop/build/*
!/desktop/build/appicon.png
!/desktop/build/appicon.svg
/desktop/desktop
/desktop/frontend/dist/*
!/desktop/frontend/dist/.gitkeep
/desktop/frontend/sourcemaps/
/desktop/frontend/wailsjs/
/desktop/frontend/package.json.md5
# Environment / local config (reasonix.example.toml is the shared template)
.env
.env.local
*.local
/reasonix.toml
/desktop/.env
/desktop/reasonix.toml
# Node / frontend dependencies and caches
node_modules/
.pnpm-store/
.npm/
.wrangler/
dist/
coverage/
.env
.env.*
*.local
**/.firebase-crash-migration-state.json
**/.firebase-crash-migration-state.json.tmp-*
# Project-local Reasonix state; keep the checked-in review command.
/.reasonix/*
!/.reasonix/commands/
!/.reasonix/commands/review.md
# Stray Reasonix state written under a package dir by a test whose RealPath
# resolves to CWD (e.g. remote bootstrap EnsureServe). Never commit these.
internal/**/.reasonix/
desktop/**/.reasonix/
# CodeGraph per-project index (rebuilt locally; never committed)
.codegraph/
# Editor / OS
.DS_Store
Thumbs.db
.idea/
.vscode/
/site/public/av/
benchmarks/context-maintenance-e2e/run/
# Local scratch
temp/
# Official theme art intermediate outputs / caches
scripts/official-theme-art/out/
scripts/official-theme-art/__pycache__/
+48
View File
@@ -0,0 +1,48 @@
version: "2"
linters:
enable:
- errcheck
- govet
- ineffassign
- staticcheck
- unused
settings:
errcheck:
check-type-assertions: false
exclude-functions:
- (*os.File).Close
- (io.Closer).Close
- (*net/http.Response).Body.Close
- golang.org/x/term.Restore
staticcheck:
checks:
- all
- -ST1005 # error strings ending with punctuation — too strict for initial adoption
- -ST1018 # Unicode format characters — intentional in TUI code
- -QF1001 # De Morgan's law — readability preference
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
rules:
# Setup/teardown in tests routinely ignores cleanup errors.
- path: _test\.go
linters:
- errcheck
# The pinned golangci-lint binary's staticcheck reports SA5011 false
# positives on `if x == nil { t.Fatal(...) }`-guarded derefs in tests (it
# doesn't treat t.Fatal as terminating); the same code is clean under a
# locally-built golangci-lint. Scope the suppression to tests so SA5011
# still guards production code.
- path: _test\.go
linters:
- staticcheck
text: SA5011
issues:
max-issues-per-linter: 0
max-same-issues: 0
+62
View File
@@ -0,0 +1,62 @@
version: 2
project_name: reasonix
before:
hooks:
- go mod download
builds:
- id: reasonix
main: ./cmd/reasonix
binary: reasonix
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w -X main.version={{ .Tag }}
goos: [darwin, linux, windows]
goarch: [amd64, arm64]
archives:
- id: reasonix
ids: [reasonix]
name_template: "reasonix-{{ .Os }}-{{ .Arch }}"
formats: [tar.gz]
format_overrides:
- goos: windows
formats: [zip]
checksum:
name_template: SHA256SUMS
algorithm: sha256
homebrew_casks:
- name: reasonix
ids: [reasonix]
# Prereleases (v*-rc*) must not update the stable tap; brew has no separate
# prerelease channel, so a pushed rc cask becomes the default `brew install`.
# npm is a separate line (release-npm.yml, `npm-v*`), so an npm prerelease no
# longer drags brew along — a stable `v*` cask can ship while npm stays on rc.
skip_upload: "auto"
repository:
owner: esengine
name: homebrew-reasonix
branch: main
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
homepage: "https://github.com/esengine/DeepSeek-Reasonix"
description: "Cache-first DeepSeek coding agent for the terminal."
commit_author:
name: reasonix
email: [email protected]
hooks:
post:
install: |
if OS.mac?
system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/reasonix"]
end
release:
prerelease: auto
make_latest: false
name_template: "Reasonix CLI {{ .Tag }}"
-1
View File
@@ -1 +0,0 @@
24.20.0
+5
View File
@@ -0,0 +1,5 @@
---
description: Review a file for bugs
argument-hint: [path]
---
Read $1 and list any correctness bugs or risky patterns, with file:line references, most important first. Focus: $ARGUMENTS.
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- GitHub Actions wraps every uploaded artifact in a zip, so the installer arrives
zipped even though it is a single file — match and sign the .exe inside. -->
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
<zip-file>
<pe-file-set>
<include path="*installer*.exe" min-matches="1" max-matches="1" />
<for-each>
<authenticode-sign />
</for-each>
</pe-file-set>
</zip-file>
</artifact-configuration>
-71
View File
@@ -1,71 +0,0 @@
# Reasonix platform capacity contract
This document defines what “ready for 10,000 concurrent users” means. It is an
acceptance contract, not a claim that the current production system has already
passed the target.
## Budget and design constraints
- Keep recurring infrastructure within CNY 200/month at the current traffic
level. Prefer Cloudflare managed primitives over idle servers.
- Never make synchronous telemetry storage part of a product launch path.
- Validate at the edge, durably enqueue, then persist asynchronously.
- A successful HTTP response must expose whether the event was `queued`,
`stored`, `sampled`, or `ignored` through
`x-reasonix-telemetry-result`. HTTP 202 alone is not proof of durable intake.
- Production load tests require owner approval. The repository probe refuses
remote and production targets unless they are unlocked separately.
## Service-level objectives
| Path | Workload | Pass condition |
| --- | --- | --- |
| Telemetry admission | 10,000 distributed requests over 60 seconds | >=99.9% `queued`, p95 <=500 ms, no 5xx |
| Telemetry drain | 10,000 queued events, batches up to 100 | backlog returns to zero within 15 minutes, no DLQ growth |
| Identity session read | 1,000 requests/second for 5 minutes | >=99.9% success, p95 <=300 ms |
| Registry public read | 1,000 requests/second for 5 minutes | >=99.9% success, p95 <=300 ms |
| Admin dashboards | 20 concurrent viewers for 10 minutes | >=99% success, p95 <=2 seconds |
“Distributed” matters because per-IP abuse protection intentionally rejects a
single machine long before the platform capacity limit. A staging environment
must mirror production bindings and use isolated databases, queues, and R2.
## Required evidence before calling the platform stable
1. A saved probe report with request count, latency percentiles, HTTP statuses,
durable/sample ratios, queue backlog peak, drain time, D1 query latency, and
DLQ delta.
2. A rollback or traffic-shed procedure exercised in staging.
3. Alerts for sustained 5xx, queue backlog age, DLQ growth, D1 overload, and
database size thresholds.
4. Seven consecutive days without unexplained ingestion gaps after a production
rollout.
## Current known gaps
- CLI and Studio telemetry use a verified isolated D1 read/write plane. The
legacy copy is retained temporarily as a rollback snapshot. Legacy desktop
telemetry is entering a dry-run-verified dual-write migration; diagnostic
denominators are queried separately so the final cutover does not require
cross-database SQL joins.
- The global telemetry budget is currently 1,000 events/minute. It protects the
database but cannot satisfy the 10,000/minute durable-intake target.
- Queue consumption is deliberately serialized for D1. A delivery is persisted
as one atomic D1 batch with two statements per valid envelope (at most 200
statements for the configured 100-message queue batch). Drain throughput must
be measured before raising the global intake budget.
- Read replication and the Sessions API have not yet been enabled for eligible
read-heavy paths.
## Local probe
Run the Worker locally, then run:
```sh
npm run capacity:telemetry -- --requests 1000 --concurrency 50
```
This probe fails when the p95 target or durable-intake ratio is missed. A local
run validates the admission contract and tool; it is not evidence for global
capacity. Remote runs need `--allow-remote`, and `*.reasonix.io` additionally
needs `--allow-production` after an approved test window.
+142
View File
@@ -0,0 +1,142 @@
# Changelog
All notable changes to the Go line (Reasonix 1.0+) are recorded here. The legacy
`0.x` TypeScript history lives on the [`v1`](https://github.com/esengine/DeepSeek-Reasonix/tree/v1)
branch.
## Unreleased
### Added
- Added a **Remote SSH** module (VS Code Remote-SSH style): a user-global
`[remote]` host config, `reasonix remote` CLI (add/list/remove/import/test/
connect/status/forward/serve/fs) and `/remote` slash command, an SSH transport
with trust-on-first-use host-key verification, keepalive + exponential-backoff
reconnect, `-L`/`-R` port forwarding, and SFTP file access. `connect`
bootstraps a persistent `reasonix serve` on the remote host and tunnels its
loopback port so the full agent runs remotely. The desktop app adds a
**Settings -> Remote SSH** host manager, a remote file browser/editor, a
port-forwarding panel, and a status-bar connection chip. Linux/macOS remotes.
- Added `reasonix serve --port-file/--token-file/--pid-file` so a supervised
headless serve can bind an ephemeral port and read its auth token from a file
(keeping it out of `ps`).
- Added Claude Code-style searchable CLI pickers for models, providers, and
sessions, with arrow, Vim, and `Ctrl+P` / `Ctrl+N` navigation.
- Added `-p` / `--print`, `text`, `json`, and `stream-json` output modes for
one-shot use and automation.
- Added session-scoped `--allowed-tools`, repeatable `--add-dir`, Claude-compatible
permission modes, flexible `--resume [QUERY]`, and the `--copy` resume escape
hatch.
- Added `/status` details for the active model, effort, cache, Git state,
background jobs, work profile, and provider balance where available.
### Changed
- Automatic Plan Mode has been retired. Plan Mode is now always entered through
an explicit user choice, and the one-time config v5 upgrade removes legacy
`agent.auto_plan` and `agent.auto_plan_classifier` values so upgraded users
receive the same behavior as new users.
- `Shift+Tab` now cycles CLI safe modes from Ask to Auto to Plan, while YOLO
remains an independent `Ctrl+Y` toggle.
- Model, provider, resume, and approval menus now use consistent row selection;
slash completion, help, aliases, and dispatch share one command registry.
- The full-screen CLI composer now uses theme-accented borders and a slim bar
cursor by default, grows within the available terminal height, scrolls long
drafts independently, and preserves selections across explicit image paste.
- The persistent CLI footer now uses a responsive, theme-aware layout for
interaction state, model, effort, localized work mode, Git identity, cache,
context, compaction headroom, jobs, and balance. Narrow terminals move or
compact complete groups instead of clipping labels.
- CLI clipboard actions now separate terminal-native text paste from explicit
image paste: `Ctrl+V` on macOS/Linux, `Alt+V` on Windows, or `/paste-image`.
Local transcript copy verifies the native clipboard write, while SSH uses a
clearly labelled OSC 52 fallback.
- Runtime rebuilds after model, effort, or work-mode changes now preserve the
conversation, session permission overrides, additional directories, and
session lease ownership.
- Agent execution now monitors host-observed Todo progress automatically. A
stalled current item receives a recovery nudge after 8 tool-call rounds with
no new completion, unique read, command, or mutation, and pauses with saved
work after 16. Exact repeats do not renew the progress lease; real work does.
Two-level task lists keep the single in_progress contract: the active
sub-step is the only current item while its phase stays pending, and the
phase becomes in_progress to sign off only after all of its sub-steps are
completed. A level-1 sub-step with no phase header above it is rejected.
Executor and planner rounds now use automatic progress management. Retired
`[agent].max_steps` and `planner_max_steps` keys remain parseable for upgrades,
but are ignored and removed by a one-time migration so stale hidden limits
cannot truncate new behavior. One-off CLI and unattended bot limits remain.
### Fixed
- Isolated the Windows desktop WebView2 shell from stale system proxies, so an
exited proxy client cannot leave the embedded UI hidden during startup. If
WebView2 still does not reach DOM-ready within 15 seconds, Reasonix now shows
the native window with a recovery prompt instead of appearing not to launch.
Remote Markdown images are fetched by the backend with Reasonix's proxy
configuration instead of bypassing that proxy through the isolated WebView.
- Restored captured-mouse right-click text paste, made composer drag selection
copy through the verified native clipboard path, and kept non-Git footer
telemetry left-aligned without reserving an empty data band.
- Restored stateful MCP behavior after the v1.17.13 regression: user-added
servers work without extra trust settings (including delivery-mode on-demand
calls), repository-provided servers use one exact launch confirmation, and
stdio tools reuse one persistent process so browser sessions survive across
calls without repeated startup latency. The former trust/reverify/catalog
management UI and CLI are removed.
- Localized persistent-footer labels and displayed work-mode values in English,
Simplified Chinese, and Traditional Chinese, while keeping command arguments
stable.
- Restored the `0.53` content boundary: model output, tool output, session
transcripts, recovery branches, and background-job artifacts retain their
original text instead of being rewritten by heuristic secret redaction.
Credential masking remains in key-entry summaries and explicit diagnostic or
session-cleanup paths. Transcript-bearing session/job sidecars are kept
private (`0600`, with private job directories), and the retired
`redact_tool_output` setting is removed with a one-time upgrade notice.
## [1.0.0] — 2026-06-03
First stable release — a **ground-up rewrite in Go**. Not an upgrade of the `0.x`
TypeScript line; a new codebase that becomes the default (`main-v2`).
### Highlights
- **Go kernel**: a single static binary (CGO-free), cross-compiled for
darwin/linux/windows on amd64 + arm64. Distributed via npm (the package wraps
the native binary), Homebrew (`esengine/reasonix` tap), and release archives;
no Node runtime needed to run it.
- **Agent core**: the loop, built-in tools (read/write/edit/multi_edit/glob/grep/
ls/bash/web_fetch/todo_write), permission gate, sandboxed bash, and the
DeepSeek prefix-cache–oriented design.
- **Subagents**: `task` plus explore/research/review/security_review skill agents.
- **Skills & hooks**: Claude-Code-style skills (`internal/skill`) and hooks
(`internal/hook`), symlink-aware and slash-integrated.
- **MCP client**: connect external servers over stdio / Streamable HTTP; reads
`[[plugins]]` and a Claude-Code `.mcp.json`.
- **Code intelligence via CodeGraph**: a tree-sitter symbol/call graph
(`codegraph_*` tools) replaces embedding semantic search — no embedding service
or API cost. Fetched into a local cache on first use (or `reasonix codegraph
install`) and indexed in the background, so installs and startup stay fast.
- **Plan mode** with evidence-backed step sign-off (`complete_step`).
- **Memory**: `REASONIX.md` hierarchy + auto-memory, folded into the cache-stable
prefix.
- **ACP** (`reasonix acp`) and an HTTP/SSE server frontend; desktop app (Wails).
### Fixed
- **File encoding support restored** — GBK/GB18030 (and other non-UTF-8) files
can now be read, edited, and grepped correctly. The v2 rewrite had dropped
v1's encoding detection; files in CJK Windows charsets were silently misread
or rejected as binary. The read/edit/write round-trip now preserves the
original file encoding. (#2637)
### Notes
- Versions: the legacy TypeScript line stays in `0.x`; the Go line starts at
`1.0.0`. See [docs/MIGRATING.md](docs/MIGRATING.md).
- Release archives ship a bare binary; CodeGraph is fetched on first use. Windows
support for the fetched runtime is unverified — install `codegraph` on PATH if
the auto-fetch doesn't resolve there.
[1.0.0]: https://github.com/esengine/DeepSeek-Reasonix/releases/tag/v1.0.0
-6
View File
@@ -1,6 +0,0 @@
# Reasonix
The project's standing instructions live in `REASONIX.md` — one file, read by
both Reasonix and Claude Code. Add rules there, never here.
@REASONIX.md
+190
View File
@@ -0,0 +1,190 @@
# Contributing to Reasonix
Thank you for your interest in contributing to Reasonix! This guide covers
everything you need to get started.
## Prerequisites
- **Go 1.25+** — the project targets the latest stable Go release
- **Git** — for version control
- **Node.js** (optional) — only if you work on the desktop app (`desktop/`)
## Getting started
```bash
git clone https://github.com/esengine/DeepSeek-Reasonix.git
cd DeepSeek-Reasonix
go build ./cmd/reasonix # builds the CLI binary
go test ./... # runs the full test suite
```
## Project structure
| Directory | Purpose |
|-----------|---------|
| `cmd/reasonix` | CLI entry point |
| `internal/agent` | Agent loop, session, coordinator |
| `internal/cli` | TUI, subcommands, setup wizard |
| `internal/control` | Transport-agnostic controller |
| `internal/config` | TOML configuration loading |
| `internal/tool/builtin` | Built-in tools (bash, read_file, …) |
| `internal/provider` | Model-backend abstraction |
| `internal/provider/openai` | OpenAI-compatible provider |
| `internal/plugin` | MCP client (stdio + HTTP) |
| `internal/event` | Typed event stream |
| `internal/hook` | Shell hooks (PreToolUse, …) |
| `internal/memory` | REASONIX.md hierarchy + auto-memory |
| `internal/skill` | Skill discovery from Markdown |
| `internal/sandbox` | OS-level sandboxing |
| `internal/serve` | HTTP/SSE server frontend |
| `internal/checkpoint` | Snapshot-based rewind |
| `desktop/` | Wails-based desktop app (separate Go module) |
| `docs/` | Engineering spec, migration guide |
### Dependency direction
```
cli → {agent, plugin, config} → {tool, provider}
```
Built-in subpackages import their parent to self-register via `init()`.
Parents never import children.
## Development workflow
### Building
```bash
make build # go build ./...
make test # go test ./...
make vet # go vet ./...
make fmt # gofmt -w .
make hooks # install git hooks (pre-push: go vet)
make cross # cross-compile for all 6 targets
```
### Isolated development environment
A source-built binary shares no on-disk state with a stable release when launched
with `REASONIX_HOME` set. This gives each build its own self-contained directory
tree — config, credentials, sessions, cache, skills, commands, hooks, and
desktop tab state — so the two builds never interfere:
**CLI**
```bash
REASONIX_HOME=/tmp/reasonix-dev go run ./cmd/reasonix
# or after building:
# REASONIX_HOME=/tmp/reasonix-dev ./bin/reasonix
```
**Desktop**
```bash
cd desktop && wails build
REASONIX_HOME=/tmp/reasonix-dev-isolated build/bin/reasonix-desktop
```
On Windows, use `$env:REASONIX_HOME` in PowerShell or `set REASONIX_HOME=` in
Command Prompt; the binary extension is `.exe`.
The directory is empty on first launch; the app behaves exactly like a fresh
install. Every subsequent write — config saves, credential storage, session
logs — stays under `REASONIX_HOME`. Legacy migration, OS-home convention
directory scanning, and all other fallback paths are skipped so no production
data leaks in or out.
### Cache-first review gate
Reasonix treats high prompt-cache hit rate as product behavior. Changes that
touch provider-visible system prompt construction, memory prefix, output styles,
skill index behavior, default tool surfaces, tool schemas, provider request
serialization, compaction, or MCP/tool registration need explicit cache review.
For these changes:
- Keep system prompt changes low-frequency and require explicit review.
- Fill the PR body `Cache-impact:` line with `none`, `low`, `medium`, or `high`
plus the reason.
- Fill the PR body `Cache-guard:` line with the focused guard test/command added
or run, or explain why an existing guard covers the change.
- Fill `System-prompt-review:` when system prompt, memory prefix, output style,
or skill index behavior changes.
- Prefer focused guard tests near the changed surface; `scripts/cache-guard.sh`
remains the broader release-level cache-hit check.
CI enforces this metadata for cache-sensitive paths so prompt/tool prefix churn
is called out before review.
### Running tests
```bash
go test ./... # all tests
go test ./internal/agent/ -v # verbose, one package
go test ./internal/tool/builtin/ -run TestGrep # one test
```
### Code style
- `gofmt` is enforced by CI — format before committing
- Follow existing patterns: wrap errors with `fmt.Errorf("...: %w", err)`
- Library code never calls `os.Exit` or prints to stdout/stderr
- Only `cli/` and `main/` decide exit codes and user-facing messages
- Exported identifiers must have doc comments
### Commit messages
Follow [Conventional Commits](https://www.conventionalcommits.org/):
```
feat(glob): add ** recursive pattern support
fix: replace silent error discards with structured logging
test(event): add comprehensive unit tests for event package
docs: add CONTRIBUTING.md
ci: add golangci-lint and govulncheck
```
## Adding a new built-in tool
1. Create `internal/tool/builtin/mytool.go`
2. Implement the `tool.Tool` interface: `Name()`, `Description()`, `Schema()`, `ReadOnly()`, `Execute()`
3. Register via `func init() { tool.RegisterBuiltin(myTool{}) }`
4. Add tests in `internal/tool/builtin/builtin_test.go` or a separate `mytool_test.go`
5. The tool is automatically available — `main` blank-imports `builtin`
## Adding a new model provider
(For MCP tool servers see `internal/plugin` instead — that's a different layer.)
1. Create `internal/provider/myprovider/`
2. Implement `provider.Provider`: `Name()`, `Stream()`
3. Register via `func init() { provider.Register("mykind", New) }`
4. The provider is available from config with `kind = "mykind"`
## Adding i18n strings
1. Add the field to `internal/i18n/i18n.go` (`Messages` struct)
2. Add the value in `internal/i18n/messages_en.go` and `messages_zh.go`
3. The `TestCatalogsComplete` test will fail if you miss a locale
## Submitting changes
1. Fork the repository
2. Create a feature branch from `main-v2`
3. Make your changes with tests
4. Ensure `go test ./...` passes
5. Ensure `gofmt -l .` shows no changes
6. Submit a pull request to `main-v2`
## Reporting issues
Open an issue on GitHub with:
- Steps to reproduce
- Expected vs actual behavior
- Go version and OS
- Relevant logs or error messages
## License
By contributing, you agree that your contributions will be licensed under the
same license as the project.
+42
View File
@@ -0,0 +1,42 @@
VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev)
LDFLAGS := -s -w -X main.version=$(VERSION)
GOEXE := $(shell go env GOEXE)
.PHONY: build vet fmt test desktop-test desktop-test-short desktop-test-times hooks cross clean
build:
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix$(GOEXE) ./cmd/reasonix
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix-plugin-example$(GOEXE) ./cmd/reasonix-plugin-example
vet:
go vet ./...
fmt:
gofmt -w .
test:
go test ./...
desktop-test:
cd desktop && go test .
desktop-test-short:
cd desktop && go test -short .
desktop-test-times:
cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py
hooks:
@git config core.hooksPath .githooks
@echo "installed: core.hooksPath -> .githooks (pre-push runs go vet)"
cross:
@mkdir -p dist
@for p in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64; do \
os=$${p%/*}; arch=$${p#*/}; ext=; [ $$os = windows ] && ext=.exe; \
echo "build $$os/$$arch"; \
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build -ldflags "$(LDFLAGS)" -o dist/reasonix-$$os-$$arch$$ext ./cmd/reasonix; \
done
clean:
rm -rf bin dist
+203 -21
View File
@@ -1,28 +1,210 @@
# Reasonix Platform
<p align="center">
<img src="docs/logo.svg" alt="Reasonix" width="640"/>
</p>
This branch contains only the shared Reasonix service platform:
<p align="center">
<strong>English</strong>
&nbsp;·&nbsp;
<a href="./README.zh-CN.md">简体中文</a>
&nbsp;·&nbsp;
<a href="./docs/GUIDE.md">Guide</a>
&nbsp;·&nbsp;
<a href="./docs/ACP.md">ACP</a>
&nbsp;·&nbsp;
<a href="./docs/SPEC.md">Spec</a>
&nbsp;·&nbsp;
<a href="https://esengine.github.io/DeepSeek-Reasonix/">Website</a>
&nbsp;·&nbsp;
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
</p>
- identity and account services;
- community and forum services;
- crash, diagnostics, telemetry, and registry services;
- Cloudflare Worker, D1, Queue, and R2 configuration;
- production deployment workflows for those services.
<p align="center">
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/v/reasonix.svg?style=flat-square&color=cb3837&labelColor=161b22&logo=npm&logoColor=white" alt="npm version"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/esengine/DeepSeek-Reasonix/ci.yml?style=flat-square&label=ci&labelColor=161b22&logo=githubactions&logoColor=white" alt="CI"/></a>
<a href="./LICENSE"><img src="https://img.shields.io/npm/l/reasonix.svg?style=flat-square&color=8b949e&labelColor=161b22" alt="license"/></a>
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/dm/reasonix.svg?style=flat-square&color=3fb950&labelColor=161b22&label=downloads" alt="downloads"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/stargazers"><img src="https://img.shields.io/github/stars/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=dbab09&labelColor=161b22&logo=github&logoColor=white" alt="GitHub stars"/></a>
<a href="https://atomgit.com/esengine/DeepSeek-Reasonix"><img src="https://atomgit.com/esengine/DeepSeek-Reasonix/star/badge.svg" alt="AtomGit stars"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
</p>
Product clients live on `main-v2` and `studio`. The public website lives on
`website`. Production deployments are accepted only from this branch.
<br/>
## Services
<h3 align="center">A DeepSeek-native AI coding agent for your terminal.</h3>
<p align="center">A config- and plugin-driven harness — a single static Go binary, tuned around DeepSeek's prefix cache so token costs stay low across long sessions.</p>
| Directory | Responsibility |
| --- | --- |
| `workers/accounts` | Shared identity, sessions, device grants, and email login |
| `workers/forum` | Community forum APIs |
| `workers/crash-report` | Diagnostics, telemetry, registry, and admin dashboards |
<br/>
Each service owns its package manager lockfile, tests, schema, migrations, and
Wrangler configuration. Run validation from the service directory before using
the matching manual production deployment workflow.
> [!IMPORTANT]
> **Community · 加入社区** — bilingual Discord for setup help (`#help` / `#求助`), workflow showcases, and feature ideas. → **<https://discord.gg/XF78rEME2D>**
The accounts worker removes expired sessions, email tokens, and device grants
daily. Raw queued telemetry is retained in R2 for eight days; aggregated D1
rows follow the service-specific retention windows in the crash worker.
<br/>
## Features
- **Config-driven.** Providers, the agent, enabled tools, and plugins are all
declared in `reasonix.toml`. No hardcoded models.
- **Multi-model & composable.** DeepSeek ships as a preset; any
OpenAI-compatible endpoint is a config entry, not new code. Optionally run
two models together (executor + planner) in separate, cache-stable sessions.
- **Plugin-driven.** External tools run as subprocesses over stdio JSON-RPC
(MCP-compatible). Built-in tools self-register at compile time.
- **Cache-aware context maintenance.** Startup injects a small stable environment
summary, stale tool output is snipped/pruned before summary compaction, and the
built-in tool schema contract is documented for regression review.
- **Zero-friction distribution.** `CGO_ENABLED=0` single binary; cross-compile
to six targets with one command. The only dependency is a TOML parser.
## Install
Choose the path that matches how you want to use Reasonix. The CLI/TUI,
desktop app, and VS Code extension all use the same local Reasonix engine.
### Path A: CLI / TUI
Install the native binary through npm on any supported platform, or use
Homebrew on macOS:
```sh
npm i -g reasonix # any OS; pulls the prebuilt native binary
brew install esengine/reasonix/reasonix # macOS
```
Prebuilt archives (`darwin|linux|windows × amd64|arm64`) and `SHA256SUMS` are on
every [GitHub release](https://github.com/esengine/DeepSeek-Reasonix/releases).
### Path B: Desktop app
Use the [official download page](https://reasonix.io/?download=desktop#start)
for the latest desktop build.
| Platform | Package | Architecture |
| --- | --- | --- |
| macOS | Universal `.dmg` or `.zip` | Apple Silicon / Intel |
| Windows | Installer `.exe` or portable `.zip` | x64 / ARM64 |
| Linux | `.deb` or `.tar.gz` | x64 |
Windows installers are code-signed through [SignPath.io](https://signpath.io/)
with a free certificate provided by the [SignPath Foundation](https://signpath.org/).
### Path C: VS Code extension
Complete Path A first. The extension does not bundle the CLI; it starts your
local `reasonix acp` backend and adds native chat, editor context, tool-call
approvals, model selection, and workspace sessions.
- **VS Code:** [install from Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=SivanLiu.reasonix-agent)
- **VSCodium / Eclipse Theia:** [install from Open VSX Registry](https://open-vsx.org/extension/SivanLiu/reasonix-agent)
- **Extension ID:** `SivanLiu.reasonix-agent` · [source and usage guide](https://github.com/SivanCola/reasonix-vscode)
### Path D: Build from source
```sh
git clone https://github.com/esengine/DeepSeek-Reasonix.git
cd DeepSeek-Reasonix
make build # -> bin/reasonix(.exe)
make cross # -> dist/ (darwin|linux|windows × amd64|arm64)
```
## Quick start
### CLI / TUI
These commands are for the CLI/TUI installed through Path A:
```sh
reasonix setup # configure a provider and model
reasonix # start an interactive session
reasonix run "implement the TODOs in main.go"
```
In an interactive session, run `/init` when you want Reasonix to create project
instructions.
### Desktop app
Download the installer for your platform from the
[official download page](https://reasonix.io/?download=desktop#start), install
and launch Reasonix, then configure a provider and model in the app. The CLI
commands above are not required for the desktop app.
For advanced CLI usage and configuration, see the **[CLI reference](./docs/CLI.md)**,
**[Guide](./docs/GUIDE.md)**, and
**[configuration paths](./docs/CONFIG_PATHS.md)**.
## Documentation
- **Getting started:** [Guide](./docs/GUIDE.md) · [CLI reference](./docs/CLI.md) ·
[Configuration paths](./docs/CONFIG_PATHS.md) · [ACP editor integration](./docs/ACP.md)
- **Features & troubleshooting:** [Subagent profiles](./docs/SUBAGENT_PROFILES.md) ·
[Capability diagnostics](./docs/CAPABILITY_DIAGNOSTICS.md) ·
[Recovery and Safe Mode](./docs/RECOVERY.md) · [Bot guide](./docs/BOT_GUIDE.md) ·
[Checkpoints & rewind](./docs/CHECKPOINTS.md)
- **Engineering & migration:** [Spec](./docs/SPEC.md) ·
[Task contracts & pause policy](./docs/TASK_CONTRACT.md) ·
[Tool contract](./docs/TOOL_CONTRACT.md) · [Migrating from 0.x](./docs/MIGRATING.md)
## Star History
<a href="https://www.star-history.com/?repos=esengine%2FDeepSeek-Reasonix&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/star-history/assets/star-history/star-history-dark.svg" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/star-history/assets/star-history/star-history-light.svg" />
<img alt="Star History Chart" src="https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/star-history/assets/star-history/star-history-light.svg" />
</picture>
</a>
<br/>
## Acknowledgments
A small list of folks whose work has shaped Reasonix the most — the current top
20 contributors by commit count. The full contributor graph is on
[GitHub](https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors?all=1).
<!-- reasonix-top-contributors:start -->
| Contributor | Contributor | Contributor | Contributor |
| --- | --- | --- | --- |
| [**SivanCola**](https://github.com/SivanCola) | [**esengine**](https://github.com/esengine) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
| **reasonix** (anonymous) | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**GTC2080**](https://github.com/GTC2080) | [**light-front-theory**](https://github.com/light-front-theory) |
| **merge-order-check** (anonymous) | [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**eghrhegpe**](https://github.com/eghrhegpe) | **wufengfan** (anonymous) |
| [**CVEngineer66**](https://github.com/CVEngineer66) | [**dependabot\[bot\]**](https://github.com/apps/dependabot) | [**lanshi17**](https://github.com/lanshi17) | [**SuMuxi66**](https://github.com/SuMuxi66) |
| [**CnsMaple**](https://github.com/CnsMaple) | [**cyq1017**](https://github.com/cyq1017) | [**JesonChou**](https://github.com/JesonChou) | [**XTLine**](https://github.com/XTLine) |
<!-- reasonix-top-contributors:end -->
Also a separate thank-you to [**Bernardxu123**](https://github.com/Bernardxu123)
for designing the project logo, and to
[AIGC Link](https://xhslink.com/m/80ngts127cA) for promoting the project on XiaoHongShu.
<p align="center">
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">
<img src="https://contrib.rocks/image?repo=esengine/DeepSeek-Reasonix&max=100&columns=12" alt="Contributors to esengine/DeepSeek-Reasonix" width="860"/>
</a>
</p>
<br/>
---
<p align="center">
<sub>MIT — see <a href="./LICENSE">LICENSE</a></sub>
<br/>
<sub>Built by the community at <a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">esengine/DeepSeek-Reasonix</a></sub>
</p>
---
<p align="center"><sub><strong>Support this project</strong></sub></p>
If Reasonix has been useful and you'd like to say thanks, you can. It stays a
coffee, not a contract — donations don't buy feature priority or change how
issues get triaged.
- **International** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
- **国内** — 微信支付(扫码)
<p align="center">
<img src=".github/sponsor/wechat-pay.jpg" alt="WeChat Pay QR code" width="180"/>
</p>
+202
View File
@@ -0,0 +1,202 @@
<p align="center">
<img src="docs/logo.svg" alt="Reasonix" width="640"/>
</p>
<p align="center">
<a href="./README.md">English</a>
&nbsp;·&nbsp;
<strong>简体中文</strong>
&nbsp;·&nbsp;
<a href="./docs/GUIDE.zh-CN.md">指南</a>
&nbsp;·&nbsp;
<a href="./docs/ACP.zh-CN.md">ACP</a>
&nbsp;·&nbsp;
<a href="./docs/SPEC.zh-CN.md">规格</a>
&nbsp;·&nbsp;
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
&nbsp;·&nbsp;
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
</p>
<p align="center">
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/v/reasonix.svg?style=flat-square&color=cb3837&labelColor=161b22&logo=npm&logoColor=white" alt="npm version"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/esengine/DeepSeek-Reasonix/ci.yml?style=flat-square&label=ci&labelColor=161b22&logo=githubactions&logoColor=white" alt="CI"/></a>
<a href="./LICENSE"><img src="https://img.shields.io/npm/l/reasonix.svg?style=flat-square&color=8b949e&labelColor=161b22" alt="license"/></a>
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/dm/reasonix.svg?style=flat-square&color=3fb950&labelColor=161b22&label=downloads" alt="downloads"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/stargazers"><img src="https://img.shields.io/github/stars/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=dbab09&labelColor=161b22&logo=github&logoColor=white" alt="GitHub stars"/></a>
<a href="https://atomgit.com/esengine/DeepSeek-Reasonix"><img src="https://atomgit.com/esengine/DeepSeek-Reasonix/star/badge.svg" alt="AtomGit stars"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
</p>
<br/>
<h3 align="center">面向终端的 DeepSeek 原生 AI coding agent。</h3>
<p align="center">由配置与插件驱动的极薄 harness——单一静态 Go 二进制,围绕 DeepSeek 的前缀缓存调优,长会话也能把 token 成本压低。</p>
<br/>
> [!IMPORTANT]
> **加入社区 · Community** — 双语 Discord,提供安装答疑(`#help` / `#求助`)、工作流展示与功能想法。→ **<https://discord.gg/XF78rEME2D>**
## 特性
- **配置驱动**:provider、agent、启用的工具、插件全部在 `reasonix.toml` 中声明,
内核无硬编码模型。
- **多模型 · 可组合**:DeepSeek 作为预设内置;任何 OpenAI 兼容
端点都只是一条配置。可选让两个模型协同(执行器 + 规划器),各自独立、缓存稳定的 session。
- **插件驱动**:外部工具以子进程形式运行,通过 stdio JSON-RPC 通信(MCP 兼容);
内置工具在编译期自注册。
- **缓存友好的上下文维护**:启动时注入稳定的环境摘要;旧工具输出会先 snip/prune,
再进入摘要 compaction;内置工具 schema 合约有文档和回归测试保护。
- **零摩擦分发**:`CGO_ENABLED=0` 单二进制;一条命令交叉编译到六个目标平台。
唯一依赖是一个 TOML 解析库。
## 安装
选择适合你的使用路径。CLI/TUI、桌面端和 VS Code 扩展都使用同一套本地
Reasonix 引擎。
### 路径 A:CLI / TUI
任意支持的平台都可以通过 npm 安装原生二进制;macOS 也可以使用 Homebrew:
```sh
npm i -g reasonix # 任意系统;自动拉取对应平台的原生二进制
brew install esengine/reasonix/reasonix # macOS
```
预编译归档(`darwin|linux|windows × amd64|arm64`)和 `SHA256SUMS` 见每个
[GitHub release](https://github.com/esengine/DeepSeek-Reasonix/releases)。
### 路径 B:桌面端
前往[官方下载页](https://reasonix.io/?download=desktop#start)获取最新桌面版本。
| 平台 | 安装包 | 架构 |
| --- | --- | --- |
| macOS | 通用 `.dmg` 或 `.zip` | Apple Silicon / Intel |
| Windows | 安装器 `.exe` 或便携 `.zip` | x64 / ARM64 |
| Linux | `.deb` 或 `.tar.gz` | x64 |
Windows 安装器通过 [SignPath.io](https://signpath.io/) 完成代码签名,证书由
[SignPath 基金会](https://signpath.org/) 免费提供。
### 路径 C:VS Code 扩展
请先完成路径 A。扩展不内置 CLI,而是启动本机的 `reasonix acp` 后端,
并提供原生聊天、编辑器上下文、工具调用审批、模型选择和工作区会话。
- **VS Code:** [从 Visual Studio Marketplace 安装](https://marketplace.visualstudio.com/items?itemName=SivanLiu.reasonix-agent)
- **VSCodium / Eclipse Theia:** [从 Open VSX Registry 安装](https://open-vsx.org/extension/SivanLiu/reasonix-agent)
- **扩展 ID:** `SivanLiu.reasonix-agent` · [源码与使用说明](https://github.com/SivanCola/reasonix-vscode)
### 路径 D:从源码构建
```sh
git clone https://github.com/esengine/DeepSeek-Reasonix.git
cd DeepSeek-Reasonix
make build # -> bin/reasonix(.exe)
make cross # -> dist/(darwin|linux|windows × amd64|arm64)
```
## 快速开始
### CLI / TUI
以下命令仅适用于通过路径 A 安装的 CLI/TUI:
```sh
reasonix setup # 配置 provider 和模型
reasonix # 启动交互式会话
reasonix run "把 main.go 里的 TODO 实现掉"
```
需要项目指令时,可在交互式会话中运行 `/init`。
### 桌面端
从[官方下载页](https://reasonix.io/?download=desktop#start)下载对应系统的安装包,
安装并启动 Reasonix,然后在应用内配置 provider 和模型即可使用。桌面端无需执行
上面的 CLI 命令。
CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**、
**[指南](./docs/GUIDE.zh-CN.md)** 和
**[配置路径](./docs/CONFIG_PATHS.zh-CN.md)**。
## 文档
- **开始使用:** [指南](./docs/GUIDE.zh-CN.md) ·
[CLI 命令参考](./docs/CLI.zh-CN.md) · [配置路径](./docs/CONFIG_PATHS.zh-CN.md) ·
[ACP 编辑器接入](./docs/ACP.zh-CN.md)
- **功能与排障:** [子智能体 Profile](./docs/SUBAGENT_PROFILES.zh-CN.md) ·
[能力诊断](./docs/CAPABILITY_DIAGNOSTICS.zh-CN.md) ·
[恢复与安全模式](./docs/RECOVERY.zh-CN.md) ·
[机器人使用指南](./docs/BOT_GUIDE.zh-CN.md) ·
[Checkpoints 与 rewind](./docs/CHECKPOINTS.zh-CN.md)
- **工程与迁移:** [规格](./docs/SPEC.zh-CN.md) ·
[任务合约与暂停策略](./docs/TASK_CONTRACT.zh-CN.md) ·
[工具合约](./docs/TOOL_CONTRACT.zh-CN.md) ·
[从 0.x 迁移](./docs/MIGRATING.zh-CN.md)
## Star 趋势
<a href="https://www.star-history.com/?repos=esengine%2FDeepSeek-Reasonix&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/star-history/assets/star-history/star-history-dark.svg" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/star-history/assets/star-history/star-history-light.svg" />
<img alt="Star History Chart" src="https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/star-history/assets/star-history/star-history-light.svg" />
</picture>
</a>
<br/>
## 致谢
下面这些朋友的工作塑造了 Reasonix 今天的样子 —— 当前按 commit 数统计的前 20 名贡献者。
完整贡献者列表在
[GitHub](https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors?all=1)。
<!-- reasonix-top-contributors:start -->
| Contributor | Contributor | Contributor | Contributor |
| --- | --- | --- | --- |
| [**SivanCola**](https://github.com/SivanCola) | [**esengine**](https://github.com/esengine) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
| **reasonix**(anonymous) | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**GTC2080**](https://github.com/GTC2080) | [**light-front-theory**](https://github.com/light-front-theory) |
| **merge-order-check**(anonymous) | [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**eghrhegpe**](https://github.com/eghrhegpe) | **wufengfan**(anonymous) |
| [**CVEngineer66**](https://github.com/CVEngineer66) | [**dependabot\[bot\]**](https://github.com/apps/dependabot) | [**lanshi17**](https://github.com/lanshi17) | [**SuMuxi66**](https://github.com/SuMuxi66) |
| [**CnsMaple**](https://github.com/CnsMaple) | [**cyq1017**](https://github.com/cyq1017) | [**JesonChou**](https://github.com/JesonChou) | [**XTLine**](https://github.com/XTLine) |
<!-- reasonix-top-contributors:end -->
另外特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo,
以及 [AIGC Link](https://xhslink.com/m/80ngts127cA) 在小红书上的推广。
<p align="center">
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">
<img src="https://contrib.rocks/image?repo=esengine/DeepSeek-Reasonix&max=100&columns=12" alt="esengine/DeepSeek-Reasonix 贡献者" width="860"/>
</a>
</p>
<br/>
---
<p align="center">
<sub>MIT —— 见 <a href="./LICENSE">LICENSE</a></sub>
<br/>
<sub>由 <a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">esengine/DeepSeek-Reasonix</a> 社区共建</sub>
</p>
---
<p align="center"><sub><strong>支持本项目</strong></sub></p>
如果 Reasonix 帮你省了时间或 token,欢迎请杯咖啡。捐助不会换来 feature
优先级,也不会影响 issue 的处理顺序——就是「谢谢」。
- **国内** — 微信支付(扫下方二维码)
- **海外** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
<p align="center">
<img src=".github/sponsor/wechat-pay.jpg" alt="微信支付收款码" width="180"/>
</p>
+72 -13
View File
@@ -1,14 +1,73 @@
# Reasonix Platform instructions
# Reasonix project memory
- This branch owns shared online services only. Do not add product client or
public website code here.
- Keep every production deployment manually triggered and restricted to the
`platform` branch.
- Database changes must be backward-compatible, idempotent, and accompanied by
a migration or runtime schema guard.
- Ingest endpoints must validate before enqueueing, acknowledge only durable
work, and remain safe under retries.
- Add tests at the HTTP, queue, or storage boundary where behavior becomes
externally observable.
- Never commit credentials, tokens, customer content, or personal user data.
- Comments are English and explain only non-obvious constraints.
This file is loaded into every session's system prompt (the cache-stable prefix),
so keep it concise and durable — it is the project's standing instructions to the
agent. It is the Reasonix analog of Claude Code's CLAUDE.md.
## Conventions
- Go kernel under `internal/`; each package owns one concern and documents it in a
package comment. Match the surrounding comment density and idiom when editing.
- One transport-agnostic `control.Controller` sits behind every frontend (chat
TUI, HTTP/SSE serve, Wails desktop). Add behavior to the controller, not a
frontend, so all three inherit it.
- Cache-first: the system-prompt prefix (base prompt + tools + memory) must stay
byte-stable across turns so DeepSeek's automatic prefix cache stays warm. Never
mutate it mid-session — ride the turn tail instead (see `control.Compose`).
## Memory
- Hierarchical docs: `REASONIX.md` (this file, committed/shared), `REASONIX.local.md`
(personal, git-ignored), user-global `~/.config/reasonix/REASONIX.md`, and any
`REASONIX.md` in an ancestor dir. `AGENTS.md` is accepted as a fallback name.
- `@path` on its own line imports another file's contents.
- `#<note>` in chat quick-adds a line here. The `remember` tool saves durable
facts to the per-project auto-memory store (frontmatter files + `MEMORY.md`
index), which loads into the prefix on the next session.
## Notes
## Pre-push CI simulation
Run these **before every commit** to catch the fastest CI failures locally:
```bash
gofmt -w . # catches gofmt (saves ~13s CI)
go vet ./... # catches vet warnings (saves ~52s CI/lint)
go test ./internal/tool/builtin/ ./internal/boot/ # catches tool/boot test breaks
```
CI runs `golangci-lint` (not locally available), but gofmt + vet already block ~80% of fast-fail scenarios.
## Import cycle rule
Before importing a new internal package from a non-test file, verify the target package's **test files** aren't already importing back to you:
```
# BAD: agent(_test.go) → tool/builtin(sessions.go) → agent → setup failed
```
Use `go test ./path/to/target/` to detect cycles **before** pushing. A `[setup failed]` message means a cycle exists.
## PR hygiene
- **One force-push per round of review feedback.** Multiple force-pushes destroy review history and confuse reviewers.
- **Keep the PR diff minimal.** Only the files relevant to the PR's purpose — no stray changes from other branches.
- **Amend, don't add commits, for review feedback** — keeps the commit history clean.
## Cache-impact PR metadata
When PR changes touch files under `internal/boot/`, `internal/tool/`, `internal/provider/`, or other cache-sensitive paths (listed in `scripts/check-cache-impact.sh`), the PR body MUST include these lines at the end:
```
Cache-impact: <none|low|medium|high> — <reason>
Cache-guard: <focused guard test/command or existing guard rationale>
```
If the PR also touches files under `internal/config/`, `internal/memory/`, `internal/outputstyle/`, `internal/skill/`, or `internal/boot/`, add:
```
System-prompt-review: <reviewer/approval note>
```
Values `n/a`, `none`, `todo`, `tbd` are rejected — use a descriptive reason instead.
+45
View File
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="480" viewBox="0 0 960 480" role="img" aria-labelledby="title description">
<title id="title">esengine/DeepSeek-Reasonix Star History</title>
<desc id="description">36,039 current GitHub stargazers through 2026-11-02.</desc>
<rect width="960" height="480" rx="12" fill="#0d1117" stroke="#30363d" />
<text x="76" y="37" fill="#f0f6fc" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="22" font-weight="600">Star History</text>
<text x="76" y="60" fill="#8b949e" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="13">esengine/DeepSeek-Reasonix</text>
<g font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif">
<line x1="76" y1="418.00" x2="926" y2="418.00" stroke="#21262d" stroke-width="1" />
<text x="64" y="422.00" text-anchor="end" fill="#8b949e" font-size="12">0</text>
<line x1="76" y1="350.00" x2="926" y2="350.00" stroke="#21262d" stroke-width="1" />
<text x="64" y="354.00" text-anchor="end" fill="#8b949e" font-size="12">8,000</text>
<line x1="76" y1="282.00" x2="926" y2="282.00" stroke="#21262d" stroke-width="1" />
<text x="64" y="286.00" text-anchor="end" fill="#8b949e" font-size="12">16,000</text>
<line x1="76" y1="214.00" x2="926" y2="214.00" stroke="#21262d" stroke-width="1" />
<text x="64" y="218.00" text-anchor="end" fill="#8b949e" font-size="12">24,000</text>
<line x1="76" y1="146.00" x2="926" y2="146.00" stroke="#21262d" stroke-width="1" />
<text x="64" y="150.00" text-anchor="end" fill="#8b949e" font-size="12">32,000</text>
<line x1="76" y1="78.00" x2="926" y2="78.00" stroke="#21262d" stroke-width="1" />
<text x="64" y="82.00" text-anchor="end" fill="#8b949e" font-size="12">40,000</text>
<line x1="76.00" y1="78" x2="76.00" y2="418" stroke="#21262d" stroke-width="1" />
<text x="76.00" y="452" text-anchor="middle" fill="#8b949e" font-size="12">Apr 21</text>
<line x1="246.00" y1="78" x2="246.00" y2="418" stroke="#21262d" stroke-width="1" />
<text x="246.00" y="452" text-anchor="middle" fill="#8b949e" font-size="12">May 30</text>
<line x1="416.00" y1="78" x2="416.00" y2="418" stroke="#21262d" stroke-width="1" />
<text x="416.00" y="452" text-anchor="middle" fill="#8b949e" font-size="12">Jul 8</text>
<line x1="586.00" y1="78" x2="586.00" y2="418" stroke="#21262d" stroke-width="1" />
<text x="586.00" y="452" text-anchor="middle" fill="#8b949e" font-size="12">Aug 16</text>
<line x1="756.00" y1="78" x2="756.00" y2="418" stroke="#21262d" stroke-width="1" />
<text x="756.00" y="452" text-anchor="middle" fill="#8b949e" font-size="12">Sep 24</text>
<line x1="926.00" y1="78" x2="926.00" y2="418" stroke="#21262d" stroke-width="1" />
<text x="926.00" y="452" text-anchor="middle" fill="#8b949e" font-size="12">Nov 2</text>
<path d="M76.00,417.99 L84.72,417.86 L89.08,417.50 L93.44,417.39 L97.79,417.21 L102.15,417.01 L106.51,416.92 L110.87,416.56 L115.23,416.35 L119.59,416.19 L123.95,415.88 L128.31,415.56 L132.67,415.26 L137.03,414.91 L141.38,414.50 L145.74,413.14 L150.10,412.16 L154.46,410.57 L158.82,409.61 L163.18,408.26 L167.54,407.05 L171.90,404.59 L176.26,400.57 L180.62,395.38 L184.97,392.38 L189.33,389.34 L193.69,385.41 L198.05,382.14 L202.41,379.58 L206.77,377.85 L211.13,376.10 L215.49,372.89 L219.85,366.67 L224.21,353.02 L228.56,339.54 L232.92,326.89 L237.28,318.30 L241.64,309.11 L246.00,303.02 L250.36,298.30 L254.72,292.25 L259.08,286.56 L263.44,280.80 L267.79,275.76 L272.15,271.94 L276.51,268.99 L280.87,265.95 L285.23,261.30 L289.59,256.93 L293.95,252.91 L298.31,249.60 L302.67,246.76 L307.03,245.01 L311.38,243.07 L315.74,240.73 L320.10,237.75 L324.46,235.56 L328.82,233.90 L333.18,232.85 L337.54,231.80 L341.90,230.07 L346.26,227.80 L350.62,225.39 L354.97,223.11 L359.33,220.88 L363.69,218.67 L368.05,217.38 L372.41,216.35 L376.77,215.23 L381.13,214.09 L385.49,213.14 L389.85,212.05 L394.21,211.08 L398.56,210.28 L402.92,209.53 L407.28,208.17 L411.64,207.05 L416.00,206.10 L420.36,205.14 L424.72,204.49 L429.08,203.90 L433.44,203.25 L437.79,202.34 L442.15,201.61 L446.51,201.00 L450.87,200.39 L455.23,199.79 L459.59,198.73 L463.95,198.21 L468.31,197.42 L472.67,196.71 L477.03,195.95 L481.38,195.22 L485.74,194.64 L490.10,194.16 L494.46,193.80 L498.82,192.99 L503.18,192.52 L507.54,191.99 L511.90,191.50 L516.26,190.02 L520.62,187.74 L524.97,183.58 L529.33,176.57 L533.69,169.67 L538.05,162.99 L542.41,156.34 L546.77,151.98 L551.13,149.72 L555.49,147.31 L559.85,144.95 L564.21,142.81 L568.56,140.76 L572.92,138.15 L577.28,136.79 L581.64,136.23 L586.00,135.69 L590.36,134.91 L594.72,133.77 L599.08,132.79 L603.44,131.90 L607.79,131.13 L612.15,130.65 L616.51,130.15 L620.87,129.58 L625.23,129.03 L629.59,128.64 L633.95,128.12 L638.31,127.79 L642.67,127.56 L647.03,127.18 L651.38,126.83 L655.74,126.43 L660.10,126.02 L664.46,125.63 L668.82,125.39 L673.18,125.14 L677.54,124.87 L681.90,124.70 L686.26,124.42 L690.62,124.25 L694.97,123.77 L699.33,123.44 L703.69,123.16 L708.05,122.94 L712.41,122.61 L716.77,122.32 L721.13,122.01 L725.49,121.67 L729.85,121.38 L734.21,121.11 L738.56,120.78 L742.92,120.47 L747.28,120.23 L751.64,119.93 L756.00,119.68 L760.36,119.53 L764.72,119.34 L769.08,119.23 L773.44,119.05 L777.79,118.81 L782.15,118.49 L786.51,118.34 L790.87,118.15 L795.23,117.98 L799.59,117.87 L803.95,117.68 L808.31,117.57 L812.67,117.41 L817.03,117.18 L821.38,116.99 L825.74,116.74 L830.10,116.56 L834.46,116.35 L838.82,116.15 L843.18,115.82 L847.54,115.60 L851.90,115.37 L856.26,115.16 L860.62,114.92 L864.97,114.55 L869.33,114.30 L873.69,113.99 L878.05,113.67 L882.41,113.48 L886.77,113.27 L891.13,113.12 L895.49,112.85 L899.85,112.65 L904.21,112.44 L908.56,112.30 L912.92,112.09 L917.28,111.94 L921.64,111.80 L926.00,111.67 L926.00,418.00 L76.00,418.00 Z" fill="#58a6ff" opacity="0.12" />
<path d="M76.00,417.99 L84.72,417.86 L89.08,417.50 L93.44,417.39 L97.79,417.21 L102.15,417.01 L106.51,416.92 L110.87,416.56 L115.23,416.35 L119.59,416.19 L123.95,415.88 L128.31,415.56 L132.67,415.26 L137.03,414.91 L141.38,414.50 L145.74,413.14 L150.10,412.16 L154.46,410.57 L158.82,409.61 L163.18,408.26 L167.54,407.05 L171.90,404.59 L176.26,400.57 L180.62,395.38 L184.97,392.38 L189.33,389.34 L193.69,385.41 L198.05,382.14 L202.41,379.58 L206.77,377.85 L211.13,376.10 L215.49,372.89 L219.85,366.67 L224.21,353.02 L228.56,339.54 L232.92,326.89 L237.28,318.30 L241.64,309.11 L246.00,303.02 L250.36,298.30 L254.72,292.25 L259.08,286.56 L263.44,280.80 L267.79,275.76 L272.15,271.94 L276.51,268.99 L280.87,265.95 L285.23,261.30 L289.59,256.93 L293.95,252.91 L298.31,249.60 L302.67,246.76 L307.03,245.01 L311.38,243.07 L315.74,240.73 L320.10,237.75 L324.46,235.56 L328.82,233.90 L333.18,232.85 L337.54,231.80 L341.90,230.07 L346.26,227.80 L350.62,225.39 L354.97,223.11 L359.33,220.88 L363.69,218.67 L368.05,217.38 L372.41,216.35 L376.77,215.23 L381.13,214.09 L385.49,213.14 L389.85,212.05 L394.21,211.08 L398.56,210.28 L402.92,209.53 L407.28,208.17 L411.64,207.05 L416.00,206.10 L420.36,205.14 L424.72,204.49 L429.08,203.90 L433.44,203.25 L437.79,202.34 L442.15,201.61 L446.51,201.00 L450.87,200.39 L455.23,199.79 L459.59,198.73 L463.95,198.21 L468.31,197.42 L472.67,196.71 L477.03,195.95 L481.38,195.22 L485.74,194.64 L490.10,194.16 L494.46,193.80 L498.82,192.99 L503.18,192.52 L507.54,191.99 L511.90,191.50 L516.26,190.02 L520.62,187.74 L524.97,183.58 L529.33,176.57 L533.69,169.67 L538.05,162.99 L542.41,156.34 L546.77,151.98 L551.13,149.72 L555.49,147.31 L559.85,144.95 L564.21,142.81 L568.56,140.76 L572.92,138.15 L577.28,136.79 L581.64,136.23 L586.00,135.69 L590.36,134.91 L594.72,133.77 L599.08,132.79 L603.44,131.90 L607.79,131.13 L612.15,130.65 L616.51,130.15 L620.87,129.58 L625.23,129.03 L629.59,128.64 L633.95,128.12 L638.31,127.79 L642.67,127.56 L647.03,127.18 L651.38,126.83 L655.74,126.43 L660.10,126.02 L664.46,125.63 L668.82,125.39 L673.18,125.14 L677.54,124.87 L681.90,124.70 L686.26,124.42 L690.62,124.25 L694.97,123.77 L699.33,123.44 L703.69,123.16 L708.05,122.94 L712.41,122.61 L716.77,122.32 L721.13,122.01 L725.49,121.67 L729.85,121.38 L734.21,121.11 L738.56,120.78 L742.92,120.47 L747.28,120.23 L751.64,119.93 L756.00,119.68 L760.36,119.53 L764.72,119.34 L769.08,119.23 L773.44,119.05 L777.79,118.81 L782.15,118.49 L786.51,118.34 L790.87,118.15 L795.23,117.98 L799.59,117.87 L803.95,117.68 L808.31,117.57 L812.67,117.41 L817.03,117.18 L821.38,116.99 L825.74,116.74 L830.10,116.56 L834.46,116.35 L838.82,116.15 L843.18,115.82 L847.54,115.60 L851.90,115.37 L856.26,115.16 L860.62,114.92 L864.97,114.55 L869.33,114.30 L873.69,113.99 L878.05,113.67 L882.41,113.48 L886.77,113.27 L891.13,113.12 L895.49,112.85 L899.85,112.65 L904.21,112.44 L908.56,112.30 L912.92,112.09 L917.28,111.94 L921.64,111.80 L926.00,111.67" fill="none" stroke="#58a6ff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="926.00" cy="111.67" r="5" fill="#58a6ff" stroke="#0d1117" stroke-width="2" />
<g transform="translate(646, 25)">
<circle cx="0" cy="0" r="5" fill="#58a6ff" />
<text x="12" y="5" fill="#f0f6fc" font-size="14" font-weight="600">esengine/DeepSeek-Reasonix</text>
<text x="12" y="24" fill="#8b949e" font-size="12">36,039 stars</text>
</g>
<text x="926" y="466" text-anchor="end" fill="#8b949e" font-size="11">Updated automatically from GitHub stargazer history · 2026-11-02 UTC</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.4 KiB

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="480" viewBox="0 0 960 480" role="img" aria-labelledby="title description">
<title id="title">esengine/DeepSeek-Reasonix Star History</title>
<desc id="description">36,039 current GitHub stargazers through 2026-11-02.</desc>
<rect width="960" height="480" rx="12" fill="#ffffff" stroke="#d0d7de" />
<text x="76" y="37" fill="#24292f" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="22" font-weight="600">Star History</text>
<text x="76" y="60" fill="#57606a" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="13">esengine/DeepSeek-Reasonix</text>
<g font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif">
<line x1="76" y1="418.00" x2="926" y2="418.00" stroke="#d8dee4" stroke-width="1" />
<text x="64" y="422.00" text-anchor="end" fill="#57606a" font-size="12">0</text>
<line x1="76" y1="350.00" x2="926" y2="350.00" stroke="#d8dee4" stroke-width="1" />
<text x="64" y="354.00" text-anchor="end" fill="#57606a" font-size="12">8,000</text>
<line x1="76" y1="282.00" x2="926" y2="282.00" stroke="#d8dee4" stroke-width="1" />
<text x="64" y="286.00" text-anchor="end" fill="#57606a" font-size="12">16,000</text>
<line x1="76" y1="214.00" x2="926" y2="214.00" stroke="#d8dee4" stroke-width="1" />
<text x="64" y="218.00" text-anchor="end" fill="#57606a" font-size="12">24,000</text>
<line x1="76" y1="146.00" x2="926" y2="146.00" stroke="#d8dee4" stroke-width="1" />
<text x="64" y="150.00" text-anchor="end" fill="#57606a" font-size="12">32,000</text>
<line x1="76" y1="78.00" x2="926" y2="78.00" stroke="#d8dee4" stroke-width="1" />
<text x="64" y="82.00" text-anchor="end" fill="#57606a" font-size="12">40,000</text>
<line x1="76.00" y1="78" x2="76.00" y2="418" stroke="#d8dee4" stroke-width="1" />
<text x="76.00" y="452" text-anchor="middle" fill="#57606a" font-size="12">Apr 21</text>
<line x1="246.00" y1="78" x2="246.00" y2="418" stroke="#d8dee4" stroke-width="1" />
<text x="246.00" y="452" text-anchor="middle" fill="#57606a" font-size="12">May 30</text>
<line x1="416.00" y1="78" x2="416.00" y2="418" stroke="#d8dee4" stroke-width="1" />
<text x="416.00" y="452" text-anchor="middle" fill="#57606a" font-size="12">Jul 8</text>
<line x1="586.00" y1="78" x2="586.00" y2="418" stroke="#d8dee4" stroke-width="1" />
<text x="586.00" y="452" text-anchor="middle" fill="#57606a" font-size="12">Aug 16</text>
<line x1="756.00" y1="78" x2="756.00" y2="418" stroke="#d8dee4" stroke-width="1" />
<text x="756.00" y="452" text-anchor="middle" fill="#57606a" font-size="12">Sep 24</text>
<line x1="926.00" y1="78" x2="926.00" y2="418" stroke="#d8dee4" stroke-width="1" />
<text x="926.00" y="452" text-anchor="middle" fill="#57606a" font-size="12">Nov 2</text>
<path d="M76.00,417.99 L84.72,417.86 L89.08,417.50 L93.44,417.39 L97.79,417.21 L102.15,417.01 L106.51,416.92 L110.87,416.56 L115.23,416.35 L119.59,416.19 L123.95,415.88 L128.31,415.56 L132.67,415.26 L137.03,414.91 L141.38,414.50 L145.74,413.14 L150.10,412.16 L154.46,410.57 L158.82,409.61 L163.18,408.26 L167.54,407.05 L171.90,404.59 L176.26,400.57 L180.62,395.38 L184.97,392.38 L189.33,389.34 L193.69,385.41 L198.05,382.14 L202.41,379.58 L206.77,377.85 L211.13,376.10 L215.49,372.89 L219.85,366.67 L224.21,353.02 L228.56,339.54 L232.92,326.89 L237.28,318.30 L241.64,309.11 L246.00,303.02 L250.36,298.30 L254.72,292.25 L259.08,286.56 L263.44,280.80 L267.79,275.76 L272.15,271.94 L276.51,268.99 L280.87,265.95 L285.23,261.30 L289.59,256.93 L293.95,252.91 L298.31,249.60 L302.67,246.76 L307.03,245.01 L311.38,243.07 L315.74,240.73 L320.10,237.75 L324.46,235.56 L328.82,233.90 L333.18,232.85 L337.54,231.80 L341.90,230.07 L346.26,227.80 L350.62,225.39 L354.97,223.11 L359.33,220.88 L363.69,218.67 L368.05,217.38 L372.41,216.35 L376.77,215.23 L381.13,214.09 L385.49,213.14 L389.85,212.05 L394.21,211.08 L398.56,210.28 L402.92,209.53 L407.28,208.17 L411.64,207.05 L416.00,206.10 L420.36,205.14 L424.72,204.49 L429.08,203.90 L433.44,203.25 L437.79,202.34 L442.15,201.61 L446.51,201.00 L450.87,200.39 L455.23,199.79 L459.59,198.73 L463.95,198.21 L468.31,197.42 L472.67,196.71 L477.03,195.95 L481.38,195.22 L485.74,194.64 L490.10,194.16 L494.46,193.80 L498.82,192.99 L503.18,192.52 L507.54,191.99 L511.90,191.50 L516.26,190.02 L520.62,187.74 L524.97,183.58 L529.33,176.57 L533.69,169.67 L538.05,162.99 L542.41,156.34 L546.77,151.98 L551.13,149.72 L555.49,147.31 L559.85,144.95 L564.21,142.81 L568.56,140.76 L572.92,138.15 L577.28,136.79 L581.64,136.23 L586.00,135.69 L590.36,134.91 L594.72,133.77 L599.08,132.79 L603.44,131.90 L607.79,131.13 L612.15,130.65 L616.51,130.15 L620.87,129.58 L625.23,129.03 L629.59,128.64 L633.95,128.12 L638.31,127.79 L642.67,127.56 L647.03,127.18 L651.38,126.83 L655.74,126.43 L660.10,126.02 L664.46,125.63 L668.82,125.39 L673.18,125.14 L677.54,124.87 L681.90,124.70 L686.26,124.42 L690.62,124.25 L694.97,123.77 L699.33,123.44 L703.69,123.16 L708.05,122.94 L712.41,122.61 L716.77,122.32 L721.13,122.01 L725.49,121.67 L729.85,121.38 L734.21,121.11 L738.56,120.78 L742.92,120.47 L747.28,120.23 L751.64,119.93 L756.00,119.68 L760.36,119.53 L764.72,119.34 L769.08,119.23 L773.44,119.05 L777.79,118.81 L782.15,118.49 L786.51,118.34 L790.87,118.15 L795.23,117.98 L799.59,117.87 L803.95,117.68 L808.31,117.57 L812.67,117.41 L817.03,117.18 L821.38,116.99 L825.74,116.74 L830.10,116.56 L834.46,116.35 L838.82,116.15 L843.18,115.82 L847.54,115.60 L851.90,115.37 L856.26,115.16 L860.62,114.92 L864.97,114.55 L869.33,114.30 L873.69,113.99 L878.05,113.67 L882.41,113.48 L886.77,113.27 L891.13,113.12 L895.49,112.85 L899.85,112.65 L904.21,112.44 L908.56,112.30 L912.92,112.09 L917.28,111.94 L921.64,111.80 L926.00,111.67 L926.00,418.00 L76.00,418.00 Z" fill="#0969da" opacity="0.12" />
<path d="M76.00,417.99 L84.72,417.86 L89.08,417.50 L93.44,417.39 L97.79,417.21 L102.15,417.01 L106.51,416.92 L110.87,416.56 L115.23,416.35 L119.59,416.19 L123.95,415.88 L128.31,415.56 L132.67,415.26 L137.03,414.91 L141.38,414.50 L145.74,413.14 L150.10,412.16 L154.46,410.57 L158.82,409.61 L163.18,408.26 L167.54,407.05 L171.90,404.59 L176.26,400.57 L180.62,395.38 L184.97,392.38 L189.33,389.34 L193.69,385.41 L198.05,382.14 L202.41,379.58 L206.77,377.85 L211.13,376.10 L215.49,372.89 L219.85,366.67 L224.21,353.02 L228.56,339.54 L232.92,326.89 L237.28,318.30 L241.64,309.11 L246.00,303.02 L250.36,298.30 L254.72,292.25 L259.08,286.56 L263.44,280.80 L267.79,275.76 L272.15,271.94 L276.51,268.99 L280.87,265.95 L285.23,261.30 L289.59,256.93 L293.95,252.91 L298.31,249.60 L302.67,246.76 L307.03,245.01 L311.38,243.07 L315.74,240.73 L320.10,237.75 L324.46,235.56 L328.82,233.90 L333.18,232.85 L337.54,231.80 L341.90,230.07 L346.26,227.80 L350.62,225.39 L354.97,223.11 L359.33,220.88 L363.69,218.67 L368.05,217.38 L372.41,216.35 L376.77,215.23 L381.13,214.09 L385.49,213.14 L389.85,212.05 L394.21,211.08 L398.56,210.28 L402.92,209.53 L407.28,208.17 L411.64,207.05 L416.00,206.10 L420.36,205.14 L424.72,204.49 L429.08,203.90 L433.44,203.25 L437.79,202.34 L442.15,201.61 L446.51,201.00 L450.87,200.39 L455.23,199.79 L459.59,198.73 L463.95,198.21 L468.31,197.42 L472.67,196.71 L477.03,195.95 L481.38,195.22 L485.74,194.64 L490.10,194.16 L494.46,193.80 L498.82,192.99 L503.18,192.52 L507.54,191.99 L511.90,191.50 L516.26,190.02 L520.62,187.74 L524.97,183.58 L529.33,176.57 L533.69,169.67 L538.05,162.99 L542.41,156.34 L546.77,151.98 L551.13,149.72 L555.49,147.31 L559.85,144.95 L564.21,142.81 L568.56,140.76 L572.92,138.15 L577.28,136.79 L581.64,136.23 L586.00,135.69 L590.36,134.91 L594.72,133.77 L599.08,132.79 L603.44,131.90 L607.79,131.13 L612.15,130.65 L616.51,130.15 L620.87,129.58 L625.23,129.03 L629.59,128.64 L633.95,128.12 L638.31,127.79 L642.67,127.56 L647.03,127.18 L651.38,126.83 L655.74,126.43 L660.10,126.02 L664.46,125.63 L668.82,125.39 L673.18,125.14 L677.54,124.87 L681.90,124.70 L686.26,124.42 L690.62,124.25 L694.97,123.77 L699.33,123.44 L703.69,123.16 L708.05,122.94 L712.41,122.61 L716.77,122.32 L721.13,122.01 L725.49,121.67 L729.85,121.38 L734.21,121.11 L738.56,120.78 L742.92,120.47 L747.28,120.23 L751.64,119.93 L756.00,119.68 L760.36,119.53 L764.72,119.34 L769.08,119.23 L773.44,119.05 L777.79,118.81 L782.15,118.49 L786.51,118.34 L790.87,118.15 L795.23,117.98 L799.59,117.87 L803.95,117.68 L808.31,117.57 L812.67,117.41 L817.03,117.18 L821.38,116.99 L825.74,116.74 L830.10,116.56 L834.46,116.35 L838.82,116.15 L843.18,115.82 L847.54,115.60 L851.90,115.37 L856.26,115.16 L860.62,114.92 L864.97,114.55 L869.33,114.30 L873.69,113.99 L878.05,113.67 L882.41,113.48 L886.77,113.27 L891.13,113.12 L895.49,112.85 L899.85,112.65 L904.21,112.44 L908.56,112.30 L912.92,112.09 L917.28,111.94 L921.64,111.80 L926.00,111.67" fill="none" stroke="#0969da" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="926.00" cy="111.67" r="5" fill="#0969da" stroke="#ffffff" stroke-width="2" />
<g transform="translate(646, 25)">
<circle cx="0" cy="0" r="5" fill="#0969da" />
<text x="12" y="5" fill="#24292f" font-size="14" font-weight="600">esengine/DeepSeek-Reasonix</text>
<text x="12" y="24" fill="#57606a" font-size="12">36,039 stars</text>
</g>
<text x="926" y="466" text-anchor="end" fill="#57606a" font-size="11">Updated automatically from GitHub stargazer history · 2026-11-02 UTC</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.4 KiB

+255
View File
@@ -0,0 +1,255 @@
// Drives the context-maintenance E2E scenarios against the real DeepSeek API:
// seed → (idle past cache TTL) → resume A/B-compares cold-restart miss tokens with and without pruning.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"reasonix/internal/agent"
"reasonix/internal/event"
"reasonix/internal/provider"
_ "reasonix/internal/provider/openai"
"reasonix/internal/tool"
"reasonix/internal/tool/builtin"
)
const (
model = "deepseek-v4-flash"
baseURL = "https://api.deepseek.com"
fatResults = 20
fatBytes = 12_000
)
func prov() provider.Provider {
key := os.Getenv("DEEPSEEK_API_KEY")
if key == "" {
fmt.Fprintln(os.Stderr, "DEEPSEEK_API_KEY not set")
os.Exit(1)
}
p, err := provider.New("openai", provider.Config{Name: "e2e", BaseURL: baseURL, Model: model, APIKey: key})
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return p
}
func fakeGoFile(nonce string, i, size int) string {
var b strings.Builder
fmt.Fprintf(&b, "// module %s file%02d\npackage stress\n\n", nonce, i)
line := 0
for b.Len() < size {
fmt.Fprintf(&b, "func helper_%s_%02d_%04d(x int) int { return x*%d + %d }\n", nonce, i, line, line+3, line*7)
line++
}
return b.String()
}
func seedSession(nonce string) *agent.Session {
s := agent.NewSession("You are a terse coding agent reviewing a Go codebase.")
s.Add(provider.Message{Role: provider.RoleUser, Content: "Review every file in module " + nonce + " one by one. Keep notes short."})
for i := 0; i < fatResults; i++ {
id := fmt.Sprintf("c%02d", i)
name := fmt.Sprintf("src/file%02d.go", i)
s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "read_file", Arguments: fmt.Sprintf(`{"path":%q}`, name)}}})
s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "read_file", Content: fakeGoFile(nonce, i, fatBytes)})
s.Add(provider.Message{Role: provider.RoleAssistant, Content: fmt.Sprintf("Reviewed %s.", name)})
}
return s
}
// oneShot appends a user message and runs a single completion, returning usage.
func oneShot(p provider.Provider, msgs []provider.Message, question string) (provider.Usage, error) {
req := provider.Request{
Messages: append(append([]provider.Message(nil), msgs...), provider.Message{Role: provider.RoleUser, Content: question}),
MaxTokens: 32,
}
ch, err := p.Stream(context.Background(), req)
if err != nil {
return provider.Usage{}, err
}
var u provider.Usage
for c := range ch {
switch c.Type {
case provider.ChunkUsage:
u = *c.Usage
case provider.ChunkError:
return u, c.Err
}
}
return u, nil
}
type meta struct {
SeededAt time.Time `json:"seeded_at"`
Nonces map[string]string `json:"nonces"`
SeedUse map[string]provider.Usage `json:"seed_usage"`
}
func seed(dir string) {
p := prov()
if err := os.MkdirAll(dir, 0o755); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
m := meta{SeededAt: time.Now(), Nonces: map[string]string{}, SeedUse: map[string]provider.Usage{}}
for _, arm := range []string{"pruned", "control"} {
nonce := fmt.Sprintf("%s%d", arm, time.Now().UnixNano()%1_000_000)
s := seedSession(nonce)
u, err := oneShot(p, s.Snapshot(), "How many files have you reviewed so far? Reply with just the number.")
if err != nil {
fmt.Fprintf(os.Stderr, "seed %s: %v\n", arm, err)
os.Exit(1)
}
if err := s.Save(filepath.Join(dir, arm+".jsonl")); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
m.Nonces[arm] = nonce
m.SeedUse[arm] = u
fmt.Printf("seeded %-7s prompt=%d hit=%d miss=%d\n", arm, u.PromptTokens, u.CacheHitTokens, u.CacheMissTokens)
}
b, _ := json.MarshalIndent(m, "", " ")
if err := os.WriteFile(filepath.Join(dir, "meta.json"), b, 0o644); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func resume(dir string) {
p := prov()
raw, err := os.ReadFile(filepath.Join(dir, "meta.json"))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
var m meta
if err := json.Unmarshal(raw, &m); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
idle := time.Since(m.SeededAt).Round(time.Minute)
out := map[string]provider.Usage{}
for _, arm := range []string{"pruned", "control"} {
s, err := agent.LoadSession(filepath.Join(dir, arm+".jsonl"))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
prunes := 0
if arm == "pruned" {
a := agent.New(nil, tool.NewRegistry(), s, agent.Options{ContextWindow: 1_000_000, ArchiveDir: filepath.Join(dir, "archive")}, event.Discard)
st, err := a.PruneStaleToolResults()
if err != nil {
fmt.Fprintln(os.Stderr, "prune:", err)
os.Exit(1)
}
prunes = st.Results
}
u, err := oneShot(p, s.Snapshot(), "Which file did you review first? Reply with just the path.")
if err != nil {
fmt.Fprintf(os.Stderr, "resume %s: %v\n", arm, err)
os.Exit(1)
}
out[arm] = u
fmt.Printf("resume %-7s idle=%s pruned=%d prompt=%d hit=%d miss=%d\n", arm, idle, prunes, u.PromptTokens, u.CacheHitTokens, u.CacheMissTokens)
}
b, _ := json.MarshalIndent(map[string]any{"idle": idle.String(), "resume_usage": out}, "", " ")
if err := os.WriteFile(filepath.Join(dir, fmt.Sprintf("resume-%d.json", time.Now().Unix())), b, 0o644); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
c, pr := out["control"], out["pruned"]
if c.CacheMissTokens > 0 {
fmt.Printf("\ncold-restart miss tokens: control=%d pruned=%d (%.0f%% reduction)\n",
c.CacheMissTokens, pr.CacheMissTokens, 100*(1-float64(pr.CacheMissTokens)/float64(c.CacheMissTokens)))
}
}
// comprehension checks that the agent re-reads a file behind a prune placeholder
// instead of hallucinating: the answer is a number that exists only in the file.
func comprehension(trials int) {
p := prov()
pass := 0
for t := 0; t < trials; t++ {
dir, err := os.MkdirTemp("", "cm-e2e-")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
secret := fmt.Sprintf("%d", 1000+time.Now().UnixNano()%9000)
content := "package cfg\n\n// retention floor, milliseconds\nconst cacheRetentionFloor = " + secret + "\n" + strings.Repeat("// padding line filler for prune eligibility\n", 400)
if err := os.WriteFile(filepath.Join(dir, "config.go"), []byte(content), 0o644); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
s := agent.NewSession("You are a terse coding agent. Use tools when you need file contents.")
s.Add(provider.Message{Role: provider.RoleUser, Content: "Read config.go and note its constants."})
s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "r1", Name: "read_file", Arguments: `{"path":"config.go"}`}}})
s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "r1", Name: "read_file", Content: content})
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "Noted the constants in config.go."})
for i := 0; i < 4; i++ {
s.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("ack %d", i)})
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"})
}
reg := tool.NewRegistry()
for _, tl := range (builtin.Workspace{Dir: dir}).Tools("read_file") {
reg.Add(tl)
}
a := agent.New(p, reg, s, agent.Options{ContextWindow: 2000, RecentKeep: 2, MaxSteps: 5}, event.Discard)
st, err := a.PruneStaleToolResults()
if err != nil || st.Results == 0 {
fmt.Fprintf(os.Stderr, "trial %d: prune did not fire (st=%+v err=%v)\n", t, st, err)
os.Exit(1)
}
err = a.Run(context.Background(), "What is the exact numeric value of cacheRetentionFloor in config.go? Reply with just the number.")
reRead, answered := false, false
for _, msg := range s.Snapshot() {
if msg.Role == provider.RoleTool && strings.Contains(msg.Content, "cacheRetentionFloor = "+secret) {
reRead = true
}
if msg.Role == provider.RoleAssistant && strings.Contains(msg.Content, secret) {
answered = true
}
}
ok := err == nil && reRead && answered
if ok {
pass++
}
fmt.Printf("trial %d: re-read=%v answered=%v err=%v\n", t, reRead, answered, err)
os.RemoveAll(dir)
}
fmt.Printf("\ncomprehension: %d/%d passed\n", pass, trials)
if pass < trials {
os.Exit(1)
}
}
func main() {
dir := flag.String("dir", "benchmarks/context-maintenance-e2e/run", "state directory for seed/resume")
trials := flag.Int("trials", 5, "comprehension trials")
flag.Parse()
switch flag.Arg(0) {
case "seed":
seed(*dir)
case "resume":
resume(*dir)
case "comprehension":
comprehension(*trials)
default:
fmt.Fprintln(os.Stderr, "usage: context-maintenance-e2e [seed|resume|comprehension]")
os.Exit(1)
}
}
@@ -0,0 +1,3 @@
prompt = "Begin by reading the file story/chapter-1.md. Each chapter ends by telling you which chapter to read next; follow that trail one chapter at a time — read a chapter, find where it points you, then read that next chapter — until a chapter says the trail ends. You cannot know the next chapter without reading the current one, so do not guess or read ahead. In chapter 1 a river village is named; in the final chapter on the trail a noble House is named. When the trail ends, write a file answer.txt whose entire contents are the village name from chapter 1, a single hyphen, and the House name from the final chapter — for example Riverton-Stark — and nothing else."
max_steps = 40
timeout_sec = 900
@@ -0,0 +1,4 @@
set -e
got=$(tr -d '[:space:]' < answer.txt | tr '[:upper:]' '[:lower:]')
want="aldermoor-verrin"
[ "$got" = "$want" ] || { echo "answer.txt normalized to '$got', want '$want'"; exit 1; }
@@ -0,0 +1,220 @@
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 4 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 10 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 16 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 22 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 28 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 34 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 40 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 46 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 52 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 58 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 64 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 70 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 76 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 82 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 88 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 94 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 100 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 106 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
It was from Aldermoor, the cluster of cottages where the north and south rivers braid together, that our courier first set out.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 112 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 118 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 124 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 130 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 136 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 142 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 148 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 154 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 160 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 166 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 172 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 178 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 184 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 190 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 196 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 202 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 208 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
To pick up the trail again, the next page you must read is chapter-4.md.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 214 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 220 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
@@ -0,0 +1,220 @@
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 3 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 9 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 15 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 21 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 27 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 33 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 39 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 45 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 51 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 57 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 63 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 69 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 75 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 81 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 87 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 93 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 99 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 105 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 111 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 117 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 123 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 129 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 135 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 141 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 147 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 153 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 159 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 165 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 171 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 177 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 183 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 189 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 195 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 201 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 207 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
To pick up the trail again, the next page you must read is chapter-5.md.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 213 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 219 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
@@ -0,0 +1,220 @@
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 2 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 8 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 14 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 20 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 26 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 32 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 38 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 44 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 50 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 56 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 62 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 68 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 74 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 80 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 86 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 92 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 98 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 104 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 110 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 116 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 122 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 128 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 134 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 140 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 146 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 152 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 158 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 164 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 170 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 176 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 182 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 188 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 194 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 200 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 206 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
To pick up the trail again, the next page you must read is chapter-6.md.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 212 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 218 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
@@ -0,0 +1,220 @@
Line 1 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 7 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 13 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 19 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 25 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 31 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 37 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 43 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 49 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 55 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 61 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 67 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 73 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 79 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 85 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 91 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 97 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 103 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 109 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 115 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 121 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 127 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 133 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 139 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 145 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 151 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 157 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 163 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 169 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 175 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 181 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 187 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 193 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 199 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 205 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
To pick up the trail again, the next page you must read is chapter-2.md.
Line 211 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 217 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
@@ -0,0 +1,220 @@
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 6 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 12 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 18 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 24 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 30 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 36 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 42 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 48 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 54 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 60 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 66 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 72 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 78 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 84 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 90 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 96 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 102 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 108 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 114 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 120 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 126 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 132 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 138 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 144 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 150 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 156 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 162 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 168 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 174 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 180 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 186 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 192 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 198 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 204 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
To pick up the trail again, the next page you must read is chapter-3.md.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 216 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
@@ -0,0 +1,220 @@
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 5 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 11 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 17 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 23 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 29 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 35 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 41 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 47 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 53 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 59 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 65 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 71 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 77 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 83 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 89 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 95 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 101 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 107 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
With that, the ancestral seal of the Verrin was pressed into the warm wax and then, before the whole assembly, ceremonially broken.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 113 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 119 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 125 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 131 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 137 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 143 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 149 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 155 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 161 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 167 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 173 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 179 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 185 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 191 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 197 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 203 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 209 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
Here the trail ends; there is no further page to follow. You now hold both names you were sent to find: the river village of Aldermoor, where the courier began in the first chapter, and the House whose seal was broken above. Your task is complete the moment you write a file named answer.txt whose entire contents are that village name, a single hyphen, and that House name, and nothing else. Write it now.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
Line 215 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
The road wound on through the grey hills, and the company spoke little of what lay behind them.
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
@@ -0,0 +1,3 @@
prompt = "The file calc.py has a bug: add(a, b) returns the wrong result. Fix add so it returns the sum of a and b. Keep the other functions unchanged."
max_steps = 12
timeout_sec = 180
@@ -0,0 +1,7 @@
set -e
python3 - <<'PY'
import calc
assert calc.add(2, 3) == 5, calc.add(2, 3)
assert calc.add(10, 5) == 15, calc.add(10, 5)
assert calc.mul(2, 3) == 6, calc.mul(2, 3)
PY
@@ -0,0 +1,6 @@
def add(a, b):
return a - b
def mul(a, b):
return a * b
+3
View File
@@ -0,0 +1,3 @@
prompt = "Create a file named fizzbuzz.py containing a function fizzbuzz(n) that returns the string 'Fizz' when n is divisible by 3, 'Buzz' when divisible by 5, 'FizzBuzz' when divisible by both 3 and 5, and otherwise the number as a string. Do not print anything at import time."
max_steps = 12
timeout_sec = 180
+8
View File
@@ -0,0 +1,8 @@
set -e
python3 - <<'PY'
import fizzbuzz
assert fizzbuzz.fizzbuzz(3) == "Fizz", fizzbuzz.fizzbuzz(3)
assert fizzbuzz.fizzbuzz(5) == "Buzz", fizzbuzz.fizzbuzz(5)
assert fizzbuzz.fizzbuzz(15) == "FizzBuzz", fizzbuzz.fizzbuzz(15)
assert fizzbuzz.fizzbuzz(7) == "7", fizzbuzz.fizzbuzz(7)
PY
@@ -0,0 +1,3 @@
prompt = "Create a file named palindrome.py with a function is_palindrome(s) that returns True if s reads the same forwards and backwards ignoring case, spaces, and punctuation, and False otherwise. Do not print anything at import time."
max_steps = 12
timeout_sec = 180
@@ -0,0 +1,7 @@
set -e
python3 - <<'PY'
import palindrome
assert palindrome.is_palindrome("Race car") is True
assert palindrome.is_palindrome("A man, a plan, a canal: Panama") is True
assert palindrome.is_palindrome("hello") is False
PY
@@ -0,0 +1,3 @@
prompt = "This directory contains a data/ folder with three files: alpha.txt, beta.txt, and gamma.txt. Each file holds a single line of the form name=number. You MUST delegate the reading to a sub-agent: call the `task` tool exactly once, instructing the sub-agent to read all three files under data/ and report the three numbers back to you. Do NOT read the files yourself with your own tools. After the sub-agent reports the numbers, add them together and write ONLY the resulting integer (no other text, no trailing label or newline-prefixed words) to a file named result.txt in the current directory."
max_steps = 15
timeout_sec = 300
@@ -0,0 +1,12 @@
set -e
# Exercises a fresh `task` sub-agent delegation in the headless `reasonix run`
# path: 17 + 28 + 41 = 86. The numbers are arbitrary so the answer can only be
# produced by actually reading the three seed files (the prompt mandates doing
# that via the `task` tool). Before sub-agents could run without a parent
# session, the `task` call errored here with "parent session is required".
test -f result.txt
got=$(tr -d '[:space:]' < result.txt)
if [ "$got" != "86" ]; then
echo "result.txt = '$got', want 86"
exit 1
fi
@@ -0,0 +1 @@
alpha=17
@@ -0,0 +1 @@
beta=28
@@ -0,0 +1 @@
gamma=41
+9
View File
@@ -0,0 +1,9 @@
# MCP catalog package inputs
Place each release-ready plugin tree in a directory named after its catalog
entry ID. Catalog CI recomputes the deterministic package tree and manifest
SHA-256 values from these directories before a catalog can be signed.
These inputs are catalog-release artifacts, not runtime plugin installs. The
private minisign key remains only in the `MCP_CATALOG_MINISIGN_PRIVATE_KEY`
release secret.
+658
View File
@@ -0,0 +1,658 @@
package main
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"unicode/utf8"
"reasonix/internal/shellparse"
)
type diffOpts struct {
bin, model, repo, base, testCmd, profile string
maxSteps, timeoutSec, attempts int
}
type testRef struct{ name, pkg string }
// pinResult records whether one generated test fails when the PR's source is
// reverted (so it pins the change) and, if so, whether it failed by assertion
// (strong: it checks the new behavior) or only by compile error (weak: it just
// references a symbol the PR added).
type pinResult struct {
testRef
pins bool
byAssertion bool
}
// runDiff asks the agent to write tests covering what the PR changed, grades
// them against the repo's own tests, and — because the agent is stochastic —
// retries up to o.attempts times until a run passes, keeping the best result.
func runDiff(o diffOpts) string {
srcFiles := changedGoFiles(o.repo, o.base, false)
if len(srcFiles) == 0 {
profile := o.profile
if profile == "" {
profile = benchmarkProfileBaseline
}
return fmt.Sprintf("## 🤖 Reasonix e2e — diff test-gen (%s)\n\nNo Go source changes in this PR (excluding `_test.go`); nothing to generate tests for.\n", profile)
}
pkgs := packagesOf(srcFiles)
prompt := buildDiffPrompt(srcFiles, pkgs, truncate(gitOut(o.repo, "diff", o.base+"...HEAD", "--")))
attempts := o.attempts
if attempts < 1 {
attempts = 1
}
var best diffReport
made := 0
for i := 1; i <= attempts; i++ {
if i > 1 {
resetTree(o.repo)
}
r := runOnce(o, srcFiles, pkgs, prompt)
made = i
if i == 1 || better(r, best) {
best = r
}
if best.passed {
break // stop at the first passing run; attempts is a retry budget
}
}
best.attempt, best.attempts = made, attempts
return renderDiff(best)
}
// runOnce does one agent run + grade: generate tests, check they pass on HEAD,
// differential-check each against the reverted source, measure changed-line
// coverage, and confirm the agent didn't break the build anywhere.
func runOnce(o diffOpts, srcFiles, pkgs []string, prompt string) diffReport {
metricsPath := filepath.Join(o.repo, ".e2e-diff-metrics.json")
_ = os.Remove(metricsPath)
defer os.Remove(metricsPath)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(o.timeoutSec)*time.Second)
defer cancel()
args := []string{"run", "--metrics", metricsPath, "--max-steps", fmt.Sprint(o.maxSteps)}
if o.model != "" {
args = append(args, "--model", o.model)
}
args = appendBenchmarkProfileArgs(args, o.profile)
args = append(args, prompt)
cmd := exec.CommandContext(ctx, o.bin, args...)
cmd.Dir = o.repo
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
cmd.WaitDelay = 10 * time.Second // bound the wait for a wedged child after ctx timeout
runErr := cmd.Run()
// The agent's new files are untracked, so `git diff HEAD` would miss them;
// intent-to-add surfaces them as additions without committing.
_ = exec.Command("git", "-C", o.repo, "add", "-AN").Run()
m, _ := readMetrics(metricsPath)
testDiff := gitOut(o.repo, "diff", "HEAD", "--", "*_test.go")
refs := parseNewTests(testDiff)
sourceTouched := len(changedGoFilesWorktree(o.repo, false))
testsPass, testOut := runTests(o.repo, o.testCmd, pkgs)
var pins []pinResult
var mut mutationResult
covered, coverTotal := 0, 0
if len(refs) > 0 && testsPass {
covered, coverTotal = changedLineCoverage(o.repo, o.base, pkgs, srcFiles)
pins = differentialPerTest(o.repo, o.base, srcFiles, refs)
mut = runMutation(o.repo, o.base, srcFiles, refs)
}
buildOK, buildOut := goBuildAll(o.repo)
passed := len(refs) > 0 && testsPass && buildOK && countPins(pins) > 0
return diffReport{
srcFiles: srcFiles, pkgs: pkgs, addedTestLines: countAdded(testDiff),
newTests: refs, sourceTouched: sourceTouched, testsPass: testsPass,
pins: pins, mut: mut, covered: covered, coverTotal: coverTotal,
buildOK: buildOK, buildOut: buildOut, failing: failingTestNames(testOut),
passed: passed, profile: o.profile, m: m, runErr: runErr, testOut: testOut, testDiff: testDiff,
}
}
// better reports whether candidate a is a stronger result than b: a pass beats a
// fail, then more assertion-pins, then more pins, then higher changed-line
// coverage.
func better(a, b diffReport) bool {
if a.passed != b.passed {
return a.passed
}
if x, y := countAssertionPins(a.pins), countAssertionPins(b.pins); x != y {
return x > y
}
if x, y := countPins(a.pins), countPins(b.pins); x != y {
return x > y
}
if a.mut.caught != b.mut.caught {
return a.mut.caught > b.mut.caught
}
return ratio(a.covered, a.coverTotal) > ratio(b.covered, b.coverTotal)
}
func ratio(n, d int) float64 {
if d == 0 {
return 0
}
return float64(n) / float64(d)
}
// resetTree restores the PR-head tree between attempts, dropping the previous
// attempt's generated tests but keeping the provider config the workflow wrote.
func resetTree(repo string) {
_ = exec.Command("git", "-C", repo, "checkout", "--", ".").Run()
_ = exec.Command("git", "-C", repo, "clean", "-fd", "-e", "reasonix.toml").Run()
}
func goBuildAll(repo string) (bool, string) {
cmd := exec.Command("go", "build", "./...")
cmd.Dir = repo
cmd.WaitDelay = 2 * time.Minute // bound the wait if `go build` hangs
out, err := cmd.CombinedOutput()
return err == nil, string(out)
}
func buildDiffPrompt(srcFiles, pkgs []string, diffText string) string {
var b strings.Builder
b.WriteString("You are in a Go repository. This pull request changed these source files:\n")
for _, f := range srcFiles {
fmt.Fprintf(&b, " - %s\n", f)
}
b.WriteString("\nUnified diff of the change:\n```diff\n")
b.WriteString(diffText)
b.WriteString("\n```\n\n")
b.WriteString("Write focused Go unit tests that exercise the NEW or CHANGED behavior in those files. ")
b.WriteString("Add them to the appropriate *_test.go files in the same packages (")
b.WriteString(strings.Join(pkgs, ", "))
b.WriteString("). Do NOT modify the non-test source files — only add or extend test files. ")
b.WriteString("Prefer small, focused edits and run `gofmt`/`go vet` on the test files as you go to avoid syntax errors. ")
b.WriteString("Then run the package tests and iterate until they pass. When finished, list the test functions you added.")
return b.String()
}
type diffReport struct {
srcFiles, pkgs []string
addedTestLines int
newTests []testRef
sourceTouched int
testsPass bool
pins []pinResult
mut mutationResult
covered, coverTotal int
buildOK bool
buildOut string
failing []string
passed bool
profile string
attempt, attempts int
m runMetrics
runErr error
testOut string
testDiff string
}
func renderDiff(r diffReport) string {
var b strings.Builder
result := "❌ fail"
if r.passed {
result = "✅ pass"
}
profile := r.profile
if profile == "" {
profile = benchmarkProfileBaseline
}
fmt.Fprintf(&b, "## 🤖 Reasonix e2e — diff test-gen (%s)\n\n", profile)
fmt.Fprintf(&b, "**Result:** %s · **%d** changed source file(s) across **%d** package(s)\n\n", result, len(r.srcFiles), len(r.pkgs))
pinned, byAssert := countPins(r.pins), countAssertionPins(r.pins)
fmt.Fprintf(&b, "| Metric | Value |\n|---|---|\n")
fmt.Fprintf(&b, "| New test functions added | %d |\n", len(r.newTests))
fmt.Fprintf(&b, "| Test lines added | +%d |\n", r.addedTestLines)
fmt.Fprintf(&b, "| `go test` on affected pkgs | %s |\n", passFail(r.testsPass))
fmt.Fprintf(&b, "| Differential (fail on pre-PR code) | %s |\n", differentialCell(r))
if pinned > 0 {
fmt.Fprintf(&b, "| ↳ pin by assertion / by compile only | %d / %d |\n", byAssert, pinned-byAssert)
}
fmt.Fprintf(&b, "| Changed-line coverage | %s |\n", coverageCell(r))
fmt.Fprintf(&b, "| Mutation (changed funcs caught) | %s |\n", mutationCell(r))
fmt.Fprintf(&b, "| `go build ./...` (regression) | %s |\n", passFail(r.buildOK))
fmt.Fprintf(&b, "| Non-test source touched by agent | %d file(s) |\n", r.sourceTouched)
fmt.Fprintf(&b, "| Cache hit | %s |\n", pct(r.m.CacheHitTokens, r.m.CacheHitTokens+r.m.CacheMissTokens))
fmt.Fprintf(&b, "| Tokens (prompt / completion) | %s / %s |\n", comma(r.m.PromptTokens), comma(r.m.CompletionTokens))
fmt.Fprintf(&b, "| Model calls | %d |\n", r.m.Steps)
fmt.Fprintf(&b, "| Cost | %s%.4f |\n", currencySym(r.m.Currency), r.m.Cost)
if r.m.CapabilityRoutes > 0 || r.m.CapabilitySkillInvocations > 0 || r.m.CapabilityMCPCall > 0 || r.m.ReadinessChecks > 0 {
fmt.Fprintf(&b, "| Capability routes (semantic) | %d (%d) |\n", r.m.CapabilityRoutes, r.m.CapabilitySemanticRoutes)
fmt.Fprintf(&b, "| Routed candidates (require / prefer / suggest / declined) | %d (%d / %d / %d / %d) |\n", r.m.CapabilityRoutedCandidates, r.m.CapabilityRoutedRequire, r.m.CapabilityRoutedPrefer, r.m.CapabilityRoutedSuggest, r.m.CapabilityDeclines)
fmt.Fprintf(&b, "| Skill invocations / MCP proxy calls | %d / %d |\n", r.m.CapabilitySkillInvocations, r.m.CapabilityMCPCall)
fmt.Fprintf(&b, "| Review blocks / readiness recoveries | %d / %d |\n", r.m.CapabilityReviewBlocks, r.m.ReadinessRecoveries)
if r.m.CapabilityRouterCost > 0 || r.m.CapabilityRouterLatencyMs > 0 {
fmt.Fprintf(&b, "| Capability-router cost / latency | %s%.4f / %dms |\n", currencySym(r.m.Currency), r.m.CapabilityRouterCost, r.m.CapabilityRouterLatencyMs)
}
}
if len(r.failing) > 0 {
fmt.Fprintf(&b, "| Failing tests | `%s` |\n", strings.Join(r.failing, "`, `"))
}
if r.attempts > 1 {
status := "none passed"
if r.passed {
status = "passed"
}
fmt.Fprintf(&b, "| Attempts | %d of up to %d (%s) |\n", r.attempt, r.attempts, status)
}
fmt.Fprintf(&b, "\n**Packages:** %s\n", strings.Join(r.pkgs, ", "))
if r.attempts <= 1 {
fmt.Fprintf(&b, "\n<sub>Single stochastic run — a green result is one sample, not a guarantee. Comment `/e2e diff x3` to retry up to 3×.</sub>\n")
}
if !r.buildOK && strings.TrimSpace(r.buildOut) != "" {
fmt.Fprintf(&b, "\n<details><summary>go build ./... output (tail)</summary>\n\n```\n%s\n```\n</details>\n", tail(r.buildOut, 40))
}
if r.sourceTouched > 0 {
fmt.Fprintf(&b, "\n⚠️ The agent modified %d non-test source file(s); a green run may not reflect the PR's code. Review the diff.\n", r.sourceTouched)
}
if len(r.pins) > 0 {
fmt.Fprintf(&b, "\n<details><summary>Per-test differential</summary>\n\n| Test | Package | Pins the change? |\n|---|---|---|\n")
for _, p := range r.pins {
fmt.Fprintf(&b, "| `%s` | %s | %s |\n", p.name, p.pkg, pinCell(p))
}
fmt.Fprintf(&b, "\n</details>\n")
}
if strings.TrimSpace(r.testDiff) != "" {
fmt.Fprintf(&b, "\n<details><summary>Generated tests (review the assertions)</summary>\n\n```diff\n%s\n```\n</details>\n", truncateFor(r.testDiff, 20000))
}
if !r.testsPass && strings.TrimSpace(r.testOut) != "" {
fmt.Fprintf(&b, "\n<details><summary>go test output (tail)</summary>\n\n```\n%s\n```\n</details>\n", tail(r.testOut, 60))
}
if r.runErr != nil {
fmt.Fprintf(&b, "\n<sub>agent run note: %v</sub>\n", r.runErr)
}
fmt.Fprintf(&b, "\n<sub>Pass = the agent added ≥1 test, the affected packages are green, AND ≥1 new test fails when the PR's source is reverted. \"By assertion\" pins are strong (they check changed behavior); \"by compile only\" pins just need a PR-added symbol — and since Go compiles per package, one compile-coupled test marks every test in its package that way. Mutation is the behavioral signal for additive PRs: each changed function's return is replaced with zero values and the new tests are re-run; \"caught\" means a test asserts that output, \"survived\" means it doesn't. Read the generated tests above to judge the rest.</sub>\n")
return b.String()
}
func differentialCell(r diffReport) string {
if !(len(r.newTests) > 0 && r.testsPass) {
return "n/a (tests not green)"
}
return fmt.Sprintf("%d/%d new tests", countPins(r.pins), len(r.pins))
}
func coverageCell(r diffReport) string {
if r.coverTotal == 0 {
return "n/a"
}
return fmt.Sprintf("%s (%d/%d changed lines)", pct(r.covered, r.coverTotal), r.covered, r.coverTotal)
}
func mutationCell(r diffReport) string {
if r.mut.total == 0 {
return "n/a"
}
cell := fmt.Sprintf("%d/%d (%s)", r.mut.caught, r.mut.total, pct(r.mut.caught, r.mut.total))
if len(r.mut.survivors) > 0 {
cell += fmt.Sprintf(" · survived: `%s`", strings.Join(r.mut.survivors, "`, `"))
}
return cell
}
func pinCell(p pinResult) string {
switch {
case p.pins && p.byAssertion:
return "✅ by assertion"
case p.pins:
return "⚠️ by compile only"
default:
return "❌ no (passes on old code)"
}
}
// differentialPerTest reverts the PR's changed source to base (deleting files
// new in the PR), runs each generated test on its own against the old code, and
// restores the source. A test that fails on the old code pins the change.
func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) []pinResult {
for _, f := range srcFiles {
if err := exec.Command("git", "-C", repo, "checkout", base, "--", f).Run(); err != nil {
_ = os.Remove(filepath.Join(repo, filepath.FromSlash(f)))
}
}
// Restore source even on panic; a tree left on `base` would mask the PR for later steps.
restored := false
defer func() {
if restored {
return
}
for _, f := range srcFiles {
_ = exec.Command("git", "-C", repo, "checkout", "HEAD", "--", f).Run()
}
}()
out := make([]pinResult, 0, len(refs))
for _, r := range refs {
cmd := exec.Command("go", "test", "-run", "^"+r.name+"$", r.pkg)
cmd.Dir = repo
cmd.WaitDelay = 2 * time.Minute // bound the wait for a hung test
raw, err := cmd.CombinedOutput()
out = append(out, pinResult{
testRef: r,
pins: err != nil,
byAssertion: strings.Contains(string(raw), "--- FAIL: "+r.name),
})
}
for _, f := range srcFiles {
_ = exec.Command("git", "-C", repo, "checkout", "HEAD", "--", f).Run()
}
restored = true
return out
}
// changedLineCoverage runs the affected packages with a coverage profile and
// reports how many of the PR's changed source statement-lines the (new+existing)
// tests actually execute. covered/total are over changed lines that fall inside
// a coverage block; lines that aren't statements are ignored.
func changedLineCoverage(repo, base string, pkgs, srcFiles []string) (covered, total int) {
profile := filepath.Join(repo, ".e2e-cover.out")
defer os.Remove(profile)
args := append([]string{"test", "-covermode=set", "-coverprofile=" + profile, "-coverpkg=" + strings.Join(pkgs, ",")}, pkgs...)
cmd := exec.Command("go", args...)
cmd.Dir = repo
_ = cmd.Run() // a non-zero exit still writes the profile for the tests that ran
blocks := parseCoverProfile(repo, profile)
for file, lines := range changedLineSet(repo, base, srcFiles) {
fileBlocks := blocks[file]
for ln := range lines {
for _, blk := range fileBlocks {
if ln >= blk.start && ln <= blk.end {
total++
if blk.count > 0 {
covered++
}
break
}
}
}
}
return covered, total
}
type coverBlock struct {
start, end, count int
}
// parseCoverProfile reads a Go coverage profile, keyed by repo-relative file path
// (the profile uses module-qualified paths; we match by repo-relative suffix).
func parseCoverProfile(repo, path string) map[string][]coverBlock {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
out := map[string][]coverBlock{}
for _, ln := range strings.Split(string(data), "\n") {
if ln == "" || strings.HasPrefix(ln, "mode:") {
continue
}
colon := strings.LastIndexByte(ln, ':')
if colon < 0 {
continue
}
modPath, rest := ln[:colon], ln[colon+1:]
var sl, sc, el, ec, nstmt, count int
if _, err := fmt.Sscanf(rest, "%d.%d,%d.%d %d %d", &sl, &sc, &el, &ec, &nstmt, &count); err != nil {
continue
}
rel := repoRelFromModulePath(modPath)
out[rel] = append(out[rel], coverBlock{start: sl, end: el, count: count})
}
return out
}
// repoRelFromModulePath turns "reasonix/internal/agent/foo.go" into
// "internal/agent/foo.go" by dropping the first path element (the module root).
func repoRelFromModulePath(p string) string {
// Strip the full module prefix; a generic first-segment cut mis-strips a multi-segment module path.
prefix := "reasonix/"
if strings.HasPrefix(p, prefix) {
return p[len(prefix):]
}
if i := strings.IndexByte(p, '/'); i >= 0 {
return p[i+1:]
}
return p
}
// changedLineSet returns, per repo-relative source file, the set of new line
// numbers the PR added or changed (from a zero-context diff).
func changedLineSet(repo, base string, srcFiles []string) map[string]map[int]bool {
args := append([]string{"diff", "--unified=0", base + "...HEAD", "--"}, srcFiles...)
diff := gitOut(repo, args...)
out := map[string]map[int]bool{}
file := ""
newLine := 0
for _, ln := range strings.Split(diff, "\n") {
// '-' (deletion) lines are intentionally unhandled: they don't advance the
// new-side line counter, so they fall through with no case.
switch {
case strings.HasPrefix(ln, "+++ b/"):
file = strings.TrimPrefix(ln, "+++ b/")
out[file] = map[int]bool{}
case strings.HasPrefix(ln, "@@"):
// @@ -a,b +c,d @@ — start collecting at new-side line c.
// Digit-only cut: malformed headers (e.g. `@@ +abc @@`) fail closed.
if plus := strings.Index(ln, "+"); plus >= 0 {
num := ln[plus+1:]
end := len(num)
for i := 0; i < len(num); i++ {
if num[i] < '0' || num[i] > '9' {
end = i
break
}
}
_, _ = fmt.Sscanf(num[:end], "%d", &newLine)
}
case strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++"):
if file != "" {
out[file][newLine] = true
}
newLine++
}
}
return out
}
func countPins(ps []pinResult) int {
n := 0
for _, p := range ps {
if p.pins {
n++
}
}
return n
}
func countAssertionPins(ps []pinResult) int {
n := 0
for _, p := range ps {
if p.pins && p.byAssertion {
n++
}
}
return n
}
// parseNewTests reads the working-tree *_test.go diff and returns the Test/Fuzz/
// Benchmark functions the agent added, each tagged with its package directory.
func parseNewTests(diff string) []testRef {
var refs []testRef
pkg := ""
for _, ln := range strings.Split(diff, "\n") {
if strings.HasPrefix(ln, "+++ b/") {
pkg = "./" + filepath.ToSlash(filepath.Dir(strings.TrimPrefix(ln, "+++ b/")))
continue
}
if !strings.HasPrefix(ln, "+") || strings.HasPrefix(ln, "+++") {
continue
}
body := strings.TrimSpace(ln[1:])
if !strings.HasPrefix(body, "func ") {
continue
}
sig := strings.TrimPrefix(body, "func ")
// Method form `(r T) Name(...)` starts with '('; parse the receiver out before the name.
var name string
if sig[0] == '(' {
close := strings.IndexByte(sig, ')')
if close < 0 {
continue
}
rest := strings.TrimSpace(sig[close+1:])
methodParen := strings.IndexByte(rest, '(')
if methodParen <= 0 {
continue
}
name = rest[:methodParen]
} else {
funcParen := strings.IndexByte(sig, '(')
if funcParen <= 0 {
continue
}
name = sig[:funcParen]
}
if strings.HasPrefix(name, "Test") || strings.HasPrefix(name, "Fuzz") || strings.HasPrefix(name, "Benchmark") {
refs = append(refs, testRef{name: name, pkg: pkg})
}
}
return refs
}
func countAdded(diff string) int {
n := 0
for _, ln := range strings.Split(diff, "\n") {
if strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++") {
n++
}
}
return n
}
// failingTestNames pulls the names out of `--- FAIL: TestX (…)` lines.
func failingTestNames(out string) []string {
var names []string
seen := map[string]bool{}
for _, ln := range strings.Split(out, "\n") {
ln = strings.TrimSpace(ln)
if !strings.HasPrefix(ln, "--- FAIL:") {
continue
}
rest := strings.Fields(strings.TrimSpace(strings.TrimPrefix(ln, "--- FAIL:")))
if len(rest) > 0 && !seen[rest[0]] {
seen[rest[0]] = true
names = append(names, rest[0])
}
}
return names
}
func runTests(repo, testCmd string, pkgs []string) (bool, string) {
test, err := shellparse.ParseStaticCommand(testCmd, shellparse.StaticCommandPolicy{AllowEnvAssignments: true, AllowStderrToStdout: true})
if err != nil {
return false, "invalid test command: " + err.Error()
}
fields := test.Argv
if len(fields) == 0 {
fields = []string{"go", "test"}
}
args := append(fields[1:], pkgs...)
cmd := exec.Command(fields[0], args...)
cmd.Dir = repo
if len(test.Env) > 0 {
cmd.Env = append(os.Environ(), test.Env...)
}
cmd.WaitDelay = 5 * time.Minute // bound the wait if `go test` hangs
out, err := cmd.CombinedOutput()
return err == nil, string(out)
}
// changedGoFiles lists .go files changed by base...HEAD, excluding *_test.go
// when includeTests is false (we want the source under test).
func changedGoFiles(repo, base string, includeTests bool) []string {
return filterGo(gitOut(repo, "diff", "--name-only", base+"...HEAD", "--", "*.go"), includeTests)
}
func changedGoFilesWorktree(repo string, includeTests bool) []string {
return filterGo(gitOut(repo, "diff", "--name-only", "HEAD", "--", "*.go"), includeTests)
}
func filterGo(out string, includeTests bool) []string {
var keep []string
for _, f := range strings.Fields(strings.ReplaceAll(out, "\n", " ")) {
if strings.HasSuffix(f, "_test.go") && !includeTests {
continue
}
keep = append(keep, f)
}
sort.Strings(keep)
return keep
}
func packagesOf(files []string) []string {
seen := map[string]bool{}
var pkgs []string
for _, f := range files {
dir := "./" + filepath.ToSlash(filepath.Dir(f))
if !seen[dir] {
seen[dir] = true
pkgs = append(pkgs, dir)
}
}
sort.Strings(pkgs)
return pkgs
}
func gitOut(repo string, args ...string) string {
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
out, _ := cmd.Output()
return string(out)
}
func truncate(s string) string { return truncateFor(s, 12000) }
func truncateFor(s string, max int) string {
if max <= 0 || len(s) <= max {
return s
}
// Back the cut up to a rune boundary so we don't split a multi-byte UTF-8 rune.
cut := max
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut] + "\n…(truncated)…"
}
func tail(s string, n int) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return strings.Join(lines, "\n")
}
func passFail(ok bool) string {
if ok {
return "pass"
}
return "fail"
}
+48
View File
@@ -0,0 +1,48 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestRunTestsAllowsStaticEnvAssignment(t *testing.T) {
repo := t.TempDir()
writeE2EFile(t, filepath.Join(repo, "go.mod"), "module example.com/e2ebenchtest\n\ngo 1.23\n")
writeE2EFile(t, filepath.Join(repo, "env_test.go"), `package e2ebenchtest
import (
"os"
"testing"
)
func TestEnv(t *testing.T) {
if got := os.Getenv("REASONIX_E2E_ENV"); got != "ok" {
t.Fatalf("REASONIX_E2E_ENV = %q", got)
}
}
`)
ok, out := runTests(repo, "GOWORK=off REASONIX_E2E_ENV=ok go test", []string{"./..."})
if !ok {
t.Fatalf("runTests failed:\n%s", out)
}
}
func TestRunTestsRejectsDynamicEnvAssignment(t *testing.T) {
ok, out := runTests(t.TempDir(), "REASONIX_E2E_ENV=$(echo ok) go test", []string{"./..."})
if ok {
t.Fatal("runTests accepted dynamic env assignment")
}
if !strings.Contains(out, "invalid test command: shell expansion") {
t.Fatalf("output = %q, want shell expansion rejection", out)
}
}
func writeE2EFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
+430
View File
@@ -0,0 +1,430 @@
// e2ebench runs the committed e2e task suite against a real provider and emits a
// markdown + JSON report (accuracy, cache-hit rate, token use, cost) for a PR.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/BurntSushi/toml"
fileencoding "reasonix/internal/fileutil/encoding"
)
type task struct {
ID string
Prompt string `toml:"prompt"`
MaxSteps int `toml:"max_steps"`
TimeoutSec int `toml:"timeout_sec"`
dir string
}
type runMetrics struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
CacheHitTokens int `json:"cache_hit_tokens"`
CacheMissTokens int `json:"cache_miss_tokens"`
Steps int `json:"steps"`
Cost float64 `json:"cost"`
Currency string `json:"currency"`
Compactions int `json:"compactions"`
// Optional Delivery capability counters (omitempty for baseline/old metrics).
ReadinessChecks int `json:"readiness_checks,omitempty"`
ReadinessRecoveries int `json:"readiness_recoveries,omitempty"`
CapabilityRoutes int `json:"capability_routes,omitempty"`
CapabilityRoutedCandidates int `json:"capability_routed_candidates,omitempty"`
CapabilityRoutedRequire int `json:"capability_routed_require,omitempty"`
CapabilityRoutedPrefer int `json:"capability_routed_prefer,omitempty"`
CapabilityRoutedSuggest int `json:"capability_routed_suggest,omitempty"`
CapabilityDeclines int `json:"capability_declines,omitempty"`
CapabilitySemanticRoutes int `json:"capability_semantic_routes,omitempty"`
CapabilitySkillInvocations int `json:"capability_skill_invocations,omitempty"`
CapabilityMCPCall int `json:"capability_mcp_call,omitempty"`
CapabilityReviewBlocks int `json:"capability_review_blocks,omitempty"`
CapabilityRouterCost float64 `json:"capability_router_cost,omitempty"`
CapabilityRouterLatencyMs int64 `json:"capability_router_latency_ms,omitempty"`
}
type result struct {
task
runMetrics
Profile string `json:"profile"`
Passed bool
Skipped bool
Note string
}
func main() {
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), "e2ebench — Reasonix end-to-end benchmark.\n\n")
fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", flag.CommandLine.Name())
flag.PrintDefaults()
fmt.Fprintf(flag.CommandLine.Output(), "\nExamples:\n")
fmt.Fprintf(flag.CommandLine.Output(), " # Run the committed suite:\n")
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s\n\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
fmt.Fprintf(flag.CommandLine.Output(), " # Grade a PR's diff with a retry budget:\n")
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -mode diff -base origin/main -repo . -attempts 3 -timeout 1800\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
fmt.Fprintf(flag.CommandLine.Output(), "\n # Run the same suite with the delivery contract:\n")
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -profile delivery\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
}
mode := flag.String("mode", "suite", "suite | diff (diff = generate tests for the PR diff and grade with the repo's tests)")
suite := flag.String("suite", "benchmarks/e2e", "suite root (contains tasks/<id>/)")
bin := flag.String("bin", "reasonix", "path to the reasonix binary")
model := flag.String("model", "", "provider/model name (default: config default)")
profileFlag := flag.String("profile", benchmarkProfileBaseline, "prompt profile: baseline | delivery")
outMD := flag.String("out", "", "write the markdown report here (default: stdout)")
outJSON := flag.String("json", "", "write the JSON report here (optional)")
budget := flag.Int("budget", 400_000, "abort once total tokens cross this (0 = no cap)")
// diff-mode flags
repo := flag.String("repo", ".", "repo root (diff mode)")
base := flag.String("base", "", "base ref to diff the PR head against (diff mode)")
testCmd := flag.String("test-cmd", "go test", "grader command run on the affected packages (diff mode)")
maxSteps := flag.Int("max-steps", 80, "agent tool-call cap for the diff task")
timeoutSec := flag.Int("timeout", 1200, "agent timeout in seconds (diff mode)")
attempts := flag.Int("attempts", 1, "diff mode: retry up to N times until a run passes (stochastic agent)")
flag.Parse()
profile, err := normalizeBenchmarkProfile(*profileFlag)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if *mode == "diff" {
report := runDiff(diffOpts{
bin: *bin, model: *model, repo: *repo, base: *base,
testCmd: *testCmd, profile: profile, maxSteps: *maxSteps, timeoutSec: *timeoutSec, attempts: *attempts,
})
emit(report, *outMD, "")
return
}
tasks, err := loadTasks(*suite)
if err != nil {
fmt.Fprintln(os.Stderr, "load suite:", err)
os.Exit(1)
}
if len(tasks) == 0 {
dir := filepath.Join(*suite, "tasks")
if _, statErr := os.Stat(dir); statErr != nil {
fmt.Fprintf(os.Stderr, "no tasks found under %s: %v\n", dir, statErr)
} else {
fmt.Fprintf(os.Stderr, "no tasks found under %s (the directory exists but contains no task.toml files)\n", dir)
}
os.Exit(1)
}
var results []result
total := 0
for _, t := range tasks {
if *budget > 0 && total >= *budget {
results = append(results, result{task: t, Profile: profile, Skipped: true, Note: "skipped: token budget reached"})
continue
}
r := runTask(*bin, *model, profile, t)
total += r.PromptTokens + r.CompletionTokens
results = append(results, r)
}
report := render(results)
if *outMD != "" {
if err := os.WriteFile(*outMD, []byte(report), 0o644); err != nil {
fmt.Fprintln(os.Stderr, "write report:", err)
os.Exit(1)
}
} else {
fmt.Print(report)
}
if *outJSON != "" {
b, err := json.MarshalIndent(results, "", " ")
if err != nil {
fmt.Fprintln(os.Stderr, "marshal json:", err)
os.Exit(1)
}
if err := os.WriteFile(*outJSON, b, 0o644); err != nil {
fmt.Fprintln(os.Stderr, "write json:", err)
os.Exit(1)
}
}
}
func emit(report, outMD, _ string) {
if outMD != "" {
if err := os.WriteFile(outMD, []byte(report), 0o644); err != nil {
fmt.Fprintln(os.Stderr, "write report:", err)
os.Exit(1)
}
return
}
fmt.Print(report)
}
func loadTasks(suite string) ([]task, error) {
tasksDir := filepath.Join(suite, "tasks")
entries, err := os.ReadDir(tasksDir)
if err != nil {
return nil, err
}
var tasks []task
for _, e := range entries {
if !e.IsDir() {
continue
}
dir := filepath.Join(tasksDir, e.Name())
var t task
data, err := fileencoding.ReadFileUTF8(filepath.Join(dir, "task.toml"))
if err != nil {
return nil, fmt.Errorf("%s: %w", e.Name(), err)
}
if _, err := toml.Decode(string(data), &t); err != nil {
return nil, fmt.Errorf("%s: %w", e.Name(), err)
}
t.ID = e.Name()
t.dir = dir
if t.TimeoutSec == 0 {
t.TimeoutSec = 240
}
tasks = append(tasks, t)
}
sort.Slice(tasks, func(i, j int) bool { return tasks[i].ID < tasks[j].ID })
return tasks, nil
}
// runTask copies the task's seed workdir into a temp dir, runs the agent there,
// then drops in verify.sh and runs it as the grader. The grader is added only
// after the run so the agent can't read the answer key.
func runTask(bin, model, profile string, t task) result {
r := result{task: t, Profile: profile}
work, err := os.MkdirTemp("", "e2ebench-"+t.ID+"-")
if err != nil {
r.Note = "mktemp: " + err.Error()
return r
}
defer os.RemoveAll(work)
if seed := filepath.Join(t.dir, "workdir"); dirExists(seed) {
if err := copyDir(seed, work); err != nil {
r.Note = "copy seed: " + err.Error()
return r
}
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(t.TimeoutSec)*time.Second)
defer cancel()
metricsPath := filepath.Join(work, ".run-metrics.json")
args := []string{"run", "--metrics", metricsPath}
if model != "" {
args = append(args, "--model", model)
}
if t.MaxSteps > 0 {
args = append(args, "--max-steps", fmt.Sprint(t.MaxSteps))
}
args = appendBenchmarkProfileArgs(args, profile)
args = append(args, t.Prompt)
cmd := exec.CommandContext(ctx, bin, args...)
cmd.Dir = work
cmd.Stdout = os.Stderr // stream the run to the job log, keep stdout clean for the report
cmd.Stderr = os.Stderr
cmd.WaitDelay = 10 * time.Second // bound the wait for a stuck child after ctx timeout
runErr := cmd.Run()
if m, err := readMetrics(metricsPath); err == nil {
r.runMetrics = m
}
if runErr != nil {
r.Note = "run: " + runErr.Error()
// still grade — a non-zero exit may just be a max-steps notice
}
r.Passed = grade(work, t.dir)
return r
}
func grade(work, taskDir string) bool {
verify := filepath.Join(taskDir, "verify.sh")
if !fileExists(verify) {
return false
}
dst := filepath.Join(work, "verify.sh")
if err := copyFile(verify, dst); err != nil {
return false
}
cmd := exec.Command("bash", "verify.sh")
cmd.Dir = work
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
return cmd.Run() == nil
}
func render(results []result) string {
var b strings.Builder
passed, ran := 0, 0
var pTok, cTok, hit, miss, compacts int
var cost float64
currency := ""
for _, r := range results {
if r.Skipped {
continue
}
ran++
if r.Passed {
passed++
}
pTok += r.PromptTokens
cTok += r.CompletionTokens
hit += r.CacheHitTokens
miss += r.CacheMissTokens
compacts += r.Compactions
cost += r.Cost
if r.Currency != "" {
currency = r.Currency
}
}
profile := benchmarkProfileBaseline
if len(results) > 0 && results[0].Profile != "" {
profile = results[0].Profile
}
fmt.Fprintf(&b, "## 🤖 Reasonix e2e benchmark (%s)\n\n", profile)
fmt.Fprintf(&b, "**Accuracy:** %d/%d (%s) · **Cache hit:** %s · **Tokens:** %s (prompt %s / completion %s) · **Compactions:** %d · **Cost:** %s%.4f\n\n",
passed, ran, pct(passed, ran), pct(hit, hit+miss),
comma(pTok+cTok), comma(pTok), comma(cTok), compacts, currencySym(currency), cost)
fmt.Fprintf(&b, "| Task | Result | Steps | Prompt | Completion | Cache hit | Compact | Cost |\n")
fmt.Fprintf(&b, "|------|--------|------:|-------:|-----------:|----------:|--------:|-----:|\n")
for _, r := range results {
switch {
case r.Skipped:
fmt.Fprintf(&b, "| `%s` | ⏭️ skipped | — | — | — | — | — | — |\n", r.ID)
default:
res := "❌ fail"
if r.Passed {
res = "✅ pass"
}
fmt.Fprintf(&b, "| `%s` | %s | %d | %s | %s | %s | %d | %s%.4f |\n",
r.ID, res, r.Steps, comma(r.PromptTokens), comma(r.CompletionTokens),
pct(r.CacheHitTokens, r.CacheHitTokens+r.CacheMissTokens),
r.Compactions, currencySym(r.Currency), r.Cost)
}
}
fmt.Fprintf(&b, "\n<sub>Real provider run. Cache-hit %% is cached prompt tokens / total prompt tokens.</sub>\n")
notes := false
for _, r := range results {
if r.Note != "" {
if !notes {
fmt.Fprintf(&b, "\n<details><summary>Notes</summary>\n\n")
notes = true
}
fmt.Fprintf(&b, "- `%s`: %s\n", r.ID, r.Note)
}
}
if notes {
fmt.Fprintf(&b, "\n</details>\n")
}
return b.String()
}
func pct(n, d int) string {
if d == 0 {
return "n/a"
}
return fmt.Sprintf("%.0f%%", 100*float64(n)/float64(d))
}
func comma(n int) string {
s := fmt.Sprint(n)
if len(s) <= 3 {
return s
}
var out []byte
for i, c := range []byte(s) {
if i > 0 && (len(s)-i)%3 == 0 {
out = append(out, ',')
}
out = append(out, c)
}
return string(out)
}
func currencySym(c string) string {
if c == "" {
return ""
}
return c + " "
}
func readMetrics(path string) (runMetrics, error) {
var m runMetrics
b, err := fileencoding.ReadFileUTF8(path)
if err != nil {
return m, err
}
return m, json.Unmarshal(b, &m)
}
func dirExists(p string) bool {
fi, err := os.Stat(p)
return err == nil && fi.IsDir()
}
func fileExists(p string) bool {
fi, err := os.Stat(p)
return err == nil && !fi.IsDir()
}
func copyDir(src, dst string) error {
return filepath.Walk(src, func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Skip symlinks so a seed link can't leak a file from outside the seed tree.
if info.Mode()&os.ModeSymlink != 0 {
return nil
}
rel, _ := filepath.Rel(src, p)
target := filepath.Join(dst, rel)
if info.IsDir() {
return os.MkdirAll(target, 0o755)
}
return copyFile(p, target)
})
}
func copyFile(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
info, err := in.Stat()
if err != nil {
return err
}
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode().Perm())
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
// Mirror the source mode so a seed's read-only / exec bit survives the copy.
return os.Chmod(dst, info.Mode().Perm())
}
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"go/ast"
"go/parser"
"go/printer"
"go/token"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
const maxMutants = 15
type mutationResult struct {
caught, total int
survivors []string
}
// runMutation gives a behavioral signal that the differential can't for additive
// PRs: it replaces each changed function's body with a zero-value return (which
// compiles for any signature via *new(T)), runs only the agent's new tests for
// that package, and records whether they catch the mutation. A caught mutant
// means a test actually asserts that function's output; a survivor means the new
// tests don't check it.
func runMutation(repo, base string, srcFiles []string, refs []testRef) mutationResult {
changed := changedLineSet(repo, base, srcFiles)
byPkg := map[string][]string{}
for _, r := range refs {
byPkg[r.pkg] = append(byPkg[r.pkg], r.name)
}
var res mutationResult
for _, file := range srcFiles {
if res.total >= maxMutants {
break
}
pkg := "./" + filepath.ToSlash(filepath.Dir(file))
tests := byPkg[pkg]
if len(tests) == 0 {
continue // no new tests to attribute a catch to
}
runRe := "^(" + strings.Join(tests, "|") + ")$"
abs := filepath.Join(repo, filepath.FromSlash(file))
srcB, err := os.ReadFile(abs)
if err != nil {
continue
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, abs, srcB, 0)
if err != nil {
continue
}
src := string(srcB)
for _, fd := range changedFuncs(fset, f, changed[file]) {
if res.total >= maxMutants {
break
}
res.total++
lb := fset.Position(fd.Body.Lbrace).Offset
rb := fset.Position(fd.Body.Rbrace).Offset
mutated := src[:lb] + mutantBody(fset, fd.Type.Results) + src[rb+1:]
if os.WriteFile(abs, []byte(mutated), 0o644) != nil {
res.total--
continue
}
cmd := exec.Command("go", "test", "-run", runRe, pkg)
cmd.Dir = repo
cmd.WaitDelay = 2 * time.Minute // bound the wait for a mutant that wedges a test
// Restore source even on panic; a file left mutated would corrupt the next mutant.
restored := false
defer func() {
if !restored {
_ = os.WriteFile(abs, srcB, 0o644)
}
}()
caught := cmd.Run() != nil
_ = os.WriteFile(abs, srcB, 0o644)
restored = true
if caught {
res.caught++
} else {
res.survivors = append(res.survivors, fd.Name.Name)
}
}
}
return res
}
// changedFuncs returns the funcs in f whose line range overlaps a changed line.
// main/init and nil-named decls are skipped (no meaningful return to mutate).
func changedFuncs(fset *token.FileSet, f *ast.File, lines map[int]bool) []*ast.FuncDecl {
var out []*ast.FuncDecl
for _, decl := range f.Decls {
fd, ok := decl.(*ast.FuncDecl)
if !ok || fd.Body == nil || fd.Name == nil {
continue
}
name := fd.Name.Name
if name == "main" || name == "init" {
continue
}
start := fset.Position(fd.Pos()).Line
end := fset.Position(fd.End()).Line
for ln := range lines {
if ln >= start && ln <= end {
out = append(out, fd)
break
}
}
}
return out
}
// mutantBody returns a replacement body that returns the zero value for each
// result. *new(T) is the zero value of any type T, so this compiles for every
// signature without naming the results or knowing their concrete types.
func mutantBody(fset *token.FileSet, results *ast.FieldList) string {
if results == nil || len(results.List) == 0 {
return "{\n}"
}
var rets []string
for _, field := range results.List {
t := "*new(" + printType(fset, field.Type) + ")"
n := len(field.Names)
if n == 0 {
n = 1
}
for i := 0; i < n; i++ {
rets = append(rets, t)
}
}
return "{\n\treturn " + strings.Join(rets, ", ") + "\n}"
}
func printType(fset *token.FileSet, e ast.Expr) string {
var b strings.Builder
_ = printer.Fprint(&b, fset, e)
return b.String()
}
+29
View File
@@ -0,0 +1,29 @@
package main
import (
"fmt"
"strings"
)
const (
benchmarkProfileBaseline = "baseline"
benchmarkProfileDelivery = "delivery"
)
func normalizeBenchmarkProfile(profile string) (string, error) {
switch strings.ToLower(strings.TrimSpace(profile)) {
case "", benchmarkProfileBaseline:
return benchmarkProfileBaseline, nil
case benchmarkProfileDelivery:
return benchmarkProfileDelivery, nil
default:
return "", fmt.Errorf("unknown benchmark profile %q (want baseline or delivery)", profile)
}
}
func appendBenchmarkProfileArgs(args []string, profile string) []string {
if profile == benchmarkProfileDelivery {
return append(args, "--profile", "delivery")
}
return args
}
+36
View File
@@ -0,0 +1,36 @@
package main
import (
"reflect"
"testing"
)
func TestAppendBenchmarkProfileArgsBaselineIsByteIdentical(t *testing.T) {
args := []string{"run", "fix the bug"}
if got := appendBenchmarkProfileArgs(args, benchmarkProfileBaseline); !reflect.DeepEqual(got, args) {
t.Fatalf("baseline args changed: %v", got)
}
}
func TestAppendBenchmarkProfileArgsDeliveryUsesRealRuntimeProfile(t *testing.T) {
args := []string{"run"}
got := appendBenchmarkProfileArgs(args, benchmarkProfileDelivery)
want := []string{"run", "--profile", "delivery"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("delivery args = %v, want %v", got, want)
}
}
func TestNormalizeBenchmarkProfile(t *testing.T) {
for _, input := range []string{"", "baseline", " BASELINE "} {
if got, err := normalizeBenchmarkProfile(input); err != nil || got != benchmarkProfileBaseline {
t.Fatalf("normalize(%q) = %q, %v", input, got, err)
}
}
if got, err := normalizeBenchmarkProfile("delivery"); err != nil || got != benchmarkProfileDelivery {
t.Fatalf("normalize(delivery) = %q, %v", got, err)
}
if _, err := normalizeBenchmarkProfile("fast"); err == nil {
t.Fatal("unknown profile should fail")
}
}
+126
View File
@@ -0,0 +1,126 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"aead.dev/minisign"
"reasonix/internal/mcpcatalog"
)
func main() {
if len(os.Args) < 2 {
fail("usage: mcp-catalog validate|sign [flags]")
}
switch os.Args[1] {
case "validate":
validate(os.Args[2:])
case "sign":
sign(os.Args[2:])
default:
fail("unknown command %q", os.Args[1])
}
}
func validate(args []string) {
flags := flag.NewFlagSet("validate", flag.ExitOnError)
indexPath := flags.String("index", "internal/mcpcatalog/catalog-v1.json", "catalog source JSON")
packagesDir := flags.String("packages", "", "directory containing one package tree per catalog entry ID")
signaturePath := flags.String("signature", "", "optional minisign signature to verify")
_ = flags.Parse(args)
data := mustRead(*indexPath)
index, err := mcpcatalog.Parse(data)
if err != nil {
fail("validate catalog: %v", err)
}
if strings.TrimSpace(*signaturePath) != "" {
if _, err := mcpcatalog.Verify(data, mustRead(*signaturePath), mcpcatalog.PublicKeys); err != nil {
fail("verify catalog signature: %v", err)
}
}
if strings.TrimSpace(*packagesDir) != "" {
for _, entry := range index.Entries {
root := filepath.Join(*packagesDir, entry.ID)
digest, err := mcpcatalog.TreeSHA256(root)
if err != nil {
fail("hash package %s: %v", entry.ID, err)
}
if !strings.EqualFold(digest, entry.PackageSHA256) {
fail("package %s digest mismatch: got %s want %s", entry.ID, digest, entry.PackageSHA256)
}
manifest, err := manifestDigest(root)
if err != nil {
fail("hash package %s manifest: %v", entry.ID, err)
}
if !strings.EqualFold(manifest, entry.ManifestSHA256) {
fail("package %s manifest digest mismatch: got %s want %s", entry.ID, manifest, entry.ManifestSHA256)
}
}
}
fmt.Printf("validated MCP catalog sequence %d with %d entries\n", index.Sequence, len(index.Entries))
}
func sign(args []string) {
flags := flag.NewFlagSet("sign", flag.ExitOnError)
indexPath := flags.String("index", "internal/mcpcatalog/catalog-v1.json", "catalog source JSON")
outputPath := flags.String("output", "internal/mcpcatalog/catalog-v1.json.minisig", "signature output")
_ = flags.Parse(args)
data := mustRead(*indexPath)
if _, err := mcpcatalog.Parse(data); err != nil {
fail("validate catalog before signing: %v", err)
}
keyText := os.Getenv("MCP_CATALOG_MINISIGN_PRIVATE_KEY")
if strings.TrimSpace(keyText) == "" {
fail("MCP_CATALOG_MINISIGN_PRIVATE_KEY is required")
}
key, err := minisign.DecryptKey(os.Getenv("MCP_CATALOG_MINISIGN_PASSWORD"), []byte(keyText))
if err != nil {
var plain minisign.PrivateKey
if plainErr := plain.UnmarshalText([]byte(keyText)); plainErr != nil {
fail("parse catalog signing key: encrypted=%v plain=%v", err, plainErr)
}
key = plain
}
signature := minisign.Sign(key, data)
if err := os.WriteFile(*outputPath, signature, 0o600); err != nil {
fail("write signature: %v", err)
}
if _, err := mcpcatalog.Verify(data, signature, mcpcatalog.PublicKeys); err != nil {
_ = os.Remove(*outputPath)
fail("new signature does not match the app key ring: %v", err)
}
fmt.Printf("signed %s\n", *indexPath)
}
func manifestDigest(root string) (string, error) {
for _, rel := range []string{"reasonix-plugin.json", ".codex-plugin/plugin.json", ".claude-plugin/plugin.json"} {
body, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel)))
if err == nil {
sum := sha256.Sum256(body)
return hex.EncodeToString(sum[:]), nil
}
if !os.IsNotExist(err) {
return "", err
}
}
return "", fmt.Errorf("plugin manifest not found")
}
func mustRead(path string) []byte {
data, err := os.ReadFile(path)
if err != nil {
fail("read %s: %v", path, err)
}
return data
}
func fail(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}
+358
View File
@@ -0,0 +1,358 @@
package main
import (
"bufio"
"context"
"encoding/json"
"flag"
"fmt"
"os"
"strings"
"time"
"reasonix/internal/config"
"reasonix/internal/provider"
_ "reasonix/internal/provider/anthropic"
_ "reasonix/internal/provider/openai"
"reasonix/internal/repair"
)
func runAssist(args []string) int {
fs := flag.NewFlagSet("reasonix-guard assist", flag.ContinueOnError)
root := fs.String("root", ".", "project root to diagnose")
model := fs.String("model", "", "provider or provider/model used for the one-shot plan")
network := fs.Bool("network", true, "include provider connectivity diagnostics")
apply := fs.Bool("apply", false, "apply the validated plan after preview and confirmation")
yes := fs.Bool("yes", false, "confirm plan application non-interactively")
allowProject := fs.Bool("allow-project", false, "allow a plan to quarantine project reasonix.toml")
jsonOut := fs.Bool("json", false, "print the plan and preview as JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
report, err := repair.Diagnose(ctx, repair.DiagnoseOptions{Root: *root, Network: *network})
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
plan, err := requestRepairPlan(ctx, *root, *model, report)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
opts := repair.ApplyPlanOptions{Root: *root, AllowProject: *allowProject}
preview, err := repair.PreviewRepairPlan(plan, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
if code := printJSON(struct {
Plan repair.RepairPlan `json:"plan"`
Preview []repair.RepairPlanPreview `json:"preview"`
}{plan, preview}); code != 0 {
return code
}
} else {
printPlanPreview(plan, preview)
}
if !*apply {
return 0
}
if !*yes && !confirmPlan() {
fmt.Println("repair plan not applied")
return 0
}
result, err := repair.ApplyRepairPlan(plan, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
return printJSON(result)
}
for _, action := range result.Applied {
fmt.Println("applied:", action)
}
return 0
}
func runApplyPlan(args []string) int {
fs := flag.NewFlagSet("reasonix-guard apply-plan", flag.ContinueOnError)
root := fs.String("root", ".", "project root")
file := fs.String("file", "", "RepairPlan JSON file")
yes := fs.Bool("yes", false, "confirm plan application non-interactively")
allowProject := fs.Bool("allow-project", false, "allow project reasonix.toml repair")
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 || strings.TrimSpace(*file) == "" {
return 2
}
b, err := os.ReadFile(*file)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
plan, err := repair.DecodeRepairPlan(b)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
opts := repair.ApplyPlanOptions{Root: *root, AllowProject: *allowProject}
preview, err := repair.PreviewRepairPlan(plan, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if !*jsonOut {
printPlanPreview(plan, preview)
}
if !*yes && !confirmPlan() {
fmt.Println("repair plan not applied")
return 0
}
result, err := repair.ApplyRepairPlan(plan, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
return printJSON(result)
}
for _, action := range result.Applied {
fmt.Println("applied:", action)
}
return 0
}
func requestRepairPlan(ctx context.Context, root, modelRef string, report repair.DiagnosticReport) (repair.RepairPlan, error) {
cfg, err := loadAIProviderConfig(root)
if err != nil {
return repair.RepairPlan{}, err
}
if strings.TrimSpace(modelRef) == "" {
modelRef = cfg.DefaultModel
}
entry, ok := cfg.ResolveModel(modelRef)
if !ok {
return repair.RepairPlan{}, fmt.Errorf("AI assistance model %q is not configured", modelRef)
}
if entry.RequiresAPIKey() && entry.APIKey() == "" {
return repair.RepairPlan{}, fmt.Errorf("AI assistance provider %q has no configured API key", entry.Name)
}
p, err := provider.New(entry.Kind, provider.Config{
Name: entry.Name, BaseURL: entry.BaseURL, Model: entry.Model, APIKey: entry.APIKey(),
Extra: map[string]any{
"api_key_env": entry.APIKeyEnv, "api_key_source": entry.APIKeySourceLabel(),
"thinking": entry.Thinking, "effort": entry.Effort, "reasoning_protocol": entry.ReasoningProtocol,
"chat_url": entry.ChatURL, "headers": entry.Headers, "extra_body": entry.ExtraBody,
"auth_header": entry.AuthHeader, "proxy_spec": cfg.NetworkProxySpec(),
},
})
if err != nil {
return repair.RepairPlan{}, err
}
safeReport, snapshotAliases := providerSafeReportFrom(report)
payload, err := json.Marshal(safeReport)
if err != nil {
return repair.RepairPlan{}, err
}
stream, err := p.Stream(ctx, provider.Request{
Messages: []provider.Message{
{Role: provider.RoleSystem, Content: repairPlanSystemPrompt},
{Role: provider.RoleUser, Content: string(payload)},
},
Temperature: provider.TemperaturePtr(0),
MaxTokens: 2000,
})
if err != nil {
return repair.RepairPlan{}, err
}
var out strings.Builder
for chunk := range stream {
switch chunk.Type {
case provider.ChunkText:
out.WriteString(chunk.Text)
case provider.ChunkError:
if chunk.Err != nil {
return repair.RepairPlan{}, chunk.Err
}
}
}
plan, err := repair.DecodeRepairPlan([]byte(out.String()))
if err != nil {
return repair.RepairPlan{}, err
}
return resolveProviderSnapshotAliases(plan, snapshotAliases)
}
func loadAIProviderConfig(root string) (*config.Config, error) {
// Recovery tooling must never rewrite user configuration, so skip the
// on-disk legacy MCP tier migration that LoadForRoot performs.
if cfg, err := config.LoadForRootReadOnly(root); err == nil {
return cfg, nil
}
if snapshots, err := repair.ListConfigSnapshots(); err == nil {
for _, snapshot := range snapshots {
cfg, loadErr := config.LoadForEditReadOnlyStrict(snapshot.Path)
if loadErr == nil {
return cfg, nil
}
}
}
// Built-in recovery defaults resolve the global credential file without
// reading, migrating, or rewriting malformed user/project TOML.
return config.LoadRecoveryDefaultsForRoot(root), nil
}
const repairPlanSystemPrompt = `You are a Reasonix recovery planner. Return JSON only, matching exactly:
{"schemaVersion":1,"summary":"...","actions":[{"type":"...","scope":"","snapshotId":"","target":"","reason":"..."}]}
The user message is a diagnostic report whose findings carry only a severity, a machine code (e.g. config.invalid_toml, derived.invalid_json, update related codes), and a generic scope; pick actions from those codes.
Snapshot IDs are host-issued aliases ordered newest first; pendingUpdate=true means rollback_update is available.
Allowed actions only (return an empty actions array when no safe action applies):
- repair_config with scope global or project
- restore_snapshot with snapshotId
- rebuild_derived_state with target tabs, projects, window, zoom, or all
- rollback_update with no parameters
Never request shell commands, credential changes, session-content edits, arbitrary paths, plugin execution, or source-code changes. Prefer the smallest reversible plan supported by the diagnostics. Do not invent snapshot IDs.`
// providerSafeReport is the strict allowlist DTO sent to the AI provider.
// It deliberately carries no free-form diagnostic strings or state metadata:
// finding messages can quote config lines, URLs, commands, and permission
// rules, while snapshot and update metadata can be edited on disk. Only fixed
// diagnostic vocabulary, booleans, and host-issued snapshot aliases leave the
// machine.
type providerSafeReport struct {
Root string `json:"root"`
Network bool `json:"network"`
Snapshots []providerSafeSnapshot `json:"snapshots"`
PendingUpdate bool `json:"pendingUpdate"`
Findings []providerSafeFinding `json:"findings"`
}
type providerSafeSnapshot struct {
ID string `json:"id"`
}
type providerSafeFinding struct {
Severity string `json:"severity"`
Code string `json:"code"`
Scope string `json:"scope,omitempty"`
}
func providerSafeReportFrom(report repair.DiagnosticReport) (providerSafeReport, map[string]string) {
safe := providerSafeReport{
Root: "<project>",
Network: report.Network,
Snapshots: make([]providerSafeSnapshot, 0, len(report.Snapshots)),
PendingUpdate: report.PendingUpdate != nil,
Findings: make([]providerSafeFinding, 0, len(report.Findings)),
}
snapshotAliases := make(map[string]string, len(report.Snapshots))
for _, snapshot := range report.Snapshots {
if strings.TrimSpace(snapshot.ID) != "" {
alias := fmt.Sprintf("snapshot-%d", len(safe.Snapshots)+1)
safe.Snapshots = append(safe.Snapshots, providerSafeSnapshot{ID: alias})
snapshotAliases[alias] = snapshot.ID
}
}
for _, finding := range report.Findings {
safe.Findings = append(safe.Findings, providerSafeFinding{
Severity: providerSafeSeverity(finding.Severity),
Code: providerSafeDiagnosticCode(finding.Code),
Scope: providerSafeScope(finding.Scope),
})
}
return safe, snapshotAliases
}
func resolveProviderSnapshotAliases(plan repair.RepairPlan, aliases map[string]string) (repair.RepairPlan, error) {
resolved := plan
resolved.Actions = append([]repair.RepairPlanAction(nil), plan.Actions...)
for i := range resolved.Actions {
action := &resolved.Actions[i]
if action.Type != "restore_snapshot" {
continue
}
id, ok := aliases[action.SnapshotID]
if !ok {
return repair.RepairPlan{}, fmt.Errorf("AI repair plan referenced unknown snapshot alias %q", action.SnapshotID)
}
action.SnapshotID = id
}
return resolved, nil
}
func providerSafeSeverity(severity string) string {
switch severity {
case "error", "warning", "info":
return severity
default:
return "unknown"
}
}
func providerSafeDiagnosticCode(code string) string {
switch code {
case "config.invalid_toml", "config.load_failed",
"provider.missing_name", "provider.duplicate_name", "provider.unsupported_kind",
"provider.no_models", "provider.invalid_url", "provider.invalid_models_url",
"provider.invalid_key_name", "provider.missing_key",
"model.no_default", "model.invalid_default",
"plugin.missing_name", "plugin.duplicate_name", "plugin.missing_command",
"plugin.command_missing", "plugin.invalid_url", "plugin.invalid_type",
"permissions.conflict", "file.permissions",
"directory.unavailable", "directory.unreadable", "directory.not_directory", "directory.not_writable",
"derived.invalid_json",
"network.invalid_proxy", "network.client_failed", "network.unreachable",
"network.authentication_failed", "network.ok", "network.unexpected_status":
return code
default:
return "unknown"
}
}
// providerSafeScope generalizes a finding scope to a closed vocabulary.
// derived:* names survive because rebuild_derived_state needs the target and
// the suffixes are a fixed enum; provider:*/plugin:* carry user-chosen names
// and collapse to their category; anything unrecognized becomes "other".
func providerSafeScope(scope string) string {
scope = strings.TrimSpace(scope)
switch {
case scope == "":
return ""
case strings.HasPrefix(scope, "derived:"):
switch scope[len("derived:"):] {
case "tabs", "projects", "window", "zoom":
return scope
}
return "derived:other"
case strings.HasPrefix(scope, "provider:"):
return "provider"
case strings.HasPrefix(scope, "plugin:"):
return "plugin"
}
switch scope {
case "global", "project", "runtime", "network", "model", "provider", "plugin", "permissions",
"global config", "credential file", "Reasonix home", "Reasonix state directory", "project root":
return scope
}
return "other"
}
func printPlanPreview(plan repair.RepairPlan, previews []repair.RepairPlanPreview) {
fmt.Println("AI repair plan:", plan.Summary)
for _, preview := range previews {
fmt.Printf(" %d. %s\n", preview.Index, preview.Description)
if preview.Diff != "" {
fmt.Println(preview.Diff)
}
}
}
func confirmPlan() bool {
fmt.Print("Apply this repair plan? [y/N] ")
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
line = strings.ToLower(strings.TrimSpace(line))
return line == "y" || line == "yes"
}
+144
View File
@@ -0,0 +1,144 @@
package main
import (
"encoding/json"
"strings"
"testing"
"reasonix/internal/repair"
)
// TestProviderSafeReportDropsUserControlledContent pins the outbound-privacy
// contract: the payload sent to the AI provider must carry only allowlisted
// codes, severities, and generic scopes — never diagnostic prose, which can
// quote config lines, MCP command lines, permission rules, URLs with
// credentials, emails, tokens, or absolute paths.
func TestProviderSafeReportDropsUserControlledContent(t *testing.T) {
const metadataSecret = "snapshot-metadata-secret"
const snapshotID = "20260715T000000.000000000Z-abcdef123456"
secrets := []string{
"/Users/someone/dotfiles",
"[email protected]",
"sk-live-Abc123Secret",
"npx --api-key=sk-live-Abc123Secret mcp-server",
"bash(rm -rf *)",
"corp-internal-llm",
"https://user:[email protected]:8080",
metadataSecret,
"secret.finding.code",
"secret-severity",
snapshotID,
}
report := repair.DiagnosticReport{
GeneratedAt: metadataSecret,
Root: "/Users/someone/dotfiles",
Network: true,
Snapshots: []repair.DiagnosticSnapshot{
{ID: snapshotID, RecordedAt: metadataSecret, Version: metadataSecret},
{ID: metadataSecret, RecordedAt: metadataSecret, Version: metadataSecret},
},
PendingUpdate: &repair.DiagnosticUpdate{FromVersion: metadataSecret, ToVersion: metadataSecret},
Findings: []repair.DiagnosticFinding{
{Severity: "error", Code: "config.invalid_toml", Scope: "global",
Message: "Configuration cannot be parsed: line 3: api_key = \"sk-live-Abc123Secret\" contact [email protected]"},
{Severity: "warning", Code: "plugin.command_missing", Scope: "plugin:corp-internal-llm",
Message: `MCP command "npx --api-key=sk-live-Abc123Secret mcp-server" was not found.`},
{Severity: "warning", Code: "permissions.conflict", Scope: "permissions",
Message: `Permission rule "bash(rm -rf *)" appears in allow, deny; deny takes precedence.`},
{Severity: "error", Code: "network.invalid_proxy", Scope: "network",
Message: "Proxy configuration is invalid: parse \"https://user:[email protected]:8080\": bad"},
{Severity: "warning", Code: "provider.missing_key", Scope: "provider:corp-internal-llm",
Message: "The configured API key is missing."},
{Severity: "warning", Code: "derived.invalid_json", Scope: "derived:tabs",
Message: "Derived desktop state desktop-tabs.json is malformed."},
{Severity: "secret-severity", Code: "secret.finding.code", Scope: metadataSecret,
Message: metadataSecret, Remediation: metadataSecret},
},
}
safeReport, aliases := providerSafeReportFrom(report)
payload, err := json.Marshal(safeReport)
if err != nil {
t.Fatal(err)
}
body := string(payload)
for _, secret := range secrets {
if strings.Contains(body, secret) {
t.Fatalf("outbound payload leaked %q:\n%s", secret, body)
}
}
for _, want := range []string{
`"root":"\u003cproject\u003e"`, // json.Marshal HTML-escapes <project>
`"code":"config.invalid_toml"`,
`"scope":"provider"`,
`"scope":"plugin"`,
`"scope":"derived:tabs"`,
`"id":"snapshot-1"`,
`"id":"snapshot-2"`,
`"pendingUpdate":true`,
`"severity":"unknown"`,
`"code":"unknown"`,
`"scope":"other"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("outbound payload missing %s:\n%s", want, body)
}
}
if aliases["snapshot-1"] != snapshotID || aliases["snapshot-2"] != metadataSecret {
t.Fatalf("snapshot alias map = %v", aliases)
}
for _, forbiddenField := range []string{"generatedAt", "recordedAt", "version", "fromVersion", "toVersion"} {
if strings.Contains(body, `"`+forbiddenField+`"`) {
t.Fatalf("outbound payload retained free-form field %q:\n%s", forbiddenField, body)
}
}
}
func TestResolveProviderSnapshotAliases(t *testing.T) {
const snapshotID = "20260715T000000.000000000Z-abcdef123456"
plan, err := repair.DecodeRepairPlan([]byte(`{
"schemaVersion": 1,
"summary": "restore newest snapshot",
"actions": [
{"type":"repair_config","scope":"global","reason":"invalid config"},
{"type":"restore_snapshot","snapshotId":"snapshot-1","reason":"known-good config"}
]
}`))
if err != nil {
t.Fatal(err)
}
resolved, err := resolveProviderSnapshotAliases(plan, map[string]string{"snapshot-1": snapshotID})
if err != nil {
t.Fatal(err)
}
if got := resolved.Actions[1].SnapshotID; got != snapshotID {
t.Fatalf("resolved snapshot ID = %q, want %q", got, snapshotID)
}
if got := plan.Actions[1].SnapshotID; got != "snapshot-1" {
t.Fatalf("alias resolution mutated source plan: %q", got)
}
if _, err := resolveProviderSnapshotAliases(plan, nil); err == nil {
t.Fatal("unknown snapshot alias was accepted")
}
}
func TestProviderSafeScopeClosedVocabulary(t *testing.T) {
cases := map[string]string{
"": "",
"global": "global",
"project": "project",
"derived:tabs": "derived:tabs",
"derived:zoom": "derived:zoom",
"derived:/etc/passwd": "derived:other",
"provider:corp-llm": "provider",
"plugin:internal-mcp": "plugin",
"credential file": "credential file",
"[email protected]": "other",
"/Users/someone/project": "other",
}
for in, want := range cases {
if got := providerSafeScope(in); got != want {
t.Errorf("providerSafeScope(%q) = %q, want %q", in, got, want)
}
}
}
+398
View File
@@ -0,0 +1,398 @@
// Command reasonix-guard diagnoses and repairs Reasonix without loading the
// desktop shell. It is packaged beside the desktop application so recovery
// remains available when Wails, WebView, or user configuration cannot start.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"reasonix/internal/repair"
)
var version = "dev"
func main() { os.Exit(run(os.Args[1:])) }
func run(args []string) int {
if len(args) == 0 {
return runLaunch(nil)
}
switch args[0] {
case "check":
return runCheck(args[1:], false)
case "repair":
return runCheck(args[1:], true)
case "launch":
return runLaunch(args[1:])
case "recover":
return runRecover(args[1:])
case "snapshots":
return runSnapshots(args[1:])
case "restore":
return runRestore(args[1:])
case "undo":
return runUndo(args[1:])
case "diagnose":
return runDiagnose(args[1:])
case "rebuild":
return runRebuild(args[1:])
case "assist":
return runAssist(args[1:])
case "apply-plan":
return runApplyPlan(args[1:])
case "version", "--version", "-v":
fmt.Println("reasonix-guard", version)
return 0
case "help", "--help", "-h":
usage()
return 0
default:
if isMacBundleLauncher() {
return runLaunch(append([]string{"--"}, args...))
}
fmt.Fprintln(os.Stderr, "unknown command:", args[0])
usage()
return 2
}
}
func runDiagnose(args []string) int {
fs := flag.NewFlagSet("reasonix-guard diagnose", flag.ContinueOnError)
root := fs.String("root", ".", "project root to inspect")
network := fs.Bool("network", false, "probe provider endpoints and credentials")
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
report, err := repair.Diagnose(context.Background(), repair.DiagnoseOptions{Root: *root, Network: *network})
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
if code := printJSON(report); code != 0 {
return code
}
} else {
fmt.Println("Reasonix Guard diagnostics")
if len(report.Findings) == 0 {
fmt.Println(" ok: no issues found")
}
for _, finding := range report.Findings {
fmt.Printf(" %-7s %-32s %s\n", finding.Severity, finding.Code, finding.Message)
}
}
if report.HasErrors() {
return 1
}
return 0
}
func runRebuild(args []string) int {
fs := flag.NewFlagSet("reasonix-guard rebuild", flag.ContinueOnError)
target := fs.String("target", "", "tabs|projects|window|zoom|all")
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 || strings.TrimSpace(*target) == "" {
return 2
}
applied, err := repair.RebuildDerivedState(*target)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
return printJSON(applied)
}
for _, path := range applied {
fmt.Println("quarantined:", path)
}
return 0
}
func runSnapshots(args []string) int {
fs := flag.NewFlagSet("reasonix-guard snapshots", flag.ContinueOnError)
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
snapshots, err := repair.ListConfigSnapshots()
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
return printJSON(snapshots)
}
for _, snap := range snapshots {
fmt.Printf("%s %s %s\n", snap.ID, snap.Version, snap.RecordedAt)
}
return 0
}
func runRestore(args []string) int {
fs := flag.NewFlagSet("reasonix-guard restore", flag.ContinueOnError)
snapshot := fs.String("snapshot", "", "snapshot id to restore")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 || strings.TrimSpace(*snapshot) == "" {
return 2
}
tx, err := repair.RestoreConfigSnapshot(*snapshot)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
fmt.Println("restored config snapshot; undo transaction:", tx.ID)
return 0
}
func runUndo(args []string) int {
fs := flag.NewFlagSet("reasonix-guard undo", flag.ContinueOnError)
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
tx, err := repair.UndoLastRepair()
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
return printJSON(tx)
}
fmt.Println("undid repair:", tx.ID)
return 0
}
func printJSON(v any) int {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(v); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
return 0
}
func runCheck(args []string, apply bool) int {
name := "reasonix-guard check"
if apply {
name = "reasonix-guard repair"
}
fs := flag.NewFlagSet(name, flag.ContinueOnError)
root := fs.String("root", ".", "project root to inspect")
includeProject := fs.Bool("project", false, "allow repair to quarantine project reasonix.toml")
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
report, err := repair.InspectAndRepairConfig(repair.ConfigOptions{Root: *root, Apply: apply, IncludeProject: *includeProject})
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(report); err != nil {
return 1
}
} else {
fmt.Println("Reasonix Guard")
for _, check := range report.Checks {
status := "ok"
if !check.Exists {
status = "missing"
} else if !check.Valid {
status = "invalid"
}
fmt.Printf(" %-8s %-8s %s\n", check.Scope, status, check.Path)
if check.Error != "" {
fmt.Println(" ", check.Error)
}
}
for _, action := range report.Applied {
fmt.Println(" applied:", action)
}
}
for _, check := range report.Checks {
if check.Exists && !check.Valid {
return 1
}
}
return 0
}
// failedInstallBlocksLaunch decides whether startup must stop after a failed
// RecoverFailedInstall. The failure marker means the installer may have left
// the release unit partially replaced, so the binaries cannot be presumed
// coherent until a rollback completes: any error with the restore unfinished
// fails closed. MixedInstall alone is not the signal — a rollback that failed
// before touching anything (e.g. while staging) reports MixedInstall=false yet
// leaves the installer's half-written unit in place. Only a completed restore
// (RolledBack, with just the marker cleanup failing) is safe to launch.
func failedInstallBlocksLaunch(result repair.UpdateRollbackResult, err error) bool {
return err != nil && !result.RolledBack
}
func runLaunch(args []string) int {
fs := flag.NewFlagSet("reasonix-guard launch", flag.ContinueOnError)
app := fs.String("app", "", "desktop executable path")
safeMode := fs.Bool("safe-mode", false, "force Safe Mode")
detach := fs.Bool("detach", packagedDetachedLauncher(), "start the desktop and exit the launcher")
if err := fs.Parse(args); err != nil {
return 2
}
// An update helper that watched the installer fail records a marker (it
// cannot roll back itself from outside the install directory). Restore the
// previous release unit immediately instead of waiting for a crash loop.
if result, failure, err := repair.RecoverFailedInstall(); err != nil {
fmt.Fprintln(os.Stderr, "update rollback after failed install failed:", err)
if failedInstallBlocksLaunch(result, err) {
fmt.Fprintln(os.Stderr, "error: the failed installer may have left a mix of two releases and the rollback did not complete; refusing to start. Re-run reasonix-guard to retry the rollback, or reinstall Reasonix.")
return 1
}
} else if failure != nil && result.RolledBack {
fmt.Fprintf(os.Stderr, "Reasonix Guard restored %s after the %s installer failed.\n", result.ToVersion, result.FromVersion)
}
tracker := repair.NewStartupTracker("")
useSafeMode := *safeMode
if !*safeMode && tracker.SafeModeRecommended() {
if result, err := repair.RollbackPendingUpdate(); err != nil {
fmt.Fprintln(os.Stderr, "update rollback failed:", err)
if result.MixedInstall {
fmt.Fprintln(os.Stderr, "error: the installation now mixes two releases; refusing to start. Re-run reasonix-guard to retry the rollback, or reinstall Reasonix.")
return 1
}
// The compensated install is still a coherent (new-version) release
// unit, so a Safe Mode boot is a sound fallback while the pending
// transaction stays on disk for the next rollback attempt.
useSafeMode = true
} else if result.RolledBack {
fmt.Fprintf(os.Stderr, "Reasonix Guard restored %s after %s failed to start.\n", result.ToVersion, result.FromVersion)
_ = tracker.MarkClean()
} else {
switch nativeRecoveryChoice() {
case recoveryRepair:
if _, err := repair.InspectAndRepairConfig(repair.ConfigOptions{Root: ".", Apply: true}); err != nil {
fmt.Fprintln(os.Stderr, "repair failed:", err)
}
useSafeMode = true
case recoverySafeMode:
useSafeMode = true
case recoveryQuit:
return 0
}
}
}
path := strings.TrimSpace(*app)
if path == "" {
path = siblingDesktopExecutable()
}
if path == "" {
fmt.Fprintln(os.Stderr, "error: cannot locate reasonix-desktop; pass --app PATH")
return 1
}
childArgs := append([]string(nil), fs.Args()...)
if useSafeMode {
childArgs = append([]string{"--safe-mode"}, childArgs...)
}
cmd := exec.Command(path, childArgs...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
if *detach {
if err := cmd.Start(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
return 0
}
if err := cmd.Run(); err != nil {
if exit, ok := err.(*exec.ExitError); ok {
return exit.ExitCode()
}
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
return 0
}
func packagedDetachedLauncher() bool {
if runtime.GOOS != "windows" {
return false
}
exe, err := os.Executable()
if err != nil {
return false
}
name := strings.ToLower(filepath.Base(exe))
return name == "reasonix-launcher.exe" || name == "reasonix.exe"
}
func runRecover(args []string) int {
fs := flag.NewFlagSet("reasonix-guard recover", flag.ContinueOnError)
root := fs.String("root", ".", "project root to inspect")
includeProject := fs.Bool("project", false, "allow repair to quarantine project reasonix.toml")
app := fs.String("app", "", "desktop executable path")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
switch nativeRecoveryChoice() {
case recoveryRepair:
if _, err := repair.InspectAndRepairConfig(repair.ConfigOptions{Root: *root, Apply: true, IncludeProject: *includeProject}); err != nil {
fmt.Fprintln(os.Stderr, "repair failed:", err)
return 1
}
launch := []string{"--safe-mode"}
if strings.TrimSpace(*app) != "" {
launch = append(launch, "--app", *app)
}
return runLaunch(launch)
case recoverySafeMode:
launch := []string{"--safe-mode"}
if strings.TrimSpace(*app) != "" {
launch = append(launch, "--app", *app)
}
return runLaunch(launch)
default:
return 0
}
}
func siblingDesktopExecutable() string {
exe, err := os.Executable()
if err != nil {
return ""
}
name := "reasonix-desktop"
if runtime.GOOS == "windows" {
name += ".exe"
}
candidates := []string{filepath.Join(filepath.Dir(exe), name)}
if runtime.GOOS == "darwin" {
candidates = append(candidates, filepath.Clean(filepath.Join(filepath.Dir(exe), "..", "MacOS", name)))
}
for _, path := range candidates {
if _, err := os.Stat(path); err == nil {
return path
}
}
return ""
}
func isMacBundleLauncher() bool {
if runtime.GOOS != "darwin" {
return false
}
exe, err := os.Executable()
return err == nil && strings.Contains(filepath.ToSlash(exe), ".app/Contents/MacOS/")
}
func usage() {
fmt.Println("usage: reasonix-guard <check|repair|diagnose|rebuild|assist|apply-plan|launch|recover|snapshots|restore|undo> [options]")
}
+39
View File
@@ -0,0 +1,39 @@
package main
import (
"errors"
"os"
"path/filepath"
"testing"
"reasonix/internal/repair"
)
func TestCheckReportsInvalidProjectConfig(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte("[broken\n"), 0o600); err != nil {
t.Fatal(err)
}
if code := run([]string{"check", "--root", root, "--json"}); code != 1 {
t.Fatalf("exit code = %d, want 1", code)
}
}
func TestFailedInstallBlocksLaunch(t *testing.T) {
cases := []struct {
name string
result repair.UpdateRollbackResult
err error
want bool
}{
{name: "no error never blocks", result: repair.UpdateRollbackResult{}, err: nil, want: false},
{name: "incomplete rollback fails closed", result: repair.UpdateRollbackResult{}, err: errors.New("stage failed"), want: true},
{name: "uncompensated rollback fails closed", result: repair.UpdateRollbackResult{MixedInstall: true}, err: errors.New("restore failed"), want: true},
{name: "completed restore with marker cleanup error launches", result: repair.UpdateRollbackResult{RolledBack: true}, err: errors.New("remove marker: permission denied"), want: false},
}
for _, tc := range cases {
if got := failedInstallBlocksLaunch(tc.result, tc.err); got != tc.want {
t.Errorf("%s: failedInstallBlocksLaunch = %v, want %v", tc.name, got, tc.want)
}
}
}
+9
View File
@@ -0,0 +1,9 @@
package main
type recoveryChoice int
const (
recoveryQuit recoveryChoice = iota
recoverySafeMode
recoveryRepair
)
+24
View File
@@ -0,0 +1,24 @@
//go:build darwin
package main
import (
"os/exec"
"strings"
)
func nativeRecoveryChoice() recoveryChoice {
script := `button returned of (display dialog "Reasonix failed to start repeatedly. You can repair damaged configuration or start with external integrations disabled." with title "Reasonix Recovery" buttons {"Quit", "Safe Mode", "Repair and Start"} default button "Repair and Start" with icon caution)`
out, err := exec.Command("osascript", "-e", script).Output()
if err != nil {
return recoverySafeMode
}
switch strings.TrimSpace(string(out)) {
case "Repair and Start":
return recoveryRepair
case "Safe Mode":
return recoverySafeMode
default:
return recoveryQuit
}
}
+36
View File
@@ -0,0 +1,36 @@
//go:build linux
package main
import (
"errors"
"os/exec"
"strings"
)
func nativeRecoveryChoice() recoveryChoice {
if path, err := exec.LookPath("zenity"); err == nil {
cmd := exec.Command(path, "--question", "--title=Reasonix Recovery",
"--text=Reasonix failed to start repeatedly.",
"--ok-label=Repair and Start", "--cancel-label=Safe Mode", "--extra-button=Quit")
out, err := cmd.Output()
if strings.TrimSpace(string(out)) == "Quit" {
return recoveryQuit
}
if err == nil {
return recoveryRepair
}
return recoverySafeMode
}
if path, err := exec.LookPath("kdialog"); err == nil {
err := exec.Command(path, "--warningyesnocancel", "Reasonix failed to start repeatedly. Repair configuration and start?", "--title", "Reasonix Recovery").Run()
if err == nil {
return recoveryRepair
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 2 {
return recoveryQuit
}
}
return recoverySafeMode
}
+5
View File
@@ -0,0 +1,5 @@
//go:build !darwin && !windows && !linux
package main
func nativeRecoveryChoice() recoveryChoice { return recoverySafeMode }
+27
View File
@@ -0,0 +1,27 @@
//go:build windows
package main
import (
"os/exec"
"strings"
"syscall"
)
func nativeRecoveryChoice() recoveryChoice {
script := `Add-Type -AssemblyName PresentationFramework; $r=[System.Windows.MessageBox]::Show('Reasonix failed to start repeatedly. Yes: repair configuration and start. No: start in Safe Mode. Cancel: quit.','Reasonix Recovery','YesNoCancel','Warning'); Write-Output $r`
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
out, err := cmd.Output()
if err != nil {
return recoverySafeMode
}
switch strings.TrimSpace(string(out)) {
case "Yes":
return recoveryRepair
case "No":
return recoverySafeMode
default:
return recoveryQuit
}
}
+349
View File
@@ -0,0 +1,349 @@
// Command reasonix-plugin-example is a reference Reasonix plugin: a minimal MCP stdio
// server speaking newline-delimited JSON-RPC 2.0 on stdin/stdout. It exists to
// document the contract end-to-end (the protocol the internal/plugin client
// drives) and to give users a working example to copy.
//
// Wire it up in reasonix.toml:
//
// [[plugins]]
// name = "example"
// command = "reasonix-plugin-example"
//
// Then reasonix surfaces its tools as "mcp__example__echo" / "mcp__example__wordcount",
// its prompt as the "/mcp__example__review" slash command, and its resource as
// the "@example:doc://style-guide" reference.
//
// Protocol, one JSON object per line:
// - initialize → {protocolVersion, capabilities, serverInfo}
// - notifications/initialized (notification, no id) → ignored
// - tools/list → {tools: [{name, description, inputSchema, annotations}]}
// - tools/call {name, arguments} → {content: [{type:"text", text}], isError}
// - prompts/list → {prompts: [{name, description, arguments}]}
// - prompts/get {name, arguments} → {messages: [{role, content:{type,text}}]}
// - resources/list → {resources: [{uri, name, description, mimeType}]}
// - resources/read {uri} → {contents: [{uri, mimeType, text}]}
//
// Logs go to stderr (reasonix forwards plugin stderr to the terminal); stdout is
// reserved for JSON-RPC so it must never carry stray prose.
package main
import (
"bufio"
"encoding/json"
"fmt"
"log"
"os"
"strings"
"unicode/utf8"
)
// version is overridable via -ldflags "-X main.version=...". Reported in
// initialize's serverInfo so reasonix (and humans) can see which build is running.
var version = "dev"
func main() {
log.SetPrefix("reasonix-plugin-example: ")
log.SetFlags(0)
if err := serve(os.Stdin, os.Stdout); err != nil {
log.Fatal(err)
}
}
// --- JSON-RPC framing ---
type request struct {
JSONRPC string `json:"jsonrpc"`
ID *json.RawMessage `json:"id"` // nil ⇒ notification (no reply); echoed back verbatim otherwise
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
type response struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result any `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
const (
protocolVersion = "2024-11-05"
codeMethodNotFound = -32601
codeInvalidParams = -32602
)
// serve runs the read-dispatch-reply loop until stdin closes (reasonix closed the
// pipe / is shutting down). Each line is one JSON-RPC message.
func serve(in *os.File, out *os.File) error {
r := bufio.NewReader(in)
w := bufio.NewWriter(out)
defer w.Flush()
for {
line, err := r.ReadBytes('\n')
if len(line) > 0 {
if rerr := handleLine(line, w); rerr != nil {
return rerr
}
if ferr := w.Flush(); ferr != nil {
return ferr
}
}
if err != nil {
return nil // EOF or pipe closed: clean shutdown
}
}
}
func handleLine(line []byte, w *bufio.Writer) error {
line = trimSpace(line)
if len(line) == 0 {
return nil
}
var req request
if err := json.Unmarshal(line, &req); err != nil {
log.Printf("skipping unparseable line: %v", err)
return nil
}
if req.ID == nil {
return nil // notification (e.g. notifications/initialized): no reply
}
resp := response{JSONRPC: "2.0", ID: *req.ID}
switch req.Method {
case "initialize":
resp.Result = map[string]any{
"protocolVersion": protocolVersion,
"capabilities": map[string]any{
"tools": map[string]any{},
"prompts": map[string]any{},
"resources": map[string]any{},
},
"serverInfo": map[string]any{"name": "reasonix-plugin-example", "version": version},
}
case "tools/list":
resp.Result = map[string]any{"tools": toolList()}
case "tools/call":
resp.Result, resp.Error = callTool(req.Params)
case "prompts/list":
resp.Result = map[string]any{"prompts": promptList()}
case "prompts/get":
resp.Result, resp.Error = getPrompt(req.Params)
case "resources/list":
resp.Result = map[string]any{"resources": resourceList()}
case "resources/read":
resp.Result, resp.Error = readResource(req.Params)
default:
resp.Error = &rpcError{Code: codeMethodNotFound, Message: "method not found: " + req.Method}
}
b, err := json.Marshal(resp)
if err != nil {
return fmt.Errorf("marshal response: %w", err)
}
if _, err := w.Write(append(b, '\n')); err != nil {
return err
}
return nil
}
// --- tools ---
// toolDef is one exposed tool: its advertised metadata plus the handler. run
// returns the text result, or an error which becomes an isError tool result the
// model can read and adapt to.
type toolDef struct {
name string
description string
schema map[string]any
readOnly bool
run func(args map[string]any) (string, error)
}
// tools is the registry. Both demo tools are read-only and declare it via the
// readOnlyHint annotation, so reasonix runs them in parallel batches and (with the
// permission layer) auto-allows them without prompting.
var tools = []toolDef{
{
name: "echo",
description: "Echo the given text back. The simplest possible proof the plugin round-trip works.",
readOnly: true,
schema: map[string]any{
"type": "object",
"properties": map[string]any{
"text": map[string]any{"type": "string", "description": "Text to echo back"},
},
"required": []string{"text"},
},
run: func(args map[string]any) (string, error) {
text, ok := args["text"].(string)
if !ok {
return "", fmt.Errorf("argument 'text' must be a string")
}
return text, nil
},
},
{
name: "wordcount",
description: "Count the lines, words, and bytes of the given text (like wc).",
readOnly: true,
schema: map[string]any{
"type": "object",
"properties": map[string]any{
"text": map[string]any{"type": "string", "description": "Text to measure"},
},
"required": []string{"text"},
},
run: func(args map[string]any) (string, error) {
text, ok := args["text"].(string)
if !ok {
return "", fmt.Errorf("argument 'text' must be a string")
}
return fmt.Sprintf("lines: %d, words: %d, bytes: %d, runes: %d",
countLines(text), len(strings.Fields(text)), len(text), utf8.RuneCountInString(text)), nil
},
},
}
func toolList() []map[string]any {
out := make([]map[string]any, 0, len(tools))
for _, t := range tools {
out = append(out, map[string]any{
"name": t.name,
"description": t.description,
"inputSchema": t.schema,
"annotations": map[string]any{
"readOnlyHint": t.readOnly,
"title": t.name,
},
})
}
return out
}
// callTool dispatches a tools/call. A handler error is reported as an isError
// content result (in-band, so the model sees it) rather than a JSON-RPC error;
// JSON-RPC errors are reserved for protocol-level faults (bad params, unknown
// tool).
func callTool(params json.RawMessage) (any, *rpcError) {
var p struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
}
if err := json.Unmarshal(params, &p); err != nil {
return nil, &rpcError{Code: codeInvalidParams, Message: "invalid params: " + err.Error()}
}
for _, t := range tools {
if t.name != p.Name {
continue
}
text, err := t.run(p.Arguments)
if err != nil {
return textResult(err.Error(), true), nil
}
return textResult(text, false), nil
}
return nil, &rpcError{Code: codeInvalidParams, Message: "unknown tool: " + p.Name}
}
func textResult(text string, isError bool) map[string]any {
return map[string]any{
"content": []map[string]any{{"type": "text", "text": text}},
"isError": isError,
}
}
// --- prompts ---
// promptList advertises the server's prompts. They surface in reasonix as
// /mcp__example__<name> slash commands.
func promptList() []map[string]any {
return []map[string]any{{
"name": "review",
"description": "Draft a focused code-review request for a file.",
"arguments": []map[string]any{
{"name": "path", "description": "File to review", "required": true},
},
}}
}
// getPrompt renders a prompt into MCP messages. The returned text becomes the
// next user turn in reasonix, so it's phrased as an instruction to the model.
func getPrompt(params json.RawMessage) (any, *rpcError) {
var p struct {
Name string `json:"name"`
Arguments map[string]string `json:"arguments"`
}
if err := json.Unmarshal(params, &p); err != nil {
return nil, &rpcError{Code: codeInvalidParams, Message: "invalid params: " + err.Error()}
}
if p.Name != "review" {
return nil, &rpcError{Code: codeInvalidParams, Message: "unknown prompt: " + p.Name}
}
path := p.Arguments["path"]
if path == "" {
path = "the current file"
}
text := fmt.Sprintf("Please review %s. Read it, then list any correctness bugs and risky patterns with file:line references, most important first.", path)
return map[string]any{
"description": "Code review request",
"messages": []map[string]any{
{"role": "user", "content": map[string]any{"type": "text", "text": text}},
},
}, nil
}
// --- resources ---
// resourceContents is the demo resource store, keyed by uri.
var resourceContents = map[string]string{
"doc://style-guide": "Project style: tabs for indentation; comments explain why, not what; keep functions short.",
}
func resourceList() []map[string]any {
return []map[string]any{{
"uri": "doc://style-guide",
"name": "Style guide",
"description": "The project's coding style notes.",
"mimeType": "text/plain",
}}
}
func readResource(params json.RawMessage) (any, *rpcError) {
var p struct {
URI string `json:"uri"`
}
if err := json.Unmarshal(params, &p); err != nil {
return nil, &rpcError{Code: codeInvalidParams, Message: "invalid params: " + err.Error()}
}
text, ok := resourceContents[p.URI]
if !ok {
return nil, &rpcError{Code: codeInvalidParams, Message: "unknown resource: " + p.URI}
}
return map[string]any{
"contents": []map[string]any{
{"uri": p.URI, "mimeType": "text/plain", "text": text},
},
}, nil
}
// countLines counts newline-separated lines, counting a final unterminated line.
func countLines(s string) int {
if s == "" {
return 0
}
n := strings.Count(s, "\n")
if !strings.HasSuffix(s, "\n") {
n++
}
return n
}
// trimSpace trims leading/trailing ASCII whitespace without pulling in bytes.
func trimSpace(b []byte) []byte {
return []byte(strings.TrimSpace(string(b)))
}
+20
View File
@@ -0,0 +1,20 @@
// Command reasonix is a config- and plugin-driven coding agent CLI.
package main
import (
"os"
"reasonix/internal/cli"
// Blank imports wire compile-time built-ins into their registries.
_ "reasonix/internal/provider/anthropic"
_ "reasonix/internal/provider/openai"
_ "reasonix/internal/tool/builtin"
)
// version is injected at build time via -ldflags "-X main.version=...".
var version = "dev"
func main() {
os.Exit(cli.Run(os.Args[1:], version))
}
+45
View File
@@ -0,0 +1,45 @@
# Wails build assets/output — mostly auto-generated (plists, manifests, compiled
# binary); ignore them but keep the committed designed app icon source and PNG.
/build/*
!/build/appicon.png
!/build/appicon.svg
# Commit only our customized per-user NSIS installer template. wails_tools.nsh and
# everything else under build/ are regenerated by `wails build`, so stay ignored.
# (Each `!` un-ignore needs its parent dir un-ignored first.)
!/build/windows
/build/windows/*
!/build/windows/installer
/build/windows/installer/*
!/build/windows/installer/project.nsi
# Commit our hand-written hardened-runtime entitlements (scripts/desktop-build.sh
# passes it to codesign for Developer ID signing). Unlike Info.plist it is NOT
# regenerated by `wails build`, so it must live in git. Parent dir un-ignored first.
!/build/darwin
/build/darwin/*
!/build/darwin/entitlements.plist
!/build/darwin/icon.icns
# Commit the hand-written Linux .deb packaging inputs and app icon assets.
# `wails build` does not generate these, so they must live in git. The .deb is built
# by scripts/desktop-build.sh's linux branch via goreleaser/nfpm. Parent un-ignored first.
!/build/linux
/build/linux/*
!/build/linux/nfpm.yaml
!/build/linux/reasonix.desktop
!/build/linux/icons
!/build/linux/icons/**
# Go test binaries
*.test
# Local config symlinks for `wails dev` (point at the repo-root reasonix.toml/.env
# so the desktop app resolves the same config/keys the CLI does). Never commit.
/reasonix.toml
/.env
# `go build` binary produced in-tree (module reasonix/desktop → "desktop")
/desktop
# Wails dependency-change cache
/frontend/package.json.md5
+281
View File
@@ -0,0 +1,281 @@
# Reasonix Desktop (Wails shell)
A native desktop window around the Reasonix Go kernel. The same
transport-agnostic `control.Controller` that backs the chat TUI and the HTTP/SSE
server is bound **directly** to a React webview — Go methods in, typed events
out, no HTTP hop.
```
┌─────────────────────────────────────────────────────────────┐
│ webview (React + TS, Vite) │
│ bridge.ts ──calls──▶ window.go.main.App.{Submit,Cancel,…} │
│ bridge.ts ◀─events── window.runtime.EventsOn("agent:event")│
└───────────────▲───────────────────────────┬─────────────────┘
bound methods runtime.EventsEmit
┌───────────────┴───────────────────────────▼─────────────────┐
│ desktop/app.go App (bound) + eventSink (event.Sink) │
│ desktop/main.go Wails options, window, embed frontend/dist │
└───────────────▲───────────────────────────┬─────────────────┘
commands │ │ typed event stream
┌───────────────┴────────────────────────────▼────────────────┐
│ internal/boot.Build → internal/control.Controller (kernel) │
│ (same assembly the CLI uses: providers, tools, gate, …) │
└──────────────────────────────────────────────────────────────┘
```
## Why a nested module
`desktop/` is its own Go module (`module reasonix/desktop`, `replace reasonix =>
../`). That keeps the CGO + WebKit desktop build entirely separate from the CLI's
`CGO_ENABLED=0` single-static-binary guarantee: the parent module's `go build /
vet / test ./...` skip this directory, while the import path stays under
`reasonix/` so it can still import the `reasonix/internal/*` kernel.
## Prerequisites
- Go (matches the parent module).
- Node + **pnpm** (`npm i -g pnpm`).
- Wails CLI: `go install github.com/wailsapp/wails/v2/cmd/wails@latest`
- Platform webview libs: macOS ships WebKit; Windows needs the Edge **WebView2**
runtime; Linux needs `libgtk-3-dev` plus WebKitGTK. The default build links
against **WebKitGTK 4.0**; distros that only ship **4.1** (Fedora 40+, Ubuntu
24.04+, Arch) build with `-tags webkit2_41` — see [Build](#build). Run
`wails doctor` to verify.
## Develop
```sh
cd desktop
wails dev # hot-reloads Go + frontend (Vite dev server)
```
Frontend-only iteration without the Go side:
```sh
cd desktop/frontend
pnpm install
pnpm dev # opens in a plain browser; bridge.ts uses the dev mock
```
In a plain browser the native bindings are absent, so `bridge.ts` falls back to a
**mock** that streams a canned turn (text + one `edit_file` tool call) through the
exact same event contract — so layout, streaming, markdown, tool cards, and the
diff seam can all be built without rebuilding Go.
## Test
The desktop package is a nested Go module, so parent `go test ./...` does not run
it. Use the full lane before merging desktop changes, and the short lane for fast
local feedback:
```sh
make desktop-test # cd desktop && go test .
make desktop-test-short # skips slow desktop integration/e2e checks
```
To find the next bottleneck, rank individual test cases from the JSON stream:
```sh
make desktop-test-times
# or: cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py
```
### Frontend UI review checklist
For anchored menus, dropdowns, tooltips, and other portaled UI, review both the
component code and the CSS positioning contract:
- If a component uses `createPortal` plus `getBoundingClientRect()`, it must
handle scrollable ancestors, window resize, and `visualViewport` changes.
- Add a focused regression test when changing shared positioning primitives such
as `AnchoredPopover`, not only the specific menu that exposed the bug.
- Exercise at least one scrollable container path, such as Settings content, when
manually checking dropdown or popover changes.
## Build
```sh
cd desktop
wails build # → build/bin/Reasonix(.app/.exe)
```
**Linux on WebKitGTK 4.1 only** (Fedora 40+, Ubuntu 24.04+, Arch — no
`webkit2gtk-4.0` package): pass the Wails build tag so cgo links against 4.1.
```sh
wails build -tags webkit2_41
wails dev -tags webkit2_41 # same tag for hot-reload
```
Fedora deps: `sudo dnf install webkit2gtk4.1-devel gtk3-devel`.
`frontend/dist` is generated by the build (it's git-ignored except for a
`.gitkeep` that keeps the Go `//go:embed all:frontend/dist` compilable on a fresh
checkout). A bare `go build` without a prior `pnpm build` produces a blank window.
## Releases & auto-update
Desktop releases ride their own tag namespace, `desktop-v<semver>` (plain `v*`
tags are the CLI release). Pushing one triggers `.github/workflows/release-desktop.yml`,
which builds on a native runner per platform (Wails can't cross-compile a
CGO/WebKit binary), packages each artifact, signs it with minisign, generates a
`latest.json` manifest, publishes a GitHub release, marks the desktop release as
GitHub's repository-wide `Latest`, mirrors everything to R2, and attaches the
current desktop manifest to the matching CLI release for old clients that still
ask GitHub's repository-wide `latest` release for it.
The Linux artifact links against WebKitGTK 4.1 (`-tags webkit2_41`), so it needs
`libwebkit2gtk-4.1-0` at runtime — present by default on Ubuntu 22.04+, Fedora 40+.
```sh
git tag desktop-v1.1.0 && git push origin desktop-v1.1.0
```
The app checks `latest.json` on startup (R2 first, then the
`crash.reasonix.io` desktop release gateway) and shows an update banner when a
newer version is published; **Settings → Software update** has a manual check.
The gateway resolves only the desktop `desktop-v*` release line and never uses
GitHub's repository-wide `/releases/latest` shortcut, so updater behavior does
not depend on homepage badge semantics. Self-update behavior by platform:
- **Linux / Windows** — download, verify the minisign signature, then update in
place: Linux replaces the binary and relaunches; Windows runs the per-user NSIS
installer (no admin rights needed).
- **macOS** — *not* self-updating yet. The build is unsigned/un-notarized, so an
in-place swap would be blocked by Gatekeeper; the banner links to the download
page for a manual update instead.
### Unsigned builds — first launch
There are no Apple/Windows code-signing certificates yet, so a downloaded build
trips the OS gatekeepers on first run:
- **macOS** — open `Reasonix-darwin-universal.dmg` and drag Reasonix into
Applications. Gatekeeper may then report the app "is damaged" or is from an
unidentified developer; clear the quarantine attribute and open it:
```sh
xattr -dr com.apple.quarantine /Applications/Reasonix.app
```
- **Windows** — SmartScreen shows "Windows protected your PC". Click *More info →
Run anyway*.
When Developer ID / Authenticode certificates are added, the release workflow's
`HAS_APPLE_CERT` gate flips to the signed path and these steps go away.
### Verifying a download
Artifacts are signed with minisign (public key ID `AF12CA46F4A9EBB0`). The `.minisig`
signature sits next to each artifact in the release; verify with the
[minisign](https://jedisct1.github.io/minisign/) CLI:
```sh
minisign -Vm Reasonix-darwin-arm64.zip \
-P RWSw66n0RsoSr6Zhh6qt5YO95YkpCayTOCMFVDNUQSjJYwxoYngNVBSq
```
## Editor seam (Monaco / CodeMirror)
Code and diff rendering go through two components with stable prop contracts and a
lazy boundary, so a heavy editor stays out of the initial bundle and dropping one
in is a one-line change — no consumer touches:
| Component | Props | Default impl | Upgrade |
|---|---|---|---|
| `components/CodeViewer.tsx` | `EditorProps` | `editors/PlainCode.tsx` (`<pre>`) | swap the lazy import for `editors/MonacoCode` or `editors/CodeMirrorCode` |
| `components/DiffView.tsx` | `DiffProps` | `editors/PlainDiff.tsx` (LCS line diff) | swap for `editors/MonacoDiff` or `editors/CodeMirrorMerge` |
```sh
# Monaco
pnpm add @monaco-editor/react monaco-editor
# or CodeMirror 6
pnpm add @uiw/react-codemirror @codemirror/lang-javascript @codemirror/merge
```
Then add `editors/MonacoCode.tsx` (default-export a component taking
`EditorProps`) and point `CodeViewer.tsx`'s `lazy(() => import(...))` at it.
`ToolCard` already routes `edit_file` calls' `old_string`/`new_string` through
`DiffView`, and `Markdown` routes fenced code blocks through `CodeViewer`, so
both seams light up everywhere at once.
Markdown itself is currently minimal (fenced code + plain text). Upgrade path:
`pnpm add react-markdown remark-gfm` and render in `components/Markdown.tsx`,
keeping fenced code delegated to `CodeViewer`.
## Multi-platform adaptation
Wails is the right shell for a Go kernel (no sidecar), but a Go+webview stack uses
the **native** webview per OS, so the rough edges are platform-specific. What's
handled here, and what to reach for if a target misbehaves:
- **Linux / WebKitGTK** is the one real pain point — rendering varies by distro &
GPU driver. `main.go` keeps `WebviewGpuPolicy: OnDemand` when a DRI render node
is usable, and falls back to `Never` for xrdp/headless/software-rendered sessions
that cannot access `/dev/dri`. If artifacts persist, launch with
`WEBKIT_DISABLE_COMPOSITING_MODE=1`. Test on at least one GTK target before release;
the CSS deliberately avoids `backdrop-filter`/blur (slow & inconsistent there).
- **Wayland + NVIDIA**: On KDE Plasma Wayland with NVIDIA GPUs, WebKitGTK can
crash at startup (`Error 71: Protocol error`) due to an upstream WebKit
explicit-sync bug (WebKit #280210, #317089, NVIDIA/egl-wayland #179).
Reasonix automatically sets `__NV_DISABLE_EXPLICIT_SYNC=1` when it detects
Wayland + NVIDIA GPU. To opt out, set `__NV_DISABLE_EXPLICIT_SYNC=0`.
Alternative fallbacks: `WEBKIT_DISABLE_DMABUF_RENDERER=1` (poor performance)
or `GDK_BACKEND=x11` (forces XWayland).
- **Windows / WebView2** — `Theme: SystemDefault` follows the OS light/dark
setting; the installer embeds the WebView2 bootstrapper. Canary builds disable
WebView2 GPU acceleration by default to smoke-test blank-window reports; set
`REASONIX_DESKTOP_DISABLE_WEBVIEW2_GPU=1` or `0` to force the fallback on or
off. The WebView2 shell always uses a direct connection for embedded assets
and loopback remote-workspace pages; provider and other outbound traffic keeps
using Reasonix's own proxy configuration. Remote Markdown images are fetched
by the Go backend with the same proxy settings and re-served from the local
asset origin, so WebView2 never bypasses the configured proxy for them. Image
hosts must resolve locally to public addresses; direct, HTTP(S)-proxy, and
SOCKS-proxy connections are pinned to those vetted IPs while preserving the
original Host and TLS SNI. If the DOM is still not ready after 15 seconds, the
hidden startup window is shown with a native recovery prompt.
- **macOS / WebKit** — inset/hidden title bar (`TitleBarHiddenInset`); the CSS
marks the top bar as an OS drag region (`--wails-draggable: drag`) and leaves
room for the traffic lights.
- **Theming** — colors are CSS variables gated on `prefers-color-scheme`, which all
three webviews honor, so the UI follows the OS theme without native glue.
- **Fonts / offline** — system font stack only; no web-font fetches, so first paint
is instant and identical offline.
- **First paint** — the window background is set to the dark shell color so there's
no white flash before CSS loads (most visible on WebKitGTK).
## Files
```
desktop/
main.go Wails options, window, embed frontend/dist
app.go App (bound command surface) + eventSink (event.Sink → webview)
wire.go event.Event → JSON wire form (mirrors internal/serve/wire.go)
wails.json Wails project config (pnpm install/build/dev)
frontend/
src/
lib/
types.ts wire contract (mirrors wire.go)
bridge.ts window.go/window.runtime wrapper + browser dev mock
useController.ts event-stream reducer + command surface (the hook)
components/
Transcript, Message, ToolCard, Composer, ApprovalModal, ContextGauge,
Markdown, CodeViewer, DiffView
editors/ PlainCode, PlainDiff ← editor seam impls (swap targets)
```
## Telemetry
The desktop app sends one anonymous ping per launch to `crash.reasonix.io`:
a random install id (generated locally, tied to nothing), app version, OS,
arch, and OS version. It exists solely to count active installs. It never
includes conversations, API keys, file contents, or paths.
Opt out any time: Settings > Updates > "Anonymous usage ping", or set
`telemetry = false` under `[desktop]` in the global config. Dev builds
never ping. Crash and performance-pressure reports are separate and only
ever sent when the user clicks "Send report" on the diagnostic UI.
Aggregate quality metrics are also enabled by default and can be disabled from
Settings > Updates > "Share aggregate quality metrics", or by setting
`metrics = false` under `[desktop]`. These metrics are anonymous signal/bucket
counts and preference buckets; they never include conversations, prompts, keys,
paths, base URLs, or file contents.
+9999
View File
File diff suppressed because it is too large. Load diff
+674
View File
@@ -0,0 +1,674 @@
package main
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
type stubProvider struct{}
func (stubProvider) Name() string { return "stub" }
func (stubProvider) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
ch := make(chan provider.Chunk, 1)
close(ch)
return ch, nil
}
func controllerWithContent(t *testing.T, path string) *control.Controller {
t.Helper()
sess := agent.NewSession("system")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "remember this turn"})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "acknowledged"})
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
return control.New(control.Options{Executor: ag, SessionDir: filepath.Dir(path), SessionPath: path, Sink: event.Discard})
}
func waitForFile(t *testing.T, path, want string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if b, err := os.ReadFile(path); err == nil && strings.Contains(string(b), want) {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("session file %q never contained %q", path, want)
}
func waitForAutosaveIdle(t *testing.T, tab *WorkspaceTab) {
t.Helper()
waitForAutosaveIdleWithin(t, tab, 2*time.Second)
}
func waitForAutosaveIdleWithin(t *testing.T, tab *WorkspaceTab, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
tab.saveMu.Lock()
idle := !tab.saving && !tab.saveAgain
tab.saveMu.Unlock()
if idle {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("autosave loop did not become idle")
}
func appWithTab(t *testing.T, path string) (*App, *WorkspaceTab) {
t.Helper()
ctrl := controllerWithContent(t, path)
tab := &WorkspaceTab{
ID: "test_tab",
Ctrl: ctrl,
Scope: "global",
WorkspaceRoot: "",
Ready: true,
disabledMCP: map[string]ServerView{},
}
tab.sink = &tabEventSink{tabID: tab.ID, app: nil}
a := &App{
tabs: map[string]*WorkspaceTab{"test_tab": tab},
activeTabID: "test_tab",
}
tab.sink.app = a
return a, tab
}
// TestTurnDonePersistsSession proves a completed turn is written to disk without
// any explicit Snapshot call — the desktop autosave the data-loss fix adds. A
// nil sink ctx (no webview) must not disable persistence.
func TestTurnDonePersistsSession(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
_, tab := appWithTab(t, path)
tab.sink.Emit(event.Event{Kind: event.TurnDone})
waitForFile(t, path, "remember this turn")
waitForAutosaveIdle(t, tab)
}
// TestNonTurnDoneDoesNotPersist confirms only TurnDone triggers a save, so the
// per-token event storm doesn't thrash the disk.
func TestNonTurnDoneDoesNotPersist(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
a, tab := appWithTab(t, path)
_ = a
tab.sink.Emit(event.Event{Kind: event.Text, Text: "tok"})
time.Sleep(50 * time.Millisecond)
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("a non-TurnDone event wrote the session file (err=%v)", err)
}
}
// TestScheduleSnapshotCoalesces hammers the scheduler concurrently to prove the
// single-flight loop neither panics nor drops the final write.
func TestScheduleSnapshotCoalesces(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
a, tab := appWithTab(t, path)
_ = a
var wg sync.WaitGroup
for i := 0; i < 64; i++ {
wg.Add(1)
go func() {
defer wg.Done()
tab.sink.Emit(event.Event{Kind: event.TurnDone})
}()
}
wg.Wait()
waitForFile(t, path, "acknowledged")
waitForAutosaveIdle(t, tab)
}
func TestAutosaveFailureRetriesAndRecoversOnNextTurnDone(t *testing.T) {
path := filepath.Join(t.TempDir(), "blocked.jsonl")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("mkdir blocked path: %v", err)
}
a, tab := appWithTab(t, path)
_ = a
tab.sink.Emit(event.Event{Kind: event.TurnDone})
waitForAutosaveIdleWithin(t, tab, 5*time.Second)
tab.saveMu.Lock()
failures := tab.saveFailures
tab.saveMu.Unlock()
if failures == 0 {
t.Fatal("autosave failure should be recorded and retried")
}
if info, err := os.Stat(path); err != nil || !info.IsDir() {
t.Fatalf("blocked session path should still be the directory, info=%v err=%v", info, err)
}
if err := os.Remove(path); err != nil {
t.Fatalf("remove blocked dir: %v", err)
}
tab.sink.Emit(event.Event{Kind: event.TurnDone})
waitForFile(t, path, "remember this turn")
waitForAutosaveIdle(t, tab)
tab.saveMu.Lock()
failures = tab.saveFailures
tab.saveMu.Unlock()
if failures != 0 {
t.Fatalf("autosave failures after recovery = %d, want 0", failures)
}
}
func TestDesktopSnapshotConflictRecoveryUpdatesTabAndProjectTree(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
originalPath := filepath.Join(dir, "session.jsonl")
originalTopic := "topic_original"
if err := setTopicTitle("", originalTopic, "Original"); err != nil {
t.Fatalf("set original topic title: %v", err)
}
current := agent.NewSession("sys")
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
if err := current.Save(originalPath); err != nil {
t.Fatalf("Save current: %v", err)
}
if err := agent.SaveBranchMeta(originalPath, agent.BranchMeta{
Scope: "global",
TopicID: originalTopic,
TopicTitle: "Original",
Preview: "first",
Turns: 2,
SchemaVersion: agent.BranchMetaCountsVersion,
}); err != nil {
t.Fatalf("SaveBranchMeta original: %v", err)
}
staleSess := agent.NewSession("sys")
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
app := &App{
tabs: map[string]*WorkspaceTab{},
detachedSessions: map[string]*WorkspaceTab{},
activeTabID: "recovery_tab",
}
tab := &WorkspaceTab{
ID: "recovery_tab",
Scope: "global",
WorkspaceRoot: root,
TopicID: originalTopic,
TopicTitle: "Original",
SessionPath: originalPath,
Ready: true,
model: "test-model",
disabledMCP: map[string]ServerView{},
}
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
tab.Ctrl = control.New(control.Options{
Executor: staleExec,
SessionDir: dir,
SessionPath: originalPath,
Label: "test",
Sink: tab.sink,
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
OnSessionRecovered: app.handleTabSessionRecovered(tab),
})
app.tabs[tab.ID] = tab
if err := tab.Ctrl.Snapshot(); err != nil {
t.Fatalf("Snapshot: %v", err)
}
recoveryPath := tab.Ctrl.SessionPath()
if recoveryPath == "" || recoveryPath == originalPath {
t.Fatalf("recovery path = %q, want distinct path", recoveryPath)
}
if tab.SessionPath != recoveryPath {
t.Fatalf("tab session path = %q, want recovery path %q", tab.SessionPath, recoveryPath)
}
saved := loadTabsFile()
if len(saved.Tabs) != 1 || saved.Tabs[0].ID != tab.ID {
t.Fatalf("saved tabs = %+v, want recovered tab %q", saved.Tabs, tab.ID)
}
if got := saved.Tabs[0].SessionPath; got != recoveryPath {
t.Fatalf("saved tab session path = %q, want recovery path %q", got, recoveryPath)
}
if tab.TopicID != originalTopic {
t.Fatalf("tab topic ID = %q, want original topic %q", tab.TopicID, originalTopic)
}
meta, ok, err := agent.LoadBranchMeta(recoveryPath)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta recovery ok=%v err=%v", ok, err)
}
if !meta.Recovered || meta.TopicID != tab.TopicID || meta.TopicTitle != tab.TopicTitle {
t.Fatalf("recovery meta = %+v, tab topic=%q/%q", meta, tab.TopicID, tab.TopicTitle)
}
tabMeta := app.tabMeta(tab, true)
if !tabMeta.Recovered || tabMeta.RecoveryDigest != meta.RecoveryDigest || tabMeta.RecoveryParentID != string(meta.ParentID) {
t.Fatalf("tab recovery meta = %+v, want digest %q parent %q", tabMeta, meta.RecoveryDigest, meta.ParentID)
}
nodes := app.ListProjectTree()
foundOriginal := false
var walk func([]ProjectNode)
walk = func(list []ProjectNode) {
for _, node := range list {
if node.Recovered {
t.Fatalf("project tree should hide recovery metadata, got node %+v", node)
}
if node.TopicID == originalTopic {
foundOriginal = true
}
walk(node.Children)
}
}
walk(nodes)
if !foundOriginal {
t.Fatalf("project tree did not include original topic %q: %#v", originalTopic, nodes)
}
}
func TestDesktopSnapshotConflictRecoveryRequiresRecoveryLease(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
originalPath := filepath.Join(dir, "session.jsonl")
current := agent.NewSession("sys")
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
if err := current.Save(originalPath); err != nil {
t.Fatalf("Save current: %v", err)
}
staleSess := agent.NewSession("sys")
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
recovery, err := staleSess.SaveRecoveryBranch(agent.RecoveryBranchOptions{
OriginalPath: originalPath,
BranchMeta: agent.BranchMeta{
Name: agent.RecoveryBranchDefaultName,
Scope: "global",
TopicID: "topic_recovery",
TopicTitle: "Recovery",
},
})
if err != nil {
t.Fatalf("SaveRecoveryBranch: %v", err)
}
lease, err := agent.TryAcquireSessionLease(recovery.Path)
if err != nil {
t.Fatalf("TryAcquireSessionLease recovery: %v", err)
}
defer lease.Release()
staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
runtimeEvents := make(chan runtimeEventEnvelope, 4)
app := &App{
ctx: context.Background(),
tabs: map[string]*WorkspaceTab{},
detachedSessions: map[string]*WorkspaceTab{},
activeTabID: "recovery_tab",
}
app.runtimeEvents.emit = func(ctx context.Context, name string, payload ...interface{}) {
runtimeEvents <- runtimeEventEnvelope{
ctx: ctx,
name: name,
payload: append([]interface{}(nil), payload...),
}
}
tab := &WorkspaceTab{
ID: "recovery_tab",
Scope: "global",
WorkspaceRoot: root,
TopicID: "topic_original",
TopicTitle: "Original",
SessionPath: originalPath,
Ready: true,
model: "test-model",
disabledMCP: map[string]ServerView{},
}
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
tab.Ctrl = control.New(control.Options{
Executor: staleExec,
SessionDir: dir,
SessionPath: originalPath,
Label: "test",
Sink: tab.sink,
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
OnSessionRecovered: app.handleTabSessionRecovered(tab),
})
app.tabs[tab.ID] = tab
app.mu.Lock()
app.saveTabsLocked()
app.mu.Unlock()
err = tab.Ctrl.Snapshot()
if !errors.Is(err, agent.ErrSessionLeaseHeld) {
t.Fatalf("Snapshot err = %v, want ErrSessionLeaseHeld", err)
}
if got := tab.Ctrl.SessionPath(); got != originalPath {
t.Fatalf("controller session path = %q, want original %q", got, originalPath)
}
if tab.SessionPath != originalPath {
t.Fatalf("tab session path = %q, want original %q", tab.SessionPath, originalPath)
}
if tab.TopicID != "topic_original" {
t.Fatalf("tab topic ID = %q, want original topic", tab.TopicID)
}
saved := loadTabsFile()
if len(saved.Tabs) != 1 || saved.Tabs[0].SessionPath != originalPath {
t.Fatalf("saved tabs after failed recovery = %+v, want original path %q", saved.Tabs, originalPath)
}
deadline := time.After(time.Second)
for {
select {
case emitted := <-runtimeEvents:
if emitted.name != "session:recovery-failed" {
continue
}
if len(emitted.payload) != 1 {
t.Fatalf("session:recovery-failed payload count = %d, want 1", len(emitted.payload))
}
failed, ok := emitted.payload[0].(sessionRecoveryFailedEvent)
if !ok {
t.Fatalf("session:recovery-failed payload type = %T, want sessionRecoveryFailedEvent", emitted.payload[0])
}
if failed.Reason != "lease_held" {
t.Fatalf("session:recovery-failed reason = %q, want lease_held", failed.Reason)
}
return
case <-deadline:
t.Fatal("session:recovery-failed event was not emitted")
}
}
}
func TestSetActiveTabBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
path := filepath.Join(t.TempDir(), "blocked.jsonl")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("mkdir blocked path: %v", err)
}
a, _ := appWithTab(t, path)
a.tabs["target_tab"] = &WorkspaceTab{
ID: "target_tab",
Scope: "global",
Ready: true,
disabledMCP: map[string]ServerView{},
}
a.tabOrder = []string{"test_tab", "target_tab"}
err := a.SetActiveTab("target_tab")
if err == nil || !strings.Contains(err.Error(), "save current session before switching tabs") {
t.Fatalf("SetActiveTab error = %v, want persistence failure", err)
}
if a.activeTabID != "test_tab" {
t.Fatalf("active tab = %q, want original tab after failed save", a.activeTabID)
}
}
func TestRebindSessionBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
path := filepath.Join(t.TempDir(), "blocked.jsonl")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("mkdir blocked path: %v", err)
}
a, tab := appWithTab(t, path)
target := filepath.Join(t.TempDir(), "target.jsonl")
sess := agent.NewSession("system")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "target prompt"})
if err := sess.Save(target); err != nil {
t.Fatalf("save target: %v", err)
}
loaded, err := agent.LoadSession(target)
if err != nil {
t.Fatalf("load target: %v", err)
}
err = a.rebindTabToLoadedSessionPath(tab, target, loaded)
if err == nil || !strings.Contains(err.Error(), "save current session before switching sessions") {
t.Fatalf("rebind error = %v, want persistence failure", err)
}
if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
t.Fatalf("tab controller/path changed after failed save: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
}
}
// TestCloseTabNoResurrectionFromAutosave is the regression test for #4384.
// It proves that after CloseTab returns, the per-turn autosave goroutine can no
// longer write the session file — even when it is in flight at the moment the
// tab is closed. Pre-fix, the loop held a raw *WorkspaceTab pointer and a
// captured session path, so its Snapshot() call landed after DeleteSession
// trashed the file, "resurrecting" it.
func TestCloseTabNoResurrectionFromAutosave(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
doomed, doomedTab := appWithTab(t, path)
// CloseTab needs >1 tab and mutates activeTabID, so add a survivor tab.
survivor := &WorkspaceTab{
ID: "survivor_tab",
Scope: "global",
Ready: true,
disabledMCP: map[string]ServerView{},
}
survivor.sink = &tabEventSink{tabID: survivor.ID, app: doomed}
doomed.tabs["survivor_tab"] = survivor
doomed.activeTabID = "test_tab"
// Write the session file once via the autosave loop, then wait for idle so
// the next TurnDone reliably kicks off a fresh loop.
doomedTab.sink.Emit(event.Event{Kind: event.TurnDone})
waitForFile(t, path, "acknowledged")
waitForAutosaveIdle(t, doomedTab)
// Kick the autosave loop and close the tab in close succession. The loop
// will be in flight when CloseTab runs — exactly the #4384 window.
doomedTab.sink.Emit(event.Event{Kind: event.TurnDone})
if err := doomed.CloseTab("test_tab"); err != nil {
t.Fatalf("CloseTab: %v", err)
}
// CloseTab must have returned only after the autosave loop finished. Remove
// the file the way DeleteSession would (move to trash is just a remove here
// since we only care that nothing rewrites the original path).
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
t.Fatalf("remove session file: %v", err)
}
// Give any would-be resurrection a chance to strike. If the autosave loop
// were still alive (the bug), the file reappears here.
time.Sleep(100 * time.Millisecond)
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("session file resurrected after CloseTab + delete (stat err=%v) — autosave loop not drained", err)
}
// And the controller's session path must be cleared so no future Snapshot
// can write either.
if got := doomedTab.Ctrl.SessionPath(); got != "" {
t.Fatalf("controller session path = %q after CloseTab, want empty so snapshots no-op", got)
}
}
func TestCloseTabBlocksWhenSessionCannotPersist(t *testing.T) {
path := filepath.Join(t.TempDir(), "blocked.jsonl")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("mkdir blocked path: %v", err)
}
a, tab := appWithTab(t, path)
survivor := &WorkspaceTab{
ID: "survivor_tab",
Scope: "global",
Ready: true,
disabledMCP: map[string]ServerView{},
}
survivor.sink = &tabEventSink{tabID: survivor.ID, app: a}
a.tabs[survivor.ID] = survivor
a.tabOrder = []string{tab.ID, survivor.ID}
err := a.CloseTab(tab.ID)
if err == nil || !strings.Contains(err.Error(), "save current session before closing tab") {
t.Fatalf("CloseTab error = %v, want persistence failure", err)
}
if _, ok := a.tabs[tab.ID]; !ok {
t.Fatal("tab was removed even though its session could not be saved")
}
if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
t.Fatalf("tab controller/path changed after failed close: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
}
}
// TestCloseTabSurvivorKeepsAutosave ensures the survivor tab is untouched: the
// closing/drain logic is per-tab and must not leak to other tabs.
func TestCloseTabSurvivorKeepsAutosave(t *testing.T) {
doomedPath := filepath.Join(t.TempDir(), "doomed.jsonl")
survivorPath := filepath.Join(t.TempDir(), "survivor.jsonl")
a, _ := appWithTab(t, doomedPath)
survivorCtrl := controllerWithContent(t, survivorPath)
survivor := &WorkspaceTab{
ID: "survivor_tab",
Ctrl: survivorCtrl,
Scope: "global",
Ready: true,
disabledMCP: map[string]ServerView{},
}
survivor.sink = &tabEventSink{tabID: survivor.ID, app: a}
a.tabs["survivor_tab"] = survivor
a.activeTabID = "test_tab"
survivor.sink.Emit(event.Event{Kind: event.TurnDone})
waitForFile(t, survivorPath, "acknowledged")
waitForAutosaveIdle(t, survivor)
if err := a.CloseTab("test_tab"); err != nil {
t.Fatalf("CloseTab: %v", err)
}
if got := survivor.Ctrl.SessionPath(); got != survivorPath {
t.Fatalf("survivor session path = %q, want %q", got, survivorPath)
}
if survivor.closing {
t.Fatal("survivor tab was marked closing — closing flag leaked across tabs")
}
}
func TestDeleteSessionClearsRemovedRuntimeSessionPath(t *testing.T) {
isolateDesktopUserDirs(t)
dir := t.TempDir()
path := filepath.Join(dir, "delete-open.jsonl")
ctrl := controllerWithContent(t, path)
tab := &WorkspaceTab{
ID: "delete_open",
Scope: "global",
Ready: true,
Ctrl: ctrl,
disabledMCP: map[string]ServerView{},
}
app := &App{
tabs: map[string]*WorkspaceTab{"delete_open": tab},
activeTabID: "delete_open",
}
if err := ctrl.Snapshot(); err != nil {
t.Fatalf("snapshot: %v", err)
}
if err := app.DeleteSession(path); err != nil {
t.Fatalf("DeleteSession: %v", err)
}
if got := ctrl.SessionPath(); got != "" {
t.Fatalf("removed controller session path = %q, want empty before trash move can race Windows file locks", got)
}
trashPath := filepath.Join(dir, sessionTrashDir, "delete-open.jsonl", "delete-open.jsonl")
if _, err := os.Stat(trashPath); err != nil {
t.Fatalf("session should be in trash: %v", err)
}
}
func TestTrashTopicClearsRemovedRuntimeSessionPath(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topicID := "topic_clear_removed_runtime"
if err := addProject(projectRoot, ""); err != nil {
t.Fatalf("add project: %v", err)
}
if err := setTopicTitle(projectRoot, topicID, "Clear removed runtime"); err != nil {
t.Fatalf("set topic title: %v", err)
}
dir := t.TempDir()
path := filepath.Join(dir, "trash-open-topic.jsonl")
ctrl := controllerWithContent(t, path)
if err := ctrl.Snapshot(); err != nil {
t.Fatalf("snapshot: %v", err)
}
if err := agent.SaveBranchMeta(path, agent.BranchMeta{
CreatedAt: time.Now().Add(-time.Minute),
UpdatedAt: time.Now(),
Scope: "project",
WorkspaceRoot: projectRoot,
TopicID: topicID,
TopicTitle: "Clear removed runtime",
}); err != nil {
t.Fatalf("save branch meta: %v", err)
}
tab := &WorkspaceTab{
ID: "trash_open",
Scope: "project",
WorkspaceRoot: projectRoot,
TopicID: topicID,
TopicTitle: "Clear removed runtime",
Ready: true,
Ctrl: ctrl,
disabledMCP: map[string]ServerView{},
}
survivor := &WorkspaceTab{
ID: "survivor",
Scope: "global",
Ready: true,
disabledMCP: map[string]ServerView{},
}
app := &App{
tabs: map[string]*WorkspaceTab{"trash_open": tab, "survivor": survivor},
tabOrder: []string{"trash_open", "survivor"},
activeTabID: "trash_open",
}
if err := app.TrashTopic(topicID); err != nil {
t.Fatalf("TrashTopic: %v", err)
}
if got := ctrl.SessionPath(); got != "" {
t.Fatalf("removed topic controller session path = %q, want empty before trash move can race Windows file locks", got)
}
trashPath := filepath.Join(dir, sessionTrashDir, "trash-open-topic.jsonl", "trash-open-topic.jsonl")
if _, err := os.Stat(trashPath); err != nil {
t.Fatalf("topic session should be in trash: %v", err)
}
}
+14
View File
@@ -0,0 +1,14 @@
package main
import (
"runtime"
"testing"
)
func TestAppPlatformReturnsRuntimeGOOS(t *testing.T) {
app := NewApp()
if got := app.Platform(); got != runtime.GOOS {
t.Fatalf("Platform() = %q, want %q", got, runtime.GOOS)
}
}
+799
View File
@@ -0,0 +1,799 @@
package main
import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
func carryingController(carried []provider.Message, path string) *control.Controller {
sess := &agent.Session{}
sess.Replace(carried)
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
return control.New(control.Options{Executor: ag, SessionPath: path, Sink: event.Discard})
}
// TestCarriedRebuildsKeepOneSession reproduces issue #2807: a model switch or any
// config change rebuilds the controller and carries the conversation forward. Each
// rebuild must keep writing to the same file, so a run of them leaves exactly one
// history entry — not a new identical duplicate per rebuild.
func TestCarriedRebuildsKeepOneSession(t *testing.T) {
dir := t.TempDir()
path := agent.NewSessionPath(dir, "model-a")
ctrl := controllerWithContent(t, path)
if err := ctrl.Snapshot(); err != nil {
t.Fatal(err)
}
for i := 0; i < 5; i++ {
prevPath := ctrl.SessionPath()
carried := ctrl.History()
ctrl.Close()
newPath := agent.ContinueSessionPath(prevPath, dir, "model-b")
ctrl = carryingController(carried, newPath)
if err := ctrl.Snapshot(); err != nil {
t.Fatal(err)
}
}
ctrl.Close()
infos, err := agent.ListSessions(dir)
if err != nil {
t.Fatal(err)
}
if len(infos) != 1 {
paths := make([]string, len(infos))
for i, s := range infos {
paths[i] = filepath.Base(s.Path)
}
t.Fatalf("after 5 carried rebuilds the history shows %d sessions, want 1: %v", len(infos), paths)
}
}
// EnsureBlankTab reuses an already-open blank tab rather than creating a second one.
func TestEnsureBlankTabReusesExistingBlankTab(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
first, err := app.EnsureBlankTab("global", "")
if err != nil {
t.Fatal(err)
}
if first.SessionPath == "" {
t.Fatal("EnsureBlankTab should pre-create a session path for immediate deletion")
}
if _, err := os.Stat(first.SessionPath); err != nil {
t.Fatalf("pre-created blank session should exist: %v", err)
}
second, err := app.EnsureBlankTab("global", "")
if err != nil {
t.Fatal(err)
}
if second.ID != first.ID {
t.Fatalf("EnsureBlankTab created duplicate blank tab: first=%q second=%q", first.ID, second.ID)
}
if tabs := app.ListTabs(); len(tabs) != 1 {
t.Fatalf("ListTabs length = %d, want 1: %+v", len(tabs), tabs)
}
}
func TestEnsureBlankTabReusesPrecreatedBlankBeforeControllerReady(t *testing.T) {
isolateDesktopUserDirs(t)
globalRoot := globalWorkspaceRoot()
if err := os.MkdirAll(globalRoot, 0o755); err != nil {
t.Fatal(err)
}
sessionPath := agent.NewSessionPath(desktopSessionDir(globalRoot), "")
if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
t.Fatal(err)
}
app := NewApp()
topic, err := app.CreateTopic("global", "", "")
if err != nil {
t.Fatalf("create topic: %v", err)
}
app.tabs["blank"] = &WorkspaceTab{
ID: "blank",
Scope: "global",
WorkspaceRoot: globalRoot,
TopicID: topic.ID,
TopicTitle: defaultTopicTitle,
SessionPath: sessionPath,
disabledMCP: map[string]ServerView{},
}
app.tabOrder = []string{"blank"}
app.activeTabID = "blank"
meta, err := app.EnsureBlankTab("global", "")
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
if meta.ID != "blank" {
t.Fatalf("EnsureBlankTab created duplicate blank tab %q, want existing pre-created blank", meta.ID)
}
}
func TestEnsureBlankTabReusesIndexedTopicWithEmptyStub(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
topic, err := app.CreateTopic("global", "", "")
if err != nil {
t.Fatalf("create topic: %v", err)
}
globalRoot := globalWorkspaceRoot()
dir := desktopSessionDir(globalRoot)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
stubPath := filepath.Join(dir, "empty-stub.jsonl")
if err := os.WriteFile(stubPath, nil, 0o644); err != nil {
t.Fatalf("write empty stub: %v", err)
}
now := time.Now()
if err := agent.SaveBranchMetaPreserveUpdated(stubPath, agent.BranchMeta{
CreatedAt: now.Add(-time.Minute),
UpdatedAt: now,
Scope: "global",
WorkspaceRoot: globalRoot,
TopicID: topic.ID,
TopicTitle: defaultTopicTitle,
}); err != nil {
t.Fatalf("save branch meta: %v", err)
}
meta, err := app.EnsureBlankTab("global", "")
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
if meta.TopicID != topic.ID {
t.Fatalf("EnsureBlankTab topic = %q, want reused empty topic %q", meta.TopicID, topic.ID)
}
}
func TestEnsureBlankTabStoresCreatedAt(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
before := time.Now().UnixMilli()
meta, err := app.EnsureBlankTab("global", "")
after := time.Now().UnixMilli()
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
createdAt := loadTopicCreatedAt("", meta.TopicID)
if createdAt < before || createdAt > after {
t.Fatalf("createdAt = %d, want between %d and %d", createdAt, before, after)
}
nodes := app.ListProjectTree()
if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 {
t.Fatalf("project tree = %#v, want Global with one topic", nodes)
}
if got := nodes[0].Children[0].CreatedAt; got != createdAt {
t.Fatalf("project tree createdAt = %d, want %d", got, createdAt)
}
}
func TestEnsureBlankTabRepairsMissingCreatedAtForReusedTopic(t *testing.T) {
isolateDesktopUserDirs(t)
const topicID = "topic_20260704-104018_deadbeef"
if err := setTopicTitleWithSource("", topicID, defaultTopicTitle, topicTitleSourceAuto); err != nil {
t.Fatalf("set topic title: %v", err)
}
if err := prependTopicInProjectsFile("", topicID, false); err != nil {
t.Fatalf("prepend topic: %v", err)
}
if got := loadTopicCreatedAt("", topicID); got != 0 {
t.Fatalf("createdAt before reuse = %d, want 0", got)
}
app := NewApp()
meta, err := app.EnsureBlankTab("global", "")
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
if meta.TopicID != topicID {
t.Fatalf("EnsureBlankTab topic = %q, want reused topic %q", meta.TopicID, topicID)
}
expected := time.Date(2026, 7, 4, 10, 40, 18, 0, time.UTC).UnixMilli()
if got := loadTopicCreatedAt("", topicID); got != expected {
t.Fatalf("repaired createdAt = %d, want %d", got, expected)
}
}
// EnsureBlankTab reuses an already-open project-scoped blank tab.
func TestEnsureBlankTabCreatesOneBlankPerProject(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
app := NewApp()
first, err := app.EnsureBlankTab("project", projectRoot)
if err != nil {
t.Fatal(err)
}
second, err := app.EnsureBlankTab("project", projectRoot)
if err != nil {
t.Fatal(err)
}
if second.ID != first.ID {
t.Fatalf("EnsureBlankTab created duplicate project blank tab: first=%q second=%q", first.ID, second.ID)
}
if tabs := app.ListTabs(); len(tabs) != 1 {
t.Fatalf("ListTabs length = %d, want 1: %+v", len(tabs), tabs)
}
}
func TestEnsureBlankTabStartsProjectRuntimeWithCurrentWorkspacePrompt(t *testing.T) {
isolateDesktopUserDirs(t)
projectA := robustTempDir(t)
projectB := robustTempDir(t)
if err := addProject(projectA, "Project A"); err != nil {
t.Fatalf("add project A: %v", err)
}
if err := addProject(projectB, "Project B"); err != nil {
t.Fatalf("add project B: %v", err)
}
app := NewApp()
first, err := app.EnsureBlankTab("project", projectA)
if err != nil {
t.Fatalf("EnsureBlankTab(project A): %v", err)
}
tabA := waitForTabReady(t, app, first.ID)
if got := normalizeProjectRoot(tabA.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectA) {
t.Fatalf("project A controller workspace root = %q, want %q", got, normalizeProjectRoot(projectA))
}
second, err := app.EnsureBlankTab("project", projectB)
if err != nil {
t.Fatalf("EnsureBlankTab(project B): %v", err)
}
if second.ID == first.ID {
t.Fatalf("EnsureBlankTab reused project A tab %q for project B", second.ID)
}
tabB := waitForTabReady(t, app, second.ID)
if got := normalizeProjectRoot(tabB.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
t.Fatalf("project B tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
}
if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
t.Fatalf("project B controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
}
if !sameDesktopPath(tabB.Ctrl.SessionDir(), desktopSessionDir(projectB)) {
t.Fatalf("project B controller session dir = %q, want %q", tabB.Ctrl.SessionDir(), desktopSessionDir(projectB))
}
if !sameDesktopPath(filepath.Dir(tabB.Ctrl.SessionPath()), desktopSessionDir(projectB)) {
t.Fatalf("project B controller session path = %q, want under %q", tabB.Ctrl.SessionPath(), desktopSessionDir(projectB))
}
sys := systemPromptFrom(tabB.Ctrl.History())
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
t.Fatalf("project B system prompt missing current workspace %q:\n%s", projectB, sys)
}
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
t.Fatalf("project B system prompt retained project A workspace %q:\n%s", projectA, sys)
}
}
func TestBlankTabSessionPathRejectsOtherProjectWorkspace(t *testing.T) {
isolateDesktopUserDirs(t)
projectA := robustTempDir(t)
projectB := robustTempDir(t)
pathA, err := createEmptySessionFile(desktopSessionDir(projectA), "test-model")
if err != nil {
t.Fatalf("create project A empty session: %v", err)
}
tab := &WorkspaceTab{
ID: "blank-project-b",
Scope: "project",
WorkspaceRoot: projectB,
SessionPath: pathA,
}
if blankTabSessionPathHasNoContent(tab) {
t.Fatalf("blank tab treated session %q from project A as reusable for project B %q", pathA, projectB)
}
}
func TestForkKeepsProjectWorkspacePrompt(t *testing.T) {
isolateDesktopUserDirs(t)
projectA := robustTempDir(t)
projectB := robustTempDir(t)
if err := addProject(projectA, "Project A"); err != nil {
t.Fatalf("add project A: %v", err)
}
if err := addProject(projectB, "Project B"); err != nil {
t.Fatalf("add project B: %v", err)
}
app := NewApp()
first, err := app.EnsureBlankTab("project", projectA)
if err != nil {
t.Fatalf("EnsureBlankTab(project A): %v", err)
}
waitForTabReady(t, app, first.ID)
second, err := app.EnsureBlankTab("project", projectB)
if err != nil {
t.Fatalf("EnsureBlankTab(project B): %v", err)
}
tabB := waitForTabReady(t, app, second.ID)
ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
forked, err := app.Fork(turn)
if err != nil {
t.Fatalf("Fork: %v", err)
}
if forked.ID == "" || forked.ID == second.ID {
t.Fatalf("forked tab ID = %q, want a fresh tab distinct from %q", forked.ID, second.ID)
}
forkTab := waitForTabReady(t, app, forked.ID)
if got := normalizeProjectRoot(forkTab.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
t.Fatalf("fork tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
}
if got := normalizeProjectRoot(forkTab.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
t.Fatalf("fork controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
}
sys := systemPromptFrom(forkTab.Ctrl.History())
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
t.Fatalf("fork system prompt missing project B workspace %q:\n%s", projectB, sys)
}
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
t.Fatalf("fork system prompt retained project A workspace %q:\n%s", projectA, sys)
}
}
func TestRewindKeepsProjectWorkspacePrompt(t *testing.T) {
isolateDesktopUserDirs(t)
projectA := robustTempDir(t)
projectB := robustTempDir(t)
if err := addProject(projectA, "Project A"); err != nil {
t.Fatalf("add project A: %v", err)
}
if err := addProject(projectB, "Project B"); err != nil {
t.Fatalf("add project B: %v", err)
}
app := NewApp()
first, err := app.EnsureBlankTab("project", projectA)
if err != nil {
t.Fatalf("EnsureBlankTab(project A): %v", err)
}
waitForTabReady(t, app, first.ID)
second, err := app.EnsureBlankTab("project", projectB)
if err != nil {
t.Fatalf("EnsureBlankTab(project B): %v", err)
}
tabB := waitForTabReady(t, app, second.ID)
ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
if err := app.Rewind(turn, "conversation"); err != nil {
t.Fatalf("Rewind: %v", err)
}
if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
t.Fatalf("rewound controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
}
sys := systemPromptFrom(tabB.Ctrl.History())
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
t.Fatalf("rewound system prompt missing project B workspace %q:\n%s", projectB, sys)
}
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
t.Fatalf("rewound system prompt retained project A workspace %q:\n%s", projectA, sys)
}
}
func installStubControllerWithCurrentPrompt(t *testing.T, app *App, tab *WorkspaceTab) *control.Controller {
t.Helper()
if tab == nil || tab.Ctrl == nil {
t.Fatal("tab controller is required")
}
sys := systemPromptFrom(tab.Ctrl.History())
if strings.TrimSpace(sys) == "" {
t.Fatal("tab controller did not expose a system prompt")
}
sessionDir := tab.Ctrl.SessionDir()
sessionPath := tab.Ctrl.SessionPath()
workspaceRoot := tab.Ctrl.WorkspaceRoot()
label := tab.Ctrl.Label()
tab.Ctrl.Close()
sess := agent.NewSession(sys)
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
ctrl := control.New(control.Options{
Runner: ag,
Executor: ag,
SessionDir: sessionDir,
SessionPath: sessionPath,
WorkspaceRoot: workspaceRoot,
Label: label,
SystemPrompt: sys,
Sink: event.Discard,
})
tab.Ctrl = ctrl
app.bindControllerDisplayRecorder(ctrl)
return ctrl
}
func submitStubTurnAndWaitForCheckpoint(t *testing.T, ctrl control.SessionAPI, input string) int {
t.Helper()
ctrl.SubmitUserTurn(input, input)
waitNotRunning(t, ctrl)
deadline := time.Now().Add(time.Second)
for {
checkpoints := ctrl.Checkpoints()
if len(checkpoints) > 0 {
return checkpoints[len(checkpoints)-1].Turn
}
if time.Now().After(deadline) {
t.Fatal("controller did not record a checkpoint")
}
time.Sleep(10 * time.Millisecond)
}
}
func TestEnsureBlankTabResetsReusableAutoTopicTitle(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
app := NewApp()
topic, err := app.CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatalf("create topic: %v", err)
}
if err := setTopicTitleWithSource(projectRoot, topic.ID, "Old auto title", topicTitleSourceAuto); err != nil {
t.Fatalf("set stale auto title: %v", err)
}
tab := app.createTabEntryWithID("project", projectRoot, topic.ID, "tab1")
app.tabs[tab.ID] = tab
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
meta, err := app.EnsureBlankTab("project", projectRoot)
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
if got := meta.TopicTitle; got != defaultTopicTitle {
t.Fatalf("reused auto topic title = %q, want %q", got, defaultTopicTitle)
}
if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
t.Fatalf("stored title = %q, want %q", got, defaultTopicTitle)
}
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceAuto {
t.Fatalf("title source = %q, want auto", got)
}
}
func TestEnsureBlankTabPreservesReusableManualTopicTitle(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
app := NewApp()
topic, err := app.CreateTopic("project", projectRoot, "Manual title")
if err != nil {
t.Fatalf("create topic: %v", err)
}
tab := app.createTabEntryWithID("project", projectRoot, topic.ID, "tab1")
app.tabs[tab.ID] = tab
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
meta, err := app.EnsureBlankTab("project", projectRoot)
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
if got := meta.TopicTitle; got != "Manual title" {
t.Fatalf("reused manual topic title = %q, want Manual title", got)
}
if got := loadTopicTitle(projectRoot, topic.ID); got != "Manual title" {
t.Fatalf("stored title = %q, want Manual title", got)
}
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
t.Fatalf("title source = %q, want manual", got)
}
}
func TestEnsureBlankTabKeepsActiveTabWhenTitleResetFails(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
app := NewApp()
topic, err := app.CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatalf("create topic: %v", err)
}
if err := setTopicTitleWithSource(projectRoot, topic.ID, "Old auto title", topicTitleSourceAuto); err != nil {
t.Fatalf("set stale auto title: %v", err)
}
activeTab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "active-tab")
reusableTab := app.createTabEntryWithID("project", projectRoot, topic.ID, "reusable-tab")
app.tabs[activeTab.ID] = activeTab
app.tabs[reusableTab.ID] = reusableTab
app.tabOrder = []string{activeTab.ID, reusableTab.ID}
app.activeTabID = activeTab.ID
titlePath := topicTitlesPath(projectRoot)
if err := os.Remove(titlePath); err != nil {
t.Fatalf("remove title file: %v", err)
}
if err := os.Mkdir(titlePath, 0o755); err != nil {
t.Fatalf("replace title file with directory: %v", err)
}
if _, err := app.EnsureBlankTab("project", projectRoot); err == nil {
t.Fatal("EnsureBlankTab succeeded, want title reset error")
}
if got := app.activeTabID; got != activeTab.ID {
t.Fatalf("active tab after failed title reset = %q, want %q", got, activeTab.ID)
}
}
// EnsureBlankTab picks up an existing blank topic created in the sidebar
// instead of creating a fresh topic, for global scope.
func TestEnsureBlankTabOpensExistingSidebarBlankTopic(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
topic, err := app.CreateTopic("global", "", "")
if err != nil {
t.Fatal(err)
}
meta, err := app.EnsureBlankTab("global", "")
if err != nil {
t.Fatal(err)
}
if meta.TopicID != topic.ID {
t.Fatalf("EnsureBlankTab opened topic %q, want existing blank topic %q", meta.TopicID, topic.ID)
}
if topics := loadProjectsFile().GlobalTopics; len(topics) != 1 {
t.Fatalf("global topics length = %d, want 1: %v", len(topics), topics)
}
}
// EnsureBlankTab picks up an existing blank topic created in the sidebar
// instead of creating a fresh topic, for project scope.
func TestEnsureBlankTabOpensExistingProjectSidebarBlankTopic(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
app := NewApp()
topic, err := app.CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatal(err)
}
meta, err := app.EnsureBlankTab("project", projectRoot)
if err != nil {
t.Fatal(err)
}
if meta.TopicID != topic.ID {
t.Fatalf("EnsureBlankTab opened topic %q, want existing blank topic %q", meta.TopicID, topic.ID)
}
var topics []string
for _, project := range loadProjectsFile().Projects {
if project.Root == projectRoot {
topics = project.Topics
break
}
}
if len(topics) != 1 {
t.Fatalf("project topics length = %d, want 1: %v", len(topics), topics)
}
}
func TestEnsureBlankTabDoesNotReuseProjectTopicWithSession(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := robustTempDir(t)
app := NewApp()
topic, err := app.CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatalf("CreateTopic: %v", err)
}
dir := desktopSessionDir(projectRoot)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
existingPath := writeTopicSession(t, dir, "existing.jsonl", topic.ID, defaultTopicTitle, projectRoot)
if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != existingPath {
t.Fatalf("precondition topic session = %q, want %q", got, existingPath)
}
meta, err := app.EnsureBlankTab("project", projectRoot)
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
if meta.TopicID == topic.ID {
t.Fatalf("EnsureBlankTab reused topic %q even though it already has session %q", topic.ID, existingPath)
}
if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != existingPath {
t.Fatalf("existing topic session changed = %q, want %q", got, existingPath)
}
}
// EnsureBlankTab must not reuse a tombstoned topic: the reused ID would flow
// into ensureTopicIndexed, whose intentional prepend clears the delete
// tombstone and resurrects the topic the user removed.
func TestEnsureBlankTabDoesNotReuseTombstonedTopic(t *testing.T) {
isolateDesktopUserDirs(t)
// Race product on disk: deleted topic whose default title lingered in the
// global title map (title-only, absent from GlobalTopics, no sessions).
tombstonedID := "topic_tombstone_blank"
if err := setTopicTitle("", tombstonedID, defaultTopicTitle); err != nil {
t.Fatalf("set lingering title: %v", err)
}
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
f.DeletedTopics = prependUniqueString(f.DeletedTopics, tombstonedID)
return true, nil
}); err != nil {
t.Fatalf("seed tombstone: %v", err)
}
meta, err := NewApp().EnsureBlankTab("global", "")
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
if meta.TopicID == tombstonedID {
t.Fatalf("EnsureBlankTab reused tombstoned topic %q", meta.TopicID)
}
f := loadProjectsFile()
if !containsDesktopString(f.DeletedTopics, tombstonedID) {
t.Fatalf("deletedTopics = %#v, tombstone must survive blank-tab creation", f.DeletedTopics)
}
if containsDesktopString(f.GlobalTopics, tombstonedID) {
t.Fatalf("globalTopics = %#v, tombstoned topic must not be re-indexed", f.GlobalTopics)
}
}
// NewSession skips the snapshot when the current tab has no real conversation content.
func TestNewSessionNoopsWhenCurrentTabIsBlank(t *testing.T) {
isolateDesktopUserDirs(t)
dir := t.TempDir()
path := agent.NewSessionPath(dir, "model-a")
ctrl := carryingController([]provider.Message{{Role: provider.RoleSystem, Content: "sys"}}, path)
app := NewApp()
app.setTestCtrl(ctrl, "model-a")
if err := app.NewSession(); err != nil {
t.Fatal(err)
}
if got := ctrl.SessionPath(); got != path {
t.Fatalf("blank NewSession changed session path = %q, want %q", got, path)
}
}
func TestNewSessionUsesFreshTopicIdentity(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
oldTopicID := "topic_old"
oldTopicTitle := "Old topic"
oldPath := writeTopicSessionWithPrompt(t, dir, "old.jsonl", oldTopicID, oldTopicTitle, projectRoot, "old prompt", time.Now().Add(-time.Hour))
sess := &agent.Session{}
sess.Replace([]provider.Message{{Role: provider.RoleUser, Content: "old prompt"}})
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, SessionPath: oldPath, Sink: event.Discard})
app := NewApp()
app.setTestCtrl(ctrl, "model-a")
tab := app.tabs["test"]
tab.Scope = "project"
tab.WorkspaceRoot = projectRoot
tab.TopicID = oldTopicID
tab.TopicTitle = oldTopicTitle
tab.SessionPath = oldPath
app.projectTreeChangedHook = func() {}
if err := app.NewSession(); err != nil {
t.Fatalf("NewSession: %v", err)
}
if got := tab.TopicID; got == "" || got == oldTopicID {
t.Fatalf("new session topic ID = %q, want fresh ID distinct from %q", got, oldTopicID)
}
if got := tab.TopicTitle; got != defaultTopicTitle {
t.Fatalf("new session topic title = %q, want %q", got, defaultTopicTitle)
}
newPath := ctrl.SessionPath()
if newPath == "" || filepath.Clean(newPath) == filepath.Clean(oldPath) {
t.Fatalf("new session path = %q, want fresh path distinct from %q", newPath, oldPath)
}
if err := os.WriteFile(newPath, []byte(`{"role":"user","content":"new prompt"}`+"\n"), 0o644); err != nil {
t.Fatalf("write new session: %v", err)
}
if !app.maybeAutoTitleTopic(tab) {
t.Fatalf("new session should auto-title its fresh topic")
}
oldMeta, ok, err := agent.LoadBranchMeta(oldPath)
if err != nil || !ok {
t.Fatalf("load old meta: ok=%v err=%v", ok, err)
}
if oldMeta.TopicID != oldTopicID || oldMeta.TopicTitle != oldTopicTitle {
t.Fatalf("old session meta changed after new session auto-title: %+v", oldMeta)
}
newMeta, ok, err := agent.LoadBranchMeta(newPath)
if err != nil || !ok {
t.Fatalf("load new meta: ok=%v err=%v", ok, err)
}
if newMeta.TopicID != tab.TopicID || newMeta.TopicTitle != "new prompt" {
t.Fatalf("new session meta = %+v, want topic %q titled new prompt", newMeta, tab.TopicID)
}
}
func TestNewSessionKeepsFreshRuntimeWhenTopicRepairFails(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
path := agent.NewSessionPath(dir, "model-a")
ctrl := controllerWithContent(t, path)
app := NewApp()
app.projectTreeChangedHook = func() {}
app.setTestCtrl(ctrl, "model-a")
tab := app.tabs["test"]
tab.TopicID = "topic_old"
tab.TopicTitle = "Old topic"
// Block desktopConfigDir-backed topic-index writes without affecting the
// session directory, which exercises the post-NewSession repair failure path.
if err := os.MkdirAll(filepath.Dir(desktopConfigDir()), 0o755); err != nil {
t.Fatalf("mkdir desktop config parent: %v", err)
}
if err := os.WriteFile(desktopConfigDir(), []byte("not-a-directory"), 0o644); err != nil {
t.Fatalf("block desktop config dir: %v", err)
}
if err := app.NewSession(); err != nil {
t.Fatalf("NewSession should keep the fresh runtime even when topic repair fails: %v", err)
}
if got := tab.TopicID; got == "" || got == "topic_old" {
t.Fatalf("new session topic ID = %q, want fresh ID distinct from the old topic", got)
}
if got := tab.TopicTitle; got != defaultTopicTitle {
t.Fatalf("new session topic title = %q, want %q", got, defaultTopicTitle)
}
if got := ctrl.SessionPath(); got == "" || filepath.Clean(got) == filepath.Clean(path) {
t.Fatalf("new session path = %q, want a fresh path distinct from %q", got, path)
}
}
+10274
View File
File diff suppressed because it is too large. Load diff
+183
View File
@@ -0,0 +1,183 @@
package main
import (
"bytes"
"encoding/binary"
"fmt"
"image"
"image/color"
"image/png"
"os"
"testing"
)
func TestAppIconPNGUsesBlueFullCanvasRoundedBackground(t *testing.T) {
f, err := os.Open("build/appicon.png")
if err != nil {
t.Fatal(err)
}
defer f.Close()
img, err := png.Decode(f)
if err != nil {
t.Fatal(err)
}
assertFullCanvasRoundedIcon(t, img, 1024)
}
func TestWindowsICOUsesBlueFullCanvasRoundedBackground(t *testing.T) {
for _, size := range []int{16, 24, 32, 48, 64, 256} {
t.Run(fmt.Sprintf("%dx%d", size, size), func(t *testing.T) {
img := decodeICOImage(t, "build/windows/icon.ico", size)
assertFullCanvasRoundedIcon(t, img, size)
})
}
}
func assertFullCanvasRoundedIcon(t *testing.T, img image.Image, size int) {
t.Helper()
bounds := img.Bounds()
if bounds.Dx() != size || bounds.Dy() != size {
t.Fatalf("app icon must be square, got %dx%d", bounds.Dx(), bounds.Dy())
}
corners := []struct {
name string
x int
y int
}{
{"top-left", bounds.Min.X, bounds.Min.Y},
{"top-right", bounds.Max.X - 1, bounds.Min.Y},
{"bottom-left", bounds.Min.X, bounds.Max.Y - 1},
{"bottom-right", bounds.Max.X - 1, bounds.Max.Y - 1},
}
for _, corner := range corners {
_, _, _, a := img.At(corner.x, corner.y).RGBA()
if a > 0xff {
t.Fatalf("%s corner must be transparent, alpha=%d", corner.name, a)
}
}
_, _, _, centerAlpha := img.At(bounds.Min.X+bounds.Dx()/2, bounds.Min.Y+bounds.Dy()/2).RGBA()
if centerAlpha == 0 {
t.Fatal("app icon center must contain visible artwork")
}
edgePoints := []struct {
name string
x int
y int
}{
{"top", bounds.Min.X + bounds.Dx()/2, bounds.Min.Y},
{"right", bounds.Max.X - 1, bounds.Min.Y + bounds.Dy()/2},
{"bottom", bounds.Min.X + bounds.Dx()/2, bounds.Max.Y - 1},
{"left", bounds.Min.X, bounds.Min.Y + bounds.Dy()/2},
}
for _, point := range edgePoints {
_, _, _, a := img.At(point.x, point.y).RGBA()
if a == 0 {
t.Fatalf("%s edge must contain visible rounded-rect background", point.name)
}
assertReasonixBlue(t, point.name, img.At(point.x, point.y))
}
}
func assertReasonixBlue(t *testing.T, name string, colorValue color.Color) {
t.Helper()
r16, g16, b16, _ := colorValue.RGBA()
r, g, b := uint8(r16>>8), uint8(g16>>8), uint8(b16>>8)
if !near(r, 0x01, 2) || !near(g, 0x53, 2) || !near(b, 0xe5, 2) {
t.Fatalf("%s edge must use Reasonix blue background, got #%02x%02x%02x", name, r, g, b)
}
}
func near(got, want uint8, tolerance uint8) bool {
if got > want {
return got-want <= tolerance
}
return want-got <= tolerance
}
func decodeICOImage(t *testing.T, path string, size int) image.Image {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
r := bytes.NewReader(data)
var header struct {
Reserved uint16
Type uint16
Count uint16
}
if err := binary.Read(r, binary.LittleEndian, &header); err != nil {
t.Fatal(err)
}
if header.Reserved != 0 || header.Type != 1 {
t.Fatalf("invalid ICO header: reserved=%d type=%d", header.Reserved, header.Type)
}
type iconEntry struct {
Width uint8
Height uint8
ColorCount uint8
Reserved uint8
Planes uint16
BitCount uint16
BytesInRes uint32
ImageOffset uint32
}
entries := make([]iconEntry, header.Count)
for i := range entries {
if err := binary.Read(r, binary.LittleEndian, &entries[i]); err != nil {
t.Fatal(err)
}
}
expectedSizes := map[int]bool{16: false, 24: false, 32: false, 48: false, 64: false, 256: false}
targetIndex := -1
for i, entry := range entries {
width := int(entry.Width)
height := int(entry.Height)
if width == 0 {
width = 256
}
if height == 0 {
height = 256
}
if width != height {
t.Fatalf("ICO image must be square, got %dx%d", width, height)
}
if _, ok := expectedSizes[width]; ok {
expectedSizes[width] = true
}
if width == size {
targetIndex = i
}
}
for expectedSize, found := range expectedSizes {
if !found {
t.Fatalf("ICO is missing %dx%d image", expectedSize, expectedSize)
}
}
if targetIndex < 0 {
t.Fatalf("ICO is missing %dx%d image", size, size)
}
entry := entries[targetIndex]
end := int(entry.ImageOffset + entry.BytesInRes)
if end > len(data) {
t.Fatalf("ICO image offset exceeds file size: offset=%d size=%d file=%d", entry.ImageOffset, entry.BytesInRes, len(data))
}
img, err := png.Decode(bytes.NewReader(data[entry.ImageOffset:end]))
if err != nil {
t.Fatal(err)
}
return img
}
+427
View File
@@ -0,0 +1,427 @@
package main
import (
"context"
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
)
const desktopTinyPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
func TestWorkspaceRelativeIn(t *testing.T) {
root := t.TempDir()
if rel, ok := workspaceRelativeIn(filepath.Join(root, "sub", "file.go"), root); !ok || rel != "sub/file.go" {
t.Fatalf("in-tree = (%q, %v), want (sub/file.go, true)", rel, ok)
}
if _, ok := workspaceRelativeIn(filepath.Join(filepath.Dir(root), "sibling.txt"), root); ok {
t.Fatal("a path above the workspace must not resolve as in-tree")
}
}
func TestIsImageExt(t *testing.T) {
for _, p := range []string{"a.png", "A.PNG", "b.jpeg", "c.webp"} {
if !isImageExt(p) {
t.Errorf("%q should be an image extension", p)
}
}
for _, p := range []string{"notes.pdf", "main.go", "noext"} {
if isImageExt(p) {
t.Errorf("%q should not be an image extension", p)
}
}
}
func TestSavePastedImageUsesActiveWorkspaceRoot(t *testing.T) {
orig, _ := os.Getwd()
defer os.Chdir(orig)
launchRoot := t.TempDir()
projectRoot := t.TempDir()
if err := os.Chdir(projectRoot); err != nil {
t.Fatal(err)
}
projectRoot, _ = os.Getwd()
if err := os.Chdir(launchRoot); err != nil {
t.Fatal(err)
}
app := &App{
tabs: map[string]*WorkspaceTab{
"project": {ID: "project", WorkspaceRoot: projectRoot},
},
activeTabID: "project",
}
got, err := app.SavePastedImage("data:image/png;base64," + desktopTinyPNG)
if err != nil {
t.Fatalf("SavePastedImage: %v", err)
}
if _, err := os.Stat(filepath.Join(projectRoot, filepath.FromSlash(got))); err != nil {
t.Fatalf("pasted image should be saved under active workspace: %v", err)
}
if _, err := os.Stat(filepath.Join(launchRoot, filepath.FromSlash(got))); !os.IsNotExist(err) {
t.Fatalf("pasted image should not be saved under launch root, stat err=%v", err)
}
preview, err := app.AttachmentDataURL(got)
if err != nil {
t.Fatalf("AttachmentDataURL: %v", err)
}
if !strings.HasPrefix(preview, "data:image/png;base64,") {
t.Fatalf("preview = %q, want png data URL", preview)
}
}
func TestSavePastedImageUsesPinnedSessionOwnerBeforeStaleWorkspaceRoot(t *testing.T) {
isolateDesktopUserDirs(t)
orig, _ := os.Getwd()
defer os.Chdir(orig)
launchRoot := t.TempDir()
projectA := t.TempDir()
projectB := t.TempDir()
if err := addProject(projectA, "Project A"); err != nil {
t.Fatalf("add project A: %v", err)
}
if err := addProject(projectB, "Project B"); err != nil {
t.Fatalf("add project B: %v", err)
}
sessionDirA := desktopSessionDir(projectA)
if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
t.Fatalf("mkdir project A sessions: %v", err)
}
sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", "topic_attach_owner", "Attach owner", projectA, "project A prompt", time.Now())
if err := os.Chdir(launchRoot); err != nil {
t.Fatal(err)
}
app := &App{
tabs: map[string]*WorkspaceTab{
"project": {ID: "project", Scope: "project", WorkspaceRoot: projectB, SessionPath: sessionPathA},
},
activeTabID: "project",
}
got, err := app.SavePastedImage("data:image/png;base64," + desktopTinyPNG)
if err != nil {
t.Fatalf("SavePastedImage: %v", err)
}
if _, err := os.Stat(filepath.Join(projectA, filepath.FromSlash(got))); err != nil {
t.Fatalf("pasted image should be saved under pinned session owner project A: %v", err)
}
if _, err := os.Stat(filepath.Join(projectB, filepath.FromSlash(got))); !os.IsNotExist(err) {
t.Fatalf("pasted image should not be saved under stale project B, stat err=%v", err)
}
if gotRoot := normalizeProjectRoot(app.tabs["project"].WorkspaceRoot); gotRoot != normalizeProjectRoot(projectA) {
t.Fatalf("tab workspace root = %q, want project A %q", gotRoot, normalizeProjectRoot(projectA))
}
}
func TestAttachDroppedUsesActiveWorkspaceRoot(t *testing.T) {
orig, _ := os.Getwd()
defer os.Chdir(orig)
launchRoot := t.TempDir()
projectRoot := t.TempDir()
if err := os.Chdir(projectRoot); err != nil {
t.Fatal(err)
}
projectRoot, _ = os.Getwd()
if err := os.Chdir(launchRoot); err != nil {
t.Fatal(err)
}
app := &App{
tabs: map[string]*WorkspaceTab{
"project": {ID: "project", WorkspaceRoot: projectRoot},
},
activeTabID: "project",
}
if err := os.MkdirAll(filepath.Join(projectRoot, "sub"), 0o755); err != nil {
t.Fatal(err)
}
target := filepath.Join(projectRoot, "sub", "notes.txt")
if err := os.WriteFile(target, []byte("body"), 0o644); err != nil {
t.Fatal(err)
}
got, err := app.AttachDropped(target)
if err != nil {
t.Fatalf("AttachDropped: %v", err)
}
if got.Kind != "workspace" || got.Path != "sub/notes.txt" {
t.Fatalf("got %+v, want workspace ref sub/notes.txt", got)
}
}
func TestAttachDroppedImageUsesActiveWorkspaceRoot(t *testing.T) {
orig, _ := os.Getwd()
defer os.Chdir(orig)
launchRoot := t.TempDir()
projectRoot := t.TempDir()
if err := os.Chdir(launchRoot); err != nil {
t.Fatal(err)
}
app := &App{
tabs: map[string]*WorkspaceTab{
"project": {ID: "project", WorkspaceRoot: projectRoot},
},
activeTabID: "project",
}
raw, err := base64.StdEncoding.DecodeString(desktopTinyPNG)
if err != nil {
t.Fatal(err)
}
outside := filepath.Join(t.TempDir(), "shot.png")
if err := os.WriteFile(outside, raw, 0o644); err != nil {
t.Fatal(err)
}
got, err := app.AttachDropped(outside)
if err != nil {
t.Fatalf("AttachDropped: %v", err)
}
if got.Kind != "attachment" || !strings.HasSuffix(got.Path, ".png") {
t.Fatalf("got %+v, want png attachment", got)
}
if _, err := os.Stat(filepath.Join(projectRoot, filepath.FromSlash(got.Path))); err != nil {
t.Fatalf("dropped image should be saved under active workspace: %v", err)
}
if _, err := os.Stat(filepath.Join(launchRoot, filepath.FromSlash(got.Path))); !os.IsNotExist(err) {
t.Fatalf("dropped image should not be saved under launch root, stat err=%v", err)
}
if !strings.HasPrefix(got.PreviewURL, "data:image/png;base64,") {
t.Fatalf("preview = %q, want png data URL", got.PreviewURL)
}
}
func TestAttachDroppedInWorkspaceReferencesInPlace(t *testing.T) {
orig, _ := os.Getwd()
defer os.Chdir(orig)
root := t.TempDir()
if err := os.Chdir(root); err != nil {
t.Fatal(err)
}
cwd, _ := os.Getwd()
if err := os.MkdirAll(filepath.Join(cwd, "sub"), 0o755); err != nil {
t.Fatal(err)
}
target := filepath.Join(cwd, "sub", "notes.txt")
if err := os.WriteFile(target, []byte("body"), 0o644); err != nil {
t.Fatal(err)
}
got, err := (&App{}).AttachDropped(target)
if err != nil {
t.Fatalf("AttachDropped: %v", err)
}
if got.Kind != "workspace" || got.Path != "sub/notes.txt" {
t.Fatalf("got %+v, want workspace ref sub/notes.txt", got)
}
}
func TestAttachDroppedOutsideWorkspaceCopiesToAttachments(t *testing.T) {
orig, _ := os.Getwd()
defer os.Chdir(orig)
outside := filepath.Join(t.TempDir(), "report.pdf")
if err := os.WriteFile(outside, []byte("%PDF body"), 0o644); err != nil {
t.Fatal(err)
}
root := t.TempDir()
if err := os.Chdir(root); err != nil {
t.Fatal(err)
}
got, err := (&App{}).AttachDropped(outside)
if err != nil {
t.Fatalf("AttachDropped: %v", err)
}
if got.Kind != "attachment" || !strings.HasPrefix(got.Path, ".reasonix/attachments/") || !strings.HasSuffix(got.Path, ".pdf") {
t.Fatalf("got %+v, want copied pdf attachment", got)
}
}
func TestAttachDroppedImageStoresThumbnail(t *testing.T) {
orig, _ := os.Getwd()
defer os.Chdir(orig)
root := t.TempDir()
if err := os.Chdir(root); err != nil {
t.Fatal(err)
}
cwd, _ := os.Getwd()
png := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 64)...)
if err := os.WriteFile(filepath.Join(cwd, "shot.png"), png, 0o644); err != nil {
t.Fatal(err)
}
got, err := (&App{}).AttachDropped(filepath.Join(cwd, "shot.png"))
if err != nil {
t.Fatalf("AttachDropped: %v", err)
}
if got.Kind != "attachment" || !strings.HasSuffix(got.Path, ".png") {
t.Fatalf("got %+v, want png attachment", got)
}
if !strings.HasPrefix(got.PreviewURL, "data:image/png;base64,") {
t.Fatalf("preview = %q, want png data URL", got.PreviewURL)
}
}
func TestAttachDroppedOutsideWorkspaceDirRegistersWorkspaceRef(t *testing.T) {
orig, _ := os.Getwd()
defer os.Chdir(orig)
workspace := t.TempDir()
outside := filepath.Join(t.TempDir(), "Folder With Spaces")
if err := os.MkdirAll(filepath.Join(outside, "sub"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(outside, "sub", "notes.txt"), []byte("notes"), 0o644); err != nil {
t.Fatal(err)
}
expectedOutside := outside
if resolved, err := filepath.EvalSymlinks(outside); err == nil {
expectedOutside = resolved
}
expectedDisplayPath := filepath.ToSlash(expectedOutside)
if err := os.Chdir(workspace); err != nil {
t.Fatal(err)
}
ctrl := control.New(control.Options{WorkspaceRoot: workspace})
app := &App{
tabs: map[string]*WorkspaceTab{
"project": {ID: "project", WorkspaceRoot: workspace, Ctrl: ctrl},
},
activeTabID: "project",
}
got, err := app.AttachDropped(outside)
if err != nil {
t.Fatalf("AttachDropped: %v", err)
}
if got.Kind != "workspace" || !got.IsDir {
t.Fatalf("got %+v, want workspace directory ref", got)
}
if !strings.HasPrefix(got.Path, "__reasonix_external_folder/") || strings.ContainsAny(got.Path, " \t\r\n") {
t.Fatalf("external folder path token = %q, want whitespace-free external token", got.Path)
}
if got.DisplayPath != expectedDisplayPath {
t.Fatalf("display path = %q, want %q", got.DisplayPath, expectedDisplayPath)
}
block, errs := ctrl.ResolveScopedRefs(context.Background(), "inspect @"+got.Path+"/")
if len(errs) != 0 {
t.Fatalf("ResolveScopedRefs errors = %v", errs)
}
if !strings.Contains(block, `<dir path="`+expectedDisplayPath+`">`) ||
!strings.Contains(block, "sub/") ||
!strings.Contains(block, "sub/notes.txt") {
t.Fatalf("external dropped folder should resolve as dir context:\n%s", block)
}
}
func TestAttachDroppedOutsideWorkspaceDirRegistersAfterPinnedOwnerRebuild(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "TEST_MODEL_KEY", "sk-test")
cfg := config.Default()
cfg.DefaultModel = "test/test-model"
cfg.Desktop.ProviderAccess = []string{"test"}
cfg.Providers = []config.ProviderEntry{
{Name: "test", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "test-model", APIKeyEnv: "TEST_MODEL_KEY"},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
orig, _ := os.Getwd()
defer os.Chdir(orig)
projectA := t.TempDir()
projectB := t.TempDir()
outside := filepath.Join(t.TempDir(), "External")
if err := os.MkdirAll(filepath.Join(outside, "sub"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(outside, "sub", "notes.txt"), []byte("notes"), 0o644); err != nil {
t.Fatal(err)
}
if err := addProject(projectA, "Project A"); err != nil {
t.Fatalf("add project A: %v", err)
}
if err := addProject(projectB, "Project B"); err != nil {
t.Fatalf("add project B: %v", err)
}
sessionDirA := desktopSessionDir(projectA)
sessionDirB := desktopSessionDir(projectB)
if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
t.Fatalf("mkdir project A sessions: %v", err)
}
if err := os.MkdirAll(sessionDirB, 0o755); err != nil {
t.Fatalf("mkdir project B sessions: %v", err)
}
sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", "topic_external_ref", "External ref", projectA, "project A prompt", time.Now())
sessionPathB := filepath.Join(sessionDirB, "wrong.jsonl")
oldCtrl := control.New(control.Options{
SessionDir: sessionDirB,
SessionPath: sessionPathB,
WorkspaceRoot: projectB,
Sink: event.Discard,
})
app := NewApp()
app.readyHook = func() {}
tab := &WorkspaceTab{
ID: "project",
Scope: "project",
WorkspaceRoot: projectB,
TopicID: "topic_external_ref",
TopicTitle: "External ref",
SessionPath: sessionPathA,
Ready: true,
model: "test/test-model",
Ctrl: oldCtrl,
sink: &tabEventSink{tabID: "project", app: app},
disabledMCP: map[string]ServerView{},
}
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
t.Cleanup(func() {
if tab.Ctrl != nil {
tab.Ctrl.Close()
}
})
got, err := app.AttachDropped(outside)
if err != nil {
t.Fatalf("AttachDropped: %v", err)
}
if tab.Ctrl == oldCtrl {
t.Fatal("stale controller was reused for external folder ref")
}
if gotRoot := normalizeProjectRoot(tab.Ctrl.WorkspaceRoot()); gotRoot != normalizeProjectRoot(projectA) {
t.Fatalf("controller workspace root = %q, want project A %q", gotRoot, normalizeProjectRoot(projectA))
}
resolver, ok := tab.Ctrl.(interface {
ResolveScopedRefs(context.Context, string) (string, []string)
})
if !ok {
t.Fatalf("rebuilt controller does not resolve scoped refs: %T", tab.Ctrl)
}
block, errs := resolver.ResolveScopedRefs(context.Background(), "inspect @"+got.Path+"/")
if len(errs) != 0 {
t.Fatalf("ResolveScopedRefs errors = %v", errs)
}
if !strings.Contains(block, "sub/notes.txt") {
t.Fatalf("external dropped folder should resolve on rebuilt controller:\n%s", block)
}
}
+52
View File
@@ -0,0 +1,52 @@
package main
import (
"errors"
"testing"
"time"
)
func TestReportTabSnapshotErrorDebouncesAutosave(t *testing.T) {
app := NewApp()
tab := &WorkspaceTab{ID: "tab_warn", sink: &tabEventSink{tabID: "tab_warn", app: app}}
failure := errors.New("disk unhappy")
app.reportTabSnapshotError(tab, "autosave", failure)
tab.saveMu.Lock()
first := tab.lastAutosaveWarnAt
tab.saveMu.Unlock()
if first.IsZero() {
t.Fatal("first autosave warning did not record its timestamp")
}
// Within the window: suppressed, timestamp untouched.
app.reportTabSnapshotError(tab, "autosave", failure)
tab.saveMu.Lock()
second := tab.lastAutosaveWarnAt
tab.saveMu.Unlock()
if !second.Equal(first) {
t.Fatalf("suppressed warning moved the debounce timestamp: %v -> %v", first, second)
}
// Window expired: warns again.
aged := time.Now().Add(-autosaveWarnInterval - time.Second)
tab.saveMu.Lock()
tab.lastAutosaveWarnAt = aged
tab.saveMu.Unlock()
app.reportTabSnapshotError(tab, "autosave", failure)
tab.saveMu.Lock()
third := tab.lastAutosaveWarnAt
tab.saveMu.Unlock()
if !third.After(aged) {
t.Fatal("expired window did not re-arm the autosave warning")
}
// Explicit user actions are never debounced (state untouched).
app.reportTabSnapshotError(tab, "changing model", failure)
tab.saveMu.Lock()
fourth := tab.lastAutosaveWarnAt
tab.saveMu.Unlock()
if !fourth.Equal(third) {
t.Fatal("action-save warning must not consume the autosave debounce window")
}
}
Loaded 100 of 2146 files, more files were not shown because too many files have changed in this diff. Show more