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