gsd-pi 2.18.0 → 2.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (289) hide show
  1. package/README.md +5 -1
  2. package/dist/cli.js +3 -3
  3. package/dist/onboarding.d.ts +3 -1
  4. package/dist/onboarding.js +77 -3
  5. package/dist/remote-questions-config.d.ts +1 -1
  6. package/dist/resources/extensions/google-search/index.ts +164 -47
  7. package/dist/resources/extensions/gsd/auto-dashboard.ts +14 -2
  8. package/dist/resources/extensions/gsd/auto-prompts.ts +148 -39
  9. package/dist/resources/extensions/gsd/auto-worktree.ts +93 -9
  10. package/dist/resources/extensions/gsd/auto.ts +690 -39
  11. package/dist/resources/extensions/gsd/captures.ts +384 -0
  12. package/dist/resources/extensions/gsd/commands.ts +654 -36
  13. package/dist/resources/extensions/gsd/complexity-classifier.ts +322 -0
  14. package/dist/resources/extensions/gsd/context-budget.ts +243 -0
  15. package/dist/resources/extensions/gsd/context-store.ts +195 -0
  16. package/dist/resources/extensions/gsd/dashboard-overlay.ts +51 -3
  17. package/dist/resources/extensions/gsd/db-writer.ts +341 -0
  18. package/dist/resources/extensions/gsd/debug-logger.ts +178 -0
  19. package/dist/resources/extensions/gsd/dispatch-guard.ts +0 -1
  20. package/dist/resources/extensions/gsd/docs/preferences-reference.md +54 -0
  21. package/dist/resources/extensions/gsd/doctor-proactive.ts +286 -0
  22. package/dist/resources/extensions/gsd/doctor.ts +283 -2
  23. package/dist/resources/extensions/gsd/export.ts +81 -2
  24. package/dist/resources/extensions/gsd/files.ts +39 -9
  25. package/dist/resources/extensions/gsd/git-service.ts +6 -0
  26. package/dist/resources/extensions/gsd/gsd-db.ts +752 -0
  27. package/dist/resources/extensions/gsd/guided-flow.ts +26 -1
  28. package/dist/resources/extensions/gsd/history.ts +0 -1
  29. package/dist/resources/extensions/gsd/index.ts +277 -1
  30. package/dist/resources/extensions/gsd/md-importer.ts +526 -0
  31. package/dist/resources/extensions/gsd/metrics.ts +84 -0
  32. package/dist/resources/extensions/gsd/model-cost-table.ts +65 -0
  33. package/dist/resources/extensions/gsd/model-router.ts +256 -0
  34. package/dist/resources/extensions/gsd/notifications.ts +0 -1
  35. package/dist/resources/extensions/gsd/post-unit-hooks.ts +72 -2
  36. package/dist/resources/extensions/gsd/preferences.ts +198 -150
  37. package/dist/resources/extensions/gsd/prompt-loader.ts +45 -9
  38. package/dist/resources/extensions/gsd/prompts/execute-task.md +3 -5
  39. package/dist/resources/extensions/gsd/prompts/heal-skill.md +45 -0
  40. package/dist/resources/extensions/gsd/prompts/plan-slice.md +5 -1
  41. package/dist/resources/extensions/gsd/prompts/quick-task.md +48 -0
  42. package/dist/resources/extensions/gsd/prompts/reassess-roadmap.md +6 -0
  43. package/dist/resources/extensions/gsd/prompts/replan-slice.md +8 -0
  44. package/dist/resources/extensions/gsd/prompts/system.md +2 -1
  45. package/dist/resources/extensions/gsd/prompts/triage-captures.md +62 -0
  46. package/dist/resources/extensions/gsd/quick.ts +156 -0
  47. package/dist/resources/extensions/gsd/skill-discovery.ts +5 -3
  48. package/dist/resources/extensions/gsd/skill-health.ts +417 -0
  49. package/dist/resources/extensions/gsd/skill-telemetry.ts +127 -0
  50. package/dist/resources/extensions/gsd/state.ts +30 -0
  51. package/dist/resources/extensions/gsd/templates/preferences.md +1 -0
  52. package/dist/resources/extensions/gsd/tests/captures.test.ts +438 -0
  53. package/dist/resources/extensions/gsd/tests/complexity-classifier.test.ts +181 -0
  54. package/dist/resources/extensions/gsd/tests/context-budget.test.ts +283 -0
  55. package/dist/resources/extensions/gsd/tests/context-compression.test.ts +1 -1
  56. package/dist/resources/extensions/gsd/tests/context-store.test.ts +462 -0
  57. package/dist/resources/extensions/gsd/tests/continue-here.test.ts +204 -0
  58. package/dist/resources/extensions/gsd/tests/dashboard-budget.test.ts +346 -0
  59. package/dist/resources/extensions/gsd/tests/db-writer.test.ts +602 -0
  60. package/dist/resources/extensions/gsd/tests/debug-logger.test.ts +185 -0
  61. package/dist/resources/extensions/gsd/tests/derive-state-db.test.ts +406 -0
  62. package/dist/resources/extensions/gsd/tests/dispatch-guard.test.ts +0 -1
  63. package/dist/resources/extensions/gsd/tests/dist-redirect.mjs +22 -0
  64. package/dist/resources/extensions/gsd/tests/doctor-proactive.test.ts +244 -0
  65. package/dist/resources/extensions/gsd/tests/doctor-runtime.test.ts +303 -0
  66. package/dist/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts +434 -0
  67. package/dist/resources/extensions/gsd/tests/gsd-db.test.ts +353 -0
  68. package/dist/resources/extensions/gsd/tests/gsd-inspect.test.ts +125 -0
  69. package/dist/resources/extensions/gsd/tests/gsd-tools.test.ts +326 -0
  70. package/dist/resources/extensions/gsd/tests/integration-edge.test.ts +228 -0
  71. package/dist/resources/extensions/gsd/tests/integration-lifecycle.test.ts +277 -0
  72. package/dist/resources/extensions/gsd/tests/md-importer.test.ts +411 -0
  73. package/dist/resources/extensions/gsd/tests/metrics.test.ts +197 -0
  74. package/dist/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +144 -0
  75. package/dist/resources/extensions/gsd/tests/model-cost-table.test.ts +69 -0
  76. package/dist/resources/extensions/gsd/tests/model-isolation.test.ts +99 -0
  77. package/dist/resources/extensions/gsd/tests/model-router.test.ts +167 -0
  78. package/dist/resources/extensions/gsd/tests/parsers.test.ts +40 -0
  79. package/dist/resources/extensions/gsd/tests/post-unit-hooks.test.ts +41 -1
  80. package/dist/resources/extensions/gsd/tests/preferences-git.test.ts +0 -1
  81. package/dist/resources/extensions/gsd/tests/preferences-hooks.test.ts +0 -1
  82. package/dist/resources/extensions/gsd/tests/preferences-mode.test.ts +110 -0
  83. package/dist/resources/extensions/gsd/tests/preferences-models.test.ts +0 -1
  84. package/dist/resources/extensions/gsd/tests/prompt-budget-enforcement.test.ts +464 -0
  85. package/dist/resources/extensions/gsd/tests/prompt-db.test.ts +385 -0
  86. package/dist/resources/extensions/gsd/tests/remote-questions.test.ts +488 -1
  87. package/dist/resources/extensions/gsd/tests/resolve-ts-hooks.mjs +17 -29
  88. package/dist/resources/extensions/gsd/tests/resolve-ts.mjs +2 -8
  89. package/dist/resources/extensions/gsd/tests/routing-history.test.ts +215 -62
  90. package/dist/resources/extensions/gsd/tests/skill-lifecycle.test.ts +126 -0
  91. package/dist/resources/extensions/gsd/tests/stop-auto-remote.test.ts +31 -8
  92. package/dist/resources/extensions/gsd/tests/token-savings.test.ts +366 -0
  93. package/dist/resources/extensions/gsd/tests/triage-dispatch.test.ts +224 -0
  94. package/dist/resources/extensions/gsd/tests/triage-resolution.test.ts +215 -0
  95. package/dist/resources/extensions/gsd/tests/unit-runtime.test.ts +25 -1
  96. package/dist/resources/extensions/gsd/tests/visualizer-critical-path.test.ts +145 -0
  97. package/dist/resources/extensions/gsd/tests/visualizer-data.test.ts +290 -0
  98. package/dist/resources/extensions/gsd/tests/visualizer-overlay.test.ts +120 -0
  99. package/dist/resources/extensions/gsd/tests/visualizer-views.test.ts +478 -0
  100. package/dist/resources/extensions/gsd/tests/worktree-db-integration.test.ts +205 -0
  101. package/dist/resources/extensions/gsd/tests/worktree-db.test.ts +442 -0
  102. package/dist/resources/extensions/gsd/tests/worktree-post-create-hook.test.ts +165 -0
  103. package/dist/resources/extensions/gsd/triage-resolution.ts +200 -0
  104. package/dist/resources/extensions/gsd/triage-ui.ts +175 -0
  105. package/dist/resources/extensions/gsd/types.ts +29 -0
  106. package/dist/resources/extensions/gsd/undo.ts +0 -1
  107. package/dist/resources/extensions/gsd/unit-runtime.ts +5 -1
  108. package/dist/resources/extensions/gsd/visualizer-data.ts +505 -0
  109. package/dist/resources/extensions/gsd/visualizer-overlay.ts +337 -0
  110. package/dist/resources/extensions/gsd/visualizer-views.ts +755 -0
  111. package/dist/resources/extensions/gsd/worktree-command.ts +18 -0
  112. package/dist/resources/extensions/gsd/worktree-manager.ts +11 -4
  113. package/dist/resources/extensions/remote-questions/config.ts +4 -2
  114. package/dist/resources/extensions/remote-questions/discord-adapter.ts +35 -4
  115. package/dist/resources/extensions/remote-questions/format.ts +166 -14
  116. package/dist/resources/extensions/remote-questions/manager.ts +14 -4
  117. package/dist/resources/extensions/remote-questions/remote-command.ts +100 -4
  118. package/dist/resources/extensions/remote-questions/slack-adapter.ts +58 -2
  119. package/dist/resources/extensions/remote-questions/telegram-adapter.ts +161 -0
  120. package/dist/resources/extensions/remote-questions/types.ts +2 -1
  121. package/dist/resources/extensions/ttsr/ttsr-manager.ts +26 -0
  122. package/dist/resources/extensions/voice/index.ts +4 -3
  123. package/package.json +1 -1
  124. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  125. package/packages/pi-coding-agent/dist/core/agent-session.js +12 -1
  126. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  127. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
  128. package/packages/pi-coding-agent/dist/core/extensions/loader.js +5 -0
  129. package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
  130. package/packages/pi-coding-agent/dist/core/lsp/client.d.ts +6 -0
  131. package/packages/pi-coding-agent/dist/core/lsp/client.d.ts.map +1 -1
  132. package/packages/pi-coding-agent/dist/core/lsp/client.js +25 -0
  133. package/packages/pi-coding-agent/dist/core/lsp/client.js.map +1 -1
  134. package/packages/pi-coding-agent/dist/core/lsp/index.d.ts +2 -0
  135. package/packages/pi-coding-agent/dist/core/lsp/index.d.ts.map +1 -1
  136. package/packages/pi-coding-agent/dist/core/lsp/index.js +106 -3
  137. package/packages/pi-coding-agent/dist/core/lsp/index.js.map +1 -1
  138. package/packages/pi-coding-agent/dist/core/lsp/lsp.md +6 -0
  139. package/packages/pi-coding-agent/dist/core/lsp/types.d.ts +35 -0
  140. package/packages/pi-coding-agent/dist/core/lsp/types.d.ts.map +1 -1
  141. package/packages/pi-coding-agent/dist/core/lsp/types.js +6 -0
  142. package/packages/pi-coding-agent/dist/core/lsp/types.js.map +1 -1
  143. package/packages/pi-coding-agent/dist/core/lsp/utils.d.ts +3 -1
  144. package/packages/pi-coding-agent/dist/core/lsp/utils.d.ts.map +1 -1
  145. package/packages/pi-coding-agent/dist/core/lsp/utils.js +45 -0
  146. package/packages/pi-coding-agent/dist/core/lsp/utils.js.map +1 -1
  147. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts +6 -0
  148. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
  149. package/packages/pi-coding-agent/dist/core/settings-manager.js +43 -11
  150. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  151. package/packages/pi-coding-agent/dist/core/system-prompt.d.ts.map +1 -1
  152. package/packages/pi-coding-agent/dist/core/system-prompt.js +7 -1
  153. package/packages/pi-coding-agent/dist/core/system-prompt.js.map +1 -1
  154. package/packages/pi-coding-agent/dist/core/tools/edit.d.ts.map +1 -1
  155. package/packages/pi-coding-agent/dist/core/tools/edit.js +5 -0
  156. package/packages/pi-coding-agent/dist/core/tools/edit.js.map +1 -1
  157. package/packages/pi-coding-agent/dist/core/tools/index.d.ts +2 -0
  158. package/packages/pi-coding-agent/dist/core/tools/index.d.ts.map +1 -1
  159. package/packages/pi-coding-agent/dist/core/tools/write.d.ts.map +1 -1
  160. package/packages/pi-coding-agent/dist/core/tools/write.js +5 -0
  161. package/packages/pi-coding-agent/dist/core/tools/write.js.map +1 -1
  162. package/packages/pi-coding-agent/src/core/agent-session.ts +13 -1
  163. package/packages/pi-coding-agent/src/core/extensions/loader.ts +6 -0
  164. package/packages/pi-coding-agent/src/core/lsp/client.ts +26 -0
  165. package/packages/pi-coding-agent/src/core/lsp/index.ts +157 -2
  166. package/packages/pi-coding-agent/src/core/lsp/lsp.md +6 -0
  167. package/packages/pi-coding-agent/src/core/lsp/types.ts +53 -0
  168. package/packages/pi-coding-agent/src/core/lsp/utils.ts +56 -0
  169. package/packages/pi-coding-agent/src/core/settings-manager.ts +41 -11
  170. package/packages/pi-coding-agent/src/core/system-prompt.ts +7 -1
  171. package/packages/pi-coding-agent/src/core/tools/edit.ts +3 -0
  172. package/packages/pi-coding-agent/src/core/tools/write.ts +3 -0
  173. package/src/resources/extensions/google-search/index.ts +164 -47
  174. package/src/resources/extensions/gsd/auto-dashboard.ts +14 -2
  175. package/src/resources/extensions/gsd/auto-prompts.ts +148 -39
  176. package/src/resources/extensions/gsd/auto-worktree.ts +93 -9
  177. package/src/resources/extensions/gsd/auto.ts +690 -39
  178. package/src/resources/extensions/gsd/captures.ts +384 -0
  179. package/src/resources/extensions/gsd/commands.ts +654 -36
  180. package/src/resources/extensions/gsd/complexity-classifier.ts +322 -0
  181. package/src/resources/extensions/gsd/context-budget.ts +243 -0
  182. package/src/resources/extensions/gsd/context-store.ts +195 -0
  183. package/src/resources/extensions/gsd/dashboard-overlay.ts +51 -3
  184. package/src/resources/extensions/gsd/db-writer.ts +341 -0
  185. package/src/resources/extensions/gsd/debug-logger.ts +178 -0
  186. package/src/resources/extensions/gsd/dispatch-guard.ts +0 -1
  187. package/src/resources/extensions/gsd/docs/preferences-reference.md +54 -0
  188. package/src/resources/extensions/gsd/doctor-proactive.ts +286 -0
  189. package/src/resources/extensions/gsd/doctor.ts +283 -2
  190. package/src/resources/extensions/gsd/export.ts +81 -2
  191. package/src/resources/extensions/gsd/files.ts +39 -9
  192. package/src/resources/extensions/gsd/git-service.ts +6 -0
  193. package/src/resources/extensions/gsd/gsd-db.ts +752 -0
  194. package/src/resources/extensions/gsd/guided-flow.ts +26 -1
  195. package/src/resources/extensions/gsd/history.ts +0 -1
  196. package/src/resources/extensions/gsd/index.ts +277 -1
  197. package/src/resources/extensions/gsd/md-importer.ts +526 -0
  198. package/src/resources/extensions/gsd/metrics.ts +84 -0
  199. package/src/resources/extensions/gsd/model-cost-table.ts +65 -0
  200. package/src/resources/extensions/gsd/model-router.ts +256 -0
  201. package/src/resources/extensions/gsd/notifications.ts +0 -1
  202. package/src/resources/extensions/gsd/post-unit-hooks.ts +72 -2
  203. package/src/resources/extensions/gsd/preferences.ts +198 -150
  204. package/src/resources/extensions/gsd/prompt-loader.ts +45 -9
  205. package/src/resources/extensions/gsd/prompts/execute-task.md +3 -5
  206. package/src/resources/extensions/gsd/prompts/heal-skill.md +45 -0
  207. package/src/resources/extensions/gsd/prompts/plan-slice.md +5 -1
  208. package/src/resources/extensions/gsd/prompts/quick-task.md +48 -0
  209. package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +6 -0
  210. package/src/resources/extensions/gsd/prompts/replan-slice.md +8 -0
  211. package/src/resources/extensions/gsd/prompts/system.md +2 -1
  212. package/src/resources/extensions/gsd/prompts/triage-captures.md +62 -0
  213. package/src/resources/extensions/gsd/quick.ts +156 -0
  214. package/src/resources/extensions/gsd/skill-discovery.ts +5 -3
  215. package/src/resources/extensions/gsd/skill-health.ts +417 -0
  216. package/src/resources/extensions/gsd/skill-telemetry.ts +127 -0
  217. package/src/resources/extensions/gsd/state.ts +30 -0
  218. package/src/resources/extensions/gsd/templates/preferences.md +1 -0
  219. package/src/resources/extensions/gsd/tests/captures.test.ts +438 -0
  220. package/src/resources/extensions/gsd/tests/complexity-classifier.test.ts +181 -0
  221. package/src/resources/extensions/gsd/tests/context-budget.test.ts +283 -0
  222. package/src/resources/extensions/gsd/tests/context-compression.test.ts +1 -1
  223. package/src/resources/extensions/gsd/tests/context-store.test.ts +462 -0
  224. package/src/resources/extensions/gsd/tests/continue-here.test.ts +204 -0
  225. package/src/resources/extensions/gsd/tests/dashboard-budget.test.ts +346 -0
  226. package/src/resources/extensions/gsd/tests/db-writer.test.ts +602 -0
  227. package/src/resources/extensions/gsd/tests/debug-logger.test.ts +185 -0
  228. package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +406 -0
  229. package/src/resources/extensions/gsd/tests/dispatch-guard.test.ts +0 -1
  230. package/src/resources/extensions/gsd/tests/dist-redirect.mjs +22 -0
  231. package/src/resources/extensions/gsd/tests/doctor-proactive.test.ts +244 -0
  232. package/src/resources/extensions/gsd/tests/doctor-runtime.test.ts +303 -0
  233. package/src/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts +434 -0
  234. package/src/resources/extensions/gsd/tests/gsd-db.test.ts +353 -0
  235. package/src/resources/extensions/gsd/tests/gsd-inspect.test.ts +125 -0
  236. package/src/resources/extensions/gsd/tests/gsd-tools.test.ts +326 -0
  237. package/src/resources/extensions/gsd/tests/integration-edge.test.ts +228 -0
  238. package/src/resources/extensions/gsd/tests/integration-lifecycle.test.ts +277 -0
  239. package/src/resources/extensions/gsd/tests/md-importer.test.ts +411 -0
  240. package/src/resources/extensions/gsd/tests/metrics.test.ts +197 -0
  241. package/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +144 -0
  242. package/src/resources/extensions/gsd/tests/model-cost-table.test.ts +69 -0
  243. package/src/resources/extensions/gsd/tests/model-isolation.test.ts +99 -0
  244. package/src/resources/extensions/gsd/tests/model-router.test.ts +167 -0
  245. package/src/resources/extensions/gsd/tests/parsers.test.ts +40 -0
  246. package/src/resources/extensions/gsd/tests/post-unit-hooks.test.ts +41 -1
  247. package/src/resources/extensions/gsd/tests/preferences-git.test.ts +0 -1
  248. package/src/resources/extensions/gsd/tests/preferences-hooks.test.ts +0 -1
  249. package/src/resources/extensions/gsd/tests/preferences-mode.test.ts +110 -0
  250. package/src/resources/extensions/gsd/tests/preferences-models.test.ts +0 -1
  251. package/src/resources/extensions/gsd/tests/prompt-budget-enforcement.test.ts +464 -0
  252. package/src/resources/extensions/gsd/tests/prompt-db.test.ts +385 -0
  253. package/src/resources/extensions/gsd/tests/remote-questions.test.ts +488 -1
  254. package/src/resources/extensions/gsd/tests/resolve-ts-hooks.mjs +17 -29
  255. package/src/resources/extensions/gsd/tests/resolve-ts.mjs +2 -8
  256. package/src/resources/extensions/gsd/tests/routing-history.test.ts +215 -62
  257. package/src/resources/extensions/gsd/tests/skill-lifecycle.test.ts +126 -0
  258. package/src/resources/extensions/gsd/tests/stop-auto-remote.test.ts +31 -8
  259. package/src/resources/extensions/gsd/tests/token-savings.test.ts +366 -0
  260. package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +224 -0
  261. package/src/resources/extensions/gsd/tests/triage-resolution.test.ts +215 -0
  262. package/src/resources/extensions/gsd/tests/unit-runtime.test.ts +25 -1
  263. package/src/resources/extensions/gsd/tests/visualizer-critical-path.test.ts +145 -0
  264. package/src/resources/extensions/gsd/tests/visualizer-data.test.ts +290 -0
  265. package/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts +120 -0
  266. package/src/resources/extensions/gsd/tests/visualizer-views.test.ts +478 -0
  267. package/src/resources/extensions/gsd/tests/worktree-db-integration.test.ts +205 -0
  268. package/src/resources/extensions/gsd/tests/worktree-db.test.ts +442 -0
  269. package/src/resources/extensions/gsd/tests/worktree-post-create-hook.test.ts +165 -0
  270. package/src/resources/extensions/gsd/triage-resolution.ts +200 -0
  271. package/src/resources/extensions/gsd/triage-ui.ts +175 -0
  272. package/src/resources/extensions/gsd/types.ts +29 -0
  273. package/src/resources/extensions/gsd/undo.ts +0 -1
  274. package/src/resources/extensions/gsd/unit-runtime.ts +5 -1
  275. package/src/resources/extensions/gsd/visualizer-data.ts +505 -0
  276. package/src/resources/extensions/gsd/visualizer-overlay.ts +337 -0
  277. package/src/resources/extensions/gsd/visualizer-views.ts +755 -0
  278. package/src/resources/extensions/gsd/worktree-command.ts +18 -0
  279. package/src/resources/extensions/gsd/worktree-manager.ts +11 -4
  280. package/src/resources/extensions/remote-questions/config.ts +4 -2
  281. package/src/resources/extensions/remote-questions/discord-adapter.ts +35 -4
  282. package/src/resources/extensions/remote-questions/format.ts +166 -14
  283. package/src/resources/extensions/remote-questions/manager.ts +14 -4
  284. package/src/resources/extensions/remote-questions/remote-command.ts +100 -4
  285. package/src/resources/extensions/remote-questions/slack-adapter.ts +58 -2
  286. package/src/resources/extensions/remote-questions/telegram-adapter.ts +161 -0
  287. package/src/resources/extensions/remote-questions/types.ts +2 -1
  288. package/src/resources/extensions/ttsr/ttsr-manager.ts +26 -0
  289. package/src/resources/extensions/voice/index.ts +4 -3
@@ -0,0 +1,411 @@
1
+ import { createTestContext } from './test-helpers.ts';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import * as os from 'node:os';
5
+ import {
6
+ openDatabase,
7
+ closeDatabase,
8
+ getDecisionById,
9
+ getActiveDecisions,
10
+ getRequirementById,
11
+ getActiveRequirements,
12
+ insertArtifact,
13
+ _getAdapter,
14
+ } from '../gsd-db.ts';
15
+ import {
16
+ parseDecisionsTable,
17
+ parseRequirementsSections,
18
+ migrateFromMarkdown,
19
+ } from '../md-importer.ts';
20
+
21
+ const { assertEq, assertTrue, report } = createTestContext();
22
+
23
+ // ═══════════════════════════════════════════════════════════════════════════
24
+ // Fixtures
25
+ // ═══════════════════════════════════════════════════════════════════════════
26
+
27
+ const DECISIONS_MD = `# Decisions Register
28
+
29
+ | # | When | Scope | Decision | Choice | Rationale | Revisable? |
30
+ |---|------|-------|----------|--------|-----------|------------|
31
+ | D001 | M001 | library | SQLite library | better-sqlite3 | Sync API | No |
32
+ | D002 | M001 | arch | DB location | .gsd/gsd.db | Derived state | No |
33
+ | D010 | M001/S01 | library | Provider strategy (amends D001) | node:sqlite fallback | Zero deps | No |
34
+ | D020 | M001/S02 | library | Importer approach (amends D010) | Direct parse | Simple | Yes |
35
+ `;
36
+
37
+ const REQUIREMENTS_MD = `# Requirements
38
+
39
+ ## Active
40
+
41
+ ### R001 — SQLite DB layer
42
+ - Class: core-capability
43
+ - Status: active
44
+ - Description: A SQLite database with typed wrappers
45
+ - Why it matters: Foundation for storage
46
+ - Source: user
47
+ - Primary owning slice: M001/S01
48
+ - Supporting slices: none
49
+ - Validation: unmapped
50
+ - Notes: WAL mode enabled
51
+
52
+ ### R002 — Graceful fallback
53
+ - Class: failure-visibility
54
+ - Status: active
55
+ - Description: Falls back to markdown if SQLite unavailable
56
+ - Why it matters: Must not break on exotic platforms
57
+ - Source: user
58
+ - Primary owning slice: M001/S01
59
+ - Supporting slices: M001/S03
60
+ - Validation: unmapped
61
+ - Notes: Transparent fallback
62
+
63
+ ## Validated
64
+
65
+ ### R017 — Sub-5ms query latency
66
+ - Validated by: M001/S01
67
+ - Proof: 50 decisions queried in 0.62ms
68
+
69
+ ## Deferred
70
+
71
+ ### R030 — Vector search
72
+ - Class: differentiator
73
+ - Status: deferred
74
+ - Description: Rust crate for embeddings
75
+ - Why it matters: Semantic retrieval
76
+ - Source: user
77
+ - Primary owning slice: none
78
+ - Supporting slices: none
79
+ - Validation: unmapped
80
+ - Notes: Deferred to M002
81
+
82
+ ## Out of Scope
83
+
84
+ ### R040 — Web UI
85
+ - Class: anti-feature
86
+ - Status: out-of-scope
87
+ - Description: No web interface for DB
88
+ - Why it matters: Prevents scope creep
89
+ - Source: user
90
+ - Primary owning slice: none
91
+ - Supporting slices: none
92
+ - Validation: n/a
93
+ - Notes: Excluded in PRD
94
+ `;
95
+
96
+ // ═══════════════════════════════════════════════════════════════════════════
97
+ // Helpers
98
+ // ═══════════════════════════════════════════════════════════════════════════
99
+
100
+ function createFixtureTree(baseDir: string): void {
101
+ const gsd = path.join(baseDir, '.gsd');
102
+ fs.mkdirSync(gsd, { recursive: true });
103
+ fs.writeFileSync(path.join(gsd, 'DECISIONS.md'), DECISIONS_MD);
104
+ fs.writeFileSync(path.join(gsd, 'REQUIREMENTS.md'), REQUIREMENTS_MD);
105
+ fs.writeFileSync(path.join(gsd, 'PROJECT.md'), '# Test Project\nA test project.');
106
+
107
+ // Create milestone hierarchy
108
+ const m001 = path.join(gsd, 'milestones', 'M001');
109
+ fs.mkdirSync(m001, { recursive: true });
110
+ fs.writeFileSync(path.join(m001, 'M001-ROADMAP.md'), '# M001 Roadmap\nTest roadmap content.');
111
+ fs.writeFileSync(path.join(m001, 'M001-CONTEXT.md'), '# M001 Context\nTest context.');
112
+
113
+ // Create slice
114
+ const s01 = path.join(m001, 'slices', 'S01');
115
+ fs.mkdirSync(s01, { recursive: true });
116
+ fs.writeFileSync(path.join(s01, 'S01-PLAN.md'), '# S01 Plan\nTest plan.');
117
+ fs.writeFileSync(path.join(s01, 'S01-SUMMARY.md'), '# S01 Summary\nTest summary.');
118
+
119
+ // Create tasks
120
+ const tasks = path.join(s01, 'tasks');
121
+ fs.mkdirSync(tasks, { recursive: true });
122
+ fs.writeFileSync(path.join(tasks, 'T01-PLAN.md'), '# T01 Plan\nTask plan.');
123
+ fs.writeFileSync(path.join(tasks, 'T01-SUMMARY.md'), '# T01 Summary\nTask summary.');
124
+ }
125
+
126
+ function cleanupDir(dir: string): void {
127
+ try {
128
+ fs.rmSync(dir, { recursive: true, force: true });
129
+ } catch {
130
+ // best effort
131
+ }
132
+ }
133
+
134
+ // ═══════════════════════════════════════════════════════════════════════════
135
+ // md-importer: parseDecisionsTable
136
+ // ═══════════════════════════════════════════════════════════════════════════
137
+
138
+ console.log('\n=== md-importer: parseDecisionsTable ===');
139
+
140
+ {
141
+ const decisions = parseDecisionsTable(DECISIONS_MD);
142
+ assertEq(decisions.length, 4, 'should parse 4 decisions');
143
+ assertEq(decisions[0].id, 'D001', 'first decision should be D001');
144
+ assertEq(decisions[0].decision, 'SQLite library', 'D001 decision text');
145
+ assertEq(decisions[0].choice, 'better-sqlite3', 'D001 choice');
146
+ assertEq(decisions[0].scope, 'library', 'D001 scope');
147
+ assertEq(decisions[0].revisable, 'No', 'D001 revisable');
148
+ }
149
+
150
+ console.log('=== md-importer: supersession detection ===');
151
+
152
+ {
153
+ const decisions = parseDecisionsTable(DECISIONS_MD);
154
+
155
+ // D010 amends D001 → D001.superseded_by = D010
156
+ const d001 = decisions.find(d => d.id === 'D001');
157
+ assertEq(d001?.superseded_by, 'D010', 'D001 should be superseded by D010');
158
+
159
+ // D020 amends D010 → D010.superseded_by = D020
160
+ const d010 = decisions.find(d => d.id === 'D010');
161
+ assertEq(d010?.superseded_by, 'D020', 'D010 should be superseded by D020');
162
+
163
+ // D002 is not amended
164
+ const d002 = decisions.find(d => d.id === 'D002');
165
+ assertEq(d002?.superseded_by, null, 'D002 should not be superseded');
166
+
167
+ // D020 is the latest in chain, not superseded
168
+ const d020 = decisions.find(d => d.id === 'D020');
169
+ assertEq(d020?.superseded_by, null, 'D020 should not be superseded');
170
+ }
171
+
172
+ console.log('=== md-importer: malformed/empty rows skipped ===');
173
+
174
+ {
175
+ const malformedInput = `# Decisions
176
+
177
+ | # | When | Scope | Decision | Choice | Rationale | Revisable? |
178
+ |---|------|-------|----------|--------|-----------|------------|
179
+ | D001 | M001 | lib | Pick lib | sqlite | Fast | No |
180
+ | not-a-decision | bad | x | y | z | w | q |
181
+ | | | | | | | |
182
+ | D003 | M001 | arch | Config | JSON | Simple | Yes |
183
+ `;
184
+ const decisions = parseDecisionsTable(malformedInput);
185
+ assertEq(decisions.length, 2, 'should skip rows without D-prefix IDs');
186
+ assertEq(decisions[0].id, 'D001', 'first valid row');
187
+ assertEq(decisions[1].id, 'D003', 'second valid row (skipping malformed)');
188
+ }
189
+
190
+ // ═══════════════════════════════════════════════════════════════════════════
191
+ // md-importer: parseRequirementsSections
192
+ // ═══════════════════════════════════════════════════════════════════════════
193
+
194
+ console.log('=== md-importer: parseRequirementsSections ===');
195
+
196
+ {
197
+ const reqs = parseRequirementsSections(REQUIREMENTS_MD);
198
+ assertEq(reqs.length, 5, 'should parse 5 unique requirements');
199
+
200
+ const r001 = reqs.find(r => r.id === 'R001');
201
+ assertTrue(!!r001, 'R001 should exist');
202
+ assertEq(r001?.class, 'core-capability', 'R001 class');
203
+ assertEq(r001?.status, 'active', 'R001 status');
204
+ assertEq(r001?.description, 'A SQLite database with typed wrappers', 'R001 description');
205
+ assertEq(r001?.why, 'Foundation for storage', 'R001 why');
206
+ assertEq(r001?.source, 'user', 'R001 source');
207
+ assertEq(r001?.primary_owner, 'M001/S01', 'R001 primary_owner');
208
+ assertEq(r001?.supporting_slices, 'none', 'R001 supporting_slices');
209
+ assertEq(r001?.validation, 'unmapped', 'R001 validation');
210
+ assertEq(r001?.notes, 'WAL mode enabled', 'R001 notes');
211
+ assertTrue(r001?.full_content?.includes('### R001') ?? false, 'R001 full_content should have heading');
212
+
213
+ // Validated section — R017 (abbreviated format with "Validated by" / "Proof" bullets)
214
+ const r017 = reqs.find(r => r.id === 'R017');
215
+ assertTrue(!!r017, 'R017 should exist');
216
+ assertEq(r017?.status, 'validated', 'R017 status from validated section');
217
+ assertEq(r017?.validation, 'M001/S01', 'R017 validation (from "Validated by" bullet)');
218
+ assertEq(r017?.notes, '50 decisions queried in 0.62ms', 'R017 notes (from "Proof" bullet)');
219
+
220
+ // Deferred requirement
221
+ const r030 = reqs.find(r => r.id === 'R030');
222
+ assertEq(r030?.status, 'deferred', 'R030 status should be deferred');
223
+ assertEq(r030?.class, 'differentiator', 'R030 class');
224
+ assertEq(r030?.description, 'Rust crate for embeddings', 'R030 description');
225
+
226
+ // Out of scope
227
+ const r040 = reqs.find(r => r.id === 'R040');
228
+ assertEq(r040?.status, 'out-of-scope', 'R040 status should be out-of-scope');
229
+ assertEq(r040?.class, 'anti-feature', 'R040 class');
230
+ }
231
+
232
+ // ═══════════════════════════════════════════════════════════════════════════
233
+ // md-importer: migrateFromMarkdown orchestrator
234
+ // ═══════════════════════════════════════════════════════════════════════════
235
+
236
+ console.log('=== md-importer: migrateFromMarkdown orchestrator ===');
237
+
238
+ {
239
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-import-test-'));
240
+ createFixtureTree(tmpDir);
241
+
242
+ try {
243
+ openDatabase(':memory:');
244
+ const result = migrateFromMarkdown(tmpDir);
245
+
246
+ assertEq(result.decisions, 4, 'should import 4 decisions');
247
+ assertEq(result.requirements, 5, 'should import 5 requirements');
248
+ assertTrue(result.artifacts > 0, 'should import some artifacts');
249
+
250
+ // Verify decisions queryable
251
+ const d001 = getDecisionById('D001');
252
+ assertTrue(!!d001, 'D001 should be queryable');
253
+ assertEq(d001?.superseded_by, 'D010', 'D001 superseded_by should be D010');
254
+
255
+ // Verify requirements queryable
256
+ const r001 = getRequirementById('R001');
257
+ assertTrue(!!r001, 'R001 should be queryable');
258
+ assertEq(r001?.status, 'active', 'R001 status from DB');
259
+
260
+ // Verify active views
261
+ const activeD = getActiveDecisions();
262
+ assertEq(activeD.length, 2, 'should have 2 active decisions (D002, D020)');
263
+
264
+ // Verify artifacts table
265
+ const adapter = _getAdapter();
266
+ const artifacts = adapter?.prepare('SELECT count(*) as c FROM artifacts').get();
267
+ assertTrue((artifacts?.c as number) > 0, 'artifacts table should have rows');
268
+
269
+ // Verify hierarchy correctness
270
+ const roadmap = adapter?.prepare('SELECT * FROM artifacts WHERE artifact_type = :type').get({ ':type': 'ROADMAP' });
271
+ assertTrue(!!roadmap, 'ROADMAP artifact should exist');
272
+ assertEq(roadmap?.milestone_id, 'M001', 'ROADMAP should be in M001');
273
+
274
+ const taskPlan = adapter?.prepare('SELECT * FROM artifacts WHERE task_id = :taskId AND artifact_type = :type').get({
275
+ ':taskId': 'T01',
276
+ ':type': 'PLAN',
277
+ });
278
+ assertTrue(!!taskPlan, 'T01-PLAN artifact should exist');
279
+
280
+ closeDatabase();
281
+ } finally {
282
+ cleanupDir(tmpDir);
283
+ }
284
+ }
285
+
286
+ // ═══════════════════════════════════════════════════════════════════════════
287
+ // md-importer: idempotent re-import
288
+ // ═══════════════════════════════════════════════════════════════════════════
289
+
290
+ console.log('=== md-importer: idempotent re-import ===');
291
+
292
+ {
293
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-idemp-test-'));
294
+ createFixtureTree(tmpDir);
295
+
296
+ try {
297
+ openDatabase(':memory:');
298
+ const r1 = migrateFromMarkdown(tmpDir);
299
+ const r2 = migrateFromMarkdown(tmpDir);
300
+
301
+ assertEq(r1.decisions, r2.decisions, 'double import should produce same decision count');
302
+ assertEq(r1.requirements, r2.requirements, 'double import should produce same requirement count');
303
+ assertEq(r1.artifacts, r2.artifacts, 'double import should produce same artifact count');
304
+
305
+ // Verify no duplicates
306
+ const adapter = _getAdapter();
307
+ const dc = adapter?.prepare('SELECT count(*) as c FROM decisions').get()?.c as number;
308
+ const rc = adapter?.prepare('SELECT count(*) as c FROM requirements').get()?.c as number;
309
+ const ac = adapter?.prepare('SELECT count(*) as c FROM artifacts').get()?.c as number;
310
+
311
+ assertEq(dc, r1.decisions, 'DB decision count matches import count');
312
+ assertEq(rc, r1.requirements, 'DB requirement count matches import count');
313
+ assertEq(ac, r1.artifacts, 'DB artifact count matches import count');
314
+
315
+ closeDatabase();
316
+ } finally {
317
+ cleanupDir(tmpDir);
318
+ }
319
+ }
320
+
321
+ // ═══════════════════════════════════════════════════════════════════════════
322
+ // md-importer: missing file graceful handling
323
+ // ═══════════════════════════════════════════════════════════════════════════
324
+
325
+ console.log('=== md-importer: missing file handling ===');
326
+
327
+ {
328
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-empty-test-'));
329
+ // Create empty .gsd/ with no files
330
+ fs.mkdirSync(path.join(tmpDir, '.gsd'), { recursive: true });
331
+
332
+ try {
333
+ openDatabase(':memory:');
334
+ const result = migrateFromMarkdown(tmpDir);
335
+
336
+ assertEq(result.decisions, 0, 'missing DECISIONS.md → 0 decisions');
337
+ assertEq(result.requirements, 0, 'missing REQUIREMENTS.md → 0 requirements');
338
+ assertEq(result.artifacts, 0, 'empty tree → 0 artifacts');
339
+
340
+ closeDatabase();
341
+ } finally {
342
+ cleanupDir(tmpDir);
343
+ }
344
+ }
345
+
346
+ // ═══════════════════════════════════════════════════════════════════════════
347
+ // md-importer: schema v1→v2 migration on existing DBs
348
+ // ═══════════════════════════════════════════════════════════════════════════
349
+
350
+ console.log('=== md-importer: schema v1→v2 migration ===');
351
+
352
+ {
353
+ // This test verifies that opening a v1 DB auto-migrates to v2
354
+ // (The actual migration is tested via the gsd-db.test.ts schema version assertion = 2)
355
+ openDatabase(':memory:');
356
+ const adapter = _getAdapter();
357
+ const version = adapter?.prepare('SELECT MAX(version) as v FROM schema_version').get();
358
+ assertEq(version?.v, 2, 'new DB should be at schema version 2');
359
+
360
+ // Artifacts table should exist
361
+ const tableCheck = adapter?.prepare("SELECT count(*) as c FROM sqlite_master WHERE type='table' AND name='artifacts'").get();
362
+ assertEq(tableCheck?.c, 1, 'artifacts table should exist');
363
+
364
+ closeDatabase();
365
+ }
366
+
367
+ // ═══════════════════════════════════════════════════════════════════════════
368
+ // md-importer: round-trip fidelity
369
+ // ═══════════════════════════════════════════════════════════════════════════
370
+
371
+ console.log('=== md-importer: round-trip fidelity ===');
372
+
373
+ {
374
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-roundtrip-test-'));
375
+ createFixtureTree(tmpDir);
376
+
377
+ try {
378
+ openDatabase(':memory:');
379
+ migrateFromMarkdown(tmpDir);
380
+
381
+ // Round-trip: verify imported field values match source
382
+ const d002 = getDecisionById('D002');
383
+ assertEq(d002?.when_context, 'M001', 'D002 when_context round-trip');
384
+ assertEq(d002?.scope, 'arch', 'D002 scope round-trip');
385
+ assertEq(d002?.decision, 'DB location', 'D002 decision round-trip');
386
+ assertEq(d002?.choice, '.gsd/gsd.db', 'D002 choice round-trip');
387
+ assertEq(d002?.rationale, 'Derived state', 'D002 rationale round-trip');
388
+
389
+ const r002 = getRequirementById('R002');
390
+ assertEq(r002?.class, 'failure-visibility', 'R002 class round-trip');
391
+ assertEq(r002?.description, 'Falls back to markdown if SQLite unavailable', 'R002 description round-trip');
392
+ assertEq(r002?.why, 'Must not break on exotic platforms', 'R002 why round-trip');
393
+ assertEq(r002?.primary_owner, 'M001/S01', 'R002 primary_owner round-trip');
394
+ assertEq(r002?.supporting_slices, 'M001/S03', 'R002 supporting_slices round-trip');
395
+ assertEq(r002?.notes, 'Transparent fallback', 'R002 notes round-trip');
396
+ assertEq(r002?.validation, 'unmapped', 'R002 validation round-trip');
397
+
398
+ // Verify artifact content is stored
399
+ const adapter = _getAdapter();
400
+ const project = adapter?.prepare("SELECT * FROM artifacts WHERE path = :path").get({ ':path': 'PROJECT.md' });
401
+ assertTrue((project?.full_content as string)?.includes('Test Project'), 'PROJECT.md content round-trip');
402
+
403
+ closeDatabase();
404
+ } finally {
405
+ cleanupDir(tmpDir);
406
+ }
407
+ }
408
+
409
+ // ═══════════════════════════════════════════════════════════════════════════
410
+
411
+ report();
@@ -6,6 +6,7 @@
6
6
  import {
7
7
  type UnitMetrics,
8
8
  type TokenCounts,
9
+ type BudgetInfo,
9
10
  classifyUnitPhase,
10
11
  aggregateByPhase,
11
12
  aggregateBySlice,
@@ -183,6 +184,202 @@ assertEq(formatTokenCount(1500), "1.5k", "1.5k");
183
184
  assertEq(formatTokenCount(150000), "150.0k", "150k");
184
185
  assertEq(formatTokenCount(1500000), "1.50M", "1.5M");
185
186
 
187
+ // ─── Backward compat: UnitMetrics without budget fields ───────────────────────
188
+
189
+ console.log("\n=== Backward compat: UnitMetrics without budget fields ===");
190
+
191
+ {
192
+ // Simulate old metrics.json data — no budget fields present
193
+ const oldUnit: UnitMetrics = {
194
+ type: "execute-task",
195
+ id: "M001/S01/T01",
196
+ model: "claude-sonnet-4-20250514",
197
+ startedAt: 1000,
198
+ finishedAt: 2000,
199
+ tokens: { input: 1000, output: 500, cacheRead: 200, cacheWrite: 100, total: 1800 },
200
+ cost: 0.05,
201
+ toolCalls: 3,
202
+ assistantMessages: 2,
203
+ userMessages: 1,
204
+ };
205
+
206
+ // All aggregation functions must work with old data
207
+ const phases = aggregateByPhase([oldUnit]);
208
+ assertEq(phases.length, 1, "backward compat: aggregateByPhase works");
209
+ assertEq(phases[0].phase, "execution", "backward compat: correct phase");
210
+
211
+ const slices = aggregateBySlice([oldUnit]);
212
+ assertEq(slices.length, 1, "backward compat: aggregateBySlice works");
213
+ assertEq(slices[0].sliceId, "M001/S01", "backward compat: correct sliceId");
214
+
215
+ const models = aggregateByModel([oldUnit]);
216
+ assertEq(models.length, 1, "backward compat: aggregateByModel works");
217
+
218
+ const totals = getProjectTotals([oldUnit]);
219
+ assertEq(totals.units, 1, "backward compat: getProjectTotals works");
220
+ assertClose(totals.cost, 0.05, 0.001, "backward compat: cost preserved");
221
+
222
+ // Budget fields should be undefined
223
+ assertEq(oldUnit.contextWindowTokens, undefined, "backward compat: no contextWindowTokens");
224
+ assertEq(oldUnit.truncationSections, undefined, "backward compat: no truncationSections");
225
+ assertEq(oldUnit.continueHereFired, undefined, "backward compat: no continueHereFired");
226
+ }
227
+
228
+ // ─── UnitMetrics with budget fields populated ─────────────────────────────────
229
+
230
+ console.log("\n=== UnitMetrics with budget fields ===");
231
+
232
+ {
233
+ const unitWithBudget: UnitMetrics = {
234
+ type: "execute-task",
235
+ id: "M002/S01/T03",
236
+ model: "claude-sonnet-4-20250514",
237
+ startedAt: 5000,
238
+ finishedAt: 10000,
239
+ tokens: { input: 3000, output: 1500, cacheRead: 600, cacheWrite: 300, total: 5400 },
240
+ cost: 0.12,
241
+ toolCalls: 8,
242
+ assistantMessages: 4,
243
+ userMessages: 3,
244
+ contextWindowTokens: 200000,
245
+ truncationSections: 3,
246
+ continueHereFired: true,
247
+ };
248
+
249
+ // Budget fields are present
250
+ assertEq(unitWithBudget.contextWindowTokens, 200000, "budget: contextWindowTokens present");
251
+ assertEq(unitWithBudget.truncationSections, 3, "budget: truncationSections present");
252
+ assertEq(unitWithBudget.continueHereFired, true, "budget: continueHereFired present");
253
+
254
+ // Aggregation still works correctly with budget fields present
255
+ const phases = aggregateByPhase([unitWithBudget]);
256
+ assertEq(phases.length, 1, "budget: aggregateByPhase works");
257
+ assertClose(phases[0].cost, 0.12, 0.001, "budget: cost aggregated correctly");
258
+
259
+ const slices = aggregateBySlice([unitWithBudget]);
260
+ assertEq(slices.length, 1, "budget: aggregateBySlice works");
261
+ assertEq(slices[0].sliceId, "M002/S01", "budget: sliceId correct");
262
+
263
+ const models = aggregateByModel([unitWithBudget]);
264
+ assertEq(models.length, 1, "budget: aggregateByModel works");
265
+
266
+ const totals = getProjectTotals([unitWithBudget]);
267
+ assertEq(totals.units, 1, "budget: getProjectTotals works");
268
+ assertEq(totals.toolCalls, 8, "budget: toolCalls aggregated");
269
+
270
+ // Mix old and new units together
271
+ const oldUnit = makeUnit(); // no budget fields
272
+ const mixed = [oldUnit, unitWithBudget];
273
+ const mixedTotals = getProjectTotals(mixed);
274
+ assertEq(mixedTotals.units, 2, "mixed: 2 units total");
275
+ assertClose(mixedTotals.cost, 0.17, 0.001, "mixed: costs summed correctly");
276
+
277
+ const mixedPhases = aggregateByPhase(mixed);
278
+ assertEq(mixedPhases.length, 1, "mixed: both are execution phase");
279
+ assertEq(mixedPhases[0].units, 2, "mixed: both counted");
280
+ }
281
+
282
+ // ─── aggregateByModel: contextWindowTokens pick logic ─────────────────────────
283
+
284
+ console.log("\n=== aggregateByModel: contextWindowTokens pick logic ===");
285
+
286
+ {
287
+ // Single unit with contextWindowTokens — aggregate picks it
288
+ const units = [
289
+ makeUnit({ model: "claude-sonnet-4-20250514", contextWindowTokens: 200000, cost: 0.05 }),
290
+ ];
291
+ const models = aggregateByModel(units);
292
+ assertEq(models.length, 1, "ctxWindow: one model");
293
+ assertEq(models[0].contextWindowTokens, 200000, "ctxWindow: picks value from unit");
294
+ }
295
+
296
+ {
297
+ // Two units same model with different context windows — first defined value wins
298
+ const units = [
299
+ makeUnit({ model: "claude-sonnet-4-20250514", contextWindowTokens: 200000, cost: 0.05 }),
300
+ makeUnit({ model: "claude-sonnet-4-20250514", contextWindowTokens: 150000, cost: 0.04 }),
301
+ ];
302
+ const models = aggregateByModel(units);
303
+ assertEq(models.length, 1, "ctxWindow first-wins: one model");
304
+ assertEq(models[0].contextWindowTokens, 200000, "ctxWindow first-wins: first value kept");
305
+ }
306
+
307
+ {
308
+ // First unit undefined, second has value — second is picked
309
+ const units = [
310
+ makeUnit({ model: "claude-sonnet-4-20250514", cost: 0.05 }),
311
+ makeUnit({ model: "claude-sonnet-4-20250514", contextWindowTokens: 200000, cost: 0.04 }),
312
+ ];
313
+ const models = aggregateByModel(units);
314
+ assertEq(models[0].contextWindowTokens, 200000, "ctxWindow: picks first defined, not first unit");
315
+ }
316
+
317
+ {
318
+ // Old units without contextWindowTokens — aggregate has undefined
319
+ const units = [
320
+ makeUnit({ model: "claude-sonnet-4-20250514", cost: 0.05 }),
321
+ makeUnit({ model: "claude-sonnet-4-20250514", cost: 0.04 }),
322
+ ];
323
+ const models = aggregateByModel(units);
324
+ assertEq(models[0].contextWindowTokens, undefined, "ctxWindow: undefined when no unit has it");
325
+ }
326
+
327
+ {
328
+ // Multiple models — each gets its own context window
329
+ const units = [
330
+ makeUnit({ model: "claude-sonnet-4-20250514", contextWindowTokens: 200000, cost: 0.05 }),
331
+ makeUnit({ model: "claude-opus-4-20250514", contextWindowTokens: 200000, cost: 0.30 }),
332
+ ];
333
+ const models = aggregateByModel(units);
334
+ assertEq(models.length, 2, "ctxWindow multi-model: 2 models");
335
+ const opus = models.find(m => m.model === "claude-opus-4-20250514");
336
+ const sonnet = models.find(m => m.model === "claude-sonnet-4-20250514");
337
+ assertEq(opus!.contextWindowTokens, 200000, "ctxWindow multi-model: opus has value");
338
+ assertEq(sonnet!.contextWindowTokens, 200000, "ctxWindow multi-model: sonnet has value");
339
+ }
340
+
341
+ // ─── getProjectTotals: budget field aggregation ───────────────────────────────
342
+
343
+ console.log("\n=== getProjectTotals: budget field aggregation ===");
344
+
345
+ {
346
+ // Units with truncationSections and continueHereFired — verify sums/counts
347
+ const units = [
348
+ makeUnit({ truncationSections: 3, continueHereFired: true }),
349
+ makeUnit({ truncationSections: 2, continueHereFired: false }),
350
+ makeUnit({ truncationSections: 1, continueHereFired: true }),
351
+ ];
352
+ const totals = getProjectTotals(units);
353
+ assertEq(totals.totalTruncationSections, 6, "budget totals: truncation sections summed");
354
+ assertEq(totals.continueHereFiredCount, 2, "budget totals: continueHereFired counted");
355
+ }
356
+
357
+ {
358
+ // Old units without budget fields — verify 0 defaults
359
+ const units = [makeUnit(), makeUnit()];
360
+ const totals = getProjectTotals(units);
361
+ assertEq(totals.totalTruncationSections, 0, "budget totals backward compat: truncation = 0");
362
+ assertEq(totals.continueHereFiredCount, 0, "budget totals backward compat: continueHere = 0");
363
+ }
364
+
365
+ {
366
+ // Mixed old and new units
367
+ const units = [
368
+ makeUnit(), // old, no budget fields
369
+ makeUnit({ truncationSections: 5, continueHereFired: true }),
370
+ ];
371
+ const totals = getProjectTotals(units);
372
+ assertEq(totals.totalTruncationSections, 5, "budget totals mixed: only new unit contributes");
373
+ assertEq(totals.continueHereFiredCount, 1, "budget totals mixed: only one fired");
374
+ }
375
+
376
+ {
377
+ // Empty input — safe defaults
378
+ const totals = getProjectTotals([]);
379
+ assertEq(totals.totalTruncationSections, 0, "budget totals empty: truncation = 0");
380
+ assertEq(totals.continueHereFiredCount, 0, "budget totals empty: continueHere = 0");
381
+ }
382
+
186
383
  // ─── Summary ──────────────────────────────────────────────────────────────────
187
384
 
188
385
  report();