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