@seanmars/tospec 0.19.0-beta.8 → 0.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 (421) hide show
  1. package/CHANGELOG.md +60 -310
  2. package/README.md +69 -82
  3. package/assets/dashboard/app.js +11 -0
  4. package/assets/dashboard/style.css +7 -0
  5. package/dist/cli/index.d.ts.map +1 -1
  6. package/dist/cli/index.js +89 -106
  7. package/dist/cli/index.js.map +1 -1
  8. package/dist/commands/config.d.ts +9 -17
  9. package/dist/commands/config.d.ts.map +1 -1
  10. package/dist/commands/config.js +293 -145
  11. package/dist/commands/config.js.map +1 -1
  12. package/dist/commands/dashboard.d.ts +57 -99
  13. package/dist/commands/dashboard.d.ts.map +1 -1
  14. package/dist/commands/dashboard.js +190 -313
  15. package/dist/commands/dashboard.js.map +1 -1
  16. package/dist/commands/decision.d.ts +38 -27
  17. package/dist/commands/decision.d.ts.map +1 -1
  18. package/dist/commands/decision.js +296 -128
  19. package/dist/commands/decision.js.map +1 -1
  20. package/dist/commands/metrics.d.ts +28 -51
  21. package/dist/commands/metrics.d.ts.map +1 -1
  22. package/dist/commands/metrics.js +62 -93
  23. package/dist/commands/metrics.js.map +1 -1
  24. package/dist/commands/shared-output.d.ts +12 -27
  25. package/dist/commands/shared-output.d.ts.map +1 -1
  26. package/dist/commands/shared-output.js +22 -45
  27. package/dist/commands/shared-output.js.map +1 -1
  28. package/dist/commands/show.d.ts +4 -7
  29. package/dist/commands/show.d.ts.map +1 -1
  30. package/dist/commands/show.js +23 -11
  31. package/dist/commands/show.js.map +1 -1
  32. package/dist/commands/validate.d.ts +34 -58
  33. package/dist/commands/validate.d.ts.map +1 -1
  34. package/dist/commands/validate.js +225 -141
  35. package/dist/commands/validate.js.map +1 -1
  36. package/dist/commands/workflow/index.d.ts +1 -5
  37. package/dist/commands/workflow/index.d.ts.map +1 -1
  38. package/dist/commands/workflow/index.js +1 -5
  39. package/dist/commands/workflow/index.js.map +1 -1
  40. package/dist/commands/workflow/instructions.d.ts +14 -24
  41. package/dist/commands/workflow/instructions.d.ts.map +1 -1
  42. package/dist/commands/workflow/instructions.js +219 -128
  43. package/dist/commands/workflow/instructions.js.map +1 -1
  44. package/dist/commands/workflow/new-change.d.ts +2 -5
  45. package/dist/commands/workflow/new-change.d.ts.map +1 -1
  46. package/dist/commands/workflow/new-change.js +69 -32
  47. package/dist/commands/workflow/new-change.js.map +1 -1
  48. package/dist/commands/workflow/schemas.d.ts +1 -5
  49. package/dist/commands/workflow/schemas.d.ts.map +1 -1
  50. package/dist/commands/workflow/schemas.js +6 -17
  51. package/dist/commands/workflow/schemas.js.map +1 -1
  52. package/dist/commands/workflow/shared.d.ts +37 -42
  53. package/dist/commands/workflow/shared.d.ts.map +1 -1
  54. package/dist/commands/workflow/shared.js +23 -54
  55. package/dist/commands/workflow/shared.js.map +1 -1
  56. package/dist/commands/workflow/status.d.ts +7 -17
  57. package/dist/commands/workflow/status.d.ts.map +1 -1
  58. package/dist/commands/workflow/status.js +57 -72
  59. package/dist/commands/workflow/status.js.map +1 -1
  60. package/dist/commands/workflow/templates.d.ts +8 -8
  61. package/dist/commands/workflow/templates.d.ts.map +1 -1
  62. package/dist/commands/workflow/templates.js +32 -46
  63. package/dist/commands/workflow/templates.js.map +1 -1
  64. package/dist/core/archive.d.ts +22 -31
  65. package/dist/core/archive.d.ts.map +1 -1
  66. package/dist/core/archive.js +296 -283
  67. package/dist/core/archive.js.map +1 -1
  68. package/dist/core/artifact-graph/graph.d.ts +25 -42
  69. package/dist/core/artifact-graph/graph.d.ts.map +1 -1
  70. package/dist/core/artifact-graph/graph.js +45 -63
  71. package/dist/core/artifact-graph/graph.js.map +1 -1
  72. package/dist/core/artifact-graph/index.d.ts +1 -1
  73. package/dist/core/artifact-graph/index.d.ts.map +1 -1
  74. package/dist/core/artifact-graph/index.js +1 -1
  75. package/dist/core/artifact-graph/index.js.map +1 -1
  76. package/dist/core/artifact-graph/instruction-loader.d.ts +54 -120
  77. package/dist/core/artifact-graph/instruction-loader.d.ts.map +1 -1
  78. package/dist/core/artifact-graph/instruction-loader.js +129 -111
  79. package/dist/core/artifact-graph/instruction-loader.js.map +1 -1
  80. package/dist/core/artifact-graph/outputs.d.ts +9 -23
  81. package/dist/core/artifact-graph/outputs.d.ts.map +1 -1
  82. package/dist/core/artifact-graph/outputs.js +45 -38
  83. package/dist/core/artifact-graph/outputs.js.map +1 -1
  84. package/dist/core/artifact-graph/resolver.d.ts +36 -81
  85. package/dist/core/artifact-graph/resolver.d.ts.map +1 -1
  86. package/dist/core/artifact-graph/resolver.js +60 -101
  87. package/dist/core/artifact-graph/resolver.js.map +1 -1
  88. package/dist/core/artifact-graph/schema.d.ts +0 -6
  89. package/dist/core/artifact-graph/schema.d.ts.map +1 -1
  90. package/dist/core/artifact-graph/schema.js +7 -32
  91. package/dist/core/artifact-graph/schema.js.map +1 -1
  92. package/dist/core/artifact-graph/state.d.ts +1 -8
  93. package/dist/core/artifact-graph/state.d.ts.map +1 -1
  94. package/dist/core/artifact-graph/state.js +2 -17
  95. package/dist/core/artifact-graph/state.js.map +1 -1
  96. package/dist/core/artifact-graph/stub-detection.d.ts +6 -14
  97. package/dist/core/artifact-graph/stub-detection.d.ts.map +1 -1
  98. package/dist/core/artifact-graph/stub-detection.js +13 -16
  99. package/dist/core/artifact-graph/stub-detection.js.map +1 -1
  100. package/dist/core/artifact-graph/types.d.ts +4 -0
  101. package/dist/core/artifact-graph/types.d.ts.map +1 -1
  102. package/dist/core/artifact-graph/types.js +30 -10
  103. package/dist/core/artifact-graph/types.js.map +1 -1
  104. package/dist/core/available-tools.d.ts +3 -12
  105. package/dist/core/available-tools.d.ts.map +1 -1
  106. package/dist/core/available-tools.js +4 -13
  107. package/dist/core/available-tools.js.map +1 -1
  108. package/dist/core/change-metadata/schema.d.ts +1 -1
  109. package/dist/core/change-metadata/schema.d.ts.map +1 -1
  110. package/dist/core/change-metadata/schema.js +10 -7
  111. package/dist/core/change-metadata/schema.js.map +1 -1
  112. package/dist/core/change-presenter.d.ts +16 -27
  113. package/dist/core/change-presenter.d.ts.map +1 -1
  114. package/dist/core/change-presenter.js +53 -53
  115. package/dist/core/change-presenter.js.map +1 -1
  116. package/dist/core/change-status-policy.d.ts +4 -8
  117. package/dist/core/change-status-policy.d.ts.map +1 -1
  118. package/dist/core/change-status-policy.js +9 -17
  119. package/dist/core/change-status-policy.js.map +1 -1
  120. package/dist/core/codex-metrics.d.ts +25 -45
  121. package/dist/core/codex-metrics.d.ts.map +1 -1
  122. package/dist/core/codex-metrics.js +44 -88
  123. package/dist/core/codex-metrics.js.map +1 -1
  124. package/dist/core/codex-residue.d.ts +14 -15
  125. package/dist/core/codex-residue.d.ts.map +1 -1
  126. package/dist/core/codex-residue.js +18 -22
  127. package/dist/core/codex-residue.js.map +1 -1
  128. package/dist/core/command-generation/adapters/claude.d.ts +2 -9
  129. package/dist/core/command-generation/adapters/claude.d.ts.map +1 -1
  130. package/dist/core/command-generation/adapters/claude.js +2 -12
  131. package/dist/core/command-generation/adapters/claude.js.map +1 -1
  132. package/dist/core/command-generation/adapters/index.d.ts +1 -9
  133. package/dist/core/command-generation/adapters/index.d.ts.map +1 -1
  134. package/dist/core/command-generation/adapters/index.js +1 -9
  135. package/dist/core/command-generation/adapters/index.js.map +1 -1
  136. package/dist/core/command-generation/generator.d.ts +0 -17
  137. package/dist/core/command-generation/generator.d.ts.map +1 -1
  138. package/dist/core/command-generation/generator.js +0 -17
  139. package/dist/core/command-generation/generator.js.map +1 -1
  140. package/dist/core/command-generation/index.d.ts +2 -5
  141. package/dist/core/command-generation/index.d.ts.map +1 -1
  142. package/dist/core/command-generation/index.js +0 -9
  143. package/dist/core/command-generation/index.js.map +1 -1
  144. package/dist/core/command-generation/types.d.ts +10 -36
  145. package/dist/core/command-generation/types.d.ts.map +1 -1
  146. package/dist/core/command-generation/types.js +0 -6
  147. package/dist/core/command-generation/types.js.map +1 -1
  148. package/dist/core/command-generation/yaml.d.ts +3 -18
  149. package/dist/core/command-generation/yaml.d.ts.map +1 -1
  150. package/dist/core/command-generation/yaml.js +5 -23
  151. package/dist/core/command-generation/yaml.js.map +1 -1
  152. package/dist/core/config-prompts.d.ts +2 -4
  153. package/dist/core/config-prompts.d.ts.map +1 -1
  154. package/dist/core/config-prompts.js +2 -7
  155. package/dist/core/config-prompts.js.map +1 -1
  156. package/dist/core/config-schema.d.ts +7 -41
  157. package/dist/core/config-schema.d.ts.map +1 -1
  158. package/dist/core/config-schema.js +35 -74
  159. package/dist/core/config-schema.js.map +1 -1
  160. package/dist/core/config.d.ts +25 -49
  161. package/dist/core/config.d.ts.map +1 -1
  162. package/dist/core/config.js +22 -45
  163. package/dist/core/config.js.map +1 -1
  164. package/dist/core/dashboard-activity.d.ts +7 -9
  165. package/dist/core/dashboard-activity.d.ts.map +1 -1
  166. package/dist/core/dashboard-activity.js +26 -24
  167. package/dist/core/dashboard-activity.js.map +1 -1
  168. package/dist/core/dashboard-data.d.ts +35 -22
  169. package/dist/core/dashboard-data.d.ts.map +1 -1
  170. package/dist/core/dashboard-data.js +57 -72
  171. package/dist/core/dashboard-data.js.map +1 -1
  172. package/dist/core/global-config.d.ts +24 -53
  173. package/dist/core/global-config.d.ts.map +1 -1
  174. package/dist/core/global-config.js +38 -67
  175. package/dist/core/global-config.js.map +1 -1
  176. package/dist/core/init.d.ts +12 -28
  177. package/dist/core/init.d.ts.map +1 -1
  178. package/dist/core/init.js +90 -168
  179. package/dist/core/init.js.map +1 -1
  180. package/dist/core/list.d.ts.map +1 -1
  181. package/dist/core/list.js +80 -38
  182. package/dist/core/list.js.map +1 -1
  183. package/dist/core/local-server.d.ts +41 -83
  184. package/dist/core/local-server.d.ts.map +1 -1
  185. package/dist/core/local-server.js +53 -98
  186. package/dist/core/local-server.js.map +1 -1
  187. package/dist/core/markdown-render.d.ts +15 -23
  188. package/dist/core/markdown-render.d.ts.map +1 -1
  189. package/dist/core/markdown-render.js +25 -34
  190. package/dist/core/markdown-render.js.map +1 -1
  191. package/dist/core/migrate.d.ts +19 -16
  192. package/dist/core/migrate.d.ts.map +1 -1
  193. package/dist/core/migrate.js +151 -135
  194. package/dist/core/migrate.js.map +1 -1
  195. package/dist/core/parsers/change-parser.d.ts +7 -10
  196. package/dist/core/parsers/change-parser.d.ts.map +1 -1
  197. package/dist/core/parsers/change-parser.js +48 -56
  198. package/dist/core/parsers/change-parser.js.map +1 -1
  199. package/dist/core/parsers/markdown-parser.d.ts +8 -9
  200. package/dist/core/parsers/markdown-parser.d.ts.map +1 -1
  201. package/dist/core/parsers/markdown-parser.js +23 -30
  202. package/dist/core/parsers/markdown-parser.js.map +1 -1
  203. package/dist/core/parsers/requirement-blocks.d.ts +43 -15
  204. package/dist/core/parsers/requirement-blocks.d.ts.map +1 -1
  205. package/dist/core/parsers/requirement-blocks.js +142 -70
  206. package/dist/core/parsers/requirement-blocks.js.map +1 -1
  207. package/dist/core/parsers/requirement-text.d.ts +73 -79
  208. package/dist/core/parsers/requirement-text.d.ts.map +1 -1
  209. package/dist/core/parsers/requirement-text.js +137 -79
  210. package/dist/core/parsers/requirement-text.js.map +1 -1
  211. package/dist/core/parsers/spec-structure.d.ts.map +1 -1
  212. package/dist/core/parsers/spec-structure.js +10 -6
  213. package/dist/core/parsers/spec-structure.js.map +1 -1
  214. package/dist/core/profiles.d.ts +3 -10
  215. package/dist/core/profiles.d.ts.map +1 -1
  216. package/dist/core/profiles.js +5 -12
  217. package/dist/core/profiles.js.map +1 -1
  218. package/dist/core/project-config.d.ts +43 -44
  219. package/dist/core/project-config.d.ts.map +1 -1
  220. package/dist/core/project-config.js +107 -82
  221. package/dist/core/project-config.js.map +1 -1
  222. package/dist/core/project-layout.d.ts +9 -17
  223. package/dist/core/project-layout.d.ts.map +1 -1
  224. package/dist/core/project-layout.js +16 -26
  225. package/dist/core/project-layout.js.map +1 -1
  226. package/dist/core/root-selection.d.ts +8 -14
  227. package/dist/core/root-selection.d.ts.map +1 -1
  228. package/dist/core/root-selection.js +3 -6
  229. package/dist/core/root-selection.js.map +1 -1
  230. package/dist/core/rules.d.ts.map +1 -1
  231. package/dist/core/rules.js +2 -3
  232. package/dist/core/rules.js.map +1 -1
  233. package/dist/core/schema-names.d.ts +16 -0
  234. package/dist/core/schema-names.d.ts.map +1 -0
  235. package/dist/core/schema-names.js +16 -0
  236. package/dist/core/schema-names.js.map +1 -0
  237. package/dist/core/schemas/base.schema.d.ts.map +1 -1
  238. package/dist/core/schemas/base.schema.js +6 -12
  239. package/dist/core/schemas/base.schema.js.map +1 -1
  240. package/dist/core/schemas/change.schema.d.ts +8 -0
  241. package/dist/core/schemas/change.schema.d.ts.map +1 -1
  242. package/dist/core/schemas/change.schema.js +41 -10
  243. package/dist/core/schemas/change.schema.js.map +1 -1
  244. package/dist/core/shared/index.d.ts +2 -7
  245. package/dist/core/shared/index.d.ts.map +1 -1
  246. package/dist/core/shared/index.js +2 -7
  247. package/dist/core/shared/index.js.map +1 -1
  248. package/dist/core/shared/rules-generation.d.ts +5 -15
  249. package/dist/core/shared/rules-generation.d.ts.map +1 -1
  250. package/dist/core/shared/rules-generation.js +33 -37
  251. package/dist/core/shared/rules-generation.js.map +1 -1
  252. package/dist/core/shared/skill-generation.d.ts +28 -43
  253. package/dist/core/shared/skill-generation.d.ts.map +1 -1
  254. package/dist/core/shared/skill-generation.js +82 -51
  255. package/dist/core/shared/skill-generation.js.map +1 -1
  256. package/dist/core/shared/tool-detection.d.ts +35 -76
  257. package/dist/core/shared/tool-detection.d.ts.map +1 -1
  258. package/dist/core/shared/tool-detection.js +73 -93
  259. package/dist/core/shared/tool-detection.js.map +1 -1
  260. package/dist/core/skill-metrics.d.ts +36 -63
  261. package/dist/core/skill-metrics.d.ts.map +1 -1
  262. package/dist/core/skill-metrics.js +34 -73
  263. package/dist/core/skill-metrics.js.map +1 -1
  264. package/dist/core/spec-presenter.d.ts.map +1 -1
  265. package/dist/core/spec-presenter.js +5 -10
  266. package/dist/core/spec-presenter.js.map +1 -1
  267. package/dist/core/specs-apply.d.ts +16 -31
  268. package/dist/core/specs-apply.d.ts.map +1 -1
  269. package/dist/core/specs-apply.js +146 -195
  270. package/dist/core/specs-apply.js.map +1 -1
  271. package/dist/core/templates/fragments/interview.d.ts +2 -6
  272. package/dist/core/templates/fragments/interview.d.ts.map +1 -1
  273. package/dist/core/templates/fragments/interview.js +2 -6
  274. package/dist/core/templates/fragments/interview.js.map +1 -1
  275. package/dist/core/templates/fragments/next-step.d.ts +4 -8
  276. package/dist/core/templates/fragments/next-step.d.ts.map +1 -1
  277. package/dist/core/templates/fragments/next-step.js +4 -8
  278. package/dist/core/templates/fragments/next-step.js.map +1 -1
  279. package/dist/core/templates/fragments/validate.d.ts +13 -0
  280. package/dist/core/templates/fragments/validate.d.ts.map +1 -0
  281. package/dist/core/templates/fragments/validate.js +13 -0
  282. package/dist/core/templates/fragments/validate.js.map +1 -0
  283. package/dist/core/templates/fragments/verify.d.ts +9 -12
  284. package/dist/core/templates/fragments/verify.d.ts.map +1 -1
  285. package/dist/core/templates/fragments/verify.js +9 -12
  286. package/dist/core/templates/fragments/verify.js.map +1 -1
  287. package/dist/core/templates/index.d.ts +0 -6
  288. package/dist/core/templates/index.d.ts.map +1 -1
  289. package/dist/core/templates/index.js +0 -7
  290. package/dist/core/templates/index.js.map +1 -1
  291. package/dist/core/templates/skill-templates.d.ts +1 -5
  292. package/dist/core/templates/skill-templates.d.ts.map +1 -1
  293. package/dist/core/templates/skill-templates.js +0 -5
  294. package/dist/core/templates/skill-templates.js.map +1 -1
  295. package/dist/core/templates/types.d.ts +3 -7
  296. package/dist/core/templates/types.d.ts.map +1 -1
  297. package/dist/core/templates/types.js +0 -3
  298. package/dist/core/templates/types.js.map +1 -1
  299. package/dist/core/templates/workflows/apply.d.ts +3 -9
  300. package/dist/core/templates/workflows/apply.d.ts.map +1 -1
  301. package/dist/core/templates/workflows/apply.js +11 -13
  302. package/dist/core/templates/workflows/apply.js.map +1 -1
  303. package/dist/core/templates/workflows/archive.d.ts +0 -6
  304. package/dist/core/templates/workflows/archive.d.ts.map +1 -1
  305. package/dist/core/templates/workflows/archive.js +16 -5
  306. package/dist/core/templates/workflows/archive.js.map +1 -1
  307. package/dist/core/templates/workflows/decision.js +3 -3
  308. package/dist/core/templates/workflows/decision.js.map +1 -1
  309. package/dist/core/templates/workflows/explore.js +1 -1
  310. package/dist/core/templates/workflows/grill.d.ts.map +1 -1
  311. package/dist/core/templates/workflows/grill.js +0 -2
  312. package/dist/core/templates/workflows/grill.js.map +1 -1
  313. package/dist/core/templates/workflows/issue.d.ts +0 -6
  314. package/dist/core/templates/workflows/issue.d.ts.map +1 -1
  315. package/dist/core/templates/workflows/issue.js +3 -2
  316. package/dist/core/templates/workflows/issue.js.map +1 -1
  317. package/dist/core/templates/workflows/propose.d.ts +0 -6
  318. package/dist/core/templates/workflows/propose.d.ts.map +1 -1
  319. package/dist/core/templates/workflows/propose.js +2 -2
  320. package/dist/core/templates/workflows/propose.js.map +1 -1
  321. package/dist/core/templates/workflows/sync.d.ts +2 -8
  322. package/dist/core/templates/workflows/sync.d.ts.map +1 -1
  323. package/dist/core/templates/workflows/sync.js +4 -3
  324. package/dist/core/templates/workflows/sync.js.map +1 -1
  325. package/dist/core/templates/workflows/update.d.ts +0 -6
  326. package/dist/core/templates/workflows/update.d.ts.map +1 -1
  327. package/dist/core/templates/workflows/update.js +9 -2
  328. package/dist/core/templates/workflows/update.js.map +1 -1
  329. package/dist/core/update.d.ts +10 -36
  330. package/dist/core/update.d.ts.map +1 -1
  331. package/dist/core/update.js +59 -126
  332. package/dist/core/update.js.map +1 -1
  333. package/dist/core/user-state-migration.d.ts +13 -15
  334. package/dist/core/user-state-migration.d.ts.map +1 -1
  335. package/dist/core/user-state-migration.js +16 -20
  336. package/dist/core/user-state-migration.js.map +1 -1
  337. package/dist/core/validation/constants.d.ts +4 -10
  338. package/dist/core/validation/constants.d.ts.map +1 -1
  339. package/dist/core/validation/constants.js +25 -25
  340. package/dist/core/validation/constants.js.map +1 -1
  341. package/dist/core/validation/prose-length.d.ts +15 -0
  342. package/dist/core/validation/prose-length.d.ts.map +1 -0
  343. package/dist/core/validation/prose-length.js +29 -0
  344. package/dist/core/validation/prose-length.js.map +1 -0
  345. package/dist/core/validation/purpose-placeholder.d.ts +9 -16
  346. package/dist/core/validation/purpose-placeholder.d.ts.map +1 -1
  347. package/dist/core/validation/purpose-placeholder.js +30 -44
  348. package/dist/core/validation/purpose-placeholder.js.map +1 -1
  349. package/dist/core/validation/section-validator.d.ts +4 -4
  350. package/dist/core/validation/section-validator.d.ts.map +1 -1
  351. package/dist/core/validation/section-validator.js +30 -12
  352. package/dist/core/validation/section-validator.js.map +1 -1
  353. package/dist/core/validation/task-numbering.d.ts +6 -3
  354. package/dist/core/validation/task-numbering.d.ts.map +1 -1
  355. package/dist/core/validation/task-numbering.js +23 -11
  356. package/dist/core/validation/task-numbering.js.map +1 -1
  357. package/dist/core/validation/types.d.ts +18 -0
  358. package/dist/core/validation/types.d.ts.map +1 -1
  359. package/dist/core/validation/types.js +12 -1
  360. package/dist/core/validation/types.js.map +1 -1
  361. package/dist/core/validation/validator.d.ts +42 -68
  362. package/dist/core/validation/validator.d.ts.map +1 -1
  363. package/dist/core/validation/validator.js +468 -285
  364. package/dist/core/validation/validator.js.map +1 -1
  365. package/dist/prompts/searchable-multi-select.d.ts +3 -8
  366. package/dist/prompts/searchable-multi-select.d.ts.map +1 -1
  367. package/dist/prompts/searchable-multi-select.js +16 -39
  368. package/dist/prompts/searchable-multi-select.js.map +1 -1
  369. package/dist/utils/change-metadata.d.ts +11 -50
  370. package/dist/utils/change-metadata.d.ts.map +1 -1
  371. package/dist/utils/change-metadata.js +48 -67
  372. package/dist/utils/change-metadata.js.map +1 -1
  373. package/dist/utils/change-utils.d.ts +24 -76
  374. package/dist/utils/change-utils.d.ts.map +1 -1
  375. package/dist/utils/change-utils.js +95 -142
  376. package/dist/utils/change-utils.js.map +1 -1
  377. package/dist/utils/file-lock.d.ts +39 -0
  378. package/dist/utils/file-lock.d.ts.map +1 -0
  379. package/dist/utils/file-lock.js +149 -0
  380. package/dist/utils/file-lock.js.map +1 -0
  381. package/dist/utils/file-system.d.ts +12 -32
  382. package/dist/utils/file-system.d.ts.map +1 -1
  383. package/dist/utils/file-system.js +16 -40
  384. package/dist/utils/file-system.js.map +1 -1
  385. package/dist/utils/frontmatter.d.ts +7 -11
  386. package/dist/utils/frontmatter.d.ts.map +1 -1
  387. package/dist/utils/frontmatter.js +11 -11
  388. package/dist/utils/frontmatter.js.map +1 -1
  389. package/dist/utils/interactive.d.ts +4 -9
  390. package/dist/utils/interactive.d.ts.map +1 -1
  391. package/dist/utils/interactive.js +2 -4
  392. package/dist/utils/interactive.js.map +1 -1
  393. package/dist/utils/item-discovery.d.ts +10 -20
  394. package/dist/utils/item-discovery.d.ts.map +1 -1
  395. package/dist/utils/item-discovery.js +31 -55
  396. package/dist/utils/item-discovery.js.map +1 -1
  397. package/dist/utils/link.d.ts +9 -18
  398. package/dist/utils/link.d.ts.map +1 -1
  399. package/dist/utils/link.js +9 -18
  400. package/dist/utils/link.js.map +1 -1
  401. package/dist/utils/requirement-diff.d.ts +13 -23
  402. package/dist/utils/requirement-diff.d.ts.map +1 -1
  403. package/dist/utils/requirement-diff.js +13 -23
  404. package/dist/utils/requirement-diff.js.map +1 -1
  405. package/dist/utils/spec-files.d.ts +7 -20
  406. package/dist/utils/spec-files.d.ts.map +1 -1
  407. package/dist/utils/spec-files.js +26 -51
  408. package/dist/utils/spec-files.js.map +1 -1
  409. package/dist/utils/task-progress.d.ts +11 -9
  410. package/dist/utils/task-progress.d.ts.map +1 -1
  411. package/dist/utils/task-progress.js +53 -32
  412. package/dist/utils/task-progress.js.map +1 -1
  413. package/dist/utils/timestamp.d.ts +5 -8
  414. package/dist/utils/timestamp.d.ts.map +1 -1
  415. package/dist/utils/timestamp.js +5 -8
  416. package/dist/utils/timestamp.js.map +1 -1
  417. package/package.json +2 -3
  418. package/schemas/issue/schema.yaml +8 -1
  419. package/schemas/issue/templates/spec.md +20 -3
  420. package/schemas/sdd/schema.yaml +20 -1
  421. package/schemas/sdd/templates/spec.md +20 -3
@@ -1,13 +1,8 @@
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
8
  import { promises as fs, readFileSync, unlinkSync, watch } from 'node:fs';
@@ -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) {
@@ -218,14 +181,12 @@ async function readJsonBody(req, limit = 8192) {
218
181
  }
219
182
  }
220
183
  /**
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.
184
+ * Ticks a task checkbox: `{ file, line, done }`, `file` root-relative and `line`
185
+ * 1-based. Writes are confined to `<root>/tospec/changes/`, so the dashboard can
186
+ * never edit specs.
225
187
  *
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.
188
+ * Requiring `content-type: application/json` is load-bearing: it pushes any
189
+ * cross-origin POST into a CORS preflight this server does not answer.
229
190
  */
230
191
  async function handleTaskUpdate(projectRoot, req, res) {
231
192
  if (!/^application\/json\b/.test(req.headers['content-type'] ?? '')) {
@@ -249,30 +210,47 @@ async function handleTaskUpdate(projectRoot, req, res) {
249
210
  if (!resolved) {
250
211
  return sendJson(res, 403, { error: 'forbidden' });
251
212
  }
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.
213
+ // Before the sync gate: that gate asks whether a change is *becoming*
214
+ // immutable; an archived one already is.
254
215
  if (isArchivedChangePath(projectRoot, resolved)) {
255
216
  return sendJson(res, 403, { error: 'change is archived; tasks are immutable' });
256
217
  }
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.
218
+ // The checks above are lexical. A task file that is itself a symlink out of
219
+ // the project passes both, and the write would land outside the root, so the
220
+ // fence is re-checked on the real path. Before the tracked-file check below:
221
+ // that check canonicalizes its own side, so the symlink used to fail there
222
+ // as "not the tracked file" — refused, but for the wrong reason. A file that
223
+ // is gone by now is the 404 the write would have reported.
224
+ let realFile;
225
+ try {
226
+ const realRoot = await fs.realpath(projectRoot);
227
+ realFile = await fs.realpath(resolved);
228
+ if (!realFile.startsWith(realRoot + path.sep)) {
229
+ return sendJson(res, 403, { error: 'forbidden' });
230
+ }
231
+ }
232
+ catch (err) {
233
+ if (err.code === 'ENOENT')
234
+ return sendJson(res, 404, { error: 'not found' });
235
+ throw err;
236
+ }
237
+ // Freeze task edits once a change is sync certified. The frontend disable is
238
+ // only UX; this gate is what protects certified content from a raw POST. A
239
+ // read error reads as not certified.
260
240
  const changeName = changeNameFromTaskFile(projectRoot, resolved);
261
241
  if (changeName) {
262
242
  const changeDir = path.join(projectRoot, 'tospec', 'changes', changeName);
263
243
  if ((await readSyncConclusion(changeDir)) === 'PASS') {
264
244
  return sendJson(res, 409, { error: 'change is archiving; tasks are frozen' });
265
245
  }
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.
246
+ // Confined to the schema's tracked task file, via the resolver the read
247
+ // side uses. The path check above only proves the file sits inside a change
248
+ // directory, so any other markdown there with a checkbox on the addressed
249
+ // line was writable too, invisibly. The resolver canonicalizes the files it
250
+ // finds and leaves its `tasks.md` fallback as joined, so both spellings of
251
+ // the request path are compared.
274
252
  const tracked = resolveTaskFiles(changeDir, projectRoot).map((file) => path.resolve(file));
275
- if (!tracked.includes(path.resolve(resolved))) {
253
+ if (!tracked.includes(realFile) && !tracked.includes(path.resolve(resolved))) {
276
254
  return sendJson(res, 409, {
277
255
  error: `${path.basename(resolved)} is not this change's tracked task file`,
278
256
  });
@@ -283,8 +261,7 @@ async function handleTaskUpdate(projectRoot, req, res) {
283
261
  }
284
262
  catch (err) {
285
263
  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.
264
+ // Stale client view or a deleted file — neither is a server fault, so no 500.
288
265
  if (/not a task/.test(message))
289
266
  return sendJson(res, 409, { error: message });
290
267
  if (/ENOENT/.test(message))
@@ -293,10 +270,6 @@ async function handleTaskUpdate(projectRoot, req, res) {
293
270
  }
294
271
  sendJson(res, 200, { file, line, done });
295
272
  }
296
- // -----------------------------------------------------------------------------
297
- // Live updates (SSE)
298
- // -----------------------------------------------------------------------------
299
- /** Registers an SSE client and streams `change` events until it disconnects. */
300
273
  function handleEvents(req, res, clients) {
301
274
  res.writeHead(200, {
302
275
  'content-type': 'text/event-stream',
@@ -307,27 +280,18 @@ function handleEvents(req, res, clients) {
307
280
  clients.add(res);
308
281
  req.on('close', () => clients.delete(res));
309
282
  }
310
- /** Pushes a `change` event to every connected SSE client. */
311
283
  function broadcastChange(clients) {
312
284
  for (const client of clients) {
313
285
  client.write('event: change\ndata: {}\n\n');
314
286
  }
315
287
  }
316
- // -----------------------------------------------------------------------------
317
- // Router
318
- // -----------------------------------------------------------------------------
319
288
  /**
320
289
  * Second layer behind `renderMarkdownSafe`, applied to every response.
321
290
  *
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.
291
+ * `script-src 'self'` is the load-bearing directive: it refuses the inline
292
+ * handlers and `javascript:` URLs a markdown file would need to reach
293
+ * `POST /api/task` from inside this origin. `style-src` keeps `'unsafe-inline'`
294
+ * for Chart.js — a style injection cannot call the write endpoint.
331
295
  */
332
296
  function applyContentSecurityPolicy(res) {
333
297
  res.setHeader('content-security-policy', [
@@ -336,7 +300,6 @@ function applyContentSecurityPolicy(res) {
336
300
  "style-src 'self' 'unsafe-inline'",
337
301
  "img-src 'self' data:",
338
302
  "font-src 'self'",
339
- // EventSource on /api/events and every fetch of /api/*.
340
303
  "connect-src 'self'",
341
304
  "base-uri 'none'",
342
305
  "form-action 'none'",
@@ -360,8 +323,10 @@ async function handleRequest(projectRoot, req, res, clients, boundHost, acceptAn
360
323
  }
361
324
  if (pathname === '/api/events')
362
325
  return handleEvents(req, res, clients);
363
- if (pathname === '/')
326
+ // `/index.html` alongside `/`: nothing links to it, but typing it 404'd.
327
+ if (pathname === '/' || pathname === '/index.html') {
364
328
  return await serveAsset(res, ASSETS_DIR, 'index.html');
329
+ }
365
330
  if (pathname.startsWith('/assets/')) {
366
331
  return await serveAsset(res, ASSETS_DIR, pathname.slice('/assets/'.length));
367
332
  }
@@ -393,21 +358,14 @@ async function handleRequest(projectRoot, req, res, clients, boundHost, acceptAn
393
358
  sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
394
359
  }
395
360
  }
396
- // -----------------------------------------------------------------------------
397
- // Lifecycle
398
- // -----------------------------------------------------------------------------
399
361
  /**
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.
362
+ * Starts the server, advancing past `EADDRINUSE` until a port is free (D6) so
363
+ * dashboards for different projects coexist. Side-effect free (no logging,
364
+ * signal handlers or registry) so tests can drive it directly.
405
365
  */
406
366
  export async function startDashboardServer(projectRoot, opts = {}) {
407
367
  const host = opts.host ?? DEFAULT_HOST;
408
368
  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
369
  const acceptAnyHost = acceptsAnyHostHeader(host, opts.allowRemote);
412
370
  const clients = new Set();
413
371
  const server = http.createServer((req, res) => {
@@ -416,14 +374,13 @@ export async function startDashboardServer(projectRoot, opts = {}) {
416
374
  const actualPort = await listenFrom(server, opts.port ?? DEFAULT_PORT, host);
417
375
  const watcher = watchTospec(projectRoot, () => broadcastChange(clients));
418
376
  server.once('close', () => watcher?.close());
419
- return { server, url: `http://${host}:${actualPort}`, port: actualPort };
377
+ return { server, url: connectableUrl(host, actualPort), port: actualPort };
420
378
  }
421
379
  /**
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.
380
+ * Watches `<root>/tospec` recursively and calls `onChange` (debounced), or null
381
+ * if the directory can't be watched. `fs.watch` uses OS change notifications,
382
+ * so it never locks the watched files — agents and users stay free to edit or
383
+ * delete them (D2). Recursive watch is native on win32/macOS, Node 20+ on Linux.
427
384
  */
428
385
  function watchTospec(projectRoot, onChange) {
429
386
  const dir = path.join(projectRoot, 'tospec');
@@ -443,22 +400,19 @@ function watchTospec(projectRoot, onChange) {
443
400
  }
444
401
  }
445
402
  /**
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.
403
+ * PID-file location, keyed by project root so `--detach` and `--stop` derive the
404
+ * same path. In the OS temp dir, not the repo, so it never gets committed.
449
405
  */
450
406
  export function pidFilePath(projectRoot) {
451
407
  const hash = crypto.createHash('sha1').update(path.resolve(projectRoot)).digest('hex').slice(0, 12);
452
408
  return path.join(os.tmpdir(), `tospec-dashboard-${hash}.json`);
453
409
  }
454
- /** True if a process with this PID currently exists (signal 0 probes without killing). */
410
+ /** True if a process with this PID exists (signal 0 probes without killing). */
455
411
  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`.
412
+ // Every record's pid arrives here, so the sign check guards the module: on
413
+ // POSIX `kill(0, sig)` signals our own process group and `kill(-n, sig)` a
414
+ // named one, so a corrupt record in the shared temp dir would make `--stop`
415
+ // terminate the user's own shell.
462
416
  if (!Number.isInteger(pid) || pid <= 0)
463
417
  return false;
464
418
  try {
@@ -471,11 +425,8 @@ function isProcessAlive(pid) {
471
425
  }
472
426
  }
473
427
  /**
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`.
428
+ * This project's dashboard PID record, or null if absent or corrupt. `pid` is
429
+ * the only required field — a truncated record still names a process to reap.
479
430
  */
480
431
  async function readPidRecord(pidFile) {
481
432
  try {
@@ -487,32 +438,23 @@ async function readPidRecord(pidFile) {
487
438
  }
488
439
  }
489
440
  /**
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.
441
+ * Records this dashboard so `--stop` can find it. Reads tolerate the missing
442
+ * optional fields of pre-0.13 records; writes do not, since the root is what
443
+ * attributes a scanned record to its project.
493
444
  */
494
445
  async function writePidRecord(pidFile, rec) {
495
446
  await fs.writeFile(pidFile, JSON.stringify(rec), 'utf-8');
496
447
  }
497
448
  /**
498
- * Removes a pid record if it still names `pid`, so neither caller deletes a
499
- * record describing a different dashboard.
500
- *
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.
449
+ * Removes a pid record only if it still names `pid`: the path is keyed by
450
+ * project, not process, so two sibling dashboards share this file and either
451
+ * clearer would otherwise delete the other's record.
505
452
  *
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.
453
+ * Limitation: one record per project, so only the later writer is tracked by
454
+ * pid file; both stay reachable through their registry markers.
513
455
  *
514
456
  * Sync so it completes before the `process.exit` its signal-handler caller
515
- * races — which is why it does not reuse the async `readPidRecord`.
457
+ * races — hence not reusing the async `readPidRecord`.
516
458
  */
517
459
  export function clearPidRecordOwnedBy(pidFile, pid) {
518
460
  try {
@@ -521,9 +463,8 @@ export function clearPidRecordOwnedBy(pidFile, pid) {
521
463
  unlinkSync(pidFile);
522
464
  }
523
465
  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.
466
+ // Absent: nothing to do. Corrupt: left alone — it names no process, and a
467
+ // half-written record may belong to one that is starting up.
527
468
  }
528
469
  }
529
470
  /** Every project's pid file, not just this one's — they all live in the temp dir. */
@@ -531,7 +472,7 @@ async function pidFilePaths() {
531
472
  const dir = os.tmpdir();
532
473
  try {
533
474
  return (await fs.readdir(dir))
534
- // Exactly the shape pidFilePath() generates — never a wider set than we write.
475
+ // Exactly the shape pidFilePath() generates — never wider than we write.
535
476
  .filter((name) => /^tospec-dashboard-[0-9a-f]{12}\.json$/.test(name))
536
477
  .map((name) => path.join(dir, name));
537
478
  }
@@ -540,15 +481,13 @@ async function pidFilePaths() {
540
481
  }
541
482
  }
542
483
  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.
484
+ // Derived from the one config resolver, so relocating the config directory
485
+ // moves the registry with it.
546
486
  return path.join(getGlobalConfigDir(), 'dashboards');
547
487
  }
548
488
  /**
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.
489
+ * Lists live dashboards sorted by port, reaping any marker whose process has
490
+ * died. A missing or unreadable dir means no dashboards are running.
552
491
  */
553
492
  export async function listDashboards() {
554
493
  const dir = registryDir();
@@ -567,9 +506,8 @@ export async function listDashboards() {
567
506
  const pid = Number(m[1]);
568
507
  const file = path.join(dir, name);
569
508
  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.
509
+ // Best-effort: failing to unlink one stale marker must not take down a
510
+ // command that only wanted to list.
573
511
  await fs.rm(file, { force: true }).catch(() => { });
574
512
  continue;
575
513
  }
@@ -586,8 +524,7 @@ export async function listDashboards() {
586
524
  }
587
525
  /**
588
526
  * 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.
527
+ * The URL assumes loopback — `RunningDashboard` doesn't record the bound host.
591
528
  */
592
529
  export function formatRunningDashboard(d, sep = '\t') {
593
530
  const url = `http://${DEFAULT_HOST}:${d.port}`;
@@ -595,8 +532,7 @@ export function formatRunningDashboard(d, sep = '\t') {
595
532
  }
596
533
  /**
597
534
  * `--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`.
535
+ * its own diagnostic so the shared failure sink renders it.
600
536
  */
601
537
  export class DashboardStopError extends Error {
602
538
  diagnostic;
@@ -607,11 +543,9 @@ export class DashboardStopError extends Error {
607
543
  }
608
544
  }
609
545
  /**
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.
546
+ * The detached child neither reported itself listening nor exited. It may still
547
+ * be coming up, so the message reports what was not observed rather than
548
+ * claiming failure.
615
549
  */
616
550
  export class DashboardReadinessTimeoutError extends Error {
617
551
  diagnostic;
@@ -629,14 +563,12 @@ export class DashboardReadinessTimeoutError extends Error {
629
563
  }
630
564
  }
631
565
  /**
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.
566
+ * Every dashboard `--stop` can reach: the registry unioned with every project's
567
+ * pid file, keyed by pid, reaping dead records along the way.
634
568
  *
635
569
  * The registry alone is not enough — `listDashboards()` reaps any marker whose
636
570
  * 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.
571
+ * while its pid file still names it.
640
572
  *
641
573
  * `ownRootHint` names records written before pid files carried their own root;
642
574
  * it never restricts the result.
@@ -650,14 +582,10 @@ export async function collectStoppableDashboards(ownRootHint) {
650
582
  const rec = await readPidRecord(file);
651
583
  if (!rec)
652
584
  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.
585
+ // Reap like the marker reaper does: a crashed dashboard's record would
586
+ // otherwise linger until the OS recycled its pid, then read as alive again —
587
+ // a phantom `--stop` would kill an unrelated process for. Unlink is
588
+ // best-effort: the POSIX temp dir is shared with other users.
661
589
  if (!isProcessAlive(rec.pid)) {
662
590
  await fs.rm(file, { force: true }).catch(() => { });
663
591
  continue;
@@ -671,15 +599,15 @@ export async function collectStoppableDashboards(ownRootHint) {
671
599
  byPid.set(rec.pid, { pid: rec.pid, port: rec.port, projectRoot: root });
672
600
  continue;
673
601
  }
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.
602
+ // A content-less registry marker never matches its project; the pid file may
603
+ // still know the root.
676
604
  if (!known.projectRoot && root)
677
605
  byPid.set(rec.pid, { ...known, projectRoot: root });
678
606
  }
679
607
  return [...byPid.values()].sort((a, b) => a.port - b.port);
680
608
  }
681
609
  /**
682
- * Ports held by live dashboards (reaps dead markers as a side effect). Runs at
610
+ * Ports held by live dashboards, reaping dead markers as a side effect. Runs at
683
611
  * every startup so a crashed dashboard's port frees up.
684
612
  */
685
613
  async function reapAndCollectPorts() {
@@ -702,59 +630,40 @@ async function unregisterDashboard(pid) {
702
630
  }
703
631
  await Promise.all(names
704
632
  .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.
633
+ // Best-effort: a marker we cannot unlink must not abort the stop loop.
707
634
  .map((n) => fs.rm(path.join(dir, n), { force: true }).catch(() => { })));
708
635
  }
709
636
  /**
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.
637
+ * Launches the dashboard as a detached background process: re-runs this CLI
638
+ * without `--detach` (the child is the actual server), then waits for the
639
+ * child's `LISTENING` report, so the URL printed is one the child actually
640
+ * bound — including after an EADDRINUSE fallback moved it off the predicted
641
+ * port. Order: spawn → wait for the report → write the PID record → print.
716
642
  *
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.
643
+ * Refuses if any live dashboard already serves this project, asking the shared
644
+ * candidate set because either record can be the only one left (a foreground
645
+ * dashboard writes no pid file; a background one can lose its registry marker).
723
646
  *
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).
647
+ * This process and the child both write the PID record, with identical fields.
648
+ * The child's is all that remains if this process dies mid-handshake; this one
649
+ * makes a record exist by the time the call returns, since the child writes only
650
+ * after signalling.
737
651
  *
738
652
  * 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.
653
+ * `tospec dashboard`, so "one dashboard per project" is not machine-wide.
741
654
  */
742
655
  export async function detachDashboard(projectRoot, cliEntry, opts) {
743
656
  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
657
  const running = shouldRefuseDetach(await collectStoppableDashboards(projectRoot), projectRoot);
747
658
  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.
659
+ // Same fields and separator as `--list` and `--stop`'s picker.
750
660
  console.log('Dashboard already running for this project.');
751
661
  console.log(` ${formatRunningDashboard(running, ' ')}`);
752
662
  console.log('Stop it with: tospec dashboard --stop');
753
663
  return;
754
664
  }
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.
665
+ // Predict the port so the message and provisional record are accurate. The
666
+ // child re-checks and binds, so a race for this port just shifts it upward.
758
667
  const used = await reapAndCollectPorts();
759
668
  let port = opts.port;
760
669
  while (used.has(port))
@@ -767,41 +676,32 @@ export async function detachDashboard(projectRoot, cliEntry, opts) {
767
676
  args.push('--allow-remote');
768
677
  const child = spawn(process.execPath, args, {
769
678
  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.
679
+ // stderr is piped so the parent can read the child's LISTENING report or
680
+ // its error text on an early exit (waitForListening).
774
681
  stdio: ['ignore', 'ignore', 'pipe'],
775
682
  // On Windows, detached: true otherwise pops a new console window.
776
683
  windowsHide: true,
777
684
  env: { ...process.env, TOSPEC_DASHBOARD_PIDFILE: pidFile },
778
685
  });
779
- // No pid means the spawn itself failed; say so rather than report success and
780
- // leave behind a record `--stop` could never use.
686
+ // No pid means the spawn itself failed; reporting success would leave a record
687
+ // `--stop` could never use.
781
688
  if (child.pid === undefined) {
782
689
  throw new Error('Could not start the background dashboard process.');
783
690
  }
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'.
691
+ // Everything that can go wrong after a successful spawn happens inside the
692
+ // child and is only observable through this handshake.
788
693
  let confirmedUrl;
789
694
  try {
790
695
  confirmedUrl = await waitForListening(child);
791
696
  }
792
697
  finally {
793
698
  // 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.
699
+ // un-unref'd handle would keep this process's event loop alive.
796
700
  child.unref();
797
701
  }
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.
702
+ // Derived from the URL the child reported, so this record and the child's own
703
+ // agree whichever lands last. Limitation: no cross-process lock, so two
704
+ // simultaneous --detach could both pass the guard.
805
705
  await writePidRecord(pidFile, {
806
706
  pid: child.pid,
807
707
  port: portFromUrl(confirmedUrl) ?? port,
@@ -817,16 +717,13 @@ function portFromUrl(url) {
817
717
  return match ? Number(match[1]) : null;
818
718
  }
819
719
  /**
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.
720
+ * Races the child's outcome: resolves with the confirmed URL on
721
+ * `LISTENING <url>`, rejects with its captured stderr if it exits first, and
722
+ * rejects on timeout if it does neither. Closes the stderr pipe once settled so
723
+ * our end cannot keep this process's event loop alive.
825
724
  *
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.
725
+ * The timeout is a real third outcome: a child that binds then wedges emits
726
+ * neither event, and `--detach` would hang with no output.
830
727
  */
831
728
  function waitForListening(child, timeoutMs = READINESS_TIMEOUT_MS) {
832
729
  return new Promise((resolve, reject) => {
@@ -855,11 +752,8 @@ function waitForListening(child, timeoutMs = READINESS_TIMEOUT_MS) {
855
752
  settled = true;
856
753
  cleanup();
857
754
  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.
755
+ // The child already formatted its failure as `Error: <message>`;
756
+ // carrying that through made the parent print "Error: Error: ...".
863
757
  stripErrorPrefix(stderr.trim()) ||
864
758
  `The background dashboard process exited before it started listening (code ${code}).`));
865
759
  };
@@ -881,12 +775,9 @@ export function samePath(a, b) {
881
775
  return process.platform === 'win32' ? na.toLowerCase() === nb.toLowerCase() : na === nb;
882
776
  }
883
777
  /**
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.
778
+ * The dashboards `--stop` may terminate without asking. Empty is the caller's
779
+ * cue to offer the pick-from-all list. A candidate with no recorded project
780
+ * never matches: `path.resolve('')` is the cwd, so a blank root would claim it.
890
781
  */
891
782
  export function selectStopTargets(candidates, projectRoot) {
892
783
  if (!projectRoot)
@@ -899,55 +790,46 @@ export function selectStopTargets(candidates, projectRoot) {
899
790
  * `tospec/decisions/20260727_232950-one-dashboard-per-project.md`.
900
791
  *
901
792
  * 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.
793
+ * the two can never disagree about what is running.
906
794
  */
907
795
  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.
796
+ // First match only: one is enough to refuse and point at.
911
797
  return selectStopTargets(candidates, projectRoot)[0] ?? null;
912
798
  }
913
799
  /**
914
800
  * 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
801
+ * per-project pid file if that still names this pid. A dead pid counts as
916
802
  * already stopped.
917
803
  *
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.
804
+ * Returns null on success, or the reason the signal was refused — the POSIX temp
805
+ * dir is shared, so a candidate can belong to another user.
922
806
  *
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.
807
+ * Limitation: SIGTERM; Windows maps it to TerminateProcess (hard kill), so the
808
+ * target's own cleanup won't run — we clear its files here.
925
809
  */
926
810
  export async function stopRunningDashboard(d) {
927
811
  try {
928
812
  process.kill(d.pid);
929
813
  }
930
814
  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.
815
+ // ESRCH means it exited between listing and now: already stopped, so still
816
+ // clear its records. Anything else (EPERM) is a real refusal.
933
817
  const { code, message } = err;
934
818
  if (code !== 'ESRCH')
935
819
  return code ?? message;
936
820
  }
937
821
  await unregisterDashboard(d.pid);
938
822
  // 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.
823
+ // two siblings must not clear the other's.
940
824
  if (d.projectRoot)
941
825
  clearPidRecordOwnedBy(pidFilePath(d.projectRoot), d.pid);
942
826
  return null;
943
827
  }
944
828
  /**
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.
829
+ * CLI entry: allocates a free port, starts the server, prints the URL, and stays
830
+ * up until terminated. Port allocation reaps dead registry markers and skips
831
+ * ports live dashboards hold; the bind then advances past any the registry
832
+ * didn't know about.
951
833
  */
952
834
  export async function runDashboard(projectRoot, opts = {}) {
953
835
  const used = await reapAndCollectPorts();
@@ -956,19 +838,16 @@ export async function runDashboard(projectRoot, opts = {}) {
956
838
  start++;
957
839
  const { server, url, port } = await startDashboardServer(projectRoot, { ...opts, port: start });
958
840
  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.
841
+ // Detached child only: `detachDashboard` waits on stderr for exactly this line
842
+ // before it reports anything. A foreground run has no parent watching.
962
843
  if (process.env.TOSPEC_DASHBOARD_PIDFILE) {
963
844
  console.error(`${LISTENING_PREFIX}${url}`);
964
845
  }
965
846
  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.
847
+ // Every dashboard records itself, foreground included: `--detach` can only
848
+ // refuse for one that left a record, and a registry marker alone can be
849
+ // reaped out from under a live server. The child is handed its parent's path
850
+ // so the two cannot disagree about where the file lives.
972
851
  const pidFile = process.env.TOSPEC_DASHBOARD_PIDFILE ?? pidFilePath(projectRoot);
973
852
  await writePidRecord(pidFile, {
974
853
  pid: process.pid,
@@ -979,10 +858,8 @@ export async function runDashboard(projectRoot, opts = {}) {
979
858
  if (opts.open) {
980
859
  openBrowser(url);
981
860
  }
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.
861
+ // Normal termination removes this dashboard's own records. Windows hard-kills
862
+ // on `--stop`, so there `stopRunningDashboard` clears them instead.
986
863
  const cleanup = () => {
987
864
  // SSE keep-alive sockets would otherwise block close() from ever draining.
988
865
  server.closeAllConnections?.();