@codyswann/lisa 3.62.0 → 3.64.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 (80) hide show
  1. package/all/copy-overwrite/scripts/lisa-gates.mjs +223 -10
  2. package/all/copy-overwrite/scripts/lisa-reconcile-policy.mjs +325 -32
  3. package/dist/cli/gate-report-templates.js +1 -1
  4. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  5. package/dist/core/lisa-owned-hash-ledger.js +8 -0
  6. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  7. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  8. package/dist/core/upstream-evidence-manifest.js +16 -9
  9. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  10. package/dist/health/ruleset-inspection.d.ts.map +1 -1
  11. package/dist/health/ruleset-inspection.js +73 -6
  12. package/dist/health/ruleset-inspection.js.map +1 -1
  13. package/package.json +2 -1
  14. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  15. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  16. package/plugins/lisa/.codex-plugin/skills/lisa-doctor/SKILL.md +46 -7
  17. package/plugins/lisa/.codex-plugin/skills/lisa-setup-github-repo/SKILL.md +7 -3
  18. package/plugins/lisa/skills/lisa-doctor/SKILL.md +46 -7
  19. package/plugins/lisa/skills/lisa-setup-github-repo/SKILL.md +7 -3
  20. package/plugins/lisa-agy/plugin.json +1 -1
  21. package/plugins/lisa-agy/skills/lisa-doctor/SKILL.md +46 -7
  22. package/plugins/lisa-agy/skills/lisa-setup-github-repo/SKILL.md +7 -3
  23. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  25. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  26. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-copilot/skills/lisa-doctor/SKILL.md +46 -7
  30. package/plugins/lisa-copilot/skills/lisa-setup-github-repo/SKILL.md +7 -3
  31. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-cursor/skills/lisa-doctor/SKILL.md +46 -7
  33. package/plugins/lisa-cursor/skills/lisa-setup-github-repo/SKILL.md +7 -3
  34. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  36. package/plugins/lisa-expo-agy/plugin.json +1 -1
  37. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  41. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  42. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  46. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  47. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  51. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  52. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  56. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  57. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-phaser-cursor/.claude-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-typescript/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  66. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  67. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  71. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  72. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  74. package/plugins/src/base/skills/lisa-doctor/SKILL.md +46 -7
  75. package/plugins/src/base/skills/lisa-setup-github-repo/SKILL.md +7 -3
  76. package/scripts/check-required-check-promotions.mjs +69 -4
  77. package/scripts/lisa-github-repo-setup.sh +64 -5
  78. package/scripts/lisa-github-rulesets.sh +201 -21
  79. package/scripts/lisa-ruleset-payload.mjs +376 -0
  80. package/all/github-rulesets/base.json +0 -64
@@ -1974,12 +1974,32 @@ export const EVIDENCE_DEFAULTS = Object.freeze({
1974
1974
  ]),
1975
1975
  });
1976
1976
 
1977
+ /**
1978
+ * How hard a ruleset bites.
1979
+ *
1980
+ * `evaluate` and `disabled` are not milder versions of `active` — a ruleset in
1981
+ * either state asserts NOTHING, which is why `rulesetSignals` refuses to read
1982
+ * policy off one. Declaring the value is what lets a project say it is running
1983
+ * a ruleset in dry-run on purpose instead of having it silently do nothing.
1984
+ */
1985
+ export const ENFORCEMENTS = Object.freeze(["active", "evaluate", "disabled"]);
1986
+
1977
1987
  /**
1978
1988
  * Repository policy Lisa asserts.
1979
1989
  *
1980
1990
  * Policy differs from a gate in what failure means. A gate failing says stop
1981
1991
  * the change; a policy having drifted says put the setting back. That is why
1982
1992
  * `on_drift` defaults to repair and a gate never does.
1993
+ *
1994
+ * `review` and `ruleset` describe the SHAPE of the branch ruleset Lisa builds,
1995
+ * and they exist because a shipped `all/github-rulesets/base.json` used to
1996
+ * carry them instead. Seven of its fields were already declared here, so two
1997
+ * writers set the same settings and whichever ran last won with no drift
1998
+ * report between them; four more — `bypass_actors`, the `ref_name` conditions,
1999
+ * `required_approving_review_count`, `enforcement` — could not be declared at
2000
+ * all, which made a template value a fleet-wide lock no project could override.
2001
+ * The template is gone and the applier generates its payload from these fields,
2002
+ * so there is one writer and one declaration.
1983
2003
  */
1984
2004
  export const POLICY_SCHEMA = Object.freeze({
1985
2005
  merge: Object.freeze({
@@ -2008,6 +2028,16 @@ export const POLICY_SCHEMA = Object.freeze({
2008
2028
  has_wiki: "boolean",
2009
2029
  default_branch: "string",
2010
2030
  }),
2031
+ review: Object.freeze({
2032
+ required_approving_review_count: "number",
2033
+ require_code_owner_review: "boolean",
2034
+ }),
2035
+ ruleset: Object.freeze({
2036
+ enforcement: ENFORCEMENTS,
2037
+ include_refs: "string[]",
2038
+ exclude_refs: "string[]",
2039
+ bypass_actors: "object[]",
2040
+ }),
2011
2041
  });
2012
2042
 
2013
2043
  /** How to respond when reality has drifted from declared policy. */
@@ -2059,6 +2089,31 @@ export const RETIRED_CONFIG_KEYS = Object.freeze({
2059
2089
  */
2060
2090
  export const DEFAULT_RUNNER = "npm run";
2061
2091
 
2092
+ /**
2093
+ * What a presence-gated CI job does when nothing proves its property.
2094
+ *
2095
+ * Six `quality.yml` jobs decide what to run by grepping for a script. When the
2096
+ * script is absent AND no declaration covers the gate, they used to print a
2097
+ * notice and exit 0 — and two of the six post required branch-protection
2098
+ * contexts, so the context reported satisfied having proved nothing. That is
2099
+ * strictly worse than having no gate, because it manufactures evidence.
2100
+ *
2101
+ * This is ONE control for the whole family, deliberately. `bdd_coverage`
2102
+ * hand-rolled a private three-state input (`bdd_mode`) for the same decision;
2103
+ * repeating that six more times would be six more adoption operations and six
2104
+ * more places to drift.
2105
+ *
2106
+ * `warn` is the default and reproduces today's behaviour exactly, because
2107
+ * making absence fatal before a declaration is guaranteed turns every
2108
+ * unconfigured consumer red on the next bump — the ordering #2838 states.
2109
+ */
2110
+ export const UNPROVEN_RESPONSES = Object.freeze(["warn", "fail"]);
2111
+
2112
+ /**
2113
+ * The response Lisa assumes when a project declares none.
2114
+ */
2115
+ export const DEFAULT_UNPROVEN = "warn";
2116
+
2062
2117
  /**
2063
2118
  * Commands that ignore their arguments and return a fixed status.
2064
2119
  *
@@ -2107,14 +2162,25 @@ export function isRunner(value) {
2107
2162
  * The previous destructuring default fired only on `undefined`, so `null`,
2108
2163
  * `true` and `0` all became the runner, and `list --json` emitted whatever was
2109
2164
  * in the file for nineteen workflow facades to consume.
2165
+ * `unproven` is split out for the same reason: it is a RESERVED sibling of
2166
+ * `runner`, not a gate id. Left in the block it reaches `validateGates` as
2167
+ * `gates."unproven" is not a gate Lisa knows` — a BLOCKING problem — so a
2168
+ * project that set the control would have `validate` refuse it and get no
2169
+ * enforcement either.
2110
2170
  * @param {string} [cwd] Directory to look in.
2111
- * @returns {{runner: string, gates: object, policy: object, config: object}} Parsed config.
2112
- * @throws {Error} When `gates.runner` is present but is not a runner.
2171
+ * @returns {{runner: string, unproven: string, gates: object, policy: object, config: object}} Parsed config.
2172
+ * @throws {Error} When `gates.runner` is not a runner, or `gates.unproven` is not a permitted response.
2113
2173
  */
2114
2174
  export function readGates(cwd = process.cwd()) {
2115
2175
  const path = join(cwd, ".lisa.config.json");
2116
2176
  if (!existsSync(path)) {
2117
- return { runner: DEFAULT_RUNNER, gates: {}, policy: {}, config: {} };
2177
+ return {
2178
+ runner: DEFAULT_RUNNER,
2179
+ unproven: DEFAULT_UNPROVEN,
2180
+ gates: {},
2181
+ policy: {},
2182
+ config: {},
2183
+ };
2118
2184
  }
2119
2185
  let config;
2120
2186
  try {
@@ -2122,7 +2188,22 @@ export function readGates(cwd = process.cwd()) {
2122
2188
  } catch (err) {
2123
2189
  throw new Error(`.lisa.config.json is not readable: ${err.message}`);
2124
2190
  }
2125
- const { runner: declared, ...gates } = config.gates ?? {};
2191
+ const {
2192
+ runner: declared,
2193
+ unproven: declaredUnproven,
2194
+ ...gates
2195
+ } = config.gates ?? {};
2196
+ const unproven =
2197
+ declaredUnproven === undefined ? DEFAULT_UNPROVEN : declaredUnproven;
2198
+ if (!UNPROVEN_RESPONSES.includes(unproven)) {
2199
+ throw new Error(
2200
+ `gates.unproven is ${JSON.stringify(declaredUnproven)}, which is not ` +
2201
+ `one of ${UNPROVEN_RESPONSES.join(", ")}. An unrecognised response is ` +
2202
+ `refused rather than defaulted, because falling through to the ` +
2203
+ `permissive value is how a typo silently turns enforcement off for ` +
2204
+ `the project that asked for it.`
2205
+ );
2206
+ }
2126
2207
  const runner = declared === undefined ? DEFAULT_RUNNER : declared;
2127
2208
  if (!isRunner(runner)) {
2128
2209
  throw new Error(
@@ -2133,7 +2214,7 @@ export function readGates(cwd = process.cwd()) {
2133
2214
  `anything, which turns every configured gate green while proving nothing.`
2134
2215
  );
2135
2216
  }
2136
- return { runner, gates, policy: config.policy ?? {}, config };
2217
+ return { runner, unproven, gates, policy: config.policy ?? {}, config };
2137
2218
  }
2138
2219
 
2139
2220
  /**
@@ -2247,6 +2328,54 @@ export function validateGates(gates) {
2247
2328
  for (const [id, gate] of Object.entries(gates ?? {})) {
2248
2329
  problems.push(...validateGate(id, gate));
2249
2330
  }
2331
+ problems.push(...validateAwaitedContexts(gates));
2332
+ return problems;
2333
+ }
2334
+
2335
+ /**
2336
+ * Refuse two required gates that await one context with different pins.
2337
+ *
2338
+ * Two gates may legitimately be proved by the same external signal, and the
2339
+ * ruleset carries one entry per context — so the payload writer has to collapse
2340
+ * them. Collapsing silently keeps whichever it met first and DISCARDS the other
2341
+ * declaration's `posted_by`, which is how a project ends up requiring a context
2342
+ * pinned to an app it never named. An omitted id is not "no opinion" either: it
2343
+ * means unpinned, GitHub's "any source", which is a different requirement from
2344
+ * a pinned one.
2345
+ *
2346
+ * So an exact duplicate — same context, same pin, including both unpinned — is
2347
+ * fine and collapses. Anything else is refused here, before a payload is built
2348
+ * from it.
2349
+ * @param {object} gates The gates block.
2350
+ * @returns {string[]} Problems.
2351
+ */
2352
+ function validateAwaitedContexts(gates) {
2353
+ const problems = [];
2354
+ const seen = new Map();
2355
+ for (const [id, gate] of Object.entries(gates ?? {})) {
2356
+ if (!gate || typeof gate !== "object" || Array.isArray(gate)) continue;
2357
+ for (const [moment, value] of Object.entries(gate)) {
2358
+ const entry =
2359
+ typeof value === "string" ? { level: value } : (value ?? {});
2360
+ if (entry.level !== "required" || !entry.await) continue;
2361
+ const key = `${moment}\u0000${entry.await}`;
2362
+ const pin = entry.posted_by ?? null;
2363
+ const previous = seen.get(key);
2364
+ if (previous === undefined) {
2365
+ seen.set(key, { id, pin });
2366
+ continue;
2367
+ }
2368
+ if (previous.pin === pin) continue;
2369
+ problems.push(
2370
+ `gates."${id}"."${moment}" and gates."${previous.id}"."${moment}" both ` +
2371
+ `await "${entry.await}" but name different apps ` +
2372
+ `(${JSON.stringify(pin)} vs ${JSON.stringify(previous.pin)}). A ruleset ` +
2373
+ `carries ONE entry per context, so one of these pins would be dropped ` +
2374
+ `without a word — and an omitted posted_by means unpinned, which is a ` +
2375
+ `different requirement from a pinned one, not an absent opinion.`
2376
+ );
2377
+ }
2378
+ }
2250
2379
  return problems;
2251
2380
  }
2252
2381
 
@@ -2397,6 +2526,7 @@ function validateMoment(id, moment, value, known, interceptor, gateRun) {
2397
2526
  }
2398
2527
  problems.push(...validateEvidence(id, moment, entry.evidence ?? {}));
2399
2528
  }
2529
+ problems.push(...validatePostedBy(id, moment, entry));
2400
2530
  if (!known && !interceptor && !entry.await && !entry.run && !gateRun) {
2401
2531
  problems.push(
2402
2532
  `gates."${id}"."${moment}" names no prover and Lisa has no default task ` +
@@ -2406,6 +2536,45 @@ function validateMoment(id, moment, value, known, interceptor, gateRun) {
2406
2536
  return problems;
2407
2537
  }
2408
2538
 
2539
+ /**
2540
+ * Validate the app pin on an awaited signal.
2541
+ *
2542
+ * A required status check can name the ONE app allowed to post it, which is
2543
+ * what stops any other writer satisfying it. The pin therefore has to travel
2544
+ * with the declaration of who posts the signal, not with the ruleset payload —
2545
+ * a shipped template that hardcoded two vendor integration ids is precisely
2546
+ * what this replaces, and it was a fleet-wide lock no project could override.
2547
+ *
2548
+ * Refused on a gate Lisa RUNS, because those are posted by GitHub Actions and
2549
+ * the applier pins them to it already; a second, different pin on the same
2550
+ * context would name an app that can never post it and block every pull
2551
+ * request in the repository forever.
2552
+ * @param {string} id Gate id.
2553
+ * @param {string} moment Moment key.
2554
+ * @param {object} entry The moment entry.
2555
+ * @returns {string[]} Problems.
2556
+ */
2557
+ function validatePostedBy(id, moment, entry) {
2558
+ if (entry.posted_by === undefined) return [];
2559
+ const where = `gates."${id}"."${moment}".posted_by`;
2560
+ if (!entry.await) {
2561
+ return [
2562
+ `${where} names the app that posts a signal, but this moment declares ` +
2563
+ `no await. Lisa posts its own gates through GitHub Actions and pins ` +
2564
+ `them itself; a second pin would name an app that never posts the ` +
2565
+ `context, and a required check nothing can post blocks every pull ` +
2566
+ `request.`,
2567
+ ];
2568
+ }
2569
+ if (!Number.isInteger(entry.posted_by) || entry.posted_by <= 0) {
2570
+ return [
2571
+ `${where} is ${JSON.stringify(entry.posted_by)}; expected the positive ` +
2572
+ `integer GitHub App id that posts "${entry.await}".`,
2573
+ ];
2574
+ }
2575
+ return [];
2576
+ }
2577
+
2409
2578
  /**
2410
2579
  * Validate an awaited signal's evidence block.
2411
2580
  * @param {string} id Gate id.
@@ -2480,6 +2649,49 @@ function validateNeeds(id, gate) {
2480
2649
  return problems;
2481
2650
  }
2482
2651
 
2652
+ /**
2653
+ * Describe why a declared policy value does not fit its declared type.
2654
+ *
2655
+ * The vocabulary grew past `typeof` when the ruleset shape moved into config:
2656
+ * `bypass_actors` is an array of objects and `include_refs` an array of
2657
+ * strings, and `typeof` calls both of them "object", so the original check
2658
+ * would have accepted `"include_refs": {}` — a condition list that silently
2659
+ * matches no branch, which is a ruleset that protects nothing while reading as
2660
+ * configured. An expected value given as an ARRAY is a closed set of literals.
2661
+ * @param {string|readonly string[]} expected The schema entry.
2662
+ * @param {*} value The declared value.
2663
+ * @returns {string|null} The problem, or null when the value fits.
2664
+ */
2665
+ function policyTypeProblem(expected, value) {
2666
+ if (Array.isArray(expected)) {
2667
+ return expected.includes(value)
2668
+ ? null
2669
+ : `must be one of ${expected.join(", ")}, got ${JSON.stringify(value)}`;
2670
+ }
2671
+ if (expected === "string[]" || expected === "object[]") {
2672
+ const member = expected === "string[]" ? "string" : "object";
2673
+ if (!Array.isArray(value)) {
2674
+ return `must be an array of ${member}s, got ${typeof value}`;
2675
+ }
2676
+ const bad = value.findIndex(entry =>
2677
+ member === "string"
2678
+ ? typeof entry !== "string"
2679
+ : !entry || typeof entry !== "object" || Array.isArray(entry)
2680
+ );
2681
+ return bad === -1
2682
+ ? null
2683
+ : `must be an array of ${member}s; entry ${bad} is ${JSON.stringify(value[bad])}`;
2684
+ }
2685
+ if (expected === "number") {
2686
+ return Number.isInteger(value) && value >= 0
2687
+ ? null
2688
+ : `must be a non-negative integer, got ${JSON.stringify(value)}`;
2689
+ }
2690
+ return typeof value === expected
2691
+ ? null
2692
+ : `must be a ${expected}, got ${typeof value}`;
2693
+ }
2694
+
2483
2695
  /**
2484
2696
  * Validate the policy block.
2485
2697
  * @param {object} policy The policy block.
@@ -2522,10 +2734,9 @@ export function validatePolicy(policy) {
2522
2734
  );
2523
2735
  continue;
2524
2736
  }
2525
- if (typeof value !== expected) {
2526
- problems.push(
2527
- `policy.${section}.${field} must be a ${expected}, got ${typeof value}`
2528
- );
2737
+ const wrong = policyTypeProblem(expected, value);
2738
+ if (wrong) {
2739
+ problems.push(`policy.${section}.${field} ${wrong}`);
2529
2740
  }
2530
2741
  }
2531
2742
  }
@@ -2586,7 +2797,7 @@ export function auditConfigKeys(config) {
2586
2797
  * UNKNOWN, and an unknown manifest resolves exactly as it did before this
2587
2798
  * option existed — a caller that has not been taught to read the manifest
2588
2799
  * must not have its answers changed by silence.
2589
- * @returns {Array<{id: string, level: string, mode: string, awaits: string|null, task: string|null, command: string|null, label: string, work: string|null, alias: {from: string, to: string}|null, evidence: {proof: string[], no_work: string[], on_hollow: string, wait_minutes: number|null, on_timeout: string}|null}>} Resolved provers, sorted by gate id.
2800
+ * @returns {Array<{id: string, level: string, mode: string, awaits: string|null, postedBy: number|null, task: string|null, command: string|null, label: string, work: string|null, alias: {from: string, to: string}|null, evidence: {proof: string[], no_work: string[], on_hollow: string, wait_minutes: number|null, on_timeout: string}|null}>} Resolved provers, sorted by gate id.
2590
2801
  */
2591
2802
  export function resolveMoment({
2592
2803
  gates,
@@ -2640,6 +2851,7 @@ export function resolveMoment({
2640
2851
  level: "off",
2641
2852
  mode: "off",
2642
2853
  awaits: null,
2854
+ postedBy: null,
2643
2855
  task: null,
2644
2856
  command: null,
2645
2857
  label: REGISTRY[id]?.label ?? id,
@@ -2682,6 +2894,7 @@ export function resolveMoment({
2682
2894
  level: entry.level,
2683
2895
  mode: entry.await ? "await" : intercepts ? "intercept" : "run",
2684
2896
  awaits: entry.await ?? null,
2897
+ postedBy: entry.await ? (entry.posted_by ?? null) : null,
2685
2898
  task: entry.await || intercepts ? null : task,
2686
2899
  command: entry.await || intercepts || !task ? null : `${runner} ${task}`,
2687
2900
  label: definition?.label ?? id,