@codyswann/lisa 4.71.7 → 4.71.9

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 (105) hide show
  1. package/README.md +7 -0
  2. package/all/copy-overwrite/scripts/check-threshold-ratchet.mjs +571 -0
  3. package/all/copy-overwrite/scripts/check-workflow-load-failures.mjs +31 -9
  4. package/all/copy-overwrite/scripts/lib/automation-provenance-contract.mjs +19 -0
  5. package/all/copy-overwrite/scripts/lib/automation-provenance-files.mjs +49 -0
  6. package/all/copy-overwrite/scripts/lib/automation-provenance-local.mjs +54 -38
  7. package/all/copy-overwrite/scripts/lib/npm-update-allocate.mjs +8 -2
  8. package/all/copy-overwrite/scripts/lib/npm-update-bun.mjs +189 -0
  9. package/all/copy-overwrite/scripts/lib/npm-update-cancel-origin.mjs +7 -3
  10. package/all/copy-overwrite/scripts/lib/npm-update-cancellation-proof.mjs +6 -1
  11. package/all/copy-overwrite/scripts/lib/npm-update-contract.mjs +26 -5
  12. package/all/copy-overwrite/scripts/lib/npm-update-gate.mjs +8 -5
  13. package/all/copy-overwrite/scripts/lib/npm-update-github.mjs +2 -2
  14. package/all/copy-overwrite/scripts/lib/npm-update-helper-inventory.mjs +3 -4
  15. package/all/copy-overwrite/scripts/lib/npm-update-hook-installation.mjs +68 -1
  16. package/all/copy-overwrite/scripts/lib/npm-update-hosted-gate.mjs +13 -80
  17. package/all/copy-overwrite/scripts/lib/npm-update-leaf-contract.mjs +6 -5
  18. package/all/copy-overwrite/scripts/lib/npm-update-prepare.mjs +35 -7
  19. package/all/copy-overwrite/scripts/lib/npm-update-publish.mjs +1 -1
  20. package/all/copy-overwrite/scripts/npm-updater-helper-graph.json +43 -16
  21. package/all/copy-overwrite/scripts/threshold-ratchet-compare.mjs +521 -0
  22. package/all/copy-overwrite/scripts/threshold-ratchet-families.mjs +546 -0
  23. package/dist/configs/repo-scan.js +1 -1
  24. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  25. package/dist/core/lisa-owned-hash-ledger.js +60 -0
  26. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  27. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  28. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  29. package/dist/core/upstream-evidence-manifest.js +46 -32
  30. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  31. package/dist/strategies/merge.d.ts +1 -1
  32. package/dist/strategies/merge.d.ts.map +1 -1
  33. package/dist/strategies/merge.js +22 -2
  34. package/dist/strategies/merge.js.map +1 -1
  35. package/package.json +4 -4
  36. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  38. package/plugins/lisa/hooks/threshold-ratchet-compare.mjs +3 -3
  39. package/plugins/lisa/hooks/threshold-ratchet.mjs +1 -1
  40. package/plugins/lisa-agy/plugin.json +1 -1
  41. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  43. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  44. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-copilot/hooks/threshold-ratchet-compare.mjs +3 -3
  48. package/plugins/lisa-copilot/hooks/threshold-ratchet.mjs +1 -1
  49. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-cursor/hooks/threshold-ratchet-compare.mjs +3 -3
  51. package/plugins/lisa-cursor/hooks/threshold-ratchet.mjs +1 -1
  52. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  54. package/plugins/lisa-expo-agy/plugin.json +1 -1
  55. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  59. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  60. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  64. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  65. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  69. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  70. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  74. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  75. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  79. package/plugins/lisa-rails-agy/plugin.json +1 -1
  80. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  84. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  85. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  87. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  89. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  90. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  92. package/plugins/materialized-artifacts.json +3 -0
  93. package/plugins/src/base/hooks/threshold-ratchet-compare.mjs +3 -3
  94. package/plugins/src/base/hooks/threshold-ratchet.mjs +1 -1
  95. package/rails/copy-overwrite/scripts/check-threshold-ratchet.mjs +1 -1
  96. package/rails/copy-overwrite/scripts/threshold-ratchet-compare.mjs +3 -3
  97. package/scripts/build-plugins.sh +2 -1
  98. package/scripts/generate-scratch-supervisor-profile.mjs +1 -1
  99. package/scripts/install-claude-plugins.sh +8 -4
  100. package/scripts/two-channel-couplings.json +9 -7
  101. package/typescript/copy-overwrite/scripts/check-threshold-ratchet.mjs +1 -1
  102. package/typescript/copy-overwrite/scripts/threshold-ratchet-compare.mjs +3 -3
  103. /package/{rails → all}/copy-contents/scripts/lisa-mutation.sh +0 -0
  104. /package/{rails → all}/copy-overwrite/scripts/lisa-clean-git-env.sh +0 -0
  105. /package/{rails → all}/copy-overwrite/scripts/lisa-scratch-run.sh +0 -0
package/README.md CHANGED
@@ -259,6 +259,13 @@ servers, or configuration. Other harnesses retain their existing delivery
259
259
  behavior. For a new project, run the CLI ephemerally with
260
260
  `bunx @codyswann/lisa setup-project ...`.
261
261
 
262
+ The official Sentry Claude plugin is disabled by default because Lisa already
263
+ provides the Sentry MCP integration. To use Sentry's maintained plugin skills,
264
+ install that plugin and set `enabledPlugins["sentry@claude-plugins-official"]`
265
+ to `true` in your project's `.claude/settings.json`. Lisa preserves an explicit
266
+ boolean choice during apply and plugin sync. Enabling it can register Sentry
267
+ tools twice; the choice belongs to the project.
268
+
262
269
  Entire session capture is host-owned opt-in. Lisa's Rails settings and base
263
270
  Claude plugin register no Entire commands. Full apply also removes the seven
264
271
  exact automatic command strings shipped by older Lisa releases, because an
@@ -0,0 +1,571 @@
1
+ #!/usr/bin/env node
2
+ // This file is managed by Lisa and IS replaced on each `lisa` run.
3
+ // Do not edit directly — durable changes belong upstream in Lisa.
4
+
5
+ /**
6
+ * Threshold ratchet gate — quality thresholds may tighten, never weaken.
7
+ *
8
+ * Deterministic comparator shared by three enforcement layers:
9
+ * 1. Agent-time soft block: PostToolUse hook via threshold-ratchet.sh
10
+ * (`--hook`, exit 2 on weakening so the agent gets actionable feedback).
11
+ * 2. Pre-commit backstop: husky / lefthook (`--staged`, exit 1).
12
+ * 3. CI gate: reusable quality workflows (`--base <ref>`, exit 1),
13
+ * comparing against the merge-base so nothing weakened lands in a PR.
14
+ *
15
+ * Tier 1 — designed tunables: vitest/jest/simplecov/e2e thresholds
16
+ * (minimums) and eslint/rubocop thresholds (maximums). Tier 2 — stryker's
17
+ * break score and k6 expression bounds. Tier 3 — exemption additions
18
+ * (stryker mutate exclusions, thresholdRatchet.allow entries) which weaken
19
+ * a gate without touching a number. Audit ignore lists are deliberately NOT
20
+ * watched: the security-audit-handling ladder authorizes agents to add
21
+ * documented entries autonomously, and doctor readiness (B5) audits that
22
+ * every entry carries a written decision.
23
+ *
24
+ * Human override: `.lisa.config.json` → `thresholdRatchet.allow` entries
25
+ * ({ file, key, reason, until }). Honored ONLY from the baseline side (HEAD /
26
+ * merge-base), never from the change under review — an agent cannot grant
27
+ * itself an exception in the same change that weakens a gate. `key: "*"`
28
+ * allows every key in the file, and reports as file-wide so it is not mistaken
29
+ * for a key-scoped one.
30
+ *
31
+ * Exemptions END (#3856). `until` is a `YYYY-MM-DD` day the entry is live
32
+ * through; past it the entry stops exempting and the weakening it permitted is
33
+ * refused again, naming the entry, its scope, its reason and the remedy. An
34
+ * entry naming no evaluable condition still exempts — that keeps a project
35
+ * mid-migration off a red wall — but is reported on every run until somebody
36
+ * gives it a condition or deletes it. Modelled on `_thresholdsDivergence` in
37
+ * `stryker.conf.json`, the sibling exemption that already stops exempting when
38
+ * it goes stale; see `threshold-ratchet-families.mjs` `ALLOW_STATE`.
39
+ *
40
+ * One exception, and only one: a PROMOTION between deploy-chain branches
41
+ * (`--base` + `--head`, both named in `deploy.branches`, head upstream of
42
+ * base, head fully containing base). There the allow list is read from the
43
+ * head, because the change under review is the baseline plus history that has
44
+ * already passed this same gate. See `isPromotion` for why that is the only
45
+ * discriminator that holds.
46
+ *
47
+ * Extraction lives in threshold-ratchet-families.mjs; comparison rules in
48
+ * threshold-ratchet-compare.mjs. Zero dependencies.
49
+ */
50
+ import { execFileSync } from "node:child_process";
51
+ import * as fs from "node:fs";
52
+ import * as path from "node:path";
53
+ import { fileURLToPath } from "node:url";
54
+ import {
55
+ extractAllowEntries,
56
+ familyFor,
57
+ parseJson,
58
+ } from "./threshold-ratchet-families.mjs";
59
+ import {
60
+ applyAllowList,
61
+ compareFile,
62
+ describeAllowList,
63
+ formatReport,
64
+ } from "./threshold-ratchet-compare.mjs";
65
+
66
+ /**
67
+ * Standard git locations, checked in order so the executable comes from a
68
+ * fixed, unwriteable directory rather than a PATH lookup. The bare "git"
69
+ * fallback keeps unusual layouts (e.g. Windows git-bash) working.
70
+ *
71
+ * Order within that constraint is set by measurement, not by convention. On
72
+ * macOS `/usr/bin/git` is not git: it is Apple's `xcrun` shim, which locates a
73
+ * developer directory and re-executes the real binary there. Dispatching
74
+ * through it costs a **median 33 ms against 15 ms** for either
75
+ * developer-directory git, and **100 ms against 21 ms at p90** — randomized
76
+ * call order, fixed inter-call gaps, `git rev-parse --show-toplevel`, n=30 each
77
+ * (lisa#2898). The call does no work at all; the difference is the dispatch.
78
+ *
79
+ * The two entries promoted ahead of it are the developer-directory gits the
80
+ * shim itself re-executes. Both are `root:wheel` files in system locations, so
81
+ * this is the same trust class as `/usr/bin/git` and not a relaxation: the
82
+ * user-writable `/usr/local` and Homebrew entries stay behind it, exactly where
83
+ * they already were. Neither promoted path exists on Linux, so every CI runner
84
+ * resolves precisely what it resolved before.
85
+ */
86
+ const GIT_LOCATIONS = [
87
+ "/Library/Developer/CommandLineTools/usr/bin/git",
88
+ "/Applications/Xcode.app/Contents/Developer/usr/bin/git",
89
+ "/usr/bin/git",
90
+ "/usr/local/bin/git",
91
+ "/opt/homebrew/bin/git",
92
+ ];
93
+ const GIT = GIT_LOCATIONS.find(candidate => fs.existsSync(candidate)) ?? "git";
94
+
95
+ /** Git flag shared by every changed-file listing. */
96
+ const NAME_ONLY = "--name-only";
97
+ /**
98
+ * Suppress rename detection when discovering changed files.
99
+ *
100
+ * With it on, `git diff --name-only` reports a rename as the destination path
101
+ * only. Moving a watched threshold file to an unwatched path therefore lists
102
+ * one name the ratchet does not watch, and the loosening at the old path is
103
+ * never compared. Both sides have to be visible for the gate to mean anything.
104
+ */
105
+ const NO_RENAMES = "--no-renames";
106
+
107
+ /**
108
+ * Run git, returning stdout or null on any failure.
109
+ *
110
+ * The swallow is CORRECT here and is kept, because #2777 already made the
111
+ * consumer fail closed: a null change set reaches `threshold-ratchet: failing
112
+ * closed — an enforcement gate that cannot read the change set has not verified
113
+ * it`. That comment names the original defect exactly — "git() swallows every
114
+ * failure, so the ratchet passed exactly when it had no evidence" — so a killed
115
+ * child now joins the failures that already refuse. This call site needed the
116
+ * DEADLINE and nothing else.
117
+ *
118
+ * The deadline is written inline rather than imported. This file is
119
+ * materialized from `plugins/src/base/hooks/` into two stack lanes AND runs as
120
+ * an agent-time hook inside a plugin payload, which has no `./lib/` to import
121
+ * from — the same accommodation `preflight-secrets.mjs` makes, and the same one
122
+ * the entry guard below is written out for.
123
+ * @param {string[]} args Git arguments
124
+ * @param {string} [cwd] Working directory
125
+ * @returns {string | null} Captured stdout, or null when git failed
126
+ */
127
+ function git(args, cwd) {
128
+ try {
129
+ return execFileSync(GIT, args, {
130
+ cwd,
131
+ encoding: "utf-8",
132
+ stdio: ["ignore", "pipe", "ignore"],
133
+ // A hang detector, not a budget. `git` reached through PATH on macOS goes
134
+ // via Apple's xcrun shim, measured over 20s under load in #2887.
135
+ timeout: 30_000,
136
+ });
137
+ } catch {
138
+ // probe-direction: fail-closed — null never reaches a verdict as "no
139
+ // weakening found". `changePlan` turns it into `undeterminable`, which
140
+ // REFUSES, and `isPromotion` falls to the strict reading that keeps the
141
+ // allow list pinned to the baseline.
142
+ return null;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Resolve mode-specific candidate files and content readers.
148
+ * @param {"hook"|"staged"|"base"} mode Comparison mode
149
+ * @param {string} root Repo root
150
+ * @param {string | undefined} baseRef Base ref (base mode only)
151
+ * @param {string[] | undefined} onlyFiles Restrict to these repo-relative
152
+ * paths (hook mode with a known edited file)
153
+ * @returns {{ files: string[], baselineRef: string, readCurrent: (f: string) => string | null } | null}
154
+ * The comparison plan, or null when git state can't support the mode
155
+ */
156
+ function resolvePlan(mode, root, baseRef, onlyFiles) {
157
+ if (mode === "staged") {
158
+ const diff = git(["diff", "--cached", NAME_ONLY, NO_RENAMES], root);
159
+ if (diff === null) return null;
160
+ return {
161
+ files: diff.split("\n").filter(Boolean),
162
+ baselineRef: "HEAD",
163
+ readCurrent: f => git(["show", `:${f}`], root),
164
+ };
165
+ }
166
+ if (mode === "base") {
167
+ if (!baseRef) return null;
168
+ const mergeBase = git(["merge-base", baseRef, "HEAD"], root)?.trim();
169
+ if (!mergeBase) return null;
170
+ const diff = git(["diff", NAME_ONLY, NO_RENAMES, mergeBase, "HEAD"], root);
171
+ if (diff === null) return null;
172
+ return {
173
+ files: diff.split("\n").filter(Boolean),
174
+ baselineRef: mergeBase,
175
+ readCurrent: f => git(["show", `HEAD:${f}`], root),
176
+ };
177
+ }
178
+ const diff = git(["diff", NAME_ONLY, NO_RENAMES, "HEAD"], root);
179
+ if (diff === null) return null;
180
+ return {
181
+ files: onlyFiles ?? diff.split("\n").filter(Boolean),
182
+ baselineRef: "HEAD",
183
+ readCurrent: f => {
184
+ try {
185
+ return fs.readFileSync(path.join(root, f), "utf-8");
186
+ } catch {
187
+ // probe-direction: fail-closed — an unreadable current file compares as
188
+ // absent against the baseline, which is the strict side of the ratchet.
189
+ return null;
190
+ }
191
+ },
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Strip a remote prefix so `origin/staging` and `staging` compare equal to a
197
+ * branch name declared in `.lisa.config.json`.
198
+ * @param {string} ref Git ref, possibly remote-qualified
199
+ * @returns {string} Bare branch name
200
+ */
201
+ function bareBranch(ref) {
202
+ return ref.replace(/^refs\/heads\//u, "").replace(/^origin\//u, "");
203
+ }
204
+
205
+ /**
206
+ * The deploy chain, earliest environment first, from `deploy.branches`.
207
+ *
208
+ * Declaration order IS the chain order — that is already how Lisa reads it
209
+ * (dev → staging → production), and it is what makes "upstream of" decidable.
210
+ * @param {unknown} config Parsed `.lisa.config.json`
211
+ * @returns {string[]} Branch names in chain order
212
+ */
213
+ function deployChain(config) {
214
+ const branches = config?.deploy?.branches;
215
+ if (!branches || typeof branches !== "object") return [];
216
+ return Object.values(branches).filter(b => typeof b === "string" && b !== "");
217
+ }
218
+
219
+ /**
220
+ * Whether this is a promotion of one deploy-chain branch into the next.
221
+ *
222
+ * A promotion carries approved history into a branch that is behind, so the
223
+ * exemptions it brings with it are not new — each one already faced this gate
224
+ * on the upstream branch. Reading the allow list from the baseline there
225
+ * reports every one of them as newly added, and the documented remedy is
226
+ * circular: recording them means adding `thresholdRatchet.allow` entries,
227
+ * which is itself the Tier 3 change being blocked. That deadlocked the whole
228
+ * promotion lane (#2531).
229
+ *
230
+ * The discriminator is branch IDENTITY, not ancestry. "The head contains the
231
+ * base" is true of any ordinary topic branch that is up to date with its base
232
+ * — and a repository with a strict up-to-date branch-protection rule REQUIRES
233
+ * that of every PR — so ancestry alone would hand self-approval to exactly the
234
+ * changes Tier 3 exists to stop. Being a deploy-chain branch cannot be
235
+ * arranged by a topic branch: those branches are protected, so everything on
236
+ * them arrived through a reviewed PR that passed this same ratchet.
237
+ *
238
+ * Ancestry is still required, as a second condition rather than the only one:
239
+ * a head that has diverged from its base is not "the baseline plus approved
240
+ * history", and the strict reading should stand.
241
+ *
242
+ * The chain is read from the BASELINE config, so a change cannot declare
243
+ * itself a promotion by adding `deploy.branches` entries in the same commit.
244
+ * @param {string} root Repo root
245
+ * @param {unknown} baselineConfig `.lisa.config.json` at the baseline
246
+ * @param {string | undefined} baseRef Ref being merged into
247
+ * @param {string | undefined} headRef Ref being merged from
248
+ * @returns {boolean} True when the allow list may be read from the head
249
+ */
250
+ function isPromotion(root, baselineConfig, baseRef, headRef) {
251
+ if (!baseRef || !headRef) return false;
252
+ const chain = deployChain(baselineConfig);
253
+ const basePosition = chain.indexOf(bareBranch(baseRef));
254
+ const headPosition = chain.indexOf(bareBranch(headRef));
255
+ if (basePosition === -1 || headPosition === -1) return false;
256
+ if (headPosition >= basePosition) return false;
257
+ // Empty string on success, null when git exits non-zero or the ref is bogus.
258
+ if (git(["merge-base", "--is-ancestor", baseRef, headRef], root) !== null) {
259
+ return true;
260
+ }
261
+ // Both ARE deploy-chain branches, so this is a promotion that has diverged —
262
+ // typically a hotfix that landed on the base and was never synced down. The
263
+ // strict reading is correct here, but silence would leave an operator
264
+ // guessing why this promotion behaves differently from the last one.
265
+ process.stderr.write(
266
+ `threshold-ratchet: ${bareBranch(headRef)} does not contain ` +
267
+ `${bareBranch(baseRef)}, so this promotion is not the baseline plus ` +
268
+ `approved history and the allow list is read from the baseline. Sync ` +
269
+ `${bareBranch(baseRef)} down into ${bareBranch(headRef)} first.\n`
270
+ );
271
+ return false;
272
+ }
273
+
274
+ /**
275
+ * Resolve the allow list and say where it came from.
276
+ * @param {string} root Repo root
277
+ * @param {string} baselineRef Ref the comparison baselines against
278
+ * @param {"hook"|"staged"|"base"} mode Comparison mode
279
+ * @param {string | undefined} baseRef Base ref (base mode only)
280
+ * @param {string | undefined} headRef Head ref (base mode only)
281
+ * @returns {{ entries: object[], promotion: boolean, note: string | null }}
282
+ * Entries, whether this is a promotion, and an audit line to print when the
283
+ * entries came from anywhere but the baseline
284
+ */
285
+ function resolveAllowList(root, baselineRef, mode, baseRef, headRef) {
286
+ const baselineConfig = parseJson(
287
+ git(["show", `${baselineRef}:.lisa.config.json`], root)
288
+ );
289
+ if (mode !== "base" || !isPromotion(root, baselineConfig, baseRef, headRef)) {
290
+ return {
291
+ entries: extractAllowEntries(baselineConfig),
292
+ promotion: false,
293
+ note: null,
294
+ };
295
+ }
296
+ return {
297
+ entries: extractAllowEntries(
298
+ parseJson(git(["show", "HEAD:.lisa.config.json"], root))
299
+ ),
300
+ promotion: true,
301
+ note:
302
+ `threshold-ratchet: promotion ${bareBranch(headRef)} → ` +
303
+ `${bareBranch(baseRef)}; both are deploy-chain branches declared at ` +
304
+ `${baselineRef} and the head fully contains the base, so the allow list ` +
305
+ `is read from the head. Exemptions below were approved upstream, not by ` +
306
+ `this change.`,
307
+ };
308
+ }
309
+
310
+ /**
311
+ * Split off the allow-entry additions a promotion is carrying forward.
312
+ *
313
+ * `applyAllowList` never drops an `allow-added` finding, from either side —
314
+ * an exception must not approve its own creation. That is right for an
315
+ * ordinary PR and wrong for a promotion, where the entry is not being created:
316
+ * it already exists on the upstream branch, where its creation faced this same
317
+ * unconditional block and needed a human to clear it. Without this the fix
318
+ * would be cosmetic — a promotion carrying an approved exemption also carries
319
+ * the `.lisa.config.json` diff that records it, so it would still be blocked
320
+ * by the finding for the record of its own approval.
321
+ *
322
+ * Only `allow-added` is carried. An actual threshold weakening in the same
323
+ * promotion still has to be covered by an allow entry.
324
+ * @param {Array<{ type: string }>} findings All findings from the change
325
+ * @param {boolean} promotion Whether the change is a recognised promotion
326
+ * @returns {{ carried: object[], rest: object[] }} Findings excused as already
327
+ * approved upstream, and findings still subject to the allow list
328
+ */
329
+ function partitionCarriedEntries(findings, promotion) {
330
+ if (!promotion) return { carried: [], rest: findings };
331
+ return {
332
+ carried: findings.filter(f => f.type === "allow-added"),
333
+ rest: findings.filter(f => f.type !== "allow-added"),
334
+ };
335
+ }
336
+
337
+ /**
338
+ * Decide the exit code when the ratchet cannot determine what changed.
339
+ *
340
+ * `staged` and `base` are enforcement gates — pre-commit and CI. A gate that
341
+ * cannot see the change set has not found the change set clean, and returning 0
342
+ * reports exactly that. It fails closed, loudly.
343
+ *
344
+ * `hook` is advisory feedback to an agent mid-edit, fires on every tool call,
345
+ * and blocks nothing downstream. Failing it closed on a transient git hiccup
346
+ * would turn a hint into an outage, so it stays permissive — but still says so.
347
+ * @param {"hook"|"staged"|"base"} mode Comparison mode
348
+ * @param {string} reason What could not be determined
349
+ * @returns {number} Process exit code
350
+ */
351
+ function undeterminable(mode, reason) {
352
+ process.stderr.write(
353
+ `threshold-ratchet: ${reason}; the ratchet could not run in ${mode} mode\n`
354
+ );
355
+ if (mode === "hook") return 0;
356
+ process.stderr.write(
357
+ "threshold-ratchet: failing closed — an enforcement gate that cannot read the change set has not verified it\n"
358
+ );
359
+ return 1;
360
+ }
361
+
362
+ /**
363
+ * Run the ratchet for a mode, print the report, and return the exit code.
364
+ * @param {"hook"|"staged"|"base"} mode Comparison mode
365
+ * @param {string | undefined} [baseRef] Base ref (base mode only)
366
+ * @param {string[] | undefined} [onlyFiles] Restrict to these paths (hook mode)
367
+ * @param {string | undefined} [headRef] Head ref (base mode only), used solely
368
+ * to recognise a promotion between deploy-chain branches
369
+ * @returns {number} Process exit code (2 for hook mode, 1 otherwise; 0 clean)
370
+ */
371
+ function run(mode, baseRef, onlyFiles, headRef) {
372
+ const root = git(["rev-parse", "--show-toplevel"])?.trim();
373
+ if (!root) return undeterminable(mode, "not a git repository");
374
+ const plan = resolvePlan(mode, root, baseRef, onlyFiles);
375
+ if (!plan) return undeterminable(mode, "could not resolve the changed files");
376
+
377
+ const watched = plan.files.filter(f => familyFor(f));
378
+ if (watched.length === 0) return 0;
379
+
380
+ // A null baseline means one of two opposite things, and they must not be
381
+ // conflated: the file is NEW (nothing to weaken — pass), or it exists at the
382
+ // baseline and could not be read (nothing could be COMPARED — the one case
383
+ // where the ratchet cannot do its job). Both arrive here as null because
384
+ // `git()` swallows every failure, so the ratchet passed exactly when it had
385
+ // no evidence — failing open in its blind spot.
386
+ //
387
+ // `cat-file -e` answers the question `git show` cannot: does this path exist
388
+ // at that ref? Absent means new; present-but-unreadable means undeterminable,
389
+ // and an undeterminable ratchet must refuse rather than wave the change on.
390
+ const unreadable = watched.filter(
391
+ f =>
392
+ git(["show", `${plan.baselineRef}:${f}`], root) === null &&
393
+ git(["cat-file", "-e", `${plan.baselineRef}:${f}`], root) !== null
394
+ );
395
+ if (unreadable.length > 0) {
396
+ return undeterminable(
397
+ mode,
398
+ `could not read the baseline for ${unreadable.join(", ")} — ` +
399
+ `the file exists at ${plan.baselineRef} but its contents could not be ` +
400
+ `retrieved, so a loosened threshold could not be detected`
401
+ );
402
+ }
403
+
404
+ const allow = resolveAllowList(
405
+ root,
406
+ plan.baselineRef,
407
+ mode,
408
+ baseRef,
409
+ headRef
410
+ );
411
+ const findings = watched.flatMap(f =>
412
+ compareFile(
413
+ f,
414
+ git(["show", `${plan.baselineRef}:${f}`], root),
415
+ plan.readCurrent(f)
416
+ )
417
+ );
418
+ // The allow-list inventory is printed whether or not the change produced
419
+ // findings. An exemption that has outlived its condition is dead weight the
420
+ // moment the condition passes, and reporting it only when something else has
421
+ // already failed is how a list nobody reads stays a list nobody reads.
422
+ if (findings.length === 0) {
423
+ reportAllowList(allow.entries, []);
424
+ return 0;
425
+ }
426
+ return reportFindings(findings, allow, mode);
427
+ }
428
+
429
+ /**
430
+ * Print the allow entries a human has to act on, minus the ones a refusal is
431
+ * about to name in full.
432
+ * @param {object[]} entries The resolved allow list
433
+ * @param {Array<{ message: string }>} expired Refusals already being printed
434
+ * @returns {void}
435
+ */
436
+ function reportAllowList(entries, expired) {
437
+ for (const line of describeAllowList(entries)) {
438
+ if (expired.some(item => item.message.startsWith(line))) continue;
439
+ process.stdout.write(`threshold-ratchet: ${line}\n`);
440
+ }
441
+ }
442
+
443
+ /**
444
+ * Print the verdict for a change that produced findings.
445
+ * @param {object[]} findings Findings from every watched file
446
+ * @param {{ entries: object[], promotion: boolean, note: string | null }} allow
447
+ * The resolved allow list and its provenance
448
+ * @param {"hook"|"staged"|"base"} mode Comparison mode
449
+ * @returns {number} Process exit code (2 for hook mode, 1 otherwise; 0 clean)
450
+ */
451
+ function reportFindings(findings, allow, mode) {
452
+ const split = partitionCarriedEntries(findings, allow.promotion);
453
+ const { blocked, allowed, expired } = applyAllowList(
454
+ split.rest,
455
+ allow.entries
456
+ );
457
+ if (allow.note) process.stdout.write(`${allow.note}\n`);
458
+ reportAllowList(allow.entries, expired);
459
+ for (const finding of split.carried) {
460
+ process.stdout.write(
461
+ `threshold-ratchet: carried forward by this promotion, approved upstream — ${finding.message}\n`
462
+ );
463
+ }
464
+ for (const item of allowed) {
465
+ process.stdout.write(
466
+ `threshold-ratchet: ${item.message} (.lisa.config.json exception)\n`
467
+ );
468
+ }
469
+ for (const item of expired) {
470
+ process.stderr.write(`threshold-ratchet: refused — ${item.message}\n`);
471
+ }
472
+ if (blocked.length === 0) return 0;
473
+ process.stderr.write(`${formatReport(blocked)}\n`);
474
+ return mode === "hook" ? 2 : 1;
475
+ }
476
+
477
+ /**
478
+ * Handle `--hook` mode: parse the tool-use event from stdin and scope the
479
+ * check to the edited file (Edit/Write/NotebookEdit) or every changed
480
+ * watched file (Bash).
481
+ * @returns {number} Process exit code
482
+ */
483
+ function runHookMode() {
484
+ const state = { stdin: "" };
485
+ try {
486
+ state.stdin = fs.readFileSync(0, "utf-8");
487
+ } catch {
488
+ return 0;
489
+ }
490
+ const input = parseJson(state.stdin);
491
+ if (!input || typeof input !== "object") return 0;
492
+ if (input.tool_name === "Bash") return run("hook");
493
+ if (!["Edit", "Write", "NotebookEdit"].includes(input.tool_name)) return 0;
494
+ const filePath = input.tool_input?.file_path;
495
+ if (typeof filePath !== "string") return 0;
496
+ const root = git(["rev-parse", "--show-toplevel"])?.trim();
497
+ if (!root) return 0;
498
+ const rel = path
499
+ .relative(root, path.resolve(filePath))
500
+ .split(path.sep)
501
+ .join("/");
502
+ if (rel.startsWith("..") || !familyFor(rel)) return 0;
503
+ return run("hook", undefined, [rel]);
504
+ }
505
+
506
+ /**
507
+ * CLI entrypoint.
508
+ * @returns {number} Process exit code
509
+ */
510
+ function main() {
511
+ const args = process.argv.slice(2);
512
+ const headIndex = args.indexOf("--head");
513
+ const headRef = headIndex === -1 ? undefined : args[headIndex + 1];
514
+ if (args[0] === "--staged") return run("staged");
515
+ // `--head` is optional and additive: a caller that omits it gets exactly the
516
+ // behavior that shipped before promotions were recognised, so an older
517
+ // workflow driving a newer script stays strict rather than silently relaxing.
518
+ if (args[0] === "--base") return run("base", args[1], undefined, headRef);
519
+ if (args[0] === "--hook") return runHookMode();
520
+ process.stderr.write(
521
+ "usage: threshold-ratchet.mjs --hook | --staged | --base <ref> [--head <ref>]\n"
522
+ );
523
+ return 2;
524
+ }
525
+
526
+ /**
527
+ * True when `moduleUrl` names the module node was asked to run.
528
+ *
529
+ * The one implementation of this lives at `scripts/lib/invoked-as-script.mjs`,
530
+ * and every other shipped entry point imports it. This file cannot: it is
531
+ * materialized from `plugins/src/base/hooks/`, where it also runs as an
532
+ * agent-time hook, and a plugin payload has no `./lib/` to import from. So the
533
+ * rule is written out here rather than pointed at — the same accommodation
534
+ * `preflight-secrets.mjs` makes, for the same reason.
535
+ *
536
+ * Both sides are realpath'd. The previous spelling compared
537
+ * `fileURLToPath(import.meta.url)` against `path.resolve(process.argv[1])`, and
538
+ * `resolve` makes a path absolute without following symlinks. `import.meta.url`
539
+ * is the REAL path — node resolves ESM through realpath unless
540
+ * `--preserve-symlinks` or `--preserve-symlinks-main` is set — while `argv[1]`
541
+ * is whatever the caller typed. Reached through a symlinked checkout, a git
542
+ * worktree, or a `/tmp` path on macOS (`/tmp` being a symlink to `/private/tmp`),
543
+ * the two disagreed, `main()` never ran, and the process exited 0.
544
+ *
545
+ * For a CHECK that is a fail-OPEN, not a mild degradation: no output, exit 0,
546
+ * the npm script "succeeds", and the ratchet silently stops having an opinion.
547
+ * It was measured, not theorised — every control in the #2531 promotion tests
548
+ * no-opped until the fixture directory was realpath'd.
549
+ *
550
+ * Realpathing BOTH sides rather than only `argv[1]` matters under
551
+ * `--preserve-symlinks-main`, which tells node not to resolve the main entry:
552
+ * normalizing one side then compares a real path against a symlinked one and
553
+ * answers `false` for an entry point that WAS invoked directly. Any resolution
554
+ * error returns `false` — node loaded the entry from that path moments ago, so
555
+ * a path that will not resolve now is not the path this module came through.
556
+ * @param {string} moduleUrl - The caller's own `import.meta.url`.
557
+ * @param {string | undefined} [argv1] - Entry path; defaults to `process.argv[1]`.
558
+ * @returns {boolean} Whether the caller should run its CLI body.
559
+ */
560
+ export function invokedAsScript(moduleUrl, argv1 = process.argv[1]) {
561
+ if (!argv1) return false;
562
+ try {
563
+ return fs.realpathSync(argv1) === fs.realpathSync(fileURLToPath(moduleUrl));
564
+ } catch {
565
+ return false;
566
+ }
567
+ }
568
+
569
+ if (invokedAsScript(import.meta.url)) {
570
+ process.exit(main());
571
+ }
@@ -73,13 +73,19 @@ export function ghRequest(repo) {
73
73
  export function describe(result) {
74
74
  // Callers built before the startup-failure arm report no such key.
75
75
  const startupFailures = result.startupFailures ?? [];
76
- if (!result.covered) {
77
- return `check-workflow-load-failures: INCOMPLETE. The scan ${result.reason} It inspected ${result.inspected} run(s), but that is not the same as having covered the window, so this is an error rather than a pass.`;
78
- }
79
- if (result.loadFailures.length === 0 && startupFailures.length === 0) {
76
+ if (
77
+ result.covered &&
78
+ result.loadFailures.length === 0 &&
79
+ startupFailures.length === 0
80
+ ) {
80
81
  return `check-workflow-load-failures: OK. ${result.inspected} run(s) inspected across the last ${WINDOW_HOURS}h; no reusable workflow load failures found.`;
81
82
  }
82
83
  const sections = [];
84
+ if (!result.covered) {
85
+ sections.push(
86
+ `check-workflow-load-failures: INCOMPLETE. The scan ${result.reason} It inspected ${result.inspected} run(s), but that is not the same as having covered the window, so this is an error rather than a pass.`
87
+ );
88
+ }
83
89
  if (result.loadFailures.length > 0) {
84
90
  const lines = result.loadFailures.map(
85
91
  finding =>
@@ -87,7 +93,7 @@ export function describe(result) {
87
93
  );
88
94
  sections.push(
89
95
  [
90
- `check-workflow-load-failures: ${result.loadFailures.length} run(s) failed to LOAD a reusable workflow.`,
96
+ `check-workflow-load-failures: ${result.covered ? "" : "at least "}${result.loadFailures.length} run(s) failed to LOAD a reusable workflow.`,
91
97
  ...lines,
92
98
  "",
93
99
  "A load failure creates NO jobs, so there is no red job to open and no annotation naming the line. Check the upstream workflow's most recent commit for a syntax or schema error.",
@@ -106,9 +112,15 @@ export function describe(result) {
106
112
  .map(finding => finding.createdAt)
107
113
  .filter(Boolean)
108
114
  .sort();
109
- const paths = [...new Set(startupFailures.map(finding => finding.path))];
115
+ const paths = [
116
+ ...new Set(
117
+ startupFailures
118
+ .map(finding => finding.path)
119
+ .filter(value => value && value !== "BuildFailed")
120
+ ),
121
+ ];
110
122
  const summary = [
111
- `check-workflow-load-failures: ${startupFailures.length} run(s) ended in startup_failure — GitHub never started them, whatever workflow they are recorded under.`,
123
+ `check-workflow-load-failures: ${result.covered ? "" : "at least "}${startupFailures.length} run(s) ended in startup_failure — GitHub never started them, whatever workflow they are recorded under.`,
112
124
  ...lines,
113
125
  "",
114
126
  `First: ${times[0] ?? "unknown"}. Last: ${times.at(-1) ?? "unknown"}. Distinct workflow path(s): ${paths.length}.`,
@@ -117,9 +129,19 @@ export function describe(result) {
117
129
  // ended paging — the outage shape is every run INSIDE the window, so the
118
130
  // comparison belongs to `inWindow` (absent on older callers → fall back).
119
131
  const inWindow = result.inWindow ?? result.inspected;
120
- if (inWindow > 0 && startupFailures.length === inWindow) {
132
+ if (
133
+ result.covered &&
134
+ inWindow > 0 &&
135
+ startupFailures.length === inWindow &&
136
+ (paths.length > 1 ||
137
+ startupFailures.some(finding => finding.path === "BuildFailed"))
138
+ ) {
139
+ summary.push(
140
+ "EVERY run in the window failed to start across multiple workflows or GitHub's BuildFailed placeholder. This may indicate an account or Actions service availability problem. Check Actions availability and account restrictions, and verify the workflow files before deciding the cause."
141
+ );
142
+ } else if (paths.length === 1) {
121
143
  summary.push(
122
- "EVERY run in the window failed to start — that is the shape of an account-, plan- or billing-level outage (for example a private-repo org that dropped to GitHub Free), not a workflow-file error. Check the org's Actions availability before editing workflow source."
144
+ "One workflow file is identified among the observed startup failures. Inspect the workflow file and check Actions availability; the run count alone does not establish the cause."
123
145
  );
124
146
  }
125
147
  sections.push(summary.join("\n"));
@@ -26,6 +26,25 @@ const DESCRIPTOR_KEYS = [
26
26
  const HEX = /^[0-9a-f]{64}$/;
27
27
  const OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
28
28
 
29
+ /** Only an explicit original Bun digest extends the unchanged npm-only schema. */
30
+ export function optionalLockFields(value) {
31
+ if (!Object.hasOwn(value, "bunLockSha256")) return [];
32
+ requireProof(
33
+ typeof value.bunLockSha256 === "string" && HEX.test(value.bunLockSha256),
34
+ "invalid original Bun lock digest"
35
+ );
36
+ return ["bunLockSha256"];
37
+ }
38
+
39
+ /** Signed original presence selects one closed two- or three-file cohort. */
40
+ export function proposalFileNames(value) {
41
+ const files = optionalLockFields(value).length
42
+ ? ["bun.lock", ...FILES]
43
+ : FILES;
44
+ exactKeys(value.files, files, "proposal files");
45
+ return files;
46
+ }
47
+
29
48
  /** Canonical bytes are shared by allocator and consumer, not inferred hashes. */
30
49
  export function canonicalJson(value) {
31
50
  if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;