@codyswann/lisa 4.9.4 → 4.10.1

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 (59) hide show
  1. package/all/copy-overwrite/scripts/lisa-gates.mjs +824 -3
  2. package/all/copy-overwrite/scripts/lisa-run-gates.mjs +39 -109
  3. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  4. package/dist/core/lisa-owned-hash-ledger.js +9 -0
  5. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  6. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  7. package/dist/core/upstream-evidence-manifest.js +7 -2
  8. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  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
@@ -68,7 +68,8 @@
68
68
  * @module lisa-gates
69
69
  */
70
70
 
71
- import { existsSync, readdirSync, readFileSync } from "node:fs";
71
+ import { createHash } from "node:crypto";
72
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
72
73
  import { join } from "node:path";
73
74
 
74
75
  import { invokedAsScript } from "./lib/invoked-as-script.mjs";
@@ -163,7 +164,7 @@ const NO_STATUS_MOMENTS = Object.freeze([
163
164
  export const STATE_FAMILIES = [CONTINUOUS];
164
165
 
165
166
  /** Keys on a gate entry that are settings rather than moments. */
166
- export const GATE_FIELDS = new Set(["run", "needs", "task"]);
167
+ export const GATE_FIELDS = new Set(["run", "needs", "task", "reuse"]);
167
168
 
168
169
  /**
169
170
  * The per-declaration field naming the job chain this gate's prover sits under.
@@ -2757,6 +2758,711 @@ export function readEvidence(evidence, definition = {}, nowMs = Date.now()) {
2757
2758
  return { status: "pass", reason: null };
2758
2759
  }
2759
2760
 
2761
+ // ─── Release evidence reuse (CodySwannGT/lisa#3013) ─────────────────────────
2762
+ //
2763
+ // Release calls the full quality workflow again after merge, on a tree that
2764
+ // already passed the same contract on its pull request. Reuse is allowed only
2765
+ // when a VERIFIED `lisa.gate-evidence/v1` envelope proves the same tree under
2766
+ // the same-or-stricter contract, and every unproved dimension makes the gate
2767
+ // RUN. The design, including the permission wall that shaped it, is in
2768
+ // `docs/design/release-evidence-reuse.md`.
2769
+
2770
+ /**
2771
+ * The schema token, spelled here as well as in the producer.
2772
+ *
2773
+ * This is a deliberate SECOND ADDRESS for one string, and it is forced rather
2774
+ * than sloppy: `.github/workflows/gates.yml` tells an old runner apart from a
2775
+ * current one by grepping the runner file for this literal, so the literal
2776
+ * cannot move out of `lisa-run-gates.mjs`. A verifier that imported it from
2777
+ * there would make this module depend on the runner, inverting the dependency.
2778
+ * `tests/unit/scripts/lisa-gates-reuse-plan.test.ts` pins the two together, so
2779
+ * they cannot drift even though they are written twice.
2780
+ */
2781
+ export const EVIDENCE_SCHEMA_TOKEN = "lisa.gate-evidence/v1";
2782
+
2783
+ /**
2784
+ * Compare strings by UTF-16 code units, independent of the host locale.
2785
+ *
2786
+ * Evidence digests cross machines. `localeCompare` answers a human collation
2787
+ * question using the process locale and installed ICU data, so it can order the
2788
+ * same keys differently on two runners. Relational string comparison is the
2789
+ * ECMAScript code-unit order and is therefore the contract we can reuse.
2790
+ * @param {string} left Left value.
2791
+ * @param {string} right Right value.
2792
+ * @returns {number} Negative, zero, or positive.
2793
+ */
2794
+ export function compareCodeUnits(left, right) {
2795
+ if (left === right) return 0;
2796
+ return left < right ? -1 : 1;
2797
+ }
2798
+
2799
+ /**
2800
+ * The same value with every object key in a stable order.
2801
+ *
2802
+ * A digest over `JSON.stringify` of the raw block would change when an editor
2803
+ * reordered two keys, which would make every prior observation read as produced
2804
+ * under a different contract. Ordering is normalised so the digest answers "is
2805
+ * this the same declaration?" and nothing else.
2806
+ *
2807
+ * This lives HERE rather than beside the producer that first needed it, because
2808
+ * a verifier that recomputed the digest with a second implementation would be
2809
+ * comparing two answers to the same question. One implementation, two callers.
2810
+ * @param {unknown} value Any JSON value.
2811
+ * @returns {unknown} The same value, key-ordered.
2812
+ */
2813
+ export function canonical(value) {
2814
+ if (Array.isArray(value)) return value.map(canonical);
2815
+ if (value === null || typeof value !== "object") return value;
2816
+ return Object.fromEntries(
2817
+ Object.keys(value)
2818
+ // An explicit comparator, never a bare `.sort()`: the default sorts by
2819
+ // UTF-16 code unit through `String()`, and a lint rule in this tree
2820
+ // rejects the bare form for exactly that reason.
2821
+ .sort(compareCodeUnits)
2822
+ .map(key => [key, canonical(value[key])])
2823
+ );
2824
+ }
2825
+
2826
+ /**
2827
+ * A sha256 over canonical JSON, spelled once.
2828
+ * @param {unknown} value Any JSON value.
2829
+ * @returns {string} `sha256:<hex>`.
2830
+ */
2831
+ export function digest(value) {
2832
+ const canonicalised = JSON.stringify(canonical(value));
2833
+ return `sha256:${createHash("sha256").update(canonicalised).digest("hex")}`;
2834
+ }
2835
+
2836
+ /**
2837
+ * Digest of the RESOLVED PLAN at a moment — not the config file's bytes.
2838
+ *
2839
+ * Evidence is only reusable while the contract that produced it still holds,
2840
+ * and three things make the plan the right subject rather than the file:
2841
+ *
2842
+ * - an unrelated key elsewhere in `.lisa.config.json` being edited would force
2843
+ * spurious reruns against a file digest, while proving nothing changed here;
2844
+ * - the raw file does not capture `shippedAs` alias resolution, which reads
2845
+ * `package.json` scripts and genuinely changes WHICH COMMAND proved the
2846
+ * gate — a file digest would call two different provers the same contract;
2847
+ * - `includeOff: true` is what makes "this project declared the gate off" a
2848
+ * RECORDED fact rather than an absence indistinguishable from a registry that
2849
+ * never knew the gate existed.
2850
+ *
2851
+ * It is also what refuses a result recorded while a gate was `optional` from
2852
+ * satisfying a moment that now declares it `required` — a stale-evidence hole
2853
+ * no timestamp closes.
2854
+ * @param {object} options Inputs.
2855
+ * @param {object|null} options.gates The gates block.
2856
+ * @param {string} options.moment The moment being run.
2857
+ * @param {string} [options.runner] The task-runner prefix.
2858
+ * @param {Record<string,string>|null} [options.scripts] Project scripts.
2859
+ * @returns {string|null} `sha256:<hex>`, or null when no plan could resolve.
2860
+ */
2861
+ export function planDigest({ gates, moment, runner, scripts = null }) {
2862
+ if (!gates) return null;
2863
+ try {
2864
+ // The runner is forwarded verbatim when stated, INCLUDING a value
2865
+ // `resolveMoment` refuses. A truthiness guard here would let an invalid
2866
+ // runner fall through to the default, and the envelope would then record
2867
+ // `contract.runner` as one thing while digesting a plan built from another
2868
+ // — two facts about the same run, in one document.
2869
+ const options = { gates, includeOff: true, moment, scripts };
2870
+ const plan = resolveMoment(
2871
+ runner === undefined ? options : { ...options, runner }
2872
+ ).map(gate => ({
2873
+ awaits: gate.awaits,
2874
+ command: gate.command,
2875
+ id: gate.id,
2876
+ level: gate.level,
2877
+ mode: gate.mode,
2878
+ task: gate.task,
2879
+ work: gate.work,
2880
+ }));
2881
+ return digest({
2882
+ gates: [...plan].sort((left, right) =>
2883
+ compareCodeUnits(left.id, right.id)
2884
+ ),
2885
+ runner: runner ?? null,
2886
+ });
2887
+ } catch {
2888
+ // An unresolvable plan — an invalid runner, a refused configuration — has
2889
+ // no contract to digest. Null says so; it does not guess one.
2890
+ return null;
2891
+ }
2892
+ }
2893
+
2894
+ /**
2895
+ * What kind of thing a gate's verdict is, which decides whether it may be
2896
+ * reused and for how long.
2897
+ *
2898
+ * Three values, and the default for anything not named is `never`. That
2899
+ * asymmetry is the whole safety posture: a gate becomes reusable only when
2900
+ * someone wrote down why it is, and a gate added tomorrow is not reusable by
2901
+ * accident.
2902
+ */
2903
+ export const REUSE_CLASS = Object.freeze({
2904
+ /** Same tree + same contract ⇒ same verdict. No external state consulted. */
2905
+ DETERMINISTIC: "deterministic",
2906
+ /** Never satisfiable by evidence from another run. */
2907
+ NEVER: "never",
2908
+ /** Depends on external state that changes without the tree. Needs a window. */
2909
+ TIME_SENSITIVE: "time-sensitive",
2910
+ });
2911
+
2912
+ /** Every legal class, for validation. */
2913
+ export const REUSE_CLASSES = Object.freeze(Object.values(REUSE_CLASS));
2914
+
2915
+ /**
2916
+ * The built-in classification, one row per registry gate.
2917
+ *
2918
+ * `tests/unit/scripts/lisa-gates-reuse-plan.test.ts` derives the key set from
2919
+ * `REGISTRY` and fails when they disagree, so a gate cannot be added without a
2920
+ * stated class. Deriving the table itself is not possible — whether a verdict
2921
+ * depends on external state is a fact about the prover, not about the registry
2922
+ * entry — but requiring the row is, and that is what stops a silent default.
2923
+ *
2924
+ * A project overrides a row with `gates.<id>.reuse` in `.lisa.config.json`.
2925
+ */
2926
+ export const GATE_REUSE_CLASS = Object.freeze({
2927
+ accessibility: { class: REUSE_CLASS.NEVER },
2928
+ // A guard whose job is to detect a stale generated artifact must not itself
2929
+ // be satisfied by a record of a previous run, and it costs seconds.
2930
+ "artifact-freshness": { class: REUSE_CLASS.NEVER },
2931
+ "behavior-contract": { class: REUSE_CLASS.DETERMINISTIC, diff: true },
2932
+ "build-integrity": { class: REUSE_CLASS.DETERMINISTIC },
2933
+ "code-review": { class: REUSE_CLASS.NEVER },
2934
+ "code-style": { class: REUSE_CLASS.DETERMINISTIC },
2935
+ "code-style-slow": { class: REUSE_CLASS.DETERMINISTIC },
2936
+ "commit-conformance": { class: REUSE_CLASS.DETERMINISTIC, diff: true },
2937
+ "conflict-residue": { class: REUSE_CLASS.DETERMINISTIC },
2938
+ "coverage-adequacy": { class: REUSE_CLASS.DETERMINISTIC },
2939
+ "credential-availability": { class: REUSE_CLASS.NEVER },
2940
+ // ── Time-sensitive. 60 minutes throughout, stated rather than inferred: it
2941
+ // sits well above the observed merge→release latency (minutes) and well below
2942
+ // the interval over which a GIVEN lockfile's answer meaningfully changes.
2943
+ // Re-proving these is cheap, so a narrow window costs little.
2944
+ "credential-leakage": {
2945
+ class: REUSE_CLASS.TIME_SENSITIVE,
2946
+ maxAgeMinutes: 60,
2947
+ },
2948
+ "dead-code": { class: REUSE_CLASS.DETERMINISTIC },
2949
+ "dependency-vulnerability": {
2950
+ class: REUSE_CLASS.TIME_SENSITIVE,
2951
+ maxAgeMinutes: 60,
2952
+ },
2953
+ "e2e-browser": { class: REUSE_CLASS.NEVER },
2954
+ "e2e-native": { class: REUSE_CLASS.NEVER },
2955
+ "environment-reseed": { class: REUSE_CLASS.NEVER },
2956
+ "environment-reset": { class: REUSE_CLASS.NEVER },
2957
+ "format-conformance": { class: REUSE_CLASS.DETERMINISTIC },
2958
+ "generative-testing": { class: REUSE_CLASS.DETERMINISTIC },
2959
+ "journey-coverage": { class: REUSE_CLASS.DETERMINISTIC },
2960
+ "learnings-budget": { class: REUSE_CLASS.DETERMINISTIC },
2961
+ "license-compliance": {
2962
+ class: REUSE_CLASS.TIME_SENSITIVE,
2963
+ maxAgeMinutes: 60,
2964
+ },
2965
+ "load-capacity": { class: REUSE_CLASS.NEVER },
2966
+ "migration-provenance": { class: REUSE_CLASS.DETERMINISTIC },
2967
+ // Its measurement varies with runner speed, so it is not a pure function of
2968
+ // the tree. But the property it gates is a property of the CODE, and runner
2969
+ // variance is noise in both directions: rerunning it adds a second sample of
2970
+ // the same noise, not a second proof. A project that disagrees declares
2971
+ // `"reuse": { "class": "time-sensitive", "max_age_minutes": N }`.
2972
+ "performance-budget": { class: REUSE_CLASS.DETERMINISTIC },
2973
+ "runtime-web-vulnerability": { class: REUSE_CLASS.NEVER },
2974
+ "security-floor-integrity": { class: REUSE_CLASS.DETERMINISTIC },
2975
+ "state-classification": { class: REUSE_CLASS.DETERMINISTIC },
2976
+ // The main-branch analysis is a PUBLISHING action — it updates the
2977
+ // server-side baseline every later pull request is measured against — not
2978
+ // only a proof. Skipping it would silently stop maintaining that baseline.
2979
+ "static-security": { class: REUSE_CLASS.NEVER },
2980
+ "structural-rules": { class: REUSE_CLASS.DETERMINISTIC },
2981
+ "suppression-residue": { class: REUSE_CLASS.DETERMINISTIC },
2982
+ "test-correctness": { class: REUSE_CLASS.DETERMINISTIC },
2983
+ // Hermetic in this repository. A project whose integration suite reaches a
2984
+ // live service MUST override this — the built-in cannot know, and the cost of
2985
+ // being wrong is reusing a proof about a system that has since changed.
2986
+ "test-integration": { class: REUSE_CLASS.DETERMINISTIC },
2987
+ "test-meaningfulness": { class: REUSE_CLASS.DETERMINISTIC },
2988
+ "test-node-suites": { class: REUSE_CLASS.DETERMINISTIC },
2989
+ "threshold-monotonicity": { class: REUSE_CLASS.DETERMINISTIC },
2990
+ "tool-availability": { class: REUSE_CLASS.NEVER },
2991
+ // `pull_request`-only by its job condition, so there is nothing to reuse and
2992
+ // nothing to save; declaring it `never` says that rather than implying it.
2993
+ traceability: { class: REUSE_CLASS.NEVER },
2994
+ "type-correctness": { class: REUSE_CLASS.DETERMINISTIC },
2995
+ "version-duplication": { class: REUSE_CLASS.DETERMINISTIC },
2996
+ });
2997
+
2998
+ /**
2999
+ * Why a gate is running rather than reusing, as a stable token.
3000
+ *
3001
+ * Tokens rather than prose because the ledger is read by a job that must
3002
+ * compare two sets, and by an operator who needs the same phrase to mean the
3003
+ * same thing across runs.
3004
+ */
3005
+ export const REUSE_REASON = Object.freeze({
3006
+ CONTRACT_MISMATCH: "contract-mismatch",
3007
+ DERIVATIVE: "derivative",
3008
+ LEVEL_DOWNGRADE: "level-downgrade",
3009
+ MALFORMED: "malformed",
3010
+ NEVER_REUSABLE: "never-reusable",
3011
+ NOT_PROVED: "not-proved",
3012
+ REUSED: "reused",
3013
+ STALE: "stale",
3014
+ SUBJECT_MISMATCH: "subject-mismatch",
3015
+ UNATTRIBUTABLE: "unattributable",
3016
+ UNAVAILABLE: "unavailable",
3017
+ UNCLASSIFIED: "unclassified",
3018
+ UNCOVERED: "uncovered",
3019
+ });
3020
+
3021
+ /** The largest envelope the verifier will read, in bytes. */
3022
+ export const EVIDENCE_BYTE_BUDGET = 1024 * 1024;
3023
+
3024
+ /** How deep a caller chain may be and still be primary pull-request proof. */
3025
+ const PRIMARY_CALLER_CHAIN_DEPTH = 1;
3026
+
3027
+ /** How strong each level is, so evidence can never satisfy a stricter one. */
3028
+ const LEVEL_RANK = Object.freeze({ off: 0, optional: 1, required: 2 });
3029
+
3030
+ /**
3031
+ * The reuse policy for one gate: built-in row, overridden by the project.
3032
+ *
3033
+ * An unknown gate id resolves to `never`. That is the fail-closed default and
3034
+ * it is why the built-in table does not need to anticipate a consumer's own
3035
+ * gate: a gate nobody classified is a gate nobody argued was reusable.
3036
+ * @param {string} id Gate id.
3037
+ * @param {object|null} [gates] The project gates block.
3038
+ * @returns {{class: string, diff: boolean, known: boolean, maxAgeMinutes: number|null}} Policy.
3039
+ */
3040
+ export function reusePolicyFor(id, gates = null) {
3041
+ const builtIn = GATE_REUSE_CLASS[id] ?? null;
3042
+ const declared = gates?.[id]?.reuse ?? null;
3043
+ const known = Boolean(builtIn || declared);
3044
+ const merged = {
3045
+ class: declared?.class ?? builtIn?.class ?? REUSE_CLASS.NEVER,
3046
+ diff: declared?.diff ?? builtIn?.diff ?? false,
3047
+ known,
3048
+ maxAgeMinutes: declared?.max_age_minutes ?? builtIn?.maxAgeMinutes ?? null,
3049
+ };
3050
+ // An unrecognised class is not a licence to guess. `validate` refuses it at
3051
+ // declaration time; this is the runtime arm of the same refusal.
3052
+ return REUSE_CLASSES.includes(merged.class)
3053
+ ? merged
3054
+ : { class: REUSE_CLASS.NEVER, diff: false, known, maxAgeMinutes: null };
3055
+ }
3056
+
3057
+ /**
3058
+ * The tree this evidence is about must be the tree being released.
3059
+ *
3060
+ * `tree` rather than `commit` is the identity: two commits legitimately share a
3061
+ * tree after a rebase or a merge of an up-to-date branch, and that is exactly
3062
+ * the case where reuse is sound. A null on either side is a mismatch, not a
3063
+ * pass — an unknown tree is not the same tree.
3064
+ * @param {object} subject The envelope's subject block.
3065
+ * @param {object} observed Locally observed facts.
3066
+ * @returns {string|null} A problem, or null.
3067
+ */
3068
+ function verifySubject(subject, observed) {
3069
+ if (!subject.repository || subject.repository !== observed.repository) {
3070
+ return `evidence is about ${JSON.stringify(subject.repository ?? null)}, not ${JSON.stringify(observed.repository ?? null)}`;
3071
+ }
3072
+ if (!subject.tree || !observed.tree || subject.tree !== observed.tree) {
3073
+ return `evidence is about tree ${JSON.stringify(subject.tree ?? null)}, not ${JSON.stringify(observed.tree ?? null)}`;
3074
+ }
3075
+ return null;
3076
+ }
3077
+
3078
+ /**
3079
+ * Compare two dotted versions numerically, ignoring any pre-release suffix.
3080
+ *
3081
+ * Deliberately small: the only question asked of it is "is the producer's
3082
+ * registry at least as new as mine", and a full semver implementation would be
3083
+ * a second answer to a question nothing else asks.
3084
+ * @param {string} left Version.
3085
+ * @param {string} right Version.
3086
+ * @returns {number} Negative, zero or positive.
3087
+ */
3088
+ function compareVersions(left, right) {
3089
+ const parts = value =>
3090
+ String(value)
3091
+ .split("-")[0]
3092
+ .split(".")
3093
+ .map(part => Number.parseInt(part, 10) || 0);
3094
+ const a = parts(left);
3095
+ const b = parts(right);
3096
+ const width = Math.max(a.length, b.length);
3097
+ for (let index = 0; index < width; index += 1) {
3098
+ const difference = (a[index] ?? 0) - (b[index] ?? 0);
3099
+ if (difference !== 0) return difference;
3100
+ }
3101
+ return 0;
3102
+ }
3103
+
3104
+ /**
3105
+ * The contract that produced this evidence must be the one being planned.
3106
+ *
3107
+ * `workflow_ref` and `workflow_sha` are not redundant with the tree: consumers
3108
+ * call the reusable workflow at `@main`, so its contents can change with NO
3109
+ * change to the caller's tree, and tree identity alone would let an older,
3110
+ * weaker workflow's proof satisfy a stricter one. `registry_version` may be
3111
+ * NEWER than the local one — a stricter producer is acceptable, which is what
3112
+ * "same or stricter" means — but never older.
3113
+ * @param {object} contract The envelope's contract block.
3114
+ * @param {string} moment The moment being planned.
3115
+ * @param {object} observed Locally observed facts.
3116
+ * @returns {string|null} A problem, or null.
3117
+ */
3118
+ function verifyContract(contract, moment, observed) {
3119
+ if (contract.moment !== moment) {
3120
+ return `evidence is for moment ${JSON.stringify(contract.moment ?? null)}, not ${JSON.stringify(moment)}`;
3121
+ }
3122
+ const pairs = [
3123
+ ["gates_digest", observed.gatesDigest],
3124
+ ["inputs_digest", observed.inputsDigest],
3125
+ ["workflow_ref", observed.workflowRef],
3126
+ ["workflow_sha", observed.workflowSha],
3127
+ ];
3128
+ for (const [field, expected] of pairs) {
3129
+ if (!contract[field] || !expected || contract[field] !== expected) {
3130
+ return `${field} is ${JSON.stringify(contract[field] ?? null)}, not ${JSON.stringify(expected ?? null)}`;
3131
+ }
3132
+ }
3133
+ if (!contract.registry_version || !observed.registryVersion) {
3134
+ return `registry_version is ${JSON.stringify(contract.registry_version ?? null)}, and an unknown producer version cannot be shown to be same-or-stricter`;
3135
+ }
3136
+ if (
3137
+ compareVersions(contract.registry_version, observed.registryVersion) < 0
3138
+ ) {
3139
+ return `registry_version ${contract.registry_version} is older than this run's ${observed.registryVersion}, so it may declare less or declare it more weakly`;
3140
+ }
3141
+ return null;
3142
+ }
3143
+
3144
+ /**
3145
+ * The evidence must be PRIMARY and attributable.
3146
+ *
3147
+ * `reused_gates` closes circular reuse: a run that reused evidence and then
3148
+ * emitted its own could otherwise be reused in turn, and the chain of proof
3149
+ * bottoms out on nothing. `caller_chain` closes it a second time from a
3150
+ * different direction — a release-path envelope is nested one level deeper than
3151
+ * a pull-request one — and a null chain is ineligible rather than assumed
3152
+ * shallow. `run_url` is what makes the ledger auditable: evidence nobody can go
3153
+ * and read is not evidence anyone checked.
3154
+ * @param {object} producer The envelope's producer block.
3155
+ * @returns {{detail: string, reason: string}|null} A refusal, or null.
3156
+ */
3157
+ function verifyProducer(producer) {
3158
+ const reused = producer.reused_gates;
3159
+ if (!Array.isArray(reused)) {
3160
+ return {
3161
+ detail:
3162
+ "producer.reused_gates is absent or not an array, so this run cannot be shown to be primary proof",
3163
+ reason: REUSE_REASON.DERIVATIVE,
3164
+ };
3165
+ }
3166
+ if (reused.length) {
3167
+ return {
3168
+ detail: `producer reused ${reused.length} gate(s), so this is not primary proof`,
3169
+ reason: REUSE_REASON.DERIVATIVE,
3170
+ };
3171
+ }
3172
+ const chain = producer.caller_chain;
3173
+ if (!Array.isArray(chain) || chain.length !== PRIMARY_CALLER_CHAIN_DEPTH) {
3174
+ return {
3175
+ detail: `producer.caller_chain is ${JSON.stringify(chain ?? null)}; only a chain of depth ${PRIMARY_CALLER_CHAIN_DEPTH} is a primary pull-request path`,
3176
+ reason: REUSE_REASON.UNATTRIBUTABLE,
3177
+ };
3178
+ }
3179
+ if (!producer.run_id || !producer.run_url) {
3180
+ return {
3181
+ detail:
3182
+ "producer.run_id or producer.run_url is absent, so the proof cannot be read back",
3183
+ reason: REUSE_REASON.UNATTRIBUTABLE,
3184
+ };
3185
+ }
3186
+ return null;
3187
+ }
3188
+
3189
+ /**
3190
+ * Whether the envelope as a WHOLE may be used as a source of proof.
3191
+ *
3192
+ * Every check here discards the entire document, because each one says the
3193
+ * evidence is about a different subject, a different contract, or is not
3194
+ * primary. A per-gate check cannot rescue any of them.
3195
+ * @param {object} options Inputs.
3196
+ * @param {unknown} options.envelope The parsed envelope, or null.
3197
+ * @param {string} options.moment The moment being planned.
3198
+ * @param {object} options.observed Locally observed facts.
3199
+ * @returns {{detail: string|null, ok: boolean, reason: string|null}} Verdict.
3200
+ */
3201
+ function verifyEnvelope({ envelope, moment, observed }) {
3202
+ const refuse = (reason, detail) => ({ detail, ok: false, reason });
3203
+ if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) {
3204
+ return refuse(REUSE_REASON.MALFORMED, "the envelope is not a JSON object");
3205
+ }
3206
+ if (envelope.schema !== EVIDENCE_SCHEMA_TOKEN) {
3207
+ return refuse(
3208
+ REUSE_REASON.MALFORMED,
3209
+ `schema is ${JSON.stringify(envelope.schema ?? null)}, not ${JSON.stringify(EVIDENCE_SCHEMA_TOKEN)}`
3210
+ );
3211
+ }
3212
+ if (!Array.isArray(envelope.gates)) {
3213
+ return refuse(REUSE_REASON.MALFORMED, "gates is not an array");
3214
+ }
3215
+ if (envelope.verdict !== "proved") {
3216
+ return refuse(
3217
+ REUSE_REASON.NOT_PROVED,
3218
+ `verdict is ${JSON.stringify(envelope.verdict ?? null)}, not "proved"`
3219
+ );
3220
+ }
3221
+ const subjectProblem = verifySubject(envelope.subject ?? {}, observed);
3222
+ if (subjectProblem) {
3223
+ return refuse(REUSE_REASON.SUBJECT_MISMATCH, subjectProblem);
3224
+ }
3225
+ const contractProblem = verifyContract(
3226
+ envelope.contract ?? {},
3227
+ moment,
3228
+ observed
3229
+ );
3230
+ if (contractProblem) {
3231
+ return refuse(REUSE_REASON.CONTRACT_MISMATCH, contractProblem);
3232
+ }
3233
+ const producerProblem = verifyProducer(envelope.producer ?? {});
3234
+ if (producerProblem) {
3235
+ return refuse(producerProblem.reason, producerProblem.detail);
3236
+ }
3237
+ return { detail: null, ok: true, reason: null };
3238
+ }
3239
+
3240
+ /**
3241
+ * The freshness bound to judge a row by: the tighter of the two, never the
3242
+ * looser.
3243
+ *
3244
+ * A producer that stamped its own bound and a policy that declares one are two
3245
+ * claims about the same question, and taking the maximum would let a generous
3246
+ * producer widen a window the project narrowed.
3247
+ * @param {object} row The evidence row.
3248
+ * @param {object} policy The gate's reuse policy.
3249
+ * @returns {number|null} The bound in minutes, or null for unbounded.
3250
+ */
3251
+ function effectiveBound(row, policy) {
3252
+ const bounds = [row.max_age_minutes, policy.maxAgeMinutes].filter(
3253
+ value => typeof value === "number" && value > 0
3254
+ );
3255
+ return bounds.length ? Math.min(...bounds) : null;
3256
+ }
3257
+
3258
+ /**
3259
+ * Whether ONE gate's row proves what this moment requires of it.
3260
+ *
3261
+ * Row-level failures affect one gate and never leak into another's decision —
3262
+ * the fixtures assert exactly that, because a verifier whose failures
3263
+ * cross-contaminate is one nobody can reason about one dimension at a time.
3264
+ * @param {object} options Inputs.
3265
+ * @param {object} options.gate The resolved gate at this moment.
3266
+ * @param {number} options.nowMs Clock.
3267
+ * @param {object} options.observed Locally observed facts.
3268
+ * @param {object} options.policy Its reuse policy.
3269
+ * @param {object} [options.row] Its evidence row, if any.
3270
+ * @returns {{decision: string, detail: string|null, reason: string}} Verdict.
3271
+ */
3272
+ function verifyRow({ gate, nowMs, observed, policy, row }) {
3273
+ const run = (reason, detail = null) => ({ decision: "run", detail, reason });
3274
+ if (policy.class === REUSE_CLASS.NEVER) {
3275
+ return policy.known
3276
+ ? run(REUSE_REASON.NEVER_REUSABLE, "declared never reusable")
3277
+ : run(
3278
+ REUSE_REASON.UNCLASSIFIED,
3279
+ "no reuse class is declared for this gate, and the default is never"
3280
+ );
3281
+ }
3282
+ if (!row) {
3283
+ return run(REUSE_REASON.UNCOVERED, "the envelope carries no row for it");
3284
+ }
3285
+ const bound = effectiveBound(row, policy);
3286
+ const effective = readEvidence(
3287
+ { ...row, max_age_minutes: bound },
3288
+ gate,
3289
+ nowMs
3290
+ );
3291
+ if (effective.status !== "pass") {
3292
+ const stale = (effective.reason ?? "").includes("past its");
3293
+ return run(
3294
+ stale ? REUSE_REASON.STALE : REUSE_REASON.NOT_PROVED,
3295
+ effective.reason
3296
+ );
3297
+ }
3298
+ if (policy.class === REUSE_CLASS.TIME_SENSITIVE) {
3299
+ if (!bound) {
3300
+ return run(
3301
+ REUSE_REASON.STALE,
3302
+ "time-sensitive with no declared window, so freshness was never bounded"
3303
+ );
3304
+ }
3305
+ if (!row.prover?.version) {
3306
+ return run(
3307
+ REUSE_REASON.UNATTRIBUTABLE,
3308
+ "time-sensitive and prover.version is absent, so the scanner that produced it is unknown"
3309
+ );
3310
+ }
3311
+ }
3312
+ const rank = LEVEL_RANK[row.level];
3313
+ if (rank === undefined || rank < LEVEL_RANK[gate.level]) {
3314
+ return run(
3315
+ REUSE_REASON.LEVEL_DOWNGRADE,
3316
+ `proved at ${JSON.stringify(row.level ?? null)} but this moment requires ${gate.level}`
3317
+ );
3318
+ }
3319
+ // A diff gate resolved a BASE revision, which the tree hash does not carry.
3320
+ // Two commits sharing a tree do not share a diff, so this sub-class binds to
3321
+ // the commit as well — a strictly stronger requirement than the rest.
3322
+ if (policy.diff && !observed.commitMatches) {
3323
+ return run(
3324
+ REUSE_REASON.SUBJECT_MISMATCH,
3325
+ "a diff gate is bound to the commit it diffed, and the commit differs"
3326
+ );
3327
+ }
3328
+ return { decision: "reuse", detail: null, reason: REUSE_REASON.REUSED };
3329
+ }
3330
+
3331
+ /**
3332
+ * Plan which gates at a moment may stand down on verified prior evidence.
3333
+ *
3334
+ * FAIL-CLOSED IS THE WHOLE CONTRACT. Every path through this function that is
3335
+ * not "the envelope passed every check AND this row passed every check" returns
3336
+ * `run`. There is no blanket switch, no `skip_jobs` equivalent, and no branch
3337
+ * where the ABSENCE of evidence produces anything but a full plan.
3338
+ * @param {object} options Inputs.
3339
+ * @param {unknown} options.envelope The parsed inbound envelope, or null.
3340
+ * @param {object|null} [options.gates] The project gates block.
3341
+ * @param {string} options.moment The moment being planned.
3342
+ * @param {number} [options.nowMs] Clock, injected for tests.
3343
+ * @param {object} options.observed Locally observed facts.
3344
+ * @param {{detail: string, reason: string}|null} [options.refusal] A refusal the caller already reached.
3345
+ * @param {string} [options.runner] The task-runner prefix.
3346
+ * @param {Record<string,string>|null} [options.scripts] Project scripts.
3347
+ * @returns {{decisions: object[], detail: string|null, verdict: string}} Plan.
3348
+ */
3349
+ export function reusePlan({
3350
+ envelope,
3351
+ gates = null,
3352
+ moment,
3353
+ nowMs = Date.now(),
3354
+ observed,
3355
+ refusal = null,
3356
+ runner = undefined,
3357
+ scripts = null,
3358
+ }) {
3359
+ const resolveOptions = { gates, moment, scripts };
3360
+ const resolved = resolveMoment(
3361
+ runner === undefined ? resolveOptions : { ...resolveOptions, runner }
3362
+ );
3363
+ // A refusal the CALLER already reached outranks anything this function could
3364
+ // work out. "The file was not there" and "the file was there and wrong" are
3365
+ // different facts about the same absent proof, and only the caller that tried
3366
+ // to open it knows which — reporting the wrong one sends an operator looking
3367
+ // at the wrong thing, and the ledger compares these tokens.
3368
+ const envelopeVerdict = refusal
3369
+ ? { detail: refusal.detail, ok: false, reason: refusal.reason }
3370
+ : verifyEnvelope({ envelope, moment, observed });
3371
+ const rows = new Map(
3372
+ envelopeVerdict.ok
3373
+ ? envelope.gates
3374
+ .filter(row => row && typeof row.gate === "string")
3375
+ .map(row => [row.gate, row])
3376
+ : []
3377
+ );
3378
+ const commitMatches = Boolean(
3379
+ envelope?.subject?.commit &&
3380
+ observed.commit &&
3381
+ envelope.subject.commit === observed.commit
3382
+ );
3383
+ const decisions = resolved.map(gate => {
3384
+ const policy = reusePolicyFor(gate.id, gates);
3385
+ const outcome = envelopeVerdict.ok
3386
+ ? verifyRow({
3387
+ gate,
3388
+ nowMs,
3389
+ observed: { ...observed, commitMatches },
3390
+ policy,
3391
+ row: rows.get(gate.id),
3392
+ })
3393
+ : {
3394
+ decision: "run",
3395
+ detail: envelopeVerdict.detail,
3396
+ reason: envelopeVerdict.reason,
3397
+ };
3398
+ return {
3399
+ class: policy.class,
3400
+ decision: outcome.decision,
3401
+ detail: outcome.detail,
3402
+ gate: gate.id,
3403
+ label: gate.label,
3404
+ level: gate.level,
3405
+ proof:
3406
+ outcome.decision === "reuse"
3407
+ ? (envelope.producer?.run_url ?? null)
3408
+ : null,
3409
+ reason: outcome.reason,
3410
+ };
3411
+ });
3412
+ return {
3413
+ decisions,
3414
+ detail: envelopeVerdict.detail,
3415
+ verdict: envelopeVerdict.ok ? "verified" : envelopeVerdict.reason,
3416
+ };
3417
+ }
3418
+
3419
+ /**
3420
+ * Read an envelope from disk for the verifier, refusing anything it cannot
3421
+ * safely read.
3422
+ *
3423
+ * A read failure and a parse failure both return null plus a reason, because
3424
+ * the caller does the same thing with both: run everything. They carry
3425
+ * DIFFERENT refusal tokens, because an absent record and a corrupt one are
3426
+ * different things to go and fix. The byte budget lives here rather than in the
3427
+ * caller so every entry point shares one limit.
3428
+ * @param {string} path File to read.
3429
+ * @param {object} [io] Injected fs, for tests.
3430
+ * @returns {{envelope: object|null, reason: string|null, refusal: string|null}} The parsed envelope.
3431
+ */
3432
+ export function readEvidenceFile(path, io = {}) {
3433
+ const { readFile = readFileSync, statFile = statSync } = io;
3434
+ let raw = null;
3435
+ try {
3436
+ const { size } = statFile(path);
3437
+ if (size > EVIDENCE_BYTE_BUDGET) {
3438
+ return {
3439
+ envelope: null,
3440
+ reason: `evidence at ${path} is ${size} bytes, past the ${EVIDENCE_BYTE_BUDGET}-byte budget`,
3441
+ refusal: REUSE_REASON.UNAVAILABLE,
3442
+ };
3443
+ }
3444
+ raw = readFile(path, "utf8");
3445
+ } catch (err) {
3446
+ return {
3447
+ envelope: null,
3448
+ reason: `evidence at ${path} could not be opened: ${err.message}`,
3449
+ refusal: REUSE_REASON.UNAVAILABLE,
3450
+ };
3451
+ }
3452
+ try {
3453
+ return { envelope: JSON.parse(raw), reason: null, refusal: null };
3454
+ } catch (err) {
3455
+ // Split from the open failure deliberately. Bytes that exist and do not
3456
+ // parse are a MALFORMED record; bytes that are not there are an ABSENT one.
3457
+ // Both run everything, and an operator needs to know which to go and fix.
3458
+ return {
3459
+ envelope: null,
3460
+ reason: `evidence at ${path} is not JSON: ${err.message}`,
3461
+ refusal: REUSE_REASON.MALFORMED,
3462
+ };
3463
+ }
3464
+ }
3465
+
2760
3466
  /** Description phrases that grant or deny credit for an awaited signal. */
2761
3467
  export const EVIDENCE_DEFAULTS = Object.freeze({
2762
3468
  // Matched strictly — whole description, case-insensitive — because a match
@@ -3752,6 +4458,7 @@ function validateGate(id, gate) {
3752
4458
  );
3753
4459
  }
3754
4460
  problems.push(...validateNeeds(id, gate));
4461
+ problems.push(...validateReuse(id, gate.reuse));
3755
4462
 
3756
4463
  for (const [moment, value] of Object.entries(gate)) {
3757
4464
  // A key that is neither a known field nor a well-formed moment is a typo,
@@ -3791,6 +4498,57 @@ function validateGate(id, gate) {
3791
4498
  return problems;
3792
4499
  }
3793
4500
 
4501
+ /**
4502
+ * Validate a gate's reuse declaration (#3013).
4503
+ *
4504
+ * A half-written declaration here is the dangerous kind: a `time-sensitive`
4505
+ * class with no window would either be unbounded — the freshness requirement
4506
+ * resolving against nothing, which is the failure the gate façade exists to
4507
+ * refuse — or silently fall back to `never`, which is safe but leaves the
4508
+ * author believing they configured something. Refusing at declaration time is
4509
+ * the only reading that tells them.
4510
+ *
4511
+ * A window on a class that does not use one is refused for the mirror reason:
4512
+ * it reads as a bound that is being applied, and it is not.
4513
+ * @param {string} id Gate id.
4514
+ * @param {*} reuse The declared block, if any.
4515
+ * @returns {string[]} Problems.
4516
+ */
4517
+ function validateReuse(id, reuse) {
4518
+ if (reuse === undefined) return [];
4519
+ const where = `gates."${id}"."reuse"`;
4520
+ if (!reuse || typeof reuse !== "object" || Array.isArray(reuse)) {
4521
+ return [`${where} must be an object`];
4522
+ }
4523
+ const problems = [];
4524
+ if (!REUSE_CLASSES.includes(reuse.class)) {
4525
+ problems.push(
4526
+ `${where}.class is ${JSON.stringify(reuse.class ?? null)}; ` +
4527
+ `expected one of ${REUSE_CLASSES.join(", ")}.`
4528
+ );
4529
+ }
4530
+ const window = reuse.max_age_minutes;
4531
+ if (reuse.class === REUSE_CLASS.TIME_SENSITIVE) {
4532
+ if (!Number.isFinite(window) || window <= 0) {
4533
+ problems.push(
4534
+ `${where}.max_age_minutes is ${JSON.stringify(window ?? null)}; ` +
4535
+ `a time-sensitive gate depends on external state that changes ` +
4536
+ `without the tree, so it must say how stale an answer it accepts. ` +
4537
+ `An unbounded window is not freshness, it is an unmeasured claim.`
4538
+ );
4539
+ }
4540
+ } else if (window !== undefined) {
4541
+ problems.push(
4542
+ `${where}.max_age_minutes is declared on a "${reuse.class}" gate, ` +
4543
+ `where nothing reads it. Either the class is wrong or the window is.`
4544
+ );
4545
+ }
4546
+ if (reuse.diff !== undefined && typeof reuse.diff !== "boolean") {
4547
+ problems.push(`${where}.diff must be true or false`);
4548
+ }
4549
+ return problems;
4550
+ }
4551
+
3794
4552
  /**
3795
4553
  * Validate one moment entry on a gate.
3796
4554
  * @param {string} id Gate id.
@@ -5375,6 +6133,69 @@ function main() {
5375
6133
  return;
5376
6134
  }
5377
6135
 
6136
+ // ─── reuse-plan (#3013) ────────────────────────────────────────────────
6137
+ // Answers ONE question: which gates at this moment may stand down because a
6138
+ // VERIFIED envelope already proves them for this exact tree under this exact
6139
+ // contract. Every ambiguity — an unreadable file, a mismatched dimension, a
6140
+ // gate nobody classified — resolves to `run`, so the worst this command can
6141
+ // do is describe today's behaviour. It never exits non-zero on a refusal: an
6142
+ // optimization that can redden a release is not an optimization.
6143
+ if (command === "reuse-plan") {
6144
+ const moment = flag("moment");
6145
+ if (!moment) {
6146
+ throw new Error(
6147
+ "usage: lisa-gates.mjs reuse-plan --moment=<moment> --evidence=<file> --repository=<o/r> --tree=<sha> [--commit=<sha>] [--workflow-ref=<ref>] [--workflow-sha=<sha>] [--inputs-digest=<sha256:…>] [--registry-version=<v>] [--json]"
6148
+ );
6149
+ }
6150
+ const evidencePath = flag("evidence");
6151
+ const read = evidencePath
6152
+ ? readEvidenceFile(evidencePath)
6153
+ : {
6154
+ envelope: null,
6155
+ reason: "no --evidence path was given, so nothing was verified",
6156
+ refusal: REUSE_REASON.UNAVAILABLE,
6157
+ };
6158
+ const plan = reusePlan({
6159
+ envelope: read.envelope,
6160
+ gates,
6161
+ moment,
6162
+ observed: {
6163
+ commit: flag("commit") ?? null,
6164
+ gatesDigest: planDigest({ gates, moment, runner, scripts }),
6165
+ inputsDigest: flag("inputs-digest") ?? null,
6166
+ registryVersion: flag("registry-version") ?? null,
6167
+ repository: flag("repository") ?? null,
6168
+ tree: flag("tree") ?? null,
6169
+ workflowRef: flag("workflow-ref") ?? null,
6170
+ workflowSha: flag("workflow-sha") ?? null,
6171
+ },
6172
+ refusal: read.refusal
6173
+ ? { detail: read.reason, reason: read.refusal }
6174
+ : null,
6175
+ runner,
6176
+ scripts,
6177
+ });
6178
+ if (rest.includes("--json")) {
6179
+ console.log(JSON.stringify(plan, null, 2));
6180
+ return;
6181
+ }
6182
+ console.log(`reuse verdict: ${plan.verdict}`);
6183
+ if (plan.detail) console.log(` ${plan.detail}`);
6184
+ for (const entry of plan.decisions) {
6185
+ const proof = entry.proof ? ` ← ${entry.proof}` : "";
6186
+ console.log(
6187
+ ` ${entry.decision === "reuse" ? "♻️ " : "▶️ "} ${entry.gate.padEnd(28)} ${entry.reason}${proof}`
6188
+ );
6189
+ }
6190
+ const reused = plan.decisions.filter(
6191
+ entry => entry.decision === "reuse"
6192
+ ).length;
6193
+ console.log(
6194
+ `\n${reused} of ${plan.decisions.length} gate(s) at ${moment} are proved by prior evidence.`
6195
+ );
6196
+ return;
6197
+ }
6198
+
5378
6199
  if (command === "audit-config") {
5379
6200
  const findings = auditConfigKeys(config);
5380
6201
  for (const finding of findings) console.error(` ${finding.message}`);
@@ -5383,7 +6204,7 @@ function main() {
5383
6204
  }
5384
6205
 
5385
6206
  throw new Error(
5386
- "usage: lisa-gates.mjs validate|list|legs|quality-plan|needs|contexts|verify-contexts|skip-jobs|audit-config|inventory|unconfigured|seed"
6207
+ "usage: lisa-gates.mjs validate|list|legs|quality-plan|reuse-plan|needs|contexts|verify-contexts|skip-jobs|audit-config|inventory|unconfigured|seed"
5387
6208
  );
5388
6209
  }
5389
6210