forgedock 1.28.0 → 1.29.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 (53) hide show
  1. package/bin/engine/outcome-codes.mjs +29 -0
  2. package/bin/engine-cli.mjs +71 -16
  3. package/bin/lean/batch.test.mjs +2 -2
  4. package/bin/lean/conformance.mjs +47 -2
  5. package/bin/lean/conformance.test.mjs +53 -1
  6. package/bin/lean/context.mjs +333 -0
  7. package/bin/lean/context.test.mjs +279 -0
  8. package/bin/lean/effects.mjs +19 -1
  9. package/bin/lean/lean.mjs +171 -35
  10. package/bin/lean/lean.test.mjs +413 -7
  11. package/bin/lean/orchestrate.mjs +80 -3
  12. package/bin/lean/prefilter.mjs +2 -2
  13. package/bin/lean/prefilter.test.mjs +3 -0
  14. package/bin/lean/prompts.mjs +22 -7
  15. package/bin/lean/recovery.test.mjs +230 -0
  16. package/bin/lean/render.mjs +25 -5
  17. package/bin/lean/testmap.mjs +84 -0
  18. package/bin/lean/testmap.test.mjs +58 -0
  19. package/bin/lean/verify.mjs +48 -3
  20. package/bin/lean/verify.test.mjs +81 -3
  21. package/bin/stats/index.mjs +12 -2
  22. package/bin/stats/local-source.mjs +13 -0
  23. package/bin/stats/query.mjs +27 -0
  24. package/bin/stats/stats.test.mjs +41 -2
  25. package/commands/orchestrate.md +52 -6
  26. package/docs/CONFIG.md +15 -0
  27. package/docs/OUTCOME-CODES.md +14 -0
  28. package/docs/RELEASE-NOTES.md +9 -0
  29. package/package.json +1 -1
  30. package/scripts/check-spec-markers.sh +1 -0
  31. package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +23 -0
  32. package/scripts/fixtures/test-map/go/Makefile +5 -0
  33. package/scripts/fixtures/test-map/go/README.md +1 -0
  34. package/scripts/fixtures/test-map/go/go.mod +3 -0
  35. package/scripts/fixtures/test-map/go/internal/util/util.go +3 -0
  36. package/scripts/fixtures/test-map/go/main.go +3 -0
  37. package/scripts/fixtures/test-map/node/package.json +10 -0
  38. package/scripts/fixtures/test-map/node/packages/ui/package.json +4 -0
  39. package/scripts/fixtures/test-map/node/packages/ui/src/button.tsx +1 -0
  40. package/scripts/fixtures/test-map/node/pnpm-lock.yaml +1 -0
  41. package/scripts/fixtures/test-map/node/src/a.ts +1 -0
  42. package/scripts/fixtures/test-map/node/vitest.config.ts +1 -0
  43. package/scripts/fixtures/test-map/python/pkg/__init__.py +0 -0
  44. package/scripts/fixtures/test-map/python/pkg/mod.py +2 -0
  45. package/scripts/fixtures/test-map/python/pyproject.toml +9 -0
  46. package/scripts/fixtures/test-map/python/tests/test_mod.py +5 -0
  47. package/scripts/fixtures/test-map/python/uv.lock +1 -0
  48. package/scripts/repo-facts.sh +146 -0
  49. package/scripts/repo-facts.test.sh +106 -0
  50. package/scripts/test-map.sh +211 -0
  51. package/scripts/test-map.test.sh +165 -0
  52. package/scripts/wait-ci-green.sh +10 -4
  53. package/scripts/wait-ci-green.test.sh +9 -0
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+ /**
5
+ * Optional repo caches for the pre-PR step (#4272): `.forge/test-map.json` (scripts/test-map.sh, file glob → check
6
+ * command) picks the checks for the lane's changed files; `.forge/repo-facts.json` (scripts/repo-facts.sh) is cited to
7
+ * the builder by path. The test map's commands are run, so it is read from the base ref, then the main checkout, never
8
+ * the builder-writable lane worktree (#4297); repo facts are looked up in the worktree, then the main checkout. A missing
9
+ * or invalid file is absent: the lane keeps forge.yaml `learned.test_commands` and today's prompts.
10
+ *
11
+ * node bin/lean/testmap.mjs --map FILE --select FILE... print the selected commands, one per line
12
+ */
13
+ import fs from "node:fs";
14
+ import { join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { globToRegex, shellQuote } from "../../scripts/ci-local/lib.mjs";
17
+
18
+ export const TEST_MAP_FILE = ".forge/test-map.json";
19
+ export const REPO_FACTS_FILE = ".forge/repo-facts.json";
20
+
21
+ /** A parsed test map when valid ({version:1, entries:[{command, globs:string[]}]}), else null. */
22
+ export function parseTestMap(text) {
23
+ let j;
24
+ try { j = JSON.parse(text); } catch { return null; }
25
+ if (!j || j.version !== 1 || !Array.isArray(j.entries)) return null;
26
+ const ok = j.entries.every((e) => e && typeof e.command === "string" && e.command.trim() && Array.isArray(e.globs) && e.globs.length && e.globs.every((g) => typeof g === "string" && g));
27
+ return ok ? j : null;
28
+ }
29
+
30
+ /** The first valid test map under `dirs` (in order), or null; never throws. */
31
+ export async function readTestMap(dirs, read) {
32
+ if (typeof read !== "function") return null;
33
+ for (const d of dirs.filter(Boolean)) {
34
+ let text;
35
+ try { text = await read(join(d, TEST_MAP_FILE)); } catch { continue; }
36
+ const map = parseTestMap(String(text ?? ""));
37
+ if (map) return map;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ /** #4297: the valid test map committed at `ref` (`git show <ref>:.forge/test-map.json`), or null; never throws. */
43
+ export async function readTestMapAtRef(git, ref) {
44
+ if (typeof git !== "function" || !ref) return null;
45
+ try { return parseTestMap(String((await git(["show", `${ref}:${TEST_MAP_FILE}`])) ?? "")); } catch { return null; }
46
+ }
47
+
48
+ /** Commands of entries with a glob matching a changed file: map order, deduped; a non-root cwd runs as `cd <cwd> && cmd`. */
49
+ export function selectCommands(map, files) {
50
+ const out = [];
51
+ const changed = (Array.isArray(files) ? files : []).map((f) => String(f).replace(/^\.\//, ""));
52
+ for (const e of map?.entries ?? []) {
53
+ const res = e.globs.map((g) => globToRegex(g.replace(/^\.\//, "")));
54
+ if (!changed.some((f) => res.some((re) => re.test(f)))) continue;
55
+ const cwd = typeof e.cwd === "string" && e.cwd && e.cwd !== "." ? e.cwd : "";
56
+ const cmd = cwd ? `cd ${shellQuote(cwd)} && ${e.command.trim()}` : e.command.trim();
57
+ if (!out.includes(cmd)) out.push(cmd);
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /** The path of the first repo-facts file under `dirs` that exists, or null; never throws. */
63
+ export async function factsPath(dirs, exists) {
64
+ if (typeof exists !== "function") return null;
65
+ for (const d of dirs.filter(Boolean)) {
66
+ const p = join(d, REPO_FACTS_FILE);
67
+ try { if (await exists(p)) return p; } catch { /* absent */ }
68
+ }
69
+ return null;
70
+ }
71
+
72
+ function main(argv) {
73
+ const i = argv.indexOf("--map"), j = argv.indexOf("--select");
74
+ if (i < 0 || !argv[i + 1] || j !== i + 2) { console.error("usage: testmap.mjs --map FILE --select FILE..."); return 2; }
75
+ let text;
76
+ try { text = fs.readFileSync(argv[i + 1], "utf8"); } catch { console.error(`testmap: cannot read ${argv[i + 1]}`); return 2; }
77
+ const map = parseTestMap(text);
78
+ if (!map) { console.error("testmap: invalid test map"); return 2; }
79
+ const files = argv.slice(j + 1);
80
+ for (const c of selectCommands(map, files)) console.log(c);
81
+ return 0;
82
+ }
83
+
84
+ if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = main(process.argv.slice(2));
@@ -0,0 +1,58 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { parseTestMap, readTestMap, selectCommands, factsPath, TEST_MAP_FILE, REPO_FACTS_FILE } from "./testmap.mjs";
6
+
7
+ const MAP = {
8
+ version: 1,
9
+ entries: [
10
+ { globs: ["**/*.ts", "**/*.tsx"], command: "pnpm run lint", cwd: ".", source: "package.json#scripts.lint" },
11
+ { globs: ["packages/ui/**/*.tsx"], command: "pnpm run test", cwd: "packages/ui", source: "packages/ui/package.json#scripts.test" },
12
+ { globs: ["**/*.py"], command: "uv run pytest", cwd: ".", source: "pyproject.toml#tool.pytest" },
13
+ { globs: ["**/*.go"], command: "golangci-lint run ./...", cwd: ".", source: ".github/workflows/ci.yml#jobs.lint" },
14
+ { globs: ["**/*.ts"], command: "pnpm run lint", cwd: ".", source: "dup" },
15
+ { globs: ["dir with space/**"], command: "make x", cwd: "dir with space", source: "x" },
16
+ ],
17
+ };
18
+ const reader = (files) => async (p) => { if (!(p in files)) throw new Error("ENOENT"); return files[p]; };
19
+
20
+ describe("test map (#4272)", () => {
21
+ it("parseTestMap accepts version 1 with command + globs on every entry, rejects the rest", () => {
22
+ assert.deepEqual(parseTestMap(JSON.stringify(MAP)), MAP);
23
+ for (const bad of ["", "nope", "null", "{}", '{"version":2,"entries":[]}', '{"version":1}',
24
+ '{"version":1,"entries":[{"command":"x"}]}', '{"version":1,"entries":[{"command":"","globs":["**"]}]}',
25
+ '{"version":1,"entries":[{"command":"x","globs":[]}]}', '{"version":1,"entries":[{"command":"x","globs":[3]}]}']) {
26
+ assert.equal(parseTestMap(bad), null, bad);
27
+ }
28
+ });
29
+
30
+ it("readTestMap: worktree before main checkout; missing or invalid files are skipped; never throws", async () => {
31
+ const other = { version: 1, entries: [{ globs: ["**"], command: "make test" }] };
32
+ assert.deepEqual(await readTestMap(["/wt", "/main"], reader({ [`/wt/${TEST_MAP_FILE}`]: JSON.stringify(MAP), [`/main/${TEST_MAP_FILE}`]: JSON.stringify(other) })), MAP);
33
+ assert.deepEqual(await readTestMap(["/wt", "/main"], reader({ [`/main/${TEST_MAP_FILE}`]: JSON.stringify(other) })), other);
34
+ assert.deepEqual(await readTestMap(["/wt", "/main"], reader({ [`/wt/${TEST_MAP_FILE}`]: "{broken", [`/main/${TEST_MAP_FILE}`]: JSON.stringify(other) })), other);
35
+ assert.equal(await readTestMap(["/wt", "/main"], reader({})), null);
36
+ assert.equal(await readTestMap(["/wt"], undefined), null);
37
+ assert.equal(await readTestMap([null, "/wt"], reader({})), null);
38
+ });
39
+
40
+ it("selectCommands: matching entries in map order, deduped, non-root cwd as cd && cmd", () => {
41
+ assert.deepEqual(selectCommands(MAP, ["src/a.ts"]), ["pnpm run lint"]);
42
+ assert.deepEqual(selectCommands(MAP, ["packages/ui/src/b.tsx", "./svc/main.go"]), ["pnpm run lint", "cd packages/ui && pnpm run test", "golangci-lint run ./..."]);
43
+ assert.deepEqual(selectCommands(MAP, ["a.py"]), ["uv run pytest"], "** matches a root-level file");
44
+ assert.deepEqual(selectCommands(MAP, ["dir with space/f"]), ["cd 'dir with space' && make x"]);
45
+ assert.deepEqual(selectCommands(MAP, ["README.md"]), []);
46
+ assert.deepEqual(selectCommands(MAP, []), []);
47
+ assert.deepEqual(selectCommands(null, ["a.ts"]), []);
48
+ });
49
+
50
+ it("factsPath: the first existing repo-facts file, else null", async () => {
51
+ const exists = (set) => async (p) => set.includes(p);
52
+ assert.equal(await factsPath(["/wt", "/main"], exists([`/main/${REPO_FACTS_FILE}`, `/wt/${REPO_FACTS_FILE}`])), `/wt/${REPO_FACTS_FILE}`);
53
+ assert.equal(await factsPath(["/wt", "/main"], exists([`/main/${REPO_FACTS_FILE}`])), `/main/${REPO_FACTS_FILE}`);
54
+ assert.equal(await factsPath(["/wt", "/main"], exists([])), null);
55
+ assert.equal(await factsPath(["/wt"], undefined), null);
56
+ assert.equal(await factsPath(["/wt"], async () => { throw new Error("EACCES"); }), null);
57
+ });
58
+ });
@@ -18,11 +18,16 @@ export const LOG_CAP = 12000;
18
18
  /** No run or check for the head after this long means CI is unavailable (local fallback). */
19
19
  export const DEFAULT_GRACE_MS = 5 * 60000;
20
20
  const LEAN_PREFIX = ".forge-lean/";
21
+ /** The lane's own report-only check run (#4270, `verify.check_run`): never part of ForgeDock's own CI waits. */
22
+ export const VERIFY_CHECK_NAME = "forgedock/verify";
23
+ /** Checks API output.text limit is 65535 characters; stay well under it. */
24
+ export const VERIFY_TEXT_CAP = 60000;
21
25
  const OK = ["success", "neutral", "skipped"];
22
26
 
23
27
  /**
24
28
  * forge.yaml `verify:` section (block style; parseForgeYaml gives strings) → {mode, workflow, pathsInput, skipPaths,
25
- * maxRounds, precheckTimeoutSeconds}. Invalid values fall back to the defaults; mode defaults to local (today's behaviour).
29
+ * maxRounds, precheckTimeoutSeconds, checkRun}. Invalid values fall back to the defaults; mode defaults to local (today's
30
+ * behaviour); checkRun (`check_run`, #4270) is true only for the literal `true`.
26
31
  */
27
32
  export function parseVerify(sec = {}) {
28
33
  const s = sec && typeof sec === "object" ? sec : {};
@@ -36,7 +41,8 @@ export function parseVerify(sec = {}) {
36
41
  const maxRounds = str(s.max_rounds) !== "" && Number.isInteger(n) && n >= 0 && n <= 10 ? n : DEFAULT_MAX_ROUNDS;
37
42
  const t = Number(str(s.precheck_timeout_seconds));
38
43
  const precheckTimeoutSeconds = str(s.precheck_timeout_seconds) !== "" && Number.isInteger(t) && t >= 1 && t <= 3600 ? t : DEFAULT_PRECHECK_TIMEOUT_SECONDS;
39
- return { mode, workflow, pathsInput, skipPaths, maxRounds, precheckTimeoutSeconds };
44
+ const checkRun = str(s.check_run).toLowerCase() === "true";
45
+ return { mode, workflow, pathsInput, skipPaths, maxRounds, precheckTimeoutSeconds, checkRun };
40
46
  }
41
47
 
42
48
  const globRe = (g) => new RegExp(`^${g.split("**").map((p) => p.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")).join(".*")}$`);
@@ -109,7 +115,7 @@ export async function runCiVerify({ io, branch, head, changedFiles, verify, ciMa
109
115
  if (!run && now() - startedAt >= graceMs) return unavailable(`no ${v.workflow} run for ${String(head).slice(0, 12)} appeared within ${Math.round(graceMs / 60000)} min`);
110
116
  } else {
111
117
  let checks = [];
112
- try { checks = (await io.commitChecks(head)) ?? []; } catch { checks = []; }
118
+ try { checks = ((await io.commitChecks(head)) ?? []).filter((c) => c?.name !== VERIFY_CHECK_NAME); } catch { checks = []; }
113
119
  if (!checks.length && now() - startedAt >= graceMs) return unavailable(`no checks reported for ${String(head).slice(0, 12)} within ${Math.round(graceMs / 60000)} min`);
114
120
  if (checks.length && checks.every((c) => c.state !== "pending")) {
115
121
  const failed = checks.filter((c) => c.state === "failure");
@@ -122,3 +128,42 @@ export async function runCiVerify({ io, branch, head, changedFiles, verify, ciMa
122
128
  await sleep(ciPollDelayMs(i, random));
123
129
  }
124
130
  }
131
+
132
+ const VALIDATE_KINDS = ["validate_missing", "validate_failed"], REQUIREMENT_KINDS = ["requirement_unimplemented", "untracked_deferral"];
133
+ /**
134
+ * #4270: the `forgedock/verify` check-run payload from the results the lane already computed (no gate logic of its own).
135
+ * commands: [{command, rc}] of the last declared-check round, or null (none configured); structure: the structure delta or
136
+ * null; undeclaredEdges: package edges the contract does not name; docs: io.docsFreshness result or null; precheck: the
137
+ * conformance precheck failures of the last gate() run (null = never ran); amendments: its amendment problems; hotspots:
138
+ * whether hotspot facts were available. Returns {conclusion, gates: [{id, ok, ran, evidence}], title, summary, text}. A gate
139
+ * counts only when it ran (#4297): failure if a gate that ran failed, neutral ("no gates ran") when none ran, else success.
140
+ */
141
+ export function verifyReport({ head = null, commands = null, structure = null, undeclaredEdges = [], docs = null, precheck = null, amendments = [], hotspots = false, contract = null, result = null } = {}) {
142
+ const NOT_RUN = "not run";
143
+ const pre = Array.isArray(precheck) ? precheck : [];
144
+ const details = (kinds) => pre.filter((f) => kinds.includes(f?.kind)).map((f) => String(f.detail ?? f.code ?? ""));
145
+ const runs = Array.isArray(result?.validation) ? result.validation : [];
146
+ const reqs = Array.isArray(result?.requirements) ? result.requirements : [];
147
+ const edges = (xs) => (xs ?? []).map((e) => `${e?.from}→${e?.to}${e?.manifest ? ` (${e.manifest})` : ""}`);
148
+ const gate = (id, ran, failures, okEvidence) => ({ id, ok: !failures.length, ran: Boolean(ran), evidence: !ran ? NOT_RUN : failures.length ? failures : okEvidence });
149
+ const gates = [
150
+ gate("precheck.commands", Array.isArray(commands), (commands ?? []).filter((c) => c.rc !== 0).map((c) => `${c.command} (exit ${c.rc})`),
151
+ (commands ?? []).map((c) => ({ command: c.command, rc: c.rc }))),
152
+ gate("structure.edges", Boolean(structure), edges(undeclaredEdges).map((e) => `undeclared ${e}`), { added: edges(structure?.package_edges_added) }),
153
+ gate("docs.freshness", Boolean(docs), docs && !docs.ok ? [String(docs.output ?? "stale docs").slice(0, 2000)] : [], "ok"),
154
+ gate("conformance.scope", precheck !== null, details(["scope_creep"]), "every changed file is inside the contract's deliverables"),
155
+ gate("conformance.validate", precheck !== null, details(VALIDATE_KINDS),
156
+ (contract?.validate ?? []).map((v) => { const r = runs.find((x) => x?.id === v.id); return { id: v.id, command: v.command, ...(r?.exit !== undefined ? { exit: r.exit } : r?.ci ? { ci: true } : {}) }; })),
157
+ gate("conformance.requirements", precheck !== null, details(REQUIREMENT_KINDS), reqs.map((r) => ({ id: r?.id, status: r?.status }))),
158
+ gate("maintenance.hotspot_growth", precheck !== null && Boolean(hotspots), details(["hotspot_growth"]), "no over-threshold file grew past the limit"),
159
+ gate("conformance.amendments", precheck !== null, (amendments ?? []).map((a) => String(a?.detail ?? a)), "ok"),
160
+ ];
161
+ const ran = gates.filter((g) => g.ran), failed = ran.filter((g) => !g.ok);
162
+ const conclusion = failed.length ? "failure" : ran.length ? "success" : "neutral";
163
+ const title = failed.length ? `${failed.length} of ${ran.length} deterministic gate(s) failed` : ran.length ? `${ran.length} deterministic gate(s) passed` : "no gates ran";
164
+ const summary = [`ForgeDock deterministic gates${head ? ` at \`${String(head).slice(0, 12)}\`` : ""}: report only (requiring it is the repository's branch-protection choice).`, "",
165
+ ...(ran.length ? gates.map((g) => `- ${!g.ran ? "⏭️" : g.ok ? "✅" : "❌"} \`${g.id}\`${g.ran ? "" : " (not run)"}`) : ["no gates ran"])].join("\n");
166
+ let json = JSON.stringify({ head, conclusion, gates }, null, 2);
167
+ if (json.length > VERIFY_TEXT_CAP) json = JSON.stringify({ head, conclusion, truncated: true, gates: gates.map((g) => ({ id: g.id, ok: g.ok, ran: g.ran, evidence: JSON.stringify(g.evidence).slice(0, Math.floor(VERIFY_TEXT_CAP / (gates.length + 1))) })) }, null, 2);
168
+ return { conclusion, gates, title, summary, text: "```json\n" + json + "\n```" };
169
+ }
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
  import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
- import { parseVerify, matchesGlob, skipsCi, capLogs, runCiVerify, collectFailedLogs, DEFAULT_SKIP_PATHS, LOG_CAP } from "./verify.mjs";
5
+ import { parseVerify, matchesGlob, skipsCi, capLogs, runCiVerify, collectFailedLogs, verifyReport, DEFAULT_SKIP_PATHS, LOG_CAP, VERIFY_CHECK_NAME, VERIFY_TEXT_CAP } from "./verify.mjs";
6
6
  import { precheck } from "./conformance.mjs";
7
7
 
8
8
  const HEAD = "abc123abc123abc123";
@@ -22,9 +22,9 @@ function fake(o = {}) {
22
22
 
23
23
  describe("verify-by-CI config (#4167)", () => {
24
24
  it("parseVerify defaults to local and rejects invalid values", () => {
25
- assert.deepEqual(parseVerify({}), { mode: "local", workflow: null, pathsInput: null, skipPaths: [...DEFAULT_SKIP_PATHS], maxRounds: 2, precheckTimeoutSeconds: 300 });
25
+ assert.deepEqual(parseVerify({}), { mode: "local", workflow: null, pathsInput: null, skipPaths: [...DEFAULT_SKIP_PATHS], maxRounds: 2, precheckTimeoutSeconds: 300, checkRun: false });
26
26
  const v = parseVerify({ mode: "ci", workflow: ".github/workflows/verify.yml", paths_input: "changed_paths", skip_paths: "*.md, docs/** ,", max_rounds: "3" });
27
- assert.deepEqual(v, { mode: "ci", workflow: ".github/workflows/verify.yml", pathsInput: "changed_paths", skipPaths: ["*.md", "docs/**"], maxRounds: 3, precheckTimeoutSeconds: 300 });
27
+ assert.deepEqual(v, { mode: "ci", workflow: ".github/workflows/verify.yml", pathsInput: "changed_paths", skipPaths: ["*.md", "docs/**"], maxRounds: 3, precheckTimeoutSeconds: 300, checkRun: false });
28
28
  assert.equal(parseVerify({ mode: "remote" }).mode, "local");
29
29
  assert.equal(parseVerify({ workflow: "x; rm -rf /" }).workflow, null);
30
30
  assert.equal(parseVerify({ max_rounds: "-1" }).maxRounds, 2);
@@ -116,3 +116,81 @@ describe("merge-time fix rounds and declared check commands (#4240)", () => {
116
116
  for (const bad of ["0", "-5", "1.5", "abc", "3601"]) assert.equal(parseVerify({ precheck_timeout_seconds: bad }).precheckTimeoutSeconds, 300, bad);
117
117
  });
118
118
  });
119
+
120
+ describe("forgedock/verify check run (#4270)", () => {
121
+ const contract = { requirements: [{ id: "R1", disposition: "deliver" }], deliverables: [{ id: "D1", path: "src/a.ts", change: "x", requirements: ["R1"] }], validate: [{ id: "V1", command: "node --test t.mjs" }, { id: "V2", command: "tsc" }] };
122
+ const result = { validation: [{ id: "V1", exit: 0 }, { id: "V2", ci: true }], requirements: [{ id: "R1", status: "implemented" }] };
123
+ const out = (r) => JSON.parse(/```json\n([\s\S]*)\n```/.exec(r.text)[1]);
124
+
125
+ it("parseVerify: check_run is true only for the literal true; default false", () => {
126
+ assert.equal(parseVerify({}).checkRun, false);
127
+ assert.equal(parseVerify({ check_run: "true" }).checkRun, true);
128
+ assert.equal(parseVerify({ check_run: true }).checkRun, true);
129
+ for (const v of ["false", "yes", "1", ""]) assert.equal(parseVerify({ check_run: v }).checkRun, false, v);
130
+ assert.equal(VERIFY_CHECK_NAME, "forgedock/verify");
131
+ });
132
+
133
+ it("success iff every gate passed; per-gate typed evidence from the computed results", () => {
134
+ const r = verifyReport({ head: "abc", commands: [{ command: "npm run lint", rc: 0 }], structure: { package_edges_added: [{ from: "@s/a", to: "@s/b", manifest: "packages/a/package.json" }] },
135
+ docs: { ok: true }, precheck: [], amendments: [], hotspots: true, contract, result });
136
+ assert.equal(r.conclusion, "success");
137
+ const g = Object.fromEntries(out(r).gates.map((x) => [x.id, x]));
138
+ assert.deepEqual(g["precheck.commands"].evidence, [{ command: "npm run lint", rc: 0 }]);
139
+ assert.deepEqual(g["structure.edges"].evidence, { added: ["@s/a→@s/b (packages/a/package.json)"] });
140
+ assert.deepEqual(g["conformance.validate"].evidence, [{ id: "V1", command: "node --test t.mjs", exit: 0 }, { id: "V2", command: "tsc", ci: true }]);
141
+ assert.deepEqual(g["conformance.requirements"].evidence, [{ id: "R1", status: "implemented" }]);
142
+ assert.ok(Object.values(g).every((x) => x.ok));
143
+ });
144
+
145
+ it("each failing gate is failure with its evidence; gates without input are ok and marked not run", () => {
146
+ const pre = [
147
+ { kind: "scope_creep", detail: "changed outside the contract's deliverables: src/x.ts" },
148
+ { kind: "validate_failed", detail: "validate V1 failed (exit 1)" },
149
+ { kind: "requirement_unimplemented", detail: "R1 reported \"partial\"" },
150
+ { kind: "hotspot_growth", detail: "src/big.ts grew by 300 lines" },
151
+ ];
152
+ const r = verifyReport({ head: "abc", commands: [{ command: "lint", rc: 2 }], structure: { package_edges_added: [] }, undeclaredEdges: [{ from: "@s/a", to: "@s/b" }],
153
+ docs: { ok: false, output: "stale devdocs/x.md" }, precheck: pre, amendments: [{ detail: "add-deliverable without why" }], hotspots: true, contract, result });
154
+ assert.equal(r.conclusion, "failure");
155
+ const g = Object.fromEntries(r.gates.map((x) => [x.id, x]));
156
+ assert.ok(r.gates.every((x) => !x.ok), "every gate failed");
157
+ assert.deepEqual(g["precheck.commands"].evidence, ["lint (exit 2)"]);
158
+ assert.deepEqual(g["structure.edges"].evidence, ["undeclared @s/a→@s/b"]);
159
+ assert.match(g["conformance.scope"].evidence[0], /src\/x\.ts/);
160
+ assert.match(g["maintenance.hotspot_growth"].evidence[0], /src\/big\.ts/);
161
+ assert.match(r.title, /8 of 8/);
162
+
163
+ const none = verifyReport({});
164
+ assert.ok(none.gates.every((x) => x.ok && !x.ran && x.evidence === "not run"));
165
+ });
166
+
167
+ it("verifyReport with no gate results is neutral with \"no gates ran\" (#4297)", () => {
168
+ for (const r of [verifyReport({}), verifyReport({ head: "abc" })]) {
169
+ assert.equal(r.conclusion, "neutral");
170
+ assert.equal(r.title, "no gates ran");
171
+ assert.match(r.summary, /no gates ran/);
172
+ assert.doesNotMatch(`${r.title}\n${r.summary}`, /passed|✅/);
173
+ }
174
+ // Only gates that ran are counted: one command round, nothing else.
175
+ const one = verifyReport({ head: "abc", commands: [{ command: "lint", rc: 0 }] });
176
+ assert.equal(one.conclusion, "success");
177
+ assert.equal(one.title, "1 deterministic gate(s) passed");
178
+ assert.match(one.summary, /`structure\.edges` \(not run\)/);
179
+ const bad = verifyReport({ head: "abc", commands: [{ command: "lint", rc: 1 }] });
180
+ assert.equal(bad.conclusion, "failure"); assert.equal(bad.title, "1 of 1 deterministic gate(s) failed");
181
+ });
182
+
183
+ it("the output text stays under the Checks API limit", () => {
184
+ const r = verifyReport({ precheck: Array.from({ length: 2000 }, (_, i) => ({ kind: "scope_creep", detail: `changed outside: src/${"x".repeat(80)}${i}.ts` })), contract, result });
185
+ assert.ok(r.text.length < 65535); assert.ok(r.text.length <= VERIFY_TEXT_CAP + 100);
186
+ assert.equal(r.conclusion, "failure"); assert.equal(out(r).truncated, true);
187
+ });
188
+
189
+ it("runCiVerify never waits on the lane's own forgedock/verify check run", async () => {
190
+ const f = fake({ checks: [[{ name: "ci", state: "success" }, { name: VERIFY_CHECK_NAME, state: "failure" }]] });
191
+ const r = await runCiVerify(f.args({ verify: parseVerify({ mode: "ci" }) }));
192
+ assert.equal(r.result, "pass");
193
+ const g = fake({ checks: [[{ name: VERIFY_CHECK_NAME, state: "success" }], [{ name: "ci", state: "success" }]] });
194
+ assert.equal((await runCiVerify(g.args({ verify: parseVerify({ mode: "ci" }) }))).detail, "1 check(s) green", "its own check alone is not CI");
195
+ });
196
+ });
@@ -13,7 +13,7 @@ import { aggregate, DEFAULT_MIN_N } from "./aggregate.mjs";
13
13
  import { fetchGithubRecords, isValidRepo, toModel } from "./github-source.mjs";
14
14
  import { readLocalRuns, runsDir } from "./local-source.mjs";
15
15
  import { renderCompact, renderJson, renderMarkdown } from "./render.mjs";
16
- import { edgeQuery, parseEdge, verdictMix } from "./query.mjs";
16
+ import { edgeQuery, parseEdge, verdictMix, contextPackAb, renderContextAb } from "./query.mjs";
17
17
 
18
18
  const here = dirname(fileURLToPath(import.meta.url));
19
19
 
@@ -39,10 +39,14 @@ delta against the previous version and a low-n flag for samples under 10. Missin
39
39
  Audit queries (forge#4165; JSON, from structure_delta / run_terminal telemetry, no comment bodies read):
40
40
  --edge FROM:TO merged PRs that added the dependency or import edge FROM -> TO (e.g. @s/web:@s/db)
41
41
  --milestone M conformance verdict mix and per-day structure series for milestone M
42
+
43
+ Context-pack A/B (forge#4275; local run logs only, no GitHub crawl):
44
+ --context-ab first-pass yield, builder turns, cache tokens and cost per lane type (rerun, child,
45
+ first) and context_pack mode (on, off, shadow); --json for JSON
42
46
  `;
43
47
 
44
48
  export function parseArgs(argv) {
45
- const o = { since: null, repo: null, edge: null, milestone: null, json: false, refresh: false, compact: false, local: true, cache: true, minN: DEFAULT_MIN_N, releaseTimes: null, help: false };
49
+ const o = { since: null, repo: null, edge: null, milestone: null, contextAb: false, json: false, refresh: false, compact: false, local: true, cache: true, minN: DEFAULT_MIN_N, releaseTimes: null, help: false };
46
50
  const val = (i, name) => {
47
51
  const v = argv[i + 1];
48
52
  if (v == null || v.startsWith("--")) throw new Error(`${name} needs a value`);
@@ -58,6 +62,7 @@ export function parseArgs(argv) {
58
62
  else if (name === "--release-times") o.releaseTimes = take();
59
63
  else if (name === "--edge") { const v = take(); if (!parseEdge(v)) throw new Error("--edge must be FROM:TO"); o.edge = v; }
60
64
  else if (name === "--milestone") o.milestone = take();
65
+ else if (name === "--context-ab") o.contextAb = true;
61
66
  else if (name === "--min-n") { const v = take(); if (!/^\d+$/.test(v)) throw new Error("--min-n must be a non-negative integer"); o.minN = Number(v); }
62
67
  else if (name === "--json") o.json = true;
63
68
  else if (name === "--refresh") o.refresh = true;
@@ -156,6 +161,10 @@ export async function buildReport(opts, deps = {}) {
156
161
  if (opts.releaseTimes) releaseTimes = parseReleaseTimes(readFileSync(opts.releaseTimes, "utf8"));
157
162
  else if (deps.releaseTimes) releaseTimes = deps.releaseTimes;
158
163
  else releaseTimes = gitTagTimes();
164
+ if (opts.contextAb) { // forge#4275: run logs only
165
+ const runs = (deps.localRuns ?? readLocalRuns(runsDir(env))).filter((r) => r.repo == null || r.repo === repo);
166
+ return { repo, sources: { local: { runs: runs.length } }, query: { context_ab: contextPackAb(runs) } };
167
+ }
159
168
  const bound = sinceToTimestamp(opts.since, releaseTimes);
160
169
  if (bound.note) (deps.warn ?? ((m) => process.stderr.write(`forgedock stats: ${m}\n`)))(bound.note);
161
170
  const gh = await fetchGithubRecords({ repo, since: bound.since, refresh: opts.refresh, useCache: opts.cache, gh: deps.gh, env,
@@ -190,6 +199,7 @@ export async function runStats(argv, deps = {}) {
190
199
  }
191
200
  try {
192
201
  const report = await buildReport(opts, { onProgress: makeProgressPrinter(), ...deps });
202
+ if (report.query?.context_ab && !opts.json) { process.stdout.write(`${renderContextAb(report.query.context_ab)}\n`); return 0; }
193
203
  process.stdout.write(`${opts.json || report.query ? renderJson(report) : opts.compact ? renderCompact(report) : renderMarkdown(report)}\n`);
194
204
  return 0;
195
205
  } catch (err) {
@@ -50,6 +50,13 @@ export function summariseLog(events, fallbackIssue = null) {
50
50
  if (attempt && Number.isFinite(e.cost_usd)) p.usd = (p.usd ?? 0) + e.cost_usd;
51
51
  }
52
52
  const finished = t >= 0 && (r < 0 || t > r);
53
+ // forge#4275: the context-pack A/B inputs. Builder calls log as phase "implement" (the plan call is the first).
54
+ const term = finished ? ev[t] : null;
55
+ const cp = [...ev].reverse().find((e) => e.event === "CONTEXT_PACK");
56
+ const usage = ev.filter((e) => e.event === "PHASE_ATTEMPT_USAGE");
57
+ const builderCalls = usage.filter((e) => e.phase === "implement");
58
+ const reviewCalls = usage.filter((e) => e.phase === "review").length;
59
+ const contextPack = { mode: term?.context_pack ?? cp?.mode ?? "off", laneType: term?.lane_type ?? cp?.lane_type ?? null };
53
60
  const startTs = start.ts ?? null;
54
61
  const endTs = finished ? ev[t].ts ?? null : null;
55
62
  const a = Date.parse(startTs ?? ""); const b = Date.parse(endTs ?? "");
@@ -60,6 +67,12 @@ export function summariseLog(events, fallbackIssue = null) {
60
67
  remediated: ev.some((e) => e.event === "PHASE_COMMIT" && e.phase === "remediate"),
61
68
  startTs, endTs, wallMs: finished && !Number.isNaN(a) && !Number.isNaN(b) ? Math.max(0, b - a) : null,
62
69
  phases,
70
+ contextPack,
71
+ builderTurns: builderCalls.reduce((x, e) => x + n(e.turns), 0),
72
+ cacheTokens: usage.reduce((x, e) => x + n(e.usage?.cache_read_input_tokens) + n(e.usage?.cache_creation_input_tokens), 0),
73
+ costUsd: usage.reduce((x, e) => x + n(e.cost_usd), 0),
74
+ // first pass: merged off one plan + one implement call, at most one review, no CI fix round
75
+ firstPass: finished && term?.reason === "merged" && builderCalls.length <= 2 && reviewCalls <= 1 && !ev.some((e) => e.event === "CI_FIX_ROUND"),
63
76
  };
64
77
  }
65
78
 
@@ -70,3 +70,30 @@ export function verdictMix(records, items = [], milestone = null) {
70
70
  series: [...series.values()].sort((a, b) => a.date.localeCompare(b.date)),
71
71
  };
72
72
  }
73
+
74
+ /**
75
+ * forge#4275: the context-pack A/B over local run summaries (local-source.mjs summariseLog), per lane type (rerun,
76
+ * child, first) and mode (on, off, shadow), never one blended average. First-pass yield: merged with one plan + one
77
+ * implement builder call and at most one review. Runs without a recorded lane type (older logs) are left out.
78
+ */
79
+ export const CONTEXT_LANE_TYPES = Object.freeze(["rerun", "child", "first"]);
80
+ export function contextPackAb(runs) {
81
+ const out = {};
82
+ const mean = (xs) => (xs.length ? Math.round((xs.reduce((a, b) => a + b, 0) / xs.length) * 1e4) / 1e4 : null);
83
+ for (const lt of CONTEXT_LANE_TYPES) {
84
+ out[lt] = {};
85
+ for (const mode of ["on", "off", "shadow"]) {
86
+ const rs = runs.filter((r) => r?.finished && r.contextPack?.laneType === lt && r.contextPack?.mode === mode);
87
+ out[lt][mode] = { n: rs.length, first_pass_yield: mean(rs.map((r) => (r.firstPass ? 1 : 0))), builder_turns: mean(rs.map((r) => r.builderTurns ?? 0)),
88
+ cache_tokens: mean(rs.map((r) => r.cacheTokens ?? 0)), cost_usd: mean(rs.map((r) => r.costUsd ?? 0)) };
89
+ }
90
+ }
91
+ return out;
92
+ }
93
+
94
+ /** Markdown table for contextPackAb. */
95
+ export function renderContextAb(ab) {
96
+ const f = (v) => (v == null ? "n/a" : String(v));
97
+ const rows = CONTEXT_LANE_TYPES.flatMap((lt) => ["on", "off", "shadow"].map((m) => { const g = ab[lt][m]; return `| ${lt} | ${m} | ${g.n} | ${f(g.first_pass_yield)} | ${f(g.builder_turns)} | ${f(g.cache_tokens)} | ${f(g.cost_usd)} |`; }));
98
+ return `| Lane type | Mode | n | First-pass yield | Builder turns | Cache tokens | Cost USD |\n| --- | --- | --- | --- | --- | --- | --- |\n${rows.join("\n")}`;
99
+ }
@@ -12,9 +12,9 @@ import { aggregate, compareVersions, percentile } from "./aggregate.mjs";
12
12
  import { cacheDir } from "./cache.mjs";
13
13
  import { fetchGithubRecords, parseTelemetryComments, runsFromRecords, toModel } from "./github-source.mjs";
14
14
  import { buildReport, makeProgressPrinter, parseArgs, parseSince, runStats, sinceToTimestamp } from "./index.mjs";
15
- import { readLocalRuns } from "./local-source.mjs";
15
+ import { readLocalRuns, summariseLog } from "./local-source.mjs";
16
16
  import { renderCompact, renderJson, renderMarkdown } from "./render.mjs";
17
- import { edgeQuery, parseEdge, verdictMix } from "./query.mjs";
17
+ import { edgeQuery, parseEdge, verdictMix, contextPackAb, renderContextAb } from "./query.mjs";
18
18
 
19
19
  const here = dirname(fileURLToPath(import.meta.url));
20
20
  const fx = (f) => join(here, "fixtures", f);
@@ -525,3 +525,42 @@ describe("audit queries (forge#4165)", () => {
525
525
  assert.deepEqual([parseArgs([]).edge, parseArgs([]).milestone], [null, null]);
526
526
  });
527
527
  });
528
+
529
+ describe("context-pack A/B over run logs (forge#4275)", () => {
530
+ const use = (phase, turns, cr, usd) => ({ event: "PHASE_ATTEMPT_USAGE", phase, turns, cost_usd: usd, usage: { cache_read_input_tokens: cr, cache_creation_input_tokens: 10, output_tokens: 1 } });
531
+ const log = (mode, lt, { reason = "merged", builds = 2, reviews = 1 } = {}) => [
532
+ { event: "RUN_START", issue: 1, repo: "o/r", run: "r" },
533
+ ...(mode !== "off" ? [{ event: "CONTEXT_PACK", mode, lane_type: lt, bytes: 100 }] : []),
534
+ ...Array.from({ length: builds }, () => use("implement", 5, 1000, 0.5)),
535
+ ...Array.from({ length: reviews }, () => use("review", 2, 100, 0.1)),
536
+ { event: "RUN_TERMINAL", reason, code: "x", context_pack: mode, lane_type: lt },
537
+ ];
538
+
539
+ it("summariseLog carries mode, lane type, builder turns, cache tokens, cost and first-pass", () => {
540
+ const s = summariseLog(log("on", "child"));
541
+ assert.deepEqual(s.contextPack, { mode: "on", laneType: "child" });
542
+ assert.deepEqual([s.builderTurns, s.cacheTokens, s.costUsd, s.firstPass], [10, 2130, 1.1, true]);
543
+ assert.equal(summariseLog(log("on", "child", { builds: 4 })).firstPass, false);
544
+ assert.equal(summariseLog(log("on", "child", { reason: "needs-human" })).firstPass, false);
545
+ const fixed = log("on", "child"); fixed.splice(-1, 0, { event: "CI_FIX_ROUND", trigger: "build", round: 1 });
546
+ assert.equal(summariseLog(fixed).firstPass, false);
547
+ assert.equal(summariseLog([{ event: "RUN_START", issue: 3 }, { event: "RUN_TERMINAL", reason: "merged" }]).contextPack.mode, "off");
548
+ });
549
+
550
+ it("reports per lane type and mode, never one blended average", () => {
551
+ const runs = [log("on", "first"), log("on", "first", { builds: 4, reviews: 2 }), log("off", "first"), log("shadow", "rerun"),
552
+ [{ event: "RUN_START", issue: 2 }, { event: "RUN_TERMINAL", reason: "merged" }]].map((l) => summariseLog(l));
553
+ const ab = contextPackAb(runs);
554
+ assert.deepEqual(Object.keys(ab), ["rerun", "child", "first"]);
555
+ assert.deepEqual(ab.first.on, { n: 2, first_pass_yield: 0.5, builder_turns: 15, cache_tokens: 3195, cost_usd: 1.65 });
556
+ assert.equal(ab.first.off.n, 1); assert.equal(ab.first.off.first_pass_yield, 1);
557
+ assert.equal(ab.rerun.shadow.n, 1); assert.equal(ab.child.on.n, 0); assert.equal(ab.child.on.cost_usd, null);
558
+ assert.match(renderContextAb(ab), /\| first \| on \| 2 \| 0\.5 \| 15 \| 3195 \| 1\.65 \|/);
559
+ });
560
+
561
+ it("--context-ab reads run logs only (no GitHub crawl)", async () => {
562
+ assert.equal(parseArgs(["--context-ab"]).contextAb, true);
563
+ const rep = await buildReport({ repo: "o/r", contextAb: true }, { localRuns: [summariseLog(log("on", "first"))], releaseTimes: [], gh: async () => { throw new Error("no network"); } });
564
+ assert.equal(rep.query.context_ab.first.on.n, 1);
565
+ });
566
+ });
@@ -96,7 +96,9 @@ remaining maintenance once no feature is pending; otherwise the rest ends `held
96
96
 
97
97
  Its first line is `batch: <id> (...)`. The batch state lives in `~/.forge/batches/<id>/` and every lane runs in its
98
98
  own session, so the batch survives this session: if the runner dies, run `forgedock orchestrate --resume <id>`
99
- (live lanes are re-adopted, nothing is launched twice). Change K of a running batch with
99
+ (live lanes are re-adopted, nothing is launched twice). The runner is hosted by the detached `forgedock schedd` daemon,
100
+ which resumes every daemon-hosted batch that has no report yet when it starts, so a closed session needs no manual
101
+ `--resume`; a batch started without the daemon still needs `forgedock orchestrate --resume <id>`. Change K of a running batch with
100
102
  `forgedock orchestrate --set-parallel <id> K`. The runner requeues `deferred` lanes by code (host busy after its
101
103
  retry-in, a usage limit holds admissions for that account (backend identity) until the reset, across every batch on
102
104
  the host; `forgedock limits show|clear <id>|--all` lists or drops holds; transient merge/push/idle failures back off; non-host
@@ -137,14 +139,58 @@ When a lane finishes (agent lanes; the batch runner applies the same rules itsel
137
139
  `**Parent**` and `**Depends on**` lines). Add them to the DAG, launch them, and let the parent's dependents
138
140
  wait on the sub-issues.
139
141
  - `merged` or `invalid`: start the next ready issue.
140
- - `needs-human`: an external action agents cannot take (credentials, spend, promotion, customer) or a product
141
- decision too critical for agents. Read the issue's last comment for the reason, and mark its dependents skipped. Only
142
+ - `needs-human` with a **recoverable code**: `ci.red`, `base.conflict` (merge conflict), `ci-pending` on a conflicting
143
+ PR, `contract.invalid` caused by an issue edited mid-run, and `builder.block_untyped` (an in-scope technical choice).
144
+ These are mechanical. **Do not ask for confirmation on them.** The batch runner recovers each one itself
145
+ (`orchestration.recovery.max_attempts`, default 1 per issue). It launches one recovery lane under the same lease, lane
146
+ and admission rules as any lane, with `run-issue --recover <code> [--recover-attempt n]` (core path only:
147
+ `--path full` rejects `--recover`). That lane:
148
+ - removes `needs-human`;
149
+ - resumes the open PR: merges the base, fixes the failed required checks from their logs, and pushes new commits only;
150
+ - re-queues the issue, keeping its dependents queued in the same batch.
151
+
152
+ Report what was recovered afterwards. A failed recovery stays `needs-human` with its evidence, exactly once (failed = `needs-human`, `ci-pending`, or a final `engine-error`/`deferred`; `invalid`, `decomposed` and `dependency-*` are not failures and never re-label), and there
153
+ is no second attempt beyond `max_attempts`.
154
+ - `needs-human` with a **human code** (`builder.blocked`: product-decision, spend, credentials, promotion, customer): an
155
+ external action agents cannot take, or a product decision too critical for agents. It is never auto-recovered. Read
156
+ the issue's last comment for the reason, mark its dependents skipped, and add it to the escalation list (step 4). Only
142
157
  explicit or logical edges skip a dependent; an issue that merely overlaps the same file keeps running.
158
+ - `needs-human` with a **terminal code** that is neither recoverable nor human (for example a `conformance.*` code, or
159
+ `contract.invalid` without a mid-run issue edit): the runner does not recover it and it is not an escalation. The
160
+ batch report lists it as `unrecovered: #N (<code>: <detail>)`, like a recovery that did not merge. Mark its dependents
161
+ skipped and leave the issue for a later run once its cause is fixed.
143
162
  - `engine-error`: relaunch it once.
144
- - `ci-pending` or `deferred`: leave it. A later `/orchestrate` resumes it.
163
+ - `ci-pending` (PR not conflicting) or `deferred`: leave it. A later `/orchestrate` resumes it.
164
+
165
+ **Operational decisions are yours.** Decide them without asking and keep going. Record each one as a
166
+ `<!-- FORGE:DECISION -->` comment (what, why, alternative) on the issue, or on the batch for batch-wide choices. The
167
+ runner posts its own decisions (recovery, relaunch, dependency requeue, sub-issues added). The decisions you take
168
+ yourself:
169
+ - **Recovery and retries:** relaunch, resume, fix-ci, base-sync, and stopping and relaunching a stuck lane.
170
+ - **Scheduling:** K changes under host pressure, ordering, adding logical `Depends on` edges, and adding newly filed or
171
+ decomposed sub-issues to the running batch.
172
+ - **Engine gaps:** file each engine-gap issue you discover and queue it.
173
+ - **In-scope technical choices** a builder escalates as `untyped` or ambiguous: pick the option most consistent with the
174
+ issue, the repo's conventions and prior merged decisions, and note the alternative.
175
+
176
+ Escalate only these:
177
+ - product direction (`product-decision`);
178
+ - spend;
179
+ - credentials;
180
+ - customer-facing actions;
181
+ - staging→main release or deploy, unless the operator approved it for the session.
182
+
183
+ Collect escalations into one question list and ask it once, at the end of the batch. Never ask per issue, and keep every
184
+ other lane running meanwhile.
145
185
 
146
186
  ## 4. Report
147
187
 
148
188
  When all lanes have finished, print one table: issue, outcome, PR, minutes (issues the pre-filter closed
149
- show `invalid (pre-filter)`). Then the `pre-filter:` line from step 2.2 (count closed and spend avoided), then
150
- list each `needs-human` or skipped issue with its one-line reason. Nothing else.
189
+ show `invalid (pre-filter)`). Then print:
190
+ 1. the `pre-filter:` line from step 2.2 (count closed and spend avoided);
191
+ 2. the runner's `recovered: #A (ci.red) #B (base.conflict)` line;
192
+ 3. its `unrecovered:` lines (mechanical codes whose recovery did not merge);
193
+ 4. each skipped issue with its one-line reason;
194
+ 5. one escalation list: the human-code `needs-human` issues only, asked once.
195
+
196
+ Nothing else.