@intentius/chant 0.101.0 → 0.102.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 (427) hide show
  1. package/dist/agents/checks.d.ts.map +1 -1
  2. package/dist/attrref.d.ts.map +1 -1
  3. package/dist/audit/discover.d.ts.map +1 -1
  4. package/dist/audit/nginx.d.ts.map +1 -1
  5. package/dist/audit/prior-art.d.ts +48 -0
  6. package/dist/audit/prior-art.d.ts.map +1 -1
  7. package/dist/behaviour-kinds.d.ts.map +1 -1
  8. package/dist/builder.d.ts.map +1 -1
  9. package/dist/cli/commands/carve-emit.d.ts.map +1 -1
  10. package/dist/cli/commands/check-lexicon.d.ts +16 -0
  11. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  12. package/dist/cli/commands/import.d.ts +8 -0
  13. package/dist/cli/commands/import.d.ts.map +1 -1
  14. package/dist/cli/commands/init-lexicon.d.ts.map +1 -1
  15. package/dist/cli/commands/init.d.ts +19 -0
  16. package/dist/cli/commands/init.d.ts.map +1 -1
  17. package/dist/cli/commands/onboard.d.ts +9 -0
  18. package/dist/cli/commands/onboard.d.ts.map +1 -1
  19. package/dist/cli/commands/pinned-upgrade.d.ts +19 -5
  20. package/dist/cli/commands/pinned-upgrade.d.ts.map +1 -1
  21. package/dist/cli/handlers/dev.d.ts.map +1 -1
  22. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  23. package/dist/cli/handlers/misc.d.ts.map +1 -1
  24. package/dist/cli/handlers/operator.d.ts +9 -0
  25. package/dist/cli/handlers/operator.d.ts.map +1 -1
  26. package/dist/cli/handlers/run.d.ts.map +1 -1
  27. package/dist/cli/lsp/server.d.ts.map +1 -1
  28. package/dist/cli/main.d.ts.map +1 -1
  29. package/dist/cli/mcp/server.d.ts.map +1 -1
  30. package/dist/cli/mcp/tools/build.d.ts.map +1 -1
  31. package/dist/cli/mcp/tools/composites.d.ts.map +1 -1
  32. package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
  33. package/dist/cli/mcp/tools/import.d.ts.map +1 -1
  34. package/dist/cli/mcp/tools/lint.d.ts.map +1 -1
  35. package/dist/cli/mcp/tools/scaffold.d.ts.map +1 -1
  36. package/dist/cli/mcp/tools/search.d.ts.map +1 -1
  37. package/dist/cli/mcp/workspace-tools.d.ts +1 -1
  38. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  39. package/dist/cli/registry.d.ts +11 -2
  40. package/dist/cli/registry.d.ts.map +1 -1
  41. package/dist/codegen/fetch.d.ts.map +1 -1
  42. package/dist/codegen/naming.d.ts.map +1 -1
  43. package/dist/codegen/pinned-upgrade.d.ts +67 -3
  44. package/dist/codegen/pinned-upgrade.d.ts.map +1 -1
  45. package/dist/codegen/registry.d.ts.map +1 -1
  46. package/dist/components/capability-plugin-loader.d.ts.map +1 -1
  47. package/dist/components/capability.d.ts.map +1 -1
  48. package/dist/components/driver.d.ts.map +1 -1
  49. package/dist/components/fan-out.d.ts.map +1 -1
  50. package/dist/components/starter-plugin.d.ts.map +1 -1
  51. package/dist/components/verbs/process-runner.d.ts.map +1 -1
  52. package/dist/components/verbs/r2-sync.d.ts.map +1 -1
  53. package/dist/components/verbs/run-agent.d.ts.map +1 -1
  54. package/dist/components/verbs/sbom-generator.d.ts.map +1 -1
  55. package/dist/components/verbs/sign.d.ts.map +1 -1
  56. package/dist/components/verbs/verify.d.ts.map +1 -1
  57. package/dist/components/verbs/vuln-gate.d.ts.map +1 -1
  58. package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
  59. package/dist/components/verbs/wrangler.d.ts.map +1 -1
  60. package/dist/composite.d.ts.map +1 -1
  61. package/dist/config.d.ts +19 -7
  62. package/dist/config.d.ts.map +1 -1
  63. package/dist/declarable.d.ts +16 -0
  64. package/dist/declarable.d.ts.map +1 -1
  65. package/dist/discovery/cache.d.ts.map +1 -1
  66. package/dist/discovery/fold-import.d.ts +15 -5
  67. package/dist/discovery/fold-import.d.ts.map +1 -1
  68. package/dist/discovery/param-deps.d.ts +16 -0
  69. package/dist/discovery/param-deps.d.ts.map +1 -1
  70. package/dist/discovery/sandbox/policy-wire.d.ts.map +1 -1
  71. package/dist/discovery/specifier-resolve.d.ts +37 -0
  72. package/dist/discovery/specifier-resolve.d.ts.map +1 -0
  73. package/dist/errors.d.ts.map +1 -1
  74. package/dist/fold/fold.d.ts +3 -1
  75. package/dist/fold/fold.d.ts.map +1 -1
  76. package/dist/graph-detail.d.ts.map +1 -1
  77. package/dist/graph-ir.d.ts.map +1 -1
  78. package/dist/graph-layout.d.ts.map +1 -1
  79. package/dist/import/base-parser.d.ts.map +1 -1
  80. package/dist/import/embedded.d.ts +7 -0
  81. package/dist/import/embedded.d.ts.map +1 -1
  82. package/dist/import/parser-options.d.ts +14 -0
  83. package/dist/import/parser-options.d.ts.map +1 -0
  84. package/dist/import/parser.d.ts +17 -0
  85. package/dist/import/parser.d.ts.map +1 -1
  86. package/dist/kubectl-context.d.ts.map +1 -1
  87. package/dist/lexicon-output.d.ts.map +1 -1
  88. package/dist/lexicon-schema.d.ts +3 -3
  89. package/dist/lexicon.d.ts +55 -3
  90. package/dist/lexicon.d.ts.map +1 -1
  91. package/dist/lifecycle/assert-live.d.ts.map +1 -1
  92. package/dist/lifecycle/gate-ledger.d.ts +15 -0
  93. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  94. package/dist/lifecycle/git.d.ts.map +1 -1
  95. package/dist/lifecycle/member-ledger.d.ts.map +1 -1
  96. package/dist/lifecycle/observe.d.ts.map +1 -1
  97. package/dist/lifecycle/plan-ledger.d.ts.map +1 -1
  98. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  99. package/dist/lifecycle/scenario.d.ts.map +1 -1
  100. package/dist/lifecycle/status.d.ts.map +1 -1
  101. package/dist/lifecycle/work-lease.d.ts +61 -2
  102. package/dist/lifecycle/work-lease.d.ts.map +1 -1
  103. package/dist/lint/config.d.ts +8 -8
  104. package/dist/lint/engine.d.ts +6 -0
  105. package/dist/lint/engine.d.ts.map +1 -1
  106. package/dist/lsp/lexicon-providers.d.ts.map +1 -1
  107. package/dist/op/activities/activity-contracts.d.ts +11 -11
  108. package/dist/op/activities/apply.d.ts +79 -9
  109. package/dist/op/activities/apply.d.ts.map +1 -1
  110. package/dist/op/activities/decide.d.ts.map +1 -1
  111. package/dist/op/activities/index.d.ts +1 -1
  112. package/dist/op/activities/index.d.ts.map +1 -1
  113. package/dist/op/activities/lexicon-upgrade.d.ts +44 -12
  114. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  115. package/dist/op/activity-contract.d.ts +1 -1
  116. package/dist/op/activity-failure.d.ts.map +1 -1
  117. package/dist/op/activity-profiles.d.ts.map +1 -1
  118. package/dist/op/builders.d.ts.map +1 -1
  119. package/dist/op/composites/apply-op.d.ts +12 -5
  120. package/dist/op/composites/apply-op.d.ts.map +1 -1
  121. package/dist/op/composites/lexicon-upgrade-op.d.ts +22 -6
  122. package/dist/op/composites/lexicon-upgrade-op.d.ts.map +1 -1
  123. package/dist/op/decide-backend.d.ts.map +1 -1
  124. package/dist/op/decide-read-inputs.d.ts.map +1 -1
  125. package/dist/op/emulator-lifecycle.d.ts +21 -2
  126. package/dist/op/emulator-lifecycle.d.ts.map +1 -1
  127. package/dist/op/gate-approval.d.ts +5 -1
  128. package/dist/op/gate-approval.d.ts.map +1 -1
  129. package/dist/op/gate-wait.d.ts.map +1 -1
  130. package/dist/op/gate.d.ts +19 -3
  131. package/dist/op/gate.d.ts.map +1 -1
  132. package/dist/op/index.d.ts +2 -0
  133. package/dist/op/index.d.ts.map +1 -1
  134. package/dist/op/local-executor.d.ts +6 -0
  135. package/dist/op/local-executor.d.ts.map +1 -1
  136. package/dist/op/operator.d.ts.map +1 -1
  137. package/dist/op/run-context.d.ts +39 -0
  138. package/dist/op/run-context.d.ts.map +1 -0
  139. package/dist/op/run-live.d.ts.map +1 -1
  140. package/dist/op/runtimes/local.d.ts.map +1 -1
  141. package/dist/op/source-archive.d.ts.map +1 -1
  142. package/dist/op/steward-beside.d.ts.map +1 -1
  143. package/dist/op/steward-points.d.ts.map +1 -1
  144. package/dist/op/steward.d.ts.map +1 -1
  145. package/dist/op/types.d.ts +8 -3
  146. package/dist/op/types.d.ts.map +1 -1
  147. package/dist/op/work-lease-run.d.ts +24 -4
  148. package/dist/op/work-lease-run.d.ts.map +1 -1
  149. package/dist/project-root.d.ts.map +1 -1
  150. package/dist/provenance.d.ts +21 -0
  151. package/dist/provenance.d.ts.map +1 -1
  152. package/dist/pseudo-parameter.d.ts.map +1 -1
  153. package/dist/reconcile.d.ts.map +1 -1
  154. package/dist/secret-materialization.d.ts.map +1 -1
  155. package/dist/terraform/emit-source.d.ts.map +1 -1
  156. package/dist/terraform/parse.d.ts.map +1 -1
  157. package/dist/testing.d.ts.map +1 -1
  158. package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
  159. package/dist/workspace/__fixtures__/decide-workspace.d.ts +1 -1
  160. package/dist/workspace/__fixtures__/decide-workspace.d.ts.map +1 -1
  161. package/dist/workspace/agent-cli.d.ts +5 -0
  162. package/dist/workspace/agent-cli.d.ts.map +1 -1
  163. package/dist/workspace/box-factory.d.ts +73 -0
  164. package/dist/workspace/box-factory.d.ts.map +1 -0
  165. package/dist/workspace/changes.d.ts.map +1 -1
  166. package/dist/workspace/chant-migrations/chud-lexicon-exit.d.ts.map +1 -1
  167. package/dist/workspace/checks/boxes.d.ts.map +1 -1
  168. package/dist/workspace/checks.d.ts +5 -0
  169. package/dist/workspace/checks.d.ts.map +1 -1
  170. package/dist/workspace/compose-graph.d.ts +10 -0
  171. package/dist/workspace/compose-graph.d.ts.map +1 -1
  172. package/dist/workspace/conformance/index.d.ts +67 -6
  173. package/dist/workspace/conformance/index.d.ts.map +1 -1
  174. package/dist/workspace/conformance/vitest.d.ts +12 -3
  175. package/dist/workspace/conformance/vitest.d.ts.map +1 -1
  176. package/dist/workspace/conformance/writer.d.ts +368 -0
  177. package/dist/workspace/conformance/writer.d.ts.map +1 -0
  178. package/dist/workspace/decide.d.ts +1 -1
  179. package/dist/workspace/decide.d.ts.map +1 -1
  180. package/dist/workspace/declaration.d.ts +91 -4
  181. package/dist/workspace/declaration.d.ts.map +1 -1
  182. package/dist/workspace/declaration.schema.json +234 -5
  183. package/dist/workspace/generated-files.d.ts.map +1 -1
  184. package/dist/workspace/graph-cache.d.ts.map +1 -1
  185. package/dist/workspace/graph-cli.d.ts +2 -0
  186. package/dist/workspace/graph-cli.d.ts.map +1 -1
  187. package/dist/workspace/identity.d.ts +111 -0
  188. package/dist/workspace/identity.d.ts.map +1 -0
  189. package/dist/workspace/intent-cli.d.ts +7 -4
  190. package/dist/workspace/intent-cli.d.ts.map +1 -1
  191. package/dist/workspace/intent-record.d.ts +8 -1
  192. package/dist/workspace/intent-record.d.ts.map +1 -1
  193. package/dist/workspace/intent.d.ts +149 -5
  194. package/dist/workspace/intent.d.ts.map +1 -1
  195. package/dist/workspace/kinds.d.ts +18 -4
  196. package/dist/workspace/kinds.d.ts.map +1 -1
  197. package/dist/workspace/lineage-hash-index.d.ts.map +1 -1
  198. package/dist/workspace/lineage-lock.d.ts +2 -2
  199. package/dist/workspace/lineage-lock.d.ts.map +1 -1
  200. package/dist/workspace/links.d.ts.map +1 -1
  201. package/dist/workspace/points.d.ts.map +1 -1
  202. package/dist/workspace/principal-classes.d.ts +114 -0
  203. package/dist/workspace/principal-classes.d.ts.map +1 -0
  204. package/dist/workspace/reason-codes.d.ts +21 -1
  205. package/dist/workspace/reason-codes.d.ts.map +1 -1
  206. package/dist/workspace/record-assets.d.ts.map +1 -1
  207. package/dist/workspace/records-checkout.d.ts +54 -0
  208. package/dist/workspace/records-checkout.d.ts.map +1 -0
  209. package/dist/workspace/records-cli.d.ts +27 -1
  210. package/dist/workspace/records-cli.d.ts.map +1 -1
  211. package/dist/workspace/records-close.d.ts +1 -1
  212. package/dist/workspace/records-close.d.ts.map +1 -1
  213. package/dist/workspace/records-since.d.ts.map +1 -1
  214. package/dist/workspace/records-write.d.ts +5 -4
  215. package/dist/workspace/records-write.d.ts.map +1 -1
  216. package/dist/workspace/records.d.ts +15 -0
  217. package/dist/workspace/records.d.ts.map +1 -1
  218. package/dist/workspace/runs-cli.d.ts +164 -0
  219. package/dist/workspace/runs-cli.d.ts.map +1 -0
  220. package/dist/workspace/runs.d.ts +423 -0
  221. package/dist/workspace/runs.d.ts.map +1 -0
  222. package/dist/workspace/source-block.d.ts +1 -1
  223. package/dist/workspace/source-block.d.ts.map +1 -1
  224. package/dist/workspace/status-gates.d.ts +15 -3
  225. package/dist/workspace/status-gates.d.ts.map +1 -1
  226. package/dist/workspace/status.d.ts +10 -0
  227. package/dist/workspace/status.d.ts.map +1 -1
  228. package/dist/workspace/symbols.d.ts +52 -0
  229. package/dist/workspace/symbols.d.ts.map +1 -0
  230. package/dist/workspace/trailer-joins.d.ts +46 -0
  231. package/dist/workspace/trailer-joins.d.ts.map +1 -0
  232. package/dist/workspace/trailers.d.ts +89 -0
  233. package/dist/workspace/trailers.d.ts.map +1 -0
  234. package/dist/workspace/trust/attestor.d.ts.map +1 -1
  235. package/dist/workspace/trust/rotation.d.ts.map +1 -1
  236. package/dist/workspace/trust/seal.d.ts +33 -0
  237. package/dist/workspace/trust/seal.d.ts.map +1 -1
  238. package/dist/workspace/trust/test-repo.d.ts.map +1 -1
  239. package/dist/workspace/work-cli.d.ts +39 -3
  240. package/dist/workspace/work-cli.d.ts.map +1 -1
  241. package/dist/workspace/work-evidence.d.ts +1 -1
  242. package/dist/workspace/work-evidence.d.ts.map +1 -1
  243. package/dist/workspace/work.d.ts +26 -1
  244. package/dist/workspace/work.d.ts.map +1 -1
  245. package/dist/workspace/workspace-principals.schema.json +60 -0
  246. package/dist/workspace/write-scope.d.ts +79 -19
  247. package/dist/workspace/write-scope.d.ts.map +1 -1
  248. package/dist/yaml.d.ts +21 -39
  249. package/dist/yaml.d.ts.map +1 -1
  250. package/package.json +2 -2
  251. package/src/audit/prior-art.ts +15 -0
  252. package/src/cli/commands/__fixtures__/init-lexicon-output/package.json +1 -1
  253. package/src/cli/commands/carve-emit-state.test.ts +4 -0
  254. package/src/cli/commands/carve-emit.ts +5 -19
  255. package/src/cli/commands/check-lexicon.test.ts +62 -1
  256. package/src/cli/commands/check-lexicon.ts +36 -6
  257. package/src/cli/commands/import-live.test.ts +18 -0
  258. package/src/cli/commands/import-parser-options.test.ts +133 -0
  259. package/src/cli/commands/import.ts +21 -3
  260. package/src/cli/commands/init-lexicon/templates/project.ts +1 -1
  261. package/src/cli/commands/init.test.ts +8 -1
  262. package/src/cli/commands/init.ts +23 -8
  263. package/src/cli/commands/lint.test.ts +37 -0
  264. package/src/cli/commands/onboard.test.ts +53 -3
  265. package/src/cli/commands/onboard.ts +52 -4
  266. package/src/cli/commands/pinned-upgrade.ts +73 -33
  267. package/src/cli/handlers/__snapshots__/build-member.test.ts.snap +2 -2
  268. package/src/cli/handlers/components.ts +2 -2
  269. package/src/cli/handlers/dev.ts +8 -6
  270. package/src/cli/handlers/lifecycle.test.ts +67 -0
  271. package/src/cli/handlers/lifecycle.ts +6 -5
  272. package/src/cli/handlers/misc.ts +2 -0
  273. package/src/cli/handlers/operator.ts +87 -3
  274. package/src/cli/handlers/run.test.ts +111 -0
  275. package/src/cli/handlers/run.ts +85 -24
  276. package/src/cli/handlers/scenario.ts +2 -2
  277. package/src/cli/main.test.ts +1 -0
  278. package/src/cli/main.ts +65 -11
  279. package/src/cli/mcp/workspace-tools.test.ts +5 -2
  280. package/src/cli/mcp/workspace-tools.ts +29 -2
  281. package/src/cli/registry.ts +11 -2
  282. package/src/codegen/pinned-upgrade-multi.test.ts +157 -0
  283. package/src/codegen/pinned-upgrade-tags.test.ts +112 -0
  284. package/src/codegen/pinned-upgrade.test.ts +104 -0
  285. package/src/codegen/pinned-upgrade.ts +250 -56
  286. package/src/codegen/testdata/postgres-tags.json +242 -0
  287. package/src/config.test.ts +21 -0
  288. package/src/config.ts +15 -0
  289. package/src/declarable.test.ts +21 -0
  290. package/src/declarable.ts +19 -0
  291. package/src/discovery/fold-composite.test.ts +41 -5
  292. package/src/discovery/fold-import.test.ts +129 -0
  293. package/src/discovery/fold-import.ts +643 -131
  294. package/src/discovery/fold-package-composite.test.ts +264 -0
  295. package/src/discovery/fold-specifiers.test.ts +191 -0
  296. package/src/discovery/param-deps.test.ts +66 -1
  297. package/src/discovery/param-deps.ts +45 -0
  298. package/src/discovery/specifier-resolve.ts +344 -0
  299. package/src/fold/fold.test.ts +39 -1
  300. package/src/fold/fold.ts +83 -9
  301. package/src/fold-provenance.test.ts +140 -0
  302. package/src/graph-ir.test.ts +83 -0
  303. package/src/graph-ir.ts +29 -0
  304. package/src/import/embedded.test.ts +20 -0
  305. package/src/import/embedded.ts +13 -0
  306. package/src/import/parser-options.ts +46 -0
  307. package/src/import/parser.ts +20 -0
  308. package/src/lexicon.ts +55 -3
  309. package/src/lifecycle/gate-ledger.ts +16 -0
  310. package/src/lifecycle/observe.test.ts +18 -0
  311. package/src/lifecycle/observe.ts +6 -4
  312. package/src/lifecycle/snapshot.ts +2 -2
  313. package/src/lifecycle/work-lease.test.ts +1 -1
  314. package/src/lifecycle/work-lease.ts +93 -2
  315. package/src/lint/engine.test.ts +55 -0
  316. package/src/lint/engine.ts +30 -4
  317. package/src/lint/output-docs.test.ts +59 -56
  318. package/src/op/activities/apply.test.ts +189 -5
  319. package/src/op/activities/apply.ts +230 -8
  320. package/src/op/activities/index.ts +3 -0
  321. package/src/op/activities/lexicon-upgrade.test.ts +160 -6
  322. package/src/op/activities/lexicon-upgrade.ts +154 -51
  323. package/src/op/composites/apply-op.ts +12 -5
  324. package/src/op/composites/composites.test.ts +30 -3
  325. package/src/op/composites/lexicon-upgrade-op.test.ts +7 -2
  326. package/src/op/composites/lexicon-upgrade-op.ts +29 -8
  327. package/src/op/emulator-lifecycle.test.ts +23 -0
  328. package/src/op/emulator-lifecycle.ts +52 -14
  329. package/src/op/gate-approval.test.ts +74 -0
  330. package/src/op/gate-approval.ts +5 -1
  331. package/src/op/gate.ts +31 -5
  332. package/src/op/index.ts +2 -0
  333. package/src/op/local-executor.test.ts +15 -0
  334. package/src/op/local-executor.ts +40 -12
  335. package/src/op/local-output.ts +1 -1
  336. package/src/op/operator.ts +4 -0
  337. package/src/op/run-context.test.ts +141 -0
  338. package/src/op/run-context.ts +51 -0
  339. package/src/op/runtimes/local.test.ts +14 -0
  340. package/src/op/runtimes/local.ts +2 -0
  341. package/src/op/steward-beside.ts +2 -0
  342. package/src/op/types.ts +8 -3
  343. package/src/op/work-lease-run.test.ts +47 -0
  344. package/src/op/work-lease-run.ts +73 -11
  345. package/src/provenance.ts +35 -0
  346. package/src/workspace/__fixtures__/studio-template-84c8b21.json +1 -1
  347. package/src/workspace/agent-cli.ts +7 -3
  348. package/src/workspace/agent.schema.json +22 -0
  349. package/src/workspace/box-factory.test.ts +320 -0
  350. package/src/workspace/box-factory.ts +103 -0
  351. package/src/workspace/changes.schema.json +12 -20
  352. package/src/workspace/changes.ts +4 -1
  353. package/src/workspace/checks/boxes.test.ts +2 -0
  354. package/src/workspace/checks.ts +29 -5
  355. package/src/workspace/compose-graph.ts +7 -0
  356. package/src/workspace/conformance/__writer_fixture__/answers/answer.kind.mjs +27 -0
  357. package/src/workspace/conformance/__writer_fixture__/answers/answer.schema.json +95 -0
  358. package/src/workspace/conformance/__writer_fixture__/decisions/points.json +132 -0
  359. package/src/workspace/conformance/__writer_fixture__/sessions/session.kind.mjs +48 -0
  360. package/src/workspace/conformance/__writer_fixture__/sessions/session.schema.json +108 -0
  361. package/src/workspace/conformance/__writer_fixture__/work/W-001-the-writer-suite-works-this-item.md +25 -0
  362. package/src/workspace/conformance/__writer_fixture__/work/work.kind.mjs +60 -0
  363. package/src/workspace/conformance/__writer_fixture__/work/work.schema.json +578 -0
  364. package/src/workspace/conformance/conformance.e2e.test.ts +35 -3
  365. package/src/workspace/conformance/index.mjs +30 -3
  366. package/src/workspace/conformance/index.ts +103 -15
  367. package/src/workspace/conformance/vitest.ts +44 -2
  368. package/src/workspace/conformance/writer-conformance.e2e.test.ts +131 -0
  369. package/src/workspace/conformance/writer.test.ts +156 -0
  370. package/src/workspace/conformance/writer.ts +880 -0
  371. package/src/workspace/decide.ts +7 -1
  372. package/src/workspace/declaration.schema.json +234 -5
  373. package/src/workspace/declaration.ts +174 -10
  374. package/src/workspace/graph-cli.ts +5 -2
  375. package/src/workspace/graph.schema.json +247 -0
  376. package/src/workspace/identity.test.ts +196 -0
  377. package/src/workspace/identity.ts +186 -0
  378. package/src/workspace/intent-cli.ts +64 -10
  379. package/src/workspace/intent-record.schema.json +208 -0
  380. package/src/workspace/intent-record.ts +35 -1
  381. package/src/workspace/intent-why.test.ts +274 -0
  382. package/src/workspace/intent.schema.json +718 -7
  383. package/src/workspace/intent.test.ts +11 -2
  384. package/src/workspace/intent.ts +495 -32
  385. package/src/workspace/kinds.ts +44 -37
  386. package/src/workspace/points-write.schema.json +1 -0
  387. package/src/workspace/principal-classes.test.ts +175 -0
  388. package/src/workspace/principal-classes.ts +307 -0
  389. package/src/workspace/reason-codes.test.ts +11 -3
  390. package/src/workspace/reason-codes.ts +24 -1
  391. package/src/workspace/records-amend.schema.json +2 -0
  392. package/src/workspace/records-checkout.ts +106 -0
  393. package/src/workspace/records-cli.ts +103 -11
  394. package/src/workspace/records-close.schema.json +1 -0
  395. package/src/workspace/records-new.schema.json +2 -0
  396. package/src/workspace/records-review.schema.json +2 -0
  397. package/src/workspace/records-uncommitted.test.ts +171 -0
  398. package/src/workspace/records-write.ts +28 -6
  399. package/src/workspace/records.schema.json +72 -1
  400. package/src/workspace/records.ts +27 -0
  401. package/src/workspace/runs-cli.ts +381 -0
  402. package/src/workspace/runs-write.schema.json +583 -0
  403. package/src/workspace/runs.schema.json +810 -0
  404. package/src/workspace/runs.test.ts +242 -0
  405. package/src/workspace/runs.ts +709 -0
  406. package/src/workspace/status-contract.test.ts +5 -0
  407. package/src/workspace/status-gates.ts +22 -8
  408. package/src/workspace/status.schema.json +127 -2
  409. package/src/workspace/status.ts +21 -1
  410. package/src/workspace/symbols.ts +180 -0
  411. package/src/workspace/trailer-joins.test.ts +171 -0
  412. package/src/workspace/trailer-joins.ts +82 -0
  413. package/src/workspace/trailers.ts +148 -0
  414. package/src/workspace/trust/seal.ts +52 -1
  415. package/src/workspace/work-cli.e2e.test.ts +28 -1
  416. package/src/workspace/work-cli.ts +133 -8
  417. package/src/workspace/work-evidence.schema.json +2 -0
  418. package/src/workspace/work-history.schema.json +73 -1
  419. package/src/workspace/work-history.test.ts +48 -5
  420. package/src/workspace/work-kind-fields.test.ts +154 -0
  421. package/src/workspace/work-lease.schema.json +14 -1
  422. package/src/workspace/work.ts +82 -0
  423. package/src/workspace/workspace-principals.schema.json +60 -0
  424. package/src/workspace/write-scope.test.ts +128 -2
  425. package/src/workspace/write-scope.ts +170 -45
  426. package/src/yaml.test.ts +111 -1
  427. package/src/yaml.ts +141 -549
@@ -16,8 +16,25 @@ export interface EmulatorSpec {
16
16
  image: string;
17
17
  /** Port the emulator listens on inside the container (e.g. 4566, 4280). */
18
18
  containerPort: number;
19
- /** Health path on the host port (e.g. `/_localstack/health`, `/_mudflaps/health`). */
20
- healthPath: string;
19
+ /**
20
+ * Health path on the host port (e.g. `/_localstack/health`, `/_mudflaps/health`).
21
+ * Required unless {@link readyCommand} is set: an emulator is reached over
22
+ * HTTP or checks itself inside its container, one or the other.
23
+ */
24
+ healthPath?: string;
25
+ /**
26
+ * A command run inside the container (`docker exec <name> ...`) whose exit
27
+ * status 0 means ready, for an emulator with no HTTP health endpoint: a
28
+ * database server answers its own client (`pg_isready`), not a URL. When
29
+ * set, it replaces the HTTP poll of {@link healthPath}.
30
+ */
31
+ readyCommand?: readonly string[];
32
+ /**
33
+ * The endpoint a tool connects to, for an emulator that is not reached over
34
+ * HTTP, e.g. a `postgres://` URL for a database server.
35
+ * Default: `http://localhost:<port>`.
36
+ */
37
+ endpoint?: (port: number) => string;
21
38
  /** Readiness predicate over the health body. Default: any 200 response is ready. */
22
39
  ready?: (healthBody: string) => boolean;
23
40
  /** Extra `docker run` args inserted before the image (e.g. a socket mount). */
@@ -102,6 +119,8 @@ export interface EmulatorLifecycle {
102
119
  existsCommand(name: string): string;
103
120
  rmCommand(name: string): string;
104
121
  healthUrl(port: number): string;
122
+ /** `docker exec <name> <readyCommand...>`, or undefined for an emulator polled over HTTP. */
123
+ readyExecCommand(name: string): string | undefined;
105
124
  endpoint(port: number): string;
106
125
  up(args?: EmulatorUpArgs, signal?: AbortSignal): Promise<{ endpoint: string }>;
107
126
  down(args?: { name?: string }, signal?: AbortSignal): Promise<void>;
@@ -116,6 +135,9 @@ export interface EmulatorLifecycle {
116
135
  */
117
136
  export function emulatorLifecycle(spec: EmulatorSpec): EmulatorLifecycle {
118
137
  const ready = spec.ready ?? (() => true);
138
+ if (spec.healthPath === undefined && !spec.readyCommand?.length) {
139
+ throw new Error(`emulator "${spec.name}" declares neither a healthPath nor a readyCommand, so nothing can tell when it is ready`);
140
+ }
119
141
 
120
142
  const runCommand = (args: EmulatorUpArgs = {}): string => {
121
143
  const name = args.name ?? spec.name;
@@ -128,8 +150,30 @@ export function emulatorLifecycle(spec: EmulatorSpec): EmulatorLifecycle {
128
150
  };
129
151
  const existsCommand = (name: string): string => `docker ps -q -f name=${name}`;
130
152
  const rmCommand = (name: string): string => `docker rm -f ${name}`;
131
- const healthUrl = (port: number): string => `http://localhost:${port}${spec.healthPath}`;
132
- const endpoint = (port: number): string => `http://localhost:${port}`;
153
+ const healthUrl = (port: number): string => `http://localhost:${port}${spec.healthPath ?? ""}`;
154
+ const endpoint = (port: number): string => (spec.endpoint ? spec.endpoint(port) : `http://localhost:${port}`);
155
+ const readyExecCommand = (name: string): string | undefined =>
156
+ spec.readyCommand?.length ? ["docker", "exec", name, ...spec.readyCommand].join(" ") : undefined;
157
+
158
+ /** One readiness probe: the in-container command when declared, else the HTTP health path. */
159
+ async function probe(name: string, port: number, signal?: AbortSignal): Promise<boolean> {
160
+ const command = readyExecCommand(name);
161
+ if (command) {
162
+ try {
163
+ await execAsync(command, { signal });
164
+ return true;
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
169
+ try {
170
+ const res = await fetch(healthUrl(port), { signal });
171
+ return res.ok && ready(await res.text());
172
+ } catch {
173
+ // Not up yet (connection refused / non-2xx) — retry.
174
+ return false;
175
+ }
176
+ }
133
177
 
134
178
  async function up(args: EmulatorUpArgs = {}, signal?: AbortSignal): Promise<{ endpoint: string }> {
135
179
  const name = args.name ?? spec.name;
@@ -153,19 +197,13 @@ export function emulatorLifecycle(spec: EmulatorSpec): EmulatorLifecycle {
153
197
  await execAsync(runCommand({ ...args, name, port }), { signal });
154
198
  }
155
199
 
156
- const url = healthUrl(port);
157
200
  const deadline = Date.now() + timeoutMs;
158
201
  let ok = false;
159
202
  while (Date.now() < deadline) {
160
203
  if (signal?.aborted) throw new Error(`emulator "${name}" wait aborted`);
161
- try {
162
- const res = await fetch(url, { signal });
163
- if (res.ok && ready(await res.text())) {
164
- ok = true;
165
- break;
166
- }
167
- } catch {
168
- // Not up yet (connection refused / non-2xx) — retry.
204
+ if (await probe(name, port, signal)) {
205
+ ok = true;
206
+ break;
169
207
  }
170
208
  await sleep(intervalMs, signal);
171
209
  }
@@ -187,5 +225,5 @@ export function emulatorLifecycle(spec: EmulatorSpec): EmulatorLifecycle {
187
225
  }
188
226
  }
189
227
 
190
- return { runCommand, existsCommand, rmCommand, healthUrl, endpoint, up, down };
228
+ return { runCommand, existsCommand, rmCommand, healthUrl, readyExecCommand, endpoint, up, down };
191
229
  }
@@ -191,6 +191,80 @@ describe("gate approval — policy decisions (#2508)", () => {
191
191
  expect(check.satisfied).toBe(false);
192
192
  });
193
193
 
194
+ test("log-only then enforce: a permit recorded under log-only does not pass the enforced gate (#2512)", async () => {
195
+ const logOnly: ResolvedGateApproval = { policy: POLICY, mode: "log-only", context: { risk: "low" } };
196
+ const enforce: ResolvedGateApproval = { ...logOnly, mode: "enforce" };
197
+ // The pending fact was recorded while the gate was log-only, and the agent's allow was recorded against it.
198
+ const port = memoryGateLedgerPort({
199
+ resolutions: [approval({ resolvedBy: "release-bot", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "agent" }, policyDecision: allow("log-only") })],
200
+ pending: [{ ...PENDING, approval: logOnly }],
201
+ });
202
+ const check = await evaluateGate(port, { op: "release", gate: "ship", planDigest: PLAN_A, approval: enforce, now: NOW });
203
+ expect(check.satisfied).toBe(false);
204
+
205
+ const tally = tallyGateApprovals(
206
+ [approval({ resolvedBy: "release-bot", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "agent" }, policyDecision: allow("log-only") })],
207
+ "ship", PENDING.timestamp, PLAN_A, enforce,
208
+ );
209
+ expect(tally.permit).toBeUndefined();
210
+ });
211
+
212
+ test("log-only then enforce: a human's approval recorded under log-only still counts toward the quorum (#2512)", async () => {
213
+ const logOnly: ResolvedGateApproval = { quorum: { count: 1 }, policy: POLICY, mode: "log-only" };
214
+ const enforce: ResolvedGateApproval = { ...logOnly, mode: "enforce" };
215
+ const port = memoryGateLedgerPort({
216
+ resolutions: [approval({ resolvedBy: "alex", timestamp: "2026-09-01T01:00:00.000Z", policyDecision: allow("log-only") })],
217
+ pending: [{ ...PENDING, approval: logOnly }],
218
+ });
219
+ const check = await evaluateGate(port, { op: "release", gate: "ship", planDigest: PLAN_A, approval: enforce, now: NOW });
220
+ expect(check.satisfied).toBe(true);
221
+ if (!check.satisfied) return;
222
+ expect(check.via).toBe("quorum");
223
+ expect(check.resolution.resolvedBy).toBe("alex");
224
+ });
225
+
226
+ test("log-only then enforce: the gated run re-records the pending fact under enforce, and only a fresh permit passes (#2512)", async () => {
227
+ const logOnly: ResolvedGateApproval = { policy: POLICY, mode: "log-only", context: { risk: "low" } };
228
+ const enforce: ResolvedGateApproval = { ...logOnly, mode: "enforce" };
229
+ const stale = approval({ resolvedBy: "release-bot", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "agent" }, policyDecision: allow("log-only") });
230
+ const port = memoryGateLedgerPort({ resolutions: [stale], pending: [{ ...PENDING, approval: logOnly }] });
231
+
232
+ const first = await evaluateGate(port, { op: "release", gate: "ship", planDigest: PLAN_A, approval: enforce, now: NOW });
233
+ expect(first.satisfied).toBe(false);
234
+ if (first.satisfied) return;
235
+ // The standing fact described the log-only gate, so a fresh one carries
236
+ // enforce, and `chant approve` evaluates the next agent approval under it.
237
+ expect(first.recorded).toBe(true);
238
+ expect(first.pending.approval?.mode).toBe("enforce");
239
+
240
+ // Re-running without a new approval still waits: the stale permit predates the new pending fact as well.
241
+ const again = await evaluateGate(port, { op: "release", gate: "ship", planDigest: PLAN_A, approval: enforce, now: "2026-09-01T12:30:00.000Z" });
242
+ expect(again.satisfied).toBe(false);
243
+
244
+ const fresh = approval({ resolvedBy: "release-bot", timestamp: "2026-09-01T13:00:00.000Z", approver: { kind: "agent" }, policyDecision: allow("enforce") });
245
+ const after = await evaluateGate(
246
+ memoryGateLedgerPort({ resolutions: [stale, fresh], pending: [first.pending] }),
247
+ { op: "release", gate: "ship", planDigest: PLAN_A, approval: enforce, now: "2026-09-01T14:00:00.000Z" },
248
+ );
249
+ expect(after.satisfied).toBe(true);
250
+ if (!after.satisfied) return;
251
+ expect(after.via).toBe("policy");
252
+ expect(after.resolution.timestamp).toBe(fresh.timestamp);
253
+ });
254
+
255
+ test("enforce: a recorded decision with no mode does not pass the gate (#2512)", async () => {
256
+ const block: ResolvedGateApproval = { policy: POLICY, mode: "enforce" };
257
+ const { mode: _mode, ...noMode } = allow()!;
258
+ const { check } = await decide(
259
+ [approval({
260
+ resolvedBy: "release-bot", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "agent" },
261
+ policyDecision: noMode as GateResolutionRecord["policyDecision"],
262
+ })],
263
+ block,
264
+ );
265
+ expect(check.satisfied).toBe(false);
266
+ });
267
+
194
268
  test("enforce: a deny-by-default policy keeps the old behaviour, where only a human's approval counts", async () => {
195
269
  const block: ResolvedGateApproval = { policy: POLICY, mode: "enforce" };
196
270
  const deny = { ...allow()!, decision: "deny" as const, determining: ["floor"] };
@@ -119,7 +119,11 @@ export interface GatePolicyAnswer {
119
119
  export interface GatePolicyDecision extends GatePolicyAnswer {
120
120
  policy: string;
121
121
  version: string;
122
- /** The gate's mode when the decision was recorded. A run reads the gate's current mode, not this. */
122
+ /**
123
+ * The gate's mode when the decision was recorded. A permit passes a gate
124
+ * only when both this and the gate's current mode are `enforce` (#2512), so
125
+ * an allow recorded under `log-only` never binds after a switch.
126
+ */
123
127
  mode: GateApprovalMode;
124
128
  }
125
129
 
package/src/op/gate.ts CHANGED
@@ -74,6 +74,19 @@ export interface PendingGatePush {
74
74
  export interface GateLedgerPort {
75
75
  read(op: string): Promise<{ resolutions: GateResolutionRecord[]; pending: PendingGateRecord[] }>;
76
76
  appendPending(input: PendingGateInput): Promise<PendingGatePush>;
77
+ /**
78
+ * The rule a workspace sets for approvals of `gate` (#3163, ws-080), or
79
+ * null when none applies: the declaration's `identity.gates`, read at base.
80
+ * Absent, every approval is judged as before.
81
+ */
82
+ approvalRule?(gate: string): Promise<GateApprovalRule | null>;
83
+ }
84
+
85
+ /** A workspace's rule for which approvals of a gate count (#3163): a signed approval, from a class when it names one. */
86
+ export interface GateApprovalRule {
87
+ requirement: { gate: string; class: string | null };
88
+ /** Why `approval` doesn't count, or null when it does. */
89
+ refuses(approval: GateResolutionRecord): string | null;
77
90
  }
78
91
 
79
92
  /**
@@ -102,6 +115,11 @@ export function gitGateLedgerPort(opts?: { cwd?: string }): GateLedgerPort {
102
115
  const { resolutions, pending } = await readGateLedger(op, opts);
103
116
  return { resolutions, pending };
104
117
  },
118
+ async approvalRule(gate) {
119
+ // Loaded on demand: the rule lives in the workspace modules, which import the Op modules.
120
+ const { gateAdmission } = await import("../workspace/identity");
121
+ return gateAdmission(opts?.cwd ?? process.cwd(), gate);
122
+ },
105
123
  async appendPending(input) {
106
124
  const { record } = await appendPendingGate(input, opts);
107
125
  try {
@@ -288,7 +306,7 @@ export interface GateTally {
288
306
  counted: GateResolutionRecord[];
289
307
  /** The quorum's count, 1 when the gate declares none. */
290
308
  need: number;
291
- /** The newest approval whose recorded permit passes the gate on its own. Only in `enforce` mode, and only under the gate's current policy version. */
309
+ /** The newest approval whose recorded permit passes the gate on its own. Only in `enforce` mode, only for a permit recorded under `enforce`, and only under the gate's current policy version. */
292
310
  permit?: GateResolutionRecord;
293
311
  /** The newest approval of this gate for a different plan, when the gate binds one. */
294
312
  mismatched?: GateResolutionRecord;
@@ -305,8 +323,9 @@ export interface GateTally {
305
323
  * Of those, only a human's counts toward the quorum, once per `resolvedBy`,
306
324
  * and only with one of the quorum's roles when it names any. An agent counts
307
325
  * only through a recorded `allow` in `enforce` mode, evaluated under the
308
- * policy version the gate declares now. A `log-only` decision never changes
309
- * the outcome, and a `deny` never removes a human's approval: a policy can add
326
+ * policy version the gate declares now and recorded while the gate was in
327
+ * `enforce` (#2512). A `log-only` decision never changes the outcome, even
328
+ * after the gate switches to `enforce`, and a `deny` never removes a human's approval: a policy can add
310
329
  * a way through the gate but cannot take one away.
311
330
  */
312
331
  export function tallyGateApprovals(
@@ -331,8 +350,12 @@ export function tallyGateApprovals(
331
350
  }
332
351
 
333
352
  const decision = r.policyDecision;
353
+ // #2512: the decision must also have been recorded under `enforce`. An
354
+ // allow recorded while the gate was log-only was never binding, and a
355
+ // later switch to enforce does not make it binding.
334
356
  if (
335
357
  approval.mode === "enforce" && approval.policy && decision?.decision === "allow" &&
358
+ decision.mode === "enforce" &&
336
359
  decision.version === approval.policy.version && (!permit || at(r) >= at(permit))
337
360
  ) {
338
361
  permit = r;
@@ -394,10 +417,13 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
394
417
  // a mismatch with a message instead of silently not counting.
395
418
  const env = input.environment;
396
419
  const pending = env === undefined ? ledger.pending : ledger.pending.filter((p) => p.environment === env);
397
- const resolutions =
420
+ // #3163: a gate the workspace's identity.gates names counts only signed approvals from its class.
421
+ const rule = (await port.approvalRule?.(input.gate)) ?? null;
422
+ const resolutions = (
398
423
  env === undefined
399
424
  ? ledger.resolutions
400
- : ledger.resolutions.filter((r) => r.environment === undefined || r.environment === env);
425
+ : ledger.resolutions.filter((r) => r.environment === undefined || r.environment === env)
426
+ ).filter((r) => rule === null || r.gate !== input.gate || rule.refuses(r) === null);
401
427
 
402
428
  const standing = latestPendingGate(pending, input.gate);
403
429
  const since = standing?.timestamp ?? EPOCH;
package/src/op/index.ts CHANGED
@@ -60,6 +60,8 @@ export type { ActivityProfile, ActivityProfileName } from "./activity-profiles";
60
60
  export { NonRetryableActivityError, nonRetryableFailure } from "./activity-failure";
61
61
  export { runOpLocally, parseDuration, OpRunFailure } from "./local-executor";
62
62
  export type { StepRecord, OpRunResult, RunOpOptions } from "./local-executor";
63
+ export { currentOpRun, withOpRunContext } from "./run-context";
64
+ export type { OpRunContext, PassedGate } from "./run-context";
63
65
  export { evaluateGate, gitGateLedgerPort, memoryGateLedgerPort, approveCommand, describeGateMismatch } from "./gate";
64
66
  export type { GateLedgerPort, GateCheck, GateCheckInput, PendingGatePush, GateDigestMismatch, GateQuorumProgress, GateTally } from "./gate";
65
67
  export { tallyGateApprovals, approverOf } from "./gate";
@@ -232,6 +232,21 @@ describe("runOpLocally — outcomeAttribute", () => {
232
232
  expect(result.records[0].outcome).toEqual({ name: "Drift", value: true });
233
233
  });
234
234
 
235
+ test("an attribute whose path the result does not hold is left off, not recorded as undefined", async () => {
236
+ const done: ActivityFn = async () => ({ state: "done", switched: false });
237
+ const config = op({
238
+ phases: [{ name: "Switch", steps: [
239
+ { kind: "activity", fn: "migrationSwitch", outcomeAttribute: [{ name: "OldColumn", from: "oldColumn" }, { name: "State", from: "state" }] },
240
+ { kind: "activity", fn: "migrationRetain", outcomeAttribute: { name: "RetainUntil", from: "retainUntil" } },
241
+ ] }],
242
+ });
243
+ const result = await runOpLocally(config, new Map([["migrationSwitch", done], ["migrationRetain", done]]), PROFILES);
244
+ expect(result.records[0].outcomes).toEqual([{ name: "State", value: "done" }]);
245
+ expect(result.records[0].outcome).toEqual({ name: "State", value: "done" });
246
+ expect(result.records[1].outcomes).toBeUndefined();
247
+ expect(result.records[1].outcome).toBeUndefined();
248
+ });
249
+
235
250
  test("a step with no attribute leaves both fields absent", async () => {
236
251
  const noop: ActivityFn = async () => ({ ok: true });
237
252
  const config = op({ phases: [{ name: "Plan", steps: [{ kind: "activity", fn: "noop" }] }] });
@@ -29,6 +29,7 @@ import { stepTimeoutProblem } from "./activity-profiles";
29
29
  import { describeGateMismatch, evaluateGate, gitGateLedgerPort, type GateCheck, type GateLedgerPort } from "./gate";
30
30
  import { gateName } from "./gate-name";
31
31
  import type { ResolvedGateApproval } from "./gate-approval";
32
+ import { withOpRunContext, type OpRunContext, type PassedGate } from "./run-context";
32
33
  import type { PendingGateRecord } from "../lifecycle/gate-ledger";
33
34
  import { isPointWait, type WaitingPoint } from "./steward-points";
34
35
  import { isGateWait } from "./gate-wait";
@@ -416,9 +417,11 @@ async function runStep(
416
417
  if (step.id) resultsById.set(step.id, result);
417
418
  const record: StepRecord = { ...base, status: "ok", durationMs: Date.now() - start };
418
419
  const attrs = outcomeAttributesOf(step);
419
- if (attrs.length > 0) {
420
- record.outcomes = attrs.map((a) => ({ name: a.name, value: resolvePath(result, a.from) }));
421
- record.outcome = record.outcomes[0];
420
+ // An attribute whose path the result does not hold is left off, rather than recorded as undefined.
421
+ const outcomes = attrs.map((a) => ({ name: a.name, value: resolvePath(result, a.from) })).filter((o) => o.value !== undefined);
422
+ if (outcomes.length > 0) {
423
+ record.outcomes = outcomes;
424
+ record.outcome = outcomes[0];
422
425
  }
423
426
  return { record, result };
424
427
  } catch (err) {
@@ -482,6 +485,8 @@ interface GateContext {
482
485
  runId?: string;
483
486
  /** Called once per settled step, in production order (#2121) — what `--progress-json` streams from. */
484
487
  onRecord?: (record: StepRecord) => void;
488
+ /** The run context's list of passed gates (#2522). A gate that passes is appended here. */
489
+ passed?: PassedGate[];
485
490
  /** The run's hold on its work item's lease (#2748). Absent in compensation phases, which run whatever the lease did. */
486
491
  work?: RunWorkLease;
487
492
  }
@@ -643,6 +648,17 @@ async function runGateStep(
643
648
 
644
649
  if (check.satisfied) {
645
650
  const { resolution } = check;
651
+ const passedApproval: NonNullable<StepRecord["approval"]> = {
652
+ gate: gateName(step),
653
+ resolvedBy: resolution.resolvedBy,
654
+ timestamp: resolution.timestamp,
655
+ ...(resolution.url ? { url: resolution.url } : {}),
656
+ ...(check.via ? { via: check.via } : {}),
657
+ ...(check.approvals ? { approvers: check.approvals.map((r) => r.resolvedBy) } : {}),
658
+ };
659
+ // #2522: a later step reads this gate's approval from the run context
660
+ // rather than re-tallying the ledger, so it sees the decision this run made.
661
+ gates.passed?.push({ gate: gateName(step), approval: passedApproval });
646
662
  return {
647
663
  record: {
648
664
  phase: phaseName,
@@ -650,14 +666,7 @@ async function runGateStep(
650
666
  args: {},
651
667
  status: "ok",
652
668
  durationMs: Date.now() - start,
653
- approval: {
654
- gate: gateName(step),
655
- resolvedBy: resolution.resolvedBy,
656
- timestamp: resolution.timestamp,
657
- ...(resolution.url ? { url: resolution.url } : {}),
658
- ...(check.via ? { via: check.via } : {}),
659
- ...(check.approvals ? { approvers: check.approvals.map((r) => r.resolvedBy) } : {}),
660
- },
669
+ approval: passedApproval,
661
670
  },
662
671
  };
663
672
  }
@@ -971,6 +980,12 @@ export interface RunOpOptions {
971
980
  now?: string;
972
981
  /** Identifies this run on any pending fact it records. */
973
982
  runId?: string;
983
+ /**
984
+ * The environment the run was started for (`--env`, #2522). The executor
985
+ * only hands it to activities through the run context (`./run-context.ts`).
986
+ * The run ledger path still comes from the Op's `labels.Env`.
987
+ */
988
+ env?: string;
974
989
  /**
975
990
  * Called once per settled step, in the order the records are produced
976
991
  * (#2121) — what the local op runtime (./runtimes/local.ts) feeds
@@ -1039,6 +1054,13 @@ export async function runOpLocally(
1039
1054
  const turn = currentStewardTurn();
1040
1055
  const steward = options.steward ?? turn?.steward;
1041
1056
  const restore = turn && turn.run !== runId ? enterStewardTurn({ ...turn, run: runId }) : undefined;
1057
+ // #2522: every activity in this run can read the run it is in.
1058
+ const run: OpRunContext = {
1059
+ op: config.name,
1060
+ runId,
1061
+ ...(options.env !== undefined ? { env: options.env } : {}),
1062
+ passedGates: [],
1063
+ };
1042
1064
  // A run that writes to the ledger also says what it is doing while it runs
1043
1065
  // (./run-live.ts), and removes that once its record is written.
1044
1066
  const live = options.ledger
@@ -1052,7 +1074,11 @@ export async function runOpLocally(
1052
1074
  })
1053
1075
  : undefined;
1054
1076
  try {
1055
- return await withLiveRun(live, () => runOpInTurn(config, activities, profiles, signal, { ...options, runId, ...(steward ? { steward } : {}) }));
1077
+ return await withLiveRun(live, () =>
1078
+ withOpRunContext(run, () =>
1079
+ runOpInTurn(config, activities, profiles, signal, { ...options, runId, ...(steward ? { steward } : {}) }, run),
1080
+ ),
1081
+ );
1056
1082
  } finally {
1057
1083
  live?.close();
1058
1084
  restore?.();
@@ -1065,6 +1091,7 @@ async function runOpInTurn(
1065
1091
  profiles: Record<string, ActivityProfile>,
1066
1092
  signal: AbortSignal | undefined,
1067
1093
  options: RunOpOptions & { runId: string },
1094
+ run: OpRunContext,
1068
1095
  ): Promise<OpRunResult> {
1069
1096
  const runId = options.runId;
1070
1097
 
@@ -1074,6 +1101,7 @@ async function runOpInTurn(
1074
1101
  ...(options.now ? { now: options.now } : {}),
1075
1102
  runId,
1076
1103
  ...(options.onRecord ? { onRecord: options.onRecord } : {}),
1104
+ passed: run.passedGates,
1077
1105
  };
1078
1106
 
1079
1107
  // Effect steps are ordered (read-compare-run-write): refuse them in a
@@ -66,7 +66,7 @@ export function renderHuman(result: OpRunResult, write: Writer = stderr): void {
66
66
  : work.released
67
67
  ? `released ${work.outcome}`
68
68
  : "not released; it runs out on its own";
69
- write(`[work] ${work.item} held by ${work.holder}${work.branch ? ` on ${work.branch}` : ""}: ${end}`);
69
+ write(`[work] ${work.item} held by ${work.holder}${work.branch ? ` on ${work.branch}` : ""}: ${end}${work.kept ? `; the attempt is kept at ${work.kept}` : ""}`);
70
70
  } else if (work?.refusal) {
71
71
  write(`[work] nothing claimed: ${work.refusal}`);
72
72
  }
@@ -577,6 +577,10 @@ export async function runOperatorRound(opts: OperatorRoundOptions): Promise<Oper
577
577
  try {
578
578
  const result = await runOpLocally(config, opts.activities, opts.profiles, opts.signal, {
579
579
  runId,
580
+ // #2522: the environment the operator was started for (`--env`, or a
581
+ // steward's environment) reaches the activities through the run
582
+ // context, as `chant run --env` does.
583
+ ...(opts.env ?? opts.stewardEnv ? { env: opts.env ?? opts.stewardEnv } : {}),
580
584
  ...(steward ? { steward: steward.name } : {}),
581
585
  ledger: { cwd: opts.cwd },
582
586
  // A steward's turn claims work leases as `<steward>/<op>@<holder>`
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The run context an activity reads (#2522): the Op, the run id the run
3
+ * ledger records, `--env`, and the gates the run has passed.
4
+ */
5
+ import { describe, test, expect } from "vitest";
6
+ import type { OpConfig } from "./types";
7
+ import type { ActivityFn, ActivityProfile } from "./activity-registry";
8
+ import { runOpLocally } from "./local-executor";
9
+ import { memoryGateLedgerPort } from "./gate";
10
+ import { currentOpRun, type OpRunContext } from "./run-context";
11
+ import { gatePolicyVersion, type GatePolicyRef, type ResolvedGateApproval } from "./gate-approval";
12
+ import type { GateResolutionRecord } from "../lifecycle/gate-ledger";
13
+
14
+ const PROFILES: Record<string, ActivityProfile> = {
15
+ fastIdempotent: { timeout: "5m", retry: { maximumAttempts: 1 } },
16
+ };
17
+ const NOW = "2026-09-05T12:00:00.000Z";
18
+
19
+ function op(steps: OpConfig["phases"][number]["steps"]): OpConfig {
20
+ return { name: "release", overview: "", phases: [{ name: "main", steps }] };
21
+ }
22
+
23
+ describe("the Op run context (#2522)", () => {
24
+ test("an activity reads the Op, the run id and --env", async () => {
25
+ let seen: OpRunContext | undefined;
26
+ const activities = new Map<string, ActivityFn>([
27
+ ["deploy", async () => { await Promise.resolve(); seen = currentOpRun(); }],
28
+ ]);
29
+ const result = await runOpLocally(op([{ kind: "activity", fn: "deploy" }]), activities, PROFILES, undefined, {
30
+ runId: "local-123",
31
+ env: "staging",
32
+ });
33
+ expect(seen).toEqual({ op: "release", runId: "local-123", env: "staging", passedGates: [] });
34
+ expect(result.record?.id).toBe("local-123");
35
+ });
36
+
37
+ test("without --env the context carries none, and outside a run there is no context", async () => {
38
+ let seen: OpRunContext | undefined;
39
+ const activities = new Map<string, ActivityFn>([["deploy", async () => { seen = currentOpRun(); }]]);
40
+ await runOpLocally(op([{ kind: "activity", fn: "deploy" }]), activities, PROFILES, undefined, { runId: "r1" });
41
+ expect(seen).toEqual({ op: "release", runId: "r1", passedGates: [] });
42
+ expect(currentOpRun()).toBeUndefined();
43
+ });
44
+
45
+ test("a step after a passed gate reads the gate's approval", async () => {
46
+ const seen: OpRunContext[] = [];
47
+ const activities = new Map<string, ActivityFn>([
48
+ ["plan", async () => { seen.push(currentOpRun()!); }],
49
+ ["apply", async () => { seen.push(currentOpRun()!); }],
50
+ ]);
51
+ const port = memoryGateLedgerPort({
52
+ pending: [{
53
+ version: 1, kind: "pending", op: "release", gate: "ship",
54
+ timestamp: "2026-09-05T10:00:00.000Z", expiresAt: "2026-09-07T10:00:00.000Z",
55
+ }],
56
+ resolutions: [{ version: 1, op: "release", gate: "ship", resolvedBy: "alex", timestamp: "2026-09-05T11:00:00.000Z" }],
57
+ });
58
+ const result = await runOpLocally(
59
+ op([
60
+ { kind: "activity", fn: "plan" },
61
+ { kind: "gate", gate: "ship" },
62
+ { kind: "activity", fn: "apply" },
63
+ ]),
64
+ activities,
65
+ PROFILES,
66
+ undefined,
67
+ { gates: port, now: NOW, runId: "r2" },
68
+ );
69
+ expect(result.status).toBe("ok");
70
+ expect(seen[0]!.passedGates).toEqual([]);
71
+ expect(seen[1]!.passedGates).toEqual([
72
+ { gate: "ship", approval: { gate: "ship", resolvedBy: "alex", timestamp: "2026-09-05T11:00:00.000Z" } },
73
+ ]);
74
+ expect(seen[1]!.passedGates[0]!.approval).toEqual(result.records.find((r) => r.fn === "gate:ship")?.approval);
75
+ });
76
+
77
+ describe("a policy permit and the gates a run passed (#2512)", () => {
78
+ const TEXT = 'permit (principal is Chant::Agent, action, resource) when { context.risk == "low" };\n';
79
+ const POLICY: GatePolicyRef = { kind: "gate-policy", lexicon: "cedar", name: "ship", version: gatePolicyVersion(TEXT), text: TEXT };
80
+ const enforce: ResolvedGateApproval = { policy: POLICY, mode: "enforce" };
81
+ const permit = (mode: "log-only" | "enforce"): GateResolutionRecord => ({
82
+ version: 1, op: "release", gate: "ship", resolvedBy: "release-bot", timestamp: "2026-09-05T11:00:00.000Z",
83
+ approver: { kind: "agent" },
84
+ policyDecision: { policy: "ship", version: POLICY.version, mode, decision: "allow", determining: ["ship-0"], errors: [] },
85
+ });
86
+
87
+ async function run(mode: "log-only" | "enforce") {
88
+ const seen: OpRunContext[] = [];
89
+ const activities = new Map<string, ActivityFn>([["apply", async () => { seen.push(currentOpRun()!); }]]);
90
+ const port = memoryGateLedgerPort({
91
+ pending: [{
92
+ version: 1, kind: "pending", op: "release", gate: "ship",
93
+ timestamp: "2026-09-05T10:00:00.000Z", expiresAt: "2026-09-07T10:00:00.000Z",
94
+ approval: { ...enforce, mode },
95
+ }],
96
+ resolutions: [permit(mode)],
97
+ });
98
+ const result = await runOpLocally(
99
+ op([
100
+ { kind: "gate", gate: "ship", approval: { policy: POLICY, mode: "enforce" } },
101
+ { kind: "activity", fn: "apply" },
102
+ ]),
103
+ activities,
104
+ PROFILES,
105
+ undefined,
106
+ { gates: port, now: NOW, runId: "r3" },
107
+ );
108
+ return { result, seen };
109
+ }
110
+
111
+ test("an enforce-mode permit passes the gate, and the next step sees it as passed by policy", async () => {
112
+ const { result, seen } = await run("enforce");
113
+ expect(result.status).toBe("ok");
114
+ expect(seen[0]!.passedGates.map((g) => [g.gate, g.approval.via])).toEqual([["ship", "policy"]]);
115
+ });
116
+
117
+ test("a permit recorded under log-only does not pass the enforced gate, so no step sees it as passed", async () => {
118
+ const { result, seen } = await run("log-only");
119
+ expect(result.status).toBe("gated");
120
+ expect(seen).toEqual([]);
121
+ expect(result.records.find((r) => r.fn === "gate:ship")?.approval).toBeUndefined();
122
+ });
123
+ });
124
+
125
+ test("two runs in flight at once each see their own context", async () => {
126
+ const seen = new Map<string, string | undefined>();
127
+ const activities = new Map<string, ActivityFn>([
128
+ ["deploy", async () => {
129
+ await new Promise((r) => setTimeout(r, 5));
130
+ const ctx = currentOpRun()!;
131
+ seen.set(ctx.runId, ctx.env);
132
+ }],
133
+ ]);
134
+ const config = op([{ kind: "activity", fn: "deploy" }]);
135
+ await Promise.all([
136
+ runOpLocally(config, activities, PROFILES, undefined, { runId: "a", env: "staging" }),
137
+ runOpLocally(config, activities, PROFILES, undefined, { runId: "b", env: "prod" }),
138
+ ]);
139
+ expect(Object.fromEntries(seen)).toEqual({ a: "staging", b: "prod" });
140
+ });
141
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The run an activity is part of (#2522).
3
+ *
4
+ * An activity is called as `fn(args, signal)`, which says nothing about the
5
+ * run around it. An activity that deploys somewhere needs the environment the
6
+ * run was asked for, one that appends a ledger record of its own needs the run
7
+ * id to name the run that made it, and a step after a gate may need to know the
8
+ * gate passed and who approved it.
9
+ *
10
+ * `runOpLocally` (`./local-executor.ts`) sets this for the whole run, and
11
+ * {@link currentOpRun} reads it from inside an activity. It is backed by
12
+ * `AsyncLocalStorage`, so it follows the activity through every `await` and is
13
+ * `undefined` outside a run.
14
+ */
15
+
16
+ import { AsyncLocalStorage } from "node:async_hooks";
17
+ import type { StepRecord } from "./local-executor";
18
+
19
+ /** A gate this run has passed, with the approval that passed it. */
20
+ export interface PassedGate {
21
+ gate: string;
22
+ approval: NonNullable<StepRecord["approval"]>;
23
+ }
24
+
25
+ /** What an activity can read about the run it is in. */
26
+ export interface OpRunContext {
27
+ /** The Op's name. */
28
+ op: string;
29
+ /** The run id the run ledger record carries. */
30
+ runId: string;
31
+ /** `--env`, as handed to `OpRuntimeProvider.start`. Absent when the run was started without one. */
32
+ env?: string;
33
+ /** Every gate this run has passed so far, in the order it passed them. */
34
+ passedGates: PassedGate[];
35
+ }
36
+
37
+ const store = new AsyncLocalStorage<OpRunContext>();
38
+
39
+ /**
40
+ * The run the calling activity is part of, or `undefined` outside a run.
41
+ * `passedGates` is a copy taken at the time of the call.
42
+ */
43
+ export function currentOpRun(): OpRunContext | undefined {
44
+ const ctx = store.getStore();
45
+ return ctx ? { ...ctx, passedGates: [...ctx.passedGates] } : undefined;
46
+ }
47
+
48
+ /** Run `fn` with `ctx` as the current run. The executor calls this. A runtime that calls activities itself can too. */
49
+ export function withOpRunContext<T>(ctx: OpRunContext, fn: () => T): T {
50
+ return store.run(ctx, fn);
51
+ }