forgedock 1.3.0 → 1.4.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 (45) hide show
  1. package/bin/batch-runner.mjs +31 -2
  2. package/bin/engine/admission.mjs +294 -0
  3. package/bin/engine/admission.test.mjs +265 -0
  4. package/bin/engine/invariants.mjs +17 -4
  5. package/bin/engine/phases.mjs +201 -35
  6. package/bin/engine/resolve.mjs +256 -0
  7. package/bin/engine/resolve.test.mjs +149 -0
  8. package/bin/engine/state.mjs +20 -28
  9. package/bin/engine-cli.mjs +80 -5
  10. package/bin/engine.mjs +390 -18
  11. package/bin/forgedock.mjs +336 -59
  12. package/bin/hooks/interactive-engine.mjs +50 -24
  13. package/bin/hooks/pre-tool-use.mjs +169 -13
  14. package/bin/journey.mjs +8 -2
  15. package/bin/labels.json +5 -0
  16. package/bin/runner.mjs +321 -10
  17. package/bin/watch-utils.mjs +255 -0
  18. package/commands/cleanup.md +7 -3
  19. package/commands/orchestrate/config.md +41 -1
  20. package/commands/orchestrate/phase-1-resolve.md +234 -8
  21. package/commands/orchestrate/phase-3-dependency.md +27 -18
  22. package/commands/orchestrate/phase-4-execution.md +221 -22
  23. package/commands/quality-gate.md +6 -6
  24. package/commands/review-pr.md +23 -7
  25. package/commands/upgrade-deps.md +7 -2
  26. package/commands/work-on/decompose.md +2 -0
  27. package/commands/work-on/investigate.md +22 -4
  28. package/commands/work-on/remediate.md +2 -0
  29. package/commands/work-on/review.md +40 -1
  30. package/commands/work-on.md +19 -4
  31. package/docs/CONFIG.md +120 -0
  32. package/docs/spec/eval-run-result.md +8 -4
  33. package/docs/spec/label-state-machine.md +9 -0
  34. package/package.json +2 -2
  35. package/packages/protocol/src/emit.js +5 -22
  36. package/packages/protocol/src/html-comment-escape.js +68 -0
  37. package/packages/protocol/src/index.js +1 -0
  38. package/packages/protocol/src/parse.js +5 -6
  39. package/packages/protocol/src/phases.js +105 -0
  40. package/packages/protocol/src/types.js +17 -0
  41. package/scripts/check-admission-parity.mjs +167 -0
  42. package/scripts/check-command-side-effects.sh +181 -66
  43. package/scripts/check-phase-registry-drift.mjs +96 -0
  44. package/scripts/check-spec-markers.sh +1 -0
  45. package/scripts/forge-run.sh +35 -4
@@ -112,6 +112,33 @@ export function classifyRunnerResult(result) {
112
112
  return "incomplete";
113
113
  }
114
114
 
115
+ /**
116
+ * forge#2377: derive a numeric "cost" (docs/spec/eval-run-result.md's
117
+ * `cost: null | number` field — a billed-token proxy, not a USD amount; see
118
+ * forge#2397) from a runCommand() result's `usage` object. `runCommand()`
119
+ * (bin/runner.mjs, since #1295/PR #1421) already accumulates
120
+ * `{input_tokens, output_tokens, cache_creation_input_tokens,
121
+ * cache_read_input_tokens}` for the API backend. The CLI backend
122
+ * (`runCliBackend()`) also returns a best-effort-parsed `usage` object on its
123
+ * success path since forge#2398/PR #2421 — `usage: null` from the CLI
124
+ * backend now only occurs when the CLI's `--output-format json` envelope
125
+ * could not be parsed (older CLI version, or a non-JSON stdout), not
126
+ * unconditionally. This reports total billed tokens (input + output; cache
127
+ * reads/writes are not separately billed at the standard rate and are
128
+ * omitted from this proxy) as the numeric cost signal — converting to a USD
129
+ * amount requires a per-model pricing table, which is out of this issue's
130
+ * scope (recording usage, not pricing it) and is left to a follow-up.
131
+ *
132
+ * @param {{input_tokens?: number, output_tokens?: number}|null|undefined} usage
133
+ * @returns {number|null}
134
+ */
135
+ export function tokenCost(usage) {
136
+ if (!usage) return null;
137
+ const input = usage.input_tokens ?? 0;
138
+ const output = usage.output_tokens ?? 0;
139
+ return input + output;
140
+ }
141
+
115
142
  /**
116
143
  * Construct a per-run result object conforming to docs/spec/eval-run-result.md.
117
144
  *
@@ -120,7 +147,9 @@ export function classifyRunnerResult(result) {
120
147
  * @param {object} [opts]
121
148
  * @param {number} [opts.wallClockMs] - Wall-clock duration (ms).
122
149
  * @param {number} [opts.interventionCount] - Human intervention count.
123
- * @param {number|null} [opts.cost] - Token cost (null until #1255).
150
+ * @param {number|null} [opts.cost] - Total billed tokens (input + output), or null when
151
+ * the run produced no usage data (CLI backend, or an
152
+ * errored run). See tokenCost() (forge#2377).
124
153
  * @param {number} [opts.iterations] - Tool-loop iteration count.
125
154
  * @param {string} [opts.stopReason] - Anthropic stop_reason or "max_iterations".
126
155
  * @param {string|null} [opts.error] - Error message when status === "error".
@@ -236,7 +265,7 @@ export async function runCorpus(opts = {}) {
236
265
  makeRunResult(issue, status, {
237
266
  wallClockMs,
238
267
  interventionCount: 0,
239
- cost: null, // wired in once #1255 lands
268
+ cost: tokenCost(result.usage),
240
269
  iterations: result.iterations ?? null,
241
270
  stopReason: result.stopReason ?? null,
242
271
  model: model ?? result.model ?? null,
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Cascade admission policy — orchestration.cascade config surface (forge#2234).
3
+ *
4
+ * `/orchestrate`'s resource limits (`orchestration.max_concurrent`,
5
+ * `pipeline.token_budget_per_batch`, `pipeline.stall_timeout_minutes`, ...)
6
+ * were configurable, but the *admission policy* deciding whether a
7
+ * cascade-spawned review-finding is picked up was not — every rule
8
+ * (generation >= 2 cap, BATCH_FULLY_GATED idle defer, comment/typo keyword
9
+ * heuristic, P3 + same-file overlap) was a hardcoded constant baked into
10
+ * `commands/orchestrate/phase-4-execution.md` prose (see forge#1814,
11
+ * forge#1858, forge#2231). This module gives that policy a typed, unit-tested
12
+ * home: preset expansion + independently-settable granular levers, with the
13
+ * same validate-warn-fall-back idiom the rest of `orchestration.*`/`pipeline.*`
14
+ * already uses (see phase-4-execution.md L108-119 for the bash mirror of this
15
+ * idiom applied to `orchestration.max_concurrent`).
16
+ *
17
+ * This module is the typed reference implementation of the policy the prose
18
+ * specs (`commands/orchestrate/phase-4-execution.md`,
19
+ * `commands/orchestrate/phase-1-resolve.md`) read via `yq` at runtime — the
20
+ * bash blocks in those files mirror the resolution rules below by hand
21
+ * (the orchestrator is LLM-executed prose, not a `bin/engine/` call site),
22
+ * so any change to the preset table or defaults here MUST be mirrored there
23
+ * too. Keeping this module in `bin/engine/` gives the resolution logic a
24
+ * place to be unit-tested in isolation from the prose pipeline.
25
+ *
26
+ * Evidence this config surface addresses (see forge#2234 issue body): a
27
+ * cascade admitted via the pre-#2234 `--allow-gen2` all-or-nothing CLI flag
28
+ * (forge#2231) ran generation 2 -> 3 -> 4, drifting from "the engine silently
29
+ * kills entire batches" (gen 2, real value) to "a log sanitizer does not
30
+ * neutralize Unicode bidi-override characters" (gen 4, diminishing value).
31
+ * A binary flag cannot express "admit gen-2, stop at gen-3" or "admit
32
+ * cascade until N tokens spent" — `max_generation` and `token_budget` below
33
+ * are independent levers precisely so that shape of policy is expressible.
34
+ *
35
+ * Hard invariant (NOT configurable by design): safety exclusions — findings
36
+ * whose `## Problem` section indicates security/billing/anti-bot/auth
37
+ * concerns — are never batched and never auto-admitted by ANY policy,
38
+ * including `all`. That exclusion lives upstream of this module (the P3
39
+ * batching eligibility check in `phase-1-resolve.md` / the surface-area
40
+ * batching check in `phase-4-execution.md`) and is intentionally absent
41
+ * from the levers this module resolves.
42
+ */
43
+
44
+ /** Sentinel string accepted anywhere an "int | unlimited" lever is read. */
45
+ export const UNLIMITED = "unlimited";
46
+
47
+ /**
48
+ * @typedef {Object} CascadePolicy
49
+ * @property {number|typeof UNLIMITED} maxGeneration - Max cascade generation depth
50
+ * admitted. 1 = only original (non-review-finding-spawned) issues; a
51
+ * review-finding whose source is itself a review-finding is generation 2,
52
+ * and so on up the chain. `unlimited` removes the cap entirely.
53
+ * @property {number|typeof UNLIMITED} tokenBudget - Per-batch token ceiling for
54
+ * Step 4C's review-finding cascade dispatch (mirrors, and by default reads
55
+ * through to, `pipeline.token_budget_per_batch`). `unlimited` removes the cap.
56
+ * @property {boolean} deferOnBatchGated - Whether a fully-human-gated original
57
+ * batch (forge#1814's `BATCH_FULLY_GATED`) suppresses further cascade dispatch.
58
+ * @property {boolean} keywordHeuristic - Whether the comment/typo title keyword
59
+ * heuristic defers P3-and-below findings.
60
+ * @property {boolean} p3SameFileDefer - Whether a P3 finding sharing a file with
61
+ * the active batch is deferred.
62
+ */
63
+
64
+ /**
65
+ * Named presets. Each expands to a full `CascadePolicy` — every field can
66
+ * still be overridden individually on top of a preset (see `resolveCascadePolicy`).
67
+ *
68
+ * - `balanced` (default): the pre-#2234 hardcoded behavior, unchanged so an
69
+ * absent `orchestration.cascade` section is a no-op.
70
+ * - `all`: "pick up everything" — a maintainer draining a backlog. Removes
71
+ * both caps and disables every heuristic-based defer. Safety exclusions
72
+ * (see module docstring) still apply — they are not part of this table.
73
+ * - `conservative`: same admission shape as `balanced`, but a materially
74
+ * lower token ceiling for cost-sensitive or noisy repos.
75
+ *
76
+ * @type {Record<string, CascadePolicy>}
77
+ */
78
+ export const CASCADE_PRESETS = Object.freeze({
79
+ all: Object.freeze({
80
+ maxGeneration: UNLIMITED,
81
+ tokenBudget: UNLIMITED,
82
+ deferOnBatchGated: false,
83
+ keywordHeuristic: false,
84
+ p3SameFileDefer: false,
85
+ }),
86
+ balanced: Object.freeze({
87
+ maxGeneration: 1,
88
+ tokenBudget: 900000,
89
+ deferOnBatchGated: true,
90
+ keywordHeuristic: true,
91
+ p3SameFileDefer: true,
92
+ }),
93
+ conservative: Object.freeze({
94
+ maxGeneration: 1,
95
+ tokenBudget: 450000,
96
+ deferOnBatchGated: true,
97
+ keywordHeuristic: true,
98
+ p3SameFileDefer: true,
99
+ }),
100
+ });
101
+
102
+ export const DEFAULT_CASCADE_POLICY_NAME = "balanced";
103
+
104
+ /**
105
+ * Parse a raw config value that may be a positive integer, the literal
106
+ * string "unlimited" (case-insensitive), or absent/invalid. Mirrors the
107
+ * validate-warn-fall-back idiom used for `orchestration.max_concurrent`
108
+ * (phase-4-execution.md L112-119: `grep -qP '^[1-9][0-9]*$'` -> warn + default)
109
+ * but additionally threads the `unlimited` sentinel through, which that
110
+ * plain positive-int check would otherwise reject (see forge#2234 "Known
111
+ * Pitfalls": an `unlimited` value hitting the un-updated int-only validator
112
+ * silently degrades to the default and the uncap becomes a no-op).
113
+ *
114
+ * @param {unknown} raw
115
+ * @param {number|typeof UNLIMITED} fallback
116
+ * @returns {{ value: number|typeof UNLIMITED, warning: string|null }}
117
+ */
118
+ export function parseIntOrUnlimited(raw, fallback) {
119
+ if (raw === undefined || raw === null || raw === "null" || raw === "") {
120
+ return { value: fallback, warning: null };
121
+ }
122
+ if (typeof raw === "string" && raw.trim().toLowerCase() === UNLIMITED) {
123
+ return { value: UNLIMITED, warning: null };
124
+ }
125
+ const n = typeof raw === "number" ? raw : Number(String(raw).trim());
126
+ if (Number.isInteger(n) && n > 0) {
127
+ return { value: n, warning: null };
128
+ }
129
+ return {
130
+ value: fallback,
131
+ warning: `not a positive integer or "unlimited" ("${raw}") — falling back to default ${fallback}`,
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Expand `orchestration.cascade` config into a fully-resolved `CascadePolicy`.
137
+ * Precedence: explicit granular key > preset value > `balanced` preset value.
138
+ * An unrecognized `policy` name falls back to `balanced` with a warning,
139
+ * following the same validate-warn-fall-back idiom as every other
140
+ * `orchestration.*`/`pipeline.*` key.
141
+ *
142
+ * @param {Object} [config] - Parsed `orchestration.cascade` object from
143
+ * forge.yaml (or undefined/empty when the section is absent — a no-op
144
+ * that resolves to `balanced` exactly like today's hardcoded behavior).
145
+ * @param {string} [config.policy]
146
+ * @param {number|string} [config.max_generation]
147
+ * @param {number|string} [config.token_budget]
148
+ * @param {boolean} [config.defer_on_batch_gated]
149
+ * @param {boolean} [config.keyword_heuristic]
150
+ * @param {boolean} [config.p3_same_file_defer]
151
+ * @param {number|string} [legacyTokenBudgetPerBatch] - Deprecated-alias fallback:
152
+ * `pipeline.token_budget_per_batch`, read when `config.token_budget` is absent
153
+ * so existing configs keep working unchanged (see forge#1858).
154
+ * @returns {{ policy: CascadePolicy, policyName: string, bothUncapped: boolean, warnings: string[] }}
155
+ */
156
+ export function resolveCascadePolicy(config = {}, legacyTokenBudgetPerBatch) {
157
+ const warnings = [];
158
+ const requestedName =
159
+ typeof config.policy === "string" && config.policy.trim() !== ""
160
+ ? config.policy.trim()
161
+ : DEFAULT_CASCADE_POLICY_NAME;
162
+
163
+ let policyName = requestedName;
164
+ let preset = CASCADE_PRESETS[requestedName];
165
+ if (!preset) {
166
+ warnings.push(
167
+ `orchestration.cascade.policy "${requestedName}" is not one of: ${Object.keys(CASCADE_PRESETS).join(", ")} — falling back to "${DEFAULT_CASCADE_POLICY_NAME}"`,
168
+ );
169
+ policyName = DEFAULT_CASCADE_POLICY_NAME;
170
+ preset = CASCADE_PRESETS[DEFAULT_CASCADE_POLICY_NAME];
171
+ }
172
+
173
+ const maxGen = parseIntOrUnlimited(config.max_generation, preset.maxGeneration);
174
+ if (maxGen.warning) warnings.push(`orchestration.cascade.max_generation ${maxGen.warning}`);
175
+
176
+ // token_budget precedence: orchestration.cascade.token_budget (new home) >
177
+ // pipeline.token_budget_per_batch (deprecated alias, forge#1858) > preset default.
178
+ // The legacy fallback is validated through parseIntOrUnlimited itself before use —
179
+ // NOT trusted as-is — so a malformed legacy value (0, negative, NaN, a
180
+ // case-mismatched sentinel like "UNLIMITED") cannot silently bypass validation
181
+ // the way a bare pass-through would (forge#2302).
182
+ const { value: validatedLegacyFallback, warning: legacyWarning } = parseIntOrUnlimited(
183
+ legacyTokenBudgetPerBatch,
184
+ preset.tokenBudget,
185
+ );
186
+ if (legacyWarning) warnings.push(`pipeline.token_budget_per_batch (legacy alias) ${legacyWarning}`);
187
+ const tokenBudgetFallback =
188
+ legacyTokenBudgetPerBatch !== undefined ? validatedLegacyFallback : preset.tokenBudget;
189
+ const tokenBudget = parseIntOrUnlimited(config.token_budget, tokenBudgetFallback);
190
+ if (tokenBudget.warning) warnings.push(`orchestration.cascade.token_budget ${tokenBudget.warning}`);
191
+
192
+ const deferOnBatchGated =
193
+ typeof config.defer_on_batch_gated === "boolean" ? config.defer_on_batch_gated : preset.deferOnBatchGated;
194
+ const keywordHeuristic =
195
+ typeof config.keyword_heuristic === "boolean" ? config.keyword_heuristic : preset.keywordHeuristic;
196
+ const p3SameFileDefer =
197
+ typeof config.p3_same_file_defer === "boolean" ? config.p3_same_file_defer : preset.p3SameFileDefer;
198
+
199
+ // Both-uncapped notice: neither generation depth nor token spend is bounded this
200
+ // run. This is never a preset default (no preset in CASCADE_PRESETS sets both to
201
+ // UNLIMITED... except "all", which does so deliberately) — surface it loudly so an
202
+ // operator running `policy: all` (or an equivalent granular-override combination)
203
+ // sees the tradeoff explicitly rather than discovering it from an unexpectedly long
204
+ // cascade tail. Distinct from the per-parse `warnings` above (which flag malformed
205
+ // config); this is a policy-shape notice about a valid, fully-resolved configuration.
206
+ const bothUncapped = maxGen.value === UNLIMITED && tokenBudget.value === UNLIMITED;
207
+ if (bothUncapped) {
208
+ warnings.push(
209
+ "orchestration.cascade: both max_generation and token_budget are unlimited — cascade admission has no upper bound on generation depth or token spend for this run.",
210
+ );
211
+ }
212
+
213
+ return {
214
+ policy: {
215
+ maxGeneration: maxGen.value,
216
+ tokenBudget: tokenBudget.value,
217
+ deferOnBatchGated,
218
+ keywordHeuristic,
219
+ p3SameFileDefer,
220
+ },
221
+ policyName,
222
+ bothUncapped,
223
+ warnings,
224
+ };
225
+ }
226
+
227
+ /**
228
+ * @param {number|typeof UNLIMITED} generation - 1-indexed cascade depth of the
229
+ * finding being evaluated (1 = original issue, not spawned from a
230
+ * review-finding; 2 = spawned from a review-finding; 3 = spawned from a
231
+ * finding that was itself spawned from a review-finding; ...).
232
+ * @param {CascadePolicy} policy
233
+ * @returns {boolean} true if this generation is admitted by the policy.
234
+ */
235
+ export function admitsGeneration(generation, policy) {
236
+ if (policy.maxGeneration === UNLIMITED) return true;
237
+ return generation <= policy.maxGeneration;
238
+ }
239
+
240
+ /**
241
+ * @param {number} projectedSpend - BATCH_TOKEN_SPEND if this unit were admitted.
242
+ * @param {CascadePolicy} policy
243
+ * @returns {boolean} true if there is still headroom under the token budget.
244
+ */
245
+ export function admitsTokenSpend(projectedSpend, policy) {
246
+ if (policy.tokenBudget === UNLIMITED) return true;
247
+ return projectedSpend <= policy.tokenBudget;
248
+ }
249
+
250
+ /**
251
+ * Evaluate the Step 4C rule chain for a single cascade-spawned finding.
252
+ * Mirrors `commands/orchestrate/phase-4-execution.md` Step 4C's "Evaluation
253
+ * order" (rules 0-5) exactly, with rules 0/3/4 gated by the policy's
254
+ * corresponding toggle and rule 1 governed by `admitsGeneration`. Rule 1
255
+ * (generation cap) is evaluated for the finding's *computed* generation —
256
+ * NOT hardcoded to a single-hop "is my source a review-finding" boolean —
257
+ * so a `max_generation: 3` policy actually distinguishes gen 2 from gen 3,
258
+ * per the exact gap forge#2234 exists to close (a binary flag cannot say
259
+ * "admit gen-2, stop at gen-3").
260
+ *
261
+ * @param {Object} finding
262
+ * @param {number} finding.generation - 1-indexed, see `admitsGeneration`.
263
+ * @param {"P1"|"P2"|"P3"|string} finding.priority
264
+ * @param {string} finding.title
265
+ * @param {boolean} finding.sameFileAsBatch
266
+ * @param {boolean} finding.batchFullyGated
267
+ * @param {number} finding.projectedTokenSpend
268
+ * @param {CascadePolicy} policy
269
+ * @returns {{ admit: boolean, reason: string|null }}
270
+ */
271
+ export function evaluateCascadeFinding(finding, policy) {
272
+ if (policy.deferOnBatchGated && finding.batchFullyGated) {
273
+ return { admit: false, reason: "batch fully human-gated — idle policy" };
274
+ }
275
+ if (!admitsGeneration(finding.generation, policy)) {
276
+ return {
277
+ admit: false,
278
+ reason: `generation ${finding.generation} exceeds orchestration.cascade.max_generation (${policy.maxGeneration})`,
279
+ };
280
+ }
281
+ if (finding.priority === "P1" || finding.priority === "P2") {
282
+ return { admit: true, reason: null };
283
+ }
284
+ if (policy.keywordHeuristic && /comment|typo/i.test(finding.title || "")) {
285
+ return { admit: false, reason: "comment/typo heuristic" };
286
+ }
287
+ if (policy.p3SameFileDefer && finding.priority === "P3" && finding.sameFileAsBatch) {
288
+ return { admit: false, reason: "P3 + same file as batch" };
289
+ }
290
+ if (!admitsTokenSpend(finding.projectedTokenSpend, policy)) {
291
+ return { admit: false, reason: `per-batch token budget exhausted (orchestration.cascade.token_budget=${policy.tokenBudget})` };
292
+ }
293
+ return { admit: true, reason: null };
294
+ }
@@ -0,0 +1,265 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ UNLIMITED,
5
+ CASCADE_PRESETS,
6
+ DEFAULT_CASCADE_POLICY_NAME,
7
+ parseIntOrUnlimited,
8
+ resolveCascadePolicy,
9
+ admitsGeneration,
10
+ admitsTokenSpend,
11
+ evaluateCascadeFinding,
12
+ } from "./admission.mjs";
13
+
14
+ describe("parseIntOrUnlimited", () => {
15
+ it("parses a positive integer", () => {
16
+ assert.deepEqual(parseIntOrUnlimited("3", 1), { value: 3, warning: null });
17
+ assert.deepEqual(parseIntOrUnlimited(3, 1), { value: 3, warning: null });
18
+ });
19
+
20
+ it("parses the unlimited sentinel case-insensitively", () => {
21
+ assert.deepEqual(parseIntOrUnlimited("unlimited", 1), { value: UNLIMITED, warning: null });
22
+ assert.deepEqual(parseIntOrUnlimited("UNLIMITED", 1), { value: UNLIMITED, warning: null });
23
+ assert.deepEqual(parseIntOrUnlimited(" Unlimited ", 1), { value: UNLIMITED, warning: null });
24
+ });
25
+
26
+ it("falls back to default on absent/null/empty with no warning (no-op case)", () => {
27
+ assert.deepEqual(parseIntOrUnlimited(undefined, 12), { value: 12, warning: null });
28
+ assert.deepEqual(parseIntOrUnlimited(null, 12), { value: 12, warning: null });
29
+ assert.deepEqual(parseIntOrUnlimited("null", 12), { value: 12, warning: null });
30
+ assert.deepEqual(parseIntOrUnlimited("", 12), { value: 12, warning: null });
31
+ });
32
+
33
+ it("warns and falls back on invalid values (zero, negative, non-numeric)", () => {
34
+ for (const bad of ["0", "-1", "abc", "3.5"]) {
35
+ const r = parseIntOrUnlimited(bad, 5);
36
+ assert.equal(r.value, 5);
37
+ assert.match(r.warning, /falling back to default 5/);
38
+ }
39
+ });
40
+ });
41
+
42
+ describe("resolveCascadePolicy — presets", () => {
43
+ it("defaults to balanced when no config is given (no-op, matches pre-#2234 hardcoded behavior)", () => {
44
+ const { policy, policyName, warnings } = resolveCascadePolicy();
45
+ assert.equal(policyName, DEFAULT_CASCADE_POLICY_NAME);
46
+ assert.deepEqual(policy, CASCADE_PRESETS.balanced);
47
+ assert.deepEqual(warnings, []);
48
+ });
49
+
50
+ it("policy: all removes both caps and disables every heuristic", () => {
51
+ const { policy, warnings } = resolveCascadePolicy({ policy: "all" });
52
+ assert.equal(policy.maxGeneration, UNLIMITED);
53
+ assert.equal(policy.tokenBudget, UNLIMITED);
54
+ assert.equal(policy.deferOnBatchGated, false);
55
+ assert.equal(policy.keywordHeuristic, false);
56
+ assert.equal(policy.p3SameFileDefer, false);
57
+ // "all" leaves both levers uncapped by design — this is the one preset expected to
58
+ // carry the both-uncapped notice (see "resolveCascadePolicy — both-uncapped notice"
59
+ // describe block below for the dedicated coverage of that behavior).
60
+ assert.deepEqual(warnings, [
61
+ "orchestration.cascade: both max_generation and token_budget are unlimited — cascade admission has no upper bound on generation depth or token spend for this run.",
62
+ ]);
63
+ });
64
+
65
+ it("policy: conservative keeps balanced's shape but lowers the token budget", () => {
66
+ const { policy } = resolveCascadePolicy({ policy: "conservative" });
67
+ assert.equal(policy.maxGeneration, 1);
68
+ assert.equal(policy.tokenBudget, 450000);
69
+ assert.equal(policy.deferOnBatchGated, true);
70
+ });
71
+
72
+ it("unrecognized policy name falls back to balanced with a warning", () => {
73
+ const { policy, policyName, warnings } = resolveCascadePolicy({ policy: "yolo" });
74
+ assert.equal(policyName, "balanced");
75
+ assert.deepEqual(policy, CASCADE_PRESETS.balanced);
76
+ assert.equal(warnings.length, 1);
77
+ assert.match(warnings[0], /not one of/);
78
+ });
79
+ });
80
+
81
+ describe("resolveCascadePolicy — granular overrides compose with a preset", () => {
82
+ it("a single granular key overrides just that field, preset supplies the rest", () => {
83
+ const { policy } = resolveCascadePolicy({ policy: "balanced", max_generation: "unlimited" });
84
+ assert.equal(policy.maxGeneration, UNLIMITED);
85
+ // Everything else still comes from the balanced preset.
86
+ assert.equal(policy.tokenBudget, 900000);
87
+ assert.equal(policy.deferOnBatchGated, true);
88
+ assert.equal(policy.keywordHeuristic, true);
89
+ assert.equal(policy.p3SameFileDefer, true);
90
+ });
91
+
92
+ it("granular boolean overrides on top of the all preset", () => {
93
+ const { policy } = resolveCascadePolicy({ policy: "all", defer_on_batch_gated: true });
94
+ assert.equal(policy.deferOnBatchGated, true);
95
+ // Unrelated fields remain the all-preset's.
96
+ assert.equal(policy.maxGeneration, UNLIMITED);
97
+ assert.equal(policy.keywordHeuristic, false);
98
+ });
99
+
100
+ it("max_generation can express admit-gen-2-stop-at-gen-3 (the case a binary flag cannot)", () => {
101
+ const { policy } = resolveCascadePolicy({ policy: "all", max_generation: 3 });
102
+ assert.equal(admitsGeneration(1, policy), true);
103
+ assert.equal(admitsGeneration(2, policy), true);
104
+ assert.equal(admitsGeneration(3, policy), true);
105
+ assert.equal(admitsGeneration(4, policy), false);
106
+ });
107
+ });
108
+
109
+ describe("resolveCascadePolicy — token_budget deprecated-alias fallback", () => {
110
+ it("falls back to legacy pipeline.token_budget_per_batch when orchestration.cascade.token_budget is absent", () => {
111
+ const { policy } = resolveCascadePolicy({ policy: "balanced" }, 300000);
112
+ assert.equal(policy.tokenBudget, 300000);
113
+ });
114
+
115
+ it("orchestration.cascade.token_budget wins over the legacy alias when both are set", () => {
116
+ const { policy } = resolveCascadePolicy({ policy: "balanced", token_budget: 50000 }, 300000);
117
+ assert.equal(policy.tokenBudget, 50000);
118
+ });
119
+
120
+ it("validates the legacy fallback itself — a malformed legacy value does not silently bypass validation (forge#2302)", () => {
121
+ const { policy, warnings } = resolveCascadePolicy({ policy: "balanced" }, -1);
122
+ // -1 is invalid — must NOT be silently adopted; falls back to the balanced preset default.
123
+ assert.equal(policy.tokenBudget, 900000);
124
+ assert.ok(warnings.some((w) => /token_budget_per_batch \(legacy alias\)/.test(w)));
125
+ });
126
+
127
+ it("accepts a valid legacy fallback with no warning", () => {
128
+ const { policy, warnings } = resolveCascadePolicy({ policy: "balanced" }, 123456);
129
+ assert.equal(policy.tokenBudget, 123456);
130
+ assert.ok(!warnings.some((w) => /legacy alias/.test(w)));
131
+ });
132
+ });
133
+
134
+ describe("resolveCascadePolicy — both-uncapped notice", () => {
135
+ it("flags bothUncapped and adds a warning when policy: all leaves both levers unlimited", () => {
136
+ const { policy, bothUncapped, warnings } = resolveCascadePolicy({ policy: "all" });
137
+ assert.equal(policy.maxGeneration, UNLIMITED);
138
+ assert.equal(policy.tokenBudget, UNLIMITED);
139
+ assert.equal(bothUncapped, true);
140
+ assert.ok(warnings.some((w) => /both max_generation and token_budget are unlimited/.test(w)));
141
+ });
142
+
143
+ it("flags bothUncapped via a granular override combination, not just the all preset", () => {
144
+ const { bothUncapped } = resolveCascadePolicy({
145
+ policy: "balanced",
146
+ max_generation: "unlimited",
147
+ token_budget: "unlimited",
148
+ });
149
+ assert.equal(bothUncapped, true);
150
+ });
151
+
152
+ it("does not flag bothUncapped when only one lever is uncapped", () => {
153
+ const { bothUncapped: onlyGenUncapped } = resolveCascadePolicy({
154
+ policy: "balanced",
155
+ max_generation: "unlimited",
156
+ });
157
+ assert.equal(onlyGenUncapped, false);
158
+
159
+ const { bothUncapped: onlyTokenUncapped } = resolveCascadePolicy({
160
+ policy: "balanced",
161
+ token_budget: "unlimited",
162
+ });
163
+ assert.equal(onlyTokenUncapped, false);
164
+ });
165
+
166
+ it("balanced preset (default, no config) never flags bothUncapped", () => {
167
+ const { bothUncapped } = resolveCascadePolicy();
168
+ assert.equal(bothUncapped, false);
169
+ });
170
+ });
171
+
172
+ describe("admitsGeneration / admitsTokenSpend", () => {
173
+ it("unlimited admits any generation and any spend", () => {
174
+ const policy = CASCADE_PRESETS.all;
175
+ assert.equal(admitsGeneration(1, policy), true);
176
+ assert.equal(admitsGeneration(99, policy), true);
177
+ assert.equal(admitsTokenSpend(0, policy), true);
178
+ assert.equal(admitsTokenSpend(Number.MAX_SAFE_INTEGER, policy), true);
179
+ });
180
+
181
+ it("balanced caps generation at 1 and token spend at 900000", () => {
182
+ const policy = CASCADE_PRESETS.balanced;
183
+ assert.equal(admitsGeneration(1, policy), true);
184
+ assert.equal(admitsGeneration(2, policy), false);
185
+ assert.equal(admitsTokenSpend(900000, policy), true);
186
+ assert.equal(admitsTokenSpend(900001, policy), false);
187
+ });
188
+ });
189
+
190
+ describe("evaluateCascadeFinding — Step 4C rule-chain parity", () => {
191
+ const baseFinding = {
192
+ generation: 1,
193
+ priority: "P3",
194
+ title: "fix: something",
195
+ sameFileAsBatch: false,
196
+ batchFullyGated: false,
197
+ projectedTokenSpend: 0,
198
+ };
199
+
200
+ it("policy: all admits a gen >= 3 finding (the regression this issue's AC requires)", () => {
201
+ const { policy } = resolveCascadePolicy({ policy: "all" });
202
+ const result = evaluateCascadeFinding({ ...baseFinding, generation: 3 }, policy);
203
+ assert.equal(result.admit, true);
204
+ assert.equal(result.reason, null);
205
+ });
206
+
207
+ it("balanced defers a gen 2 finding even at P3", () => {
208
+ const { policy } = resolveCascadePolicy({ policy: "balanced" });
209
+ const result = evaluateCascadeFinding({ ...baseFinding, generation: 2 }, policy);
210
+ assert.equal(result.admit, false);
211
+ assert.match(result.reason, /generation 2 exceeds/);
212
+ });
213
+
214
+ it("rule 0: batch fully gated always defers when deferOnBatchGated is true, even for P1", () => {
215
+ const { policy } = resolveCascadePolicy({ policy: "balanced" });
216
+ const result = evaluateCascadeFinding(
217
+ { ...baseFinding, priority: "P1", batchFullyGated: true },
218
+ policy,
219
+ );
220
+ assert.equal(result.admit, false);
221
+ assert.match(result.reason, /batch fully human-gated/);
222
+ });
223
+
224
+ it("rule 0 is a no-op under policy: all (deferOnBatchGated: false)", () => {
225
+ const { policy } = resolveCascadePolicy({ policy: "all" });
226
+ const result = evaluateCascadeFinding({ ...baseFinding, batchFullyGated: true }, policy);
227
+ assert.equal(result.admit, true);
228
+ });
229
+
230
+ it("priority override: P1/P2 always execute, skipping keyword/same-file heuristics", () => {
231
+ const { policy } = resolveCascadePolicy({ policy: "balanced" });
232
+ const result = evaluateCascadeFinding(
233
+ { ...baseFinding, priority: "P2", title: "comment: typo fix", sameFileAsBatch: true },
234
+ policy,
235
+ );
236
+ assert.equal(result.admit, true);
237
+ });
238
+
239
+ it("keyword heuristic defers a P3 comment/typo finding when enabled", () => {
240
+ const { policy } = resolveCascadePolicy({ policy: "balanced" });
241
+ const result = evaluateCascadeFinding({ ...baseFinding, title: "fix: typo in README" }, policy);
242
+ assert.equal(result.admit, false);
243
+ assert.match(result.reason, /comment\/typo/);
244
+ });
245
+
246
+ it("keyword heuristic is disabled under policy: all", () => {
247
+ const { policy } = resolveCascadePolicy({ policy: "all" });
248
+ const result = evaluateCascadeFinding({ ...baseFinding, title: "fix: typo in README" }, policy);
249
+ assert.equal(result.admit, true);
250
+ });
251
+
252
+ it("P3 + same-file defers when enabled", () => {
253
+ const { policy } = resolveCascadePolicy({ policy: "balanced" });
254
+ const result = evaluateCascadeFinding({ ...baseFinding, sameFileAsBatch: true }, policy);
255
+ assert.equal(result.admit, false);
256
+ assert.match(result.reason, /same file/);
257
+ });
258
+
259
+ it("token budget defers a P3 finding once projected spend exceeds the ceiling", () => {
260
+ const { policy } = resolveCascadePolicy({ policy: "balanced" });
261
+ const result = evaluateCascadeFinding({ ...baseFinding, projectedTokenSpend: 900001 }, policy);
262
+ assert.equal(result.admit, false);
263
+ assert.match(result.reason, /token budget exhausted/);
264
+ });
265
+ });
@@ -319,10 +319,23 @@ function evaluateOneCloseAssertion(assertion, events) {
319
319
  }
320
320
 
321
321
  case "issue_closed_at_terminal":
322
- // This assertion is evaluated externally (requires a gh CLI call in the
323
- // close phase). The evaluator returns ok=true here so callers that do
324
- // not pass GitHub state still pass without error. The close.md phase
325
- // block implements the actual gh issue view check.
322
+ // forge#2352: this assertion is enforced inside the engine itself now —
323
+ // not just "evaluated externally" as the previous version of this
324
+ // comment claimed. Two real, wired-up call sites cover it:
325
+ // 1. bin/engine.mjs's runIssue() phase loop: a per-iteration
326
+ // divergence guard (`issueSnapshot()` from bin/engine/phases.mjs)
327
+ // that terminates the run ("invalid" or "needs-human") the moment
328
+ // the issue's live GitHub state/labels diverge from the local
329
+ // run-log, for every phase except `close`.
330
+ // 2. The `close` phase's own reconcile/detectOutcome
331
+ // (bin/engine/phases.mjs), which is exempt from (1) because
332
+ // reading and acting on exactly this state IS its job.
333
+ // This function has no GitHub I/O access (it's a pure assertion
334
+ // evaluator — see the module header), so it cannot re-verify the live
335
+ // issue state itself; it stays a structural pass-through by design.
336
+ // The real enforcement lives in the two call sites above — this case
337
+ // exists so `assertCloseInvariants` callers get a defined, non-throwing
338
+ // result for this assertion id rather than an unhandled default.
326
339
  return { ok: true, id: assertion.id };
327
340
 
328
341
  default: