@intentius/chant 0.91.0 → 0.93.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 (344) hide show
  1. package/dist/cli/commands/build.d.ts.map +1 -1
  2. package/dist/cli/commands/lint.d.ts.map +1 -1
  3. package/dist/cli/handlers/graph.d.ts.map +1 -1
  4. package/dist/cli/handlers/operator.d.ts +6 -0
  5. package/dist/cli/handlers/operator.d.ts.map +1 -1
  6. package/dist/cli/handlers/run.d.ts.map +1 -1
  7. package/dist/cli/main.d.ts.map +1 -1
  8. package/dist/cli/mcp/workspace-tools.d.ts +2 -1
  9. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  10. package/dist/cli/plugins.d.ts.map +1 -1
  11. package/dist/cli/registry.d.ts +12 -2
  12. package/dist/cli/registry.d.ts.map +1 -1
  13. package/dist/codegen/pinned-upgrade.d.ts.map +1 -1
  14. package/dist/config.d.ts +22 -0
  15. package/dist/config.d.ts.map +1 -1
  16. package/dist/lexicon-module.d.ts +22 -0
  17. package/dist/lexicon-module.d.ts.map +1 -1
  18. package/dist/lifecycle/converge-ledger.d.ts +19 -0
  19. package/dist/lifecycle/converge-ledger.d.ts.map +1 -1
  20. package/dist/lifecycle/run-ledger.d.ts +2 -0
  21. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  22. package/dist/lifecycle/symptoms.d.ts +51 -0
  23. package/dist/lifecycle/symptoms.d.ts.map +1 -1
  24. package/dist/lint/rules/index.d.ts +4 -2
  25. package/dist/lint/rules/index.d.ts.map +1 -1
  26. package/dist/lint/rules/op/index.d.ts +14 -3
  27. package/dist/lint/rules/op/index.d.ts.map +1 -1
  28. package/dist/lint/rules/op/ops014-converge-rule-refusals.d.ts.map +1 -1
  29. package/dist/lint/rules/op/sys010-key-over-plain-http.d.ts +29 -0
  30. package/dist/lint/rules/op/sys010-key-over-plain-http.d.ts.map +1 -0
  31. package/dist/lint/rules/sys001-no-literal-key.d.ts +3 -0
  32. package/dist/lint/rules/sys001-no-literal-key.d.ts.map +1 -0
  33. package/dist/op/__fixtures__/decide-stub-backend.d.ts +53 -0
  34. package/dist/op/__fixtures__/decide-stub-backend.d.ts.map +1 -0
  35. package/dist/op/activities/activity-contracts.d.ts +201 -0
  36. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  37. package/dist/op/activities/apply.d.ts.map +1 -1
  38. package/dist/op/activities/change-coverage.d.ts +44 -0
  39. package/dist/op/activities/change-coverage.d.ts.map +1 -0
  40. package/dist/op/activities/converge.d.ts +24 -1
  41. package/dist/op/activities/converge.d.ts.map +1 -1
  42. package/dist/op/activities/decide.d.ts +107 -0
  43. package/dist/op/activities/decide.d.ts.map +1 -0
  44. package/dist/op/activities/index.d.ts +10 -0
  45. package/dist/op/activities/index.d.ts.map +1 -1
  46. package/dist/op/activities/shell.d.ts +32 -0
  47. package/dist/op/activities/shell.d.ts.map +1 -1
  48. package/dist/op/activities/source-release.d.ts +86 -0
  49. package/dist/op/activities/source-release.d.ts.map +1 -0
  50. package/dist/op/activities/source-rollback.d.ts +100 -0
  51. package/dist/op/activities/source-rollback.d.ts.map +1 -0
  52. package/dist/op/activities/work-evidence.d.ts +42 -0
  53. package/dist/op/activities/work-evidence.d.ts.map +1 -0
  54. package/dist/op/activity-contract.d.ts +1 -0
  55. package/dist/op/activity-contract.d.ts.map +1 -1
  56. package/dist/op/activity-profiles.d.ts +16 -0
  57. package/dist/op/activity-profiles.d.ts.map +1 -1
  58. package/dist/op/builders.d.ts +84 -2
  59. package/dist/op/builders.d.ts.map +1 -1
  60. package/dist/op/composites/converge-op.d.ts +31 -4
  61. package/dist/op/composites/converge-op.d.ts.map +1 -1
  62. package/dist/op/decide-backend.d.ts +116 -0
  63. package/dist/op/decide-backend.d.ts.map +1 -0
  64. package/dist/op/decide-config.d.ts +120 -0
  65. package/dist/op/decide-config.d.ts.map +1 -0
  66. package/dist/op/decide-read-inputs.d.ts +40 -0
  67. package/dist/op/decide-read-inputs.d.ts.map +1 -0
  68. package/dist/op/gate-wait.d.ts +43 -0
  69. package/dist/op/gate-wait.d.ts.map +1 -0
  70. package/dist/op/gate.d.ts +5 -2
  71. package/dist/op/gate.d.ts.map +1 -1
  72. package/dist/op/index.d.ts +11 -4
  73. package/dist/op/index.d.ts.map +1 -1
  74. package/dist/op/local-executor.d.ts +29 -4
  75. package/dist/op/local-executor.d.ts.map +1 -1
  76. package/dist/op/local-output.d.ts +2 -0
  77. package/dist/op/local-output.d.ts.map +1 -1
  78. package/dist/op/op-ir.d.ts +2 -0
  79. package/dist/op/op-ir.d.ts.map +1 -1
  80. package/dist/op/op-verb-class.d.ts.map +1 -1
  81. package/dist/op/operator.d.ts +97 -1
  82. package/dist/op/operator.d.ts.map +1 -1
  83. package/dist/op/runtime.d.ts +37 -5
  84. package/dist/op/runtime.d.ts.map +1 -1
  85. package/dist/op/runtimes/local.d.ts.map +1 -1
  86. package/dist/op/source-archive.d.ts +63 -0
  87. package/dist/op/source-archive.d.ts.map +1 -0
  88. package/dist/op/steward-points.d.ts +136 -0
  89. package/dist/op/steward-points.d.ts.map +1 -0
  90. package/dist/op/steward-turn.d.ts +53 -0
  91. package/dist/op/steward-turn.d.ts.map +1 -0
  92. package/dist/op/steward.d.ts +22 -1
  93. package/dist/op/steward.d.ts.map +1 -1
  94. package/dist/op/types.d.ts +7 -0
  95. package/dist/op/types.d.ts.map +1 -1
  96. package/dist/op/work-lease-run.d.ts +1 -1
  97. package/dist/op/work-lease-run.d.ts.map +1 -1
  98. package/dist/workspace/__fixtures__/decide-workspace.d.ts +100 -0
  99. package/dist/workspace/__fixtures__/decide-workspace.d.ts.map +1 -0
  100. package/dist/workspace/answers-ledger.d.ts +76 -0
  101. package/dist/workspace/answers-ledger.d.ts.map +1 -0
  102. package/dist/workspace/changes-cli.d.ts +16 -0
  103. package/dist/workspace/changes-cli.d.ts.map +1 -0
  104. package/dist/workspace/changes.d.ts +200 -0
  105. package/dist/workspace/changes.d.ts.map +1 -0
  106. package/dist/workspace/chant-migrations/chud-lexicon-exit-fly-site.d.ts +59 -0
  107. package/dist/workspace/chant-migrations/chud-lexicon-exit-fly-site.d.ts.map +1 -0
  108. package/dist/workspace/chant-migrations/chud-lexicon-exit-live-names.d.ts +51 -0
  109. package/dist/workspace/chant-migrations/chud-lexicon-exit-live-names.d.ts.map +1 -0
  110. package/dist/workspace/chant-migrations/chud-lexicon-exit-rollback.d.ts +26 -0
  111. package/dist/workspace/chant-migrations/chud-lexicon-exit-rollback.d.ts.map +1 -0
  112. package/dist/workspace/chant-migrations/chud-lexicon-exit-ship-inputs.d.ts +59 -0
  113. package/dist/workspace/chant-migrations/chud-lexicon-exit-ship-inputs.d.ts.map +1 -0
  114. package/dist/workspace/chant-migrations/chud-lexicon-exit-ship-policy.d.ts +22 -0
  115. package/dist/workspace/chant-migrations/chud-lexicon-exit-ship-policy.d.ts.map +1 -0
  116. package/dist/workspace/chant-migrations/chud-lexicon-exit.d.ts +71 -0
  117. package/dist/workspace/chant-migrations/chud-lexicon-exit.d.ts.map +1 -0
  118. package/dist/workspace/chant-migrations.d.ts +99 -0
  119. package/dist/workspace/chant-migrations.d.ts.map +1 -0
  120. package/dist/workspace/checks/boxes.d.ts +34 -1
  121. package/dist/workspace/checks/boxes.d.ts.map +1 -1
  122. package/dist/workspace/checks/diagrams.d.ts +31 -0
  123. package/dist/workspace/checks/diagrams.d.ts.map +1 -0
  124. package/dist/workspace/checks/records.d.ts +7 -0
  125. package/dist/workspace/checks/records.d.ts.map +1 -1
  126. package/dist/workspace/checks.d.ts +3 -0
  127. package/dist/workspace/checks.d.ts.map +1 -1
  128. package/dist/workspace/compose-graph.d.ts +2 -0
  129. package/dist/workspace/compose-graph.d.ts.map +1 -1
  130. package/dist/workspace/conformance/index.d.ts.map +1 -1
  131. package/dist/workspace/decide.d.ts +49 -4
  132. package/dist/workspace/decide.d.ts.map +1 -1
  133. package/dist/workspace/declaration.d.ts +52 -0
  134. package/dist/workspace/declaration.d.ts.map +1 -1
  135. package/dist/workspace/declaration.schema.json +114 -1
  136. package/dist/workspace/declared-kinds.d.ts +20 -0
  137. package/dist/workspace/declared-kinds.d.ts.map +1 -1
  138. package/dist/workspace/generated-files.d.ts +7 -0
  139. package/dist/workspace/generated-files.d.ts.map +1 -1
  140. package/dist/workspace/intent.d.ts.map +1 -1
  141. package/dist/workspace/lineage-check.d.ts +3 -0
  142. package/dist/workspace/lineage-check.d.ts.map +1 -1
  143. package/dist/workspace/lineage-upgrade.d.ts +6 -1
  144. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  145. package/dist/workspace/ls.d.ts +39 -1
  146. package/dist/workspace/ls.d.ts.map +1 -1
  147. package/dist/workspace/point-answer.schema.json +95 -0
  148. package/dist/workspace/points-cli.d.ts.map +1 -1
  149. package/dist/workspace/points.d.ts +1 -1
  150. package/dist/workspace/reason-codes.d.ts +14 -2
  151. package/dist/workspace/reason-codes.d.ts.map +1 -1
  152. package/dist/workspace/records-cli.d.ts +11 -1
  153. package/dist/workspace/records-cli.d.ts.map +1 -1
  154. package/dist/workspace/records-close.d.ts +1 -1
  155. package/dist/workspace/records-close.d.ts.map +1 -1
  156. package/dist/workspace/records-write.d.ts +11 -5
  157. package/dist/workspace/records-write.d.ts.map +1 -1
  158. package/dist/workspace/records.d.ts +34 -4
  159. package/dist/workspace/records.d.ts.map +1 -1
  160. package/dist/workspace/status-gates.d.ts +7 -2
  161. package/dist/workspace/status-gates.d.ts.map +1 -1
  162. package/dist/workspace/status-stewards.d.ts +59 -1
  163. package/dist/workspace/status-stewards.d.ts.map +1 -1
  164. package/dist/workspace/status.d.ts +16 -0
  165. package/dist/workspace/status.d.ts.map +1 -1
  166. package/dist/workspace/work-cli.d.ts +113 -2
  167. package/dist/workspace/work-cli.d.ts.map +1 -1
  168. package/dist/workspace/work-evidence.d.ts +74 -0
  169. package/dist/workspace/work-evidence.d.ts.map +1 -0
  170. package/dist/workspace/work.d.ts +43 -1
  171. package/dist/workspace/work.d.ts.map +1 -1
  172. package/package.json +1 -1
  173. package/src/cli/commands/audit.test.ts +9 -1
  174. package/src/cli/commands/build.ts +3 -2
  175. package/src/cli/commands/lint.ts +7 -1
  176. package/src/cli/handlers/graph.test.ts +20 -0
  177. package/src/cli/handlers/graph.ts +16 -2
  178. package/src/cli/handlers/operator.ts +34 -2
  179. package/src/cli/handlers/run.test.ts +154 -2
  180. package/src/cli/handlers/run.ts +121 -2
  181. package/src/cli/main.test.ts +10 -0
  182. package/src/cli/main.ts +19 -0
  183. package/src/cli/mcp/workspace-tools.test.ts +35 -19
  184. package/src/cli/mcp/workspace-tools.ts +62 -4
  185. package/src/cli/plugins.ts +11 -3
  186. package/src/cli/registry.ts +12 -2
  187. package/src/codegen/pinned-upgrade.ts +2 -1
  188. package/src/codegen/release-wiring.test.ts +49 -2
  189. package/src/components/capability-plugin-loader.ts +3 -3
  190. package/src/components/cli-support.ts +2 -2
  191. package/src/config.ts +11 -0
  192. package/src/lexicon-module.ts +56 -1
  193. package/src/lexicon-package.test.ts +96 -0
  194. package/src/lifecycle/converge-ledger.ts +20 -0
  195. package/src/lifecycle/run-ledger.ts +6 -0
  196. package/src/lifecycle/symptoms.ts +95 -0
  197. package/src/lint/rules/index.ts +6 -2
  198. package/src/lint/rules/op/index.ts +17 -4
  199. package/src/lint/rules/op/ops014-converge-rule-refusals.test.ts +23 -1
  200. package/src/lint/rules/op/ops014-converge-rule-refusals.ts +10 -6
  201. package/src/lint/rules/op/sys010-key-over-plain-http.test.ts +52 -0
  202. package/src/lint/rules/op/sys010-key-over-plain-http.ts +100 -0
  203. package/src/lint/rules/sys001-no-literal-key.test.ts +31 -0
  204. package/src/lint/rules/sys001-no-literal-key.ts +67 -0
  205. package/src/op/__fixtures__/decide-stub-backend.ts +143 -0
  206. package/src/op/activities/activity-contracts.ts +190 -1
  207. package/src/op/activities/apply.ts +7 -6
  208. package/src/op/activities/change-coverage.ts +60 -0
  209. package/src/op/activities/converge.test.ts +55 -1
  210. package/src/op/activities/converge.ts +208 -29
  211. package/src/op/activities/decide.live.test.ts +78 -0
  212. package/src/op/activities/decide.test.ts +300 -0
  213. package/src/op/activities/decide.ts +267 -0
  214. package/src/op/activities/index.ts +21 -0
  215. package/src/op/activities/shell.test.ts +91 -1
  216. package/src/op/activities/shell.ts +91 -2
  217. package/src/op/activities/source-release.test.ts +118 -0
  218. package/src/op/activities/source-release.ts +168 -0
  219. package/src/op/activities/source-rollback.test.ts +131 -0
  220. package/src/op/activities/source-rollback.ts +239 -0
  221. package/src/op/activities/work-evidence.ts +62 -0
  222. package/src/op/activity-contract-registry.test.ts +2 -0
  223. package/src/op/activity-contract-registry.ts +2 -2
  224. package/src/op/activity-contract.test.ts +12 -0
  225. package/src/op/activity-contract.ts +6 -1
  226. package/src/op/activity-profiles.ts +29 -0
  227. package/src/op/activity-registry.test.ts +1 -1
  228. package/src/op/activity-registry.ts +2 -2
  229. package/src/op/builders.ts +119 -5
  230. package/src/op/command-gate.test.ts +192 -0
  231. package/src/op/composites/converge-op.test.ts +27 -1
  232. package/src/op/composites/converge-op.ts +69 -15
  233. package/src/op/decide-backend.test.ts +44 -0
  234. package/src/op/decide-backend.ts +212 -0
  235. package/src/op/decide-config.test.ts +52 -0
  236. package/src/op/decide-config.ts +80 -0
  237. package/src/op/decide-read-inputs.ts +117 -0
  238. package/src/op/gate-approval.ts +2 -2
  239. package/src/op/gate-wait.ts +57 -0
  240. package/src/op/gate.ts +8 -3
  241. package/src/op/generate-pipeline.ts +2 -2
  242. package/src/op/index.ts +18 -3
  243. package/src/op/local-executor.ts +151 -15
  244. package/src/op/local-output.test.ts +33 -0
  245. package/src/op/local-output.ts +20 -2
  246. package/src/op/op-ir.test.ts +10 -0
  247. package/src/op/op-ir.ts +4 -0
  248. package/src/op/op-verb-class.ts +1 -0
  249. package/src/op/operator.ts +290 -11
  250. package/src/op/runtime.ts +31 -8
  251. package/src/op/runtimes/local.ts +3 -1
  252. package/src/op/source-archive.ts +161 -0
  253. package/src/op/steward-points.test.ts +313 -0
  254. package/src/op/steward-points.ts +194 -0
  255. package/src/op/steward-turn.ts +79 -0
  256. package/src/op/steward.test.ts +87 -3
  257. package/src/op/steward.ts +25 -1
  258. package/src/op/types.ts +7 -0
  259. package/src/op/work-lease-run.test.ts +20 -1
  260. package/src/op/work-lease-run.ts +2 -2
  261. package/src/workspace/__fixtures__/decide-workspace.ts +129 -0
  262. package/src/workspace/__fixtures__/studio-template-84c8b21.json +67 -0
  263. package/src/workspace/answers-ledger.ts +118 -0
  264. package/src/workspace/changes-cli.ts +56 -0
  265. package/src/workspace/changes.schema.json +472 -0
  266. package/src/workspace/changes.test.ts +315 -0
  267. package/src/workspace/changes.ts +512 -0
  268. package/src/workspace/chant-migrations/chud-lexicon-exit-fly-site.ts +234 -0
  269. package/src/workspace/chant-migrations/chud-lexicon-exit-live-names.ts +164 -0
  270. package/src/workspace/chant-migrations/chud-lexicon-exit-rollback.ts +223 -0
  271. package/src/workspace/chant-migrations/chud-lexicon-exit-ship-inputs.ts +195 -0
  272. package/src/workspace/chant-migrations/chud-lexicon-exit-ship-policy.ts +108 -0
  273. package/src/workspace/chant-migrations/chud-lexicon-exit.ts +882 -0
  274. package/src/workspace/chant-migrations.e2e.test.ts +1089 -0
  275. package/src/workspace/chant-migrations.ts +164 -0
  276. package/src/workspace/check-contract.test.ts +4 -2
  277. package/src/workspace/check.schema.json +15 -5
  278. package/src/workspace/checks/boxes.test.ts +80 -1
  279. package/src/workspace/checks/boxes.ts +136 -1
  280. package/src/workspace/checks/diagrams.test.ts +134 -0
  281. package/src/workspace/checks/diagrams.ts +108 -0
  282. package/src/workspace/checks/records.ts +47 -0
  283. package/src/workspace/checks.ts +8 -1
  284. package/src/workspace/compose-graph.ts +2 -0
  285. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +10 -0
  286. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +26 -0
  287. package/src/workspace/conformance/index.ts +5 -0
  288. package/src/workspace/decide.test.ts +1 -1
  289. package/src/workspace/decide.ts +156 -23
  290. package/src/workspace/declaration.schema.json +114 -1
  291. package/src/workspace/declaration.test.ts +58 -0
  292. package/src/workspace/declaration.ts +104 -0
  293. package/src/workspace/declared-kinds.test.ts +5 -5
  294. package/src/workspace/declared-kinds.ts +46 -1
  295. package/src/workspace/generated-files.ts +14 -0
  296. package/src/workspace/graph-cli.ts +1 -1
  297. package/src/workspace/graph.schema.json +9 -1
  298. package/src/workspace/intent.schema.json +8 -2
  299. package/src/workspace/intent.ts +3 -10
  300. package/src/workspace/lineage-check.ts +16 -1
  301. package/src/workspace/lineage-upgrade-cli.ts +1 -1
  302. package/src/workspace/lineage-upgrade.test.ts +70 -0
  303. package/src/workspace/lineage-upgrade.ts +81 -10
  304. package/src/workspace/ls-contract.test.ts +57 -0
  305. package/src/workspace/ls.schema.json +46 -1
  306. package/src/workspace/ls.ts +62 -3
  307. package/src/workspace/points-cli.ts +14 -3
  308. package/src/workspace/points-write.schema.json +11 -1
  309. package/src/workspace/points.schema.json +33 -0
  310. package/src/workspace/points.test.ts +1 -1
  311. package/src/workspace/points.ts +1 -1
  312. package/src/workspace/read-contract.test.ts +1 -1
  313. package/src/workspace/reason-codes.test.ts +11 -1
  314. package/src/workspace/reason-codes.ts +18 -2
  315. package/src/workspace/records-amend.schema.json +2 -0
  316. package/src/workspace/records-cli.ts +13 -3
  317. package/src/workspace/records-close.schema.json +2 -0
  318. package/src/workspace/records-new.schema.json +2 -0
  319. package/src/workspace/records-review.schema.json +2 -0
  320. package/src/workspace/records-sessions-write.e2e.test.ts +77 -0
  321. package/src/workspace/records-sessions-write.test.ts +2 -34
  322. package/src/workspace/records-write.e2e.test.ts +108 -0
  323. package/src/workspace/records-write.test.ts +32 -41
  324. package/src/workspace/records-write.ts +33 -15
  325. package/src/workspace/records.schema.json +36 -2
  326. package/src/workspace/records.test.ts +48 -3
  327. package/src/workspace/records.ts +109 -3
  328. package/src/workspace/status-contract.test.ts +13 -8
  329. package/src/workspace/status-gates.ts +9 -4
  330. package/src/workspace/status-stewards.ts +108 -4
  331. package/src/workspace/status.schema.json +111 -7
  332. package/src/workspace/status.ts +36 -1
  333. package/src/workspace/work-acceptance.test.ts +274 -0
  334. package/src/workspace/{work-cli.test.ts → work-cli.e2e.test.ts} +1 -1
  335. package/src/workspace/work-cli.ts +193 -10
  336. package/src/workspace/work-evidence.schema.json +212 -0
  337. package/src/workspace/work-evidence.ts +169 -0
  338. package/src/workspace/work-history.schema.json +349 -0
  339. package/src/workspace/work-history.test.ts +156 -0
  340. package/src/workspace/work.test.ts +3 -2
  341. package/src/workspace/work.ts +100 -1
  342. /package/src/cli/{discovery-skip.test.ts → discovery-skip.e2e.test.ts} +0 -0
  343. /package/src/workspace/conformance/{conformance.test.ts → conformance.e2e.test.ts} +0 -0
  344. /package/src/workspace/{which-chant.test.ts → which-chant.e2e.test.ts} +0 -0
@@ -2,14 +2,13 @@
2
2
  * `chant workspace records new|amend|review` (#2670): each command validates
3
3
  * before it writes, writes exactly one file or none, and refuses with a closed
4
4
  * code. Run in a directory outside git holding a copy of the decision kind.
5
+ * The same commands through the CLI are in records-write.e2e.test.ts (#2817).
5
6
  */
6
7
 
7
- import { execFileSync } from "node:child_process";
8
8
  import { createHash } from "node:crypto";
9
- import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
9
+ import { cpSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
10
10
  import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
- import { pathToFileURL } from "node:url";
13
12
  import { afterEach, beforeEach, describe, expect, test } from "vitest";
14
13
  import { parseFrontMatter, recordTextDigest } from "./records";
15
14
  import { allocateId, amendRecord, newRecord, renderRecord, reviewRecord, toYaml } from "./records-write";
@@ -187,7 +186,7 @@ describe("records new", () => {
187
186
  });
188
187
 
189
188
  test("keeps the width of the ids it follows", () => {
190
- const entry = (id: string) => ({ id, path: `d/${id}-x.md`, state: null, valid: true, reasons: [], supersededBy: null, data: {}, digest: "", assets: [], warnings: [] });
189
+ const entry = (id: string) => ({ id, path: `d/${id}-x.md`, state: null, valid: true, reasons: [], supersededBy: null, remediatedBy: [], data: {}, digest: "", assets: [], warnings: [] });
191
190
  expect(allocateId([entry("s-0009"), entry("s-0010")], undefined, "k")).toBe("s-0011");
192
191
  expect(allocateId([entry("s-999")], undefined, "k")).toBe("s-1000");
193
192
  // Case and padding come from the records (#2683).
@@ -225,6 +224,35 @@ describe("records new", () => {
225
224
  test("a kind that can't be read is its read error", async () => {
226
225
  expect(code(await newRecord({ kind: "missing.kind.mjs", fields: fields(fresh()), cwd: dir }))).toBe("kind-unreadable");
227
226
  });
227
+
228
+ test("--by names the proposer of a record that opens proposed, apart from decided_by (#2756)", async () => {
229
+ const { id: _id, ...freshProposal } = proposal({ title: "Filed by someone" });
230
+ const proposed = await newRecord({ kind: KIND, fields: fields(freshProposal), by: "carol", cwd: dir });
231
+ expect(code(proposed)).toBe("ok");
232
+ const proposedPath = (proposed as { path: string }).path.replace("decisions/", "");
233
+ expect(data(proposedPath)).toMatchObject({ proposed_by: "carol", decided_by: null });
234
+
235
+ // A conflicting proposed_by given in the fields is refused, same as a conflicting decided_by is today.
236
+ const conflict = await newRecord({ kind: KIND, fields: fields({ ...freshProposal, title: "Two proposers", proposed_by: "dave" }), by: "carol", cwd: dir });
237
+ expect(code(conflict)).toBe("write-input-invalid");
238
+ });
239
+
240
+ test("--by names the decider when a new record opens straight into a later state", async () => {
241
+ const { id: _id, decided_by: _db, ...freshDecided } = decision({ title: "Decided at once" });
242
+ const decidedDoc = await newRecord({ kind: KIND, fields: fields({ ...freshDecided, decided_by: null }), by: "dana", cwd: dir });
243
+ expect(code(decidedDoc)).toBe("ok");
244
+ const decidedPath = (decidedDoc as { path: string }).path.replace("decisions/", "");
245
+ expect(data(decidedPath)).toMatchObject({ decided_by: "dana" });
246
+ expect(data(decidedPath)).not.toHaveProperty("proposed_by");
247
+ });
248
+
249
+ test("--by is refused for a kind whose new record opens decided and declares no reviews", async () => {
250
+ const kindFile = join(dir, "decisions", "decision.kind.mjs");
251
+ writeFileSync(kindFile, readFileSync(kindFile, "utf-8").replace(/^\s*reviews: \{[^}]*\},?\n/m, ""));
252
+ const { id: _id, decided_by: _db, ...freshDecided } = decision({ title: "No reviews declared" });
253
+ const doc = await newRecord({ kind: KIND, fields: fields({ ...freshDecided, decided_by: null }), by: "erin", cwd: dir });
254
+ expect(code(doc)).toBe("write-usage-invalid");
255
+ });
228
256
  });
229
257
 
230
258
  describe("records amend", () => {
@@ -336,40 +364,3 @@ describe("records review", () => {
336
364
  expect(code(await reviewRecord({ kind: KIND, id: "ws-001", verdict: "agree", by: "a", cwd: dir }))).toBe("review-unsupported");
337
365
  });
338
366
  });
339
-
340
- describe("the command line", () => {
341
- const cli = (args: string[], input?: string) => {
342
- const loader = pathToFileURL(join(REPO, "node_modules", "tsx", "dist", "loader.mjs")).href;
343
- try {
344
- const out = execFileSync(process.execPath, ["--import", loader, join(REPO, "packages", "core", "src", "cli", "main.ts"), "workspace", "records", ...args], {
345
- cwd: dir,
346
- input,
347
- encoding: "utf-8",
348
- env: { ...process.env, TSX_DISABLE_CACHE: "1" },
349
- stdio: ["pipe", "pipe", "pipe"],
350
- });
351
- return { status: 0, doc: JSON.parse(out) };
352
- } catch (err) {
353
- const e = err as { status: number; stdout: string };
354
- return { status: e.status, doc: JSON.parse(e.stdout) };
355
- }
356
- };
357
-
358
- test(
359
- "new reads fields from standard input, review appends, and a refusal exits 1 with its code",
360
- () => {
361
- const { id: _id, ...rest } = decision({ title: "From stdin" });
362
- const made = cli(["new", KIND, "--from", "-"], JSON.stringify(rest));
363
- expect(made).toMatchObject({ status: 0, doc: { id: "ws-004", path: "decisions/ws-004-from-stdin.md" } });
364
- expect(existsSync(join(dir, "decisions", "ws-004-from-stdin.md"))).toBe(true);
365
- const reviewed = cli(["review", "ws-004", "--kind", KIND, "--verdict", "agree", "--by", "alice"]);
366
- expect(reviewed).toMatchObject({ status: 0, doc: { review: { reviewer: "alice", verdict: "agree" } } });
367
- const refused = cli(["review", "ws-004", "--kind", KIND, "--verdict", "dissent", "--by", "bob"]);
368
- expect(refused).toMatchObject({ status: 1, doc: { error: { code: "review-note-required" } } });
369
- writeFileSync(join(dir, "patch.json"), JSON.stringify({ title: "Changed" }));
370
- expect(cli(["amend", "ws-004", "--kind", KIND, "--set", "patch.json"])).toMatchObject({ status: 1, doc: { error: { code: "amend-supersede-instead" } } });
371
- expect(cli(["amend", "ws-004", "--set", "patch.json"])).toMatchObject({ status: 1, doc: { error: { code: "write-usage-invalid" } } });
372
- },
373
- 120_000,
374
- );
375
- });
@@ -629,9 +629,15 @@ export interface WriteChannel {
629
629
  opensInitial?: boolean;
630
630
  }
631
631
 
632
- /** What a channel's write adds to the command line's: who the author is, and how it came. */
632
+ /** What a channel's write adds to the command line's: who named the author, and how it came. */
633
633
  export interface ChannelOptions {
634
- /** The record's author, written to the kind's `reviews.decider` field: the person or agent that decided. */
634
+ /**
635
+ * Who the caller names as the record's author (#2756). Written to the
636
+ * kind's `proposedBy` field when the kind declares one and the record
637
+ * opens (or stays) in its first state — a proposal naming its proposer.
638
+ * Otherwise written to `reviews.decider`, as before: a record opening (or
639
+ * moving) straight to another state names who decided it.
640
+ */
635
641
  by?: string;
636
642
  through?: WriteChannel;
637
643
  }
@@ -640,18 +646,8 @@ export interface ChannelOptions {
640
646
  function applyChannel(o: Opened, fields: Record<string, unknown>, opts: ChannelOptions, isNew: boolean, flag: string): Record<string, unknown> {
641
647
  const { kind } = o.loaded;
642
648
  const out = { ...fields };
643
- if (opts.by !== undefined) {
644
- if (!kind.reviews) throw new RecordWriteError("write-usage-invalid", `by names a record's author, the kind's reviews.decider field, and the ${kind.name} kind declares no reviews`);
645
- if (opts.by.trim() === "") throw new RecordWriteError("write-usage-invalid", "by needs the name of the person or agent that decided");
646
- const f = kind.reviews.decider;
647
- if (out[f] !== undefined && out[f] !== null && out[f] !== opts.by) {
648
- throw new RecordWriteError("write-input-invalid", `the fields given with ${flag} set ${f} to ${JSON.stringify(out[f])}, and by names ${JSON.stringify(opts.by)}: give one author`);
649
- }
650
- out[f] = opts.by;
651
- }
652
649
  const through = opts.through;
653
- if (!through) return out;
654
- if (isNew && through.opensInitial && kind.states && kind.stateField !== undefined) {
650
+ if (isNew && through?.opensInitial && kind.states && kind.stateField !== undefined) {
655
651
  const first = kind.states[0];
656
652
  const state = out[kind.stateField];
657
653
  if (state === undefined) out[kind.stateField] = first;
@@ -662,6 +658,27 @@ function applyChannel(o: Opened, fields: Record<string, unknown>, opts: ChannelO
662
658
  );
663
659
  }
664
660
  }
661
+ if (opts.by !== undefined) {
662
+ if (opts.by.trim() === "") throw new RecordWriteError("write-usage-invalid", "by needs the name of the person or agent that proposed or decided");
663
+ // A new record that opens in the kind's first state is a proposal, when the kind opts in with proposedBy (#2756):
664
+ // by names the proposer, kept apart from reviews.decider, which stays null until the record is decided.
665
+ const opensInitial = isNew && !!kind.states && kind.stateField !== undefined && (out[kind.stateField] === undefined || out[kind.stateField] === kind.states[0]);
666
+ if (kind.proposedBy && opensInitial) {
667
+ const f = kind.proposedBy.field;
668
+ if (out[f] !== undefined && out[f] !== null && out[f] !== opts.by) {
669
+ throw new RecordWriteError("write-input-invalid", `the fields given with ${flag} set ${f} to ${JSON.stringify(out[f])}, and by names ${JSON.stringify(opts.by)}: give one proposer`);
670
+ }
671
+ out[f] = opts.by;
672
+ } else {
673
+ if (!kind.reviews) throw new RecordWriteError("write-usage-invalid", `by names a record's author, the kind's reviews.decider field, and the ${kind.name} kind declares no reviews`);
674
+ const f = kind.reviews.decider;
675
+ if (out[f] !== undefined && out[f] !== null && out[f] !== opts.by) {
676
+ throw new RecordWriteError("write-input-invalid", `the fields given with ${flag} set ${f} to ${JSON.stringify(out[f])}, and by names ${JSON.stringify(opts.by)}: give one author`);
677
+ }
678
+ out[f] = opts.by;
679
+ }
680
+ }
681
+ if (!through) return out;
665
682
  if (kind.source && (isNew || kind.source.field in out)) {
666
683
  const f = kind.source.field;
667
684
  const block = out[f];
@@ -984,7 +1001,7 @@ async function seal(sign: string | true, cwd: string, v: { record: string; diges
984
1001
  // ── The command ──────────────────────────────────────────────────────────────
985
1002
 
986
1003
  export const WRITE_USAGE = [
987
- "chant workspace records new [<kind file or declared kind>] --from <file|-> [--prefix <prefix>] [--sign [<key file>]] [--dry-run]",
1004
+ "chant workspace records new [<kind file or declared kind>] --from <file|-> [--prefix <prefix>] [--by <name>] [--sign [<key file>]] [--dry-run]",
988
1005
  "chant workspace records amend <id> [--kind <kind file>] --set <file|-> [--sign [<key file>]] [--dry-run]",
989
1006
  "chant workspace records review <id> [--kind <kind file>] --verdict agree|dissent|abstain --by <principal> [--note <text>] [--session <id>] [--sign [<key file>]] [--dry-run]",
990
1007
  "chant workspace records close <session id> [--kind <session kind file>] [--dry-run]",
@@ -1087,7 +1104,7 @@ export async function runRecordsWrite(ctx: CommandContext): Promise<number> {
1087
1104
  if (typeof kind !== "string") return print(kind);
1088
1105
  const input = readInput(RECORDS_NEW_SCHEMA_ID, "--from", args.migrateFrom, cwd);
1089
1106
  if (typeof input !== "string") return print(input);
1090
- return print(await newRecord({ kind, fields: input, prefix: args.prefix, sign: args.sign, dryRun: args.dryRun, cwd }));
1107
+ return print(await newRecord({ kind, fields: input, prefix: args.prefix, by: args.by, sign: args.sign, dryRun: args.dryRun, cwd }));
1091
1108
  }
1092
1109
  const schema = verb === "amend" ? RECORDS_AMEND_SCHEMA_ID : RECORDS_REVIEW_SCHEMA_ID;
1093
1110
  const id = args.extraPositional2;
@@ -1095,6 +1112,7 @@ export async function runRecordsWrite(ctx: CommandContext): Promise<number> {
1095
1112
  const kind = args.kind !== undefined ? resolveWriteKind(args.kind, cwd) : declaredWriteKind(schema, cwd, "--kind <kind file> is required");
1096
1113
  if (typeof kind !== "string") return print(kind);
1097
1114
  if (verb === "amend") {
1115
+ if (args.by !== undefined) return print(usage(schema, "--by is not taken by amend: set decided_by, or a kind's proposedBy field, with --set"));
1098
1116
  const input = readInput(schema, "--set", args.set, cwd);
1099
1117
  if (typeof input !== "string") return print(input);
1100
1118
  return print(await amendRecord({ kind, id, fields: input, sign: args.sign, dryRun: args.dryRun, cwd }));
@@ -116,7 +116,7 @@
116
116
  },
117
117
  "record": {
118
118
  "type": "object",
119
- "required": ["id", "path", "state", "valid", "reasons", "supersededBy", "data"],
119
+ "required": ["id", "path", "state", "valid", "reasons", "supersededBy", "remediatedBy", "data"],
120
120
  "properties": {
121
121
  "id": { "type": ["string", "null"], "description": "Null when the record could not be parsed or has no string id. For a content-addressed kind (idFrom sha256, added by #2664), the lowercase hex SHA-256 of the file's bytes, the same string a pin of the file carries." },
122
122
  "path": { "type": "string", "description": "From the repository root, with / separators." },
@@ -127,6 +127,11 @@
127
127
  "type": ["string", "null"],
128
128
  "description": "The id of the closed record whose supersedes link replaces this one. Derived from that record's link, never from this record's own fields."
129
129
  },
130
+ "remediatedBy": {
131
+ "description": "Added in contract 1 by #2774. The ids of records whose remediates link names this one, in path order. Unlike supersededBy, several records may remediate the same one, and a remediation never changes this record's state or supersededBy: it stays exactly as current as it was.",
132
+ "type": "array",
133
+ "items": { "type": "string" }
134
+ },
130
135
  "data": {
131
136
  "type": ["object", "null"],
132
137
  "description": "The record's structured core as JSON: the front matter, or for a kind of format json the whole file (#2664). It follows the kind's schema when valid is true. Null when it could not be parsed."
@@ -138,7 +143,7 @@
138
143
  "items": { "$ref": "#/$defs/asset" }
139
144
  },
140
145
  "warnings": {
141
- "description": "Added in contract 1 by #2549. Findings that leave the record valid: a pinned file that changed, went missing or did not follow a superseding decision, a supersedes link that has no effect yet, (added by #2654) an evidence list that is empty, (added by #2672) a verdict that names no digest, or (added by #2683) a work record's links and dates, or (added by #2688) a record whose author seal is not attested under a signers file at base. A work record never carries record-no-evidence; a done one with no evidence carries work-done-unpinned. valid and --current do not look at them.",
146
+ "description": "Added in contract 1 by #2549. Findings that leave the record valid: a pinned file that changed, went missing or did not follow a superseding decision, a supersedes link that has no effect yet, (added by #2654) an evidence list that is empty, (added by #2672) a verdict that names no digest, or (added by #2683) a work record's links and dates, or (added by #2688) a record whose author seal is not attested under a signers file at base, or (added by #2772) a work record's acceptance criteria unmet when it is done, or met by a manual verdict from its implementer. A work record never carries record-no-evidence; a done one with no evidence carries work-done-unpinned. valid and --current do not look at them.",
142
147
  "type": "array",
143
148
  "items": { "$ref": "#/$defs/warning" }
144
149
  },
@@ -178,6 +183,10 @@
178
183
  "type": "array",
179
184
  "items": { "$ref": "#/$defs/workLink" }
180
185
  },
186
+ "acceptance": {
187
+ "description": "Added in contract 1 by #2772, on every parsed record of a work kind whose work block names acceptance criteria. How many of the record's criteria passing evidence meets: evidence naming the criterion, with result pass and the criterion's verification, and for a manual criterion a by other than the implementer. Null when the record lists no criteria, so a record without them reads as before.",
188
+ "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/workAcceptance" }]
189
+ },
181
190
  "lease": {
182
191
  "description": "Added in contract 1 by #2732, on every record of a work kind with an id, read in the working tree (absent under --at). The item's active work lease (chant workspace work claim), or null when nobody holds one live. Read from the local lease refs, refs/chant/lease/[_members/<member>/]work/<id> and the remote's as last fetched, of the ledger owning the work kind file; records never fetches.",
183
192
  "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/workLease" }]
@@ -191,6 +200,27 @@
191
200
  }
192
201
  ]
193
202
  },
203
+ "workAcceptance": {
204
+ "type": "object",
205
+ "required": ["met", "total", "criteria"],
206
+ "properties": {
207
+ "met": { "type": "integer", "minimum": 0, "description": "How many criteria passing evidence meets." },
208
+ "total": { "type": "integer", "minimum": 0, "description": "How many criteria the record lists." },
209
+ "criteria": {
210
+ "type": "array",
211
+ "description": "Each criterion, in the record's order.",
212
+ "items": {
213
+ "type": "object",
214
+ "required": ["id", "verification", "met"],
215
+ "properties": {
216
+ "id": { "type": "string" },
217
+ "verification": { "enum": ["unit", "integration", "e2e", "runtime", "manual"], "description": "The verification the criterion expects." },
218
+ "met": { "type": "boolean" }
219
+ }
220
+ }
221
+ }
222
+ }
223
+ },
194
224
  "workLease": {
195
225
  "type": "object",
196
226
  "required": ["holder", "token", "acquiredAt", "expiresAt"],
@@ -231,6 +261,8 @@
231
261
  "work-done-unpinned",
232
262
  "work-closed-without-date",
233
263
  "work-done-gap-open",
264
+ "work-acceptance-unmet",
265
+ "work-acceptance-self-verified",
234
266
  "answer-points-unreadable",
235
267
  "answer-point-unknown",
236
268
  "answer-point-changed",
@@ -372,6 +404,8 @@
372
404
  "record-id-duplicate",
373
405
  "record-supersedes-unknown",
374
406
  "record-supersedes-conflict",
407
+ "record-remediates-unknown",
408
+ "record-remediates-not-closed",
375
409
  "session-seal-mismatch",
376
410
  "session-verdict-unknown-record"
377
411
  ]
@@ -11,12 +11,17 @@ const REPO = join(import.meta.dirname, "..", "..", "..", "..");
11
11
  const DECISIONS = join(REPO, "docs", "design", "decisions");
12
12
  const SAMPLE = readFileSync(join(DECISIONS, "ws-003-seal-scope.md"), "utf-8");
13
13
 
14
- /** A copy of a real decision with its id, state and supersedes links replaced. */
15
- function decision(id: string, state = "decided", supersedes: string[] = []): string {
14
+ /** A copy of a real decision with its id, state, supersedes and remediates links replaced. */
15
+ function decision(id: string, state = "decided", supersedes: string[] = [], remediates: string[] = []): string {
16
16
  const links = supersedes.length === 0 ? "supersedes: []" : `supersedes:\n${supersedes.map((d) => ` - decision: "${d}"`).join("\n")}`;
17
- return SAMPLE.replace(/^id: .*$/m, `id: "${id}"`)
17
+ let text = SAMPLE.replace(/^id: .*$/m, `id: "${id}"`)
18
18
  .replace(/^state: .*$/m, `state: "${state}"`)
19
19
  .replace(/^supersedes:(?: \[\])?\n(?: .*\n)*/m, `${links}\n`);
20
+ if (remediates.length > 0) {
21
+ const block = `remediates:\n${remediates.map((d) => ` - decision: "${d}"`).join("\n")}\n`;
22
+ text = text.replace(/^evidence:/m, `${block}evidence:`);
23
+ }
24
+ return text;
20
25
  }
21
26
 
22
27
  let dir: string;
@@ -235,6 +240,46 @@ describe("readRecords", () => {
235
240
  expect(codes(records[2])).toEqual(["record-supersedes-conflict"]);
236
241
  });
237
242
 
243
+ test("a remediates link to a missing id is record-remediates-unknown", async () => {
244
+ write("ws-002-b.md", decision("ws-002", "decided", [], ["ws-404"]));
245
+ const [r] = (await read()).records;
246
+ expect(codes(r)).toEqual(["record-remediates-unknown"]);
247
+ });
248
+
249
+ test("a remediates link to an open record is record-remediates-not-closed: a record still open is amended instead", async () => {
250
+ write("ws-001-a.md", decision("ws-001", "decided"));
251
+ write("ws-002-b.md", decision("ws-002", "ratified", [], ["ws-001"]));
252
+ const [ws001, ws002] = (await read()).records;
253
+ expect(codes(ws002)).toEqual(["record-remediates-not-closed"]);
254
+ expect(ws001.remediatedBy).toEqual([]);
255
+ });
256
+
257
+ test("remediation leaves the target current and its state unchanged, and several records may remediate the same one (#2774)", async () => {
258
+ write("ws-001-a.md", decision("ws-001", "ratified"));
259
+ write("ws-002-b.md", decision("ws-002", "ratified", [], ["ws-001"]));
260
+ write("ws-003-c.md", decision("ws-003", "ratified", [], ["ws-001"]));
261
+ const all = await read();
262
+ const [ws001] = all.records;
263
+ expect(ws001.state).toBe("ratified");
264
+ expect(ws001.supersededBy).toBeNull();
265
+ expect(ws001.remediatedBy).toEqual(["ws-002", "ws-003"]);
266
+ expect(all.summary.superseded).toBe(0);
267
+ const current = await read({ current: true });
268
+ expect(current.records.map((r) => r.id)).toEqual(["ws-001", "ws-002", "ws-003"]);
269
+ });
270
+
271
+ test("remediates and supersedes are independent links, and a record may carry both to different targets (#2774)", async () => {
272
+ write("ws-001-a.md", decision("ws-001", "ratified"));
273
+ write("ws-002-b.md", decision("ws-002", "ratified"));
274
+ write("ws-003-c.md", decision("ws-003", "ratified", ["ws-002"], ["ws-001"]));
275
+ const all = await read();
276
+ expect(all.records.map((r) => [r.id, r.supersededBy, r.remediatedBy])).toEqual([
277
+ ["ws-001", null, ["ws-003"]],
278
+ ["ws-002", "ws-003", []],
279
+ ["ws-003", null, []],
280
+ ]);
281
+ });
282
+
238
283
  test("a missing records directory is location-missing", async () => {
239
284
  const loaded = await loadRecordKind(join(dir, "decisions", "decision.kind.mjs"));
240
285
  const moved: LoadedRecordKind = { ...loaded, dir: join(dir, "nowhere") };
@@ -6,8 +6,10 @@
6
6
  * record a kind locates, parses its structured core (the front matter, as the
7
7
  * JSON subset of YAML, or the whole file as one I-JSON object, ws-053),
8
8
  * validates it against the kind's schema and derives supersession from the
9
- * records' own `supersedes` links. It never writes a record; `records-write.ts`
10
- * does, through the rules here (#2670).
9
+ * records' own `supersedes` links, and remediation from their `remediates`
10
+ * links (#2774): a later record fixing a closed one's consequence without
11
+ * replacing it, which leaves the closed record's status unchanged. It never
12
+ * writes a record; `records-write.ts` does, through the rules here (#2670).
11
13
  *
12
14
  * A record that fails any of that is still returned, with reason codes, and the
13
15
  * read succeeds. Only a failure to read the kind, its schema or the revision is
@@ -31,7 +33,7 @@ import { joinSessions, type SessionCitation } from "./record-sessions";
31
33
  import type { RecordSource } from "./record-source";
32
34
  import { sourceBlock, sourceBlockProblems, transcriptDrift } from "./source-block";
33
35
  import type { WorkspaceTree } from "./tree";
34
- import type { DecisionWork, WorkLink, WorkWarningCode } from "./work";
36
+ import type { DecisionWork, WorkAcceptance, WorkLink, WorkWarningCode } from "./work";
35
37
  import type { AnswerWarningCode } from "./points";
36
38
 
37
39
  // ── Reason codes ─────────────────────────────────────────────────────────────
@@ -51,6 +53,10 @@ export const RECORD_REASON_CODES = [
51
53
  "record-supersedes-unknown",
52
54
  /** A second closed record supersedes a record another one already superseded. */
53
55
  "record-supersedes-conflict",
56
+ /** A `remediates` link names an id no record has (#2774). */
57
+ "record-remediates-unknown",
58
+ /** A `remediates` link names a record that isn't closed: a record still open is amended instead (#2774). */
59
+ "record-remediates-not-closed",
54
60
  /** A closed session's seal is not the digest of its text: it changed after it closed (#2673). */
55
61
  "session-seal-mismatch",
56
62
  /** A session's verdict names a record the kind's subject records do not have (#2673). */
@@ -279,6 +285,16 @@ export const recordKindSchema = z
279
285
  * (ws-053). Optional, and only on a kind with states.
280
286
  */
281
287
  supersedes: z.object({ field: z.string().min(1), key: z.string().min(1).optional() }).strict().optional(),
288
+ /**
289
+ * The field of links to records this one remediates: a later fix to a
290
+ * closed record's consequence, that leaves the closed record's status
291
+ * unchanged (#2774). Shaped like `supersedes`: with `key`, a list of
292
+ * objects whose `key` holds the target id; without it, one id or a list
293
+ * of ids. Optional, and only on a kind with states. A link is valid only
294
+ * when the target exists and is in one of `closedStates`; a target still
295
+ * open is amended instead, never remediated.
296
+ */
297
+ remediates: z.object({ field: z.string().min(1), key: z.string().min(1).optional() }).strict().optional(),
282
298
  /**
283
299
  * How strongly each state is approved (#2524 D4). With it, a supersedes
284
300
  * link takes effect when the new record's rank is above 0 and at least the
@@ -298,6 +314,15 @@ export const recordKindSchema = z
298
314
  * (#2549). Optional.
299
315
  */
300
316
  constrains: z.object({ field: z.string().min(1) }).strict().optional(),
317
+ /**
318
+ * The front-matter list of workspace paths a record puts out of scope
319
+ * (#2773): a file or a directory, from the workspace root. `chant
320
+ * workspace check --changes` reports a change to one as
321
+ * `change-out-of-scope` when the record is in hand for the change, and
322
+ * the record does not cover it even where its constrains would.
323
+ * Optional.
324
+ */
325
+ outOfScope: z.object({ field: z.string().min(1) }).strict().optional(),
301
326
  /**
302
327
  * The front-matter list of review verdicts and the field naming the
303
328
  * decider (#2671, #2672). With it, each record gets a digest of its text
@@ -307,6 +332,17 @@ export const recordKindSchema = z
307
332
  * `withdrawn_on`. Optional.
308
333
  */
309
334
  reviews: z.object({ field: z.string().min(1), decider: z.string().min(1) }).strict().optional(),
335
+ /**
336
+ * The front-matter field a new record's proposer is named on, when the
337
+ * kind opts in (#2756). `records new --by` and the MCP `records-new`
338
+ * tool's `by` write it for a record that opens in the kind's first
339
+ * state — a proposal naming who proposed it — instead of
340
+ * `reviews.decider`'s field, which `by` fills only when a new record
341
+ * opens straight into another state. Requires `states`. Optional: a kind
342
+ * without it keeps `by` writing `reviews.decider` for every new record,
343
+ * as before #2756.
344
+ */
345
+ proposedBy: z.object({ field: z.string().min(1) }).strict().optional(),
310
346
  /**
311
347
  * The front-matter object that says where a record came from, opted in
312
348
  * to the stored source block (#2708): `via`, `client`, `harness`,
@@ -349,6 +385,15 @@ export const recordKindSchema = z
349
385
  * date. With it, `work.ts` gives each record `ready`, `blockedBy` and
350
386
  * `implements`, and the read lists each decision with `implementedBy`.
351
387
  * Optional.
388
+ *
389
+ * `acceptance` (#2772) names the front-matter list of a record's
390
+ * acceptance criteria, each an `id`, `text` and the `verification` it
391
+ * expects (`unit`, `integration`, `e2e`, `runtime` or `manual`), and the
392
+ * field naming the record's implementer, whose own `manual` verdict never
393
+ * counts. With it, each record gets `acceptance` (met and total), and a
394
+ * done record with a criterion that no passing evidence meets is warned
395
+ * `work-acceptance-unmet`, which `check` fails on (WSP117). Optional, and
396
+ * a record without the list is read as before.
352
397
  */
353
398
  work: z
354
399
  .object({
@@ -358,6 +403,7 @@ export const recordKindSchema = z
358
403
  open: z.string().min(1),
359
404
  done: z.string().min(1),
360
405
  closedOn: z.string().min(1),
406
+ acceptance: z.object({ field: z.string().min(1), implementer: z.string().min(1) }).strict().optional(),
361
407
  })
362
408
  .strict()
363
409
  .optional(),
@@ -386,6 +432,10 @@ export const recordKindSchema = z
386
432
  message: "a kind without states cannot have supersedes: a link takes effect only from a closed or ranked state",
387
433
  path: ["supersedes"],
388
434
  })
435
+ .refine((k) => k.states !== undefined || k.remediates === undefined, {
436
+ message: "a kind without states cannot have remediates: a link only ever targets a record in a closed state",
437
+ path: ["remediates"],
438
+ })
389
439
  .refine((k) => k.states !== undefined || k.session === undefined, {
390
440
  message: "a session kind must have states: a session is sealed when it reaches a closed state",
391
441
  path: ["session"],
@@ -394,6 +444,10 @@ export const recordKindSchema = z
394
444
  message: "a kind without states cannot have approval ranks",
395
445
  path: ["approval"],
396
446
  })
447
+ .refine((k) => k.states !== undefined || k.proposedBy === undefined, {
448
+ message: "a kind without states cannot have proposedBy: there is no first state for a record to open in",
449
+ path: ["proposedBy"],
450
+ })
397
451
  .refine((k) => (k.closedStates ?? []).every((s) => (k.states ?? []).includes(s)), {
398
452
  message: "every closed state must be listed in states",
399
453
  path: ["closedStates"],
@@ -406,6 +460,10 @@ export const recordKindSchema = z
406
460
  message: "a work kind must have states, and its work open and done states must be listed in them",
407
461
  path: ["work"],
408
462
  })
463
+ .refine((k) => !k.work?.acceptance || k.pins !== undefined, {
464
+ message: "a work kind with acceptance criteria must have pins: a criterion is met by the evidence in its pins field",
465
+ path: ["work", "acceptance"],
466
+ })
409
467
  .refine(
410
468
  (k) =>
411
469
  !k.answers ||
@@ -961,6 +1019,13 @@ export interface RecordEntry {
961
1019
  reasons: RecordReason[];
962
1020
  /** The id of the closed record whose `supersedes` link replaces this one, or null. */
963
1021
  supersededBy: string | null;
1022
+ /**
1023
+ * The ids of records whose `remediates` link names this one (#2774), in
1024
+ * path order. Unlike `supersededBy`, several records may remediate the
1025
+ * same one, and a remediation never changes this record's state or
1026
+ * `supersededBy`: it stays exactly as current as it was.
1027
+ */
1028
+ remediatedBy: string[];
964
1029
  /** The record's structured core as JSON (the front matter, or the whole JSON file), or null when it could not be parsed. */
965
1030
  data: Record<string, unknown> | null;
966
1031
  /**
@@ -981,6 +1046,8 @@ export interface RecordEntry {
981
1046
  blockedBy?: WorkLink[];
982
1047
  /** For a work kind: each decision the record implements, with its state, or null when no decision has the id. */
983
1048
  implements?: WorkLink[];
1049
+ /** For a work kind with acceptance criteria (#2772): how many of the record's criteria passing evidence meets, or null when it lists none. */
1050
+ acceptance?: WorkAcceptance | null;
984
1051
  }
985
1052
 
986
1053
  export interface ReadRecordsOptions {
@@ -1100,6 +1167,20 @@ export function supersedesTargets(kind: Pick<RecordKind, "supersedes">, data: Re
1100
1167
  return ids.filter((x): x is string => typeof x === "string");
1101
1168
  }
1102
1169
 
1170
+ /**
1171
+ * The ids a record's remediates field names (#2774), read the same way
1172
+ * {@link supersedesTargets} reads `supersedes`.
1173
+ */
1174
+ export function remediatesTargets(kind: Pick<RecordKind, "remediates">, data: Record<string, unknown> | null): string[] {
1175
+ if (!kind.remediates || data === null) return [];
1176
+ const { field, key } = kind.remediates;
1177
+ const value = data[field];
1178
+ if (key === undefined && typeof value === "string") return [value];
1179
+ if (!Array.isArray(value)) return [];
1180
+ const ids = key === undefined ? value : value.map((l) => (l !== null && typeof l === "object" ? (l as Record<string, unknown>)[key] : undefined));
1181
+ return ids.filter((x): x is string => typeof x === "string");
1182
+ }
1183
+
1103
1184
  /** The stem of a file name: the name up to its first `.`, the hash a content-addressed record's name claims (ws-053). */
1104
1185
  function nameStem(path: string): string {
1105
1186
  const name = path.slice(path.lastIndexOf("/") + 1);
@@ -1135,6 +1216,7 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
1135
1216
  valid: true,
1136
1217
  reasons: [],
1137
1218
  supersededBy: null,
1219
+ remediatedBy: [],
1138
1220
  data: null,
1139
1221
  assets: [],
1140
1222
  warnings: [],
@@ -1254,6 +1336,30 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
1254
1336
  }
1255
1337
  }
1256
1338
 
1339
+ // Remediation (#2774): a later record fixes a consequence of a closed one
1340
+ // without replacing it. The target's status, and its supersededBy, never
1341
+ // change; several records may remediate the same one. Unlike supersedes,
1342
+ // this needs no approval rank: the target is closed or it isn't, and a
1343
+ // still-open target is amended instead of remediated.
1344
+ for (const e of entries) {
1345
+ for (const target of remediatesTargets(kind, e.data)) {
1346
+ const old = byId.get(target);
1347
+ if (!old) {
1348
+ e.reasons.push({ code: "record-remediates-unknown", message: `remediates ${target}, which no record has` });
1349
+ continue;
1350
+ }
1351
+ if (old === e) continue;
1352
+ if (old.state === null || !closed.has(old.state)) {
1353
+ e.reasons.push({
1354
+ code: "record-remediates-not-closed",
1355
+ message: `remediates ${target}, which is ${old.state ?? "stateless"}, not closed; a record still open is amended instead`,
1356
+ });
1357
+ continue;
1358
+ }
1359
+ if (e.id !== null && !old.remediatedBy.includes(e.id)) old.remediatedBy.push(e.id);
1360
+ }
1361
+ }
1362
+
1257
1363
  // A pin that still matches, at the hash a record this one supersedes
1258
1364
  // pinned, while the file has not changed since this record was recorded:
1259
1365
  // the decision moved on and the artifact did not (#2549).
@@ -419,7 +419,7 @@ describe("gate state in chant workspace status --json (#2674)", () => {
419
419
  expiresAt: "2026-12-31T00:00:00.000Z",
420
420
  approvals: [{ principal: "alice", channel: "cli", at: "2026-09-20T01:00:00.000Z" }],
421
421
  needed: 1,
422
- approve: "chant approve web deploy --env staging",
422
+ approve: `chant approve web deploy --env staging --plan ${D("1")}`,
423
423
  },
424
424
  {
425
425
  component: "web",
@@ -431,7 +431,7 @@ describe("gate state in chant workspace status --json (#2674)", () => {
431
431
  expiresAt: "2026-12-31T00:00:00.000Z",
432
432
  approvals: [],
433
433
  needed: 1,
434
- approve: "chant approve web deploy --env prod",
434
+ approve: `chant approve web deploy --env prod --plan ${D("2")}`,
435
435
  },
436
436
  {
437
437
  component: "web",
@@ -444,15 +444,15 @@ describe("gate state in chant workspace status --json (#2674)", () => {
444
444
  // The agent's approval doesn't count toward the quorum.
445
445
  approvals: [{ principal: "bob", channel: "cli", at: "2026-09-20T02:00:00.000Z" }],
446
446
  needed: 2,
447
- approve: "chant approve web review --env staging",
447
+ approve: `chant approve web review --env staging --plan ${D("4")}`,
448
448
  },
449
449
  ]);
450
450
  // api and the root member both read the flat _gates/.
451
451
  expect(api.gateLedger).toEqual({ layout: "flat", path: "_gates", shared: true, malformed: 1, reason: null });
452
452
  expect(site.gates).toEqual(api.gates);
453
453
  expect(api.gates.map((g) => [g.component, g.name, g.env, g.state, g.approve])).toEqual([
454
- ["api", "deploy", "staging", "expired", "chant approve api deploy --env staging"],
455
- ["api", "smoke", "staging", "superseded", "chant approve api smoke --env staging"],
454
+ ["api", "deploy", "staging", "expired", `chant approve api deploy --env staging --plan ${D("5")}`],
455
+ ["api", "smoke", "staging", "superseded", `chant approve api smoke --env staging --plan ${D("6")}`],
456
456
  ["nightly", "confirm", null, "approved", "chant approve nightly confirm"],
457
457
  ]);
458
458
  // The superseding approval named another plan, so it isn't listed as one that counts.
@@ -514,7 +514,7 @@ describe("gate state in chant workspace status --json (#2674)", () => {
514
514
  expect(await runWorkspaceStatus({ args: { extraPositional: "staging", extraPositional2: root, json: true } } as unknown as CommandContext)).toBe(0);
515
515
  const printed = JSON.parse(String(log.mock.calls.at(-1)?.[0])) as StatusDocument;
516
516
  expectValid(printed);
517
- expect(result(printed).members.find((m) => m.name === "web")!.gates.map((g) => g.approve)).toContain("chant approve web deploy --env staging");
517
+ expect(result(printed).members.find((m) => m.name === "web")!.gates.map((g) => g.approve)).toContain(`chant approve web deploy --env staging --plan ${D("1")}`);
518
518
  } finally {
519
519
  log.mockRestore();
520
520
  }
@@ -594,16 +594,19 @@ describe("stewards in chant workspace status --json (#2731)", () => {
594
594
  name: "box-converge",
595
595
  schedule: { cron: "* * * * *", overlap: "skip" },
596
596
  env: "local",
597
- lastRun: { id: expect.any(String), status: "ok", started: "2026-09-25T10:00:00.000Z", ended: "2026-09-25T10:00:05.000Z", gate: null },
597
+ lastRun: { id: expect.any(String), status: "ok", started: "2026-09-25T10:00:00.000Z", ended: "2026-09-25T10:00:05.000Z", gate: null, point: null },
598
+ // #2778: not a ConvergeOp (no Converge label), so no tick.
599
+ lastTick: null,
598
600
  changesCheckout: false,
599
601
  workLease: null,
600
602
  },
601
- { name: "box-release", schedule: null, env: "local", lastRun: null, changesCheckout: false, workLease: null },
603
+ { name: "box-release", schedule: null, env: "local", lastRun: null, lastTick: null, changesCheckout: false, workLease: null },
602
604
  {
603
605
  name: "box-dispatch",
604
606
  schedule: { cron: "*/5 * * * *", overlap: "skip" },
605
607
  env: "local",
606
608
  lastRun: null,
609
+ lastTick: null,
607
610
  changesCheckout: true,
608
611
  workLease: {
609
612
  kind: null,
@@ -620,6 +623,8 @@ describe("stewards in chant workspace status --json (#2731)", () => {
620
623
  },
621
624
  },
622
625
  ]);
626
+ // #2749: nothing the steward runs waits on a decision point.
627
+ expect(s.waiting).toEqual([]);
623
628
 
624
629
  const k3d = result(await workspaceStatus({ cwd: root, env: "fountain-k3d", now: "2026-09-25T11:00:00.000Z" }));
625
630
  expectValid(k3d);