@seanmars/tospec 0.19.0-beta.0 → 0.19.0-beta.13

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