@seanmars/tospec 0.19.0-beta.8 → 0.21.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 (423) hide show
  1. package/CHANGELOG.md +89 -296
  2. package/README.md +69 -82
  3. package/assets/dashboard/app.js +11 -0
  4. package/assets/dashboard/style.css +7 -0
  5. package/bin/tospec.js +1 -1
  6. package/dist/cli/index.d.ts +6 -1
  7. package/dist/cli/index.d.ts.map +1 -1
  8. package/dist/cli/index.js +120 -112
  9. package/dist/cli/index.js.map +1 -1
  10. package/dist/commands/config.d.ts +9 -17
  11. package/dist/commands/config.d.ts.map +1 -1
  12. package/dist/commands/config.js +300 -145
  13. package/dist/commands/config.js.map +1 -1
  14. package/dist/commands/dashboard.d.ts +57 -99
  15. package/dist/commands/dashboard.d.ts.map +1 -1
  16. package/dist/commands/dashboard.js +252 -330
  17. package/dist/commands/dashboard.js.map +1 -1
  18. package/dist/commands/decision.d.ts +38 -27
  19. package/dist/commands/decision.d.ts.map +1 -1
  20. package/dist/commands/decision.js +301 -131
  21. package/dist/commands/decision.js.map +1 -1
  22. package/dist/commands/metrics.d.ts +28 -51
  23. package/dist/commands/metrics.d.ts.map +1 -1
  24. package/dist/commands/metrics.js +62 -93
  25. package/dist/commands/metrics.js.map +1 -1
  26. package/dist/commands/shared-output.d.ts +12 -27
  27. package/dist/commands/shared-output.d.ts.map +1 -1
  28. package/dist/commands/shared-output.js +22 -45
  29. package/dist/commands/shared-output.js.map +1 -1
  30. package/dist/commands/show.d.ts +4 -7
  31. package/dist/commands/show.d.ts.map +1 -1
  32. package/dist/commands/show.js +23 -11
  33. package/dist/commands/show.js.map +1 -1
  34. package/dist/commands/validate.d.ts +34 -58
  35. package/dist/commands/validate.d.ts.map +1 -1
  36. package/dist/commands/validate.js +228 -142
  37. package/dist/commands/validate.js.map +1 -1
  38. package/dist/commands/workflow/index.d.ts +1 -5
  39. package/dist/commands/workflow/index.d.ts.map +1 -1
  40. package/dist/commands/workflow/index.js +1 -5
  41. package/dist/commands/workflow/index.js.map +1 -1
  42. package/dist/commands/workflow/instructions.d.ts +14 -24
  43. package/dist/commands/workflow/instructions.d.ts.map +1 -1
  44. package/dist/commands/workflow/instructions.js +230 -132
  45. package/dist/commands/workflow/instructions.js.map +1 -1
  46. package/dist/commands/workflow/new-change.d.ts +2 -5
  47. package/dist/commands/workflow/new-change.d.ts.map +1 -1
  48. package/dist/commands/workflow/new-change.js +75 -34
  49. package/dist/commands/workflow/new-change.js.map +1 -1
  50. package/dist/commands/workflow/schemas.d.ts +1 -5
  51. package/dist/commands/workflow/schemas.d.ts.map +1 -1
  52. package/dist/commands/workflow/schemas.js +6 -17
  53. package/dist/commands/workflow/schemas.js.map +1 -1
  54. package/dist/commands/workflow/shared.d.ts +37 -42
  55. package/dist/commands/workflow/shared.d.ts.map +1 -1
  56. package/dist/commands/workflow/shared.js +23 -54
  57. package/dist/commands/workflow/shared.js.map +1 -1
  58. package/dist/commands/workflow/status.d.ts +7 -17
  59. package/dist/commands/workflow/status.d.ts.map +1 -1
  60. package/dist/commands/workflow/status.js +57 -72
  61. package/dist/commands/workflow/status.js.map +1 -1
  62. package/dist/commands/workflow/templates.d.ts +8 -8
  63. package/dist/commands/workflow/templates.d.ts.map +1 -1
  64. package/dist/commands/workflow/templates.js +32 -46
  65. package/dist/commands/workflow/templates.js.map +1 -1
  66. package/dist/core/archive.d.ts +33 -31
  67. package/dist/core/archive.d.ts.map +1 -1
  68. package/dist/core/archive.js +322 -284
  69. package/dist/core/archive.js.map +1 -1
  70. package/dist/core/artifact-graph/graph.d.ts +25 -42
  71. package/dist/core/artifact-graph/graph.d.ts.map +1 -1
  72. package/dist/core/artifact-graph/graph.js +45 -63
  73. package/dist/core/artifact-graph/graph.js.map +1 -1
  74. package/dist/core/artifact-graph/index.d.ts +1 -1
  75. package/dist/core/artifact-graph/index.d.ts.map +1 -1
  76. package/dist/core/artifact-graph/index.js +1 -1
  77. package/dist/core/artifact-graph/index.js.map +1 -1
  78. package/dist/core/artifact-graph/instruction-loader.d.ts +54 -120
  79. package/dist/core/artifact-graph/instruction-loader.d.ts.map +1 -1
  80. package/dist/core/artifact-graph/instruction-loader.js +129 -111
  81. package/dist/core/artifact-graph/instruction-loader.js.map +1 -1
  82. package/dist/core/artifact-graph/outputs.d.ts +9 -23
  83. package/dist/core/artifact-graph/outputs.d.ts.map +1 -1
  84. package/dist/core/artifact-graph/outputs.js +45 -38
  85. package/dist/core/artifact-graph/outputs.js.map +1 -1
  86. package/dist/core/artifact-graph/resolver.d.ts +36 -81
  87. package/dist/core/artifact-graph/resolver.d.ts.map +1 -1
  88. package/dist/core/artifact-graph/resolver.js +60 -101
  89. package/dist/core/artifact-graph/resolver.js.map +1 -1
  90. package/dist/core/artifact-graph/schema.d.ts +0 -6
  91. package/dist/core/artifact-graph/schema.d.ts.map +1 -1
  92. package/dist/core/artifact-graph/schema.js +7 -32
  93. package/dist/core/artifact-graph/schema.js.map +1 -1
  94. package/dist/core/artifact-graph/state.d.ts +1 -8
  95. package/dist/core/artifact-graph/state.d.ts.map +1 -1
  96. package/dist/core/artifact-graph/state.js +2 -17
  97. package/dist/core/artifact-graph/state.js.map +1 -1
  98. package/dist/core/artifact-graph/stub-detection.d.ts +6 -14
  99. package/dist/core/artifact-graph/stub-detection.d.ts.map +1 -1
  100. package/dist/core/artifact-graph/stub-detection.js +13 -16
  101. package/dist/core/artifact-graph/stub-detection.js.map +1 -1
  102. package/dist/core/artifact-graph/types.d.ts +4 -0
  103. package/dist/core/artifact-graph/types.d.ts.map +1 -1
  104. package/dist/core/artifact-graph/types.js +46 -11
  105. package/dist/core/artifact-graph/types.js.map +1 -1
  106. package/dist/core/available-tools.d.ts +3 -12
  107. package/dist/core/available-tools.d.ts.map +1 -1
  108. package/dist/core/available-tools.js +4 -13
  109. package/dist/core/available-tools.js.map +1 -1
  110. package/dist/core/change-metadata/schema.d.ts +1 -1
  111. package/dist/core/change-metadata/schema.d.ts.map +1 -1
  112. package/dist/core/change-metadata/schema.js +10 -7
  113. package/dist/core/change-metadata/schema.js.map +1 -1
  114. package/dist/core/change-presenter.d.ts +16 -27
  115. package/dist/core/change-presenter.d.ts.map +1 -1
  116. package/dist/core/change-presenter.js +53 -53
  117. package/dist/core/change-presenter.js.map +1 -1
  118. package/dist/core/change-status-policy.d.ts +4 -8
  119. package/dist/core/change-status-policy.d.ts.map +1 -1
  120. package/dist/core/change-status-policy.js +9 -17
  121. package/dist/core/change-status-policy.js.map +1 -1
  122. package/dist/core/codex-metrics.d.ts +25 -45
  123. package/dist/core/codex-metrics.d.ts.map +1 -1
  124. package/dist/core/codex-metrics.js +44 -88
  125. package/dist/core/codex-metrics.js.map +1 -1
  126. package/dist/core/codex-residue.d.ts +14 -15
  127. package/dist/core/codex-residue.d.ts.map +1 -1
  128. package/dist/core/codex-residue.js +18 -22
  129. package/dist/core/codex-residue.js.map +1 -1
  130. package/dist/core/command-generation/adapters/claude.d.ts +2 -9
  131. package/dist/core/command-generation/adapters/claude.d.ts.map +1 -1
  132. package/dist/core/command-generation/adapters/claude.js +2 -12
  133. package/dist/core/command-generation/adapters/claude.js.map +1 -1
  134. package/dist/core/command-generation/adapters/index.d.ts +1 -9
  135. package/dist/core/command-generation/adapters/index.d.ts.map +1 -1
  136. package/dist/core/command-generation/adapters/index.js +1 -9
  137. package/dist/core/command-generation/adapters/index.js.map +1 -1
  138. package/dist/core/command-generation/generator.d.ts +0 -17
  139. package/dist/core/command-generation/generator.d.ts.map +1 -1
  140. package/dist/core/command-generation/generator.js +0 -17
  141. package/dist/core/command-generation/generator.js.map +1 -1
  142. package/dist/core/command-generation/index.d.ts +2 -5
  143. package/dist/core/command-generation/index.d.ts.map +1 -1
  144. package/dist/core/command-generation/index.js +0 -9
  145. package/dist/core/command-generation/index.js.map +1 -1
  146. package/dist/core/command-generation/types.d.ts +10 -36
  147. package/dist/core/command-generation/types.d.ts.map +1 -1
  148. package/dist/core/command-generation/types.js +0 -6
  149. package/dist/core/command-generation/types.js.map +1 -1
  150. package/dist/core/command-generation/yaml.d.ts +3 -18
  151. package/dist/core/command-generation/yaml.d.ts.map +1 -1
  152. package/dist/core/command-generation/yaml.js +5 -23
  153. package/dist/core/command-generation/yaml.js.map +1 -1
  154. package/dist/core/config-prompts.d.ts +2 -4
  155. package/dist/core/config-prompts.d.ts.map +1 -1
  156. package/dist/core/config-prompts.js +2 -7
  157. package/dist/core/config-prompts.js.map +1 -1
  158. package/dist/core/config-schema.d.ts +7 -41
  159. package/dist/core/config-schema.d.ts.map +1 -1
  160. package/dist/core/config-schema.js +35 -74
  161. package/dist/core/config-schema.js.map +1 -1
  162. package/dist/core/config.d.ts +25 -49
  163. package/dist/core/config.d.ts.map +1 -1
  164. package/dist/core/config.js +22 -45
  165. package/dist/core/config.js.map +1 -1
  166. package/dist/core/dashboard-activity.d.ts +7 -9
  167. package/dist/core/dashboard-activity.d.ts.map +1 -1
  168. package/dist/core/dashboard-activity.js +34 -25
  169. package/dist/core/dashboard-activity.js.map +1 -1
  170. package/dist/core/dashboard-data.d.ts +35 -22
  171. package/dist/core/dashboard-data.d.ts.map +1 -1
  172. package/dist/core/dashboard-data.js +57 -72
  173. package/dist/core/dashboard-data.js.map +1 -1
  174. package/dist/core/global-config.d.ts +24 -53
  175. package/dist/core/global-config.d.ts.map +1 -1
  176. package/dist/core/global-config.js +38 -67
  177. package/dist/core/global-config.js.map +1 -1
  178. package/dist/core/init.d.ts +12 -28
  179. package/dist/core/init.d.ts.map +1 -1
  180. package/dist/core/init.js +93 -169
  181. package/dist/core/init.js.map +1 -1
  182. package/dist/core/list.d.ts.map +1 -1
  183. package/dist/core/list.js +95 -41
  184. package/dist/core/list.js.map +1 -1
  185. package/dist/core/local-server.d.ts +41 -83
  186. package/dist/core/local-server.d.ts.map +1 -1
  187. package/dist/core/local-server.js +53 -98
  188. package/dist/core/local-server.js.map +1 -1
  189. package/dist/core/markdown-render.d.ts +15 -23
  190. package/dist/core/markdown-render.d.ts.map +1 -1
  191. package/dist/core/markdown-render.js +25 -34
  192. package/dist/core/markdown-render.js.map +1 -1
  193. package/dist/core/migrate.d.ts +19 -16
  194. package/dist/core/migrate.d.ts.map +1 -1
  195. package/dist/core/migrate.js +162 -136
  196. package/dist/core/migrate.js.map +1 -1
  197. package/dist/core/parsers/change-parser.d.ts +7 -10
  198. package/dist/core/parsers/change-parser.d.ts.map +1 -1
  199. package/dist/core/parsers/change-parser.js +48 -56
  200. package/dist/core/parsers/change-parser.js.map +1 -1
  201. package/dist/core/parsers/markdown-parser.d.ts +8 -9
  202. package/dist/core/parsers/markdown-parser.d.ts.map +1 -1
  203. package/dist/core/parsers/markdown-parser.js +23 -30
  204. package/dist/core/parsers/markdown-parser.js.map +1 -1
  205. package/dist/core/parsers/requirement-blocks.d.ts +43 -15
  206. package/dist/core/parsers/requirement-blocks.d.ts.map +1 -1
  207. package/dist/core/parsers/requirement-blocks.js +142 -70
  208. package/dist/core/parsers/requirement-blocks.js.map +1 -1
  209. package/dist/core/parsers/requirement-text.d.ts +73 -79
  210. package/dist/core/parsers/requirement-text.d.ts.map +1 -1
  211. package/dist/core/parsers/requirement-text.js +137 -79
  212. package/dist/core/parsers/requirement-text.js.map +1 -1
  213. package/dist/core/parsers/spec-structure.d.ts.map +1 -1
  214. package/dist/core/parsers/spec-structure.js +10 -6
  215. package/dist/core/parsers/spec-structure.js.map +1 -1
  216. package/dist/core/profiles.d.ts +3 -10
  217. package/dist/core/profiles.d.ts.map +1 -1
  218. package/dist/core/profiles.js +5 -12
  219. package/dist/core/profiles.js.map +1 -1
  220. package/dist/core/project-config.d.ts +43 -44
  221. package/dist/core/project-config.d.ts.map +1 -1
  222. package/dist/core/project-config.js +107 -82
  223. package/dist/core/project-config.js.map +1 -1
  224. package/dist/core/project-layout.d.ts +9 -17
  225. package/dist/core/project-layout.d.ts.map +1 -1
  226. package/dist/core/project-layout.js +16 -26
  227. package/dist/core/project-layout.js.map +1 -1
  228. package/dist/core/root-selection.d.ts +8 -14
  229. package/dist/core/root-selection.d.ts.map +1 -1
  230. package/dist/core/root-selection.js +3 -6
  231. package/dist/core/root-selection.js.map +1 -1
  232. package/dist/core/rules.d.ts.map +1 -1
  233. package/dist/core/rules.js +2 -3
  234. package/dist/core/rules.js.map +1 -1
  235. package/dist/core/schema-names.d.ts +16 -0
  236. package/dist/core/schema-names.d.ts.map +1 -0
  237. package/dist/core/schema-names.js +16 -0
  238. package/dist/core/schema-names.js.map +1 -0
  239. package/dist/core/schemas/base.schema.d.ts.map +1 -1
  240. package/dist/core/schemas/base.schema.js +6 -12
  241. package/dist/core/schemas/base.schema.js.map +1 -1
  242. package/dist/core/schemas/change.schema.d.ts +8 -0
  243. package/dist/core/schemas/change.schema.d.ts.map +1 -1
  244. package/dist/core/schemas/change.schema.js +41 -10
  245. package/dist/core/schemas/change.schema.js.map +1 -1
  246. package/dist/core/shared/index.d.ts +2 -7
  247. package/dist/core/shared/index.d.ts.map +1 -1
  248. package/dist/core/shared/index.js +2 -7
  249. package/dist/core/shared/index.js.map +1 -1
  250. package/dist/core/shared/rules-generation.d.ts +5 -15
  251. package/dist/core/shared/rules-generation.d.ts.map +1 -1
  252. package/dist/core/shared/rules-generation.js +33 -37
  253. package/dist/core/shared/rules-generation.js.map +1 -1
  254. package/dist/core/shared/skill-generation.d.ts +28 -43
  255. package/dist/core/shared/skill-generation.d.ts.map +1 -1
  256. package/dist/core/shared/skill-generation.js +82 -51
  257. package/dist/core/shared/skill-generation.js.map +1 -1
  258. package/dist/core/shared/tool-detection.d.ts +35 -76
  259. package/dist/core/shared/tool-detection.d.ts.map +1 -1
  260. package/dist/core/shared/tool-detection.js +73 -93
  261. package/dist/core/shared/tool-detection.js.map +1 -1
  262. package/dist/core/skill-metrics.d.ts +36 -63
  263. package/dist/core/skill-metrics.d.ts.map +1 -1
  264. package/dist/core/skill-metrics.js +34 -73
  265. package/dist/core/skill-metrics.js.map +1 -1
  266. package/dist/core/spec-presenter.d.ts.map +1 -1
  267. package/dist/core/spec-presenter.js +5 -10
  268. package/dist/core/spec-presenter.js.map +1 -1
  269. package/dist/core/specs-apply.d.ts +16 -31
  270. package/dist/core/specs-apply.d.ts.map +1 -1
  271. package/dist/core/specs-apply.js +146 -195
  272. package/dist/core/specs-apply.js.map +1 -1
  273. package/dist/core/templates/fragments/interview.d.ts +2 -6
  274. package/dist/core/templates/fragments/interview.d.ts.map +1 -1
  275. package/dist/core/templates/fragments/interview.js +2 -6
  276. package/dist/core/templates/fragments/interview.js.map +1 -1
  277. package/dist/core/templates/fragments/next-step.d.ts +4 -8
  278. package/dist/core/templates/fragments/next-step.d.ts.map +1 -1
  279. package/dist/core/templates/fragments/next-step.js +4 -8
  280. package/dist/core/templates/fragments/next-step.js.map +1 -1
  281. package/dist/core/templates/fragments/validate.d.ts +13 -0
  282. package/dist/core/templates/fragments/validate.d.ts.map +1 -0
  283. package/dist/core/templates/fragments/validate.js +13 -0
  284. package/dist/core/templates/fragments/validate.js.map +1 -0
  285. package/dist/core/templates/fragments/verify.d.ts +9 -12
  286. package/dist/core/templates/fragments/verify.d.ts.map +1 -1
  287. package/dist/core/templates/fragments/verify.js +9 -12
  288. package/dist/core/templates/fragments/verify.js.map +1 -1
  289. package/dist/core/templates/index.d.ts +0 -6
  290. package/dist/core/templates/index.d.ts.map +1 -1
  291. package/dist/core/templates/index.js +0 -7
  292. package/dist/core/templates/index.js.map +1 -1
  293. package/dist/core/templates/skill-templates.d.ts +1 -5
  294. package/dist/core/templates/skill-templates.d.ts.map +1 -1
  295. package/dist/core/templates/skill-templates.js +0 -5
  296. package/dist/core/templates/skill-templates.js.map +1 -1
  297. package/dist/core/templates/types.d.ts +3 -7
  298. package/dist/core/templates/types.d.ts.map +1 -1
  299. package/dist/core/templates/types.js +0 -3
  300. package/dist/core/templates/types.js.map +1 -1
  301. package/dist/core/templates/workflows/apply.d.ts +3 -9
  302. package/dist/core/templates/workflows/apply.d.ts.map +1 -1
  303. package/dist/core/templates/workflows/apply.js +11 -13
  304. package/dist/core/templates/workflows/apply.js.map +1 -1
  305. package/dist/core/templates/workflows/archive.d.ts +0 -6
  306. package/dist/core/templates/workflows/archive.d.ts.map +1 -1
  307. package/dist/core/templates/workflows/archive.js +16 -5
  308. package/dist/core/templates/workflows/archive.js.map +1 -1
  309. package/dist/core/templates/workflows/decision.js +3 -3
  310. package/dist/core/templates/workflows/decision.js.map +1 -1
  311. package/dist/core/templates/workflows/explore.js +1 -1
  312. package/dist/core/templates/workflows/grill.d.ts.map +1 -1
  313. package/dist/core/templates/workflows/grill.js +0 -2
  314. package/dist/core/templates/workflows/grill.js.map +1 -1
  315. package/dist/core/templates/workflows/issue.d.ts +0 -6
  316. package/dist/core/templates/workflows/issue.d.ts.map +1 -1
  317. package/dist/core/templates/workflows/issue.js +3 -2
  318. package/dist/core/templates/workflows/issue.js.map +1 -1
  319. package/dist/core/templates/workflows/propose.d.ts +0 -6
  320. package/dist/core/templates/workflows/propose.d.ts.map +1 -1
  321. package/dist/core/templates/workflows/propose.js +2 -2
  322. package/dist/core/templates/workflows/propose.js.map +1 -1
  323. package/dist/core/templates/workflows/sync.d.ts +2 -8
  324. package/dist/core/templates/workflows/sync.d.ts.map +1 -1
  325. package/dist/core/templates/workflows/sync.js +4 -3
  326. package/dist/core/templates/workflows/sync.js.map +1 -1
  327. package/dist/core/templates/workflows/update.d.ts +0 -6
  328. package/dist/core/templates/workflows/update.d.ts.map +1 -1
  329. package/dist/core/templates/workflows/update.js +9 -2
  330. package/dist/core/templates/workflows/update.js.map +1 -1
  331. package/dist/core/update.d.ts +10 -36
  332. package/dist/core/update.d.ts.map +1 -1
  333. package/dist/core/update.js +59 -126
  334. package/dist/core/update.js.map +1 -1
  335. package/dist/core/user-state-migration.d.ts +13 -15
  336. package/dist/core/user-state-migration.d.ts.map +1 -1
  337. package/dist/core/user-state-migration.js +30 -23
  338. package/dist/core/user-state-migration.js.map +1 -1
  339. package/dist/core/validation/constants.d.ts +4 -10
  340. package/dist/core/validation/constants.d.ts.map +1 -1
  341. package/dist/core/validation/constants.js +25 -25
  342. package/dist/core/validation/constants.js.map +1 -1
  343. package/dist/core/validation/prose-length.d.ts +15 -0
  344. package/dist/core/validation/prose-length.d.ts.map +1 -0
  345. package/dist/core/validation/prose-length.js +29 -0
  346. package/dist/core/validation/prose-length.js.map +1 -0
  347. package/dist/core/validation/purpose-placeholder.d.ts +9 -16
  348. package/dist/core/validation/purpose-placeholder.d.ts.map +1 -1
  349. package/dist/core/validation/purpose-placeholder.js +30 -44
  350. package/dist/core/validation/purpose-placeholder.js.map +1 -1
  351. package/dist/core/validation/section-validator.d.ts +4 -4
  352. package/dist/core/validation/section-validator.d.ts.map +1 -1
  353. package/dist/core/validation/section-validator.js +30 -12
  354. package/dist/core/validation/section-validator.js.map +1 -1
  355. package/dist/core/validation/task-numbering.d.ts +6 -3
  356. package/dist/core/validation/task-numbering.d.ts.map +1 -1
  357. package/dist/core/validation/task-numbering.js +23 -11
  358. package/dist/core/validation/task-numbering.js.map +1 -1
  359. package/dist/core/validation/types.d.ts +18 -0
  360. package/dist/core/validation/types.d.ts.map +1 -1
  361. package/dist/core/validation/types.js +12 -1
  362. package/dist/core/validation/types.js.map +1 -1
  363. package/dist/core/validation/validator.d.ts +42 -68
  364. package/dist/core/validation/validator.d.ts.map +1 -1
  365. package/dist/core/validation/validator.js +474 -285
  366. package/dist/core/validation/validator.js.map +1 -1
  367. package/dist/prompts/searchable-multi-select.d.ts +3 -8
  368. package/dist/prompts/searchable-multi-select.d.ts.map +1 -1
  369. package/dist/prompts/searchable-multi-select.js +16 -39
  370. package/dist/prompts/searchable-multi-select.js.map +1 -1
  371. package/dist/utils/change-metadata.d.ts +11 -50
  372. package/dist/utils/change-metadata.d.ts.map +1 -1
  373. package/dist/utils/change-metadata.js +48 -67
  374. package/dist/utils/change-metadata.js.map +1 -1
  375. package/dist/utils/change-utils.d.ts +24 -76
  376. package/dist/utils/change-utils.d.ts.map +1 -1
  377. package/dist/utils/change-utils.js +95 -142
  378. package/dist/utils/change-utils.js.map +1 -1
  379. package/dist/utils/file-lock.d.ts +39 -0
  380. package/dist/utils/file-lock.d.ts.map +1 -0
  381. package/dist/utils/file-lock.js +149 -0
  382. package/dist/utils/file-lock.js.map +1 -0
  383. package/dist/utils/file-system.d.ts +12 -32
  384. package/dist/utils/file-system.d.ts.map +1 -1
  385. package/dist/utils/file-system.js +41 -45
  386. package/dist/utils/file-system.js.map +1 -1
  387. package/dist/utils/frontmatter.d.ts +7 -11
  388. package/dist/utils/frontmatter.d.ts.map +1 -1
  389. package/dist/utils/frontmatter.js +11 -11
  390. package/dist/utils/frontmatter.js.map +1 -1
  391. package/dist/utils/interactive.d.ts +4 -9
  392. package/dist/utils/interactive.d.ts.map +1 -1
  393. package/dist/utils/interactive.js +2 -4
  394. package/dist/utils/interactive.js.map +1 -1
  395. package/dist/utils/item-discovery.d.ts +10 -20
  396. package/dist/utils/item-discovery.d.ts.map +1 -1
  397. package/dist/utils/item-discovery.js +31 -55
  398. package/dist/utils/item-discovery.js.map +1 -1
  399. package/dist/utils/link.d.ts +9 -18
  400. package/dist/utils/link.d.ts.map +1 -1
  401. package/dist/utils/link.js +9 -18
  402. package/dist/utils/link.js.map +1 -1
  403. package/dist/utils/requirement-diff.d.ts +13 -23
  404. package/dist/utils/requirement-diff.d.ts.map +1 -1
  405. package/dist/utils/requirement-diff.js +13 -23
  406. package/dist/utils/requirement-diff.js.map +1 -1
  407. package/dist/utils/spec-files.d.ts +7 -20
  408. package/dist/utils/spec-files.d.ts.map +1 -1
  409. package/dist/utils/spec-files.js +26 -51
  410. package/dist/utils/spec-files.js.map +1 -1
  411. package/dist/utils/task-progress.d.ts +11 -9
  412. package/dist/utils/task-progress.d.ts.map +1 -1
  413. package/dist/utils/task-progress.js +53 -32
  414. package/dist/utils/task-progress.js.map +1 -1
  415. package/dist/utils/timestamp.d.ts +5 -8
  416. package/dist/utils/timestamp.d.ts.map +1 -1
  417. package/dist/utils/timestamp.js +5 -8
  418. package/dist/utils/timestamp.js.map +1 -1
  419. package/package.json +2 -3
  420. package/schemas/issue/schema.yaml +8 -1
  421. package/schemas/issue/templates/spec.md +20 -3
  422. package/schemas/sdd/schema.yaml +20 -1
  423. package/schemas/sdd/templates/spec.md +20 -3
@@ -1,16 +1,11 @@
1
1
  /**
2
- * `tospec dashboard` — local web dashboard.
3
- *
4
- * A `node:http` server (no web framework) that serves the static frontend from
5
- * `assets/dashboard/` and exposes JSON APIs backed by the pure aggregation
6
- * functions in `core/dashboard-data.ts`. Reads are GET-only; the sole write is
7
- * `POST /api/task`, which ticks a task checkbox back into its markdown file.
8
- * Both file-addressing endpoints (`/api/render`, `/api/task`) are trust
9
- * boundaries: the requested path must resolve under the tospec root (design
10
- * decision D5).
2
+ * `tospec dashboard` — a `node:http` server over the aggregators in
3
+ * `core/dashboard-data.ts`. Reads are GET-only; the sole write is
4
+ * `POST /api/task`. `/api/render` and `/api/task` are trust boundaries: the
5
+ * path must resolve under the tospec root (D5).
11
6
  */
12
7
  import * as http from 'node:http';
13
- import { promises as fs, readFileSync, unlinkSync, watch } from 'node:fs';
8
+ import { promises as fs, readdirSync, readFileSync, rmSync, unlinkSync, watch, } from 'node:fs';
14
9
  import * as path from 'node:path';
15
10
  import * as os from 'node:os';
16
11
  import * as crypto from 'node:crypto';
@@ -20,50 +15,30 @@ import { renderMarkdownSafe } from '../core/markdown-render.js';
20
15
  import { collectOverview, collectChangeDetail, collectArchive, collectDecisions, readSyncConclusion, } from '../core/dashboard-data.js';
21
16
  import { collectActivity } from '../core/dashboard-activity.js';
22
17
  import { getGlobalConfigDir } from '../core/global-config.js';
23
- import { acceptsAnyHostHeader, assertBindableHost, isAllowedHostHeader, listenFrom, openBrowser, packageAssetsDir, sendJson, serveAsset, } from '../core/local-server.js';
18
+ import { acceptsAnyHostHeader, assertBindableHost, connectableUrl, isAllowedHostHeader, listenFrom, openBrowser, packageAssetsDir, sendJson, serveAsset, } from '../core/local-server.js';
24
19
  import { resolveTaskFiles, setTaskDone } from '../utils/task-progress.js';
25
20
  const DEFAULT_PORT = 5620;
26
21
  const DEFAULT_HOST = '127.0.0.1';
27
- // The detached child's readiness signal: `runDashboard` writes this to its own
28
- // stderr once it has actually bound, and only when TOSPEC_DASHBOARD_PIDFILE
29
- // marks it as a `--detach` child — a normal foreground run never emits it.
22
+ // Readiness signal the detached child writes to stderr once bound; emitted only
23
+ // when TOSPEC_DASHBOARD_PIDFILE marks it as a `--detach` child.
30
24
  const LISTENING_PREFIX = 'LISTENING ';
31
- /**
32
- * Drops one leading `Error: ` from text a child process already formatted for
33
- * display, so re-wrapping it does not stack a second prefix. Only the first
34
- * line is considered — a message whose body legitimately mentions "Error:"
35
- * further in keeps it.
36
- */
25
+ /** Drops one leading `Error: ` the child added, so wrapping doesn't stack prefixes. */
37
26
  function stripErrorPrefix(text) {
38
27
  return text.replace(/^Error:\s*/, '');
39
28
  }
40
- // How long `--detach` waits for that signal. The child emits it immediately
41
- // after binding, before the registry write and any browser launch, so anything
42
- // slower than a cold Node start plus a bind means it is stuck, not busy.
29
+ // The child signals right after binding, so slower than a cold start plus a
30
+ // bind means it is stuck, not busy.
43
31
  const READINESS_TIMEOUT_MS = 10_000;
44
32
  /** This command's shipped frontend; `serveAsset` is confined to it. */
45
33
  const ASSETS_DIR = packageAssetsDir('dashboard');
46
- // -----------------------------------------------------------------------------
47
- // API handlers
48
- // -----------------------------------------------------------------------------
49
34
  /**
50
35
  * Decodes one URL path segment and confines it to a single directory name, or
51
36
  * answers the request and returns null.
52
37
  *
53
38
  * WHATWG URL normalizes a literal `..` out of the pathname but does not
54
- * percent-decode, so `%2e%2e%2f` arrives encoded and `decodeURIComponent` turns
55
- * it back into `../../` — after every URL-level normalization has run and
56
- * immediately before the caller joins it onto a directory. Without the check, a
57
- * 200 vs 404 split was an existence oracle over arbitrary directories, and a hit
58
- * returned that directory read as a change.
59
- *
60
- * Malformed escapes (`%zz`) throw `URIError`; that is the client's mistake, so
61
- * it answers 400 rather than unwinding to the router's outer catch as a 500.
62
- *
63
- * Extracted because `handleChangeDetail` and `handleSpec` each carried their own
64
- * copy and a comment claiming they were "deliberately identical" — by which
65
- * point only one of them guarded the decode. One implementation is what keeps
66
- * that claim true; `label` is the only thing that legitimately differs.
39
+ * percent-decode, so `%2e%2e%2f` survives until `decodeURIComponent` turns it
40
+ * back into `../../` — a traversal. Malformed escapes (`%zz`) are the client's
41
+ * mistake, so 400 rather than a 500 from the router.
67
42
  */
68
43
  function decodePathSegment(raw, label, res) {
69
44
  let decoded;
@@ -108,32 +83,32 @@ async function handleSpec(projectRoot, rawId, res) {
108
83
  sendJson(res, 200, { id, requirements: spec.requirements });
109
84
  }
110
85
  /**
111
- * Git activity + health signals (ADR 001). `change` narrows the tospec series
112
- * to one change's directory; the name feeds a `git log` path filter, so reject
113
- * anything that could escape `tospec/changes/` before it reaches git.
114
- * `days` widens the window: a day count (capped at 3650 to bound the
115
- * zero-filled series) or 'all' for the full history.
86
+ * Git activity + health signals (ADR 001). `change` feeds a `git log` path
87
+ * filter, so reject anything that could escape `tospec/changes/` before it
88
+ * reaches git. `days` is capped at 3650 to bound the zero-filled series.
116
89
  */
117
90
  async function handleActivity(projectRoot, changeParam, daysParam, res) {
118
91
  const change = changeParam ?? undefined;
119
92
  if (change && (change.includes('/') || change.includes('\\') || change.includes('..'))) {
120
93
  return sendJson(res, 403, { error: 'forbidden' });
121
94
  }
122
- const parsed = Number(daysParam);
123
- const window = daysParam === 'all'
124
- ? 'all'
125
- : Number.isInteger(parsed) && parsed >= 1
126
- ? Math.min(parsed, 3650)
127
- : undefined; // absent or garbage → collectActivity's default
95
+ // Absent or garbage stays undefined → collectActivity's default.
96
+ let window;
97
+ if (daysParam === 'all') {
98
+ window = 'all';
99
+ }
100
+ else {
101
+ const parsed = Number(daysParam);
102
+ if (Number.isInteger(parsed) && parsed >= 1)
103
+ window = Math.min(parsed, 3650);
104
+ }
128
105
  sendJson(res, 200, await collectActivity(projectRoot, change, window));
129
106
  }
130
107
  /**
131
108
  * Trust boundary (D5): resolves a client-supplied path to an absolute `.md` file
132
- * under `<root>/<prefix>`, or null when it escapes. Both callers pass paths that
133
- * originate from our own API payloads, so anything else is an attack or a bug.
109
+ * under `<root>/<prefix>`, or null when it escapes.
134
110
  */
135
111
  function resolveTospecFile(projectRoot, file, prefix) {
136
- // Windows-safe: normalise backslashes before resolving.
137
112
  const resolved = path.resolve(projectRoot, file.replace(/\\/g, '/'));
138
113
  if (!resolved.startsWith(path.resolve(projectRoot) + path.sep))
139
114
  return null;
@@ -142,16 +117,11 @@ function resolveTospecFile(projectRoot, file, prefix) {
142
117
  return null;
143
118
  return resolved;
144
119
  }
145
- /** The directory holding archived changes, one level below `tospec/changes/`. */
146
120
  const ARCHIVE_SEGMENT = 'archive';
147
121
  /**
148
- * True for a path inside `tospec/changes/archive/`. Archived changes are
149
- * immutable by definition, so this is a stronger statement than the sync gate
150
- * and does not depend on a `sync-report.md` being present or readable.
151
- *
152
- * Deliberately not folded into `resolveTospecFile`: `/api/render` shares that
153
- * helper and legitimately renders archived documents read-only. The rejection
154
- * belongs on the write path.
122
+ * True for a path inside `tospec/changes/archive/`. Not folded into
123
+ * `resolveTospecFile`: `/api/render` shares that helper and legitimately renders
124
+ * archived documents, so the rejection belongs on the write path.
155
125
  */
156
126
  function isArchivedChangePath(projectRoot, resolvedAbsPath) {
157
127
  const posixRel = path.relative(projectRoot, resolvedAbsPath).replace(/\\/g, '/');
@@ -159,13 +129,9 @@ function isArchivedChangePath(projectRoot, resolvedAbsPath) {
159
129
  return parts[0] === 'tospec' && parts[1] === 'changes' && parts[2] === ARCHIVE_SEGMENT;
160
130
  }
161
131
  /**
162
- * Maps a resolved task-file path back to its change name, expecting the shape
163
- * `tospec/changes/<name>/...`. Returns null when the path doesn't match, so
164
- * callers treat "no change" as "not archiving".
165
- *
166
- * An archived change is one level deeper (`changes/archive/<stamp>-<name>/`), so
167
- * `parts[2]` there is the literal string `archive` — a directory, not a change.
168
- * Returning null keeps any future caller from re-deriving that as a change name.
132
+ * Maps a resolved task-file path (`tospec/changes/<name>/...`) back to its
133
+ * change name, or null when it doesn't match. An archived change is one level
134
+ * deeper, so `parts[2]` is the literal `archive`, never a change name.
169
135
  */
170
136
  function changeNameFromTaskFile(projectRoot, resolvedAbsPath) {
171
137
  const posixRel = path.relative(projectRoot, resolvedAbsPath).replace(/\\/g, '/');
@@ -177,12 +143,9 @@ function changeNameFromTaskFile(projectRoot, resolvedAbsPath) {
177
143
  return parts[2] || null;
178
144
  }
179
145
  /**
180
- * Renders a tospec markdown file to HTML.
181
- *
182
- * The markup goes through `renderMarkdownSafe`, not `marked.parse`: the client
183
- * assigns this with `innerHTML`, so anything the renderer passes through runs
184
- * in the dashboard's origin — inside the CSRF boundary that `handleTaskUpdate`
185
- * relies on. See `core/markdown-render.ts`.
146
+ * Renders through `renderMarkdownSafe`, not `marked.parse`: the client assigns
147
+ * this with `innerHTML`, so anything the renderer passes through runs inside
148
+ * the CSRF boundary `handleTaskUpdate` relies on.
186
149
  */
187
150
  async function handleRender(projectRoot, fileParam, res) {
188
151
  if (!fileParam) {
@@ -204,12 +167,19 @@ async function handleRender(projectRoot, fileParam, res) {
204
167
  }
205
168
  /** Reads a JSON request body, capped so a stray client can't exhaust memory. */
206
169
  async function readJsonBody(req, limit = 8192) {
207
- let raw = '';
170
+ // Bytes first, decoded once: `string += chunk` decodes each chunk alone, so a
171
+ // multi-byte character split across two chunks (a CJK filename) came out as
172
+ // U+FFFD and the request addressed a file that does not exist.
173
+ const chunks = [];
174
+ let size = 0;
208
175
  for await (const chunk of req) {
209
- raw += chunk;
210
- if (raw.length > limit)
176
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
177
+ size += buffer.length;
178
+ if (size > limit)
211
179
  throw new Error('body too large');
180
+ chunks.push(buffer);
212
181
  }
182
+ const raw = Buffer.concat(chunks).toString('utf-8');
213
183
  try {
214
184
  return JSON.parse(raw);
215
185
  }
@@ -218,14 +188,12 @@ async function readJsonBody(req, limit = 8192) {
218
188
  }
219
189
  }
220
190
  /**
221
- * Ticks a task checkbox in its markdown file: `{ file, line, done }`, where
222
- * `file` is a root-relative path from `/api/overview` and `line` its 1-based
223
- * line. Writes are confined to `<root>/tospec/changes/`, so the dashboard can
224
- * never edit specs or anything outside the tospec tree.
191
+ * Ticks a task checkbox: `{ file, line, done }`, `file` root-relative and `line`
192
+ * 1-based. Writes are confined to `<root>/tospec/changes/`, so the dashboard can
193
+ * never edit specs.
225
194
  *
226
- * Requiring `content-type: application/json` is load-bearing, not cosmetic: it
227
- * pushes any cross-origin POST into a CORS preflight that this server does not
228
- * answer, so a random web page cannot drive the loopback dashboard.
195
+ * Requiring `content-type: application/json` is load-bearing: it pushes any
196
+ * cross-origin POST into a CORS preflight this server does not answer.
229
197
  */
230
198
  async function handleTaskUpdate(projectRoot, req, res) {
231
199
  if (!/^application\/json\b/.test(req.headers['content-type'] ?? '')) {
@@ -249,42 +217,62 @@ async function handleTaskUpdate(projectRoot, req, res) {
249
217
  if (!resolved) {
250
218
  return sendJson(res, 403, { error: 'forbidden' });
251
219
  }
252
- // Archived changes are frozen outright, before the sync gate: that gate asks
253
- // whether a change is *becoming* immutable, and an archived one already is.
220
+ // Before the sync gate: that gate asks whether a change is *becoming*
221
+ // immutable; an archived one already is.
254
222
  if (isArchivedChangePath(projectRoot, resolved)) {
255
223
  return sendJson(res, 403, { error: 'change is archived; tasks are immutable' });
256
224
  }
257
- // Freeze task edits once a change is archiving (sync certified). The
258
- // frontend disable is only UX; this server-side gate is what actually protects
259
- // the certified content from a raw POST. A read error → null → not blocked.
260
- const changeName = changeNameFromTaskFile(projectRoot, resolved);
261
- if (changeName) {
262
- const changeDir = path.join(projectRoot, 'tospec', 'changes', changeName);
263
- if ((await readSyncConclusion(changeDir)) === 'PASS') {
264
- return sendJson(res, 409, { error: 'change is archiving; tasks are frozen' });
265
- }
266
- // Confined to the schema's tracked task file, from the same resolver the
267
- // read side uses. The path check above only proves the file sits inside a
268
- // change directory, so any other markdown there with a checkbox on the
269
- // addressed line was writable too — including proposal.md and design.md,
270
- // the records this whole workflow exists to keep. The dashboard never
271
- // offered those edits and never showed them afterwards either: `/api/task`
272
- // reports progress from `resolveTaskFiles` alone, so a write outside it
273
- // left no trace anywhere in the UI.
274
- const tracked = resolveTaskFiles(changeDir, projectRoot).map((file) => path.resolve(file));
275
- if (!tracked.includes(path.resolve(resolved))) {
276
- return sendJson(res, 409, {
277
- error: `${path.basename(resolved)} is not this change's tracked task file`,
278
- });
225
+ // The checks above are lexical. A task file that is itself a symlink out of
226
+ // the project passes both, and the write would land outside the root, so the
227
+ // fence is re-checked on the real path. Before the tracked-file check below:
228
+ // that check canonicalizes its own side, so the symlink used to fail there
229
+ // as "not the tracked file" — refused, but for the wrong reason. A file that
230
+ // is gone by now is the 404 the write would have reported.
231
+ let realFile;
232
+ try {
233
+ const realRoot = await fs.realpath(projectRoot);
234
+ realFile = await fs.realpath(resolved);
235
+ if (!realFile.startsWith(realRoot + path.sep)) {
236
+ return sendJson(res, 403, { error: 'forbidden' });
279
237
  }
280
238
  }
239
+ catch (err) {
240
+ if (err.code === 'ENOENT')
241
+ return sendJson(res, 404, { error: 'not found' });
242
+ throw err;
243
+ }
244
+ // A file directly under tospec/changes/ belongs to no change, so neither the
245
+ // sync gate nor the tracked-file check below can apply to it. Skipping both
246
+ // left any markdown there writable by a raw POST.
247
+ const changeName = changeNameFromTaskFile(projectRoot, resolved);
248
+ if (!changeName) {
249
+ return sendJson(res, 403, { error: 'forbidden' });
250
+ }
251
+ // Freeze task edits once a change is sync certified. The frontend disable is
252
+ // only UX; this gate is what protects certified content from a raw POST. A
253
+ // read error reads as not certified.
254
+ const changeDir = path.join(projectRoot, 'tospec', 'changes', changeName);
255
+ if ((await readSyncConclusion(changeDir)) === 'PASS') {
256
+ return sendJson(res, 409, { error: 'change is archiving; tasks are frozen' });
257
+ }
258
+ // Confined to the schema's tracked task file, via the resolver the read
259
+ // side uses. The path check above only proves the file sits inside a change
260
+ // directory, so any other markdown there with a checkbox on the addressed
261
+ // line was writable too, invisibly. The resolver canonicalizes the files it
262
+ // finds and leaves its `tasks.md` fallback as joined, so both spellings of
263
+ // the request path are compared.
264
+ const tracked = resolveTaskFiles(changeDir, projectRoot).map((file) => path.resolve(file));
265
+ if (!tracked.includes(realFile) && !tracked.includes(path.resolve(resolved))) {
266
+ return sendJson(res, 409, {
267
+ error: `${path.basename(resolved)} is not this change's tracked task file`,
268
+ });
269
+ }
281
270
  try {
282
271
  await setTaskDone(resolved, line, done);
283
272
  }
284
273
  catch (err) {
285
274
  const message = err instanceof Error ? err.message : String(err);
286
- // 'not a task' means the client's view of the file is stale; ENOENT means it
287
- // is gone. Neither is a server fault, so don't 500.
275
+ // Stale client view or a deleted file — neither is a server fault, so no 500.
288
276
  if (/not a task/.test(message))
289
277
  return sendJson(res, 409, { error: message });
290
278
  if (/ENOENT/.test(message))
@@ -293,10 +281,6 @@ async function handleTaskUpdate(projectRoot, req, res) {
293
281
  }
294
282
  sendJson(res, 200, { file, line, done });
295
283
  }
296
- // -----------------------------------------------------------------------------
297
- // Live updates (SSE)
298
- // -----------------------------------------------------------------------------
299
- /** Registers an SSE client and streams `change` events until it disconnects. */
300
284
  function handleEvents(req, res, clients) {
301
285
  res.writeHead(200, {
302
286
  'content-type': 'text/event-stream',
@@ -307,27 +291,18 @@ function handleEvents(req, res, clients) {
307
291
  clients.add(res);
308
292
  req.on('close', () => clients.delete(res));
309
293
  }
310
- /** Pushes a `change` event to every connected SSE client. */
311
294
  function broadcastChange(clients) {
312
295
  for (const client of clients) {
313
296
  client.write('event: change\ndata: {}\n\n');
314
297
  }
315
298
  }
316
- // -----------------------------------------------------------------------------
317
- // Router
318
- // -----------------------------------------------------------------------------
319
299
  /**
320
300
  * Second layer behind `renderMarkdownSafe`, applied to every response.
321
301
  *
322
- * `script-src 'self'` is the load-bearing directive: it refuses inline event
323
- * handlers and `javascript:` URLs, which is what a markdown file would have to
324
- * smuggle in to reach `POST /api/task` from inside this origin. Escaping in the
325
- * renderer already stops that; this catches anything a future renderer change
326
- * lets through.
327
- *
328
- * `style-src` keeps `'unsafe-inline'` deliberately — Chart.js writes inline
329
- * styles, and a style injection cannot call the write endpoint, so tightening
330
- * it would trade a working dashboard for no security.
302
+ * `script-src 'self'` is the load-bearing directive: it refuses the inline
303
+ * handlers and `javascript:` URLs a markdown file would need to reach
304
+ * `POST /api/task` from inside this origin. `style-src` keeps `'unsafe-inline'`
305
+ * for Chart.js — a style injection cannot call the write endpoint.
331
306
  */
332
307
  function applyContentSecurityPolicy(res) {
333
308
  res.setHeader('content-security-policy', [
@@ -336,7 +311,6 @@ function applyContentSecurityPolicy(res) {
336
311
  "style-src 'self' 'unsafe-inline'",
337
312
  "img-src 'self' data:",
338
313
  "font-src 'self'",
339
- // EventSource on /api/events and every fetch of /api/*.
340
314
  "connect-src 'self'",
341
315
  "base-uri 'none'",
342
316
  "form-action 'none'",
@@ -360,8 +334,10 @@ async function handleRequest(projectRoot, req, res, clients, boundHost, acceptAn
360
334
  }
361
335
  if (pathname === '/api/events')
362
336
  return handleEvents(req, res, clients);
363
- if (pathname === '/')
337
+ // `/index.html` alongside `/`: nothing links to it, but typing it 404'd.
338
+ if (pathname === '/' || pathname === '/index.html') {
364
339
  return await serveAsset(res, ASSETS_DIR, 'index.html');
340
+ }
365
341
  if (pathname.startsWith('/assets/')) {
366
342
  return await serveAsset(res, ASSETS_DIR, pathname.slice('/assets/'.length));
367
343
  }
@@ -393,21 +369,14 @@ async function handleRequest(projectRoot, req, res, clients, boundHost, acceptAn
393
369
  sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
394
370
  }
395
371
  }
396
- // -----------------------------------------------------------------------------
397
- // Lifecycle
398
- // -----------------------------------------------------------------------------
399
372
  /**
400
- * Starts the dashboard HTTP server, binding at `opts.port` and advancing to the
401
- * next port on `EADDRINUSE` until one is free (D6) — so dashboards for different
402
- * projects coexist. `port: 0` asks the OS for a random port and never conflicts.
403
- * Returns the server, its URL, and the actual port. Side-effect free (no
404
- * logging, no signal handlers, no registry) so tests can drive it directly.
373
+ * Starts the server, advancing past `EADDRINUSE` until a port is free (D6) so
374
+ * dashboards for different projects coexist. Side-effect free (no logging,
375
+ * signal handlers or registry) so tests can drive it directly.
405
376
  */
406
377
  export async function startDashboardServer(projectRoot, opts = {}) {
407
378
  const host = opts.host ?? DEFAULT_HOST;
408
379
  assertBindableHost(host, 'dashboard', opts.allowRemote, 'Pass --allow-remote to accept that risk.');
409
- // Resolved once here so the Host-header policy has a single home, rather than
410
- // each request re-deriving it from two separate inputs.
411
380
  const acceptAnyHost = acceptsAnyHostHeader(host, opts.allowRemote);
412
381
  const clients = new Set();
413
382
  const server = http.createServer((req, res) => {
@@ -416,14 +385,13 @@ export async function startDashboardServer(projectRoot, opts = {}) {
416
385
  const actualPort = await listenFrom(server, opts.port ?? DEFAULT_PORT, host);
417
386
  const watcher = watchTospec(projectRoot, () => broadcastChange(clients));
418
387
  server.once('close', () => watcher?.close());
419
- return { server, url: `http://${host}:${actualPort}`, port: actualPort };
388
+ return { server, url: connectableUrl(host, actualPort), port: actualPort };
420
389
  }
421
390
  /**
422
- * Watches `<root>/tospec` recursively and calls `onChange` (debounced) on any
423
- * edit. `fs.watch` uses OS change notifications, so it never opens or locks the
424
- * watched files — agents and users stay free to edit or delete them (D2).
425
- * Returns null if the directory can't be watched (e.g. missing).
426
- * Limitation: recursive watch; native on win32/macOS, Node 20+ on Linux.
391
+ * Watches `<root>/tospec` recursively and calls `onChange` (debounced), or null
392
+ * if the directory can't be watched. `fs.watch` uses OS change notifications,
393
+ * so it never locks the watched files — agents and users stay free to edit or
394
+ * delete them (D2). Recursive watch is native on win32/macOS, Node 20+ on Linux.
427
395
  */
428
396
  function watchTospec(projectRoot, onChange) {
429
397
  const dir = path.join(projectRoot, 'tospec');
@@ -443,22 +411,19 @@ function watchTospec(projectRoot, onChange) {
443
411
  }
444
412
  }
445
413
  /**
446
- * PID-file location for a project's dashboard, keyed by the project root so one
447
- * dashboard is tracked per project. Lives in the OS temp dir (not the repo) so
448
- * it never gets committed; `--detach` and `--stop` both derive the same path.
414
+ * PID-file location, keyed by project root so `--detach` and `--stop` derive the
415
+ * same path. In the OS temp dir, not the repo, so it never gets committed.
449
416
  */
450
417
  export function pidFilePath(projectRoot) {
451
418
  const hash = crypto.createHash('sha1').update(path.resolve(projectRoot)).digest('hex').slice(0, 12);
452
419
  return path.join(os.tmpdir(), `tospec-dashboard-${hash}.json`);
453
420
  }
454
- /** True if a process with this PID currently exists (signal 0 probes without killing). */
421
+ /** True if a process with this PID exists (signal 0 probes without killing). */
455
422
  function isProcessAlive(pid) {
456
- // Guard the whole module here, where every record's pid arrives: on POSIX
457
- // `kill(0, sig)` signals our own process group and `kill(-n, sig)` a named
458
- // one, so a record carrying 0 or a negative pid — which nothing we write ever
459
- // does, but a corrupt or planted file in the shared temp dir could — would
460
- // make `--stop` terminate the user's own shell. Treated as not alive, so such
461
- // a record is reaped instead of ever reaching `process.kill`.
423
+ // Every record's pid arrives here, so the sign check guards the module: on
424
+ // POSIX `kill(0, sig)` signals our own process group and `kill(-n, sig)` a
425
+ // named one, so a corrupt record in the shared temp dir would make `--stop`
426
+ // terminate the user's own shell.
462
427
  if (!Number.isInteger(pid) || pid <= 0)
463
428
  return false;
464
429
  try {
@@ -471,11 +436,8 @@ function isProcessAlive(pid) {
471
436
  }
472
437
  }
473
438
  /**
474
- * Reads this project's dashboard PID record, or null if absent or corrupt.
475
- * The pid is what identifies the record, so it is the only field required here —
476
- * a truncated record still names a process to reap. Callers that need to reach
477
- * the dashboard check `port` themselves — see `shouldRefuseDetach` for why that
478
- * matters to `--detach`.
439
+ * This project's dashboard PID record, or null if absent or corrupt. `pid` is
440
+ * the only required field — a truncated record still names a process to reap.
479
441
  */
480
442
  async function readPidRecord(pidFile) {
481
443
  try {
@@ -487,32 +449,23 @@ async function readPidRecord(pidFile) {
487
449
  }
488
450
  }
489
451
  /**
490
- * Records this dashboard so `--stop` can find it. Reads are lenient about the
491
- * optional fields (pre-0.13 records lack them); writes are not, since recording
492
- * the root is what lets a scanned record be attributed to its project.
452
+ * Records this dashboard so `--stop` can find it. Reads tolerate the missing
453
+ * optional fields of pre-0.13 records; writes do not, since the root is what
454
+ * attributes a scanned record to its project.
493
455
  */
494
456
  async function writePidRecord(pidFile, rec) {
495
457
  await fs.writeFile(pidFile, JSON.stringify(rec), 'utf-8');
496
458
  }
497
459
  /**
498
- * Removes a pid record if it still names `pid`, so neither caller deletes a
499
- * record describing a different dashboard.
460
+ * Removes a pid record only if it still names `pid`: the path is keyed by
461
+ * project, not process, so two sibling dashboards share this file and either
462
+ * clearer would otherwise delete the other's record.
500
463
  *
501
- * The path is keyed by project, not by process, and nothing guards a second
502
- * foreground start — so two dashboards for one project share this file. Both
503
- * places that clear it (a dashboard's own exit, and `--stop` once it has killed
504
- * one) would otherwise delete whichever record happened to be there.
505
- *
506
- * Limitation: one record per project, so of two sibling dashboards only the later
507
- * writer is tracked by pid file at all. This keeps the tracked one from being
508
- * deleted by the other's exit; it cannot hand the earlier dashboard a record
509
- * back. Both stay reachable through their registry markers, which is what the
510
- * guard reads. Per-process pid files only if a second foreground start ever
511
- * becomes something we support. The read and the delete are also not one atomic
512
- * step, so this is best-effort like every other tidy-up here.
464
+ * Limitation: one record per project, so only the later writer is tracked by
465
+ * pid file; both stay reachable through their registry markers.
513
466
  *
514
467
  * Sync so it completes before the `process.exit` its signal-handler caller
515
- * races — which is why it does not reuse the async `readPidRecord`.
468
+ * races — hence not reusing the async `readPidRecord`.
516
469
  */
517
470
  export function clearPidRecordOwnedBy(pidFile, pid) {
518
471
  try {
@@ -521,9 +474,8 @@ export function clearPidRecordOwnedBy(pidFile, pid) {
521
474
  unlinkSync(pidFile);
522
475
  }
523
476
  catch {
524
- // Absent or already gone: nothing to do. Corrupt: left alone deliberately —
525
- // it names no process, so no command mistakes it for a dashboard, and a
526
- // half-written record could still belong to one that is starting up.
477
+ // Absent: nothing to do. Corrupt: left alone — it names no process, and a
478
+ // half-written record may belong to one that is starting up.
527
479
  }
528
480
  }
529
481
  /** Every project's pid file, not just this one's — they all live in the temp dir. */
@@ -531,7 +483,7 @@ async function pidFilePaths() {
531
483
  const dir = os.tmpdir();
532
484
  try {
533
485
  return (await fs.readdir(dir))
534
- // Exactly the shape pidFilePath() generates — never a wider set than we write.
486
+ // Exactly the shape pidFilePath() generates — never wider than we write.
535
487
  .filter((name) => /^tospec-dashboard-[0-9a-f]{12}\.json$/.test(name))
536
488
  .map((name) => path.join(dir, name));
537
489
  }
@@ -540,15 +492,13 @@ async function pidFilePaths() {
540
492
  }
541
493
  }
542
494
  function registryDir() {
543
- // Derived, not assembled: config, schema overrides and these markers all hang
544
- // off the one resolver, so relocating the config directory moves the registry
545
- // with it instead of leaving the two in different trees.
495
+ // Derived from the one config resolver, so relocating the config directory
496
+ // moves the registry with it.
546
497
  return path.join(getGlobalConfigDir(), 'dashboards');
547
498
  }
548
499
  /**
549
- * Lists live dashboards, reaping any marker whose process has died along the
550
- * way. Each marker is named `{pid}_{port}` and holds its project root as
551
- * content. A missing/unreadable dir → no dashboards running. Sorted by port.
500
+ * Lists live dashboards sorted by port, reaping any marker whose process has
501
+ * died. A missing or unreadable dir means no dashboards are running.
552
502
  */
553
503
  export async function listDashboards() {
554
504
  const dir = registryDir();
@@ -567,9 +517,8 @@ export async function listDashboards() {
567
517
  const pid = Number(m[1]);
568
518
  const file = path.join(dir, name);
569
519
  if (!isProcessAlive(pid)) {
570
- // Best-effort, like every other record tidy-up here: `force` only swallows
571
- // ENOENT, and failing to unlink one stale marker must not take down the
572
- // command that merely wanted to list what is running.
520
+ // Best-effort: failing to unlink one stale marker must not take down a
521
+ // command that only wanted to list.
573
522
  await fs.rm(file, { force: true }).catch(() => { });
574
523
  continue;
575
524
  }
@@ -586,8 +535,7 @@ export async function listDashboards() {
586
535
  }
587
536
  /**
588
537
  * One line describing a running dashboard, for `--list` and `--stop`'s picker.
589
- * The URL assumes the default loopback host — `RunningDashboard` doesn't record
590
- * the host a `--allow-remote` dashboard was bound to.
538
+ * The URL assumes loopback — `RunningDashboard` doesn't record the bound host.
591
539
  */
592
540
  export function formatRunningDashboard(d, sep = '\t') {
593
541
  const url = `http://${DEFAULT_HOST}:${d.port}`;
@@ -595,8 +543,7 @@ export function formatRunningDashboard(d, sep = '\t') {
595
543
  }
596
544
  /**
597
545
  * `--stop` found dashboards but cannot ask which to stop (no terminal). Carries
598
- * its own diagnostic so the shared failure sink renders it, like
599
- * `RootSelectionError` and `ArchiveBlockedError`.
546
+ * its own diagnostic so the shared failure sink renders it.
600
547
  */
601
548
  export class DashboardStopError extends Error {
602
549
  diagnostic;
@@ -607,11 +554,9 @@ export class DashboardStopError extends Error {
607
554
  }
608
555
  }
609
556
  /**
610
- * The detached child neither reported itself listening nor exited. Distinct
611
- * from an early-exit failure: the child may well still be coming up, so the
612
- * message says what was not observed rather than claiming a startup failure,
613
- * and points at `--list`, which can still find it via the record the child
614
- * writes for itself.
557
+ * The detached child neither reported itself listening nor exited. It may still
558
+ * be coming up, so the message reports what was not observed rather than
559
+ * claiming failure.
615
560
  */
616
561
  export class DashboardReadinessTimeoutError extends Error {
617
562
  diagnostic;
@@ -629,14 +574,12 @@ export class DashboardReadinessTimeoutError extends Error {
629
574
  }
630
575
  }
631
576
  /**
632
- * Every dashboard `--stop` can reach: the registry union every project's pid
633
- * file, keyed by pid, reaping any record whose process has died along the way.
577
+ * Every dashboard `--stop` can reach: the registry unioned with every project's
578
+ * pid file, keyed by pid, reaping dead records along the way.
634
579
  *
635
580
  * The registry alone is not enough — `listDashboards()` reaps any marker whose
636
581
  * pid looks dead, so a live server can lose its marker and become unstoppable
637
- * while its pid file still names it correctly. Scanning every pid file rather
638
- * than only this project's is what makes that reachable from any directory,
639
- * which is the case `--stop`'s pick-from-all list exists for.
582
+ * while its pid file still names it.
640
583
  *
641
584
  * `ownRootHint` names records written before pid files carried their own root;
642
585
  * it never restricts the result.
@@ -650,14 +593,10 @@ export async function collectStoppableDashboards(ownRootHint) {
650
593
  const rec = await readPidRecord(file);
651
594
  if (!rec)
652
595
  continue;
653
- // Reap like the marker reaper does. A record left by a crashed dashboard
654
- // would otherwise sit in the temp dir forever, and once the OS recycled its
655
- // pid it would read as alive again — a phantom dashboard, attributed to a
656
- // real project, that `--stop` would kill an unrelated process for.
657
- //
658
- // Tidying is best-effort: `force` only swallows ENOENT, and the temp dir is
659
- // shared between users on POSIX, so another user's stale file would fail to
660
- // unlink. Losing that race must not take the whole command down with it.
596
+ // Reap like the marker reaper does: a crashed dashboard's record would
597
+ // otherwise linger until the OS recycled its pid, then read as alive again —
598
+ // a phantom `--stop` would kill an unrelated process for. Unlink is
599
+ // best-effort: the POSIX temp dir is shared with other users.
661
600
  if (!isProcessAlive(rec.pid)) {
662
601
  await fs.rm(file, { force: true }).catch(() => { });
663
602
  continue;
@@ -671,15 +610,15 @@ export async function collectStoppableDashboards(ownRootHint) {
671
610
  byPid.set(rec.pid, { pid: rec.pid, port: rec.port, projectRoot: root });
672
611
  continue;
673
612
  }
674
- // A registry marker can be content-less; the pid file may know the root, and
675
- // without it this dashboard would never match the project it belongs to.
613
+ // A content-less registry marker never matches its project; the pid file may
614
+ // still know the root.
676
615
  if (!known.projectRoot && root)
677
616
  byPid.set(rec.pid, { ...known, projectRoot: root });
678
617
  }
679
618
  return [...byPid.values()].sort((a, b) => a.port - b.port);
680
619
  }
681
620
  /**
682
- * Ports held by live dashboards (reaps dead markers as a side effect). Runs at
621
+ * Ports held by live dashboards, reaping dead markers as a side effect. Runs at
683
622
  * every startup so a crashed dashboard's port frees up.
684
623
  */
685
624
  async function reapAndCollectPorts() {
@@ -702,64 +641,66 @@ async function unregisterDashboard(pid) {
702
641
  }
703
642
  await Promise.all(names
704
643
  .filter((n) => n.startsWith(`${pid}_`))
705
- // Best-effort: a marker we cannot unlink must not abort the stop loop and
706
- // leave the dashboards after this one running.
644
+ // Best-effort: a marker we cannot unlink must not abort the stop loop.
707
645
  .map((n) => fs.rm(path.join(dir, n), { force: true }).catch(() => { })));
708
646
  }
647
+ /** `unregisterDashboard` for the signal handler, which must finish before exit. */
648
+ function unregisterDashboardSync(pid) {
649
+ const dir = registryDir();
650
+ let names;
651
+ try {
652
+ names = readdirSync(dir);
653
+ }
654
+ catch {
655
+ return;
656
+ }
657
+ for (const name of names) {
658
+ if (!name.startsWith(`${pid}_`))
659
+ continue;
660
+ try {
661
+ rmSync(path.join(dir, name), { force: true });
662
+ }
663
+ catch {
664
+ // Best-effort, as in the async variant.
665
+ }
666
+ }
667
+ }
709
668
  /**
710
- * Launches the dashboard as a detached background process. Re-runs this same
711
- * CLI without `--detach` (the child is the actual server), then waits for the
712
- * child's own `LISTENING` report before doing anything else, so the URL it
713
- * prints is one the child actually bound — including after an EADDRINUSE
714
- * fallback moved it off the port this process predicted. Order is therefore:
715
- * spawn → wait for the child's report → write the PID record → print the URL.
669
+ * Launches the dashboard as a detached background process: re-runs this CLI
670
+ * without `--detach` (the child is the actual server), then waits for the
671
+ * child's `LISTENING` report, so the URL printed is one the child actually
672
+ * bound — including after an EADDRINUSE fallback moved it off the predicted
673
+ * port. Order: spawn → wait for the report → write the PID record → print.
716
674
  *
717
- * Refuses if any live dashboard already serves this project, foreground or
718
- * background, rather than spawning a second orphaned server. The guard asks the
719
- * shared candidate set rather than this project's pid file alone, because
720
- * either record can be the only one left: a foreground dashboard used to write
721
- * no pid file at all, and a background one can lose the one it wrote. Both made
722
- * the old guard see nothing and spawn a second server.
675
+ * Refuses if any live dashboard already serves this project, asking the shared
676
+ * candidate set because either record can be the only one left (a foreground
677
+ * dashboard writes no pid file; a background one can lose its registry marker).
723
678
  *
724
- * Both this process and the child write that PID record, with identical fields
725
- * by construction. They are not redundant — each is the only record that exists
726
- * in one failure mode:
727
- * - The child's (`runDashboard`) is the record *every* dashboard writes for
728
- * itself, foreground included, and is all that remains if this process is
729
- * killed mid-handshake or gives up on the readiness timeout.
730
- * - This one is what makes a record exist by the time the call returns. The
731
- * child writes its own only *after* it has signalled, so without this a
732
- * rapid second `--detach` could find nothing to refuse against, and a child
733
- * that wedges after binding would be an untrackable orphan.
734
- * Both use `fs.writeFile`, so a concurrent truncate-then-write is two processes
735
- * laying down the same bytes; a reader that catches it mid-write falls back to
736
- * the registry marker (`readPidRecord` returns null on a parse failure).
679
+ * This process and the child both write the PID record, with identical fields.
680
+ * The child's is all that remains if this process dies mid-handshake; this one
681
+ * makes a record exist by the time the call returns, since the child writes only
682
+ * after signalling.
737
683
  *
738
684
  * This is the only guarded start path: nothing stops a second foreground
739
- * `tospec dashboard`, so "one dashboard per project" holds for `--detach` and
740
- * not as a machine-wide invariant.
685
+ * `tospec dashboard`, so "one dashboard per project" is not machine-wide.
741
686
  */
742
687
  export async function detachDashboard(projectRoot, cliEntry, opts) {
743
688
  const pidFile = pidFilePath(projectRoot);
744
- // Reaps every dead record along the way, including the stale pid file a
745
- // crashed dashboard of this project left behind.
746
689
  const running = shouldRefuseDetach(await collectStoppableDashboards(projectRoot), projectRoot);
747
690
  if (running) {
748
- // Same fields as `--list`, same separator as `--stop`'s picker, so the
749
- // dashboard named here is recognisably the row those commands show.
691
+ // Same fields and separator as `--list` and `--stop`'s picker.
750
692
  console.log('Dashboard already running for this project.');
751
693
  console.log(` ${formatRunningDashboard(running, ' ')}`);
752
694
  console.log('Stop it with: tospec dashboard --stop');
753
695
  return;
754
696
  }
755
- // Pick the likely port up front (reaping dead markers) so the message and
756
- // provisional record are accurate. The child re-checks and binds, so a race
757
- // that grabs this port between now and then just shifts the child upward.
697
+ // Predict the port to hand the child, skipping ones live dashboards hold. The
698
+ // child re-checks and binds, so a race for this port just shifts it upward;
699
+ // what gets reported is the URL the child confirms, never this prediction.
758
700
  const used = await reapAndCollectPorts();
759
701
  let port = opts.port;
760
702
  while (used.has(port))
761
703
  port++;
762
- const url = `http://${opts.host}:${port}`;
763
704
  const args = [cliEntry, 'dashboard', opts.targetPath, '--port', String(port), '--host', opts.host];
764
705
  if (opts.open)
765
706
  args.push('--open');
@@ -767,41 +708,32 @@ export async function detachDashboard(projectRoot, cliEntry, opts) {
767
708
  args.push('--allow-remote');
768
709
  const child = spawn(process.execPath, args, {
769
710
  detached: true,
770
- // stderr is piped so the parent can wait for the child's own report of
771
- // success (LISTENING) or read its error text on an early exit — see
772
- // waitForListening. stdout stays ignored; it is human text for a
773
- // foreground run, not this handshake's concern.
711
+ // stderr is piped so the parent can read the child's LISTENING report or
712
+ // its error text on an early exit (waitForListening).
774
713
  stdio: ['ignore', 'ignore', 'pipe'],
775
714
  // On Windows, detached: true otherwise pops a new console window.
776
715
  windowsHide: true,
777
716
  env: { ...process.env, TOSPEC_DASHBOARD_PIDFILE: pidFile },
778
717
  });
779
- // No pid means the spawn itself failed; say so rather than report success and
780
- // leave behind a record `--stop` could never use.
718
+ // No pid means the spawn itself failed; reporting success would leave a record
719
+ // `--stop` could never use.
781
720
  if (child.pid === undefined) {
782
721
  throw new Error('Could not start the background dashboard process.');
783
722
  }
784
- // Wait for the child to actually confirm it is listening before reporting
785
- // anything: everything that can go wrong after a successful spawn — the
786
- // --allow-remote refusal, EADDRINUSE, a permission error — happens inside
787
- // the child and was previously unobservable behind stdio: 'ignore'.
723
+ // Everything that can go wrong after a successful spawn happens inside the
724
+ // child and is only observable through this handshake.
788
725
  let confirmedUrl;
789
726
  try {
790
727
  confirmedUrl = await waitForListening(child);
791
728
  }
792
729
  finally {
793
730
  // Unconditional: on the timeout path the child is still running, and an
794
- // un-unref'd handle would keep this process's event loop alive after the
795
- // error has already been reported.
731
+ // un-unref'd handle would keep this process's event loop alive.
796
732
  child.unref();
797
733
  }
798
- // The child writes this same record for itself (see the docstring for why
799
- // both writers are kept); these fields are derived from the URL it reported,
800
- // so the two agree whichever lands last.
801
- //
802
- // Limitation: no cross-process lock; two --detach in the same instant could
803
- // still both pass the guard. A human running the command twice is sequential,
804
- // so this synchronous write is enough — add a lockfile only if that changes.
734
+ // Derived from the URL the child reported, so this record and the child's own
735
+ // agree whichever lands last. Limitation: no cross-process lock, so two
736
+ // simultaneous --detach could both pass the guard.
805
737
  await writePidRecord(pidFile, {
806
738
  pid: child.pid,
807
739
  port: portFromUrl(confirmedUrl) ?? port,
@@ -817,16 +749,13 @@ function portFromUrl(url) {
817
749
  return match ? Number(match[1]) : null;
818
750
  }
819
751
  /**
820
- * Races the child's own outcome: resolves with the confirmed URL once it
821
- * reports `LISTENING <url>` on stderr, rejects with the child's captured stderr
822
- * (or a fallback message) if it exits first, and rejects on timeout if it does
823
- * neither. Closes the stderr pipe once settled so an unref'd, still-running
824
- * child cannot keep this process's event loop alive on our end of it.
752
+ * Races the child's outcome: resolves with the confirmed URL on
753
+ * `LISTENING <url>`, rejects with its captured stderr if it exits first, and
754
+ * rejects on timeout if it does neither. Closes the stderr pipe once settled so
755
+ * our end cannot keep this process's event loop alive.
825
756
  *
826
- * The timeout is the third outcome, not a nicety: `LISTENING` and `exit` do not
827
- * cover a child that binds and then wedges, nor one whose readiness line stops
828
- * being emitted — and with only those two listeners either case left
829
- * `tospec dashboard --detach` hanging with no output at all.
757
+ * The timeout is a real third outcome: a child that binds then wedges emits
758
+ * neither event, and `--detach` would hang with no output.
830
759
  */
831
760
  function waitForListening(child, timeoutMs = READINESS_TIMEOUT_MS) {
832
761
  return new Promise((resolve, reject) => {
@@ -842,7 +771,11 @@ function waitForListening(child, timeoutMs = READINESS_TIMEOUT_MS) {
842
771
  };
843
772
  const onData = (chunk) => {
844
773
  stderr += chunk.toString();
845
- const line = stderr.split('\n').find((l) => l.startsWith(LISTENING_PREFIX));
774
+ // Complete lines only: a pipe read can end mid-line, and matching the
775
+ // unterminated tail resolved with a truncated URL (`http://127.0.0.1:56`).
776
+ const lines = stderr.split('\n');
777
+ lines.pop();
778
+ const line = lines.find((l) => l.startsWith(LISTENING_PREFIX));
846
779
  if (line && !settled) {
847
780
  settled = true;
848
781
  cleanup();
@@ -855,11 +788,8 @@ function waitForListening(child, timeoutMs = READINESS_TIMEOUT_MS) {
855
788
  settled = true;
856
789
  cleanup();
857
790
  reject(new Error(
858
- // The child already formatted its failure as `Error: <message>` on
859
- // stderr. Carrying that prefix into a new Error made the parent's own
860
- // `Error: ` formatter print it twice, so the same refusal read as
861
- // "Error: Error: Refusing to bind..." under --detach and correctly in
862
- // the foreground. Strip the child's prefix, not the parent's.
791
+ // The child already formatted its failure as `Error: <message>`;
792
+ // carrying that through made the parent print "Error: Error: ...".
863
793
  stripErrorPrefix(stderr.trim()) ||
864
794
  `The background dashboard process exited before it started listening (code ${code}).`));
865
795
  };
@@ -881,12 +811,9 @@ export function samePath(a, b) {
881
811
  return process.platform === 'win32' ? na.toLowerCase() === nb.toLowerCase() : na === nb;
882
812
  }
883
813
  /**
884
- * The dashboards `--stop` may terminate without asking. Empty means "nothing
885
- * here is mine" — the caller's cue to offer the pick-from-all list.
886
- *
887
- * A candidate with no recorded project never matches: `path.resolve('')` is the
888
- * cwd, so a blank root would silently claim it. Pure, so this is testable
889
- * without touching the registry.
814
+ * The dashboards `--stop` may terminate without asking. Empty is the caller's
815
+ * cue to offer the pick-from-all list. A candidate with no recorded project
816
+ * never matches: `path.resolve('')` is the cwd, so a blank root would claim it.
890
817
  */
891
818
  export function selectStopTargets(candidates, projectRoot) {
892
819
  if (!projectRoot)
@@ -899,55 +826,46 @@ export function selectStopTargets(candidates, projectRoot) {
899
826
  * `tospec/decisions/20260727_232950-one-dashboard-per-project.md`.
900
827
  *
901
828
  * Deliberately the same question `--stop` asks, over the same candidate set, so
902
- * the two commands can never disagree about what is running. That also settles
903
- * the record `--stop` cannot reach: a live but truncated record carries no port,
904
- * so `collectStoppableDashboards` drops it and it never blocks a start it could
905
- * not tell the user how to unblock.
829
+ * the two can never disagree about what is running.
906
830
  */
907
831
  export function shouldRefuseDetach(candidates, projectRoot) {
908
- // Limitation: names the first match only, though `selectStopTargets` can return
909
- // several (a project that already had two before this guard existed). One is
910
- // enough to refuse and to point at; `--stop` still clears all of them.
832
+ // First match only: one is enough to refuse and point at.
911
833
  return selectStopTargets(candidates, projectRoot)[0] ?? null;
912
834
  }
913
835
  /**
914
836
  * Terminates a running dashboard and clears its registry marker, plus the
915
- * per-project pid file if that still names this pid. A dead pid is treated as
837
+ * per-project pid file if that still names this pid. A dead pid counts as
916
838
  * already stopped.
917
839
  *
918
- * Returns null on success, or the reason the signal was refused — on POSIX the
919
- * temp dir is shared, so a candidate can belong to another user. Reporting that
920
- * as stopped would be this ticket's own bug all over again, so we hand the
921
- * caller what actually happened and leave that dashboard's records alone.
840
+ * Returns null on success, or the reason the signal was refused — the POSIX temp
841
+ * dir is shared, so a candidate can belong to another user.
922
842
  *
923
- * Limitation: SIGTERM; on Windows Node maps this to TerminateProcess (hard kill),
924
- * so the target's own cleanup won't run — we clear its files here.
843
+ * Limitation: SIGTERM; Windows maps it to TerminateProcess (hard kill), so the
844
+ * target's own cleanup won't run — we clear its files here.
925
845
  */
926
846
  export async function stopRunningDashboard(d) {
927
847
  try {
928
848
  process.kill(d.pid);
929
849
  }
930
850
  catch (err) {
931
- // ESRCH means it exited between listing and now: already stopped, so its
932
- // records should still be cleared. Anything else (EPERM) is a real refusal.
851
+ // ESRCH means it exited between listing and now: already stopped, so still
852
+ // clear its records. Anything else (EPERM) is a real refusal.
933
853
  const { code, message } = err;
934
854
  if (code !== 'ESRCH')
935
855
  return code ?? message;
936
856
  }
937
857
  await unregisterDashboard(d.pid);
938
858
  // Only this dashboard's record: the path is per-project, so stopping one of
939
- // two siblings must not clear the other's. Same reason the exit path checks.
859
+ // two siblings must not clear the other's.
940
860
  if (d.projectRoot)
941
861
  clearPidRecordOwnedBy(pidFilePath(d.projectRoot), d.pid);
942
862
  return null;
943
863
  }
944
864
  /**
945
- * CLI entry: allocates a free port, starts the server, prints the URL,
946
- * optionally opens a browser, and stays up until terminated.
947
- *
948
- * Port allocation: reap dead registry markers, skip ports live dashboards hold
949
- * (so each project lands on its own port off the 5620 base), then bind — which
950
- * confirms the port and advances past any the registry didn't know about.
865
+ * CLI entry: allocates a free port, starts the server, prints the URL, and stays
866
+ * up until terminated. Port allocation reaps dead registry markers and skips
867
+ * ports live dashboards hold; the bind then advances past any the registry
868
+ * didn't know about.
951
869
  */
952
870
  export async function runDashboard(projectRoot, opts = {}) {
953
871
  const used = await reapAndCollectPorts();
@@ -956,19 +874,21 @@ export async function runDashboard(projectRoot, opts = {}) {
956
874
  start++;
957
875
  const { server, url, port } = await startDashboardServer(projectRoot, { ...opts, port: start });
958
876
  console.log(`Dashboard running at ${url} (Ctrl+C to stop)`);
959
- // Detached child only: this is `detachDashboard`'s readiness handshake — its
960
- // parent is waiting on stderr for exactly this line before it reports
961
- // anything. A direct foreground run has no parent watching, so it stays quiet.
877
+ // Detached child only: `detachDashboard` waits on stderr for exactly this line
878
+ // before it reports anything. A foreground run has no parent watching.
962
879
  if (process.env.TOSPEC_DASHBOARD_PIDFILE) {
880
+ // The parent closes its end of this pipe once it reads the line below and
881
+ // then exits, so every later stderr write (a config warning on
882
+ // /api/activity, a failed --open) hits EPIPE. Unhandled, that error killed
883
+ // the background dashboard a few writes in; handled, the writes are dropped.
884
+ process.stderr.on('error', () => { });
963
885
  console.error(`${LISTENING_PREFIX}${url}`);
964
886
  }
965
887
  await registerDashboard(process.pid, port, path.resolve(projectRoot));
966
- // Every dashboard records itself, foreground included — `--detach` can only
967
- // refuse for a foreground dashboard if one leaves a record, and the registry
968
- // marker alone can be reaped out from under a live server. `--detach`'s child
969
- // is handed the path its parent already derived, so the two cannot disagree
970
- // about the file's location even though the child re-resolves the root for
971
- // the record's own contents.
888
+ // Every dashboard records itself, foreground included: `--detach` can only
889
+ // refuse for one that left a record, and a registry marker alone can be
890
+ // reaped out from under a live server. The child is handed its parent's path
891
+ // so the two cannot disagree about where the file lives.
972
892
  const pidFile = process.env.TOSPEC_DASHBOARD_PIDFILE ?? pidFilePath(projectRoot);
973
893
  await writePidRecord(pidFile, {
974
894
  pid: process.pid,
@@ -979,14 +899,16 @@ export async function runDashboard(projectRoot, opts = {}) {
979
899
  if (opts.open) {
980
900
  openBrowser(url);
981
901
  }
982
- // Normal termination (Ctrl+C → SIGINT, `--stop` → SIGTERM on POSIX) removes
983
- // this dashboard's own records. Windows hard-kills on `--stop` specifically,
984
- // so this does not run for that path and stopRunningDashboard clears them
985
- // instead; a dead one is reaped on next start either way.
902
+ // Normal termination removes this dashboard's own records. Windows hard-kills
903
+ // on `--stop`, so there `stopRunningDashboard` clears them instead.
986
904
  const cleanup = () => {
987
905
  // SSE keep-alive sockets would otherwise block close() from ever draining.
988
906
  server.closeAllConnections?.();
989
- void unregisterDashboard(process.pid);
907
+ // Sync, like clearPidRecordOwnedBy: fired and forgotten, the async
908
+ // readdir + rm could lose the race with process.exit, and a marker
909
+ // outliving its process reads as alive again once the OS reuses the pid —
910
+ // which `--stop` would then kill.
911
+ unregisterDashboardSync(process.pid);
990
912
  clearPidRecordOwnedBy(pidFile, process.pid);
991
913
  server.close(() => process.exit(0));
992
914
  };