@intentius/chant 0.122.0 → 0.124.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 (203) hide show
  1. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  2. package/dist/cli/commands/lint.d.ts.map +1 -1
  3. package/dist/cli/handlers/gate-resume.d.ts +1 -1
  4. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  5. package/dist/cli/handlers/operator.d.ts +1 -1
  6. package/dist/cli/handlers/operator.d.ts.map +1 -1
  7. package/dist/cli/handlers/override-approve.d.ts +17 -0
  8. package/dist/cli/handlers/override-approve.d.ts.map +1 -0
  9. package/dist/cli/handlers/run-wave.d.ts.map +1 -1
  10. package/dist/cli/main.d.ts.map +1 -1
  11. package/dist/cli/registry.d.ts +4 -0
  12. package/dist/cli/registry.d.ts.map +1 -1
  13. package/dist/config-import.d.ts +1 -1
  14. package/dist/gate-decision.d.ts +157 -0
  15. package/dist/gate-decision.d.ts.map +1 -0
  16. package/dist/lifecycle/gate-applied.d.ts +75 -0
  17. package/dist/lifecycle/gate-applied.d.ts.map +1 -0
  18. package/dist/lifecycle/gate-audit.d.ts +145 -0
  19. package/dist/lifecycle/gate-audit.d.ts.map +1 -0
  20. package/dist/lifecycle/gate-ledger-select.d.ts +54 -0
  21. package/dist/lifecycle/gate-ledger-select.d.ts.map +1 -0
  22. package/dist/lifecycle/gate-ledger.d.ts +1 -45
  23. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  24. package/dist/lifecycle/override-ledger.d.ts +34 -0
  25. package/dist/lifecycle/override-ledger.d.ts.map +1 -0
  26. package/dist/lifecycle/rollback.d.ts.map +1 -1
  27. package/dist/lifecycle/work-item-id.d.ts +22 -0
  28. package/dist/lifecycle/work-item-id.d.ts.map +1 -0
  29. package/dist/lifecycle/work-lease.d.ts +2 -2
  30. package/dist/lifecycle/work-lease.d.ts.map +1 -1
  31. package/dist/lint/policy-import.d.ts +1 -1
  32. package/dist/lint/post-synth.d.ts +20 -1
  33. package/dist/lint/post-synth.d.ts.map +1 -1
  34. package/dist/notify-verify.d.ts +62 -0
  35. package/dist/notify-verify.d.ts.map +1 -0
  36. package/dist/notify-verify.js +76 -0
  37. package/dist/notify.d.ts +202 -0
  38. package/dist/notify.d.ts.map +1 -0
  39. package/dist/notify.js +307 -0
  40. package/dist/op/__fixtures__/decide-stub-backend.d.ts +1 -1
  41. package/dist/op/activities/activity-contracts.d.ts +4 -4
  42. package/dist/op/activities/gh-host.d.ts +37 -0
  43. package/dist/op/activities/gh-host.d.ts.map +1 -0
  44. package/dist/op/activities/lexicon-upgrade.d.ts +11 -0
  45. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  46. package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
  47. package/dist/op/activities/reconcile.d.ts +74 -76
  48. package/dist/op/activities/reconcile.d.ts.map +1 -1
  49. package/dist/op/gate-approval.d.ts +17 -1
  50. package/dist/op/gate-approval.d.ts.map +1 -1
  51. package/dist/op/gate-name.d.ts +8 -1
  52. package/dist/op/gate-name.d.ts.map +1 -1
  53. package/dist/op/gate-resume.d.ts +4 -2
  54. package/dist/op/gate-resume.d.ts.map +1 -1
  55. package/dist/op/gate-review.d.ts +2 -1
  56. package/dist/op/gate-review.d.ts.map +1 -1
  57. package/dist/op/gate.d.ts +43 -6
  58. package/dist/op/gate.d.ts.map +1 -1
  59. package/dist/op/index.d.ts +8 -6
  60. package/dist/op/index.d.ts.map +1 -1
  61. package/dist/op/op-waves-notify.d.ts +31 -0
  62. package/dist/op/op-waves-notify.d.ts.map +1 -0
  63. package/dist/op/op-waves-run.d.ts +218 -16
  64. package/dist/op/op-waves-run.d.ts.map +1 -1
  65. package/dist/op/op-waves.d.ts +113 -2
  66. package/dist/op/op-waves.d.ts.map +1 -1
  67. package/dist/op/operator.d.ts +15 -0
  68. package/dist/op/operator.d.ts.map +1 -1
  69. package/dist/op/work-lease-decl.d.ts.map +1 -1
  70. package/dist/op/work-lease-run.d.ts.map +1 -1
  71. package/dist/override-gate.d.ts +224 -0
  72. package/dist/override-gate.d.ts.map +1 -0
  73. package/dist/pr-forge.d.ts +225 -19
  74. package/dist/pr-forge.d.ts.map +1 -1
  75. package/dist/workspace/conformance/__fixtures__/reference-broker.d.ts +1 -1
  76. package/dist/workspace/conformance/broker.d.ts +1 -1
  77. package/dist/workspace/status-gates.d.ts +1 -0
  78. package/dist/workspace/status-gates.d.ts.map +1 -1
  79. package/dist/workspace/trust/gate-seal.d.ts +96 -0
  80. package/dist/workspace/trust/gate-seal.d.ts.map +1 -0
  81. package/dist/workspace/trust/policy.d.ts +2 -41
  82. package/dist/workspace/trust/policy.d.ts.map +1 -1
  83. package/dist/workspace/trust/seal.d.ts +2 -27
  84. package/dist/workspace/trust/seal.d.ts.map +1 -1
  85. package/dist/workspace/trust/trust-files.d.ts +57 -0
  86. package/dist/workspace/trust/trust-files.d.ts.map +1 -0
  87. package/dist/workspace/work-cli.d.ts.map +1 -1
  88. package/package.json +15 -2
  89. package/src/cli/commands/build.ts +1 -1
  90. package/src/cli/commands/check-lexicon.ts +3 -1
  91. package/src/cli/commands/lint-post-synth.e2e.test.ts +117 -0
  92. package/src/cli/commands/lint.test.ts +4 -109
  93. package/src/cli/commands/lint.ts +5 -2
  94. package/src/cli/discovery-skip.e2e.test.ts +18 -3
  95. package/src/cli/handlers/__snapshots__/build-member.test.ts.snap +2 -2
  96. package/src/cli/handlers/gate-resume.ts +3 -2
  97. package/src/cli/handlers/operator.test.ts +12 -1
  98. package/src/cli/handlers/operator.ts +25 -13
  99. package/src/cli/handlers/override-approve.test.ts +87 -0
  100. package/src/cli/handlers/override-approve.ts +103 -0
  101. package/src/cli/handlers/run-wave.ts +10 -2
  102. package/src/cli/main.test.ts +8 -0
  103. package/src/cli/main.ts +15 -0
  104. package/src/cli/registry.ts +4 -0
  105. package/src/config-import.ts +1 -1
  106. package/src/discovery/sandbox/run.test.ts +1 -1
  107. package/src/gate-decision-bundle.test.ts +56 -0
  108. package/src/gate-decision.test.ts +237 -0
  109. package/src/gate-decision.ts +321 -0
  110. package/src/lifecycle/gate-applied.ts +115 -0
  111. package/src/lifecycle/gate-audit-bundle.test.ts +47 -0
  112. package/src/lifecycle/gate-audit.test.ts +134 -0
  113. package/src/lifecycle/gate-audit.ts +438 -0
  114. package/src/lifecycle/gate-ledger-select.ts +113 -0
  115. package/src/lifecycle/gate-ledger.test.ts +22 -0
  116. package/src/lifecycle/gate-ledger.ts +9 -104
  117. package/src/lifecycle/override-ledger.test.ts +63 -0
  118. package/src/lifecycle/override-ledger.ts +69 -0
  119. package/src/lifecycle/rollback.ts +5 -1
  120. package/src/lifecycle/work-item-id.test.ts +71 -0
  121. package/src/lifecycle/work-item-id.ts +35 -0
  122. package/src/lifecycle/work-lease.ts +4 -5
  123. package/src/lint/policy-import.ts +1 -1
  124. package/src/lint/post-synth.test.ts +29 -0
  125. package/src/lint/post-synth.ts +21 -2
  126. package/src/notify-bundle.test.ts +50 -0
  127. package/src/notify-plain-node.e2e.test.ts +68 -0
  128. package/src/notify-verify.test.ts +59 -0
  129. package/src/notify-verify.ts +93 -0
  130. package/src/notify.schema.json +72 -0
  131. package/src/notify.test.ts +279 -0
  132. package/src/notify.ts +398 -0
  133. package/src/op/__fixtures__/decide-stub-backend.ts +1 -1
  134. package/src/op/activities/gh-host.test.ts +130 -0
  135. package/src/op/activities/gh-host.ts +79 -0
  136. package/src/op/activities/lexicon-upgrade.ts +22 -1
  137. package/src/op/activities/propose-upgrade.ts +5 -1
  138. package/src/op/activities/reconcile.test.ts +284 -280
  139. package/src/op/activities/reconcile.ts +128 -131
  140. package/src/op/gate-approval.test.ts +175 -7
  141. package/src/op/gate-approval.ts +27 -1
  142. package/src/op/gate-name.ts +10 -2
  143. package/src/op/gate-resume.ts +6 -3
  144. package/src/op/gate-review.ts +4 -7
  145. package/src/op/gate.ts +117 -16
  146. package/src/op/index.ts +36 -5
  147. package/src/op/op-waves-notify.test.ts +127 -0
  148. package/src/op/op-waves-notify.ts +101 -0
  149. package/src/op/op-waves-run.ts +908 -123
  150. package/src/op/op-waves.test.ts +543 -3
  151. package/src/op/op-waves.ts +215 -6
  152. package/src/op/operator.test.ts +29 -0
  153. package/src/op/operator.ts +27 -2
  154. package/src/op/run-context.test.ts +2 -2
  155. package/src/op/work-lease-decl.ts +4 -4
  156. package/src/op/work-lease-run.test.ts +5 -0
  157. package/src/op/work-lease-run.ts +4 -3
  158. package/src/override-gate-bundle.test.ts +47 -0
  159. package/src/override-gate.test.ts +239 -0
  160. package/src/override-gate.ts +395 -0
  161. package/src/pr-forge-bundle.test.ts +40 -0
  162. package/src/pr-forge.test.ts +378 -0
  163. package/src/pr-forge.ts +546 -47
  164. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +1 -1
  165. package/src/workspace/conformance/__fixtures__/reference-broker.ts +1 -1
  166. package/src/workspace/conformance/broker.ts +1 -1
  167. package/src/workspace/records-write-contract.test.ts +3 -2
  168. package/src/workspace/records-write.e2e.test.ts +1 -1
  169. package/src/workspace/status-gates.ts +7 -4
  170. package/src/workspace/trust/gate-seal.test.ts +156 -0
  171. package/src/workspace/trust/gate-seal.ts +254 -0
  172. package/src/workspace/trust/policy.ts +10 -165
  173. package/src/workspace/trust/seal.ts +19 -34
  174. package/src/workspace/trust/trust-files.test.ts +50 -0
  175. package/src/workspace/trust/trust-files.ts +245 -0
  176. package/src/workspace/work-cli.ts +5 -3
  177. package/src/workspace/work-history.test.ts +7 -0
  178. /package/src/audit/{catalog.test.ts → catalog.e2e.test.ts} +0 -0
  179. /package/src/audit/{discover.test.ts → discover.e2e.test.ts} +0 -0
  180. /package/src/audit/{remote-discovery.test.ts → remote-discovery.e2e.test.ts} +0 -0
  181. /package/src/audit/{rules-doc.test.ts → rules-doc.e2e.test.ts} +0 -0
  182. /package/src/{bench.test.ts → bench.e2e.test.ts} +0 -0
  183. /package/src/cli/commands/{check-lexicon-doc-drift.test.ts → check-lexicon-doc-drift.e2e.test.ts} +0 -0
  184. /package/src/cli/commands/{import.test.ts → import.e2e.test.ts} +0 -0
  185. /package/src/cli/{component-security-boundary.test.ts → component-security-boundary.e2e.test.ts} +0 -0
  186. /package/src/cli/mcp/{workspace-tools.test.ts → workspace-tools.e2e.test.ts} +0 -0
  187. /package/src/cli/{security-boundary.test.ts → security-boundary.e2e.test.ts} +0 -0
  188. /package/src/cli/{serve-mcp-workspace.test.ts → serve-mcp-workspace.e2e.test.ts} +0 -0
  189. /package/src/cli/{static-config-read.test.ts → static-config-read.e2e.test.ts} +0 -0
  190. /package/src/codegen/{json-schema-to-ts.test.ts → json-schema-to-ts.e2e.test.ts} +0 -0
  191. /package/src/{lexicon-seams.test.ts → lexicon-seams.e2e.test.ts} +0 -0
  192. /package/src/meta/{declared-imports.test.ts → declared-imports.e2e.test.ts} +0 -0
  193. /package/src/op/{command-gate.test.ts → command-gate.e2e.test.ts} +0 -0
  194. /package/src/op/{steward-beside.test.ts → steward-beside.e2e.test.ts} +0 -0
  195. /package/src/op/{steward-points.test.ts → steward-points.e2e.test.ts} +0 -0
  196. /package/src/workspace/{checks.test.ts → checks.e2e.test.ts} +0 -0
  197. /package/src/workspace/{intent-reviews.test.ts → intent-reviews.e2e.test.ts} +0 -0
  198. /package/src/workspace/{profiles.test.ts → profiles.e2e.test.ts} +0 -0
  199. /package/src/workspace/{read-contract.test.ts → read-contract.e2e.test.ts} +0 -0
  200. /package/src/workspace/{records-contract.test.ts → records-contract.e2e.test.ts} +0 -0
  201. /package/src/workspace/{records-since.test.ts → records-since.e2e.test.ts} +0 -0
  202. /package/src/workspace/{records-write.test.ts → records-write-commands.e2e.test.ts} +0 -0
  203. /package/src/workspace/{work-size.test.ts → work-size.e2e.test.ts} +0 -0
@@ -1,6 +1,8 @@
1
1
  import { exec } from "node:child_process";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { promisify } from "node:util";
4
+ import { ForgeApiError, forgejoForge, githubForge, type ForgeFetch, type GithubShapedForge } from "../../pr-forge";
5
+ import { ghHostEnv } from "./gh-host";
4
6
 
5
7
  const execAsync = promisify(exec);
6
8
 
@@ -21,14 +23,11 @@ const execAsync = promisify(exec);
21
23
  * {@link mergeRequestContextFrom}.
22
24
  *
23
25
  * On Forgejo the same mode posts the same GitHub-shaped comment (chant
24
- * #2291): no forge split at all, because a Forgejo Actions job already sets
25
- * `GITHUB_REPOSITORY`, `GITHUB_API_URL` and `github.token` the same way a
26
- * GitHub Actions job does, and Forgejo's own `/api/v1` takes the identical
27
- * GET/POST/PATCH triple. The one thing that needed fixing was the URL: `gh
28
- * api` resolves a *relative* path against `/api/v3` for any host other than
29
- * `github.com`, and Forgejo does not serve `/api/v3` — verified on a real
30
- * Forgejo 12.0.4+gitea-1.22.0 instance during INTENTIUS/choudoufu#1027. See
31
- * {@link postOrUpdateComment} and {@link githubApiBaseFrom}.
26
+ * #2291): a Forgejo Actions job sets `GITHUB_REPOSITORY`, `GITHUB_API_URL`
27
+ * and `github.token` the way a GitHub Actions job does, and Forgejo's own
28
+ * `/api/v1` takes the same GET/POST/PATCH calls. The calls go through the
29
+ * forge client in `../../pr-forge.ts` over `fetch` (chant #3757), the client
30
+ * the pull-request loop uses. See {@link githubShapedForgeFrom}.
32
31
  *
33
32
  * `issue` needs no merge/pull request — its whole point is a cron trigger
34
33
  * that has none — so its forge split reads off a wider signal: any GitLab CI
@@ -39,48 +38,29 @@ const execAsync = promisify(exec);
39
38
  * recipe `comment` mode's note uses, and opens a new one only when it finds
40
39
  * none. Which is why that marker has to name the Op (chant #2319) and why
41
40
  * this mode refuses a step that supplies neither an `op` nor a `marker` — see
42
- * {@link issueMarker} and {@link noIssueIdentityMessage}.
41
+ * {@link issueMarker} and {@link noIssueIdentityMessage}. See {@link
42
+ * postOrUpdateGithubIssue} for why the GitHub/GHES/Forgejo lookup pages
43
+ * rather than using GitHub's Search API, and for the decision that this mode
44
+ * never closes the issue itself. Only a run outside any known CI job — no
45
+ * `CI_PROJECT_ID`, no `GITHUB_REPOSITORY` — falls back to `gh issue create`'s
46
+ * own ambient repo detection and the operator's `gh auth login`, still
47
+ * marker-prefixed so a later CI run of the same Op finds and edits it instead
48
+ * of opening a second one.
43
49
  *
44
- * none. See {@link postOrUpdateGithubIssue} for the GitHub/GHES/Forgejo half
45
- * — including why it does not use GitHub's Search API, and the decision that
46
- * this mode never closes the issue itself. Only a run outside any known CI
47
- * job — no `CI_PROJECT_ID`, no `GITHUB_REPOSITORY` — falls back to `gh issue
48
- * create`'s own ambient repo detection, still marker-prefixed so a later CI
49
- * run of the same Op finds and edits it instead of opening a second one.
50
+ * Both modes resolve the GitHub/Forgejo credential the same way, through
51
+ * {@link commentTokenFrom}: `CHANT_FORGEJO_TOKEN`, then `GH_TOKEN`, then
52
+ * `GITHUB_TOKEN` (chant #2320), refusing by name before any call when none
53
+ * is set ({@link noCommentTokenMessage}, {@link noIssueTokenMessage}).
50
54
  *
51
- * The credential did not always travel (chant #2320). The URL and the
52
- * GET/PATCH/POST triple matched `postOrUpdateComment`'s, but that function
53
- * resolved a token through {@link commentTokenFrom} and forwarded it as
54
- * `GH_TOKEN` while the issue path forwarded no environment at all, so
55
- * `CHANT_FORGEJO_TOKEN` never reached `gh`. Both paths now resolve the same
56
- * way, in the same order, and forward the same variable, with the two
57
- * refusals differing only in what they name ({@link noCommentTokenMessage},
58
- * {@link noIssueTokenMessage}). The one deliberate exception is the `gh issue
59
- * create` fallback above, which runs outside every CI job and so leaves the
60
- * credential to `gh auth login` — see the comment on that branch. Parity with
61
- * `comment` mode is not a working Forgejo write, as the next paragraph says.
62
- *
63
- * Settled against a real Forgejo 12.0.4+gitea-1.22.0 instance under chant
64
- * #2315, the way `comment` mode's endpoints were under #2291: the *read*
65
- * half works — plain paginated listing (no `/search/issues`, confirmed
66
- * absent from Forgejo's own OpenAPI spec) and the `.pull_request == null`
67
- * filter both behave exactly as they do against github.com. The *write*
68
- * half did not, for a reason no mock could have caught: `gh`'s own
69
- * `GH_TOKEN`/`GITHUB_TOKEN` only authenticate a request to github.com or a
70
- * ghe.com subdomain (`gh help environment`), never a self-hosted Forgejo,
71
- * and both this function's POST/PATCH calls and `postOrUpdateComment`'s
72
- * carried only `GH_TOKEN`. Every write either made on a real Forgejo
73
- * instance failed with `{"message":"token is required"}` (HTTP 401), with
74
- * `GH_DEBUG=api` showing no `Authorization` header at all — which is what
75
- * shipped in 0.62.0 and 0.63.0, `comment` mode included.
76
- *
77
- * Both paths now forward that credential as `GH_ENTERPRISE_TOKEN` as well as
78
- * `GH_TOKEN`, which is the variable `gh` reads for a host in neither of those
79
- * two classes (chant #2333). Re-run against the same live instance from a
80
- * shell with no stored `gh auth login`, the identical POST now sends
81
- * `Authorization: token …` and returns HTTP 201. See {@link ghCredentialEnv}
82
- * for the whole probe, for why `GH_HOST` turned out not to be needed
83
- * alongside it, and for why github.com's own behavior is untouched.
55
+ * Until #3757 these two modes shelled to `gh api`. That reached Forgejo only
56
+ * after two fixes: a full URL, because `gh` resolves a relative path against
57
+ * `/api/v3` (#2291), and the token under `GH_ENTERPRISE_TOKEN`, because `gh`
58
+ * sends `GH_TOKEN` only to github.com and `ghe.com` hosts and a Forgejo write
59
+ * arrived with no `Authorization` header (#2315, #2333, verified against a
60
+ * real Forgejo 12.0.4+gitea-1.22.0 instance). The client sends the header
61
+ * itself, so neither depends on how `gh` classifies a host. {@link
62
+ * ghCredentialEnv} keeps that fix for the `gh` calls `lexiconUpgrade` still
63
+ * makes.
84
64
  */
85
65
  export type ReconcileMode = "pull-request" | "issue" | "report" | "comment";
86
66
 
@@ -471,10 +451,10 @@ export interface CommentToken {
471
451
  }
472
452
 
473
453
  /**
474
- * Resolve the token `postOrUpdateComment` sends with, most specific first
475
- * (chant #2291). `gh` itself already resolves `GH_TOKEN`/`GITHUB_TOKEN`
476
- * ambiently from the process environment, and the generated workflow sets
477
- * both to `${{ github.token }}` for every non-`report` finding mode — on
454
+ * Resolve the token the `comment` and `issue` modes post with on GitHub and
455
+ * Forgejo, most specific first (chant #2291). The generated workflow sets
456
+ * `GH_TOKEN` and `GITHUB_TOKEN` to `${{ github.token }}` for every
457
+ * non-`report` finding mode — on
478
458
  * Forgejo the same way as on GitHub, and the real-instance read confirmed
479
459
  * `github.token` is populated there and good for a 200 read and a 201 write,
480
460
  * authored as `forgejo-actions`. So the common case needs nothing set beyond
@@ -483,8 +463,8 @@ export interface CommentToken {
483
463
  * `CHANT_FORGEJO_TOKEN` is checked first for the case that ambient token
484
464
  * cannot cover: a run posting to a Forgejo instance other than the one the
485
465
  * job executes on, where `github.token`'s scope stops at its own instance.
486
- * Resolving explicitly (rather than leaving it entirely to `gh`) also buys a
487
- * named failure before the shell-out, in place of `gh`'s own opaque 401.
466
+ * A run with none of the three is refused by name before any call, in place
467
+ * of the forge's own opaque 401.
488
468
  */
489
469
  export function commentTokenFrom(env: Record<string, string | undefined>): CommentToken | undefined {
490
470
  for (const source of ["CHANT_FORGEJO_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]) {
@@ -578,78 +558,85 @@ export function noIssueTokenMessage(repo: string): string {
578
558
  }
579
559
 
580
560
  /**
581
- * Post `body` as one comment on `ctx`'s pull request, or edit the comment this
582
- * Op already owns there. The sticky-comment recipe the github lexicon's
583
- * `PrPlanReport` uses, run from the activity instead of from generated YAML:
584
- * find the comment whose body starts with `marker`, PATCH it when there is
585
- * one, POST otherwise. `gh` ships on GitHub's hosted runners and is already
586
- * this activity's dependency for the issue and pull-request modes, so the
587
- * mode needs nothing new on the runner — Forgejo's `act_runner` ships `gh`
588
- * too, and Forgejo's `/api/v1` takes the same calls (chant #2291).
561
+ * The GitHub or Forgejo client a `comment` or `issue` finding posts through
562
+ * (#3757): `../../pr-forge.ts`, the one the pull-request loop already uses,
563
+ * over `fetch`. These two modes used to shell to `gh`, which needed `gh` on
564
+ * the runner and needed it to attach a credential to a host it did not
565
+ * recognise (#2333). The token is the one {@link commentTokenFrom} resolved
566
+ * and the base is {@link githubApiBaseFrom}'s, so nothing about what a step
567
+ * sets has changed.
589
568
  *
590
- * Every call targets a full URL built from {@link githubApiBaseFrom} rather
591
- * than the bare relative path this used before #2291 — see that function for
592
- * why a bare path broke Forgejo specifically. The token is resolved
593
- * explicitly via {@link commentTokenFrom} and forwarded through {@link
594
- * ghCredentialEnv}, which is a strict superset of `gh`'s own ambient
595
- * resolution: same value in the common case, a named refusal instead of
596
- * `gh`'s opaque 401 when neither is set, and — since chant #2333 — the same
597
- * value under `GH_ENTERPRISE_TOKEN` too, without which the full URL #2291
598
- * built reached Forgejo carrying no credential.
569
+ * The two forges share every call these modes make and differ on two things
570
+ * the client needs to know: the page parameter (`per_page` against `limit`)
571
+ * and the authorization scheme. A Forgejo run is told apart the way
572
+ * `../gate-resume.ts` tells it apart, by `FORGEJO_ACTIONS` or
573
+ * `GITEA_ACTIONS`, and also by an API base ending in `/api/v1`, which is how
574
+ * a step posting to some other Forgejo instance (`CHANT_FORGEJO_TOKEN`) names
575
+ * it. GitHub serves `/api/v3` or `api.github.com`, never `/api/v1`.
576
+ */
577
+ export function githubShapedForgeFrom(
578
+ env: Record<string, string | undefined>,
579
+ repo: string,
580
+ token: CommentToken,
581
+ signal?: AbortSignal,
582
+ ): GithubShapedForge {
583
+ const apiBase = githubApiBaseFrom(env);
584
+ const forgejo = env.FORGEJO_ACTIONS === "true" || env.GITEA_ACTIONS === "true" || /\/api\/v1$/.test(apiBase);
585
+ // Read at call time, so a test that stubs the global sees its stub.
586
+ const fetchImpl: ForgeFetch = (url, init) => globalThis.fetch(url, { ...init, ...(signal ? { signal } : {}) });
587
+ const opts = { apiBase, repo, token: token.value, fetch: fetchImpl };
588
+ if (!forgejo) return githubForge(opts);
589
+ let host: string;
590
+ try {
591
+ host = new URL(apiBase).host;
592
+ } catch {
593
+ host = apiBase;
594
+ }
595
+ return forgejoForge({ ...opts, host });
596
+ }
597
+
598
+ /** A forge's refusal, carrying which variable the token came from, the way the GitLab half's errors do. */
599
+ async function withTokenSource<T>(token: CommentToken, call: () => Promise<T>): Promise<T> {
600
+ try {
601
+ return await call();
602
+ } catch (err) {
603
+ if (err instanceof ForgeApiError) throw new Error(`${err.message} (token from ${token.source}).`);
604
+ throw err;
605
+ }
606
+ }
607
+
608
+ /**
609
+ * Post `body` as one comment on `ctx`'s pull request, or edit the comment this
610
+ * Op already owns there: the comment whose body starts with `marker`, found
611
+ * by paging every comment on the pull request, PATCHed when there is one and
612
+ * POSTed when there is not. The same note recipe `PrPlanReport` and the
613
+ * pull-request loop use. A missing token is refused by name before any call.
599
614
  */
600
615
  async function postOrUpdateComment(
601
616
  ctx: PullRequestContext,
602
617
  marker: string,
603
618
  body: string,
604
619
  signal?: AbortSignal,
605
- ): Promise<string> {
620
+ ): Promise<string | undefined> {
606
621
  const token = commentTokenFrom(process.env);
607
622
  if (!token) throw new Error(noCommentTokenMessage(ctx.repo, ctx.number));
608
- const env = ghCredentialEnv(process.env, token);
609
-
610
- const base = githubApiBaseFrom(process.env);
611
- const listUrl = `${base}/repos/${ctx.repo}/issues/${ctx.number}/comments`;
612
- const jq = `map(select(.body | startswith("${marker}"))) | .[0].id // empty`;
613
- const { stdout: found } = await execAsync(
614
- `gh api ${shellQuote(listUrl)} --paginate --jq ${shellQuote(jq)}`,
615
- { signal, env },
616
- );
617
- // `--paginate` prints one `--jq` result per page, so take the first line
618
- // that is an id and ignore the empty ones the other pages produce.
619
- const existing = found.split("\n").map((l) => l.trim()).find((l) => /^\d+$/.test(l));
620
- const field = `body=${marker}\n\n${body}`;
621
-
622
- if (existing) {
623
- const { stdout } = await execAsync(
624
- `gh api --method PATCH ${shellQuote(`${base}/repos/${ctx.repo}/issues/comments/${existing}`)} ` +
625
- `-f ${shellQuote(field)} --jq .html_url`,
626
- { signal, env },
627
- );
628
- return stdout.trim();
629
- }
630
- const { stdout } = await execAsync(
631
- `gh api --method POST ${shellQuote(listUrl)} -f ${shellQuote(field)} --jq .html_url`,
632
- { signal, env },
633
- );
634
- return stdout.trim();
623
+ const forge = githubShapedForgeFrom(process.env, ctx.repo, token, signal);
624
+ return withTokenSource(token, () => forge.upsertNote(ctx.number, marker, `${marker}\n\n${body}`));
635
625
  }
636
626
 
637
627
  // ── The GitHub/GHES/Forgejo sticky issue (#2297) ────────────────────────────
638
628
 
639
629
  /**
640
630
  * Minimal `gh` invocation shape {@link postOrUpdateGithubIssue} threads its
641
- * calls through. `reconcilePr` wraps `execAsync` (carrying its own `signal`)
642
- * to this shape; `lexiconUpgrade` passes its injectable `GhRunner` straight
643
- * through, since the two already share this signature. One function, not two
644
- * copies, because the recipe below — search, then PATCH or POST — is
645
- * identical for both callers; only the issue's title/body and the marker that
646
- * owns it differ.
631
+ * calls through. `lexiconUpgrade` passes its injectable `GhRunner` straight
632
+ * through, since the two share this signature. `reconcilePr` used to wrap
633
+ * `execAsync` to it too, before its `issue` mode moved onto the forge client
634
+ * (#3757).
647
635
  *
648
636
  * The `opts` argument exists so `postOrUpdateGithubIssue` can hand `gh` the
649
637
  * token it resolved (chant #2320) rather than leaving `gh` to find one
650
638
  * ambiently. A caller that adds nothing of its own can ignore it and pass it
651
- * straight to `exec`; `reconcilePr` merges it with the `signal` it already
652
- * carries. It is deliberately not a whole `ExecOptions`: an implementation
639
+ * straight to `exec`. It is deliberately not a whole `ExecOptions`: an implementation
653
640
  * that had to honour `cwd` or `shell` too would be a second exec wrapper, and
654
641
  * the point of this type is that there is only one.
655
642
  */
@@ -660,16 +647,17 @@ export type GhExec = (
660
647
 
661
648
  /**
662
649
  * Open `title`/`body` as one GitHub-shaped issue in `repo`, or edit the issue
663
- * this caller already owns there (#2297) — `postOrUpdateComment`'s issue
664
- * counterpart, and the GitHub/GHES/Forgejo sibling of GitLab's
665
- * `postOrUpdateIssue` (#2292): find by marker, PATCH when found, POST when
666
- * not, so a nightly finding carries one issue holding the current state
667
- * instead of stacking a new one every run. Shared by `reconcilePr`'s own
668
- * `issue` mode and `lexiconUpgrade`'s per-lexicon upgrade-status issue.
650
+ * this caller already owns there (#2297), over `gh api`: find by marker,
651
+ * PATCH when found, POST when not, so a nightly finding carries one issue
652
+ * holding the current state instead of stacking a new one every run.
653
+ * `lexiconUpgrade`'s per-lexicon upgrade-status issue uses it. `reconcilePr`'s
654
+ * `issue` mode used it until #3757 and now keeps the same issue by the same
655
+ * recipe through the forge client's `upsertIssue` (`../../pr-forge.ts`), so
656
+ * the reasoning below covers both.
669
657
  *
670
- * Every call targets a full URL built from {@link githubApiBaseFrom}, the
671
- * same as `postOrUpdateComment` (#2291) — so this reaches GitHub Enterprise
672
- * Server the same way it reaches github.com.
658
+ * Every call targets a full URL built from {@link githubApiBaseFrom} (#2291),
659
+ * so this reaches GitHub Enterprise Server and Forgejo the same way it
660
+ * reaches github.com.
673
661
  *
674
662
  * The credential is resolved and forwarded the same way too (chant #2320),
675
663
  * through {@link commentTokenFrom} and out through {@link ghCredentialEnv} on
@@ -721,7 +709,7 @@ export type GhExec = (
721
709
  * #2333 gave them a credential `gh` would apply to that host — see the
722
710
  * module doc's `issue` bullet and {@link ghCredentialEnv}.
723
711
  *
724
- * So this reuses the recipe `postOrUpdateComment` already proved for PR
712
+ * So this reuses the recipe `comment` mode already proved for PR
725
713
  * comments: list, `--paginate`, filter with `--jq` by an exact `startswith`
726
714
  * prefix match, which is correct no matter how many pages it takes. The cost
727
715
  * is real — a repository with many thousands of issues pays for every page
@@ -771,7 +759,8 @@ export async function postOrUpdateGithubIssue(
771
759
  `gh api ${shellQuote(listUrl)} --paginate --jq ${shellQuote(jq)}`,
772
760
  { env },
773
761
  );
774
- // `--paginate` prints one `--jq` result per page, same as postOrUpdateComment.
762
+ // `--paginate` prints one `--jq` result per page, so take the first line
763
+ // that is an id and ignore the empty ones the other pages produce.
775
764
  const existing = found.split("\n").map((l) => l.trim()).find((l) => /^\d+$/.test(l));
776
765
  const titleField = shellQuote(`title=${title}`);
777
766
  const bodyField = shellQuote(`body=${marker}\n\n${body}`);
@@ -999,7 +988,7 @@ async function gitlabRequest(
999
988
 
1000
989
  /**
1001
990
  * Find the note this Op already owns on `ctx`'s merge request, by the same
1002
- * hidden marker `postOrUpdateComment` looks a GitHub comment up by: the
991
+ * hidden marker `comment` mode looks a GitHub comment up by: the
1003
992
  * marker is the body's first line and the match is a prefix.
1004
993
  *
1005
994
  * Pages the way GitLab pages, following the `x-next-page` response header
@@ -1040,11 +1029,10 @@ async function findOwnedNote(
1040
1029
  * so a merge request pushed to five times carries one note holding the
1041
1030
  * current finding rather than five stale ones.
1042
1031
  *
1043
- * Over `fetch` rather than a CLI. `gh` is on GitHub's hosted runners and is
1044
- * already this activity's dependency for the issue and pull-request modes;
1045
- * `glab` is on no GitLab runner by default, and a job whose finding step
1046
- * depended on it would fail on the ordinary `node:22-slim` image the
1047
- * generator emits.
1032
+ * Over `fetch` rather than a CLI: `glab` is on no GitLab runner by default,
1033
+ * and a job whose finding step depended on it would fail on the ordinary
1034
+ * `node:22` image the generator emits. The GitHub and Forgejo half
1035
+ * follows the same rule since #3757.
1048
1036
  */
1049
1037
  async function postOrUpdateNote(
1050
1038
  ctx: MergeRequestContext,
@@ -1247,7 +1235,10 @@ function resolveMarker(mode: ReconcileMode, args: ReconcilePrArgs): string | und
1247
1235
  * supplies one, in which case that text is used verbatim and no plan is
1248
1236
  * derived (chant #2087).
1249
1237
  *
1250
- * Requires `chant` and (for non-report modes) `gh`/`git` in the environment.
1238
+ * Requires `chant` in the environment. `comment` and `issue` reach the forge
1239
+ * over `fetch` and need nothing else in CI (#3757); `pull-request` needs `git`
1240
+ * and `gh`, and so does an `issue` run outside any CI job, which leaves the
1241
+ * credential to `gh auth login`.
1251
1242
  */
1252
1243
  export async function reconcilePr(args: ReconcilePrArgs, signal?: AbortSignal): Promise<ReconcileResult> {
1253
1244
  const mode = args.mode ?? "pull-request";
@@ -1291,13 +1282,17 @@ export async function reconcilePr(args: ReconcilePrArgs, signal?: AbortSignal):
1291
1282
  }
1292
1283
 
1293
1284
  // GitHub, GHES, or Forgejo next: any job that ran under GitHub Actions or
1294
- // Forgejo Actions carries GITHUB_REPOSITORY (#2297) — see
1295
- // postOrUpdateGithubIssue's own doc comment for the search mechanism and
1296
- // the close decision.
1285
+ // Forgejo Actions carries GITHUB_REPOSITORY (#2297). The issue is kept
1286
+ // through the forge client (#3757) — see postOrUpdateGithubIssue's doc
1287
+ // comment for why the lookup pages rather than searches, and for the
1288
+ // close decision.
1297
1289
  const repo = process.env.GITHUB_REPOSITORY;
1298
1290
  if (repo) {
1299
- const issueUrl = await postOrUpdateGithubIssue(repo, marker, title, summary, (cmd, opts) =>
1300
- execAsync(cmd, { signal, ...opts }),
1291
+ const token = commentTokenFrom(process.env);
1292
+ if (!token) throw new Error(noIssueTokenMessage(repo));
1293
+ const forge = githubShapedForgeFrom(process.env, repo, token, signal);
1294
+ const issueUrl = await withTokenSource(token, () =>
1295
+ forge.upsertIssue(marker, title, `${marker}\n\n${summary}`),
1301
1296
  );
1302
1297
  return { mode, summary, entries, issueUrl };
1303
1298
  }
@@ -1368,9 +1363,11 @@ export async function reconcilePr(args: ReconcilePrArgs, signal?: AbortSignal):
1368
1363
  await execAsync(`git add ${shellQuote(output)}`, { signal });
1369
1364
  await execAsync(`git commit -m ${shellQuote(title)}`, { signal });
1370
1365
  await execAsync(`git push -u origin ${shellQuote(branch)}`, { signal });
1366
+ // `gh pr create` names no host, so on GitHub Enterprise Server or Forgejo
1367
+ // it is told the job's own (#3780). See `./gh-host.ts`.
1371
1368
  const { stdout } = await execAsync(
1372
1369
  `gh pr create --title ${shellQuote(title)} --body ${shellQuote(summary)} --head ${shellQuote(branch)}`,
1373
- { signal },
1370
+ { signal, env: await ghHostEnv(process.env) },
1374
1371
  );
1375
1372
 
1376
1373
  return { mode, branch, summary, entries, prUrl: stdout.trim() };
@@ -5,9 +5,12 @@
5
5
  * lexicon's and is tested there.
6
6
  */
7
7
  import { describe, test, expect } from "vitest";
8
- import { evaluateGate, memoryGateLedgerPort, tallyGateApprovals } from "./gate";
8
+ import { evaluateGate, humanQuorumSince, memoryGateLedgerPort, tallyGateApprovals } from "./gate";
9
+ import { resolutionAnswering } from "./gate-resume";
10
+ import { gatesInLedger } from "../workspace/status-gates";
11
+ import { describeApprovalProgress } from "../cli/handlers/operator";
9
12
  import { gate } from "./builders";
10
- import { gateApprovalProblems, gatePolicyRequest, gatePolicyVersion, type GatePolicyRef, type ResolvedGateApproval } from "./gate-approval";
13
+ import { gateApprovalProblems, gateContextDigest, gatePolicyRequest, gatePolicyVersion, type GatePolicyRef, type ResolvedGateApproval } from "./gate-approval";
11
14
  import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
12
15
 
13
16
  const PLAN_A = `sha256:${"a".repeat(64)}`;
@@ -25,8 +28,9 @@ function approval(overrides: Partial<GateResolutionRecord> & { resolvedBy: strin
25
28
  return { version: 1, op: "release", gate: "ship", planDigest: PLAN_A, approver: { kind: "human" }, ...overrides };
26
29
  }
27
30
 
28
- function allow(mode: "log-only" | "enforce" = "enforce"): GateResolutionRecord["policyDecision"] {
29
- return { policy: "ship", version: POLICY.version, mode, decision: "allow", determining: ["ship-0"], errors: [] };
31
+ /** A recorded allow, evaluated under `context` (the context the tests' blocks resolve, by default). */
32
+ function allow(mode: "log-only" | "enforce" = "enforce", context: Record<string, unknown> | undefined = { risk: "low" }): GateResolutionRecord["policyDecision"] {
33
+ return { policy: "ship", version: POLICY.version, mode, contextDigest: gateContextDigest(context), decision: "allow", determining: ["ship-0"], errors: [] };
30
34
  }
31
35
 
32
36
  async function decide(resolutions: GateResolutionRecord[], approvalBlock: ResolvedGateApproval, planDigest = PLAN_A) {
@@ -168,7 +172,7 @@ describe("gate approval — policy decisions (#2508)", () => {
168
172
  });
169
173
 
170
174
  test("enforce: an agent's permit passes the gate on its own", async () => {
171
- const block: ResolvedGateApproval = { quorum: { count: 2 }, policy: POLICY, mode: "enforce" };
175
+ const block: ResolvedGateApproval = { quorum: { count: 2 }, policy: POLICY, mode: "enforce", context: { risk: "low" } };
172
176
  const { check } = await decide(
173
177
  [approval({ resolvedBy: "release-bot", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "agent" }, policyDecision: allow() })],
174
178
  block,
@@ -180,7 +184,7 @@ describe("gate approval — policy decisions (#2508)", () => {
180
184
  });
181
185
 
182
186
  test("enforce: a permit recorded under an older policy version does not count", async () => {
183
- const block: ResolvedGateApproval = { policy: POLICY, mode: "enforce" };
187
+ const block: ResolvedGateApproval = { policy: POLICY, mode: "enforce", context: { risk: "low" } };
184
188
  const { check } = await decide(
185
189
  [approval({
186
190
  resolvedBy: "release-bot", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "agent" },
@@ -253,7 +257,7 @@ describe("gate approval — policy decisions (#2508)", () => {
253
257
  });
254
258
 
255
259
  test("enforce: a recorded decision with no mode does not pass the gate (#2512)", async () => {
256
- const block: ResolvedGateApproval = { policy: POLICY, mode: "enforce" };
260
+ const block: ResolvedGateApproval = { policy: POLICY, mode: "enforce", context: { risk: "low" } };
257
261
  const { mode: _mode, ...noMode } = allow()!;
258
262
  const { check } = await decide(
259
263
  [approval({
@@ -312,6 +316,170 @@ describe("gate approval — policy decisions (#2508)", () => {
312
316
  });
313
317
  });
314
318
 
319
+ describe("gate approval — a re-recorded pending fact keeps a partly filled quorum (#3206)", () => {
320
+ const twoLogOnly: ResolvedGateApproval = { quorum: { count: 2 }, policy: POLICY, mode: "log-only", context: { risk: "low" } };
321
+ const twoEnforce: ResolvedGateApproval = { ...twoLogOnly, mode: "enforce" };
322
+ const alex = approval({ resolvedBy: "alex", timestamp: "2026-09-01T01:00:00.000Z", policyDecision: allow("log-only") });
323
+ const sam = approval({ resolvedBy: "sam", timestamp: "2026-09-01T13:00:00.000Z" });
324
+
325
+ /** Run the gate once under `before`'s pending fact with `next`, then again after `later` approvals. */
326
+ async function switchThenApprove(before: ResolvedGateApproval, next: ResolvedGateApproval, later: GateResolutionRecord[], planDigest = PLAN_A) {
327
+ const port = memoryGateLedgerPort({ resolutions: [alex], pending: [{ ...PENDING, approval: before }] });
328
+ const first = await evaluateGate(port, { op: "release", gate: "ship", planDigest, approval: next, now: NOW });
329
+ const ledger = await port.read("release");
330
+ const after = await evaluateGate(
331
+ memoryGateLedgerPort({ resolutions: [...ledger.resolutions, ...later], pending: ledger.pending }),
332
+ { op: "release", gate: "ship", planDigest, approval: next, now: "2026-09-01T14:00:00.000Z" },
333
+ );
334
+ return { first, after, pending: ledger.pending };
335
+ }
336
+
337
+ test("log-only then enforce: the switch re-records the fact, and the human approval from before it still counts", async () => {
338
+ const { first, after } = await switchThenApprove(twoLogOnly, twoEnforce, [sam]);
339
+ expect(first.satisfied).toBe(false);
340
+ if (first.satisfied) return;
341
+ expect(first.recorded).toBe(true);
342
+ expect(first.quorum).toEqual({ approvers: ["alex"], need: 2 });
343
+
344
+ expect(after.satisfied).toBe(true);
345
+ if (!after.satisfied) return;
346
+ expect(after.via).toBe("quorum");
347
+ expect(after.approvals?.map((r) => r.resolvedBy)).toEqual(["alex", "sam"]);
348
+ });
349
+
350
+ test("a changed context keeps the quorum too", async () => {
351
+ const { after } = await switchThenApprove(twoLogOnly, { ...twoLogOnly, context: { risk: "high" } }, [sam]);
352
+ expect(after.satisfied).toBe(true);
353
+ });
354
+
355
+ test("one approval of two is still one after the switch, so the gate waits", async () => {
356
+ const { after } = await switchThenApprove(twoLogOnly, twoEnforce, []);
357
+ expect(after.satisfied).toBe(false);
358
+ if (after.satisfied) return;
359
+ expect(after.quorum).toEqual({ approvers: ["alex"], need: 2 });
360
+ });
361
+
362
+ test("a changed policy version starts the count again", async () => {
363
+ const text = TEXT.replace("low", "medium");
364
+ const edited: GatePolicyRef = { ...POLICY, version: gatePolicyVersion(text), text };
365
+ const { after } = await switchThenApprove(twoLogOnly, { ...twoLogOnly, policy: edited }, [sam]);
366
+ expect(after.satisfied).toBe(false);
367
+ if (after.satisfied) return;
368
+ expect(after.quorum).toEqual({ approvers: ["sam"], need: 2 });
369
+ });
370
+
371
+ test("a fact for another plan in between starts the count again", async () => {
372
+ const pending: PendingGateRecord[] = [
373
+ { ...PENDING, approval: twoLogOnly },
374
+ { ...PENDING, timestamp: "2026-09-01T02:00:00.000Z", planDigest: PLAN_B, approval: twoLogOnly },
375
+ { ...PENDING, timestamp: "2026-09-01T03:00:00.000Z", approval: twoEnforce },
376
+ ];
377
+ expect(humanQuorumSince(pending, pending[2]!)).toBe(pending[2]!.timestamp);
378
+ const check = await evaluateGate(
379
+ memoryGateLedgerPort({ resolutions: [alex, sam], pending }),
380
+ { op: "release", gate: "ship", planDigest: PLAN_A, approval: twoEnforce, now: "2026-09-01T14:00:00.000Z" },
381
+ );
382
+ expect(check.satisfied).toBe(false);
383
+ });
384
+
385
+ test("a fact that had expired before the next was recorded starts the count again", async () => {
386
+ const pending: PendingGateRecord[] = [
387
+ { ...PENDING, approval: twoLogOnly, expiresAt: "2026-09-01T02:00:00.000Z" },
388
+ { ...PENDING, timestamp: "2026-09-01T03:00:00.000Z", approval: twoEnforce },
389
+ ];
390
+ expect(humanQuorumSince(pending, pending[1]!)).toBe(pending[1]!.timestamp);
391
+ const unexpired = [{ ...pending[0]!, expiresAt: PENDING.expiresAt }, pending[1]!];
392
+ expect(humanQuorumSince(unexpired, unexpired[1]!)).toBe(PENDING.timestamp);
393
+ });
394
+
395
+ test("a permit from before the standing fact still never passes, even one recorded under enforce", async () => {
396
+ const bot = approval({ resolvedBy: "release-bot", timestamp: "2026-09-01T01:30:00.000Z", approver: { kind: "agent" }, policyDecision: allow("enforce") });
397
+ const enforceHigh: ResolvedGateApproval = { ...twoEnforce, context: { risk: "high" } };
398
+ const pending: PendingGateRecord[] = [
399
+ { ...PENDING, approval: enforceHigh },
400
+ { ...PENDING, timestamp: "2026-09-01T02:00:00.000Z", approval: twoEnforce },
401
+ ];
402
+ const check = await evaluateGate(
403
+ memoryGateLedgerPort({ resolutions: [bot], pending }),
404
+ { op: "release", gate: "ship", planDigest: PLAN_A, approval: twoEnforce, now: NOW },
405
+ );
406
+ expect(check.satisfied).toBe(false);
407
+ if (check.satisfied) return;
408
+ expect(check.recorded).toBe(false);
409
+ });
410
+
411
+ test("workspace status, the resume check and chant approve's progress line count it the same way", async () => {
412
+ const { pending } = await switchThenApprove(twoLogOnly, twoEnforce, []);
413
+ const standing = pending[pending.length - 1]!;
414
+
415
+ const content = [...pending, alex, sam].map((r) => JSON.stringify(r)).join("\n") + "\n";
416
+ const gate = gatesInLedger("release", content, [], "2026-09-01T14:00:00.000Z").gates[0];
417
+ expect(gate?.state).toBe("approved");
418
+ expect(gate?.approvals.map((a) => a.principal)).toEqual(["alex", "sam"]);
419
+
420
+ expect(resolutionAnswering([alex, sam], standing, pending)?.resolvedBy).toBe("sam");
421
+ expect(resolutionAnswering([alex, sam], standing)).toBeUndefined();
422
+
423
+ const lines = describeApprovalProgress(twoEnforce, sam, [alex], standing, pending);
424
+ expect(lines).toContain("Quorum: 2 of 2 human approval(s) for this plan (alex, sam).");
425
+ });
426
+ });
427
+
428
+ describe("gate approval — a permit counts only under the context it was given for (#3774)", () => {
429
+ const low: ResolvedGateApproval = { policy: POLICY, mode: "enforce", context: { risk: "low" } };
430
+ const high: ResolvedGateApproval = { ...low, context: { risk: "high" } };
431
+ const bot = approval({ resolvedBy: "release-bot", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "agent" }, policyDecision: allow("enforce", low.context) });
432
+
433
+ test("the permit passes the run whose context it was given for", async () => {
434
+ const { check } = await decide([bot], low);
435
+ expect(check.satisfied).toBe(true);
436
+ if (!check.satisfied) return;
437
+ expect(check.via).toBe("policy");
438
+ });
439
+
440
+ test("once the context changes, the permit does not pass, and the run re-records the fact under the new context", async () => {
441
+ // The standing fact is the low-risk one the permit was given against; this
442
+ // run resolved risk "high" and checks approvals before re-recording.
443
+ const port = memoryGateLedgerPort({ resolutions: [bot], pending: [{ ...PENDING, approval: low }] });
444
+ const check = await evaluateGate(port, { op: "release", gate: "ship", planDigest: PLAN_A, approval: high, now: NOW });
445
+ expect(check.satisfied).toBe(false);
446
+ if (check.satisfied) return;
447
+ expect(check.recorded).toBe(true);
448
+ expect(port.appended[0]?.approval?.context).toEqual({ risk: "high" });
449
+
450
+ // The next run, under the same high-risk context, still waits: nothing new was approved.
451
+ const ledger = await port.read("release");
452
+ const next = await evaluateGate(memoryGateLedgerPort(ledger), { op: "release", gate: "ship", planDigest: PLAN_A, approval: high, now: "2026-09-01T13:00:00.000Z" });
453
+ expect(next.satisfied).toBe(false);
454
+ if (next.satisfied) return;
455
+ expect(next.recorded).toBe(false);
456
+ });
457
+
458
+ test("context keys compare in any order, and nested values count", () => {
459
+ expect(gateContextDigest({ risk: "low", paths: ["a"] })).toBe(gateContextDigest({ paths: ["a"], risk: "low" }));
460
+ expect(gateContextDigest({ plan: { adds: 1, deletes: 0 } })).toBe(gateContextDigest({ plan: { deletes: 0, adds: 1 } }));
461
+ expect(gateContextDigest({ plan: { adds: 1, deletes: 0 } })).not.toBe(gateContextDigest({ plan: { adds: 1, deletes: 1 } }));
462
+ expect(gateContextDigest(undefined)).toBe(gateContextDigest({}));
463
+ });
464
+
465
+ test("a permit recorded before #3774, with no context on it, does not pass an enforced gate", async () => {
466
+ const { contextDigest: _c, ...legacy } = allow()!;
467
+ const { check } = await decide(
468
+ [approval({ resolvedBy: "release-bot", timestamp: "2026-09-01T01:00:00.000Z", approver: { kind: "agent" }, policyDecision: legacy })],
469
+ low,
470
+ );
471
+ expect(check.satisfied).toBe(false);
472
+ });
473
+
474
+ test("a human's approval still counts toward the quorum after the context changes (#3206)", async () => {
475
+ const alex = approval({ resolvedBy: "alex", timestamp: "2026-09-01T01:00:00.000Z", policyDecision: allow("enforce", low.context) });
476
+ const { check } = await decide([alex], high);
477
+ expect(check.satisfied).toBe(true);
478
+ if (!check.satisfied) return;
479
+ expect(check.via).toBe("quorum");
480
+ });
481
+ });
482
+
315
483
  describe("gate approval — the request a policy is asked (#2508)", () => {
316
484
  test("carries the approver, the gate and the context, with the plan digest", () => {
317
485
  expect(gatePolicyRequest({