@codyswann/lisa 4.87.10 → 4.87.12

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 (84) hide show
  1. package/all/copy-overwrite/scripts/review-evidence-event.mjs +202 -0
  2. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  3. package/dist/core/lisa-owned-hash-ledger.js +10 -0
  4. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  5. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  6. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  7. package/dist/core/upstream-evidence-manifest.js +11 -5
  8. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  9. package/package.json +4 -4
  10. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  11. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  12. package/plugins/lisa-agy/plugin.json +1 -1
  13. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  15. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  16. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  17. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-cdk-devin/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-cdk-devin/.devin-plugin/plugin.json +1 -1
  20. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-devin/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-devin/.devin-plugin/plugin.json +1 -1
  24. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  26. package/plugins/lisa-expo-agy/plugin.json +1 -1
  27. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-expo-devin/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-expo-devin/.devin-plugin/plugin.json +1 -1
  31. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  33. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  34. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-harper-fabric-devin/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-harper-fabric-devin/.devin-plugin/plugin.json +1 -1
  38. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  40. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  41. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-nestjs-devin/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-nestjs-devin/.devin-plugin/plugin.json +1 -1
  45. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  47. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  48. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-openclaw-devin/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-openclaw-devin/.devin-plugin/plugin.json +1 -1
  52. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  54. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  55. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-phaser-devin/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-phaser-devin/.devin-plugin/plugin.json +1 -1
  59. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  61. package/plugins/lisa-rails-agy/plugin.json +1 -1
  62. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-rails-devin/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-rails-devin/.devin-plugin/plugin.json +1 -1
  66. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  68. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  69. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-typescript-devin/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-typescript-devin/.devin-plugin/plugin.json +1 -1
  73. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  75. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  76. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-wiki-devin/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-wiki-devin/.devin-plugin/plugin.json +1 -1
  80. package/scripts/two-channel-couplings.json +1 -1
  81. package/typescript/copy-overwrite/.prettierignore +1 -0
  82. package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +196 -41
  83. package/typescript/create-only/.github/required-checks.json +2 -1
  84. package/typescript/create-only/.github/workflows/review-evidence.yml +43 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.87.10",
3
+ "version": "4.87.12",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.87.10",
3
+ "version": "4.87.12",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.87.10",
3
+ "version": "4.87.12",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.87.10",
3
+ "version": "4.87.12",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.87.10",
3
+ "version": "4.87.12",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -122,7 +122,7 @@
122
122
  "workflows": 26,
123
123
  "steps": 823,
124
124
  "couplings": 40,
125
- "inventory": 207
125
+ "inventory": 208
126
126
  },
127
127
  "counts": {
128
128
  "package-backed": 11,
@@ -75,6 +75,7 @@ scripts/check-skipped-required-checks.mjs
75
75
  scripts/check-state-classification.mjs
76
76
  scripts/check-third-party-action-pins.mjs
77
77
  scripts/check-third-party-review-evidence.mjs
78
+ scripts/review-evidence-event.mjs
78
79
  scripts/check-threshold-ratchet.mjs
79
80
  scripts/check-verification-coverage.mjs
80
81
  scripts/check-workflow-load-failures.mjs
@@ -1033,9 +1033,48 @@ export function loadDeclaration(rootDir) {
1033
1033
  assertEnforcementMode(declaration);
1034
1034
  assertSkipJobDeclarations(declaration.skip_job_declarations);
1035
1035
  assertEvidenceBearingChecks(declaration.evidence_bearing_checks ?? {});
1036
+ registrationContexts(declaration, {
1037
+ contexts: declaration.required_contexts,
1038
+ });
1036
1039
  return declaration;
1037
1040
  }
1038
1041
 
1042
+ /**
1043
+ * Validate reasoned registration deferrals against the set actually judged.
1044
+ * This only removes absence/registration observations. GitHub still requires
1045
+ * each deferred check to pass; evidence-bearing checks can never be deferred.
1046
+ * @param {object} declaration - Reviewed repository declaration.
1047
+ * @param {{contexts: string[]}} requiredSet - Selected base branch or union.
1048
+ * @returns {string[]} Contexts whose registration must still be observed.
1049
+ */
1050
+ export function registrationContexts(declaration, requiredSet) {
1051
+ const deferred = declaration.registration_deferred;
1052
+ if (deferred === undefined) return requiredSet.contexts;
1053
+ if (
1054
+ deferred === null ||
1055
+ typeof deferred !== "object" ||
1056
+ Array.isArray(deferred)
1057
+ )
1058
+ throw new Error(
1059
+ "check-skipped-required-checks: `registration_deferred` must be an object mapping required check names to non-empty reasons."
1060
+ );
1061
+ for (const [name, reason] of Object.entries(deferred)) {
1062
+ if (typeof reason !== "string" || reason.trim() === "")
1063
+ throw new Error(
1064
+ `check-skipped-required-checks: registration_deferred entry ${JSON.stringify(name)} needs a non-empty reason.`
1065
+ );
1066
+ if (!requiredSet.contexts.includes(name))
1067
+ throw new Error(
1068
+ `check-skipped-required-checks: registration_deferred entry ${JSON.stringify(name)} is not in the selected required set. Refusing to judge this declaration.`
1069
+ );
1070
+ if (Object.hasOwn(declaration.evidence_bearing_checks ?? {}, name))
1071
+ throw new Error(
1072
+ `check-skipped-required-checks: registration_deferred entry ${JSON.stringify(name)} is evidence-bearing. Review evidence must still be waited for and judged.`
1073
+ );
1074
+ }
1075
+ return requiredSet.contexts.filter(name => !Object.hasOwn(deferred, name));
1076
+ }
1077
+
1039
1078
  /**
1040
1079
  * Refuses a declaration that omits one of the guard's load-bearing snapshots.
1041
1080
  *
@@ -1781,7 +1820,7 @@ export function evaluateAbsentRequiredChecks(
1781
1820
  declaration,
1782
1821
  options.base ?? { base: null, source: null }
1783
1822
  );
1784
- const required = requiredSet.contexts;
1823
+ const required = registrationContexts(declaration, requiredSet);
1785
1824
  const judgedFor =
1786
1825
  requiredSet.source === "branch"
1787
1826
  ? ` Judged against the contexts required on base \`${requiredSet.base}\`.`
@@ -1848,12 +1887,12 @@ function normaliseBaseRef(ref) {
1848
1887
  * Resolves the branch the pull request under inspection merges INTO.
1849
1888
  *
1850
1889
  * Most-explicit-first, the same order {@link resolvePullRequestNumber} uses:
1851
- * an operator's `--base`, then `GITHUB_BASE_REF` (set by Actions on every
1852
- * `pull_request` and `pull_request_target` run), then the event payload's
1853
- * `pull_request.base.ref`. Offline on purpose — every source is a fact this run
1854
- * already holds, so selecting a set costs no API call, no token scope and no
1855
- * rate limit, and works the same on a fork pull request. Returning null is a
1856
- * real answer: the caller falls back to the union and SAYS so.
1890
+ * an operator's `--base`, then current metadata for the inspected PR, then
1891
+ * `GITHUB_BASE_REF` and the event payload's `pull_request.base.ref`. A rerun
1892
+ * replays its old event even after retargeting. The caller supplies metadata
1893
+ * from its existing head observation, so this pure selection adds no API call.
1894
+ * When current metadata is unavailable, the identity-checked event remains a
1895
+ * fallback. Returning null selects the reported union fallback.
1857
1896
  *
1858
1897
  * The run's own base describes the run's own pull request, and nothing else.
1859
1898
  * When `options.pr` names the pull request being inspected, GITHUB_BASE_REF
@@ -1867,13 +1906,21 @@ function normaliseBaseRef(ref) {
1867
1906
  *
1868
1907
  * @param {ReadonlyArray<string>} argv - CLI arguments
1869
1908
  * @param {NodeJS.ProcessEnv} [env] - Environment, injectable for tests
1870
- * @param {{pr?: string, repo?: string}} [options] - The pull request and repository being inspected, when known
1909
+ * @param {{pr?: string, repo?: string, currentPullRequest?: object}} [options] - Inspected identity and its current metadata, when available
1871
1910
  * @returns {{base: string|null, source: string|null, ignoredEventBase?: string}} The base and where it came from
1872
1911
  */
1873
1912
  export function resolveBaseRef(argv, env = process.env, options = {}) {
1874
1913
  const explicit = normaliseBaseRef(readFlagValue(argv, "--base"));
1875
1914
  if (explicit !== null) return { base: explicit, source: "--base" };
1876
1915
 
1916
+ if (
1917
+ isInspectedPullRequest(options.currentPullRequest, options.pr, options.repo)
1918
+ ) {
1919
+ const current = normaliseBaseRef(options.currentPullRequest.baseRefName);
1920
+ if (current !== null)
1921
+ return { base: current, source: "current PR metadata" };
1922
+ }
1923
+
1877
1924
  const eventPath = env.GITHUB_EVENT_PATH;
1878
1925
  const payload =
1879
1926
  eventPath !== undefined && eventPath !== "" && existsSync(eventPath)
@@ -1897,6 +1944,34 @@ export function resolveBaseRef(argv, env = process.env, options = {}) {
1897
1944
  : { base: null, source: null, ignoredEventBase: found.base };
1898
1945
  }
1899
1946
 
1947
+ /**
1948
+ * Match both native metadata identities before trusting its current base.
1949
+ * @param {object|undefined} current - Native PR metadata
1950
+ * @param {string|number|undefined} pr - Inspected PR
1951
+ * @param {string|undefined} repo - Inspected repository
1952
+ * @returns {boolean} Whether this is the requested PR in the requested repo
1953
+ */
1954
+ function isInspectedPullRequest(current, pr, repo) {
1955
+ if (
1956
+ !current ||
1957
+ pr === undefined ||
1958
+ repo === undefined ||
1959
+ String(current.number) !== String(pr) ||
1960
+ typeof current.url !== "string"
1961
+ )
1962
+ return false;
1963
+ try {
1964
+ const url = new URL(current.url);
1965
+ return (
1966
+ ["https:", "http:"].includes(url.protocol) &&
1967
+ url.pathname.replace(/\/$/u, "").toLowerCase() ===
1968
+ `/${repo}/pull/${pr}`.toLowerCase()
1969
+ );
1970
+ } catch {
1971
+ return false;
1972
+ }
1973
+ }
1974
+
1900
1975
  /**
1901
1976
  * True when the inspected repository is the one this run's event belongs to.
1902
1977
  *
@@ -4051,6 +4126,45 @@ export function resolveHeadSha(pr, repo) {
4051
4126
  }
4052
4127
  }
4053
4128
 
4129
+ /**
4130
+ * Observe head and current base together using the existing PR metadata read.
4131
+ * A failed metadata read retains the old head probe, so an independently
4132
+ * readable head can still use the declared event/union base fallbacks.
4133
+ * @param {string|number} pr - Inspected PR
4134
+ * @param {string|undefined} repo - Inspected repository
4135
+ * @returns {{headSha: string|undefined, repo?: string, pullRequest?: object}} One observation
4136
+ */
4137
+ function observePullRequest(pr, repo) {
4138
+ const slug = resolveRepoSlug(repo);
4139
+ if (slug === undefined) return { headSha: undefined };
4140
+ try {
4141
+ const current = JSON.parse(
4142
+ boundedExecFileSync(
4143
+ "gh",
4144
+ [
4145
+ "pr",
4146
+ "view",
4147
+ String(pr),
4148
+ "--repo",
4149
+ slug,
4150
+ "--json",
4151
+ "number,url,headRefOid,baseRefName",
4152
+ ],
4153
+ { encoding: "utf8" }
4154
+ )
4155
+ );
4156
+ if (
4157
+ isInspectedPullRequest(current, pr, slug) &&
4158
+ typeof current.headRefOid === "string" &&
4159
+ /^[a-f0-9]{40}$/iu.test(current.headRefOid)
4160
+ )
4161
+ return { headSha: current.headRefOid, repo: slug, pullRequest: current };
4162
+ } catch {
4163
+ // Current metadata is unavailable; preserve the existing head-only probe.
4164
+ }
4165
+ return { headSha: resolveHeadSha(pr, slug), repo: slug };
4166
+ }
4167
+
4054
4168
  /**
4055
4169
  * How a finding cites the commit its evidence was read at.
4056
4170
  *
@@ -4386,7 +4500,7 @@ function sleepSync(ms) {
4386
4500
  * @param {object} declaration - The per-repo declaration
4387
4501
  * @param {string|number} pr - Pull request number
4388
4502
  * @param {string|undefined} repo - `OWNER/NAME`, or undefined
4389
- * @param {{timeoutSeconds?: number, intervalSeconds?: number, fetch?: Function, now?: Function, sleep?: Function, headSha?: Function, requiredSet?: object, substitutes?: Set<string>, substituteHead?: string}} [options] -
4503
+ * @param {{timeoutSeconds?: number, intervalSeconds?: number, fetch?: Function, now?: Function, sleep?: Function, headSha?: Function, observe?: Function, initialObservation?: object, requiredSet?: object, requiredSetFor?: Function, substitutes?: Set<string>, substituteHead?: string}} [options] -
4390
4504
  * Injection seams; the suite drives the whole loop through them so nothing
4391
4505
  * here has to sleep in real time
4392
4506
  * @returns {{checks: object[], settled: boolean, headSha: string|undefined, registration: object}} Rows, reviewer completion, head and required-registration observations
@@ -4411,16 +4525,20 @@ export function fetchSettledChecks(declaration, pr, repo, options = {}) {
4411
4525
  );
4412
4526
  });
4413
4527
  const resolveHead = options.headSha ?? resolveHeadSha;
4528
+ const observe =
4529
+ options.observe ??
4530
+ ((request, slug) => ({ headSha: resolveHead(request, slug) }));
4414
4531
  const clock = options.now ?? Date.now;
4415
4532
  const sleep = options.sleep ?? sleepSync;
4416
4533
  const started = clock();
4417
4534
  const deadline = started + timeoutMs;
4418
- const requiredSet =
4535
+ const defaultRequiredSet =
4419
4536
  options.requiredSet ??
4420
4537
  selectRequiredContexts(declaration, { base: null, source: null });
4421
- const missing = checks =>
4422
- requiredSet.contexts.filter(
4423
- name => !checks.some(check => check.name === name)
4538
+ const selectSet = options.requiredSetFor ?? (() => defaultRequiredSet);
4539
+ const missing = observed =>
4540
+ registrationContexts(declaration, observed.requiredSet).filter(
4541
+ name => !observed.checks.some(check => check.name === name)
4424
4542
  );
4425
4543
  // A substitute is useful only at the head it was actually read for. Pending
4426
4544
  // rows still wait, and a missing required reviewer still needs a real row.
@@ -4435,29 +4553,41 @@ export function fetchSettledChecks(declaration, pr, repo, options = {}) {
4435
4553
  );
4436
4554
  });
4437
4555
 
4438
- // Bracket every roster read with head reads. `gh pr checks` targets the PR's
4439
- // current head but does not return the SHA it used, and the permission-light
4440
- // fallback resolves the head internally. A push between either route and a
4441
- // later provenance lookup used to make findings cite a commit whose checks
4442
- // were never evaluated. Matching observations on both sides bind the roster
4443
- // to that head; a mismatch is discarded and re-read, never reported.
4556
+ // Bracket checks with coherent head/base observations. The early substitute
4557
+ // read can supply the first observation without another metadata request.
4558
+ // A push or retarget during the read is discarded within this same deadline.
4559
+ let initial = options.initialObservation;
4444
4560
  const snapshot = () => {
4445
- const before = resolveHead(pr, repo);
4446
- if (before === undefined) {
4561
+ const before = initial?.headSha === undefined ? observe(pr, repo) : initial;
4562
+ initial = undefined;
4563
+ const beforeSet = selectSet(before);
4564
+ registrationContexts(declaration, beforeSet);
4565
+ if (before.headSha === undefined) {
4447
4566
  return {
4448
4567
  checks: [],
4449
4568
  headSha: undefined,
4450
4569
  stable: false,
4451
4570
  after: undefined,
4571
+ requiredSet: beforeSet,
4572
+ baseBefore: beforeSet.base,
4573
+ baseAfter: undefined,
4452
4574
  };
4453
4575
  }
4454
- const checks = read(pr, repo, before);
4455
- const after = resolveHead(pr, repo);
4576
+ const checks = read(pr, repo, before.headSha);
4577
+ const after = observe(pr, repo);
4578
+ const afterSet = selectSet(after);
4579
+ registrationContexts(declaration, afterSet);
4456
4580
  return {
4457
4581
  checks,
4458
- headSha: before,
4459
- stable: after !== undefined && before === after,
4460
- after,
4582
+ headSha: before.headSha,
4583
+ stable:
4584
+ after.headSha !== undefined &&
4585
+ before.headSha === after.headSha &&
4586
+ beforeSet.base === afterSet.base,
4587
+ after: after.headSha,
4588
+ requiredSet: afterSet,
4589
+ baseBefore: beforeSet.base,
4590
+ baseAfter: afterSet.base,
4461
4591
  };
4462
4592
  };
4463
4593
 
@@ -4465,20 +4595,29 @@ export function fetchSettledChecks(declaration, pr, repo, options = {}) {
4465
4595
  while (
4466
4596
  (!observed.stable ||
4467
4597
  !evidenceReady(observed) ||
4468
- missing(observed.checks).length > 0) &&
4598
+ missing(observed).length > 0) &&
4469
4599
  clock() < deadline
4470
4600
  ) {
4471
4601
  sleep(Math.min(intervalMs, deadline - clock()));
4472
4602
  observed = snapshot();
4473
4603
  }
4474
4604
  if (!observed.stable) {
4605
+ if (observed.headSha !== undefined && observed.headSha === observed.after)
4606
+ throw new Error(
4607
+ `check-skipped-required-checks: the selected pull request base changed while its checks were being read (${observed.baseBefore ?? "unresolved"} -> ${observed.baseAfter ?? "unresolved"}). Refusing to judge checks against another base; re-read the pull request after retargeting settles.`
4608
+ );
4475
4609
  throw new Error(
4476
4610
  `check-skipped-required-checks: the pull request head changed while its checks were being read (${observed.headSha ?? "unresolved"} -> ${observed.after ?? "unresolved"}). Refusing to attach one commit's review evidence to another; re-read the pull request after the push settles.`
4477
4611
  );
4478
4612
  }
4479
4613
  const elapsedSeconds = Math.max(0, clock() - started) / 1000;
4480
- const unregistered = missing(observed.checks);
4481
- const total = requiredSet.contexts.length;
4614
+ const unregistered = missing(observed);
4615
+ const total = registrationContexts(declaration, observed.requiredSet).length;
4616
+ const deferred = Object.keys(declaration.registration_deferred ?? {});
4617
+ const deferralNote =
4618
+ deferred.length === 0
4619
+ ? ""
4620
+ : ` (${deferred.length} deferred by declaration: ${deferred.join(", ")})`;
4482
4621
  const present = total - unregistered.length;
4483
4622
  const expired = unregistered.length > 0;
4484
4623
  const settled = checksSettled(declaration, observed.checks);
@@ -4496,9 +4635,10 @@ export function fetchSettledChecks(declaration, pr, repo, options = {}) {
4496
4635
  total,
4497
4636
  missing: unregistered,
4498
4637
  expired,
4638
+ ...(deferred.length === 0 ? {} : { deferred }),
4499
4639
  line: expired
4500
- ? `Required registration wait EXPIRED: the gate stopped waiting after ${elapsedSeconds}s; ${present} of ${total} required contexts observed; missing: ${unregistered.join(", ")}. RE-RUN this job before treating this as a finding about the change.`
4501
- : `Required registration settled after ${elapsedSeconds}s; ${present} of ${total} required contexts observed.`,
4640
+ ? `Required registration wait EXPIRED: the gate stopped waiting after ${elapsedSeconds}s; ${present} of ${total} required contexts observed${deferralNote}; missing: ${unregistered.join(", ")}. RE-RUN this job before treating this as a finding about the change.`
4641
+ : `Required registration settled after ${elapsedSeconds}s; ${present} of ${total} required contexts observed${deferralNote}.`,
4502
4642
  };
4503
4643
  return {
4504
4644
  checks: observed.checks,
@@ -4506,6 +4646,7 @@ export function fetchSettledChecks(declaration, pr, repo, options = {}) {
4506
4646
  headSha: observed.headSha,
4507
4647
  registration,
4508
4648
  evidence,
4649
+ requiredSet: observed.requiredSet,
4509
4650
  };
4510
4651
  }
4511
4652
 
@@ -4701,7 +4842,7 @@ function readSecondsFlag(argv, name, fallback) {
4701
4842
  *
4702
4843
  * @param {ReadonlyArray<string>} argv - CLI arguments
4703
4844
  * @param {object} declaration - The per-repo declaration
4704
- * @param {{trustRequiredContexts?: boolean, env?: NodeJS.ProcessEnv, fetch?: Function, probeBranch?: Function, now?: Function, sleep?: Function, headSha?: Function, reviewActivity?: Function}} [options] -
4845
+ * @param {{trustRequiredContexts?: boolean, env?: NodeJS.ProcessEnv, fetch?: Function, probeBranch?: Function, now?: Function, sleep?: Function, headSha?: Function, observe?: Function, reviewActivity?: Function}} [options] -
4705
4846
  * Injection seams for the suite
4706
4847
  * @returns {{pr: string|undefined, prSource: string|null, headSha: string|undefined, checked: number, violations: object[], settled: boolean, refusal: {kind: string, reason: string}|null}|undefined} The inspection
4707
4848
  */
@@ -4755,19 +4896,30 @@ export function inspectVacuity(argv, declaration, options = {}) {
4755
4896
  const declaredReviewers = Object.keys(
4756
4897
  declaration.evidence_bearing_checks ?? {}
4757
4898
  );
4758
- const earlyHead = requireReviewEvidence
4759
- ? (options.headSha ?? resolveHeadSha)(pr, repo)
4899
+ const injectedHead = options.headSha;
4900
+ const observe =
4901
+ options.observe ??
4902
+ (injectedHead
4903
+ ? (request, slug) => ({ headSha: injectedHead(request, slug) })
4904
+ : observePullRequest);
4905
+ const earlyObservation = requireReviewEvidence
4906
+ ? observe(pr, repo)
4760
4907
  : undefined;
4908
+ const earlyHead = earlyObservation?.headSha;
4761
4909
  const earlySubstitutes = readSubstitutes(earlyHead);
4762
4910
  const allStoodIn =
4763
4911
  declaredReviewers.length > 0 &&
4764
4912
  declaredReviewers.every(name => earlySubstitutes.has(name));
4765
- // Select once: polling and the absence verdict must judge the same base.
4766
- // resolveBaseRef retains its existing contract; live retargeting is #4487.
4767
- const requiredSet = selectRequiredContexts(
4768
- declaration,
4769
- resolveBaseRef(argv, options.env ?? process.env, { pr, repo })
4770
- );
4913
+ // Registration and absence share the selection returned with stable checks.
4914
+ const requiredSetFor = observed =>
4915
+ selectRequiredContexts(
4916
+ declaration,
4917
+ resolveBaseRef(argv, options.env ?? process.env, {
4918
+ pr,
4919
+ repo: repo ?? observed.repo,
4920
+ currentPullRequest: observed.pullRequest,
4921
+ })
4922
+ );
4771
4923
  let read;
4772
4924
  try {
4773
4925
  read = fetchSettledChecks(declaration, pr, repo, {
@@ -4785,13 +4937,16 @@ export function inspectVacuity(argv, declaration, options = {}) {
4785
4937
  now: options.now,
4786
4938
  sleep: options.sleep,
4787
4939
  headSha: options.headSha,
4788
- requiredSet,
4940
+ observe,
4941
+ initialObservation: earlyObservation,
4942
+ requiredSetFor,
4789
4943
  substitutes: allStoodIn ? earlySubstitutes : undefined,
4790
4944
  substituteHead: earlyHead,
4791
4945
  });
4792
4946
  } catch (error) {
4793
4947
  return refused(vacuityRefusal({ declaration, pr, error }));
4794
4948
  }
4949
+ const requiredSet = read.requiredSet;
4795
4950
 
4796
4951
  const refusal = vacuityRefusal({ declaration, pr, checks: read.checks });
4797
4952
  if (refusal !== null) {
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "_readme": [
3
+ "Optional `registration_deferred` (#4622) maps exact required context names to non-empty reasons when those jobs register after the bounded five-minute wait introduced by #4527. The registration/absence probe excludes only these names; GitHub still requires them to pass before merge. Entries outside the selected base branch's required set, malformed reasons, and evidence-bearing checks are refused. Omit the key for unchanged behavior. A slow reviewer is never deferred: the review-evidence workflow re-evaluates on its terminal check_run or status event, using default-branch code and a fresh current-head read. This workflow is create-only; existing hosts must adopt the event job explicitly to receive the wake-up behavior.",
3
4
  "Two REVIEWED SNAPSHOTS that scripts/check-skipped-required-checks.mjs makes police each other.",
4
5
  "Neither is derivable from this repository: `required_contexts` lives in a GitHub ruleset that humans edit in an admin console, and the skip_jobs token -> job name map lives in Lisa's quality.yml, which is not vendored here.",
5
6
  "Why it matters: GitHub counts a SKIPPED required status check as SATISFIED. A job named in `skip_jobs` still reports green against its required context having run zero steps, so the merge gate can never be red and therefore can never block anything.",
@@ -18,7 +19,7 @@
18
19
  "IT REFUSES RATHER THAN REPORTING AN EMPTY INSPECTION. An unresolvable pull request, a `gh` it could not read, a roster with no checks in it, or an empty `evidence_bearing_checks` all print the same \"nothing vacuous here\" that a genuinely clean run prints — so the guard fails instead and names which of the four it hit (`vacuity_pr_unresolved`, `vacuity_checks_unreadable`, `vacuity_no_checks_reported`, `vacuity_none_declared`). A refusal is NOT a finding: NOBODY LOOKED and THE REVIEW WAS FAKE are opposite facts, and a single red job would otherwise report them identically.",
19
20
  "`evidence_bearing_checks` names the checks whose GREEN is supposed to mean something reviewed the code. Use `{}` to accept the shipped description vocabulary, or add `proof` / `no_work` arrays to extend it — extensions ADD to the defaults, they do not replace them. Undeclared checks are never examined, because most CI jobs ship an empty description and flagging them all would bury the one finding that matters.",
20
21
  "This arm REPORTS AND NEVER BLOCKS, in every enforcement mode. A review bot can go hollow because an org-wide SPENDING CAP was hit, and a gate that reddens every PR on a billing state is worse than the one it criticises. What it changes is what you may CLAIM: a PR carrying a `vacuous_required_check` finding has not been shown to be reviewed, so do not record it as reviewed. Once you have made the governance call, `--fail-on-vacuous` is the supported way to ask for an exit code — add it to the command in `review-evidence.yml`, instead of writing a wrapper that reads `--json`.",
21
- "PER-BRANCH SETS (CodySwannGT/lisa#4484). `required_contexts` is the UNION across every protected branch, which is the wrong set for judging which required contexts did NOT REPORT on one pull request: a context only `dev` requires was reported absent on every staging-base pull request. Optionally add `required_contexts_by_branch: { \"<branch>\": [ ...contexts ] }`, each set transcribed byte for byte from `gh api repos/OWNER/NAME/rules/branches/<branch> --jq '.[] | select(.type==\"required_status_checks\") | .parameters.required_status_checks[].context'` (read access only). The absent arm then judges a pull request against its BASE branch's set (`--base`, else `GITHUB_BASE_REF`, else the event payload's `pull_request.base.ref`). Without a set for that base it falls back to the union — same findings as before — and says so as `required_contexts_branch_fallback`; every run prints which set it used. Every per-branch context must also appear in `required_contexts`, and the one `baseline_fetched_at` stamp covers both."
22
+ "PER-BRANCH SETS (CodySwannGT/lisa#4484). `required_contexts` is the UNION across every protected branch, which is the wrong set for judging which required contexts did NOT REPORT on one pull request: a context only `dev` requires was reported absent on every staging-base pull request. Optionally add `required_contexts_by_branch: { \"<branch>\": [ ...contexts ] }`, each set transcribed byte for byte from `gh api repos/OWNER/NAME/rules/branches/<branch> --jq '.[] | select(.type==\"required_status_checks\") | .parameters.required_status_checks[].context'` (read access only). The absent arm judges the PR against its current base (`--base`, else current metadata from the existing PR head read). If current metadata is unreadable it uses only a matching PR/repository event (`GITHUB_BASE_REF`, else `pull_request.base.ref`). A rerun replays the old event, so matching identity alone does not establish the current base. Head/base observations bracket each check read; a retarget retries within the same settlement deadline, and registration and absence share the resulting selection. Without a set for that base it falls back to the union — same findings as before — and says so as `required_contexts_branch_fallback`; every run prints which set it used. Every per-branch context must also appear in `required_contexts`, and the one `baseline_fetched_at` stamp covers both."
22
23
  ],
23
24
  "enforcement": "warn",
24
25
  "ruleset": {
@@ -122,6 +122,9 @@ name: 🕵️ Review Evidence
122
122
  on:
123
123
  pull_request:
124
124
  types: [opened, synchronize, reopened, ready_for_review]
125
+ check_run:
126
+ types: [completed]
127
+ status:
125
128
  workflow_dispatch:
126
129
  inputs:
127
130
  pr:
@@ -147,11 +150,50 @@ permissions:
147
150
  pull-requests: read
148
151
 
149
152
  concurrency:
150
- group: review-evidence-${{ github.event.pull_request.number || inputs.pr || github.ref }}
153
+ group: review-evidence-${{ github.event_name }}-${{ github.event.pull_request.number || inputs.pr || github.event.check_run.head_sha || github.event.sha || github.ref }}-${{ github.event.check_run.name || github.event.context }}-${{ github.event.check_run.app.id || github.event.creator.id }}
151
154
  cancel-in-progress: true
152
155
 
153
156
  jobs:
157
+ # Reviewer completion can outlast the bounded pull_request run. Dispatch the
158
+ # normal gate; the event is a wake-up and never proof that a review occurred.
159
+ # check_run requires this file on the default branch and GitHub suppresses
160
+ # Actions-created suites/heads; status supports legacy reviewer statuses.
161
+ recheck:
162
+ name: 🕵️ Re-evaluate a completed reviewer
163
+ if: github.event_name == 'check_run' || (github.event_name == 'status' && github.event.state != 'pending')
164
+ runs-on: ubuntu-latest
165
+ timeout-minutes: 5
166
+ permissions:
167
+ contents: read
168
+ checks: read
169
+ statuses: read
170
+ pull-requests: read
171
+ actions: write
172
+ steps:
173
+ # No head ref: never execute PR files with the dispatch token.
174
+ - name: 📥 Checkout default branch
175
+ uses: actions/checkout@v6
176
+ with:
177
+ persist-credentials: false
178
+ - name: 🔧 Setup Node.js
179
+ uses: actions/setup-node@v6
180
+ with:
181
+ package-manager-cache: false
182
+ node-version: ${{ vars.NODE_VERSION || '24.21.0' }}
183
+ - name: 🕵️ Dispatch the gate for current declared reviewer heads
184
+ env:
185
+ GH_TOKEN: ${{ github.token }}
186
+ run: |
187
+ set -eu
188
+ script=scripts/review-evidence-event.mjs
189
+ if [ ! -f "$script" ]; then
190
+ echo "::error::Reviewer event resolver missing; no gate was dispatched."
191
+ exit 1
192
+ fi
193
+ node "$script" --dispatch
194
+
154
195
  vacuity:
196
+ if: github.event_name != 'check_run' && github.event_name != 'status'
155
197
  # DECLARATIVE, NOT A QUESTION (#3917). This was `🕵️ Did the required review
156
198
  # checks do any work?`, and an unsatisfied verdict exits non-zero, so the
157
199
  # row rendered `Did the required review checks do any work? fail`. A yes/no