gsd-pi 2.19.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 (249) 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-prompts.ts +103 -24
  8. package/dist/resources/extensions/gsd/auto-worktree.ts +93 -9
  9. package/dist/resources/extensions/gsd/auto.ts +424 -30
  10. package/dist/resources/extensions/gsd/commands.ts +518 -36
  11. package/dist/resources/extensions/gsd/context-budget.ts +243 -0
  12. package/dist/resources/extensions/gsd/context-store.ts +195 -0
  13. package/dist/resources/extensions/gsd/dashboard-overlay.ts +41 -3
  14. package/dist/resources/extensions/gsd/db-writer.ts +341 -0
  15. package/dist/resources/extensions/gsd/debug-logger.ts +178 -0
  16. package/dist/resources/extensions/gsd/dispatch-guard.ts +0 -1
  17. package/dist/resources/extensions/gsd/docs/preferences-reference.md +54 -0
  18. package/dist/resources/extensions/gsd/doctor-proactive.ts +286 -0
  19. package/dist/resources/extensions/gsd/doctor.ts +283 -2
  20. package/dist/resources/extensions/gsd/export.ts +81 -2
  21. package/dist/resources/extensions/gsd/files.ts +39 -9
  22. package/dist/resources/extensions/gsd/git-service.ts +6 -0
  23. package/dist/resources/extensions/gsd/gsd-db.ts +752 -0
  24. package/dist/resources/extensions/gsd/guided-flow.ts +26 -1
  25. package/dist/resources/extensions/gsd/history.ts +0 -1
  26. package/dist/resources/extensions/gsd/index.ts +277 -1
  27. package/dist/resources/extensions/gsd/md-importer.ts +526 -0
  28. package/dist/resources/extensions/gsd/metrics.ts +39 -3
  29. package/dist/resources/extensions/gsd/notifications.ts +0 -1
  30. package/dist/resources/extensions/gsd/post-unit-hooks.ts +70 -1
  31. package/dist/resources/extensions/gsd/preferences.ts +125 -150
  32. package/dist/resources/extensions/gsd/prompts/execute-task.md +3 -5
  33. package/dist/resources/extensions/gsd/prompts/heal-skill.md +45 -0
  34. package/dist/resources/extensions/gsd/prompts/plan-slice.md +5 -1
  35. package/dist/resources/extensions/gsd/prompts/quick-task.md +48 -0
  36. package/dist/resources/extensions/gsd/prompts/system.md +2 -1
  37. package/dist/resources/extensions/gsd/quick.ts +156 -0
  38. package/dist/resources/extensions/gsd/skill-discovery.ts +5 -3
  39. package/dist/resources/extensions/gsd/skill-health.ts +417 -0
  40. package/dist/resources/extensions/gsd/skill-telemetry.ts +127 -0
  41. package/dist/resources/extensions/gsd/state.ts +30 -0
  42. package/dist/resources/extensions/gsd/templates/preferences.md +1 -0
  43. package/dist/resources/extensions/gsd/tests/context-budget.test.ts +283 -0
  44. package/dist/resources/extensions/gsd/tests/context-compression.test.ts +1 -1
  45. package/dist/resources/extensions/gsd/tests/context-store.test.ts +462 -0
  46. package/dist/resources/extensions/gsd/tests/continue-here.test.ts +204 -0
  47. package/dist/resources/extensions/gsd/tests/dashboard-budget.test.ts +346 -0
  48. package/dist/resources/extensions/gsd/tests/db-writer.test.ts +602 -0
  49. package/dist/resources/extensions/gsd/tests/debug-logger.test.ts +185 -0
  50. package/dist/resources/extensions/gsd/tests/derive-state-db.test.ts +406 -0
  51. package/dist/resources/extensions/gsd/tests/dispatch-guard.test.ts +0 -1
  52. package/dist/resources/extensions/gsd/tests/dist-redirect.mjs +22 -0
  53. package/dist/resources/extensions/gsd/tests/doctor-proactive.test.ts +244 -0
  54. package/dist/resources/extensions/gsd/tests/doctor-runtime.test.ts +303 -0
  55. package/dist/resources/extensions/gsd/tests/gsd-db.test.ts +353 -0
  56. package/dist/resources/extensions/gsd/tests/gsd-inspect.test.ts +125 -0
  57. package/dist/resources/extensions/gsd/tests/gsd-tools.test.ts +326 -0
  58. package/dist/resources/extensions/gsd/tests/integration-edge.test.ts +228 -0
  59. package/dist/resources/extensions/gsd/tests/integration-lifecycle.test.ts +277 -0
  60. package/dist/resources/extensions/gsd/tests/md-importer.test.ts +411 -0
  61. package/dist/resources/extensions/gsd/tests/metrics.test.ts +197 -0
  62. package/dist/resources/extensions/gsd/tests/model-isolation.test.ts +99 -0
  63. package/dist/resources/extensions/gsd/tests/parsers.test.ts +40 -0
  64. package/dist/resources/extensions/gsd/tests/post-unit-hooks.test.ts +41 -1
  65. package/dist/resources/extensions/gsd/tests/preferences-git.test.ts +0 -1
  66. package/dist/resources/extensions/gsd/tests/preferences-hooks.test.ts +0 -1
  67. package/dist/resources/extensions/gsd/tests/preferences-mode.test.ts +110 -0
  68. package/dist/resources/extensions/gsd/tests/preferences-models.test.ts +0 -1
  69. package/dist/resources/extensions/gsd/tests/prompt-budget-enforcement.test.ts +464 -0
  70. package/dist/resources/extensions/gsd/tests/prompt-db.test.ts +385 -0
  71. package/dist/resources/extensions/gsd/tests/remote-questions.test.ts +262 -1
  72. package/dist/resources/extensions/gsd/tests/resolve-ts-hooks.mjs +17 -29
  73. package/dist/resources/extensions/gsd/tests/resolve-ts.mjs +2 -8
  74. package/dist/resources/extensions/gsd/tests/skill-lifecycle.test.ts +126 -0
  75. package/dist/resources/extensions/gsd/tests/stop-auto-remote.test.ts +31 -8
  76. package/dist/resources/extensions/gsd/tests/token-savings.test.ts +366 -0
  77. package/dist/resources/extensions/gsd/tests/unit-runtime.test.ts +25 -1
  78. package/dist/resources/extensions/gsd/tests/visualizer-critical-path.test.ts +145 -0
  79. package/dist/resources/extensions/gsd/tests/visualizer-data.test.ts +92 -0
  80. package/dist/resources/extensions/gsd/tests/visualizer-overlay.test.ts +120 -0
  81. package/dist/resources/extensions/gsd/tests/visualizer-views.test.ts +228 -5
  82. package/dist/resources/extensions/gsd/tests/worktree-db-integration.test.ts +205 -0
  83. package/dist/resources/extensions/gsd/tests/worktree-db.test.ts +442 -0
  84. package/dist/resources/extensions/gsd/tests/worktree-post-create-hook.test.ts +165 -0
  85. package/dist/resources/extensions/gsd/types.ts +29 -0
  86. package/dist/resources/extensions/gsd/undo.ts +0 -1
  87. package/dist/resources/extensions/gsd/unit-runtime.ts +5 -1
  88. package/dist/resources/extensions/gsd/visualizer-data.ts +352 -1
  89. package/dist/resources/extensions/gsd/visualizer-overlay.ts +166 -22
  90. package/dist/resources/extensions/gsd/visualizer-views.ts +464 -2
  91. package/dist/resources/extensions/gsd/worktree-command.ts +18 -0
  92. package/dist/resources/extensions/gsd/worktree-manager.ts +11 -4
  93. package/dist/resources/extensions/remote-questions/config.ts +4 -2
  94. package/dist/resources/extensions/remote-questions/discord-adapter.ts +2 -4
  95. package/dist/resources/extensions/remote-questions/format.ts +154 -8
  96. package/dist/resources/extensions/remote-questions/manager.ts +9 -7
  97. package/dist/resources/extensions/remote-questions/remote-command.ts +100 -4
  98. package/dist/resources/extensions/remote-questions/slack-adapter.ts +58 -2
  99. package/dist/resources/extensions/remote-questions/telegram-adapter.ts +161 -0
  100. package/dist/resources/extensions/remote-questions/types.ts +2 -1
  101. package/dist/resources/extensions/ttsr/ttsr-manager.ts +26 -0
  102. package/dist/resources/extensions/voice/index.ts +4 -3
  103. package/package.json +1 -1
  104. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  105. package/packages/pi-coding-agent/dist/core/agent-session.js +12 -1
  106. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  107. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
  108. package/packages/pi-coding-agent/dist/core/extensions/loader.js +5 -0
  109. package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
  110. package/packages/pi-coding-agent/dist/core/lsp/client.d.ts +6 -0
  111. package/packages/pi-coding-agent/dist/core/lsp/client.d.ts.map +1 -1
  112. package/packages/pi-coding-agent/dist/core/lsp/client.js +25 -0
  113. package/packages/pi-coding-agent/dist/core/lsp/client.js.map +1 -1
  114. package/packages/pi-coding-agent/dist/core/lsp/index.d.ts +2 -0
  115. package/packages/pi-coding-agent/dist/core/lsp/index.d.ts.map +1 -1
  116. package/packages/pi-coding-agent/dist/core/lsp/index.js +106 -3
  117. package/packages/pi-coding-agent/dist/core/lsp/index.js.map +1 -1
  118. package/packages/pi-coding-agent/dist/core/lsp/lsp.md +6 -0
  119. package/packages/pi-coding-agent/dist/core/lsp/types.d.ts +35 -0
  120. package/packages/pi-coding-agent/dist/core/lsp/types.d.ts.map +1 -1
  121. package/packages/pi-coding-agent/dist/core/lsp/types.js +6 -0
  122. package/packages/pi-coding-agent/dist/core/lsp/types.js.map +1 -1
  123. package/packages/pi-coding-agent/dist/core/lsp/utils.d.ts +3 -1
  124. package/packages/pi-coding-agent/dist/core/lsp/utils.d.ts.map +1 -1
  125. package/packages/pi-coding-agent/dist/core/lsp/utils.js +45 -0
  126. package/packages/pi-coding-agent/dist/core/lsp/utils.js.map +1 -1
  127. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts +6 -0
  128. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
  129. package/packages/pi-coding-agent/dist/core/settings-manager.js +43 -11
  130. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  131. package/packages/pi-coding-agent/dist/core/system-prompt.d.ts.map +1 -1
  132. package/packages/pi-coding-agent/dist/core/system-prompt.js +7 -1
  133. package/packages/pi-coding-agent/dist/core/system-prompt.js.map +1 -1
  134. package/packages/pi-coding-agent/dist/core/tools/edit.d.ts.map +1 -1
  135. package/packages/pi-coding-agent/dist/core/tools/edit.js +5 -0
  136. package/packages/pi-coding-agent/dist/core/tools/edit.js.map +1 -1
  137. package/packages/pi-coding-agent/dist/core/tools/index.d.ts +2 -0
  138. package/packages/pi-coding-agent/dist/core/tools/index.d.ts.map +1 -1
  139. package/packages/pi-coding-agent/dist/core/tools/write.d.ts.map +1 -1
  140. package/packages/pi-coding-agent/dist/core/tools/write.js +5 -0
  141. package/packages/pi-coding-agent/dist/core/tools/write.js.map +1 -1
  142. package/packages/pi-coding-agent/src/core/agent-session.ts +13 -1
  143. package/packages/pi-coding-agent/src/core/extensions/loader.ts +6 -0
  144. package/packages/pi-coding-agent/src/core/lsp/client.ts +26 -0
  145. package/packages/pi-coding-agent/src/core/lsp/index.ts +157 -2
  146. package/packages/pi-coding-agent/src/core/lsp/lsp.md +6 -0
  147. package/packages/pi-coding-agent/src/core/lsp/types.ts +53 -0
  148. package/packages/pi-coding-agent/src/core/lsp/utils.ts +56 -0
  149. package/packages/pi-coding-agent/src/core/settings-manager.ts +41 -11
  150. package/packages/pi-coding-agent/src/core/system-prompt.ts +7 -1
  151. package/packages/pi-coding-agent/src/core/tools/edit.ts +3 -0
  152. package/packages/pi-coding-agent/src/core/tools/write.ts +3 -0
  153. package/src/resources/extensions/google-search/index.ts +164 -47
  154. package/src/resources/extensions/gsd/auto-prompts.ts +103 -24
  155. package/src/resources/extensions/gsd/auto-worktree.ts +93 -9
  156. package/src/resources/extensions/gsd/auto.ts +424 -30
  157. package/src/resources/extensions/gsd/commands.ts +518 -36
  158. package/src/resources/extensions/gsd/context-budget.ts +243 -0
  159. package/src/resources/extensions/gsd/context-store.ts +195 -0
  160. package/src/resources/extensions/gsd/dashboard-overlay.ts +41 -3
  161. package/src/resources/extensions/gsd/db-writer.ts +341 -0
  162. package/src/resources/extensions/gsd/debug-logger.ts +178 -0
  163. package/src/resources/extensions/gsd/dispatch-guard.ts +0 -1
  164. package/src/resources/extensions/gsd/docs/preferences-reference.md +54 -0
  165. package/src/resources/extensions/gsd/doctor-proactive.ts +286 -0
  166. package/src/resources/extensions/gsd/doctor.ts +283 -2
  167. package/src/resources/extensions/gsd/export.ts +81 -2
  168. package/src/resources/extensions/gsd/files.ts +39 -9
  169. package/src/resources/extensions/gsd/git-service.ts +6 -0
  170. package/src/resources/extensions/gsd/gsd-db.ts +752 -0
  171. package/src/resources/extensions/gsd/guided-flow.ts +26 -1
  172. package/src/resources/extensions/gsd/history.ts +0 -1
  173. package/src/resources/extensions/gsd/index.ts +277 -1
  174. package/src/resources/extensions/gsd/md-importer.ts +526 -0
  175. package/src/resources/extensions/gsd/metrics.ts +39 -3
  176. package/src/resources/extensions/gsd/notifications.ts +0 -1
  177. package/src/resources/extensions/gsd/post-unit-hooks.ts +70 -1
  178. package/src/resources/extensions/gsd/preferences.ts +125 -150
  179. package/src/resources/extensions/gsd/prompts/execute-task.md +3 -5
  180. package/src/resources/extensions/gsd/prompts/heal-skill.md +45 -0
  181. package/src/resources/extensions/gsd/prompts/plan-slice.md +5 -1
  182. package/src/resources/extensions/gsd/prompts/quick-task.md +48 -0
  183. package/src/resources/extensions/gsd/prompts/system.md +2 -1
  184. package/src/resources/extensions/gsd/quick.ts +156 -0
  185. package/src/resources/extensions/gsd/skill-discovery.ts +5 -3
  186. package/src/resources/extensions/gsd/skill-health.ts +417 -0
  187. package/src/resources/extensions/gsd/skill-telemetry.ts +127 -0
  188. package/src/resources/extensions/gsd/state.ts +30 -0
  189. package/src/resources/extensions/gsd/templates/preferences.md +1 -0
  190. package/src/resources/extensions/gsd/tests/context-budget.test.ts +283 -0
  191. package/src/resources/extensions/gsd/tests/context-compression.test.ts +1 -1
  192. package/src/resources/extensions/gsd/tests/context-store.test.ts +462 -0
  193. package/src/resources/extensions/gsd/tests/continue-here.test.ts +204 -0
  194. package/src/resources/extensions/gsd/tests/dashboard-budget.test.ts +346 -0
  195. package/src/resources/extensions/gsd/tests/db-writer.test.ts +602 -0
  196. package/src/resources/extensions/gsd/tests/debug-logger.test.ts +185 -0
  197. package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +406 -0
  198. package/src/resources/extensions/gsd/tests/dispatch-guard.test.ts +0 -1
  199. package/src/resources/extensions/gsd/tests/dist-redirect.mjs +22 -0
  200. package/src/resources/extensions/gsd/tests/doctor-proactive.test.ts +244 -0
  201. package/src/resources/extensions/gsd/tests/doctor-runtime.test.ts +303 -0
  202. package/src/resources/extensions/gsd/tests/gsd-db.test.ts +353 -0
  203. package/src/resources/extensions/gsd/tests/gsd-inspect.test.ts +125 -0
  204. package/src/resources/extensions/gsd/tests/gsd-tools.test.ts +326 -0
  205. package/src/resources/extensions/gsd/tests/integration-edge.test.ts +228 -0
  206. package/src/resources/extensions/gsd/tests/integration-lifecycle.test.ts +277 -0
  207. package/src/resources/extensions/gsd/tests/md-importer.test.ts +411 -0
  208. package/src/resources/extensions/gsd/tests/metrics.test.ts +197 -0
  209. package/src/resources/extensions/gsd/tests/model-isolation.test.ts +99 -0
  210. package/src/resources/extensions/gsd/tests/parsers.test.ts +40 -0
  211. package/src/resources/extensions/gsd/tests/post-unit-hooks.test.ts +41 -1
  212. package/src/resources/extensions/gsd/tests/preferences-git.test.ts +0 -1
  213. package/src/resources/extensions/gsd/tests/preferences-hooks.test.ts +0 -1
  214. package/src/resources/extensions/gsd/tests/preferences-mode.test.ts +110 -0
  215. package/src/resources/extensions/gsd/tests/preferences-models.test.ts +0 -1
  216. package/src/resources/extensions/gsd/tests/prompt-budget-enforcement.test.ts +464 -0
  217. package/src/resources/extensions/gsd/tests/prompt-db.test.ts +385 -0
  218. package/src/resources/extensions/gsd/tests/remote-questions.test.ts +262 -1
  219. package/src/resources/extensions/gsd/tests/resolve-ts-hooks.mjs +17 -29
  220. package/src/resources/extensions/gsd/tests/resolve-ts.mjs +2 -8
  221. package/src/resources/extensions/gsd/tests/skill-lifecycle.test.ts +126 -0
  222. package/src/resources/extensions/gsd/tests/stop-auto-remote.test.ts +31 -8
  223. package/src/resources/extensions/gsd/tests/token-savings.test.ts +366 -0
  224. package/src/resources/extensions/gsd/tests/unit-runtime.test.ts +25 -1
  225. package/src/resources/extensions/gsd/tests/visualizer-critical-path.test.ts +145 -0
  226. package/src/resources/extensions/gsd/tests/visualizer-data.test.ts +92 -0
  227. package/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts +120 -0
  228. package/src/resources/extensions/gsd/tests/visualizer-views.test.ts +228 -5
  229. package/src/resources/extensions/gsd/tests/worktree-db-integration.test.ts +205 -0
  230. package/src/resources/extensions/gsd/tests/worktree-db.test.ts +442 -0
  231. package/src/resources/extensions/gsd/tests/worktree-post-create-hook.test.ts +165 -0
  232. package/src/resources/extensions/gsd/types.ts +29 -0
  233. package/src/resources/extensions/gsd/undo.ts +0 -1
  234. package/src/resources/extensions/gsd/unit-runtime.ts +5 -1
  235. package/src/resources/extensions/gsd/visualizer-data.ts +352 -1
  236. package/src/resources/extensions/gsd/visualizer-overlay.ts +166 -22
  237. package/src/resources/extensions/gsd/visualizer-views.ts +464 -2
  238. package/src/resources/extensions/gsd/worktree-command.ts +18 -0
  239. package/src/resources/extensions/gsd/worktree-manager.ts +11 -4
  240. package/src/resources/extensions/remote-questions/config.ts +4 -2
  241. package/src/resources/extensions/remote-questions/discord-adapter.ts +2 -4
  242. package/src/resources/extensions/remote-questions/format.ts +154 -8
  243. package/src/resources/extensions/remote-questions/manager.ts +9 -7
  244. package/src/resources/extensions/remote-questions/remote-command.ts +100 -4
  245. package/src/resources/extensions/remote-questions/slack-adapter.ts +58 -2
  246. package/src/resources/extensions/remote-questions/telegram-adapter.ts +161 -0
  247. package/src/resources/extensions/remote-questions/types.ts +2 -1
  248. package/src/resources/extensions/ttsr/ttsr-manager.ts +26 -0
  249. package/src/resources/extensions/voice/index.ts +4 -3
@@ -1,35 +1,23 @@
1
- // ESM resolve hook: .js → .ts rewriting for test environments.
2
- // Only rewrites relative imports from our own source files — not from node_modules.
3
- //
4
- // Handles two patterns:
5
- // 1. .js → .ts (pi bundler convention: source files use .js specifiers)
6
- // 2. extensionless → .ts (some source files omit extensions in relative imports)
1
+ import { fileURLToPath } from 'node:url';
7
2
 
8
- export function resolve(specifier, context, nextResolve) {
9
- const parentURL = context.parentURL || '';
10
- const isFromNodeModules = parentURL.includes('/node_modules/');
11
- const isFromPackages = parentURL.includes('/packages/');
12
-
13
- if (!isFromNodeModules && !isFromPackages && !specifier.startsWith('node:')) {
14
- // Rewrite .js → .ts
15
- if (specifier.endsWith('.js')) {
16
- const tsSpecifier = specifier.replace(/\.js$/, '.ts');
17
- try {
18
- return nextResolve(tsSpecifier, context);
19
- } catch {
20
- // fall through to default resolution
21
- }
22
- }
3
+ const ROOT = new URL("../../../../../", import.meta.url);
4
+ const PACKAGES_ROOT = fileURLToPath(new URL("packages/", ROOT));
23
5
 
24
- // Try adding .ts to extensionless relative imports
25
- if (specifier.startsWith('.') && !/\.[a-z]+$/i.test(specifier)) {
26
- try {
27
- return nextResolve(specifier + '.ts', context);
28
- } catch {
29
- // fall through to default resolution
30
- }
6
+ export function resolve(specifier, context, nextResolve) {
7
+ let tsSpecifier = specifier;
8
+ if (specifier.includes('@gsd/')) {
9
+ tsSpecifier = specifier.replace('@gsd/', PACKAGES_ROOT).replace('/dist/', '/src/');
10
+ if (tsSpecifier.includes('/packages/pi-ai') && !tsSpecifier.endsWith('.ts')) {
11
+ tsSpecifier = tsSpecifier.replace(/\/packages\/pi-ai$/, '/packages/pi-ai/src/index.ts');
12
+ } else if (!tsSpecifier.includes('/src/') && !tsSpecifier.endsWith('.ts')) {
13
+ // Fallback for other gsd packages like pi-coding-agent, pi-tui, pi-agent-core
14
+ tsSpecifier = tsSpecifier.replace(/\/packages\/([^\/]+)$/, '/packages/$1/src/index.ts');
15
+ } else if (!tsSpecifier.endsWith('.ts') && !tsSpecifier.endsWith('.js') && !tsSpecifier.endsWith('.mjs')) {
16
+ tsSpecifier += '/index.ts';
31
17
  }
18
+ } else if (specifier.endsWith('.js')) {
19
+ tsSpecifier = specifier.replace(/\.js$/, '.ts');
32
20
  }
33
21
 
34
- return nextResolve(specifier, context);
22
+ return nextResolve(tsSpecifier, context);
35
23
  }
@@ -1,11 +1,5 @@
1
- // Custom ESM resolver: rewrites .js imports to .ts for node --test with TypeScript sources.
2
- // Usage: node --import ./agent/extensions/gsd/tests/resolve-ts.mjs --test ...
3
- //
4
- // This is needed because pi extension source files use .js import specifiers
5
- // (the pi runtime bundler convention), but only .ts files exist on disk.
6
- // Node's built-in TypeScript support strips types but doesn't rewrite specifiers.
7
-
8
1
  import { register } from 'node:module';
9
2
  import { pathToFileURL } from 'node:url';
10
3
 
11
- register(new URL('./resolve-ts-hooks.mjs', import.meta.url), pathToFileURL('./'));
4
+ // Register hook to redirect imports to the dist directory
5
+ register(new URL('./dist-redirect.mjs', import.meta.url), pathToFileURL('./'));
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Tests for skill telemetry and skill health (#599).
3
+ * Tests the pure functions — no file I/O, no extension context.
4
+ */
5
+
6
+ import { describe, it, beforeEach } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import type { UnitMetrics } from "../metrics.js";
9
+
10
+ // ─── Test helpers ─────────────────────────────────────────────────────────────
11
+
12
+ function makeUnit(overrides: Partial<UnitMetrics> = {}): UnitMetrics {
13
+ return {
14
+ type: "execute-task",
15
+ id: "M001/S01/T01",
16
+ model: "claude-sonnet-4-20250514",
17
+ startedAt: 1000,
18
+ finishedAt: 2000,
19
+ tokens: { input: 1000, output: 500, cacheRead: 200, cacheWrite: 100, total: 1800 },
20
+ cost: 0.05,
21
+ toolCalls: 3,
22
+ assistantMessages: 5,
23
+ userMessages: 2,
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ // ─── Skill Telemetry ──────────────────────────────────────────────────────────
29
+
30
+ describe("skill-telemetry", () => {
31
+ // Note: captureAvailableSkills/getAndClearSkills depend on filesystem (getAgentDir)
32
+ // so we test the data flow via getSkillLastUsed and detectStaleSkills which are pure
33
+
34
+ it("getSkillLastUsed returns most recent timestamp per skill", async () => {
35
+ const { getSkillLastUsed } = await import("../skill-telemetry.js");
36
+
37
+ const units = [
38
+ makeUnit({ finishedAt: 1000, skills: ["rust-core", "axum-web-framework"] }),
39
+ makeUnit({ finishedAt: 2000, skills: ["rust-core"] }),
40
+ makeUnit({ finishedAt: 3000, skills: ["axum-web-framework"] }),
41
+ ];
42
+
43
+ const result = getSkillLastUsed(units);
44
+ assert.equal(result.get("rust-core"), 2000);
45
+ assert.equal(result.get("axum-web-framework"), 3000);
46
+ });
47
+
48
+ it("getSkillLastUsed returns empty map for units without skills", async () => {
49
+ const { getSkillLastUsed } = await import("../skill-telemetry.js");
50
+
51
+ const units = [makeUnit(), makeUnit()];
52
+ const result = getSkillLastUsed(units);
53
+ assert.equal(result.size, 0);
54
+ });
55
+ });
56
+
57
+ // ─── Skill Health ─────────────────────────────────────────────────────────────
58
+
59
+ describe("skill-health", () => {
60
+ it("buildHealSkillPrompt includes unit ID", async () => {
61
+ const { buildHealSkillPrompt } = await import("../skill-health.js");
62
+ const prompt = buildHealSkillPrompt("M001/S01/T01");
63
+ assert.ok(prompt.includes("M001/S01/T01"));
64
+ assert.ok(prompt.includes("Skill Heal Analysis"));
65
+ assert.ok(prompt.includes("skill-review-queue.md"));
66
+ });
67
+
68
+ it("computeStaleAvoidList excludes already-avoided skills", async () => {
69
+ // This test requires filesystem access for loadLedgerFromDisk
70
+ // so we test the filtering logic conceptually
71
+ const { computeStaleAvoidList } = await import("../skill-health.js");
72
+
73
+ // With no metrics file, should return empty
74
+ const result = computeStaleAvoidList("/nonexistent/path", ["some-skill"]);
75
+ assert.ok(Array.isArray(result));
76
+ });
77
+ });
78
+
79
+ // ─── UnitMetrics skills field ─────────────────────────────────────────────────
80
+
81
+ describe("UnitMetrics skills field", () => {
82
+ it("skills field is optional and accepts string array", () => {
83
+ const unit = makeUnit({ skills: ["rust-core", "axum-web-framework"] });
84
+ assert.deepEqual(unit.skills, ["rust-core", "axum-web-framework"]);
85
+ });
86
+
87
+ it("skills field is undefined when not provided", () => {
88
+ const unit = makeUnit();
89
+ assert.equal(unit.skills, undefined);
90
+ });
91
+ });
92
+
93
+ // ─── Preferences ──────────────────────────────────────────────────────────────
94
+
95
+ describe("skill_staleness_days preference", () => {
96
+ it("validates valid staleness days", async () => {
97
+ const { validatePreferences } = await import("../preferences.js");
98
+
99
+ const result = validatePreferences({ skill_staleness_days: 30 });
100
+ assert.equal(result.preferences.skill_staleness_days, 30);
101
+ assert.equal(result.errors.length, 0);
102
+ });
103
+
104
+ it("validates zero (disabled) staleness days", async () => {
105
+ const { validatePreferences } = await import("../preferences.js");
106
+
107
+ const result = validatePreferences({ skill_staleness_days: 0 });
108
+ assert.equal(result.preferences.skill_staleness_days, 0);
109
+ assert.equal(result.errors.length, 0);
110
+ });
111
+
112
+ it("rejects negative staleness days", async () => {
113
+ const { validatePreferences } = await import("../preferences.js");
114
+
115
+ const result = validatePreferences({ skill_staleness_days: -5 });
116
+ assert.equal(result.preferences.skill_staleness_days, undefined);
117
+ assert.ok(result.errors.some(e => e.includes("skill_staleness_days")));
118
+ });
119
+
120
+ it("floors fractional days", async () => {
121
+ const { validatePreferences } = await import("../preferences.js");
122
+
123
+ const result = validatePreferences({ skill_staleness_days: 30.7 });
124
+ assert.equal(result.preferences.skill_staleness_days, 30);
125
+ });
126
+ });
@@ -4,7 +4,7 @@ import { mkdirSync, rmSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
6
  import { randomUUID } from "node:crypto";
7
- import { fork } from "node:child_process";
7
+ import { spawn, type ChildProcess } from "node:child_process";
8
8
 
9
9
  import { writeFileSync } from "node:fs";
10
10
  import {
@@ -25,6 +25,27 @@ function cleanup(base: string): void {
25
25
  try { rmSync(base, { recursive: true, force: true }); } catch { /* */ }
26
26
  }
27
27
 
28
+ function waitForChildExit(child: ChildProcess, timeoutMs = 5000): Promise<number | null> {
29
+ return new Promise((resolve) => {
30
+ if (child.exitCode !== null) {
31
+ resolve(child.exitCode);
32
+ return;
33
+ }
34
+
35
+ const timeout = setTimeout(() => {
36
+ child.off("exit", onExit);
37
+ resolve(child.exitCode);
38
+ }, timeoutMs);
39
+
40
+ const onExit = (code: number | null) => {
41
+ clearTimeout(timeout);
42
+ resolve(code);
43
+ };
44
+
45
+ child.once("exit", onExit);
46
+ });
47
+ }
48
+
28
49
  // ─── stopAutoRemote ──────────────────────────────────────────────────────
29
50
 
30
51
  test("stopAutoRemote returns found:false when no lock file exists", () => {
@@ -63,12 +84,16 @@ test("stopAutoRemote sends SIGTERM to a live process and returns found:true", as
63
84
  const base = makeTmpBase();
64
85
 
65
86
  // Spawn a child process that sleeps, acting as a fake auto-mode session
66
- const child = fork(
67
- "-e",
68
- ["process.on('SIGTERM', () => process.exit(0)); setTimeout(() => process.exit(1), 30000);"],
87
+ const child = spawn(
88
+ process.execPath,
89
+ ["-e", "process.on('SIGTERM', () => process.exit(0)); setTimeout(() => process.exit(1), 30000);"],
69
90
  { stdio: "ignore", detached: false },
70
91
  );
71
92
 
93
+ if (!child.pid) {
94
+ throw new Error("failed to spawn child process for stopAutoRemote test");
95
+ }
96
+
72
97
  try {
73
98
  // Wait for child to be ready
74
99
  await new Promise((resolve) => setTimeout(resolve, 200));
@@ -84,15 +109,13 @@ test("stopAutoRemote sends SIGTERM to a live process and returns found:true", as
84
109
  };
85
110
  writeFileSync(join(base, ".gsd", "auto.lock"), JSON.stringify(lockData, null, 2), "utf-8");
86
111
 
112
+ const exitPromise = waitForChildExit(child);
87
113
  const result = stopAutoRemote(base);
88
114
  assert.equal(result.found, true, "should find running auto-mode");
89
115
  assert.equal(result.pid, child.pid, "should return the PID");
90
116
 
91
117
  // Wait for child to exit (it should receive SIGTERM)
92
- const exitCode = await new Promise<number | null>((resolve) => {
93
- child.on("exit", (code) => resolve(code));
94
- setTimeout(() => resolve(null), 5000);
95
- });
118
+ const exitCode = await exitPromise;
96
119
  // On Windows, SIGTERM is not interceptable — the process exits with code 1
97
120
  // rather than running the handler. Accept either clean exit (0) or forced (1).
98
121
  assert.ok(exitCode !== null, "child should have exited after SIGTERM");
@@ -0,0 +1,366 @@
1
+ // Token Savings Validation Test
2
+ //
3
+ // Proves ≥30% character savings when using DB-scoped content vs full-markdown
4
+ // for planning/research prompt types. Uses realistic fixture data:
5
+ // 24 decisions across 3 milestones, 21 requirements across 5 slices in 2 milestones.
6
+ //
7
+ // Retires R016 (≥30% savings target) and provides evidence for R019 (no quality regression).
8
+
9
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { tmpdir } from 'node:os';
12
+
13
+ import { openDatabase, closeDatabase } from '../gsd-db.ts';
14
+ import { migrateFromMarkdown } from '../md-importer.ts';
15
+ import {
16
+ queryDecisions,
17
+ queryRequirements,
18
+ formatDecisionsForPrompt,
19
+ formatRequirementsForPrompt,
20
+ } from '../context-store.ts';
21
+ import { createTestContext } from './test-helpers.ts';
22
+
23
+ const { assertEq, assertTrue, assertMatch, assertNoMatch, report } = createTestContext();
24
+
25
+ // ─── Fixture Generators ────────────────────────────────────────────────────
26
+
27
+ /**
28
+ * Generate a realistic DECISIONS.md with `count` decisions spread across milestones.
29
+ * Each decision has realistic-length text in each column to produce meaningful size.
30
+ */
31
+ function generateDecisionsMarkdown(count: number, milestones: string[]): string {
32
+ const lines: string[] = [
33
+ '# Decisions Register',
34
+ '',
35
+ '<!-- Append-only. Never edit or remove existing rows. -->',
36
+ '',
37
+ '| # | When | Scope | Decision | Choice | Rationale | Revisable? |',
38
+ '|---|------|-------|----------|--------|-----------|------------|',
39
+ ];
40
+
41
+ for (let i = 1; i <= count; i++) {
42
+ const id = `D${String(i).padStart(3, '0')}`;
43
+ const milestone = milestones[(i - 1) % milestones.length];
44
+ const sliceNum = ((i - 1) % 5) + 1;
45
+ const when = `${milestone}/S${String(sliceNum).padStart(2, '0')}`;
46
+ const scope = ['architecture', 'testing', 'observability', 'security', 'performance'][(i - 1) % 5];
47
+ const decision = `${scope} decision ${i}: implement ${scope}-level ${['caching', 'validation', 'retry logic', 'circuit breaker', 'rate limiting'][(i - 1) % 5]} for the ${['API layer', 'data pipeline', 'auth subsystem', 'notification service', 'background workers'][(i - 1) % 5]}`;
48
+ const choice = `Use ${['SQLite', 'Redis', 'in-memory cache', 'exponential backoff', 'token bucket'][(i - 1) % 5]} with ${['WAL mode', 'cluster mode', 'LRU eviction', 'jitter', 'sliding window'][(i - 1) % 5]} configuration for optimal ${scope} characteristics`;
49
+ const rationale = `${['Built-in Node.js support eliminates external dependency', 'Sub-millisecond latency meets P99 requirement', 'Memory-efficient with bounded growth prevents OOM', 'Prevents thundering herd during recovery', 'Protects downstream services from burst traffic'][(i - 1) % 5]}. This aligns with our ${scope} principles established in the architecture review and satisfies the non-functional requirements for the ${milestone} milestone.`;
50
+ const revisable = i % 3 === 0 ? 'no' : 'yes';
51
+
52
+ lines.push(`| ${id} | ${when} | ${scope} | ${decision} | ${choice} | ${rationale} | ${revisable} |`);
53
+ }
54
+
55
+ return lines.join('\n');
56
+ }
57
+
58
+ /**
59
+ * Generate a realistic REQUIREMENTS.md with `count` requirements spread across slices.
60
+ * Each requirement has multiple detailed fields producing meaningful character content.
61
+ */
62
+ function generateRequirementsMarkdown(count: number, sliceAssignments: { milestone: string; slice: string }[]): string {
63
+ const lines: string[] = [
64
+ '# Requirements',
65
+ '',
66
+ '## Active',
67
+ '',
68
+ ];
69
+
70
+ for (let i = 1; i <= count; i++) {
71
+ const id = `R${String(i).padStart(3, '0')}`;
72
+ const assignment = sliceAssignments[(i - 1) % sliceAssignments.length];
73
+ const reqClass = ['functional', 'non-functional', 'constraint', 'functional', 'non-functional'][(i - 1) % 5];
74
+ const description = `${['Response latency', 'Data consistency', 'Error recovery', 'Access control', 'Audit logging', 'Cache invalidation', 'Schema migration'][(i - 1) % 7]} requirement for ${assignment.milestone}/${assignment.slice}`;
75
+ const why = `Critical for ${['user experience', 'data integrity', 'system reliability', 'security compliance', 'regulatory requirements', 'operational visibility', 'deployment safety'][(i - 1) % 7]}. Without this, the system would ${['degrade under load', 'lose data during failures', 'fail to recover from crashes', 'expose unauthorized data', 'violate compliance mandates', 'have stale data issues', 'break during schema changes'][(i - 1) % 7]}, which is unacceptable for production readiness.`;
76
+ const source = `Architecture review ${milestone_shorthand((i - 1) % 3)}, stakeholder feedback round ${((i - 1) % 4) + 1}`;
77
+ const primaryOwner = assignment.slice;
78
+ const supportingSlices = sliceAssignments
79
+ .filter(a => a.slice !== assignment.slice && a.milestone === assignment.milestone)
80
+ .map(a => a.slice)
81
+ .slice(0, 2)
82
+ .join(', ');
83
+ const validation = `${['Automated test suite covers all edge cases', 'Load test confirms P99 < 200ms under 1000 RPS', 'Chaos test proves recovery within 30s', 'Penetration test shows no unauthorized access paths', 'Audit log review confirms complete event capture', 'Integration test validates cache consistency', 'Migration test verifies zero-downtime upgrade'][(i - 1) % 7]}. Additionally, manual review by ${['architecture team', 'security team', 'SRE team', 'product owner', 'tech lead'][(i - 1) % 5]} confirms adherence to standards.`;
84
+ const notes = `Tracked in ${['JIRA-123', 'JIRA-456', 'JIRA-789', 'JIRA-012', 'JIRA-345'][(i - 1) % 5]}. See also ${['ADR-001', 'ADR-002', 'ADR-003', 'ADR-004', 'ADR-005'][(i - 1) % 5]} for background context on this requirement domain.`;
85
+
86
+ lines.push(`### ${id} — ${description}`);
87
+ lines.push('');
88
+ lines.push(`- Class: ${reqClass}`);
89
+ lines.push(`- Status: active`);
90
+ lines.push(`- Why it matters: ${why}`);
91
+ lines.push(`- Source: ${source}`);
92
+ lines.push(`- Primary owning slice: ${primaryOwner}`);
93
+ if (supportingSlices) {
94
+ lines.push(`- Supporting slices: ${supportingSlices}`);
95
+ }
96
+ lines.push(`- Validation: ${validation}`);
97
+ lines.push(`- Notes: ${notes}`);
98
+ lines.push('');
99
+ }
100
+
101
+ return lines.join('\n');
102
+ }
103
+
104
+ function milestone_shorthand(index: number): string {
105
+ return ['alpha', 'beta', 'GA'][index] ?? 'alpha';
106
+ }
107
+
108
+ // ─── Fixture Setup ─────────────────────────────────────────────────────────
109
+
110
+ const MILESTONES = ['M001', 'M002', 'M003'];
111
+
112
+ // Slice assignments: 5 slices spread across M001 and M002
113
+ const SLICE_ASSIGNMENTS = [
114
+ { milestone: 'M001', slice: 'S01' },
115
+ { milestone: 'M001', slice: 'S02' },
116
+ { milestone: 'M001', slice: 'S03' },
117
+ { milestone: 'M002', slice: 'S04' },
118
+ { milestone: 'M002', slice: 'S05' },
119
+ ];
120
+
121
+ const DECISIONS_COUNT = 24;
122
+ const REQUIREMENTS_COUNT = 21;
123
+
124
+ const decisionsMarkdown = generateDecisionsMarkdown(DECISIONS_COUNT, MILESTONES);
125
+ const requirementsMarkdown = generateRequirementsMarkdown(REQUIREMENTS_COUNT, SLICE_ASSIGNMENTS);
126
+
127
+ const PROJECT_CONTENT = `# Test Project
128
+
129
+ A test project for validating token savings with DB-scoped content.
130
+
131
+ ## Goals
132
+ - Validate ≥30% character savings on planning prompts
133
+ - Ensure quality of scoped content (correct items, no cross-contamination)
134
+
135
+ ## Architecture
136
+ - SQLite-backed artifact storage with markdown import
137
+ - Milestone/slice-scoped queries for prompt injection
138
+ - Fallback to full markdown when DB unavailable
139
+ `;
140
+
141
+ // ═══════════════════════════════════════════════════════════════════════════
142
+ // Test: Plan-slice savings (≥30%)
143
+ // ═══════════════════════════════════════════════════════════════════════════
144
+
145
+ console.log('\n=== token-savings: plan-slice prompt ≥30% character savings ===');
146
+ {
147
+ const base = mkdtempSync(join(tmpdir(), 'gsd-token-savings-'));
148
+ mkdirSync(join(base, '.gsd'), { recursive: true });
149
+ writeFileSync(join(base, '.gsd', 'DECISIONS.md'), decisionsMarkdown);
150
+ writeFileSync(join(base, '.gsd', 'REQUIREMENTS.md'), requirementsMarkdown);
151
+ writeFileSync(join(base, '.gsd', 'PROJECT.md'), PROJECT_CONTENT);
152
+
153
+ // Open :memory: DB and import
154
+ openDatabase(':memory:');
155
+ const result = migrateFromMarkdown(base);
156
+
157
+ assertTrue(result.decisions === DECISIONS_COUNT, `imported ${result.decisions} decisions, expected ${DECISIONS_COUNT}`);
158
+ assertTrue(result.requirements === REQUIREMENTS_COUNT, `imported ${result.requirements} requirements, expected ${REQUIREMENTS_COUNT}`);
159
+
160
+ // ── DB-scoped content for plan-slice (M001 decisions + S01 requirements) ──
161
+ const scopedDecisions = queryDecisions({ milestoneId: 'M001' });
162
+ const scopedRequirements = queryRequirements({ sliceId: 'S01' });
163
+ const dbDecisionsContent = formatDecisionsForPrompt(scopedDecisions);
164
+ const dbRequirementsContent = formatRequirementsForPrompt(scopedRequirements);
165
+
166
+ // ── Full-markdown equivalents (what inlineGsdRootFile would return) ──
167
+ const fullDecisionsContent = readFileSync(join(base, '.gsd', 'DECISIONS.md'), 'utf-8');
168
+ const fullRequirementsContent = readFileSync(join(base, '.gsd', 'REQUIREMENTS.md'), 'utf-8');
169
+
170
+ // DB-scoped total vs full-markdown total
171
+ const dbTotal = dbDecisionsContent.length + dbRequirementsContent.length;
172
+ const fullTotal = fullDecisionsContent.length + fullRequirementsContent.length;
173
+
174
+ const savingsPercent = ((fullTotal - dbTotal) / fullTotal) * 100;
175
+ console.log(` Plan-slice savings: ${savingsPercent.toFixed(1)}% (DB: ${dbTotal} chars, full: ${fullTotal} chars)`);
176
+
177
+ assertTrue(dbTotal > 0, 'DB-scoped content is non-empty');
178
+ assertTrue(dbDecisionsContent.length > 0, 'DB-scoped decisions content is non-empty');
179
+ assertTrue(dbRequirementsContent.length > 0, 'DB-scoped requirements content is non-empty');
180
+ assertTrue(savingsPercent >= 30, `plan-slice savings ≥30% (actual: ${savingsPercent.toFixed(1)}%)`);
181
+ assertTrue(dbTotal < fullTotal * 0.70, `DB total (${dbTotal}) < 70% of full total (${fullTotal})`);
182
+
183
+ // ── Verify correct scoping: decisions ──
184
+ // M001 decisions: those with when_context containing 'M001' — indices 1,4,7,10,13,16,19,22
185
+ // (24 decisions round-robin across M001/M002/M003 → 8 for M001)
186
+ assertTrue(scopedDecisions.length === 8, `M001 decisions: expected 8, got ${scopedDecisions.length}`);
187
+ for (const d of scopedDecisions) {
188
+ assertTrue(d.when_context.includes('M001'), `decision ${d.id} should have M001 in when_context, got "${d.when_context}"`);
189
+ }
190
+
191
+ // Verify NO decisions from other milestones leak in
192
+ for (const d of scopedDecisions) {
193
+ assertNoMatch(d.when_context, /M002|M003/, `decision ${d.id} should not contain M002 or M003`);
194
+ }
195
+
196
+ // ── Verify correct scoping: requirements ──
197
+ // S01 requirements: those assigned to S01 as primary_owner
198
+ // S01 appears in positions 1,6,11,16,21 (5 assignments cycling, 21 reqs → indices 0,5,10,15,20)
199
+ assertTrue(scopedRequirements.length > 0, 'S01 requirements non-empty');
200
+ for (const r of scopedRequirements) {
201
+ assertTrue(
202
+ r.primary_owner.includes('S01') || r.supporting_slices.includes('S01'),
203
+ `requirement ${r.id} should be owned by or support S01`,
204
+ );
205
+ }
206
+
207
+ // Verify specific expected IDs are present
208
+ const scopedDecisionIds = scopedDecisions.map(d => d.id);
209
+ assertTrue(scopedDecisionIds.includes('D001'), 'M001 scoped decisions includes D001');
210
+ assertTrue(scopedDecisionIds.includes('D004'), 'M001 scoped decisions includes D004');
211
+ assertTrue(!scopedDecisionIds.includes('D002'), 'M001 scoped decisions excludes D002 (M002)');
212
+ assertTrue(!scopedDecisionIds.includes('D003'), 'M001 scoped decisions excludes D003 (M003)');
213
+
214
+ const scopedReqIds = scopedRequirements.map(r => r.id);
215
+ assertTrue(scopedReqIds.includes('R001'), 'S01 scoped requirements includes R001');
216
+
217
+ closeDatabase();
218
+ rmSync(base, { recursive: true, force: true });
219
+ }
220
+
221
+ // ═══════════════════════════════════════════════════════════════════════════
222
+ // Test: Research-milestone savings
223
+ // ═══════════════════════════════════════════════════════════════════════════
224
+
225
+ console.log('\n=== token-savings: research-milestone prompt shows meaningful savings ===');
226
+ {
227
+ const base = mkdtempSync(join(tmpdir(), 'gsd-token-savings-'));
228
+ mkdirSync(join(base, '.gsd'), { recursive: true });
229
+ writeFileSync(join(base, '.gsd', 'DECISIONS.md'), decisionsMarkdown);
230
+ writeFileSync(join(base, '.gsd', 'REQUIREMENTS.md'), requirementsMarkdown);
231
+ writeFileSync(join(base, '.gsd', 'PROJECT.md'), PROJECT_CONTENT);
232
+
233
+ openDatabase(':memory:');
234
+ migrateFromMarkdown(base);
235
+
236
+ // ── Research-milestone: M001 decisions + ALL requirements ──
237
+ const scopedDecisions = queryDecisions({ milestoneId: 'M001' });
238
+ const allRequirements = queryRequirements(); // no filter — all requirements
239
+ const dbDecisionsContent = formatDecisionsForPrompt(scopedDecisions);
240
+ const dbRequirementsContent = formatRequirementsForPrompt(allRequirements);
241
+
242
+ const fullDecisionsContent = readFileSync(join(base, '.gsd', 'DECISIONS.md'), 'utf-8');
243
+ const fullRequirementsContent = readFileSync(join(base, '.gsd', 'REQUIREMENTS.md'), 'utf-8');
244
+
245
+ // Decisions should still show savings (8 of 24 scoped to M001)
246
+ const decisionsSavings = ((fullDecisionsContent.length - dbDecisionsContent.length) / fullDecisionsContent.length) * 100;
247
+ console.log(` Decisions savings (M001): ${decisionsSavings.toFixed(1)}% (DB: ${dbDecisionsContent.length}, full: ${fullDecisionsContent.length})`);
248
+
249
+ assertTrue(decisionsSavings > 0, `decisions savings > 0% (actual: ${decisionsSavings.toFixed(1)}%)`);
250
+ assertTrue(scopedDecisions.length === 8, `M001 decisions: 8 of 24 total`);
251
+ assertTrue(allRequirements.length === REQUIREMENTS_COUNT, `all requirements returned: ${allRequirements.length}`);
252
+
253
+ // Requirements: DB-formatted vs raw markdown — formatted output may differ in size
254
+ // but decisions savings alone should make the composite meaningful
255
+ const dbTotal = dbDecisionsContent.length + dbRequirementsContent.length;
256
+ const fullTotal = fullDecisionsContent.length + fullRequirementsContent.length;
257
+ const compositeSavings = ((fullTotal - dbTotal) / fullTotal) * 100;
258
+ console.log(` Research-milestone composite savings: ${compositeSavings.toFixed(1)}% (DB: ${dbTotal}, full: ${fullTotal})`);
259
+
260
+ // With 8/24 decisions = 66% reduction in decisions, even if requirements are equal,
261
+ // the composite should show meaningful savings
262
+ assertTrue(compositeSavings > 10, `research-milestone shows >10% composite savings (actual: ${compositeSavings.toFixed(1)}%)`);
263
+ assertTrue(decisionsSavings >= 30, `decisions-only savings ≥30% for M001 scope (actual: ${decisionsSavings.toFixed(1)}%)`);
264
+
265
+ closeDatabase();
266
+ rmSync(base, { recursive: true, force: true });
267
+ }
268
+
269
+ // ═══════════════════════════════════════════════════════════════════════════
270
+ // Test: Quality — correct content, no cross-contamination
271
+ // ═══════════════════════════════════════════════════════════════════════════
272
+
273
+ console.log('\n=== token-savings: quality — correct scoping, no cross-contamination ===');
274
+ {
275
+ const base = mkdtempSync(join(tmpdir(), 'gsd-token-savings-'));
276
+ mkdirSync(join(base, '.gsd'), { recursive: true });
277
+ writeFileSync(join(base, '.gsd', 'DECISIONS.md'), decisionsMarkdown);
278
+ writeFileSync(join(base, '.gsd', 'REQUIREMENTS.md'), requirementsMarkdown);
279
+ writeFileSync(join(base, '.gsd', 'PROJECT.md'), PROJECT_CONTENT);
280
+
281
+ openDatabase(':memory:');
282
+ migrateFromMarkdown(base);
283
+
284
+ // ── M002-scoped decisions should not contain M001/M003 items ──
285
+ const m002Decisions = queryDecisions({ milestoneId: 'M002' });
286
+ assertTrue(m002Decisions.length === 8, `M002 decisions: expected 8, got ${m002Decisions.length}`);
287
+ for (const d of m002Decisions) {
288
+ assertTrue(d.when_context.includes('M002'), `M002 decision ${d.id} has M002 in when_context`);
289
+ assertNoMatch(d.when_context, /M001|M003/, `M002 decision ${d.id} should not contain M001/M003`);
290
+ }
291
+
292
+ // ── S04-scoped requirements should only include S04-related items ──
293
+ const s04Requirements = queryRequirements({ sliceId: 'S04' });
294
+ assertTrue(s04Requirements.length > 0, 'S04 requirements non-empty');
295
+ for (const r of s04Requirements) {
296
+ assertTrue(
297
+ r.primary_owner.includes('S04') || r.supporting_slices.includes('S04'),
298
+ `S04 requirement ${r.id} should be owned by or support S04`,
299
+ );
300
+ }
301
+
302
+ // ── Verify formatted output is well-formed and non-empty ──
303
+ const formattedDecisions = formatDecisionsForPrompt(m002Decisions);
304
+ assertTrue(formattedDecisions.length > 0, 'formatted M002 decisions is non-empty');
305
+ assertMatch(formattedDecisions, /\| D/, 'formatted decisions contains decision rows');
306
+ assertMatch(formattedDecisions, /\| # \|/, 'formatted decisions has table header');
307
+
308
+ const formattedReqs = formatRequirementsForPrompt(s04Requirements);
309
+ assertTrue(formattedReqs.length > 0, 'formatted S04 requirements is non-empty');
310
+ assertMatch(formattedReqs, /### R\d+/, 'formatted requirements has requirement headings');
311
+
312
+ // ── Verify all milestones have decisions and counts add up ──
313
+ const m001Count = queryDecisions({ milestoneId: 'M001' }).length;
314
+ const m002Count = queryDecisions({ milestoneId: 'M002' }).length;
315
+ const m003Count = queryDecisions({ milestoneId: 'M003' }).length;
316
+ const allCount = queryDecisions().length;
317
+
318
+ assertTrue(m001Count === 8, `M001: 8 decisions (got ${m001Count})`);
319
+ assertTrue(m002Count === 8, `M002: 8 decisions (got ${m002Count})`);
320
+ assertTrue(m003Count === 8, `M003: 8 decisions (got ${m003Count})`);
321
+ assertTrue(allCount === DECISIONS_COUNT, `all: ${DECISIONS_COUNT} decisions (got ${allCount})`);
322
+ assertTrue(m001Count + m002Count + m003Count === allCount, 'milestone decision counts sum to total');
323
+
324
+ // ── Verify all slices have requirements ──
325
+ const s01Reqs = queryRequirements({ sliceId: 'S01' });
326
+ const s02Reqs = queryRequirements({ sliceId: 'S02' });
327
+ const s03Reqs = queryRequirements({ sliceId: 'S03' });
328
+ const s04Reqs = queryRequirements({ sliceId: 'S04' });
329
+ const s05Reqs = queryRequirements({ sliceId: 'S05' });
330
+
331
+ assertTrue(s01Reqs.length > 0, 'S01 has requirements');
332
+ assertTrue(s02Reqs.length > 0, 'S02 has requirements');
333
+ assertTrue(s03Reqs.length > 0, 'S03 has requirements');
334
+ assertTrue(s04Reqs.length > 0, 'S04 has requirements');
335
+ assertTrue(s05Reqs.length > 0, 'S05 has requirements');
336
+
337
+ closeDatabase();
338
+ rmSync(base, { recursive: true, force: true });
339
+ }
340
+
341
+ // ═══════════════════════════════════════════════════════════════════════════
342
+ // Test: Fixture data realism — sufficient volume and distribution
343
+ // ═══════════════════════════════════════════════════════════════════════════
344
+
345
+ console.log('\n=== token-savings: fixture data realism ===');
346
+ {
347
+ // Verify fixture generators produce sufficient volume
348
+ assertTrue(DECISIONS_COUNT >= 20, `decisions count ≥ 20 (actual: ${DECISIONS_COUNT})`);
349
+ assertTrue(REQUIREMENTS_COUNT >= 20, `requirements count ≥ 20 (actual: ${REQUIREMENTS_COUNT})`);
350
+ assertTrue(MILESTONES.length >= 3, `milestones ≥ 3 (actual: ${MILESTONES.length})`);
351
+ assertTrue(SLICE_ASSIGNMENTS.length >= 5, `slice assignments ≥ 5 (actual: ${SLICE_ASSIGNMENTS.length})`);
352
+
353
+ // Verify markdown content is substantial
354
+ assertTrue(decisionsMarkdown.length > 1000, `decisions markdown > 1000 chars (actual: ${decisionsMarkdown.length})`);
355
+ assertTrue(requirementsMarkdown.length > 1000, `requirements markdown > 1000 chars (actual: ${requirementsMarkdown.length})`);
356
+
357
+ // Verify content structure
358
+ assertMatch(decisionsMarkdown, /\| D001 \|/, 'decisions markdown has D001');
359
+ assertMatch(decisionsMarkdown, /\| D024 \|/, 'decisions markdown has D024');
360
+ assertMatch(requirementsMarkdown, /### R001/, 'requirements markdown has R001');
361
+ assertMatch(requirementsMarkdown, /### R021/, 'requirements markdown has R021');
362
+ }
363
+
364
+ // ─── Report ────────────────────────────────────────────────────────────────
365
+
366
+ report();