@seanmars/tospec 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (332) hide show
  1. package/CHANGELOG.md +243 -0
  2. package/README.md +10 -6
  3. package/assets/dashboard/lib/derive.js +11 -5
  4. package/assets/dashboard/lib/store.js +4 -2
  5. package/assets/dashboard/views/change.js +5 -3
  6. package/dist/cli/index.d.ts.map +1 -1
  7. package/dist/cli/index.js +42 -7
  8. package/dist/cli/index.js.map +1 -1
  9. package/dist/commands/config.d.ts.map +1 -1
  10. package/dist/commands/config.js +213 -100
  11. package/dist/commands/config.js.map +1 -1
  12. package/dist/commands/dashboard.d.ts +9 -0
  13. package/dist/commands/dashboard.d.ts.map +1 -1
  14. package/dist/commands/dashboard.js +77 -21
  15. package/dist/commands/dashboard.js.map +1 -1
  16. package/dist/commands/decision.d.ts +38 -1
  17. package/dist/commands/decision.d.ts.map +1 -1
  18. package/dist/commands/decision.js +303 -27
  19. package/dist/commands/decision.js.map +1 -1
  20. package/dist/commands/metrics.d.ts +3 -4
  21. package/dist/commands/metrics.d.ts.map +1 -1
  22. package/dist/commands/metrics.js +3 -5
  23. package/dist/commands/metrics.js.map +1 -1
  24. package/dist/commands/shared-output.d.ts +24 -0
  25. package/dist/commands/shared-output.d.ts.map +1 -1
  26. package/dist/commands/shared-output.js +21 -2
  27. package/dist/commands/shared-output.js.map +1 -1
  28. package/dist/commands/show.d.ts.map +1 -1
  29. package/dist/commands/show.js +12 -9
  30. package/dist/commands/show.js.map +1 -1
  31. package/dist/commands/validate.d.ts +19 -0
  32. package/dist/commands/validate.d.ts.map +1 -1
  33. package/dist/commands/validate.js +243 -64
  34. package/dist/commands/validate.js.map +1 -1
  35. package/dist/commands/workflow/instructions.d.ts +7 -1
  36. package/dist/commands/workflow/instructions.d.ts.map +1 -1
  37. package/dist/commands/workflow/instructions.js +299 -81
  38. package/dist/commands/workflow/instructions.js.map +1 -1
  39. package/dist/commands/workflow/new-change.d.ts.map +1 -1
  40. package/dist/commands/workflow/new-change.js +26 -17
  41. package/dist/commands/workflow/new-change.js.map +1 -1
  42. package/dist/commands/workflow/prompt-escape.d.ts +25 -0
  43. package/dist/commands/workflow/prompt-escape.d.ts.map +1 -0
  44. package/dist/commands/workflow/prompt-escape.js +62 -0
  45. package/dist/commands/workflow/prompt-escape.js.map +1 -0
  46. package/dist/commands/workflow/schemas.d.ts.map +1 -1
  47. package/dist/commands/workflow/schemas.js +34 -16
  48. package/dist/commands/workflow/schemas.js.map +1 -1
  49. package/dist/commands/workflow/shared.d.ts +46 -10
  50. package/dist/commands/workflow/shared.d.ts.map +1 -1
  51. package/dist/commands/workflow/shared.js +66 -30
  52. package/dist/commands/workflow/shared.js.map +1 -1
  53. package/dist/commands/workflow/status.d.ts +5 -2
  54. package/dist/commands/workflow/status.d.ts.map +1 -1
  55. package/dist/commands/workflow/status.js +62 -28
  56. package/dist/commands/workflow/status.js.map +1 -1
  57. package/dist/core/archive.d.ts +38 -0
  58. package/dist/core/archive.d.ts.map +1 -1
  59. package/dist/core/archive.js +492 -75
  60. package/dist/core/archive.js.map +1 -1
  61. package/dist/core/artifact-graph/index.d.ts +2 -2
  62. package/dist/core/artifact-graph/index.d.ts.map +1 -1
  63. package/dist/core/artifact-graph/index.js +2 -2
  64. package/dist/core/artifact-graph/index.js.map +1 -1
  65. package/dist/core/artifact-graph/instruction-loader.d.ts +8 -0
  66. package/dist/core/artifact-graph/instruction-loader.d.ts.map +1 -1
  67. package/dist/core/artifact-graph/instruction-loader.js +58 -36
  68. package/dist/core/artifact-graph/instruction-loader.js.map +1 -1
  69. package/dist/core/artifact-graph/resolver.d.ts +39 -12
  70. package/dist/core/artifact-graph/resolver.d.ts.map +1 -1
  71. package/dist/core/artifact-graph/resolver.js +82 -36
  72. package/dist/core/artifact-graph/resolver.js.map +1 -1
  73. package/dist/core/artifact-graph/schema.d.ts +24 -0
  74. package/dist/core/artifact-graph/schema.d.ts.map +1 -1
  75. package/dist/core/artifact-graph/schema.js +32 -1
  76. package/dist/core/artifact-graph/schema.js.map +1 -1
  77. package/dist/core/artifact-graph/stub-detection.d.ts +10 -2
  78. package/dist/core/artifact-graph/stub-detection.d.ts.map +1 -1
  79. package/dist/core/artifact-graph/stub-detection.js +18 -11
  80. package/dist/core/artifact-graph/stub-detection.js.map +1 -1
  81. package/dist/core/artifact-graph/types.d.ts +2 -2
  82. package/dist/core/artifact-graph/types.d.ts.map +1 -1
  83. package/dist/core/artifact-graph/types.js +10 -1
  84. package/dist/core/artifact-graph/types.js.map +1 -1
  85. package/dist/core/change-metadata/schema.d.ts.map +1 -1
  86. package/dist/core/change-metadata/schema.js +18 -4
  87. package/dist/core/change-metadata/schema.js.map +1 -1
  88. package/dist/core/change-presenter.d.ts.map +1 -1
  89. package/dist/core/change-presenter.js +13 -8
  90. package/dist/core/change-presenter.js.map +1 -1
  91. package/dist/core/change-status-policy.d.ts.map +1 -1
  92. package/dist/core/change-status-policy.js +3 -1
  93. package/dist/core/change-status-policy.js.map +1 -1
  94. package/dist/core/codex-metrics.d.ts +1 -1
  95. package/dist/core/codex-metrics.js +1 -1
  96. package/dist/core/config-schema.d.ts +8 -0
  97. package/dist/core/config-schema.d.ts.map +1 -1
  98. package/dist/core/config-schema.js +10 -0
  99. package/dist/core/config-schema.js.map +1 -1
  100. package/dist/core/dashboard-activity.d.ts +4 -4
  101. package/dist/core/dashboard-activity.js +4 -4
  102. package/dist/core/dashboard-data.d.ts +11 -5
  103. package/dist/core/dashboard-data.d.ts.map +1 -1
  104. package/dist/core/dashboard-data.js +54 -28
  105. package/dist/core/dashboard-data.js.map +1 -1
  106. package/dist/core/dashboard-hub.d.ts +3 -3
  107. package/dist/core/dashboard-hub.d.ts.map +1 -1
  108. package/dist/core/dashboard-hub.js +16 -4
  109. package/dist/core/dashboard-hub.js.map +1 -1
  110. package/dist/core/dashboard-routes.d.ts +2 -1
  111. package/dist/core/dashboard-routes.d.ts.map +1 -1
  112. package/dist/core/dashboard-routes.js +102 -33
  113. package/dist/core/dashboard-routes.js.map +1 -1
  114. package/dist/core/global-config.d.ts +71 -2
  115. package/dist/core/global-config.d.ts.map +1 -1
  116. package/dist/core/global-config.js +149 -4
  117. package/dist/core/global-config.js.map +1 -1
  118. package/dist/core/id.d.ts +9 -0
  119. package/dist/core/id.d.ts.map +1 -1
  120. package/dist/core/id.js +37 -0
  121. package/dist/core/id.js.map +1 -1
  122. package/dist/core/init.d.ts +9 -2
  123. package/dist/core/init.d.ts.map +1 -1
  124. package/dist/core/init.js +116 -33
  125. package/dist/core/init.js.map +1 -1
  126. package/dist/core/list.d.ts.map +1 -1
  127. package/dist/core/list.js +69 -45
  128. package/dist/core/list.js.map +1 -1
  129. package/dist/core/local-server.d.ts +9 -2
  130. package/dist/core/local-server.d.ts.map +1 -1
  131. package/dist/core/local-server.js +24 -4
  132. package/dist/core/local-server.js.map +1 -1
  133. package/dist/core/markdown-render.d.ts.map +1 -1
  134. package/dist/core/markdown-render.js +7 -2
  135. package/dist/core/markdown-render.js.map +1 -1
  136. package/dist/core/migrate.d.ts +24 -2
  137. package/dist/core/migrate.d.ts.map +1 -1
  138. package/dist/core/migrate.js +335 -59
  139. package/dist/core/migrate.js.map +1 -1
  140. package/dist/core/parsers/change-parser.d.ts +20 -1
  141. package/dist/core/parsers/change-parser.d.ts.map +1 -1
  142. package/dist/core/parsers/change-parser.js +76 -73
  143. package/dist/core/parsers/change-parser.js.map +1 -1
  144. package/dist/core/parsers/markdown-parser.d.ts +24 -0
  145. package/dist/core/parsers/markdown-parser.d.ts.map +1 -1
  146. package/dist/core/parsers/markdown-parser.js +65 -10
  147. package/dist/core/parsers/markdown-parser.js.map +1 -1
  148. package/dist/core/parsers/requirement-blocks.d.ts +127 -6
  149. package/dist/core/parsers/requirement-blocks.d.ts.map +1 -1
  150. package/dist/core/parsers/requirement-blocks.js +349 -56
  151. package/dist/core/parsers/requirement-blocks.js.map +1 -1
  152. package/dist/core/parsers/requirement-text.d.ts +25 -1
  153. package/dist/core/parsers/requirement-text.d.ts.map +1 -1
  154. package/dist/core/parsers/requirement-text.js +62 -6
  155. package/dist/core/parsers/requirement-text.js.map +1 -1
  156. package/dist/core/parsers/spec-structure.d.ts.map +1 -1
  157. package/dist/core/parsers/spec-structure.js +36 -24
  158. package/dist/core/parsers/spec-structure.js.map +1 -1
  159. package/dist/core/project-config.d.ts +9 -2
  160. package/dist/core/project-config.d.ts.map +1 -1
  161. package/dist/core/project-config.js +30 -7
  162. package/dist/core/project-config.js.map +1 -1
  163. package/dist/core/project-layout.d.ts +26 -0
  164. package/dist/core/project-layout.d.ts.map +1 -1
  165. package/dist/core/project-layout.js +51 -1
  166. package/dist/core/project-layout.js.map +1 -1
  167. package/dist/core/schema-names.d.ts +17 -0
  168. package/dist/core/schema-names.d.ts.map +1 -1
  169. package/dist/core/schema-names.js +24 -0
  170. package/dist/core/schema-names.js.map +1 -1
  171. package/dist/core/shared/index.d.ts +1 -1
  172. package/dist/core/shared/index.d.ts.map +1 -1
  173. package/dist/core/shared/index.js +1 -1
  174. package/dist/core/shared/index.js.map +1 -1
  175. package/dist/core/shared/rules-generation.d.ts +12 -0
  176. package/dist/core/shared/rules-generation.d.ts.map +1 -1
  177. package/dist/core/shared/rules-generation.js +26 -2
  178. package/dist/core/shared/rules-generation.js.map +1 -1
  179. package/dist/core/shared/skill-generation.d.ts +7 -0
  180. package/dist/core/shared/skill-generation.d.ts.map +1 -1
  181. package/dist/core/shared/skill-generation.js +19 -0
  182. package/dist/core/shared/skill-generation.js.map +1 -1
  183. package/dist/core/shared/tool-detection.d.ts +10 -1
  184. package/dist/core/shared/tool-detection.d.ts.map +1 -1
  185. package/dist/core/shared/tool-detection.js +37 -6
  186. package/dist/core/shared/tool-detection.js.map +1 -1
  187. package/dist/core/spec-presenter.d.ts.map +1 -1
  188. package/dist/core/spec-presenter.js +23 -1
  189. package/dist/core/spec-presenter.js.map +1 -1
  190. package/dist/core/specs-apply.d.ts +98 -1
  191. package/dist/core/specs-apply.d.ts.map +1 -1
  192. package/dist/core/specs-apply.js +426 -63
  193. package/dist/core/specs-apply.js.map +1 -1
  194. package/dist/core/templates/fragments/change-diff.d.ts +37 -0
  195. package/dist/core/templates/fragments/change-diff.d.ts.map +1 -0
  196. package/dist/core/templates/fragments/change-diff.js +37 -0
  197. package/dist/core/templates/fragments/change-diff.js.map +1 -0
  198. package/dist/core/templates/fragments/sync-fail.d.ts +8 -0
  199. package/dist/core/templates/fragments/sync-fail.d.ts.map +1 -0
  200. package/dist/core/templates/fragments/sync-fail.js +8 -0
  201. package/dist/core/templates/fragments/sync-fail.js.map +1 -0
  202. package/dist/core/templates/fragments/validate.d.ts +14 -1
  203. package/dist/core/templates/fragments/validate.d.ts.map +1 -1
  204. package/dist/core/templates/fragments/validate.js +14 -1
  205. package/dist/core/templates/fragments/validate.js.map +1 -1
  206. package/dist/core/templates/fragments/verify.d.ts +2 -2
  207. package/dist/core/templates/fragments/verify.d.ts.map +1 -1
  208. package/dist/core/templates/fragments/verify.js +8 -5
  209. package/dist/core/templates/fragments/verify.js.map +1 -1
  210. package/dist/core/templates/workflows/apply.d.ts +1 -1
  211. package/dist/core/templates/workflows/apply.d.ts.map +1 -1
  212. package/dist/core/templates/workflows/apply.js +29 -10
  213. package/dist/core/templates/workflows/apply.js.map +1 -1
  214. package/dist/core/templates/workflows/archive.d.ts.map +1 -1
  215. package/dist/core/templates/workflows/archive.js +27 -9
  216. package/dist/core/templates/workflows/archive.js.map +1 -1
  217. package/dist/core/templates/workflows/decision.d.ts.map +1 -1
  218. package/dist/core/templates/workflows/decision.js +10 -4
  219. package/dist/core/templates/workflows/decision.js.map +1 -1
  220. package/dist/core/templates/workflows/explore.d.ts +2 -2
  221. package/dist/core/templates/workflows/explore.d.ts.map +1 -1
  222. package/dist/core/templates/workflows/explore.js +7 -3
  223. package/dist/core/templates/workflows/explore.js.map +1 -1
  224. package/dist/core/templates/workflows/grill.d.ts +2 -2
  225. package/dist/core/templates/workflows/grill.d.ts.map +1 -1
  226. package/dist/core/templates/workflows/grill.js +5 -4
  227. package/dist/core/templates/workflows/grill.js.map +1 -1
  228. package/dist/core/templates/workflows/issue.d.ts.map +1 -1
  229. package/dist/core/templates/workflows/issue.js +9 -3
  230. package/dist/core/templates/workflows/issue.js.map +1 -1
  231. package/dist/core/templates/workflows/propose.d.ts.map +1 -1
  232. package/dist/core/templates/workflows/propose.js +4 -1
  233. package/dist/core/templates/workflows/propose.js.map +1 -1
  234. package/dist/core/templates/workflows/sync.d.ts.map +1 -1
  235. package/dist/core/templates/workflows/sync.js +25 -11
  236. package/dist/core/templates/workflows/sync.js.map +1 -1
  237. package/dist/core/templates/workflows/update.d.ts.map +1 -1
  238. package/dist/core/templates/workflows/update.js +17 -10
  239. package/dist/core/templates/workflows/update.js.map +1 -1
  240. package/dist/core/update.d.ts +5 -0
  241. package/dist/core/update.d.ts.map +1 -1
  242. package/dist/core/update.js +66 -29
  243. package/dist/core/update.js.map +1 -1
  244. package/dist/core/validation/constants.d.ts +7 -1
  245. package/dist/core/validation/constants.d.ts.map +1 -1
  246. package/dist/core/validation/constants.js +16 -2
  247. package/dist/core/validation/constants.js.map +1 -1
  248. package/dist/core/validation/purpose-placeholder.d.ts.map +1 -1
  249. package/dist/core/validation/purpose-placeholder.js +35 -11
  250. package/dist/core/validation/purpose-placeholder.js.map +1 -1
  251. package/dist/core/validation/section-validator.d.ts +7 -0
  252. package/dist/core/validation/section-validator.d.ts.map +1 -1
  253. package/dist/core/validation/section-validator.js +22 -2
  254. package/dist/core/validation/section-validator.js.map +1 -1
  255. package/dist/core/validation/sister-changes.d.ts +93 -0
  256. package/dist/core/validation/sister-changes.d.ts.map +1 -0
  257. package/dist/core/validation/sister-changes.js +180 -0
  258. package/dist/core/validation/sister-changes.js.map +1 -0
  259. package/dist/core/validation/task-markers.d.ts +9 -0
  260. package/dist/core/validation/task-markers.d.ts.map +1 -0
  261. package/dist/core/validation/task-markers.js +71 -0
  262. package/dist/core/validation/task-markers.js.map +1 -0
  263. package/dist/core/validation/types.d.ts +58 -0
  264. package/dist/core/validation/types.d.ts.map +1 -1
  265. package/dist/core/validation/types.js +22 -0
  266. package/dist/core/validation/types.js.map +1 -1
  267. package/dist/core/validation/validator.d.ts +79 -2
  268. package/dist/core/validation/validator.d.ts.map +1 -1
  269. package/dist/core/validation/validator.js +565 -84
  270. package/dist/core/validation/validator.js.map +1 -1
  271. package/dist/utils/change-metadata.d.ts +62 -5
  272. package/dist/utils/change-metadata.d.ts.map +1 -1
  273. package/dist/utils/change-metadata.js +193 -31
  274. package/dist/utils/change-metadata.js.map +1 -1
  275. package/dist/utils/change-utils.d.ts +21 -0
  276. package/dist/utils/change-utils.d.ts.map +1 -1
  277. package/dist/utils/change-utils.js +168 -53
  278. package/dist/utils/change-utils.js.map +1 -1
  279. package/dist/utils/command-line.d.ts +11 -0
  280. package/dist/utils/command-line.d.ts.map +1 -0
  281. package/dist/utils/command-line.js +43 -0
  282. package/dist/utils/command-line.js.map +1 -0
  283. package/dist/utils/display-width.d.ts +10 -0
  284. package/dist/utils/display-width.d.ts.map +1 -0
  285. package/dist/utils/display-width.js +15 -0
  286. package/dist/utils/display-width.js.map +1 -0
  287. package/dist/utils/file-system.d.ts +16 -0
  288. package/dist/utils/file-system.d.ts.map +1 -1
  289. package/dist/utils/file-system.js +49 -2
  290. package/dist/utils/file-system.js.map +1 -1
  291. package/dist/utils/frontmatter.d.ts.map +1 -1
  292. package/dist/utils/frontmatter.js +11 -5
  293. package/dist/utils/frontmatter.js.map +1 -1
  294. package/dist/utils/interactive.d.ts.map +1 -1
  295. package/dist/utils/interactive.js +3 -1
  296. package/dist/utils/interactive.js.map +1 -1
  297. package/dist/utils/item-discovery.d.ts +3 -2
  298. package/dist/utils/item-discovery.d.ts.map +1 -1
  299. package/dist/utils/item-discovery.js +34 -16
  300. package/dist/utils/item-discovery.js.map +1 -1
  301. package/dist/utils/link.d.ts +0 -1
  302. package/dist/utils/link.d.ts.map +1 -1
  303. package/dist/utils/link.js +0 -1
  304. package/dist/utils/link.js.map +1 -1
  305. package/dist/utils/match.d.ts.map +1 -1
  306. package/dist/utils/match.js +10 -1
  307. package/dist/utils/match.js.map +1 -1
  308. package/dist/utils/option-flags.d.ts +9 -0
  309. package/dist/utils/option-flags.d.ts.map +1 -0
  310. package/dist/utils/option-flags.js +12 -0
  311. package/dist/utils/option-flags.js.map +1 -0
  312. package/dist/utils/sentence.d.ts +9 -0
  313. package/dist/utils/sentence.d.ts.map +1 -0
  314. package/dist/utils/sentence.js +12 -0
  315. package/dist/utils/sentence.js.map +1 -0
  316. package/dist/utils/spec-files.d.ts +84 -1
  317. package/dist/utils/spec-files.d.ts.map +1 -1
  318. package/dist/utils/spec-files.js +192 -3
  319. package/dist/utils/spec-files.js.map +1 -1
  320. package/dist/utils/task-progress.d.ts.map +1 -1
  321. package/dist/utils/task-progress.js +32 -9
  322. package/dist/utils/task-progress.js.map +1 -1
  323. package/dist/utils/timestamp.d.ts +27 -0
  324. package/dist/utils/timestamp.d.ts.map +1 -1
  325. package/dist/utils/timestamp.js +37 -0
  326. package/dist/utils/timestamp.js.map +1 -1
  327. package/package.json +4 -1
  328. package/schemas/decision/templates/decision.md +2 -2
  329. package/schemas/issue/schema.yaml +12 -6
  330. package/schemas/issue/templates/task.md +1 -1
  331. package/schemas/sdd/schema.yaml +21 -10
  332. package/schemas/sdd/templates/proposal.md +1 -1
@@ -1,13 +1,15 @@
1
1
  import { promises as fs } from 'fs';
2
2
  import path from 'path';
3
3
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
4
- import { moveDirectory, moveFile } from './archive.js';
4
+ import { formatArchiveStamp, moveDirectory, moveFile } from './archive.js';
5
5
  import { serializeConfig } from './config-prompts.js';
6
- import { formatLocalDate } from '../utils/timestamp.js';
7
- import { buildCodeFenceMask, normalizeDocument } from './parsers/requirement-text.js';
8
- import { parseDeltaSpec, parseRequirementBlocks } from './parsers/requirement-blocks.js';
6
+ import { CREATED_PATTERN, formatLocalDate, parseStampedName } from '../utils/timestamp.js';
7
+ import { buildCodeFenceMask, normalizeDocument, stripClosingHashes } from './parsers/requirement-text.js';
8
+ import { MarkdownParser, findSection } from './parsers/markdown-parser.js';
9
+ import { isDeltaSectionHeader, matchRequirementHeader, parseDeltaSpec, parseRequirementBlocks, sectionHeaderKey, } from './parsers/requirement-blocks.js';
9
10
  import { emitSuccess } from '../commands/shared-output.js';
10
- import { isMissingPathError } from '../utils/file-system.js';
11
+ import { isDirectoryEntry, isMissingPathError } from '../utils/file-system.js';
12
+ import { validateNewChangeName } from '../utils/change-utils.js';
11
13
  /**
12
14
  * `tospec migrate [openspec-dir]` — one-shot conversion of an OpenSpec project
13
15
  * layout into the tospec layout, written to `<cwd>/tospec`.
@@ -17,13 +19,17 @@ import { isMissingPathError } from '../utils/file-system.js';
17
19
  * metadata filename `.openspec.yaml` → `.tospec.yaml`.
18
20
  * - archive dir names: `YYYY-MM-DD-<name>` → `YYYYMMDD_000000-<name>`
19
21
  * (tospec splits stamp/name on the first hyphen, so the OpenSpec date
20
- * prefix would parse wrong if left as-is).
22
+ * prefix would parse wrong if left as-is). A dir with no date prefix is
23
+ * stamped from its `created` date, else today, and reported.
21
24
  * - main specs OpenSpec left in delta form (`## ADDED Requirements` at top
22
25
  * level) or headerless (bare `### Requirement:` list) are rebuilt into the
23
26
  * `# <name> Specification / ## Purpose / ## Requirements` shape tospec
24
27
  * validates. MODIFIED blocks are kept (they carry the full updated text),
25
28
  * REMOVED requirements are dropped and RENAMED ones are renamed, both
26
- * through the project's own delta parser and both reported.
29
+ * through the project's own delta parser and both reported. Any other `##`
30
+ * section is kept, after the requirements, and the text above the first `##`
31
+ * stays above Purpose, under the source's own title when it has one. A `#`
32
+ * heading that ends Purpose stays below it, with the text under it.
27
33
  *
28
34
  * The source directory is never modified.
29
35
  */
@@ -31,10 +37,16 @@ const METADATA_SOURCE = '.openspec.yaml';
31
37
  const METADATA_TARGET = '.tospec.yaml';
32
38
  const TARGET_SCHEMA = 'sdd';
33
39
  const PURPOSE_STUB = 'TBD - migrated from OpenSpec; refine this purpose statement.';
34
- const DELTA_HEADER = /^## (ADDED|MODIFIED|REMOVED|RENAMED) Requirements\s*$/;
35
- const PURPOSE_HEADER = /^## Purpose\s*$/;
36
- /** Any Markdown heading — what ends the `## Purpose` body. */
37
- const ANY_HEADER = /^#{1,6}\s/;
40
+ /**
41
+ * A `#` or `##` heading: what ends the `## Purpose` body, as it ends the
42
+ * section for MarkdownParser. A `###` subheading under Purpose is part of it.
43
+ */
44
+ const PURPOSE_END_HEADER = /^#{1,2}\s/;
45
+ /**
46
+ * A level-1 heading: the title a source spec may carry above its sections, or
47
+ * the heading that ends Purpose and opens text no `##` section holds.
48
+ */
49
+ const TITLE_HEADER = /^#\s/;
38
50
  const ARCHIVE_DATE_PREFIX = /^(\d{4})-(\d{2})-(\d{2})-(.+)$/;
39
51
  /** The `--json` null-shape; mirrors the success payload with `migrate` nulled. */
40
52
  export const MIGRATE_FAILURE_PAYLOAD = { migrate: null, root: null };
@@ -55,18 +67,39 @@ export class MigrateError extends Error {
55
67
  };
56
68
  }
57
69
  }
70
+ /**
71
+ * The archive stamp of local midnight on a `YYYY-MM-DD` day, or null when the
72
+ * text names no real day (`2026-13-45` would roll over into another one).
73
+ *
74
+ * A `created` written as `YYYY-MM-DDTHH:mm:ss` gives its day only: every other
75
+ * stamp this migration assigns is a midnight, and one change stamped with its
76
+ * hour would sort apart from the others archived on that day.
77
+ */
78
+ function dayStamp(day) {
79
+ const match = CREATED_PATTERN.exec(day);
80
+ if (!match)
81
+ return null;
82
+ const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
83
+ return formatLocalDate(date) === day.slice(0, 10) ? formatArchiveStamp(date) : null;
84
+ }
58
85
  /**
59
86
  * Converts an OpenSpec archive dir name (`YYYY-MM-DD-<name>`) to the tospec
60
87
  * stamp form (`YYYYMMDD_000000-<name>`). OpenSpec only recorded the date, so the
61
- * time part is zero-filled. Names without the date prefix pass through.
88
+ * time part is zero-filled. A name already stamped passes through.
89
+ *
90
+ * A name with no date (moved into the archive by hand) is stamped from
91
+ * `created`, else today, and `stamped` says so: left bare, the dashboard read
92
+ * `no-date-change` as stamp `no`, name `date-change`.
62
93
  */
63
- export function convertArchiveDirName(dirName) {
64
- const match = dirName.match(ARCHIVE_DATE_PREFIX);
65
- if (!match) {
66
- return dirName;
67
- }
68
- const [, year, month, day, name] = match;
69
- return `${year}${month}${day}_000000-${name}`;
94
+ export function convertArchiveDirName(dirName, created = formatLocalDate()) {
95
+ if (parseStampedName(dirName))
96
+ return { name: dirName, stamped: false };
97
+ const prefixed = dirName.match(ARCHIVE_DATE_PREFIX);
98
+ const prefixStamp = prefixed ? dayStamp(`${prefixed[1]}-${prefixed[2]}-${prefixed[3]}`) : null;
99
+ if (prefixed && prefixStamp)
100
+ return { name: `${prefixStamp}-${prefixed[4]}`, stamped: false };
101
+ const stamp = dayStamp(created) ?? dayStamp(formatLocalDate());
102
+ return { name: `${stamp}-${dirName}`, stamped: true };
70
103
  }
71
104
  /**
72
105
  * Rebuilds a main spec that is in delta form or headerless into the shape
@@ -82,9 +115,16 @@ export function normalizeMainSpec(content, capabilityName) {
82
115
  for (let i = 0; i < lines.length; i++) {
83
116
  if (fenceMask[i])
84
117
  continue;
85
- if (DELTA_HEADER.test(lines[i]))
118
+ // The delta parser's own test, as the rebuild below reads with it. A local
119
+ // regex that took only `## ADDED Requirements` copied a spec headed
120
+ // `## Added Requirements` unconverted when it had a Purpose, so its REMOVED
121
+ // entries stayed and validate then refused the spec.
122
+ if (isDeltaSectionHeader(lines[i]))
86
123
  hasDelta = true;
87
- if (PURPOSE_HEADER.test(lines[i]))
124
+ // Same reading for the Purpose: a regex taking only `## Purpose` called a
125
+ // well-formed spec headed `## purpose` headerless and rebuilt it, with the
126
+ // TBD stub in place of its Purpose.
127
+ if (sectionHeaderKey(lines[i]) === 'purpose')
88
128
  hasPurpose = true;
89
129
  }
90
130
  if (!hasDelta && hasPurpose) {
@@ -136,16 +176,33 @@ export function normalizeMainSpec(content, capabilityName) {
136
176
  // with an empty shell.
137
177
  return null;
138
178
  }
139
- const purposeText = extractPurpose(lines, fenceMask) || PURPOSE_STUB;
179
+ const lead = extractLead(lines, fenceMask);
180
+ const purpose = extractPurpose(lines, fenceMask);
181
+ const { requirementsPreamble, sections } = collectUnslottedSections(lines, fenceMask);
182
+ // The lead stays above Purpose, where it stood. When it carries its own `#`
183
+ // title it replaces the generated one: writing both put two titles on one
184
+ // spec, and dropping the source's lost a title somebody chose.
185
+ const head = lead.hasTitle
186
+ ? [lead.text]
187
+ : [`# ${capabilityName} Specification`, ...(lead.text ? ['', lead.text] : [])];
188
+ // Kept sections go after the requirements, in source order. A `##` ends the
189
+ // section before it, so they change neither the Purpose nor the requirements
190
+ // the main-spec readers see.
191
+ // The Purpose tail stays below Purpose, where it stood, not with the kept
192
+ // sections. Above `## Requirements`, every reader ends Purpose at the `#`, and
193
+ // `## Requirements` ends the tail.
140
194
  const rebuilt = [
141
- `# ${capabilityName} Specification`,
195
+ ...head,
142
196
  '',
143
197
  '## Purpose',
144
- purposeText,
198
+ purpose.text || PURPOSE_STUB,
199
+ ...(purpose.tail ? ['', purpose.tail] : []),
145
200
  '',
146
201
  '## Requirements',
147
202
  '',
203
+ ...(requirementsPreamble ? [requirementsPreamble, ''] : []),
148
204
  [...blocks.values()].join('\n\n'),
205
+ ...sections.flatMap((section) => ['', section]),
149
206
  '',
150
207
  ].join('\n');
151
208
  return {
@@ -157,25 +214,137 @@ export function normalizeMainSpec(content, capabilityName) {
157
214
  };
158
215
  }
159
216
  /**
160
- * The text under `## Purpose`, up to the next heading of any level — a
161
- * `### Requirement:` ends it just as a second `##` section does.
217
+ * The text under `## Purpose`, up to the next `#` or `##` heading or the first
218
+ * requirement header, whose block is already collected. Ending at a heading of
219
+ * any level dropped a `###` subheading and the text under it.
220
+ *
221
+ * `tail` is the `#` heading that ends Purpose and the text under it, up to the
222
+ * next `##` section or requirement header, where `collectUnslottedSections`
223
+ * starts or cuts a section. It sits inside the `## Purpose` section that
224
+ * collector skips, so nothing else keeps it.
162
225
  */
163
226
  function extractPurpose(lines, fenceMask) {
164
- const collected = [];
165
- let inPurpose = false;
227
+ const purpose = [];
228
+ const tail = [];
229
+ let state = 'before';
166
230
  for (let i = 0; i < lines.length; i++) {
167
231
  if (!fenceMask[i]) {
168
- if (PURPOSE_HEADER.test(lines[i])) {
169
- inPurpose = true;
232
+ if (state !== 'tail' && sectionHeaderKey(lines[i]) === 'purpose') {
233
+ state = 'purpose';
170
234
  continue;
171
235
  }
172
- if (inPurpose && ANY_HEADER.test(lines[i]))
236
+ const isRequirement = matchRequirementHeader(lines[i]) !== null;
237
+ if (state === 'purpose' && TITLE_HEADER.test(lines[i]))
238
+ state = 'tail';
239
+ else if (state === 'purpose' && (PURPOSE_END_HEADER.test(lines[i]) || isRequirement))
240
+ break;
241
+ else if (state === 'tail' && (sectionHeaderKey(lines[i]) !== null || isRequirement))
173
242
  break;
174
243
  }
175
- if (inPurpose)
176
- collected.push(lines[i]);
244
+ if (state === 'purpose')
245
+ purpose.push(lines[i]);
246
+ else if (state === 'tail')
247
+ tail.push(lines[i]);
248
+ }
249
+ return { text: purpose.join('\n').trim(), tail: tail.join('\n').trim() };
250
+ }
251
+ /**
252
+ * The text above the first `##` section, which the rebuild otherwise dropped
253
+ * along with the source title. Cut at the first requirement header, as a
254
+ * section is: a headerless spec's blocks start there. `hasTitle` says whether
255
+ * it holds a `#` heading, so the caller does not write a second title over it.
256
+ */
257
+ function extractLead(lines, fenceMask) {
258
+ const collected = [];
259
+ let hasTitle = false;
260
+ for (let i = 0; i < lines.length; i++) {
261
+ if (!fenceMask[i]) {
262
+ if (sectionHeaderKey(lines[i]) !== null || matchRequirementHeader(lines[i]) !== null)
263
+ break;
264
+ // A `# Purpose` names a section, not the spec: taken as the title, it
265
+ // became the spec's H1 in place of the generated one.
266
+ if (TITLE_HEADER.test(lines[i]) && !isPurposeHeading(lines[i]))
267
+ hasTitle = true;
268
+ }
269
+ collected.push(lines[i]);
270
+ }
271
+ return { text: collected.join('\n').trim(), hasTitle };
272
+ }
273
+ /** A `#` heading titled Purpose, compared as `sectionHeaderKey` compares a `##` one. */
274
+ function isPurposeHeading(line) {
275
+ return stripClosingHashes(line.replace(TITLE_HEADER, '')).trim().toLowerCase() === 'purpose';
276
+ }
277
+ /**
278
+ * Sections whose text, up to the first requirement header, goes under the
279
+ * rebuilt `## Requirements`: the source one, and the delta sections holding
280
+ * whole blocks. REMOVED and RENAMED hold entries the rebuild has applied, not
281
+ * text for the spec.
282
+ */
283
+ const PREAMBLE_SECTION_KEYS = new Set([
284
+ 'requirements',
285
+ 'added requirements',
286
+ 'modified requirements',
287
+ ]);
288
+ /**
289
+ * The text of every `##` section the rebuild has no slot for (neither Purpose
290
+ * nor a delta section), which it otherwise dropped without a word. Each is cut
291
+ * at its first requirement header: every line from there to the next `#`
292
+ * heading belongs to a block already collected. The text a source
293
+ * `## Requirements`, ADDED or MODIFIED section holds before its first block is
294
+ * returned on its own, to go under the rebuilt header rather than into a
295
+ * second one.
296
+ */
297
+ function collectUnslottedSections(lines, fenceMask) {
298
+ const preambles = [];
299
+ const sections = [];
300
+ // `bareTitle` marks a `#` heading. Its key is '' as a `##` titled blanks
301
+ // alone keys '', so the key could not tell them apart: an empty `## ` was
302
+ // kept as if it were a heading.
303
+ let current = null;
304
+ let cut = false;
305
+ const flush = () => {
306
+ if (current === null || current.key === 'purpose')
307
+ return;
308
+ // An empty `##` section is dropped, but a bare `#` heading is the author's
309
+ // text in its own right: the requirement after it is in a block already, so
310
+ // dropping the chunk dropped the heading without a word.
311
+ if (!current.bareTitle && !current.body.some((line) => line.trim() !== ''))
312
+ return;
313
+ if (PREAMBLE_SECTION_KEYS.has(current.key))
314
+ preambles.push(current.body.join('\n').trim());
315
+ else if (!isDeltaSectionHeader(current.header))
316
+ sections.push([current.header, ...current.body].join('\n').trimEnd());
317
+ };
318
+ for (let i = 0; i < lines.length; i++) {
319
+ if (!fenceMask[i]) {
320
+ const key = sectionHeaderKey(lines[i]);
321
+ if (key !== null) {
322
+ flush();
323
+ current = { header: lines[i], key, body: [], bareTitle: false };
324
+ cut = false;
325
+ continue;
326
+ }
327
+ // A `#` heading ends the block before it, as the block parser ends it, so
328
+ // it and the text under it are in no block. Kept on their own, after the
329
+ // requirements: below a cut nothing else collects them. In a section that
330
+ // goes under `## Requirements` it starts its own text too, even before a
331
+ // block: written into the preamble, it ended the rebuilt section above
332
+ // every requirement, which no reader then saw.
333
+ const endsPreamble = current !== null && (PREAMBLE_SECTION_KEYS.has(current.key) || isDeltaSectionHeader(current.header));
334
+ if ((cut || endsPreamble) && TITLE_HEADER.test(lines[i])) {
335
+ flush();
336
+ current = { header: lines[i], key: '', body: [], bareTitle: true };
337
+ cut = false;
338
+ continue;
339
+ }
340
+ if (matchRequirementHeader(lines[i]) !== null)
341
+ cut = true;
342
+ }
343
+ if (current !== null && !cut)
344
+ current.body.push(lines[i]);
177
345
  }
178
- return collected.join('\n').trim();
346
+ flush();
347
+ return { requirementsPreamble: preambles.join('\n\n'), sections };
179
348
  }
180
349
  /**
181
350
  * Retitles a requirement block. The parser guarantees the block's first line is
@@ -203,10 +372,15 @@ function convertMetadata(raw, defaults) {
203
372
  return stringifyYaml(parsed);
204
373
  }
205
374
  /** Appends prose as a YAML literal block so its Markdown characters stay verbatim. */
206
- function appendContext(config, context) {
375
+ export function appendContext(config, context) {
207
376
  const prefix = config.endsWith('\n') ? config : `${config}\n`;
208
377
  const normalized = normalizeDocument(context);
209
- const trailingNewlines = normalized.match(/\n*$/)?.[0].length ?? 0;
378
+ // Counted by a scan: unanchored, `/\n*$/` retried from every newline of an
379
+ // inner run, and a project.md with 80k blank lines in its middle took 2.5 s.
380
+ let end = normalized.length;
381
+ while (end > 0 && normalized[end - 1] === '\n')
382
+ end--;
383
+ const trailingNewlines = normalized.length - end;
210
384
  // YAML block chomping: strip, clip, or keep the trailing newlines.
211
385
  let chomping;
212
386
  if (trailingNewlines === 0)
@@ -256,6 +430,47 @@ export class MigrateCommand {
256
430
  const activeChanges = await listDirs(path.join(source, 'changes'), 'archive');
257
431
  const archivedChanges = await listDirs(path.join(source, 'changes', 'archive'), 'archive');
258
432
  const specDirs = await listDirs(path.join(source, 'specs'));
433
+ // Refused rather than copied: status, instructions and archive all reject a
434
+ // change whose name fails validateChangeName, so a migrated `Bad_Name` was
435
+ // listed but could not be worked on. Held to the create-time rule, since
436
+ // migrate writes these directories: a `nul` copy cannot be checked out on
437
+ // Windows. Renaming it here was weighed and
438
+ // dropped: two source names can map to one kebab id, and the source stays
439
+ // read-only, so the reader renames it there and reruns.
440
+ const invalidNames = activeChanges.flatMap((name) => {
441
+ const validation = validateNewChangeName(name);
442
+ return validation.valid ? [] : [`${name} (${validation.error})`];
443
+ });
444
+ if (invalidNames.length > 0) {
445
+ const changesDir = path.join(source, 'changes');
446
+ throw new MigrateError(`Invalid change names in ${changesDir}: ${invalidNames.join('; ')}`, {
447
+ code: 'migrate_change_name_invalid',
448
+ fix: `Rename each listed directory under ${changesDir} to a kebab-case name Windows also accepts (e.g. add-auth, not nul or con), then rerun tospec migrate.`,
449
+ });
450
+ }
451
+ const archiveDir = path.join(source, 'changes', 'archive');
452
+ const archivePlan = await Promise.all(archivedChanges.map(async (dir) => ({
453
+ dir,
454
+ ...convertArchiveDirName(dir, await readCreatedDate(path.join(archiveDir, dir))),
455
+ })));
456
+ // Refused before anything is written: `2025-03-04-x` and an undated `x`
457
+ // created that day both become `20250304_000000-x`, and the second copy
458
+ // would merge into the first. Compared without case, as a case-insensitive
459
+ // file system (Windows, default macOS) merges names that differ only in it.
460
+ const byTarget = new Map();
461
+ for (const { dir, name } of archivePlan) {
462
+ const key = name.toLowerCase();
463
+ byTarget.set(key, [...(byTarget.get(key) ?? []), dir]);
464
+ }
465
+ const collisions = archivePlan
466
+ .filter(({ name }) => byTarget.get(name.toLowerCase()).length > 1)
467
+ .map(({ dir, name }) => `${dir} -> ${name}`);
468
+ if (collisions.length > 0) {
469
+ throw new MigrateError(`Archive dirs in ${archiveDir} map to the same tospec name: ${collisions.join('; ')}`, {
470
+ code: 'migrate_archive_name_collision',
471
+ fix: `Rename each colliding directory under ${archiveDir} to a distinct name, then rerun tospec migrate.`,
472
+ });
473
+ }
259
474
  // JSON mode is non-interactive, so the caller has to say --force up front.
260
475
  if (options?.json && !options?.force) {
261
476
  throw new MigrateError('migrate --json is non-interactive and needs confirmation up front.', {
@@ -282,6 +497,11 @@ export class MigrateCommand {
282
497
  removedRequirements: [],
283
498
  renamedRequirements: [],
284
499
  renamedKept: [],
500
+ skipped: await listUnmigratedEntries(source),
501
+ stampedArchives: archivePlan
502
+ .filter(({ stamped }) => stamped)
503
+ .map(({ dir, name }) => `${dir} -> ${name}`)
504
+ .sort(),
285
505
  };
286
506
  // Two-phase: build the whole tree in a sibling staging dir, then promote it.
287
507
  // A mid-migration failure cleans staging and leaves the target untouched, so
@@ -297,9 +517,12 @@ export class MigrateCommand {
297
517
  const specFile = path.join(source, 'specs', dir, 'spec.md');
298
518
  if (!(await exists(specFile))) {
299
519
  summary.specs.skippedEmpty++;
520
+ // The count alone hid that a README.md or other file beside the
521
+ // missing spec.md was left behind with the directory.
522
+ summary.skipped.push(`specs/${dir}/`);
300
523
  continue;
301
524
  }
302
- await fs.cp(path.join(source, 'specs', dir), path.join(staging, 'specs', dir), { recursive: true });
525
+ await copyContent(path.join(source, 'specs', dir), path.join(staging, 'specs', dir));
303
526
  const content = await fs.readFile(specFile, 'utf-8');
304
527
  const normalized = normalizeMainSpec(content, dir);
305
528
  if (normalized) {
@@ -314,16 +537,17 @@ export class MigrateCommand {
314
537
  }
315
538
  summary.specs.copied++;
316
539
  }
540
+ summary.skipped.sort();
317
541
  for (const dir of activeChanges) {
318
542
  const dest = path.join(staging, 'changes', dir);
319
- await fs.cp(path.join(source, 'changes', dir), dest, { recursive: true });
543
+ await copyContent(path.join(source, 'changes', dir), dest);
320
544
  if (await this.migrateMetadata(dest))
321
545
  summary.changes.metadataCreated++;
322
546
  summary.changes.active++;
323
547
  }
324
- for (const dir of archivedChanges) {
325
- const dest = path.join(staging, 'changes', 'archive', convertArchiveDirName(dir));
326
- await fs.cp(path.join(source, 'changes', 'archive', dir), dest, { recursive: true });
548
+ for (const { dir, name } of archivePlan) {
549
+ const dest = path.join(staging, 'changes', 'archive', name);
550
+ await copyContent(path.join(source, 'changes', 'archive', dir), dest);
327
551
  const created = dir.match(ARCHIVE_DATE_PREFIX);
328
552
  const createdDate = created ? `${created[1]}-${created[2]}-${created[3]}` : undefined;
329
553
  if (await this.migrateMetadata(dest, createdDate))
@@ -339,7 +563,6 @@ export class MigrateCommand {
339
563
  if (options?.json) {
340
564
  // A migrated project has no skills until `tospec init` runs, and the
341
565
  // deferred ticket stubs are a deliberate gap a caller must know about.
342
- // tospec/decisions/20260916_121552-migrated-changes-defer-ticket-stubs.md
343
566
  const migratedChangeCount = summary.changes.active + summary.changes.archived;
344
567
  emitSuccess({
345
568
  migrate: {
@@ -365,6 +588,12 @@ export class MigrateCommand {
365
588
  if (summary.removedRequirements.length > 0) {
366
589
  console.log(` REMOVED requirements dropped during normalization: ${summary.removedRequirements.join(', ')}`);
367
590
  }
591
+ if (summary.skipped.length > 0) {
592
+ console.log(` Warning: not migrated, copy by hand if still needed: ${summary.skipped.join(', ')}`);
593
+ }
594
+ if (summary.stampedArchives.length > 0) {
595
+ console.log(` Warning: undated archive dirs stamped from created (else today), not their archive date: ${summary.stampedArchives.join(', ')}`);
596
+ }
368
597
  switch (summary.context) {
369
598
  case 'carried':
370
599
  console.log(` Context: project.md -> config.yaml context:`);
@@ -460,7 +689,12 @@ export class MigrateCommand {
460
689
  await fs.rm(staging, { recursive: true, force: true });
461
690
  }
462
691
  }
463
- /** First non-empty line of proposal.md's `## Why` section, else a marker. */
692
+ /**
693
+ * First non-empty line of proposal.md's Why section, else a marker. The section
694
+ * validate reads as Why (`requiredSections`, through `findSection`): a regex of
695
+ * its own took only `## Why`, so `## why`, `## Why ##` and `### Why` passed
696
+ * validate and lost their goal here, and a fenced example supplied one.
697
+ */
464
698
  async function extractGoal(changeDir) {
465
699
  const fallback = 'Migrated from OpenSpec';
466
700
  let content;
@@ -470,20 +704,9 @@ async function extractGoal(changeDir) {
470
704
  catch {
471
705
  return fallback;
472
706
  }
473
- const lines = normalizeDocument(content).split('\n');
474
- let inWhy = false;
475
- for (const line of lines) {
476
- if (/^## Why\s*$/.test(line)) {
477
- inWhy = true;
478
- continue;
479
- }
480
- if (inWhy && /^#{1,2} /.test(line))
481
- break;
482
- if (inWhy && line.trim().length > 0) {
483
- return line.trim();
484
- }
485
- }
486
- return fallback;
707
+ const why = findSection(new MarkdownParser(content).getSections(), 'Why');
708
+ const first = why?.content.split(/\r?\n/).find((line) => line.trim().length > 0);
709
+ return first?.trim() || fallback;
487
710
  }
488
711
  /**
489
712
  * False only for a path that is not there. Every caller treats `false` as
@@ -501,16 +724,69 @@ async function exists(p) {
501
724
  throw error;
502
725
  }
503
726
  }
727
+ /**
728
+ * The `created` day of a source change's `.openspec.yaml`, or undefined when
729
+ * there is none to read. A file that does not parse is left to
730
+ * `migrateMetadata`, which reports it when it converts the file.
731
+ */
732
+ async function readCreatedDate(changeDir) {
733
+ try {
734
+ const parsed = parseYaml(await fs.readFile(path.join(changeDir, METADATA_SOURCE), 'utf-8'));
735
+ const created = parsed?.created;
736
+ return typeof created === 'string' ? created : undefined;
737
+ }
738
+ catch {
739
+ return undefined;
740
+ }
741
+ }
742
+ /**
743
+ * Copies a source change or spec dir by its content. `fs.cp` on a link copies
744
+ * the link, so a linked source would come out as a link in tospec/ still
745
+ * pointing outside it, and the migrated content would never be the project's.
746
+ * Links inside the dir are copied as before.
747
+ */
748
+ async function copyContent(from, to) {
749
+ await fs.cp(await fs.realpath(from), to, { recursive: true });
750
+ }
504
751
  /** Names of the `.md` files directly in `dir`. Unreadable dir → empty list. */
505
752
  async function listMarkdownFiles(dir) {
506
753
  const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
507
754
  return entries.filter((entry) => entry.isFile() && entry.name.endsWith('.md')).map((entry) => entry.name);
508
755
  }
509
- /** Sorted subdirectory names, minus `exclude`. Unreadable dir → empty list. */
756
+ /** Top-level source entries migrate reads; every other one is left behind. */
757
+ const MIGRATED_TOP_LEVEL = new Set(['config.yaml', 'project.md', 'specs', 'changes']);
758
+ /**
759
+ * The source entries no step reads: top-level items outside
760
+ * `MIGRATED_TOP_LEVEL`, and whatever `listDirs` drops (files, dangling links)
761
+ * from `specs/`, `changes/` and `changes/archive/`. Sorted, `/`-separated.
762
+ */
763
+ async function listUnmigratedEntries(source) {
764
+ const skipped = [];
765
+ const collect = async (relative, isMigrated) => {
766
+ const dir = path.join(source, relative);
767
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
768
+ for (const entry of entries) {
769
+ const isDir = isDirectoryEntry(dir, entry);
770
+ if (!isMigrated(entry, isDir))
771
+ skipped.push(`${relative}${entry.name}${isDir ? '/' : ''}`);
772
+ }
773
+ };
774
+ await collect('', (entry) => MIGRATED_TOP_LEVEL.has(entry.name));
775
+ await collect('specs/', (_, isDir) => isDir);
776
+ // `archive` by name, as `listDirs` excludes it: it is read on its own below.
777
+ await collect('changes/', (entry, isDir) => isDir || entry.name === 'archive');
778
+ await collect('changes/archive/', (_, isDir) => isDir);
779
+ return skipped.sort();
780
+ }
781
+ /**
782
+ * Sorted subdirectory names, linked ones included, minus `exclude`. Unreadable
783
+ * dir → empty list. Linked ones count because every command reading the
784
+ * migrated project follows them; left out, they were listed as not migrated.
785
+ */
510
786
  async function listDirs(dir, exclude) {
511
787
  const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
512
788
  return entries
513
- .filter((entry) => entry.isDirectory() && entry.name !== exclude)
789
+ .filter((entry) => isDirectoryEntry(dir, entry) && entry.name !== exclude)
514
790
  .map((entry) => entry.name)
515
791
  .sort();
516
792
  }