@seanmars/tospec 0.24.0 → 0.26.0-beta

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 +241 -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
@@ -3,19 +3,24 @@ import path from 'path';
3
3
  import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';
4
4
  import { Validator } from './validation/validator.js';
5
5
  import { issueSymbol } from './validation/types.js';
6
- import { validateChangeArtifacts } from '../commands/validate.js';
6
+ import { changeRequiresDeltas, validateChangeArtifacts } from '../commands/validate.js';
7
7
  import { isRootSelectionError, resolveRootForCommand, toRootOutput, } from './root-selection.js';
8
- import { findSpecUpdates, buildUpdatedSpec, writeUpdatedSpec, retireSpec, } from './specs-apply.js';
9
- import { loadChangeContext, formatChangeStatus } from './artifact-graph/index.js';
8
+ import { findSpecUpdates, MainSpecMergeError, buildUpdatedSpec, writeUpdatedSpec, retireSpec, checkTargetWithinProject, } from './specs-apply.js';
9
+ import { loadChangeContext, formatChangeStatus, findSchemaWarnings, resolveSchema, } from './artifact-graph/index.js';
10
10
  import { buildCodeFenceMask, normalizeDocument, stripBom } from './parsers/requirement-text.js';
11
+ import { parseDeltaSpec } from './parsers/requirement-blocks.js';
12
+ import { findSharedRequirements, listDeltaTouches, listSisterChangeDeltas, } from './validation/sister-changes.js';
11
13
  import { formatTimestamp } from '../utils/timestamp.js';
12
- import { findSpecFiles } from '../utils/spec-files.js';
14
+ import { findMisnamedDeltaFiles, findRootDeltaFiles, findRootSpecDelta, findSpecFiles } from '../utils/spec-files.js';
13
15
  import { FileSystemUtils, isMissingPathError } from '../utils/file-system.js';
14
16
  import { isKebabId, KEBAB_ID_DESCRIPTION } from './id.js';
15
17
  import { findTicketPaths, listActiveChangeDirs } from '../utils/item-discovery.js';
16
- import { METADATA_FILENAME, readChangeMetadata } from '../utils/change-metadata.js';
17
- import { emitSuccess, emitFailureStatus } from '../commands/shared-output.js';
18
+ import { findLayoutPathNotDirectory, LayoutPathNotDirectoryError } from './project-layout.js';
19
+ import { isReservedChangeName, RESERVED_CHANGE_NAME_REASON } from '../utils/change-utils.js';
20
+ import { METADATA_FILENAME, readChangeMetadata, resolveSchemaForChange } from '../utils/change-metadata.js';
21
+ import { emitSuccess, emitFailureStatus, formatLabeled, indentContinuation, } from '../commands/shared-output.js';
18
22
  import { withFileLock } from '../utils/file-lock.js';
23
+ import { isInteractive } from '../utils/interactive.js';
19
24
  /** Alias of `formatTimestamp`: agents must never assemble archive stamps by hand. */
20
25
  export const formatArchiveStamp = formatTimestamp;
21
26
  /** `Conclusion: PASS` / `Conclusion: FAIL`, with an optional `(reason)` note. */
@@ -82,6 +87,11 @@ function toArchiveDiagnostic(error) {
82
87
  if (isRootSelectionError(error)) {
83
88
  return error.diagnostic;
84
89
  }
90
+ // Raised by the change listing, which every other command reports by its own
91
+ // code; folding it into archive_error would make archive the one exception.
92
+ if (error instanceof LayoutPathNotDirectoryError) {
93
+ return error.diagnostic;
94
+ }
85
95
  return {
86
96
  severity: 'error',
87
97
  code: 'archive_error',
@@ -106,6 +116,52 @@ export class MoveSourceNotRemovedError extends Error {
106
116
  this.name = 'MoveSourceNotRemovedError';
107
117
  }
108
118
  }
119
+ /**
120
+ * A failure after archive changed something on disk. The fix names the manual
121
+ * steps left, and says not to rerun: the merge would refuse the change's ADDED
122
+ * requirements as already present, and a moved change is no longer found.
123
+ */
124
+ function partiallyAppliedError(error, state) {
125
+ const rel = (target) => FileSystemUtils.toPosixPath(path.relative(state.projectRoot, target));
126
+ const changeRel = rel(state.changeDir);
127
+ const archiveRel = rel(state.archivePath);
128
+ const unwritten = state.unwrittenCapabilities ?? [];
129
+ const done = [];
130
+ if (state.mergedCapabilities.length > 0) {
131
+ done.push(`merged the specs of ${state.mergedCapabilities.join(', ')} into tospec/specs/`);
132
+ }
133
+ if (state.changeMoved)
134
+ done.push(`moved the change to ${archiveRel}`);
135
+ const failed = unwritten.length > 0
136
+ ? `writing the specs of ${unwritten.join(', ')}`
137
+ : state.changeMoved
138
+ ? 'archiving the ticket in tospec/tickets/archive/'
139
+ : `moving the change to ${archiveRel}`;
140
+ const cause = error instanceof Error ? error.message : String(error);
141
+ // "Still in": the ticket move can fail after the file moved, while its
142
+ // change link is rewritten, and the step must not send the user to a file
143
+ // that is no longer there.
144
+ const ticketStep = `move the change's ticket, if it has one still in tospec/tickets/, to tospec/tickets/archive/ and point its change link at ${path.basename(state.archivePath)}`;
145
+ let fix;
146
+ if (unwritten.length > 0) {
147
+ fix =
148
+ `Do not rerun archive: the merge would refuse the requirements it already added. ` +
149
+ `Merge the change's deltas for ${unwritten.join(', ')} into tospec/specs/ by hand, move ${changeRel} to ${archiveRel}, then ${ticketStep}.`;
150
+ }
151
+ else if (state.changeMoved) {
152
+ fix = `Do not rerun archive: the change is already archived. Finish by hand: ${ticketStep}.`;
153
+ }
154
+ else if (error instanceof MoveSourceNotRemovedError) {
155
+ fix = `Do not rerun archive. ${archiveRel} is complete: delete what is left of ${changeRel}, then ${ticketStep}.`;
156
+ }
157
+ else {
158
+ const clearCopy = state.partialCopyLeft ? ` (delete the partial copy at ${archiveRel} first)` : '';
159
+ fix =
160
+ `Do not rerun archive: the merge would refuse the requirements it already added. ` +
161
+ `Move ${changeRel} to ${archiveRel} by hand${clearCopy}, then ${ticketStep}.`;
162
+ }
163
+ return new ArchiveBlockedError('archive_partially_applied', `Archive stopped partway. Done: ${done.join('; ')}. Failed while ${failed}: ${cause}`, fix);
164
+ }
109
165
  /**
110
166
  * On Windows fs.rename() often fails with EPERM when the directory is non-empty
111
167
  * or another process has it open (IDE, watcher, antivirus), so EPERM/EXDEV fall
@@ -118,9 +174,23 @@ export async function moveDirectory(src, dest) {
118
174
  catch (err) {
119
175
  const code = err?.code;
120
176
  if (code === 'EPERM' || code === 'EXDEV') {
121
- // verbatimSymlinks: otherwise Node rewrites links to absolute paths under
122
- // `src`, and removing `src` below leaves the copies dangling.
123
- await fs.cp(src, dest, { recursive: true, verbatimSymlinks: true });
177
+ // Only a destination this copy created is rolled back: one already there
178
+ // (rename onto it fails with EPERM on Windows) holds someone else's files.
179
+ const destExisted = await fs.lstat(dest).then(() => true, () => false);
180
+ try {
181
+ // verbatimSymlinks: otherwise Node rewrites links to absolute paths under
182
+ // `src`, and removing `src` below leaves the copies dangling.
183
+ await fs.cp(src, dest, { recursive: true, verbatimSymlinks: true });
184
+ }
185
+ catch (cpError) {
186
+ // A copy that dies partway (a file held open elsewhere) leaves half the
187
+ // source at `dest`, which reads as moved. `src` is still whole, so
188
+ // removing the copy leaves the plain "not moved" state. rm does not
189
+ // follow links, so a linked `src` copied as a link removes only that link.
190
+ if (!destExisted)
191
+ await fs.rm(dest, { recursive: true, force: true }).catch(() => { });
192
+ throw cpError;
193
+ }
124
194
  // The same open handle that made rename fail with EPERM can fail this
125
195
  // removal partway, after some files are already gone.
126
196
  try {
@@ -164,21 +234,34 @@ async function appendDecisionLinks(ticketFile, archivedChangeDir, projectRoot) {
164
234
  await fs.appendFile(ticketFile, block);
165
235
  }
166
236
  /**
167
- * Repoints an archived ticket's `ref:` frontmatter at the change's new path,
168
- * keeping the referenced filename. No-op when the line is absent.
237
+ * Points a ticket's `ref:` line at the archived change, keeping the referenced
238
+ * filename. Returns the text unchanged when no line matches.
169
239
  *
170
240
  * Line endings survive untouched because the pattern excludes line terminators
171
241
  * (`[^/\r\n]+`, no `s` flag), so `replace` only rewrites within a line. Do not
172
242
  * "simplify" this by splitting and rejoining the file.
243
+ *
244
+ * `$2` runs straight to `$`: a `[ \t]*` between them split a blank run with
245
+ * `[^/\r\n]+` every way, and a filename holding 80k blanks took 13 s. That
246
+ * `[ \t]*` never matched anything, since the greedy capture reached `$` first,
247
+ * so trailing blanks stay in `$2` as before. `(?![ \t])` keeps `.*` from
248
+ * re-splitting the blanks after `ref:` (2 s on a line with no `/`): a shorter
249
+ * `$1` only hands `.*` blanks, never another `/`, so it could not match anyway.
250
+ */
251
+ export function retargetRefLine(content, archiveName) {
252
+ return content.replace(/^(ref:[ \t]*)(?![ \t]).*\/([^/\r\n]+)$/m, `$1tospec/changes/archive/${archiveName}/$2`);
253
+ }
254
+ /**
255
+ * Repoints an archived ticket's `ref:` frontmatter at the change's new path,
256
+ * keeping the referenced filename. No-op when the line is absent.
173
257
  */
174
258
  async function retargetTicketRef(ticketFile, archiveName) {
175
- const raw = await fs.readFile(ticketFile, 'utf-8');
176
- // Compared against `raw`, not the stripped text, so a ticket whose only
177
- // difference is a BOM still gets written back without one: this rewrite is
178
- // where tospec re-emits the file, and what it emits is UTF-8 without a BOM.
179
- const content = stripBom(raw);
180
- const updated = content.replace(/^(ref:[ \t]*).*\/([^/\r\n]+)[ \t]*$/m, `$1tospec/changes/archive/${archiveName}/$2`);
181
- if (updated !== raw) {
259
+ // A rewrite emits UTF-8 without a BOM, as every tospec write does. Compared
260
+ // against the stripped text, as `writeTicketStub` does, so a ticket with no
261
+ // `ref:` to retarget is not rewritten just to drop its BOM.
262
+ const content = stripBom(await fs.readFile(ticketFile, 'utf-8'));
263
+ const updated = retargetRefLine(content, archiveName);
264
+ if (updated !== content) {
182
265
  await fs.writeFile(ticketFile, updated);
183
266
  }
184
267
  }
@@ -267,6 +350,12 @@ export class ArchiveCommand {
267
350
  if (opts.json) {
268
351
  throw new ArchiveBlockedError(opts.code, opts.message, opts.fix);
269
352
  }
353
+ // A prompt with no terminal reads EOF as Ctrl-C, so a CI run without
354
+ // `--yes` reported a cancellation (exit 130) instead of what it lacked.
355
+ // Same test as `config reset`, so the two refuse alike.
356
+ if (!isInteractive()) {
357
+ throw new ArchiveBlockedError(opts.code, `${opts.message} Cannot confirm it: no interactive terminal.`, opts.fix);
358
+ }
270
359
  const { confirm } = await import('@inquirer/prompts');
271
360
  const proceed = await confirm({ message: opts.prompt, default: false });
272
361
  if (!proceed) {
@@ -299,6 +388,19 @@ export class ArchiveCommand {
299
388
  return [];
300
389
  }
301
390
  }
391
+ /**
392
+ * Same rule as `findMissingRequiredArtifacts`: a schema that does not resolve
393
+ * yields nothing, because reporting it is validation's job, not this one's.
394
+ */
395
+ findChangeSchemaWarnings(projectRoot, changeDir) {
396
+ try {
397
+ const schemaName = resolveSchemaForChange(changeDir, undefined, projectRoot);
398
+ return findSchemaWarnings(resolveSchema(schemaName, projectRoot));
399
+ }
400
+ catch {
401
+ return [];
402
+ }
403
+ }
302
404
  /**
303
405
  * Shared archive flow. Human mode prompts and returns null on cancellation;
304
406
  * JSON mode keeps stdout clean and throws from every blocked path.
@@ -326,9 +428,10 @@ export class ArchiveCommand {
326
428
  throw new ArchiveBlockedError('archive_change_name_required', 'A change name is required: archive --json is non-interactive.', 'tospec archive <change-name> --json');
327
429
  }
328
430
  // Without a terminal the picker cannot be answered: it would paint its
329
- // menu into the pipe and then read EOF as "nothing chosen".
330
- if (!process.stdin.isTTY) {
331
- throw new ArchiveBlockedError('archive_change_name_required', 'A change name is required when stdin is not a terminal.', 'tospec archive <change-name>');
431
+ // menu into the pipe and then read EOF as "nothing chosen". `CI` counts
432
+ // as no terminal too, as it does for the confirmation prompts below.
433
+ if (!isInteractive()) {
434
+ throw new ArchiveBlockedError('archive_change_name_required', 'A change name is required: no interactive terminal to pick one.', 'tospec archive <change-name>');
332
435
  }
333
436
  const selectedChange = await this.selectChange(changesDir, root.path);
334
437
  if (!selectedChange) {
@@ -345,6 +448,12 @@ export class ArchiveCommand {
345
448
  if (nameFromArgument && !isKebabId(changeName)) {
346
449
  throw new ArchiveBlockedError('archive_change_name_invalid', `Change name '${changeName}' is not a valid change id — it ${KEBAB_ID_DESCRIPTION}.`);
347
450
  }
451
+ // Before the stat, which the archive directory passes: every gate then let
452
+ // it through, and only the final rename failed, as EINVAL, moving the
453
+ // directory into its own subdirectory.
454
+ if (isReservedChangeName(changeName)) {
455
+ throw new ArchiveBlockedError('archive_change_name_invalid', `${RESERVED_CHANGE_NAME_REASON}, and is not itself a change.`);
456
+ }
348
457
  const changeDir = path.join(changesDir, changeName);
349
458
  // Only a missing path may answer "not found" — a bare catch here rewrote
350
459
  // genuine IO failures (EACCES, EIO) as a nonexistent change.
@@ -359,6 +468,17 @@ export class ArchiveCommand {
359
468
  ? `Change '${changeName}' not found. Available changes: ${available.join(', ')}`
360
469
  : `Change '${changeName}' not found. No active changes exist in this root.`);
361
470
  }
471
+ // Read before the move: `fs.rename` moves the link itself, so the archived
472
+ // entry is the same link and its content never lands in the archive.
473
+ const linkedTo = (await fs.lstat(changeDir)).isSymbolicLink() ? await fs.realpath(changeDir) : null;
474
+ // Every directory the move and the ticket move write into, checked before
475
+ // the first write: a file at one of them otherwise failed only after the
476
+ // specs merge, as a partly applied archive.
477
+ for (const dir of [archiveDir, path.join(root.path, 'tospec', 'tickets', 'archive')]) {
478
+ const occupied = findLayoutPathNotDirectory(root.path, dir);
479
+ if (occupied)
480
+ throw occupied;
481
+ }
362
482
  // Reserve the archive name before anything is written. Checking after the
363
483
  // merge left specs applied with the change still in tospec/changes/, so a
364
484
  // retry re-applied the same deltas. The stamp is computed once and reused
@@ -404,16 +524,34 @@ export class ArchiveCommand {
404
524
  const skipValidation = options.validate === false || options.noValidate === true;
405
525
  // The exact pipeline `tospec validate` runs, minus the archive preflight —
406
526
  // that is a dry run of the merge performed below, which raises
407
- // archive_spec_update_failed itself.
527
+ // archive_spec_update_failed itself — and minus the sister-change check,
528
+ // which would advise archiving the other change first; the scan after the
529
+ // merge below names the other change in the direction this run decided.
408
530
  if (!skipValidation) {
409
531
  const report = await validateChangeArtifacts(root.path, changeDir, {
410
532
  archivePreflight: false,
533
+ sisterChanges: false,
411
534
  });
412
535
  if (!json && report.issues.length > 0) {
413
536
  console.error(`\nValidation issues for change '${changeName}':`);
414
537
  for (const issue of report.issues) {
415
538
  const symbol = issueSymbol(issue.level);
416
- console.error(` ${symbol} ${issue.path}: ${issue.message}`);
539
+ console.error(` ${symbol} ${issue.path}: ${indentContinuation(issue.message, ' ')}`);
540
+ }
541
+ }
542
+ // Human mode printed every finding above while --json printed none, so
543
+ // an agent archiving with --json never saw a WARNING (a dropped bullet,
544
+ // a missing SHALL) that a user at a terminal saw. ERRORs stay out:
545
+ // they fail the gate below, whose fix sends the reader to validate.
546
+ if (json) {
547
+ for (const issue of report.issues) {
548
+ if (issue.level !== 'WARNING')
549
+ continue;
550
+ jsonWarnings.push({
551
+ severity: 'warning',
552
+ code: 'archive_validation_warning',
553
+ message: `${issue.path}: ${issue.message}`,
554
+ });
417
555
  }
418
556
  }
419
557
  if (!report.valid) {
@@ -422,15 +560,17 @@ export class ArchiveCommand {
422
560
  // not at --no-validate.
423
561
  const noDeltas = report.issues.some((i) => /at least one delta/i.test(i.message));
424
562
  const deltaFix = noDeltas
425
- ? ` If this change genuinely has no behavioral effect, declare it once by setting \`skip_specs: true\` in the change's ${METADATA_FILENAME} — do not invent a requirement to satisfy validation.`
563
+ ? ` If the user confirms this change needs no delta spec, declare it once by setting \`skip_specs: true\` in the change's ${METADATA_FILENAME} — do not invent a requirement to satisfy validation.`
426
564
  : '';
427
565
  if (json) {
428
566
  throw new ArchiveBlockedError('archive_validation_failed', `Validation failed for change '${changeName}'.`, `Run ${`tospec validate ${changeName}`} for details, fix the errors, or rerun with --no-validate.${deltaFix}`);
429
567
  }
430
- console.error('\nValidation failed. Please fix the errors before archiving.');
568
+ // A line under `Error:` that is not `Fix:` is indented, here and at the
569
+ // merge aborts below: a bare line would read as a second message.
570
+ console.error('\nError: Validation failed. Please fix the errors before archiving.');
431
571
  if (deltaFix)
432
572
  console.error(`Fix:${deltaFix}`);
433
- console.error('To skip validation (not recommended), use --no-validate flag.');
573
+ console.error(' To skip validation (not recommended), use --no-validate flag.');
434
574
  process.exitCode = 1;
435
575
  return null;
436
576
  }
@@ -444,9 +584,11 @@ export class ArchiveCommand {
444
584
  // when `--yes` overrides the gate. Remedies belong in `fix`, which an
445
585
  // overridden gate drops.
446
586
  message: `Validation is disabled for change '${changeName}' (--no-validate).`,
447
- fix: 'tospec archive <change-name> --json --no-validate --yes',
448
- prompt: '⚠️ WARNING: Skipping validation may archive invalid specs. Continue? (y/N)',
449
- warning: '\n⚠️ WARNING: Skipping validation may archive invalid specs.',
587
+ // The human-mode fix also reaches a run with no terminal; `--json`
588
+ // there would swap the reader's prose output for a document.
589
+ fix: `tospec archive ${changeName}${json ? ' --json' : ''} --no-validate --yes`,
590
+ prompt: 'Warning: Skipping validation may archive invalid specs. Continue?',
591
+ warning: '\nWarning: Skipping validation may archive invalid specs.',
450
592
  warnings: jsonWarnings,
451
593
  });
452
594
  if (!proceed)
@@ -480,6 +622,19 @@ export class ArchiveCommand {
480
622
  if (!proceed)
481
623
  return null;
482
624
  }
625
+ // Ahead of the task gate: an unmatched `apply.tracks` sends progress to
626
+ // tasks.md, so the gate reports "no tracked task checkboxes" and asks for a
627
+ // file while the schema's real one sits in the change. The gate's decision
628
+ // stands; this is what makes its message make sense.
629
+ for (const warning of this.findChangeSchemaWarnings(root.path, changeDir)) {
630
+ if (json) {
631
+ jsonWarnings.push({ severity: 'warning', ...warning });
632
+ }
633
+ else {
634
+ console.log(`Warning: ${warning.message}`);
635
+ console.log(`Fix: ${warning.fix}`);
636
+ }
637
+ }
483
638
  // Pass the resolved root rather than re-deriving it from changesDir: the
484
639
  // '..','..' hop silently encodes the tospec/changes layout.
485
640
  const progress = await getTaskProgressForChange(changesDir, changeName, root.path);
@@ -517,18 +672,91 @@ export class ArchiveCommand {
517
672
  return null;
518
673
  }
519
674
  let specsUpdated = false;
675
+ let mergedCapabilities = [];
520
676
  let totals = { added: 0, modified: 0, removed: 0, renamed: 0, retired: 0 };
677
+ const specsDir = path.join(changeDir, 'specs');
678
+ const specUpdates = await findSpecUpdates(changeDir, mainSpecsDir);
679
+ // Where the merge-empty backstop below runs, it refuses and names every
680
+ // spec.md the merge cannot read. Everywhere else the merge drops those
681
+ // files, so the warnings below name them; keyed on the one condition, no
682
+ // file is both a warning and that error.
683
+ const backstopRuns = !options.skipSpecs && specUpdates.length === 0;
684
+ // Both outcomes, because the merge below can still stop the archive: a
685
+ // stopped one leaves the change in place, while a completed one has moved
686
+ // it to archive/, where it cannot be archived again.
687
+ const unmergedFix = 'To apply them: if this archive stopped, move each to specs/<capability>/spec.md and rerun; if it completed, write them as specs/<capability>/spec.md in a new change, or merge them into the main specs by hand.';
688
+ // The same list validate warns about, at the same level: a root `.md` with
689
+ // delta headers is a delta the merge will not read. Not added to the
690
+ // backstop below, which refuses — validate only warns here, and archive
691
+ // must not block what validate passes.
692
+ //
693
+ // Ahead of the --skip-specs branch, under one code for both: the file goes
694
+ // unmerged with or without the flag, so a consumer keyed on the code must
695
+ // not need to know which was passed. Not folded into
696
+ // archive_deltas_discarded, which lists the capabilities the flag dropped;
697
+ // findSpecUpdates never returns a root file, so no file is named twice.
698
+ //
699
+ // specs/spec.md joins the list only where the backstop does not run: under
700
+ // --skip-specs, or beside a capability delta, which gives the merge
701
+ // something to read. Validate's ERROR refuses a spec.md with a delta header
702
+ // first, so in practice this is --no-validate. Not added to that backstop
703
+ // instead: each flag is an explicit instruction, and the --skip-specs
704
+ // branch only warns about the deltas it drops.
705
+ const rootDeltas = await findRootDeltaFiles(specsDir, { includeRootSpec: !backstopRuns });
706
+ if (rootDeltas.length > 0) {
707
+ const listed = rootDeltas.map((file) => `specs/${path.basename(file)}`).join(', ');
708
+ const message = `${listed} hold(s) delta sections but sit at the specs/ root, so they are not merged into the main specs.`;
709
+ if (json) {
710
+ jsonWarnings.push({ severity: 'warning', code: 'archive_root_delta_ignored', message, fix: unmergedFix });
711
+ }
712
+ else {
713
+ console.log(`Warning: ${message}`);
714
+ console.log(`Fix: ${unmergedFix}`);
715
+ }
716
+ }
717
+ // The delta files below the root that the merge does not read: a spec.md
718
+ // deeper than specs/<capability>/ or in a dot folder, and a misnamed file
719
+ // (specs/<capability>/notes.md, SPEC.md) with a delta header. They are the
720
+ // files the backstop lists when nothing merges; beside a capability delta
721
+ // or under --skip-specs they were dropped with nothing naming them.
722
+ // Validate refuses each of them first, so in practice this is
723
+ // --no-validate.
724
+ //
725
+ // A code of its own rather than archive_root_delta_ignored, whose name and
726
+ // message say "root", so a consumer reads the two codes as two different
727
+ // checks. A spec.md is picked by basename, as the backstop and validate's
728
+ // nested ERROR pick it. A misnamed file needs a delta header, the root's
729
+ // rule: without one it is notes.
730
+ if (!backstopRuns) {
731
+ const merged = new Set(specUpdates.map((update) => update.source));
732
+ const misplaced = [
733
+ ...(await findSpecFiles(specsDir)).filter((file) => path.dirname(file) !== specsDir && !merged.has(file)),
734
+ ...(await findMisnamedDeltaFiles(specsDir)),
735
+ ].sort();
736
+ if (misplaced.length > 0) {
737
+ const listed = misplaced
738
+ .map((file) => `specs/${FileSystemUtils.toPosixPath(path.relative(specsDir, file))}`)
739
+ .join(', ');
740
+ const message = `${misplaced.length} delta spec file(s) are not merged into the main specs, because they do not sit at specs/<capability>/spec.md (one folder deep, not a dot folder): ${listed}.`;
741
+ if (json) {
742
+ jsonWarnings.push({ severity: 'warning', code: 'archive_misplaced_delta_ignored', message, fix: unmergedFix });
743
+ }
744
+ else {
745
+ console.log(`Warning: ${message}`);
746
+ console.log(`Fix: ${unmergedFix}`);
747
+ }
748
+ }
749
+ }
521
750
  if (options.skipSpecs) {
522
751
  // `--skip-specs` is for changes with no deltas, so pointing it at one that
523
752
  // has them is user error — and archiving in silence made it unrecoverable.
524
753
  // A warning, not a block, because the flag is an explicit instruction.
525
- const discarded = await findSpecUpdates(changeDir, mainSpecsDir);
526
- if (discarded.length > 0) {
754
+ if (specUpdates.length > 0) {
527
755
  // source is <changeDir>/specs/<capability>/spec.md.
528
- const listed = discarded
756
+ const listed = specUpdates
529
757
  .map((update) => path.basename(path.dirname(update.source)))
530
758
  .join(', ');
531
- const message = `--skip-specs discarded ${discarded.length} delta spec(s); these capabilities were not merged into the main specs: ${listed}.`;
759
+ const message = `--skip-specs discarded ${specUpdates.length} delta spec(s); these capabilities were not merged into the main specs: ${listed}.`;
532
760
  if (json) {
533
761
  jsonWarnings.push({ severity: 'warning', code: 'archive_deltas_discarded', message });
534
762
  }
@@ -541,16 +769,30 @@ export class ArchiveCommand {
541
769
  }
542
770
  }
543
771
  else {
544
- const specUpdates = await findSpecUpdates(changeDir, mainSpecsDir);
545
772
  if (specUpdates.length === 0) {
546
773
  // findSpecUpdates only reads specs/<capability>/spec.md, and validation
547
774
  // rejects anything else — so delta files here mean --no-validate, and
548
775
  // reporting success would drop their requirements silently. Matches
549
- // spec.md only: a stray NOTES.md is not a delta.
550
- const orphans = await findSpecFiles(path.join(changeDir, 'specs'));
776
+ // spec.md below the root, and another .md below the root only with a
777
+ // delta header: a stray NOTES.md is not a delta.
778
+ //
779
+ // specs/spec.md is picked by validate's own rule, under the name it has
780
+ // on disk: on a case-insensitive file system a lone SPEC.md is that
781
+ // ERROR's file. Without a delta header it counts only where the change
782
+ // must carry a delta; elsewhere validate leaves it as notes, and
783
+ // counting it anyway refused a change validate had passed while the
784
+ // Fix: sent the user back to validate.
785
+ const rootSpec = await findRootSpecDelta(specsDir, {
786
+ requireDelta: await changeRequiresDeltas(root.path, changeDir),
787
+ });
788
+ const orphans = [
789
+ ...(await findSpecFiles(specsDir)).filter((file) => path.dirname(file) !== specsDir),
790
+ ...(rootSpec === undefined ? [] : [rootSpec]),
791
+ ...(await findMisnamedDeltaFiles(specsDir)),
792
+ ].sort();
551
793
  if (orphans.length > 0) {
552
794
  const listed = orphans
553
- .map((file) => FileSystemUtils.toPosixPath(path.relative(path.join(changeDir, 'specs'), file)))
795
+ .map((file) => FileSystemUtils.toPosixPath(path.relative(specsDir, file)))
554
796
  .join(', ');
555
797
  throw new ArchiveBlockedError('archive_spec_merge_empty', `Found ${orphans.length} delta spec file(s) that cannot be merged, because none sits at specs/<capability>/spec.md: ${listed}.`, `Run ${`tospec validate ${changeName}`} for the exact fix, or move each file to specs/<capability>/spec.md.`);
556
798
  }
@@ -573,6 +815,12 @@ export class ArchiveCommand {
573
815
  // gates, so a caller who only wanted the merge had to disarm every
574
816
  // gate to get it.
575
817
  if (!options.yes && !json) {
818
+ // Refuse rather than merge by default as JSON mode does: a human run
819
+ // was promised this question. Every gate has passed by this point,
820
+ // so a rerun with `--yes` overrides nothing but this prompt.
821
+ if (!isInteractive()) {
822
+ throw new ArchiveBlockedError('archive_confirmation_required', `Cannot confirm the spec updates for change '${changeName}': no interactive terminal.`, 'Rerun with --yes to merge them.');
823
+ }
576
824
  const { confirm } = await import('@inquirer/prompts');
577
825
  shouldUpdateSpecs = await confirm({
578
826
  message: 'Proceed with spec updates?',
@@ -602,15 +850,28 @@ export class ArchiveCommand {
602
850
  for (const update of specUpdates) {
603
851
  const built = await buildUpdatedSpec(update, changeName, { silent: true });
604
852
  mergeNotices.push(...built.notices);
605
- prepared.push({ update, rebuilt: built.rebuilt, crlf: built.crlf, retired: built.retired, counts: built.counts });
853
+ // Inside the prepare pass, so a refused delete outside the
854
+ // project aborts before any spec is written.
855
+ const outside = await checkTargetWithinProject(update, root.path, built.retired);
856
+ if (outside)
857
+ mergeNotices.push(outside);
858
+ prepared.push({ update, rebuilt: built.rebuilt, crlf: built.crlf, retired: built.retired, counts: built.counts, original: built.original });
606
859
  }
607
860
  }
608
861
  catch (err) {
862
+ const message = String(err.message || err);
863
+ // Each merge error is either the delta's or the main spec's; naming
864
+ // the delta for both sent the user to edit a file that was fine.
865
+ const fix = err instanceof MainSpecMergeError
866
+ ? `Fix ${err.specPath} and rerun. No files were changed.`
867
+ : 'Fix the change delta specs and rerun. No files were changed.';
609
868
  if (json) {
610
- throw new ArchiveBlockedError('archive_spec_update_failed', String(err.message || err), 'Fix the change delta specs and rerun. No files were changed.');
869
+ throw new ArchiveBlockedError('archive_spec_update_failed', message, fix);
611
870
  }
612
- console.error(String(err.message || err));
613
- console.error('Aborted. No files were changed.');
871
+ // The diagnostic --json carries, as emitFailure would print it. Its
872
+ // fix already says no files were changed, so no `Aborted.` line.
873
+ console.error(formatLabeled('Error', message));
874
+ console.error(formatLabeled('Fix', fix));
614
875
  process.exitCode = 1;
615
876
  return writeTotalsSentinel;
616
877
  }
@@ -624,36 +885,77 @@ export class ArchiveCommand {
624
885
  if (p.retired)
625
886
  continue;
626
887
  const specName = path.basename(path.dirname(p.update.target));
627
- const report = await new Validator().validateSpecContent(specName, p.rebuilt);
628
- if (!report.valid) {
888
+ const failure = await new Validator().diagnoseRebuiltSpec(specName, p.rebuilt, p.original);
889
+ if (failure !== null) {
890
+ // The same attribution `tospec validate` uses for its preflight
891
+ // warning: a main spec that already fails is the file to edit,
892
+ // and sending the user to the delta sent them to a file that
893
+ // was fine.
894
+ const fix = failure.source === 'main'
895
+ ? `Fix ${failure.specPath} and rerun. No files were changed.`
896
+ : `Run ${`tospec validate ${specName}`} after fixing the change deltas.`;
629
897
  if (json) {
630
- throw new ArchiveBlockedError('archive_spec_validation_failed', `Rebuilt spec for '${specName}' failed validation. No files were changed.`, `Run ${`tospec validate ${specName}`} after fixing the change deltas.`);
898
+ // The findings ride in the message, as the merge's own
899
+ // refusals carry theirs: a separate field would widen the
900
+ // status-entry contract every agent parses.
901
+ const findings = failure.issues
902
+ .filter((i) => i.level === 'ERROR')
903
+ .map((i) => ` ${i.path}: ${indentContinuation(i.message, ' ')}`);
904
+ const lead = failure.source === 'main'
905
+ ? `Main spec ${failure.specPath} fails validation, so the spec rebuilt from it for '${specName}' would too. No files were changed.`
906
+ : `Rebuilt spec for '${specName}' failed validation. No files were changed.`;
907
+ throw new ArchiveBlockedError('archive_spec_validation_failed', [lead, ...findings].join('\n'), fix);
631
908
  }
632
- console.error(`\nValidation errors in rebuilt spec for ${specName} (will not write changes):`);
633
- for (const issue of report.issues) {
909
+ console.error(failure.source === 'main'
910
+ ? `\nError: Main spec ${failure.specPath} fails validation, so the rebuilt spec for ${specName} would too (will not write changes):`
911
+ : `\nError: Validation errors in rebuilt spec for ${specName} (will not write changes):`);
912
+ // A message's later lines (the scenario example some carry) sit
913
+ // under its text, not at the margin where they read as new lines.
914
+ for (const issue of failure.issues) {
634
915
  if (issue.level === 'ERROR')
635
- console.error(` ✗ ${issue.message}`);
916
+ console.error(` ✗ ${issue.path}: ${indentContinuation(issue.message, ' ')}`);
636
917
  else if (issue.level === 'WARNING')
637
- console.error(` ⚠ ${issue.message}`);
918
+ console.error(` ⚠ ${issue.path}: ${indentContinuation(issue.message, ' ')}`);
638
919
  }
639
- console.error('Aborted. No files were changed.');
920
+ console.error(' Aborted. No files were changed.');
921
+ console.error(formatLabeled('Fix', fix));
640
922
  process.exitCode = 1;
641
923
  return writeTotalsSentinel;
642
924
  }
643
925
  }
644
926
  }
645
927
  const writeTotals = { added: 0, modified: 0, removed: 0, renamed: 0, retired: 0 };
646
- for (const p of prepared) {
647
- if (p.retired) {
648
- await retireSpec(p.update, mainSpecsDir, { silent: json });
649
- writeTotals.retired += 1;
928
+ const capabilityOf = (p) => path.basename(path.dirname(p.update.target));
929
+ for (let i = 0; i < prepared.length; i++) {
930
+ const p = prepared[i];
931
+ // A write failing after an earlier one succeeded leaves the project
932
+ // half-merged, which a rerun cannot finish: the specs already
933
+ // written refuse the change's ADDED requirements.
934
+ try {
935
+ if (p.retired) {
936
+ await retireSpec(p.update, mainSpecsDir, { silent: json });
937
+ }
938
+ else {
939
+ await writeUpdatedSpec(p.update, p.rebuilt, p.counts, {
940
+ silent: json,
941
+ crlf: p.crlf,
942
+ });
943
+ }
650
944
  }
651
- else {
652
- await writeUpdatedSpec(p.update, p.rebuilt, p.counts, {
653
- silent: json,
654
- crlf: p.crlf,
945
+ catch (error) {
946
+ if (i === 0)
947
+ throw error;
948
+ throw partiallyAppliedError(error, {
949
+ projectRoot: root.path,
950
+ changeDir,
951
+ archivePath,
952
+ mergedCapabilities: prepared.slice(0, i).map(capabilityOf),
953
+ unwrittenCapabilities: prepared.slice(i).map(capabilityOf),
954
+ changeMoved: false,
655
955
  });
656
956
  }
957
+ if (p.retired)
958
+ writeTotals.retired += 1;
657
959
  writeTotals.added += p.counts.added;
658
960
  writeTotals.modified += p.counts.modified;
659
961
  writeTotals.removed += p.counts.removed;
@@ -675,22 +977,147 @@ export class ArchiveCommand {
675
977
  (writeTotals.retired ? `, retired ${writeTotals.retired}` : ''));
676
978
  console.log('Specs updated successfully.');
677
979
  }
678
- return { totals: writeTotals };
980
+ return {
981
+ totals: writeTotals,
982
+ capabilities: prepared.map(capabilityOf),
983
+ };
679
984
  }, { operation: `archive ${changeName}` });
680
985
  // The human-mode failure paths above already printed and set the exit code.
681
986
  if (locked === writeTotalsSentinel)
682
987
  return null;
683
988
  specsUpdated = true;
684
989
  totals = locked.totals;
990
+ mergedCapabilities = locked.capabilities;
991
+ // Only once the merge wrote: a skipped or refused merge replaced no
992
+ // text another change could have been written against. Warned, not
993
+ // refused, since nothing about this change is wrong.
994
+ for (const warning of await this.findStaleSisterRewrites(changesDir, changeName, specUpdates)) {
995
+ if (json) {
996
+ jsonWarnings.push(warning);
997
+ }
998
+ else {
999
+ console.error(`Warning: ${warning.message}`);
1000
+ console.error(`Fix: ${warning.fix}`);
1001
+ }
1002
+ }
685
1003
  }
686
1004
  }
687
1005
  }
688
1006
  // Name and collision were settled at the reservation above.
689
- await fs.mkdir(archiveDir, { recursive: true });
690
- await moveDirectory(changeDir, archivePath);
691
- // The ticket lives in the global tospec/tickets/ index, outside the change
692
- // dir, so it moves alongside to keep the active index to in-flight changes.
693
- const ticketPaths = await findTicketPaths(root.path, changeName);
1007
+ // From the spec write on, a failure leaves the project half-archived, and a
1008
+ // rerun cannot finish it: the change's ADDED requirements already exist in
1009
+ // the main specs, so the merge refuses them. Reported under its own code
1010
+ // with the manual remaining steps, because `archive_error` with
1011
+ // `archive: null` reads as "nothing changed".
1012
+ let changeMoved = false;
1013
+ try {
1014
+ await fs.mkdir(archiveDir, { recursive: true });
1015
+ await moveDirectory(changeDir, archivePath);
1016
+ changeMoved = true;
1017
+ await this.archiveTicket(root.path, changeName, archiveName, archivePath, json, jsonWarnings);
1018
+ }
1019
+ catch (error) {
1020
+ if (!specsUpdated && !changeMoved)
1021
+ throw error;
1022
+ // moveDirectory removes its partial copy, but that removal can fail too;
1023
+ // the fix names the leftover only when one is actually on disk.
1024
+ const partialCopyLeft = !changeMoved &&
1025
+ (await fs.lstat(archivePath).then(() => true, () => false));
1026
+ throw partiallyAppliedError(error, {
1027
+ projectRoot: root.path,
1028
+ changeDir,
1029
+ archivePath,
1030
+ mergedCapabilities,
1031
+ changeMoved,
1032
+ partialCopyLeft,
1033
+ });
1034
+ }
1035
+ // Warned, not refused: list, status and validate all work on a linked
1036
+ // change, so archive finishing it is consistent; what it cannot do is bring
1037
+ // the content into the project, and that is the reader's to decide.
1038
+ if (linkedTo !== null) {
1039
+ const warning = {
1040
+ code: 'archive_change_linked',
1041
+ message: `Change '${changeName}' is a symlink or junction to ${linkedTo}. Archive moved the link, so tospec/changes/archive/${archiveName} still points there: its content stays outside tospec/changes/archive/ and git does not track it there.`,
1042
+ fix: `To keep the archived change in the project, replace tospec/changes/archive/${archiveName} with a copy of ${linkedTo}.`,
1043
+ };
1044
+ if (json) {
1045
+ jsonWarnings.push({ severity: 'warning', ...warning });
1046
+ }
1047
+ else {
1048
+ console.log(`Warning: ${warning.message}`);
1049
+ console.log(`Fix: ${warning.fix}`);
1050
+ }
1051
+ }
1052
+ if (!json) {
1053
+ console.log(`Change '${changeName}' archived as '${archiveName}'.`);
1054
+ }
1055
+ return {
1056
+ change: changeName,
1057
+ archivedAs: archiveName,
1058
+ path: archivePath,
1059
+ specsUpdated,
1060
+ totals,
1061
+ };
1062
+ }
1063
+ /**
1064
+ * One warning per MODIFIED / REMOVED / RENAMED in another in-progress change
1065
+ * on a requirement this change's merge just wrote. That delta restates the
1066
+ * text from before this archive, often copied from this change's own delta,
1067
+ * and sync may have rewritten the requirement since; archived as is, it
1068
+ * replaces the block and the edit is lost with nothing reporting it.
1069
+ *
1070
+ * Best effort, like the sister read it builds on: an unreadable delta is
1071
+ * skipped, as the archive it warns about has already succeeded.
1072
+ */
1073
+ async findStaleSisterRewrites(changesDir, changeName, specUpdates) {
1074
+ const sisters = await listSisterChangeDeltas(changesDir, changeName);
1075
+ if (sisters.length === 0)
1076
+ return [];
1077
+ const warnings = [];
1078
+ for (const update of specUpdates) {
1079
+ // The delta's folder name, as the sister deltas spell theirs; the target
1080
+ // may carry the on-disk spelling of an existing capability instead.
1081
+ const capability = path.basename(path.dirname(update.source));
1082
+ let touches;
1083
+ try {
1084
+ touches = listDeltaTouches(parseDeltaSpec(await fs.readFile(update.source, 'utf-8')), [
1085
+ 'ADDED',
1086
+ 'MODIFIED',
1087
+ 'REMOVED',
1088
+ 'RENAMED',
1089
+ ]);
1090
+ }
1091
+ catch {
1092
+ continue;
1093
+ }
1094
+ for (const { change, sister } of findSharedRequirements(capability, touches, sisters, [
1095
+ 'MODIFIED',
1096
+ 'REMOVED',
1097
+ 'RENAMED',
1098
+ ])) {
1099
+ warnings.push({
1100
+ severity: 'warning',
1101
+ code: 'archive_sister_change_stale',
1102
+ target: change,
1103
+ // Only what the match proves: both deltas touch the requirement. The
1104
+ // sister's base text is unknown here — with a third change archived in
1105
+ // between, it predates the text this archive replaced — so the message
1106
+ // claims no base.
1107
+ message: `The ${sister.operation} "${sister.requirement}" in change '${change}' (specs/${capability}/spec.md) rewrites a requirement this archive just changed in tospec/specs/${capability}/spec.md; archived as is, it would replace this change's edit with its own text.`,
1108
+ fix: `Rewrite that delta in '${change}' against the updated tospec/specs/${capability}/spec.md before archiving '${change}'.`,
1109
+ });
1110
+ }
1111
+ }
1112
+ return warnings;
1113
+ }
1114
+ /**
1115
+ * Moves the change's ticket out of the active index. The ticket lives in the
1116
+ * global tospec/tickets/ index, outside the change dir, so it moves alongside
1117
+ * to keep the active index to in-flight changes.
1118
+ */
1119
+ async archiveTicket(projectRoot, changeName, archiveName, archivePath, json, jsonWarnings) {
1120
+ const ticketPaths = await findTicketPaths(projectRoot, changeName);
694
1121
  // Newest wins: a second ticket means the change was recreated, so the later
695
1122
  // stamp describes the change being archived. The rest are reported, not
696
1123
  // cleaned up — archive must not silently remove a record.
@@ -709,7 +1136,7 @@ export class ArchiveCommand {
709
1136
  }
710
1137
  }
711
1138
  if (ticketPath) {
712
- const ticketArchiveDir = path.join(root.path, 'tospec', 'tickets', 'archive');
1139
+ const ticketArchiveDir = path.join(projectRoot, 'tospec', 'tickets', 'archive');
713
1140
  await fs.mkdir(ticketArchiveDir, { recursive: true });
714
1141
  const archivedTicketPath = path.join(ticketArchiveDir, path.basename(ticketPath));
715
1142
  // `moveFile` overwrites, and two tickets share a filename when the same
@@ -737,19 +1164,9 @@ export class ArchiveCommand {
737
1164
  else {
738
1165
  await moveFile(ticketPath, archivedTicketPath);
739
1166
  await retargetTicketRef(archivedTicketPath, archiveName);
740
- await appendDecisionLinks(archivedTicketPath, archivePath, root.path);
1167
+ await appendDecisionLinks(archivedTicketPath, archivePath, projectRoot);
741
1168
  }
742
1169
  }
743
- if (!json) {
744
- console.log(`Change '${changeName}' archived as '${archiveName}'.`);
745
- }
746
- return {
747
- change: changeName,
748
- archivedAs: archiveName,
749
- path: archivePath,
750
- specsUpdated,
751
- totals,
752
- };
753
1170
  }
754
1171
  async selectChange(changesDir, projectRoot) {
755
1172
  const { select } = await import('@inquirer/prompts');