@codyswann/lisa 3.35.1 → 3.37.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 (61) hide show
  1. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  2. package/dist/core/lisa-owned-hash-ledger.js +4 -0
  3. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  4. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  5. package/dist/core/upstream-evidence-manifest.js +10 -4
  6. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  7. package/expo/create-only/.github/workflows/maestro-e2e.yml +27 -0
  8. package/expo/create-only/.github/workflows/nightly-e2e-health.yml +27 -0
  9. package/package.json +1 -1
  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-copilot/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  22. package/plugins/lisa-expo-agy/plugin.json +1 -1
  23. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  27. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  28. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  32. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  33. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  37. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  38. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  42. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  43. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  47. package/plugins/lisa-rails-agy/plugin.json +1 -1
  48. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  53. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  58. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  60. package/typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs +317 -18
  61. package/typescript/copy-overwrite/scripts/nightly-e2e-suites.schema.json +5 -0
@@ -89,7 +89,7 @@ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
89
89
  * rather than running a contract neither half agrees on. See §8 of
90
90
  * `docs/nightly-e2e-gate.md` for what counts as major / minor / patch.
91
91
  */
92
- export const NIGHTLY_E2E_CONTRACT_VERSION = "1.3.0";
92
+ export const NIGHTLY_E2E_CONTRACT_VERSION = "1.4.0";
93
93
 
94
94
  /**
95
95
  * The conclusions that constitute a verdict about the code.
@@ -140,6 +140,65 @@ export const SUITE_STATES = Object.freeze({
140
140
  */
141
141
  export const INCOMPLETE_EVIDENCE_REASON = "incomplete_run";
142
142
 
143
+ // ---------------------------------------------------------------------------
144
+ // SUITE SCOPE — "did this run test the WHOLE suite?" (rows 36-38)
145
+ // ---------------------------------------------------------------------------
146
+ //
147
+ // Row 26 asks whether every JOB ran. It cannot see inside a job, so it cannot
148
+ // see the other way a green run proves nothing: a job that ran, passed, and
149
+ // tested a hand-picked slice.
150
+ //
151
+ // Measured 2026-08-18. AcmeOrgB/frontend's only recent `success` ran 4 of ~80
152
+ // flows under `ios_include_tags: smoke`. AcmeOrgD/frontend's REQUIRED merge gate
153
+ // was, at the time of writing, satisfied by run 32120016803: `maestro-ios-report`
154
+ // green, Android skipped, and the run's own published count reading
155
+ // `maestro-ios-flowcount-7`. Seven flows cleared a merge gate for a suite of
156
+ // eighty. `gh run list` renders that identically to a full green, which is how a
157
+ // reader — and this gate — concludes a suite "has been green recently" when it
158
+ // never has.
159
+ //
160
+ // TWO independent signals, and BOTH are required rather than either:
161
+ //
162
+ // 1. SCOPE (the run's own recorded inputs). `maestro-native-e2e.yml` publishes
163
+ // `maestro-<platform>-scope-full` or `maestro-<platform>-scope-filtered`
164
+ // from the tag inputs it was actually given. This is the definitive signal
165
+ // — it reads the filter itself rather than its consequences — but it only
166
+ // exists on runs from a Lisa version that publishes it, so it is ABSENT on
167
+ // every historical run and on any suite that is not maestro.
168
+ // 2. COUNT (executed flows). `maestro-<platform>-flowcount-<N>` has shipped for
169
+ // longer and is the backstop: it catches a run narrowed by any mechanism at
170
+ // all — a tag filter, a `flows_dir` override, a hand-edited flow list — and
171
+ // it catches the runs signal 1 cannot see.
172
+ //
173
+ // Both travel as ARTIFACT NAMES, read from the artifacts LIST. That is one
174
+ // cheap API call with no download and no zip reader, and — the property that
175
+ // makes it usable at all — an artifact's NAME outlives its bytes, so an expired
176
+ // artifact still answers "how many flows did that night run?". The header of
177
+ // `nightly-e2e-health.yml` says this gate reads no artifacts; that meant no
178
+ // artifact CONTENT, and it now says so explicitly.
179
+
180
+ /** A run positively recorded as narrowed. Absence of evidence, not failure. */
181
+ export const FILTERED_RUN_REASON = "filtered_run";
182
+
183
+ /** A run that executed fewer flows than the suite declared it must. */
184
+ export const FLOW_SHORTFALL_REASON = "flow_shortfall";
185
+
186
+ /** `min_flows` was declared and the run published nothing to check it against. */
187
+ export const SCOPE_UNREADABLE_REASON = "scope_unreadable";
188
+
189
+ /**
190
+ * `maestro-<platform>-flowcount-<N>`, as published by `maestro-native-e2e.yml`.
191
+ *
192
+ * Anchored at both ends for the reason `job_pattern` is: unanchored, this is a
193
+ * substring test, and `maestro-ios-flowcount-7-retry` would be read as 7.
194
+ */
195
+ export const FLOW_COUNT_ARTIFACT_PATTERN =
196
+ /^maestro-(?<platform>[a-z0-9]+)-flowcount-(?<count>\d+)$/;
197
+
198
+ /** `maestro-<platform>-scope-<full|filtered>`, same publisher, same anchoring. */
199
+ export const SCOPE_ARTIFACT_PATTERN =
200
+ /^maestro-(?<platform>[a-z0-9]+)-scope-(?<scope>full|filtered)$/;
201
+
143
202
  /** The label that identifies a tracking issue this reporter owns. */
144
203
  export const TRACKING_ISSUE_LABEL = "nightly-e2e";
145
204
 
@@ -355,6 +414,7 @@ const SUITE_KEYS = Object.freeze(
355
414
  "required_sha",
356
415
  "first_seen",
357
416
  "grace_days",
417
+ "min_flows",
358
418
  ])
359
419
  );
360
420
 
@@ -555,6 +615,18 @@ export function validateSuites(raw) {
555
615
  );
556
616
  }
557
617
  }
618
+ // Row 37 — the executed-flow floor. A floor of 0 or a fractional one is
619
+ // rejected rather than coerced: `min_flows: 0` reads as "a floor is
620
+ // declared" and enforces nothing, which is the shape of every control that
621
+ // reports success because it did nothing.
622
+ if (entry.min_flows !== undefined) {
623
+ const floor = entry.min_flows;
624
+ if (!Number.isInteger(floor) || floor < 1) {
625
+ throw new GateConfigError(
626
+ `${where}: \`min_flows\` must be an integer >= 1 — the number of flows this suite must execute before its green counts as evidence. \`0\` would declare a floor and enforce nothing. Read from the \`maestro-<platform>-flowcount-<N>\` artifact the suite publishes; once declared, a run that publishes no count BLOCKS rather than passing unchecked.`
627
+ );
628
+ }
629
+ }
558
630
  if (entry.required_sha !== undefined) {
559
631
  if (
560
632
  typeof entry.required_sha !== "string" ||
@@ -601,6 +673,122 @@ export function stateForConclusion(conclusion) {
601
673
  return SUITE_STATES.unknown;
602
674
  }
603
675
 
676
+ /**
677
+ * Reads the scope markers a run published, from its artifact NAMES.
678
+ *
679
+ * Pure, so the parsing is provable without a network. `readable: false` is the
680
+ * "we could not ask" case and is deliberately distinct from an empty reading —
681
+ * a run that published no markers and a run whose artifact list would not load
682
+ * are different facts, and only one of them is the suite's fault.
683
+ *
684
+ * @param {ReadonlyArray<{name?: string}>|null} artifacts - The artifacts list, or null when unreadable
685
+ * @returns {{readable: boolean, filtered: ReadonlyArray<string>, counts: Readonly<Record<string, number>>, totalFlows: number|null}} What the run recorded about its own scope
686
+ */
687
+ export function readSuiteScope(artifacts) {
688
+ if (!Array.isArray(artifacts)) {
689
+ return Object.freeze({
690
+ readable: false,
691
+ filtered: Object.freeze([]),
692
+ counts: Object.freeze({}),
693
+ totalFlows: null,
694
+ });
695
+ }
696
+ const filtered = [];
697
+ const counts = {};
698
+ for (const artifact of artifacts) {
699
+ const name = typeof artifact?.name === "string" ? artifact.name : "";
700
+ const scope = SCOPE_ARTIFACT_PATTERN.exec(name);
701
+ if (scope && scope.groups.scope === "filtered") {
702
+ filtered.push(scope.groups.platform);
703
+ continue;
704
+ }
705
+ const count = FLOW_COUNT_ARTIFACT_PATTERN.exec(name);
706
+ if (count) {
707
+ // MAX, never sum, when one platform somehow publishes twice: a re-uploaded
708
+ // count is the same arm counted again, and summing it would inflate the
709
+ // suite past its own floor. Taking the larger of two readings of one arm
710
+ // is the reading that cannot invent flows.
711
+ const value = Number(count.groups.count);
712
+ const platform = count.groups.platform;
713
+ counts[platform] = Math.max(counts[platform] ?? 0, value);
714
+ }
715
+ }
716
+ const platforms = Object.keys(counts);
717
+ return Object.freeze({
718
+ readable: true,
719
+ filtered: Object.freeze(filtered.sort()),
720
+ counts: Object.freeze({ ...counts }),
721
+ // `null`, not 0, when nothing was published. Zero is a READING — the arm ran
722
+ // and tested nothing — and rendering "no evidence" as that reading is the
723
+ // exact substitution this whole section exists to refuse.
724
+ totalFlows:
725
+ platforms.length === 0
726
+ ? null
727
+ : platforms.reduce((sum, platform) => sum + counts[platform], 0),
728
+ });
729
+ }
730
+
731
+ /**
732
+ * Row 36-38 — was this run NARROWED?
733
+ *
734
+ * Returns `null` when the run is not disqualified, or the reason token that
735
+ * disqualifies it. Every return here resolves to state `unknown`, never `fail`:
736
+ * a narrowed run is ABSENCE of evidence about the flows it skipped, not evidence
737
+ * that they are broken. That places it on the same side of the line as the
738
+ * skipped-job half of row 26, which means bootstrap and per-suite grace forgive
739
+ * it on the same terms — deliberate, so ARMING this row cannot wedge a repo that
740
+ * is still standing its suites up.
741
+ *
742
+ * The asymmetry between the two signals is the whole design:
743
+ *
744
+ * - A POSITIVELY OBSERVED filter (row 36) disqualifies unconditionally. No
745
+ * declaration is needed to disbelieve a run that reported its own narrowing.
746
+ * - A SHORTFALL against `min_flows` (row 37) and an UNREADABLE scope (row 38)
747
+ * require the suite to have declared `min_flows`. A floor cannot be inferred:
748
+ * this gate reads suites it did not write, including non-maestro ones that
749
+ * publish no counts at all, and a guessed denominator would either forgive
750
+ * everything or block every consumer on the day it shipped.
751
+ *
752
+ * That last point is where fail-closed actually lives. Declaring `min_flows` is
753
+ * the act of saying "this suite publishes counts"; from that moment an
754
+ * unreadable count is a BLOCK (row 38), never a shrug. Without the declaration
755
+ * the gate cannot tell, and it says so on the report line rather than passing
756
+ * silently — see `scopeUnverified` on the finding.
757
+ *
758
+ * @param {object} suite - A validated suite entry
759
+ * @param {{readable: boolean, filtered: ReadonlyArray<string>, totalFlows: number|null}} scope - Output of `readSuiteScope`
760
+ * @returns {{reason: string, detail: string}|null} The disqualification, or null
761
+ */
762
+ export function assessSuiteScope(suite, scope) {
763
+ if (scope.filtered.length > 0) {
764
+ return {
765
+ reason: FILTERED_RUN_REASON,
766
+ detail: `the run recorded itself as tag-filtered on ${scope.filtered.map(platform => `\`${platform}\``).join(", ")}`,
767
+ };
768
+ }
769
+ const floor = suite.min_flows;
770
+ if (floor === undefined) return null;
771
+ if (!scope.readable) {
772
+ return {
773
+ reason: SCOPE_UNREADABLE_REASON,
774
+ detail: `\`min_flows\` is ${floor} but this run's artifact list could not be read, so the executed-flow count is unknown`,
775
+ };
776
+ }
777
+ if (scope.totalFlows === null) {
778
+ return {
779
+ reason: SCOPE_UNREADABLE_REASON,
780
+ detail: `\`min_flows\` is ${floor} but this run published no \`maestro-<platform>-flowcount-<N>\` marker, so how much of the suite ran is unknown`,
781
+ };
782
+ }
783
+ if (scope.totalFlows < floor) {
784
+ return {
785
+ reason: FLOW_SHORTFALL_REASON,
786
+ detail: `this run executed ${scope.totalFlows} flow(s) against a declared floor of ${floor}`,
787
+ };
788
+ }
789
+ return null;
790
+ }
791
+
604
792
  /**
605
793
  * Assesses one suite from what was observed for it.
606
794
  *
@@ -616,6 +804,50 @@ export function stateForConclusion(conclusion) {
616
804
  export function assessSuite(suite, observation, context) {
617
805
  const base = { label: suite.label, workflow: suite.workflow };
618
806
  const blank = { conclusion: null, url: null, createdAt: null, event: null };
807
+ // An observation with no `scope` at all is the same fact as an artifacts list
808
+ // that would not load — "nobody asked" and "the answer did not come back" are
809
+ // both absence of a reading — so it goes through the same constructor rather
810
+ // than a hand-rolled stub. A stub missing a field would throw here and take
811
+ // the whole gate red for a reason having nothing to do with any suite.
812
+ const scope = observation.scope ?? readSuiteScope(null);
813
+
814
+ /**
815
+ * The ONE place a green may be returned, so row 36-38 cannot be bypassed by
816
+ * a future third pass path.
817
+ *
818
+ * `scopeUnverified` rides on the PASSING finding rather than being swallowed:
819
+ * a suite that declared no floor and published no counts really did pass every
820
+ * check this gate can run, and the honest report of that is a green line that
821
+ * says out loud which question went unasked. A silent green here would be the
822
+ * same defect one layer up.
823
+ *
824
+ * @param {object} seen - The run fields already resolved for this finding
825
+ * @param {string} reason - The truth-table reason token for the green
826
+ * @returns {object} The finding
827
+ */
828
+ const green = (seen, reason) => {
829
+ const disqualifier = assessSuiteScope(suite, scope);
830
+ if (disqualifier) {
831
+ return {
832
+ ...base,
833
+ ...seen,
834
+ state: SUITE_STATES.unknown,
835
+ reason: disqualifier.reason,
836
+ scopeDetail: disqualifier.detail,
837
+ };
838
+ }
839
+ return {
840
+ ...base,
841
+ ...seen,
842
+ state: SUITE_STATES.pass,
843
+ reason,
844
+ // Only ever true when no floor was declared — with one declared, the same
845
+ // condition returns a disqualifier above instead.
846
+ scopeUnverified:
847
+ suite.min_flows === undefined &&
848
+ (!scope.readable || scope.totalFlows === null),
849
+ };
850
+ };
619
851
 
620
852
  // Row 11 — the workflow file the table names no longer exists. That is not
621
853
  // missing evidence, it is a broken gate: someone renamed or deleted the suite
@@ -732,12 +964,7 @@ export function assessSuite(suite, observation, context) {
732
964
  };
733
965
  }
734
966
 
735
- return {
736
- ...base,
737
- ...seen,
738
- state: SUITE_STATES.pass,
739
- reason: "run_conclusion",
740
- };
967
+ return green(seen, "run_conclusion");
741
968
  }
742
969
 
743
970
  const matches =
@@ -774,13 +1001,7 @@ export function assessSuite(suite, observation, context) {
774
1001
  // message points at the thing that failed rather than at the run's summary.
775
1002
  const offender = matches.find(job => job.conclusion !== GREEN_CONCLUSION);
776
1003
  if (!offender) {
777
- return {
778
- ...base,
779
- ...seen,
780
- conclusion: GREEN_CONCLUSION,
781
- state: SUITE_STATES.pass,
782
- reason: "job_conclusion",
783
- };
1004
+ return green({ ...seen, conclusion: GREEN_CONCLUSION }, "job_conclusion");
784
1005
  }
785
1006
  const state = stateForConclusion(offender.conclusion);
786
1007
  return {
@@ -1145,6 +1366,12 @@ const REASON_TEXT = Object.freeze({
1145
1366
  "the job pattern matched zero jobs in the newest run. Zero matches is the signature of a renamed job.",
1146
1367
  [INCOMPLETE_EVIDENCE_REASON]:
1147
1368
  "the run reported `success`, but it did not run everything: at least one job was skipped, failed under `continue-on-error`, or could not be read. A run that skipped part of itself did not gather the evidence its green claims — a suite re-run for one platform only is not a verdict about the other one. Re-run the suite WITHOUT narrowing it.",
1369
+ [FILTERED_RUN_REASON]:
1370
+ 'the run reported `success`, but it recorded itself as TAG-FILTERED — it ran a hand-picked slice of the suite, not the suite. A slice that passes says nothing about the flows it never started, so this is no result rather than a green. Re-run the suite with every tag / platform / shard picker left on its "all" default.',
1371
+ [FLOW_SHORTFALL_REASON]:
1372
+ "the run reported `success`, but it executed FEWER FLOWS than this suite declares it must (`min_flows`). A narrowed run reaching green is how a four-flow dispatch clears a merge gate for an eighty-flow suite. Re-run the whole suite, or lower `min_flows` deliberately if the suite really did shrink.",
1373
+ [SCOPE_UNREADABLE_REASON]:
1374
+ 'this suite declares `min_flows`, so its runs must publish an executed-flow count — and this one\'s could not be read. That is NOT a pass: an unreadable count is exactly what a narrowed run looks like from here, and rendering "we could not check" as "it is fine" is the defect this row exists to close. Check the suite still publishes `maestro-<platform>-flowcount-<N>` and that the caller grants `actions: read`.',
1148
1375
  run_conclusion: "",
1149
1376
  job_conclusion: "",
1150
1377
  });
@@ -1163,8 +1390,19 @@ export function formatFinding(finding) {
1163
1390
  const link = finding.url ? `: ${finding.url}` : "";
1164
1391
  const detail = REASON_TEXT[finding.reason] || "";
1165
1392
  if (finding.state === "pass") {
1166
- return `${marker} ${finding.label} — green${when}${link}`;
1393
+ // A green whose SCOPE went unchecked says so on its own line. The
1394
+ // alternative — printing it identically to a fully verified green — is the
1395
+ // precise reading error this gate was measured making: a filtered run and a
1396
+ // full one rendered the same, so the history read as "green recently".
1397
+ const unverified = finding.scopeUnverified
1398
+ ? " — ⚠️ scope unverified: this run published no executed-flow count, so how much of the suite ran is unknown. If this suite publishes `maestro-<platform>-flowcount-<N>`, declare `min_flows` to make that a blocking question; if it does not (a browser suite, say), this line is the honest limit of what the gate can see"
1399
+ : "";
1400
+ return `${marker} ${finding.label} — green${when}${link}${unverified}`;
1167
1401
  }
1402
+ // The measured numbers ride on the line, not just in the prose: "executed 7
1403
+ // flow(s) against a declared floor of 60" is actionable where "the run was
1404
+ // narrowed" sends the reader back to the run page to find out how much.
1405
+ const scopeDetail = finding.scopeDetail ? ` (${finding.scopeDetail})` : "";
1168
1406
  const verdictWord =
1169
1407
  finding.state === "bootstrap"
1170
1408
  ? "not yet blocking"
@@ -1177,7 +1415,7 @@ export function formatFinding(finding) {
1177
1415
  finding.state === "bootstrap" && finding.grace?.active
1178
1416
  ? ` — new suite (first seen ${finding.grace.firstSeen}); its grace expires ${finding.grace.until} (in ${finding.grace.expiresInDays} day(s)), after which this line blocks`
1179
1417
  : "";
1180
- return `${marker} ${finding.label} — ${verdictWord}${conclusion}${when} — ${detail}${link}${grace}`;
1418
+ return `${marker} ${finding.label} — ${verdictWord}${conclusion}${when} — ${detail}${scopeDetail}${link}${grace}`;
1181
1419
  }
1182
1420
 
1183
1421
  /**
@@ -1290,7 +1528,23 @@ export function formatReport(verdict, context) {
1290
1528
  * @returns {boolean} True when the run was complete
1291
1529
  */
1292
1530
  export function isCompleteEvidence(finding) {
1293
- return finding.reason !== INCOMPLETE_EVIDENCE_REASON;
1531
+ // Rows 36-38 join row 26 here for the reason the doc comment gives: with the
1532
+ // gate as written none of them can produce a `pass`, so asking again looks
1533
+ // redundant — and that is exactly why it is asked. The reporter's close action
1534
+ // must not depend on the gate staying strict.
1535
+ //
1536
+ // `scopeUnverified` is deliberately NOT here. It is true of every suite that
1537
+ // has not adopted `min_flows` yet, which is all of them on the day this ships;
1538
+ // refusing to close on it would make the tracking issues immortal across four
1539
+ // repositories to express a doubt the gate itself does not act on. The two
1540
+ // halves tighten together instead: declare `min_flows` and an unreadable count
1541
+ // becomes `scope_unreadable`, which IS in this list.
1542
+ return (
1543
+ finding.reason !== INCOMPLETE_EVIDENCE_REASON &&
1544
+ finding.reason !== FILTERED_RUN_REASON &&
1545
+ finding.reason !== FLOW_SHORTFALL_REASON &&
1546
+ finding.reason !== SCOPE_UNREADABLE_REASON
1547
+ );
1294
1548
  }
1295
1549
 
1296
1550
  /**
@@ -1697,6 +1951,42 @@ export async function fetchAllJobs(api, runId, wait) {
1697
1951
  );
1698
1952
  }
1699
1953
 
1954
+ /**
1955
+ * The artifact NAMES a run published, paginated to exhaustion.
1956
+ *
1957
+ * Names only — nothing here downloads an artifact, and the gate still has no zip
1958
+ * reader. The names carry the scope markers (rows 36-38) and, unlike the bytes,
1959
+ * they survive the retention window, so a three-month-old run can still answer
1960
+ * "how many flows did you run?".
1961
+ *
1962
+ * Returns `null` on 404 and on a truncated read, which is the "we could not ask"
1963
+ * signal `assessSuiteScope` turns into a BLOCK for any suite that declared a
1964
+ * floor. A partial page walk is deliberately reported as unreadable rather than
1965
+ * as what was read: a flow-count marker sitting on the page this walk never
1966
+ * reached is indistinguishable from one that was never published, and the second
1967
+ * of those readings is the one that passes.
1968
+ *
1969
+ * @param {object} api - API coordinates
1970
+ * @param {number|string} runId - The run
1971
+ * @param {(ms: number) => Promise<void>} [wait] - Injectable sleep
1972
+ * @returns {Promise<ReadonlyArray<object>|null>} All artifacts, or null when unreadable
1973
+ */
1974
+ export async function fetchRunArtifacts(api, runId, wait) {
1975
+ const artifacts = [];
1976
+ for (let page = 1; page <= api.maxPages; page += 1) {
1977
+ const result = await apiGet(
1978
+ api,
1979
+ `/repos/${api.repo}/actions/runs/${runId}/artifacts?per_page=100&page=${page}`,
1980
+ wait
1981
+ );
1982
+ if (result === null) return null;
1983
+ const batch = result.body.artifacts ?? [];
1984
+ artifacts.push(...batch);
1985
+ if (batch.length < 100) return Object.freeze(artifacts);
1986
+ }
1987
+ return null;
1988
+ }
1989
+
1700
1990
  /**
1701
1991
  * Observes every suite.
1702
1992
  *
@@ -1737,10 +2027,19 @@ export async function observe(api, suites, branch, wait) {
1737
2027
  if (!run) {
1738
2028
  return { workflowMissing: false, run, jobs: [] };
1739
2029
  }
2030
+ // Artifacts are read for every suite, not only the ones declaring
2031
+ // `min_flows`: a run that recorded ITSELF as filtered disqualifies with no
2032
+ // declaration at all (row 36), and a gate that only looked when asked to
2033
+ // would miss exactly the repos that have not adopted the field yet.
2034
+ const [jobs, artifacts] = await Promise.all([
2035
+ fetchAllJobs(api, run.id, wait),
2036
+ fetchRunArtifacts(api, run.id, wait),
2037
+ ]);
1740
2038
  return {
1741
2039
  workflowMissing: false,
1742
2040
  run,
1743
- jobs: await fetchAllJobs(api, run.id, wait),
2041
+ jobs,
2042
+ scope: readSuiteScope(artifacts),
1744
2043
  };
1745
2044
  })
1746
2045
  );
@@ -37,6 +37,11 @@
37
37
  "maximum": 720,
38
38
  "description": "Override the workflow-level freshness window for this suite. A run older than this is treated as no run at all (missing evidence), which fails once the bootstrap window has closed."
39
39
  },
40
+ "min_flows": {
41
+ "type": "integer",
42
+ "minimum": 1,
43
+ "description": "The number of flows a run must have EXECUTED before its green counts as evidence for this suite (truth-table rows 37-38). Read from the `maestro-<platform>-flowcount-<N>` artifact `maestro-native-e2e.yml` publishes, summed across platforms; the count travels as the artifact NAME, so the gate reads it from the artifacts list without downloading anything and it stays readable after the artifact expires. Declaring this field is the act of asserting that this suite publishes counts, and it is what makes the check FAIL CLOSED: from then on a run that publishes no readable count BLOCKS (`scope_unreadable`) instead of passing unchecked. Without it the gate can still reject a run that recorded ITSELF as tag-filtered (row 36), but it cannot detect a run narrowed by any other means, and it says so on the suite's report line rather than passing silently. `0` is rejected rather than coerced — a floor that enforces nothing reads as a floor."
44
+ },
40
45
  "required_sha": {
41
46
  "type": "string",
42
47
  "pattern": "^[0-9a-f]{40}$",