@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
@@ -5,6 +5,13 @@ import { join } from "node:path";
5
5
  import type { ParsedArgs } from "../registry";
6
6
 
7
7
  const discoverOpsMock = vi.fn();
8
+ // Default: no steward owns anything discovered, so `stewardTurnGate` (#2750)
9
+ // is a no-op for every test that doesn't explicitly set up a steward — a
10
+ // `mockResolvedValueOnce` in those tests overrides it for one call and this
11
+ // default is what every other call sees.
12
+ const discoverStewardsMock = vi.fn().mockResolvedValue({ stewards: new Map(), errors: [], conflicts: [] });
13
+ const acquireStewardTurnMock = vi.fn();
14
+ const releaseLeaseMock = vi.fn();
8
15
  const loadChantConfigMock = vi.fn();
9
16
  const writeFileSyncMock = vi.fn();
10
17
  const mkdirSyncMock = vi.fn();
@@ -18,7 +25,18 @@ const recordGateApprovalMock = vi.fn();
18
25
  const { memoryGateLedgerPort } = await vi.importActual<typeof import("../../op/gate")>("../../op/gate");
19
26
  let gateLedger = memoryGateLedgerPort();
20
27
 
21
- vi.mock("../../op/discover", () => ({ discoverOps: () => discoverOpsMock() }));
28
+ vi.mock("../../op/discover", () => ({
29
+ discoverOps: () => discoverOpsMock(),
30
+ discoverStewards: () => discoverStewardsMock(),
31
+ }));
32
+ vi.mock("../../op/operator", async (importOriginal) => {
33
+ const actual = await importOriginal<typeof import("../../op/operator")>();
34
+ return { ...actual, acquireStewardTurn: (...args: unknown[]) => acquireStewardTurnMock(...args) };
35
+ });
36
+ vi.mock("../../lifecycle/lease", async (importOriginal) => {
37
+ const actual = await importOriginal<typeof import("../../lifecycle/lease")>();
38
+ return { ...actual, releaseLease: (...args: unknown[]) => releaseLeaseMock(...args) };
39
+ });
22
40
  vi.mock("../../config", async () => {
23
41
  const actual = await vi.importActual<typeof import("../../config")>("../../config");
24
42
  return { ...actual, loadChantConfig: (...args: unknown[]) => loadChantConfigMock(...args) };
@@ -288,6 +306,138 @@ describe("runOp dispatcher", () => {
288
306
  });
289
307
  });
290
308
 
309
+ /**
310
+ * chant #2750 — an Op a declared steward lists is one of its turns, not a
311
+ * run beside one: `chant run <op>` for such an Op takes the steward's turn
312
+ * lease (in the local form) or is redirected to the steward's thread (on
313
+ * fountain), rather than running unaware of a turn already in progress.
314
+ *
315
+ * `acquireStewardTurn` and `releaseLease` are stubbed here (mocked above):
316
+ * the point of these tests is the CLI's own decision and its message, not
317
+ * the lease's git plumbing, which `op/steward.test.ts` and
318
+ * `lifecycle/lease.test.ts` already cover against a real repo.
319
+ */
320
+ describe("runOp: a steward's turn (#2750)", () => {
321
+ function stewardOwning(opName: string, name: string, form: "local" | "fountain" = "local") {
322
+ return {
323
+ stewards: new Map([[name, {
324
+ declaration: {
325
+ kind: "Chant::Steward", name, ops: [{ name: opName, phases: [] }],
326
+ form: { default: form, environments: {} }, capabilities: [], vault: null,
327
+ },
328
+ filePath: "ops/steward.op.ts", exportName: "steward",
329
+ }]]),
330
+ errors: [], conflicts: [],
331
+ };
332
+ }
333
+
334
+ beforeEach(() => {
335
+ discoverOpsMock.mockReset();
336
+ loadChantConfigMock.mockReset().mockResolvedValue({ config: {} });
337
+ loadPluginsMock.mockReset().mockResolvedValue([]);
338
+ acquireStewardTurnMock.mockReset();
339
+ releaseLeaseMock.mockReset().mockResolvedValue(true);
340
+ });
341
+
342
+ test("local form, the turn is free: takes it, runs, and releases it once the run is over", async () => {
343
+ discoverOpsMock.mockResolvedValue({
344
+ ops: new Map([localOp("release", [{ kind: "activity", fn: "shellCmd", args: { cmd: "true" } }])]),
345
+ errors: [],
346
+ });
347
+ discoverStewardsMock.mockResolvedValueOnce(stewardOwning("release", "box-steward"));
348
+ acquireStewardTurnMock.mockResolvedValue({
349
+ acquired: true,
350
+ lease: { op: "_turns/box-steward", holder: "h", token: "t1", acquiredAt: "x", expiresAt: "y" },
351
+ });
352
+ const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
353
+ const exit = await runOp({ args: makeArgs({ path: "release" }), plugins: [], serializers: [] });
354
+ stderrWrite.mockRestore();
355
+
356
+ expect(exit).toBe(0);
357
+ expect(acquireStewardTurnMock).toHaveBeenCalledTimes(1);
358
+ expect(acquireStewardTurnMock.mock.calls[0]?.[0]).toBe("box-steward");
359
+ expect(releaseLeaseMock).toHaveBeenCalledTimes(1);
360
+ expect(releaseLeaseMock).toHaveBeenCalledWith("_turns/box-steward", expect.any(String), "t1");
361
+ });
362
+
363
+ test("local form, a turn is already in progress: refused, naming the steward and holder, nothing runs", async () => {
364
+ discoverOpsMock.mockResolvedValue({
365
+ ops: new Map([localOp("release", [{ kind: "activity", fn: "shellCmd", args: { cmd: "true" } }])]),
366
+ errors: [],
367
+ });
368
+ discoverStewardsMock.mockResolvedValueOnce(stewardOwning("release", "box-steward"));
369
+ acquireStewardTurnMock.mockResolvedValue({
370
+ acquired: false,
371
+ reason: "held",
372
+ heldBy: { op: "_turns/box-steward", holder: "other-host:123:abcd", token: "t0", acquiredAt: "x", expiresAt: "y" },
373
+ });
374
+ const stderr = makeStderrSpy();
375
+ const exit = await runOp({ args: makeArgs({ path: "release" }), plugins: [], serializers: [] });
376
+
377
+ expect(exit).toBe(1);
378
+ const out = stderr.join("\n");
379
+ expect(out).toContain('steward "box-steward"');
380
+ expect(out).toContain("other-host:123:abcd");
381
+ expect(out).toContain("chant operator status --steward box-steward");
382
+ expect(releaseLeaseMock).not.toHaveBeenCalled();
383
+ });
384
+
385
+ test("fountain form without --on fountain: refused, naming the redirect, no turn lease touched", async () => {
386
+ discoverOpsMock.mockResolvedValue({ ops: new Map([localOp("release", [])]), errors: [] });
387
+ discoverStewardsMock.mockResolvedValueOnce(stewardOwning("release", "box-steward", "fountain"));
388
+ const stderr = makeStderrSpy();
389
+ const exit = await runOp({ args: makeArgs({ path: "release" }), plugins: [], serializers: [] });
390
+
391
+ expect(exit).toBe(1);
392
+ const out = stderr.join("\n");
393
+ expect(out).toContain('steward "box-steward"');
394
+ expect(out).toContain("chant run release --on fountain");
395
+ expect(acquireStewardTurnMock).not.toHaveBeenCalled();
396
+ });
397
+
398
+ test("fountain form with --on fountain: the local turn-lease check is skipped and the fountain runtime runs it", async () => {
399
+ const runtime = {
400
+ name: "fountain",
401
+ start: vi.fn(async (op: { name: string }) => ({
402
+ op: op.name,
403
+ runId: "f-1",
404
+ result: async () => ({ op: op.name, runId: "f-1", state: "completed" as const, startedAt: "t", endedAt: "t" }),
405
+ })),
406
+ status: vi.fn(), log: vi.fn(), list: vi.fn(), cancel: vi.fn(),
407
+ };
408
+ discoverOpsMock.mockResolvedValue({ ops: new Map([localOp("release", [])]), errors: [] });
409
+ discoverStewardsMock.mockResolvedValueOnce(stewardOwning("release", "box-steward", "fountain"));
410
+ loadChantConfigMock.mockResolvedValue({ config: { lexicons: ["fountain"] } });
411
+ makeStdoutSpy();
412
+ const exit = await runOp({
413
+ args: makeArgs({ path: "release", on: "fountain" }),
414
+ plugins: [{ name: "fountain", opRuntime: runtime } as never], serializers: [],
415
+ });
416
+
417
+ expect(exit).toBe(0);
418
+ expect(runtime.start).toHaveBeenCalledTimes(1);
419
+ expect(acquireStewardTurnMock).not.toHaveBeenCalled();
420
+ });
421
+
422
+ test("local form, the turn lease's own acquire throws (a stale .lock): refused as a lease error, not an uncaught exception, and nothing runs", async () => {
423
+ discoverOpsMock.mockResolvedValue({
424
+ ops: new Map([localOp("release", [{ kind: "activity", fn: "shellCmd", args: { cmd: "true" } }])]),
425
+ errors: [],
426
+ });
427
+ discoverStewardsMock.mockResolvedValueOnce(stewardOwning("release", "box-steward"));
428
+ acquireStewardTurnMock.mockRejectedValue(new Error("cannot lock ref 'refs/chant/lease/_turns/box-steward': File exists"));
429
+ const stderr = makeStderrSpy();
430
+
431
+ const exit = await runOp({ args: makeArgs({ path: "release" }), plugins: [], serializers: [] });
432
+
433
+ expect(exit).toBe(1);
434
+ const out = stderr.join("\n");
435
+ expect(out).toContain('steward "box-steward"');
436
+ expect(out).toContain("cannot lock ref");
437
+ expect(releaseLeaseMock).not.toHaveBeenCalled();
438
+ });
439
+ });
440
+
291
441
  /**
292
442
  * chant #2243 — a gated run's exit code is the one thing `--gated-exit`
293
443
  * remaps, and the gate is reported where CI can see it without opening a log.
@@ -373,7 +523,9 @@ describe("runOp: --gated-exit (#2243)", () => {
373
523
 
374
524
  test("a run that fails for any other reason is still red under the flag", async () => {
375
525
  discoverOpsMock.mockResolvedValue({
376
- ops: new Map([localOp("broken", [{ kind: "activity", fn: "shellCmd", args: { cmd: "exit 7" } }])]),
526
+ // atMostOnce: one attempt. The default profile retries twice with 5s and
527
+ // 10s backoff, which put this test at 15s for waits it does not test (#2817).
528
+ ops: new Map([localOp("broken", [{ kind: "activity", fn: "shellCmd", profile: "atMostOnce", args: { cmd: "exit 7" } }])]),
377
529
  errors: [],
378
530
  });
379
531
  const file = summaryFile();
@@ -11,12 +11,16 @@ import { approveCommand, describeGateMismatch } from "../../op/gate";
11
11
  import { summaryLedgerPrefix, writeGatedRunSummary, type GatedRunSummary } from "../../op/gate-summary";
12
12
  import { createLocalOpRuntime } from "../../op/runtimes/local";
13
13
  import type { OpRuntimeProvider, OpRunStatus } from "../../op/runtime";
14
- import { renderHuman, renderJson } from "../../op/local-output";
14
+ import { pointAnswerCommand, renderHuman, renderJson } from "../../op/local-output";
15
15
  import { loadPlugins } from "../plugins";
16
16
  import { recordGateApproval } from "./operator";
17
17
  import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
18
18
  import { resolveCliBuildParams, parseParamFlags } from "../build-params-cli";
19
19
  import type { CommandContext } from "../registry";
20
+ import { stewardFormFor, stewardTurnLeaseName, DEFAULT_STEWARD_ENV, type StewardDeclaration } from "../../op/steward";
21
+ import { acquireStewardTurn, STEWARD_TURN_WAIT_MS } from "../../op/operator";
22
+ import { releaseLease, currentHolderId, type AcquireLeaseResult } from "../../lifecycle/lease";
23
+ import { StaleLockError } from "../../lifecycle/git";
20
24
  import { renderDriverHuman, renderDriverJson } from "../../components/driver-output";
21
25
  import { ndjsonProgressSink } from "../../components/run-progress";
22
26
  import { maybeRecordAutoRelease } from "../../components/auto-release";
@@ -527,6 +531,94 @@ function refusesPolicyGateUnderSandbox(ctx: CommandContext, config: OpConfig, op
527
531
  return true;
528
532
  }
529
533
 
534
+ /** What `stewardTurnGate` decided before `chant run <op>` is allowed to start. */
535
+ type StewardTurnGate =
536
+ | { ok: true; release?: () => Promise<void> }
537
+ | { ok: false; message: string; hint: string };
538
+
539
+ /**
540
+ * `chant run <op>` for an Op a declared steward lists is one of that
541
+ * steward's turns, not a run beside it (#2750, follow-up to #2731).
542
+ *
543
+ * On fountain, the run belongs on the steward's thread: without `--on
544
+ * fountain` this refuses outright, naming the command that posts it there
545
+ * instead (the fountain runtime's own turn already refuses a busy teammate,
546
+ * unretried — see chant-fountain-ops).
547
+ *
548
+ * In the local form, the run takes the steward's turn lease
549
+ * (`../../op/operator.ts`'s `acquireStewardTurn`) — the same lease a round's
550
+ * scheduled tick holds for the length of its own run. Held already, this
551
+ * waits up to {@link STEWARD_TURN_WAIT_MS} (long enough to ride out a race
552
+ * with a turn that's just ending) and then refuses, naming the steward and
553
+ * its holder. It never queues past that: a round's own leases skip and
554
+ * report rather than wait (`../../op/operator.ts`'s module doc), and a
555
+ * foreground CLI blocked with no visibility for however long a scheduled
556
+ * turn takes would be a worse answer than a clear refusal with a retry in
557
+ * hand. Ctrl-C already means "abort my own run" here; it must not also mean
558
+ * "give up waiting for someone else's turn".
559
+ *
560
+ * `undefined` (via the caller checking `owner`) when no steward lists this
561
+ * op — the ordinary, untouched path.
562
+ */
563
+ async function stewardTurnGate(opName: string, ctx: CommandContext): Promise<StewardTurnGate | undefined> {
564
+ const { stewards } = await discoverStewards().catch(() => ({ stewards: new Map<string, { declaration: StewardDeclaration }>() }));
565
+ let owner: StewardDeclaration | undefined;
566
+ for (const { declaration } of stewards.values()) {
567
+ if (declaration.ops.some((op) => op.name === opName)) {
568
+ owner = declaration;
569
+ break;
570
+ }
571
+ }
572
+ if (!owner) return undefined;
573
+
574
+ const env = ctx.args.env ?? DEFAULT_STEWARD_ENV;
575
+ const form = stewardFormFor(owner, env);
576
+
577
+ if (form === "fountain") {
578
+ if (ctx.args.on === "fountain") return { ok: true };
579
+ return {
580
+ ok: false,
581
+ message: `Op "${opName}" is one of steward "${owner.name}"'s Ops, which runs on fountain in environment "${env}"`,
582
+ hint:
583
+ `Run \`chant run ${opName} --on fountain\` so it posts to "${owner.name}"'s thread. ` +
584
+ `A plain \`chant run ${opName}\` would run it outside that thread, beside the steward instead of one of its turns.`,
585
+ };
586
+ }
587
+
588
+ const holder = currentHolderId();
589
+ let turn: AcquireLeaseResult;
590
+ try {
591
+ turn = await acquireStewardTurn(owner.name, holder, { waitMs: STEWARD_TURN_WAIT_MS });
592
+ } catch (err) {
593
+ // Not turn contention — the acquire attempt itself failed (most likely a
594
+ // StaleLockError, a `.lock` left behind by a killed operator process).
595
+ // Report it as a refusal rather than letting it propagate uncaught out
596
+ // of `runOpOnRuntime`, before that function's own try/catch even starts.
597
+ return {
598
+ ok: false,
599
+ message: `Op "${opName}" is one of steward "${owner.name}"'s turns, and its lease could not be read`,
600
+ hint:
601
+ `${err instanceof StaleLockError ? err.message : err instanceof Error ? err.message : String(err)} ` +
602
+ `This can happen when an operator process was killed mid-write; check refs/chant/lease/_turns/${owner.name} for a stale lock.`,
603
+ };
604
+ }
605
+ if (!turn.acquired) {
606
+ const heldBy = turn.heldBy?.holder;
607
+ return {
608
+ ok: false,
609
+ message:
610
+ `Op "${opName}" is one of steward "${owner.name}"'s turns, and a turn is in progress` +
611
+ (heldBy ? ` (held by ${heldBy})` : ""),
612
+ hint:
613
+ `Wait for the turn to finish and re-run \`chant run ${opName}\`, or check \`chant operator status --steward ${owner.name}\`. ` +
614
+ `If it looks stuck, stop the operator process holding it (Ctrl-C, or kill it) — its lease also expires on its own.`,
615
+ };
616
+ }
617
+ const lease = turn.lease!;
618
+ const stewardName = owner.name;
619
+ return { ok: true, release: async () => { await releaseLease(stewardTurnLeaseName(stewardName), holder, lease.token).catch(() => false); } };
620
+ }
621
+
530
622
  export async function runOp(ctx: CommandContext): Promise<number> {
531
623
  if (ctx.args.generate) {
532
624
  if (ctx.args.components) {
@@ -864,8 +956,20 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
864
956
  // Pre-flight: --sandbox cannot cover a policyGate step (#2003).
865
957
  if (refusesPolicyGateUnderSandbox(ctx, config, opName)) return 1;
866
958
 
959
+ // A hand run of an Op a declared steward lists is one of that steward's
960
+ // turns, not a run beside it (#2750). `undefined` when no steward lists
961
+ // this op at all — the ordinary path.
962
+ const stewardGate = await stewardTurnGate(opName, ctx);
963
+ if (stewardGate && !stewardGate.ok) {
964
+ console.error(formatError({ message: stewardGate.message, hint: stewardGate.hint }));
965
+ return 1;
966
+ }
967
+
867
968
  const runtime = await resolveOpRuntime(ctx);
868
- if (!runtime) return 1;
969
+ if (!runtime) {
970
+ await stewardGate?.release?.();
971
+ return 1;
972
+ }
869
973
 
870
974
  // The work lease (#2748). `--work` names the item an Op with a work lease
871
975
  // runs under; it is taken where the run executes, which for a hosted
@@ -940,6 +1044,18 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
940
1044
  }
941
1045
  return gatedExit;
942
1046
  }
1047
+ // A run waiting on an open decision point (#2749) is waiting on a person,
1048
+ // like a gated one, and exits with the same code. Its answer comes through
1049
+ // hud or `points answer`, never a `chant approve`.
1050
+ if (status.state === "waiting") {
1051
+ if (!status.result && status.point) {
1052
+ console.error(formatWarning({
1053
+ message: `Op "${opName}" is waiting on decision point "${status.point.point}" (${status.point.id})`,
1054
+ hint: `A person answers it with: ${pointAnswerCommand(status.point.id)}`,
1055
+ }));
1056
+ }
1057
+ return gatedExit;
1058
+ }
943
1059
  return status.state === "completed" ? 0 : 1;
944
1060
  } catch (err) {
945
1061
  if (err instanceof OpRunFailure) {
@@ -950,6 +1066,9 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
950
1066
  return 1;
951
1067
  } finally {
952
1068
  process.removeListener("SIGINT", onSigint);
1069
+ // The turn is over, win or lose — free it so the steward's next round,
1070
+ // or the next hand run, doesn't wait out this run's own TTL (#2750).
1071
+ await stewardGate?.release?.();
953
1072
  }
954
1073
  }
955
1074
 
@@ -727,6 +727,16 @@ describe("workspace graph --intent (#2651)", () => {
727
727
  });
728
728
  });
729
729
 
730
+ describe("workspace check --changes (#2773)", () => {
731
+ test("parses the range, the work item and the severity", () => {
732
+ const args = parseArgs(["workspace", "check", "--changes", "main..HEAD", "--work", "W-001", "--severity", "fail", "--json"]);
733
+ expect(args).toMatchObject({ command: "workspace", path: "check", changes: "main..HEAD", work: "W-001", severity: "fail", json: true });
734
+ expect(() => parseArgs(["workspace", "check", "--changes"])).toThrow(/--changes needs a range/);
735
+ expect(() => parseArgs(["workspace", "check", "--changes", "main", "--severity"])).toThrow(/--severity needs off, warn or fail/);
736
+ expect(resolveCommand(args, commandRegistry)?.def.name).toBe("workspace check");
737
+ });
738
+ });
739
+
730
740
  describe("workspace init and ls (#2534)", () => {
731
741
  test("resolve to their commands in the one workspace group, with the directory as extra positional", () => {
732
742
  const ls = parseArgs(["workspace", "ls", "examples", "--at", "HEAD", "--json"]);
package/src/cli/main.ts CHANGED
@@ -379,6 +379,14 @@ export function parseArgs(args: string[]): ParsedArgs {
379
379
  // `chant workspace graph --intent <path[:start-end]>` (#2651)
380
380
  result.intent = args[++i];
381
381
  if (!result.intent || result.intent.startsWith("-")) throw new Error("--intent needs a region: --intent <path[:start-end]>");
382
+ } else if (arg === "--changes") {
383
+ // `chant workspace check --changes <base>..<head>` (#2773)
384
+ result.changes = args[++i];
385
+ if (!result.changes || result.changes.startsWith("-")) throw new Error("--changes needs a range: --changes <base>..<head>");
386
+ } else if (arg === "--severity") {
387
+ // `chant workspace check --changes <range> --severity off|warn|fail` (#2773)
388
+ result.severity = args[++i];
389
+ if (!result.severity || result.severity.startsWith("-")) throw new Error("--severity needs off, warn or fail");
382
390
  } else if (arg === "--current") {
383
391
  result.current = true;
384
392
  } else if (arg === "--set") {
@@ -858,6 +866,10 @@ Workspace (level 1, #2524):
858
866
  separate clones coordinate. A claim is refused, exit 2,
859
867
  while anyone holds it live. Each change appends to
860
868
  _leases/<id>.jsonl on chant/lifecycle
869
+ workspace work history <id> [--kind <kind file>] [--json]
870
+ A work item's lease history: each claim with its token
871
+ and holder, and how it ended, released with an outcome
872
+ or never released (expired or lost). Read-only
861
873
  workspace points [--open] [--kind <kind file>] [--at <rev>] [--json]
862
874
  List the decision points the declared answer kinds'
863
875
  points files declare, and the questions asked of them;
@@ -896,6 +908,13 @@ Workspace (level 1, #2524):
896
908
  whose pinned files changed. Needs no workspace file.
897
909
  --at reads a commit's git objects; --format json
898
910
  prints the read-contract document
911
+ workspace check --changes <base>..<head> [--work <id>] [--severity off|warn|fail] [--kind <kind file>...] [--json]
912
+ Map each path the diff changes to the current records
913
+ whose constrains cover it: change-uncovered when none
914
+ does, change-out-of-scope when a record in hand (the
915
+ --work item and its decisions) lists it in
916
+ out_of_scope. The declaration's changes block sets
917
+ the severity (warn by default) and ignore globs
899
918
  workspace build [dir] [--member <name>] [-o <dir>] [--dry-run]
900
919
  Build every chant member and example project, each with
901
920
  its own chant, one process per toolchain. -o <dir>
@@ -85,7 +85,7 @@ describe("which servers have the workspace tools", () => {
85
85
  const { tools } = (await rpc(server(cwd), "tools/list")) as { tools: { name: string }[] };
86
86
  expect(tools.map((t) => t.name)).toEqual(expect.arrayContaining(names));
87
87
  }
88
- expect(names).toEqual(["workspace-ls", "workspace-status", "workspace-graph", "workspace-records", "workspace-points", "records-new", "records-amend", "records-review", "records-close", "points-answer"]);
88
+ expect(names).toEqual(["workspace-ls", "workspace-status", "workspace-graph", "workspace-changes", "workspace-records", "workspace-points", "workspace-work-history", "records-new", "records-amend", "records-review", "records-close", "points-answer"]);
89
89
  });
90
90
 
91
91
  test("a server outside any workspace, or given none, does not", async () => {
@@ -100,8 +100,8 @@ describe("which servers have the workspace tools", () => {
100
100
  }
101
101
  });
102
102
 
103
- test("the descriptions say records are proposals and by names who decided", () => {
104
- for (const t of workspaceWriteTools) expect(t.description).toMatch(/proposals until they are reviewed.*by must name the person or agent that actually decided/s);
103
+ test("the descriptions say records are proposals and by names who proposed or decided", () => {
104
+ for (const t of workspaceWriteTools) expect(t.description).toMatch(/proposals until they are reviewed.*by must name the person or agent that actually proposed or decided it/s);
105
105
  });
106
106
  });
107
107
 
@@ -128,6 +128,28 @@ describe("reads", () => {
128
128
  expect(bad.isError).toBe(true);
129
129
  }, 120_000);
130
130
 
131
+ test("workspace-work-history returns the document chant workspace work history --json prints (#2785)", async () => {
132
+ const s = server();
133
+ const res = await call(s, "workspace-work-history", { item: "W-1" });
134
+ expect(res.isError).toBeUndefined();
135
+ expect(res.structuredContent).toEqual(cli(["workspace", "work", "history", "W-1", "--json"]));
136
+ expect(res.structuredContent).toMatchObject({ contract: 1, error: { code: expect.any(String) } });
137
+ const bad = await call(s, "workspace-work-history", {});
138
+ expect(bad.isError).toBe(true);
139
+ }, 120_000);
140
+
141
+ test("workspace-changes returns the document chant workspace check --changes --json prints (#2773)", async () => {
142
+ const s = server();
143
+ const res = await call(s, "workspace-changes", { range: "HEAD", severity: "fail" });
144
+ expect(res.isError).toBeUndefined();
145
+ expect(res.structuredContent).toEqual(cli(["workspace", "check", "--changes", "HEAD", "--severity", "fail", "--json"]));
146
+ expect(res.structuredContent).toMatchObject({ severity: "fail", paths: [], findings: [], ok: true });
147
+ const unknown = await call(s, "workspace-changes", { range: "HEAD", work: "W-999" });
148
+ expect(unknown.structuredContent).toMatchObject({ error: { code: "work-item-unknown" } });
149
+ const bad = await call(s, "workspace-changes", { range: "HEAD", severity: "loud" });
150
+ expect(bad.isError).toBe(true);
151
+ }, 120_000);
152
+
131
153
  test("an error document is returned as a document, and a flag-shaped value is refused before chant runs", async () => {
132
154
  const s = server();
133
155
  const missing = await call(s, "workspace-records", { kind: "nowhere/none.kind.mjs" });
@@ -143,6 +165,7 @@ describe("reads", () => {
143
165
  expect(mcpToolCall(["workspace", "graph", "--intent", "a.mjs:1", "--kind", KIND, "--json"])).toEqual({ name: "workspace-graph", arguments: { intent: "a.mjs:1", kind: [KIND] } });
144
166
  expect(mcpToolCall(["workspace", "graph", "--composites", "--json"])).toEqual({ name: "workspace-graph", arguments: { composites: true } });
145
167
  expect(mcpToolCall(["workspace", "check", "--format", "json"])).toBeUndefined();
168
+ expect(mcpToolCall(["workspace", "check", "--changes", "main..HEAD", "--work", "W-001", "--json"])).toEqual({ name: "workspace-changes", arguments: { range: "main..HEAD", work: "W-001" } });
146
169
  });
147
170
  });
148
171
 
@@ -186,25 +209,18 @@ describe("writes", () => {
186
209
  const reasoning = await call(s, "records-amend", { kind: KIND, id: "fix-001", fields: { question: "Something else?" } });
187
210
  expect(reasoning.structuredContent).toMatchObject({ error: { code: "amend-supersede-instead" } });
188
211
 
189
- const both = await call(s, "records-new", { kind: KIND, record: { ...proposal("Two authors"), decided_by: "alice" }, by: "bob", dryRun: true });
212
+ // A new record opens proposed (MCP forces it), so by names the proposer, in proposedBy's field, not the decider (#2756).
213
+ const both = await call(s, "records-new", { kind: KIND, record: { ...proposal("Two proposers"), proposed_by: "alice" }, by: "bob", dryRun: true });
190
214
  expect(both.structuredContent).toMatchObject({ error: { code: "write-input-invalid" } });
191
- const by = await call(s, "records-new", { kind: KIND, record: proposal("One author"), by: "alice", dryRun: true });
215
+ const by = await call(s, "records-new", { kind: KIND, record: proposal("One proposer"), by: "alice", dryRun: true });
192
216
  expect(by.structuredContent, JSON.stringify(by.structuredContent)).toHaveProperty("text");
193
- expect((by.structuredContent as { text: string }).text).toContain('decided_by: "alice"');
217
+ expect((by.structuredContent as { text: string }).text).toContain('proposed_by: "alice"');
218
+ expect((by.structuredContent as { text: string }).text).toContain("decided_by: null");
219
+
220
+ // --sign seals the decider, and a record by names as proposed carries no decided_by yet.
221
+ const signed = await call(s, "records-new", { kind: KIND, record: proposal("Signed"), by: "alice", sign: true, dryRun: true });
222
+ expect(signed.structuredContent).toMatchObject({ error: { code: "record-sign-failed", message: expect.stringContaining("names no decided_by") } });
194
223
 
195
- // No key configured on this host: the CLI's refusal and remedy.
196
- const home = { GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1" };
197
- const saved = { ...process.env };
198
- Object.assign(process.env, home);
199
- try {
200
- const signed = await call(s, "records-new", { kind: KIND, record: proposal("Signed"), by: "alice", sign: true, dryRun: true });
201
- expect(signed.structuredContent).toMatchObject({ error: { code: "record-sign-failed", message: expect.stringContaining("pass --sign <key file>") } });
202
- } finally {
203
- for (const k of Object.keys(home)) {
204
- if (saved[k] === undefined) delete process.env[k];
205
- else process.env[k] = saved[k];
206
- }
207
- }
208
224
  // Nothing above wrote a file.
209
225
  expect(readFileSync(join(ws.dir, "decisions", "fix-001-how-the-app-is-deployed.md"), "utf-8")).toContain('question: "What declares the app\'s deployment?"');
210
226
  }, 180_000);
@@ -6,7 +6,8 @@
6
6
  * is a thin call into the code the CLI runs:
7
7
  *
8
8
  * - The reads (`workspace-ls`, `workspace-status`, `workspace-graph`,
9
- * `workspace-records`, `workspace-points`) run `chant workspace <command>
9
+ * `workspace-changes`, `workspace-records`, `workspace-points`,
10
+ * `workspace-work-history`) run `chant workspace <command>
10
11
  * ... --json` with the chant this server runs as, in the server's
11
12
  * directory, and return the
12
13
  * document it printed, unchanged, with its reason codes (#2536). Running the
@@ -52,7 +53,7 @@ export interface WorkspaceToolsOptions {
52
53
 
53
54
  const PROTOCOL =
54
55
  "Records are proposals until they are reviewed: a new record opens proposed, and people decide it through reviews and amendments. " +
55
- "by must name the person or agent that actually decided, as it is recorded as given.";
56
+ "by must name the person or agent that actually proposed or decided it, as it is recorded as given.";
56
57
 
57
58
  const kindProp = {
58
59
  type: "string",
@@ -105,10 +106,29 @@ export const workspaceReadTools: ToolDefinition[] = [
105
106
  },
106
107
  },
107
108
  },
109
+ {
110
+ name: "workspace-changes",
111
+ description:
112
+ "The forward coverage check over a diff: chant workspace check --changes <range> --json (changes.schema.json, #2773). Each changed path with the current records whose constrains cover it, and a change-uncovered or change-out-of-scope finding for each gap, at the severity the declaration's changes block sets. Returns the document unchanged.",
113
+ inputSchema: {
114
+ type: "object",
115
+ properties: {
116
+ range: { type: "string", description: "The diff: <base>..<head>, <base>...<head> (from their merge base), or <base> alone, to HEAD (--changes)." },
117
+ work: { type: "string", description: "The id of the work item in hand, whose out_of_scope and whose decisions' out_of_scope apply (--work)." },
118
+ severity: { type: "string", enum: ["off", "warn", "fail"], description: "In place of the declaration's changes.severity (--severity)." },
119
+ kind: {
120
+ type: "array",
121
+ items: { type: "string" },
122
+ description: "Record kind files to read in place of the declared ones (--kind).",
123
+ },
124
+ },
125
+ required: ["range"],
126
+ },
127
+ },
108
128
  {
109
129
  name: "workspace-records",
110
130
  description:
111
- "The records of a kind, validated, with supersession, provenance, quorum and warnings: chant workspace records --json (records.schema.json). With since, what changed since a revision or a review session (records-since.schema.json). Returns the document unchanged; with id, only that record is kept in records. " +
131
+ "The records of a kind, validated, with supersession, remediation, provenance, quorum and warnings: chant workspace records --json (records.schema.json). With since, what changed since a revision or a review session (records-since.schema.json). Returns the document unchanged; with id, only that record is kept in records. " +
112
132
  PROTOCOL,
113
133
  inputSchema: {
114
134
  type: "object",
@@ -134,6 +154,19 @@ export const workspaceReadTools: ToolDefinition[] = [
134
154
  },
135
155
  },
136
156
  },
157
+ {
158
+ name: "workspace-work-history",
159
+ description:
160
+ "One work item's lease history, from the lifecycle ledger of the member that owns its work kind: chant workspace work history <item> --json (work-history.schema.json, #2785). Each claim has its fencing token and holder and how it ended: released with an outcome, still held, or never released (expired, or lost to a later claim). Returns the document unchanged.",
161
+ inputSchema: {
162
+ type: "object",
163
+ properties: {
164
+ item: { type: "string", description: "The work item's id." },
165
+ kind: { type: "string", description: "The work kind file, when more than one declared work kind has the id (--kind)." },
166
+ },
167
+ required: ["item"],
168
+ },
169
+ },
137
170
  ];
138
171
 
139
172
  export const workspaceWriteTools: ToolDefinition[] = [
@@ -148,7 +181,11 @@ export const workspaceWriteTools: ToolDefinition[] = [
148
181
  kind: kindProp,
149
182
  record: { type: "object", description: "The record's fields, as the kind's schema describes them. The id is allocated when left out." },
150
183
  prefix: { type: "string", description: "The id prefix to allocate under, when the records use several (--prefix)." },
151
- by: { type: "string", description: "Who decided: written to the kind's decider field, such as decided_by. Required to sign." },
184
+ by: {
185
+ type: "string",
186
+ description:
187
+ "Who proposed it: written to the kind's proposedBy field, such as proposed_by, since a record opens proposed. Written to its decider field, such as decided_by, only when the record's fields open it straight into a later state. Required to sign.",
188
+ },
152
189
  sign: signProp,
153
190
  dryRun: dryRunProp,
154
191
  },
@@ -281,6 +318,27 @@ export function readArgv(tool: string, params: Record<string, unknown>): string[
281
318
  if (intent !== undefined) return ["workspace", "graph", "--intent", intent, ...kindArgs, ...atArgs, "--json"];
282
319
  return ["workspace", "graph", ...kindArgs, ...atArgs, "--json"];
283
320
  }
321
+ case "workspace-changes": {
322
+ const range = str(params, "range", true)!;
323
+ const work = str(params, "work");
324
+ const severity = str(params, "severity");
325
+ if (severity !== undefined && !["off", "warn", "fail"].includes(severity)) throw new ToolInputError("severity must be off, warn or fail");
326
+ return [
327
+ "workspace",
328
+ "check",
329
+ "--changes",
330
+ range,
331
+ ...(work !== undefined ? ["--work", work] : []),
332
+ ...(severity !== undefined ? ["--severity", severity] : []),
333
+ ...kinds(params).flatMap((k) => ["--kind", k]),
334
+ "--json",
335
+ ];
336
+ }
337
+ case "workspace-work-history": {
338
+ const item = str(params, "item", true)!;
339
+ const kind = str(params, "kind");
340
+ return ["workspace", "work", "history", item, ...(kind !== undefined ? ["--kind", kind] : []), "--json"];
341
+ }
284
342
  case "workspace-points": {
285
343
  const kind = str(params, "kind");
286
344
  return ["workspace", "points", ...(bool(params, "open") ? ["--open"] : []), ...(kind !== undefined ? ["--kind", kind] : []), ...atArgs, "--json"];
@@ -1,4 +1,4 @@
1
- import { importLexiconModule, lexiconModulePath, lexiconNames, registerLexiconDeclarations } from "../lexicon-module";
1
+ import { importLexiconModule, importLexiconPackage, lexiconModulePath, lexiconNames, registerLexiconDeclarations, resolveFromProject } from "../lexicon-module";
2
2
  import { readLexiconDeclarationsStatically, type StaticLexiconRead } from "../config-static";
3
3
  export { unknownPathLexiconsNotice } from "../config-static";
4
4
  import { createRequire } from "node:module";
@@ -51,7 +51,7 @@ export async function loadPlugin(lexiconName: string): Promise<LexiconPlugin> {
51
51
  }
52
52
 
53
53
  const packageName = `@intentius/chant-lexicon-${lexiconName}`;
54
- const mod = await import(packageName);
54
+ const mod = (await importLexiconPackage(packageName)) as Record<string, any>;
55
55
 
56
56
  // Look for an explicit LexiconPlugin export
57
57
  for (const value of Object.values(mod)) {
@@ -109,7 +109,15 @@ export function resolveLexiconVersions(lexiconNames: readonly string[]): Record<
109
109
  if (lexiconModulePath(name) !== undefined) continue;
110
110
  const packageName = `@intentius/chant-lexicon-${name}`;
111
111
  try {
112
- let dir = dirname(require_.resolve(packageName));
112
+ // chant#2845 — a lexicon chant's own install can't reach is resolved from the project.
113
+ let entry: string | undefined;
114
+ try {
115
+ entry = require_.resolve(packageName);
116
+ } catch (err) {
117
+ entry = resolveFromProject(packageName);
118
+ if (entry === undefined) throw err;
119
+ }
120
+ let dir = dirname(entry);
113
121
  // Bounded walk — a resolved entry point is never deeply nested inside
114
122
  // its own package, and an unbounded loop here would climb to `/`.
115
123
  for (let depth = 0; depth < 10; depth++) {