@codyswann/lisa 2.240.0 → 2.242.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 (109) hide show
  1. package/dist/sync/registry.d.ts.map +1 -1
  2. package/dist/sync/registry.js +7 -0
  3. package/dist/sync/registry.js.map +1 -1
  4. package/package.json +1 -1
  5. package/plugins/lisa/.claude-plugin/plugin.json +10 -1
  6. package/plugins/lisa/.codex-plugin/hooks.json +9 -0
  7. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  8. package/plugins/lisa/.codex-plugin/skills/lisa-github-build-intake/SKILL.md +2 -0
  9. package/plugins/lisa/.codex-plugin/skills/lisa-jira-build-intake/SKILL.md +2 -0
  10. package/plugins/lisa/.codex-plugin/skills/lisa-linear-build-intake/SKILL.md +2 -0
  11. package/plugins/lisa/hooks/threshold-ratchet-compare.mjs +315 -0
  12. package/plugins/lisa/hooks/threshold-ratchet-families.mjs +295 -0
  13. package/plugins/lisa/hooks/threshold-ratchet.mjs +213 -0
  14. package/plugins/lisa/hooks/threshold-ratchet.sh +22 -0
  15. package/plugins/lisa/rules/eager/claim-archaeology.md +37 -0
  16. package/plugins/lisa/rules/reference/claim-archaeology.md +142 -0
  17. package/plugins/lisa/skills/lisa-github-build-intake/SKILL.md +2 -0
  18. package/plugins/lisa/skills/lisa-jira-build-intake/SKILL.md +2 -0
  19. package/plugins/lisa/skills/lisa-linear-build-intake/SKILL.md +2 -0
  20. package/plugins/lisa-agy/plugin.json +1 -1
  21. package/plugins/lisa-agy/skills/lisa-github-build-intake/SKILL.md +2 -0
  22. package/plugins/lisa-agy/skills/lisa-jira-build-intake/SKILL.md +2 -0
  23. package/plugins/lisa-agy/skills/lisa-linear-build-intake/SKILL.md +2 -0
  24. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  26. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  27. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-copilot/.claude-plugin/plugin.json +10 -1
  30. package/plugins/lisa-copilot/hooks/threshold-ratchet-compare.mjs +315 -0
  31. package/plugins/lisa-copilot/hooks/threshold-ratchet-families.mjs +295 -0
  32. package/plugins/lisa-copilot/hooks/threshold-ratchet.mjs +213 -0
  33. package/plugins/lisa-copilot/hooks/threshold-ratchet.sh +22 -0
  34. package/plugins/lisa-copilot/rules/eager/claim-archaeology.md +37 -0
  35. package/plugins/lisa-copilot/rules/reference/claim-archaeology.md +142 -0
  36. package/plugins/lisa-copilot/skills/lisa-github-build-intake/SKILL.md +2 -0
  37. package/plugins/lisa-copilot/skills/lisa-jira-build-intake/SKILL.md +2 -0
  38. package/plugins/lisa-copilot/skills/lisa-linear-build-intake/SKILL.md +2 -0
  39. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-cursor/hooks/hooks.json +4 -0
  41. package/plugins/lisa-cursor/hooks/threshold-ratchet-compare.mjs +315 -0
  42. package/plugins/lisa-cursor/hooks/threshold-ratchet-families.mjs +295 -0
  43. package/plugins/lisa-cursor/hooks/threshold-ratchet.mjs +213 -0
  44. package/plugins/lisa-cursor/hooks/threshold-ratchet.sh +22 -0
  45. package/plugins/lisa-cursor/rules/claim-archaeology-reference.mdc +147 -0
  46. package/plugins/lisa-cursor/rules/claim-archaeology.mdc +42 -0
  47. package/plugins/lisa-cursor/skills/lisa-github-build-intake/SKILL.md +2 -0
  48. package/plugins/lisa-cursor/skills/lisa-jira-build-intake/SKILL.md +2 -0
  49. package/plugins/lisa-cursor/skills/lisa-linear-build-intake/SKILL.md +2 -0
  50. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-expo-agy/plugin.json +1 -1
  53. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  58. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  62. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  63. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  67. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  68. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  72. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  73. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  77. package/plugins/lisa-rails-agy/plugin.json +1 -1
  78. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  82. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  83. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  87. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  88. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  90. package/plugins/src/base/.claude-plugin/plugin.json +9 -0
  91. package/plugins/src/base/hooks/threshold-ratchet-compare.mjs +315 -0
  92. package/plugins/src/base/hooks/threshold-ratchet-families.mjs +295 -0
  93. package/plugins/src/base/hooks/threshold-ratchet.mjs +213 -0
  94. package/plugins/src/base/hooks/threshold-ratchet.sh +22 -0
  95. package/plugins/src/base/rules/eager/claim-archaeology.md +37 -0
  96. package/plugins/src/base/rules/reference/claim-archaeology.md +142 -0
  97. package/plugins/src/base/skills/lisa-github-build-intake/SKILL.md +2 -0
  98. package/plugins/src/base/skills/lisa-jira-build-intake/SKILL.md +2 -0
  99. package/plugins/src/base/skills/lisa-linear-build-intake/SKILL.md +2 -0
  100. package/rails/copy-overwrite/lefthook.yml +6 -0
  101. package/rails/copy-overwrite/scripts/check-threshold-ratchet.mjs +213 -0
  102. package/rails/copy-overwrite/scripts/threshold-ratchet-compare.mjs +315 -0
  103. package/rails/copy-overwrite/scripts/threshold-ratchet-families.mjs +295 -0
  104. package/scripts/build-plugins.sh +20 -0
  105. package/scripts/lib/per-agent-hook-filter.mjs +7 -0
  106. package/typescript/copy-contents/.husky/pre-commit +17 -0
  107. package/typescript/copy-overwrite/scripts/check-threshold-ratchet.mjs +213 -0
  108. package/typescript/copy-overwrite/scripts/threshold-ratchet-compare.mjs +315 -0
  109. package/typescript/copy-overwrite/scripts/threshold-ratchet-families.mjs +295 -0
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Threshold ratchet — watched file families and value extractors.
3
+ *
4
+ * Pure extraction layer: given file contents, produce comparable constraint
5
+ * maps. No filesystem or git access. See threshold-ratchet.mjs for the CLI
6
+ * and threshold-ratchet-compare.mjs for the comparison rules.
7
+ */
8
+
9
+ /**
10
+ * File families the ratchet watches. `kind` selects the extractor;
11
+ * `direction` applies to numeric-leaf kinds ("min" values may only rise,
12
+ * "max" values may only fall).
13
+ */
14
+ export const FAMILIES = [
15
+ {
16
+ id: "coverage",
17
+ match: /(^|\/)(vitest|jest)\.thresholds\.json$/,
18
+ kind: "json-num",
19
+ direction: "min",
20
+ },
21
+ {
22
+ id: "simplecov",
23
+ match: /(^|\/)simplecov\.thresholds\.json$/,
24
+ kind: "json-num",
25
+ direction: "min",
26
+ },
27
+ {
28
+ id: "e2e",
29
+ match: /(^|\/)e2e\.thresholds\.json$/,
30
+ kind: "json-num",
31
+ direction: "min",
32
+ },
33
+ {
34
+ id: "eslint",
35
+ match: /(^|\/)eslint\.thresholds\.json$/,
36
+ kind: "json-num",
37
+ direction: "max",
38
+ },
39
+ {
40
+ id: "rubocop",
41
+ match: /(^|\/)rubocop\.thresholds\.yml$/,
42
+ kind: "rubocop-yaml",
43
+ direction: "max",
44
+ },
45
+ { id: "stryker", match: /(^|\/)stryker\.conf\.json$/, kind: "stryker" },
46
+ {
47
+ id: "k6",
48
+ match: /(^|\/)\.github\/k6\/thresholds\/[^/]+\.json$/,
49
+ kind: "k6",
50
+ },
51
+ {
52
+ id: "audit-ignore",
53
+ match: /(^|\/)audit\.ignore\.(config|local)\.json$/,
54
+ kind: "exemption-list",
55
+ },
56
+ {
57
+ id: "lisa-config",
58
+ match: /(^|\/)\.lisa\.config\.json$/,
59
+ kind: "allow-list",
60
+ },
61
+ ];
62
+
63
+ /**
64
+ * Find the family a repo-relative path belongs to.
65
+ * @param {string} relPath Repo-relative path (forward slashes)
66
+ * @returns {(typeof FAMILIES)[number] | undefined} The matching family, or
67
+ * undefined when the path is not a watched gate file
68
+ */
69
+ export function familyFor(relPath) {
70
+ return FAMILIES.find(f => f.match.test(relPath));
71
+ }
72
+
73
+ /**
74
+ * Safe JSON parse.
75
+ * @param {string | null | undefined} text JSON text
76
+ * @returns {unknown | undefined} Parsed value, or undefined on failure
77
+ */
78
+ export function parseJson(text) {
79
+ if (typeof text !== "string") return undefined;
80
+ try {
81
+ return JSON.parse(text);
82
+ } catch {
83
+ return undefined;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Walk a JSON object and collect numeric leaves as dotted-path constraints.
89
+ * Keys starting with "_" (e.g. `_comment`) are documentation, not thresholds.
90
+ * @param {unknown} node Parsed JSON value
91
+ * @param {"min"|"max"} direction Ratchet direction for every leaf
92
+ * @param {string} [prefix] Dotted path accumulated so far
93
+ * @returns {Map<string, { value: number, direction: "min"|"max" }>} Dotted
94
+ * path → numeric constraint for every finite numeric leaf
95
+ */
96
+ export function extractNumericLeaves(node, direction, prefix = "") {
97
+ const out = new Map();
98
+ if (node === null || typeof node !== "object" || Array.isArray(node)) {
99
+ return out;
100
+ }
101
+ for (const [key, value] of Object.entries(node)) {
102
+ if (key.startsWith("_")) continue;
103
+ const p = prefix ? `${prefix}.${key}` : key;
104
+ if (typeof value === "number" && Number.isFinite(value)) {
105
+ out.set(p, { value, direction });
106
+ } else if (value && typeof value === "object" && !Array.isArray(value)) {
107
+ for (const [cp, c] of extractNumericLeaves(value, direction, p)) {
108
+ out.set(cp, c);
109
+ }
110
+ }
111
+ }
112
+ return out;
113
+ }
114
+
115
+ /**
116
+ * Minimal parser for rubocop.thresholds.yml — a two-level document of
117
+ * `Section:` headers with indented `Key: <number>` scalars (comments and
118
+ * blank lines ignored). Deliberately NOT a general YAML parser: the file is
119
+ * Lisa-authored with this exact shape, and hand-parsing keeps the gate
120
+ * dependency-free with no backtracking-prone regexes.
121
+ * @param {string} text File contents
122
+ * @param {"min"|"max"} direction Ratchet direction for every scalar
123
+ * @returns {Map<string, { value: number, direction: "min"|"max" }>} Dotted
124
+ * `Section.Key` path → numeric constraint
125
+ */
126
+ export function extractRubocopThresholds(text, direction) {
127
+ const out = new Map();
128
+ const state = { section: "" };
129
+ for (const rawLine of text.split("\n")) {
130
+ const hash = rawLine.indexOf("#");
131
+ const line = (hash >= 0 ? rawLine.slice(0, hash) : rawLine).trimEnd();
132
+ if (!line.trim()) continue;
133
+ const indented = line.startsWith(" ") || line.startsWith("\t");
134
+ if (!indented && line.endsWith(":")) {
135
+ state.section = line.slice(0, -1).trim();
136
+ continue;
137
+ }
138
+ const colon = line.indexOf(":");
139
+ if (!indented || colon < 0 || !state.section) continue;
140
+ const key = line.slice(0, colon).trim();
141
+ const rawValue = line.slice(colon + 1).trim();
142
+ // Number("") is 0, so an empty value (e.g. a nested `Exclude:` list
143
+ // header) must be skipped, not recorded as a zero threshold.
144
+ const value = Number(rawValue);
145
+ if (key && rawValue !== "" && Number.isFinite(value)) {
146
+ out.set(`${state.section}.${key}`, { value, direction });
147
+ }
148
+ }
149
+ return out;
150
+ }
151
+
152
+ /**
153
+ * Extract the gating constraint from stryker.conf.json: only
154
+ * `thresholds.break` fails a run (`high`/`low` are reporting bands).
155
+ * @param {unknown} conf Parsed stryker.conf.json
156
+ * @returns {Map<string, { value: number, direction: "min"|"max" }>} The
157
+ * `thresholds.break` constraint when present, otherwise an empty map
158
+ */
159
+ export function extractStrykerConstraints(conf) {
160
+ const out = new Map();
161
+ const breakValue = conf?.thresholds?.break;
162
+ if (typeof breakValue === "number" && Number.isFinite(breakValue)) {
163
+ out.set("thresholds.break", { value: breakValue, direction: "min" });
164
+ }
165
+ return out;
166
+ }
167
+
168
+ /**
169
+ * Split a stryker `mutate` array into positive globs and negations.
170
+ * @param {unknown} conf Parsed stryker.conf.json
171
+ * @returns {{ positives: Set<string>, negations: Set<string> }} Globs that
172
+ * include files vs. `!`-prefixed globs that exclude them
173
+ */
174
+ export function extractStrykerMutate(conf) {
175
+ const positives = new Set();
176
+ const negations = new Set();
177
+ const mutate = Array.isArray(conf?.mutate) ? conf.mutate : [];
178
+ for (const glob of mutate) {
179
+ if (typeof glob !== "string") continue;
180
+ if (glob.startsWith("!")) negations.add(glob);
181
+ else positives.add(glob);
182
+ }
183
+ return { positives, negations };
184
+ }
185
+
186
+ /**
187
+ * Parse one k6 threshold expression (`p(95)<1000`, `rate>=0.99`, …) into a
188
+ * ratchet constraint. Upper bounds (`<`, `<=`) may only decrease; lower
189
+ * bounds (`>`, `>=`) may only increase. Hand-parsed — no regex.
190
+ * @param {string} expr Threshold expression
191
+ * @returns {{ value: number, direction: "min"|"max" } | undefined} The bound
192
+ * as a constraint, or undefined when the expression has no numeric bound
193
+ */
194
+ export function parseK6Expression(expr) {
195
+ for (const op of ["<=", ">=", "<", ">"]) {
196
+ const idx = expr.indexOf(op);
197
+ if (idx < 0) continue;
198
+ const value = Number(expr.slice(idx + op.length).trim());
199
+ if (!Number.isFinite(value)) return undefined;
200
+ return { value, direction: op.startsWith("<") ? "max" : "min" };
201
+ }
202
+ return undefined;
203
+ }
204
+
205
+ /**
206
+ * Extract constraints from a k6 thresholds file. Each metric contributes its
207
+ * expression bound(s) plus (when present) `abortOnFail` boolean constraints —
208
+ * turning abortOnFail off makes the gate advisory, which is a weakening.
209
+ * Handles every documented k6 shape: a bare expression string, a single long
210
+ * form `{ threshold, abortOnFail }` object, and arrays mixing both.
211
+ * @param {unknown} conf Parsed k6 thresholds JSON
212
+ * @returns {{
213
+ * numeric: Map<string, { value: number, direction: "min"|"max" }>,
214
+ * booleans: Map<string, boolean>,
215
+ * }} Numeric bounds keyed by `<metric>.threshold[i]` and abortOnFail flags
216
+ * keyed by `<metric>.abortOnFail` (`[i]`-suffixed for array items)
217
+ */
218
+ export function extractK6Constraints(conf) {
219
+ const numeric = new Map();
220
+ const booleans = new Map();
221
+ const thresholds = conf?.thresholds;
222
+ if (thresholds && typeof thresholds === "object") {
223
+ for (const [metric, spec] of Object.entries(thresholds)) {
224
+ const isArray = Array.isArray(spec);
225
+ const items = isArray ? spec : [spec];
226
+ items.forEach((item, i) => {
227
+ const expr =
228
+ typeof item === "string"
229
+ ? item
230
+ : item && typeof item === "object" && !Array.isArray(item)
231
+ ? item.threshold
232
+ : undefined;
233
+ if (typeof expr === "string") {
234
+ const c = parseK6Expression(expr);
235
+ if (c) numeric.set(`${metric}.threshold[${i}]`, c);
236
+ }
237
+ if (
238
+ item &&
239
+ typeof item === "object" &&
240
+ !Array.isArray(item) &&
241
+ typeof item.abortOnFail === "boolean"
242
+ ) {
243
+ booleans.set(
244
+ isArray ? `${metric}.abortOnFail[${i}]` : `${metric}.abortOnFail`,
245
+ item.abortOnFail
246
+ );
247
+ }
248
+ });
249
+ }
250
+ }
251
+ return { numeric, booleans };
252
+ }
253
+
254
+ /**
255
+ * Flatten an exemption file (audit ignore list) into a set of entry tokens.
256
+ * Arrays contribute their string items; objects contribute their keys.
257
+ * @param {unknown} conf Parsed JSON
258
+ * @returns {Set<string>} One token per exemption entry
259
+ */
260
+ export function extractExemptionEntries(conf) {
261
+ const out = new Set();
262
+ if (Array.isArray(conf)) {
263
+ for (const item of conf) {
264
+ if (typeof item === "string") out.add(item);
265
+ else if (item && typeof item === "object") out.add(JSON.stringify(item));
266
+ }
267
+ } else if (conf && typeof conf === "object") {
268
+ for (const [key, value] of Object.entries(conf)) {
269
+ if (Array.isArray(value)) {
270
+ for (const item of value) {
271
+ out.add(
272
+ `${key}:${typeof item === "string" ? item : JSON.stringify(item)}`
273
+ );
274
+ }
275
+ } else {
276
+ out.add(key);
277
+ }
278
+ }
279
+ }
280
+ return out;
281
+ }
282
+
283
+ /**
284
+ * Extract thresholdRatchet.allow entries from parsed .lisa.config.json.
285
+ * @param {unknown} config Parsed config (may be undefined)
286
+ * @returns {Array<{ file: string, key: string, reason?: string }>} The
287
+ * well-formed allow entries; malformed entries are dropped
288
+ */
289
+ export function extractAllowEntries(config) {
290
+ const raw = config?.thresholdRatchet?.allow;
291
+ if (!Array.isArray(raw)) return [];
292
+ return raw.filter(
293
+ e => e && typeof e.file === "string" && typeof e.key === "string"
294
+ );
295
+ }
@@ -70,6 +70,26 @@ build_per_agent_variant() {
70
70
  # Base plugin
71
71
  build_plugin base lisa
72
72
 
73
+ # Threshold-ratchet comparator: the canonical implementation lives in the base
74
+ # plugin's hooks/ (agent-time layer). The pre-commit (husky/lefthook) and CI
75
+ # layers in downstream projects run the same file as scripts/
76
+ # check-threshold-ratchet.mjs, delivered via the stack templates. Sync the
77
+ # copies here so they can never drift; a unit test asserts byte-equality.
78
+ if [ -f "$SRC_DIR/base/hooks/threshold-ratchet.mjs" ]; then
79
+ for ratchet_scripts_dir in \
80
+ "$ROOT_DIR/typescript/copy-overwrite/scripts" \
81
+ "$ROOT_DIR/rails/copy-overwrite/scripts"; do
82
+ mkdir -p "$ratchet_scripts_dir"
83
+ # The entry point takes the template check-* naming; its relative imports
84
+ # (threshold-ratchet-*.mjs) keep their canonical names in both trees.
85
+ cp "$SRC_DIR/base/hooks/threshold-ratchet.mjs" \
86
+ "$ratchet_scripts_dir/check-threshold-ratchet.mjs"
87
+ cp "$SRC_DIR/base/hooks/threshold-ratchet-families.mjs" \
88
+ "$SRC_DIR/base/hooks/threshold-ratchet-compare.mjs" \
89
+ "$ratchet_scripts_dir/"
90
+ done
91
+ fi
92
+
73
93
  # Stack-specific plugins (NO base copy)
74
94
  STACKS=(typescript expo nestjs cdk harper-fabric phaser rails)
75
95
  for stack in "${STACKS[@]}"; do
@@ -78,6 +78,13 @@ const SCRIPT_RULES = {
78
78
  agy: true,
79
79
  copilot: true,
80
80
  },
81
+ "threshold-ratchet.sh": {
82
+ claude: true,
83
+ codex: true,
84
+ cursor: true,
85
+ agy: false, // agy hooks ship only via AGY_PLUGIN_HOOKS (root hooks.json) and don't fire headless; the pre-commit + CI ratchet layers carry parity there
86
+ copilot: true,
87
+ },
81
88
  // Unregistered scripts — exclude by default until classified.
82
89
  "ticket-sync-reminder.sh": {
83
90
  claude: false,
@@ -102,6 +102,23 @@ fi
102
102
  # exit 1
103
103
  # fi
104
104
 
105
+ # Threshold ratchet: quality thresholds may tighten, never weaken. The staged
106
+ # change is compared against HEAD by the same deterministic comparator the
107
+ # agent-time hooks and CI run; human-approved exceptions live in
108
+ # .lisa.config.json under thresholdRatchet.allow.
109
+ if [ -f "scripts/check-threshold-ratchet.mjs" ] && command -v node >/dev/null 2>&1; then
110
+ echo "📐 Checking threshold ratchet..."
111
+ node scripts/check-threshold-ratchet.mjs --staged
112
+ if [ $? -ne 0 ]; then
113
+ echo ""
114
+ echo "❌ A quality threshold was weakened. Thresholds may only tighten."
115
+ echo " Fix the code to meet the current gate, or ask a human to record a"
116
+ echo " documented exception in .lisa.config.json (thresholdRatchet.allow)."
117
+ echo ""
118
+ exit 1
119
+ fi
120
+ fi
121
+
105
122
  # Run type check on entire project (can't be done incrementally)
106
123
  echo "🔍 Running type check..."
107
124
  $RUNNER typecheck
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Threshold ratchet gate — quality thresholds may tighten, never weaken.
4
+ *
5
+ * Deterministic comparator shared by three enforcement layers:
6
+ * 1. Agent-time soft block: PostToolUse hook via threshold-ratchet.sh
7
+ * (`--hook`, exit 2 on weakening so the agent gets actionable feedback).
8
+ * 2. Pre-commit backstop: husky / lefthook (`--staged`, exit 1).
9
+ * 3. CI gate: reusable quality workflows (`--base <ref>`, exit 1),
10
+ * comparing against the merge-base so nothing weakened lands in a PR.
11
+ *
12
+ * Tier 1 — designed tunables: vitest/jest/simplecov/e2e thresholds
13
+ * (minimums) and eslint/rubocop thresholds (maximums). Tier 2 — stryker's
14
+ * break score and k6 expression bounds. Tier 3 — exemption additions
15
+ * (audit-ignore entries, stryker mutate exclusions, thresholdRatchet.allow
16
+ * entries) which weaken a gate without touching a number.
17
+ *
18
+ * Human override: `.lisa.config.json` → `thresholdRatchet.allow` entries
19
+ * ({ file, key, reason }). Honored ONLY from the baseline side (HEAD /
20
+ * merge-base), never from the change under review — an agent cannot grant
21
+ * itself an exception in the same change that weakens a gate. `key: "*"`
22
+ * allows every key in the file.
23
+ *
24
+ * Extraction lives in threshold-ratchet-families.mjs; comparison rules in
25
+ * threshold-ratchet-compare.mjs. Zero dependencies.
26
+ */
27
+ import { execFileSync } from "node:child_process";
28
+ import * as fs from "node:fs";
29
+ import * as path from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+ import {
32
+ extractAllowEntries,
33
+ familyFor,
34
+ parseJson,
35
+ } from "./threshold-ratchet-families.mjs";
36
+ import {
37
+ applyAllowList,
38
+ compareFile,
39
+ formatReport,
40
+ } from "./threshold-ratchet-compare.mjs";
41
+
42
+ /**
43
+ * Standard git locations, checked in order so the executable comes from a
44
+ * fixed, unwriteable directory rather than a PATH lookup. The bare "git"
45
+ * fallback keeps unusual layouts (e.g. Windows git-bash) working.
46
+ */
47
+ const GIT_LOCATIONS = [
48
+ "/usr/bin/git",
49
+ "/usr/local/bin/git",
50
+ "/opt/homebrew/bin/git",
51
+ ];
52
+ const GIT = GIT_LOCATIONS.find(candidate => fs.existsSync(candidate)) ?? "git";
53
+
54
+ /** Git flag shared by every changed-file listing. */
55
+ const NAME_ONLY = "--name-only";
56
+
57
+ /**
58
+ * Run git, returning stdout or null on any failure.
59
+ * @param {string[]} args Git arguments
60
+ * @param {string} [cwd] Working directory
61
+ * @returns {string | null} Captured stdout, or null when git failed
62
+ */
63
+ function git(args, cwd) {
64
+ try {
65
+ return execFileSync(GIT, args, {
66
+ cwd,
67
+ encoding: "utf-8",
68
+ stdio: ["ignore", "pipe", "ignore"],
69
+ });
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Resolve mode-specific candidate files and content readers.
77
+ * @param {"hook"|"staged"|"base"} mode Comparison mode
78
+ * @param {string} root Repo root
79
+ * @param {string | undefined} baseRef Base ref (base mode only)
80
+ * @param {string[] | undefined} onlyFiles Restrict to these repo-relative
81
+ * paths (hook mode with a known edited file)
82
+ * @returns {{ files: string[], baselineRef: string, readCurrent: (f: string) => string | null } | null}
83
+ * The comparison plan, or null when git state can't support the mode
84
+ */
85
+ function resolvePlan(mode, root, baseRef, onlyFiles) {
86
+ if (mode === "staged") {
87
+ const diff = git(["diff", "--cached", NAME_ONLY], root);
88
+ if (diff === null) return null;
89
+ return {
90
+ files: diff.split("\n").filter(Boolean),
91
+ baselineRef: "HEAD",
92
+ readCurrent: f => git(["show", `:${f}`], root),
93
+ };
94
+ }
95
+ if (mode === "base") {
96
+ if (!baseRef) return null;
97
+ const mergeBase = git(["merge-base", baseRef, "HEAD"], root)?.trim();
98
+ if (!mergeBase) return null;
99
+ const diff = git(["diff", NAME_ONLY, mergeBase, "HEAD"], root);
100
+ if (diff === null) return null;
101
+ return {
102
+ files: diff.split("\n").filter(Boolean),
103
+ baselineRef: mergeBase,
104
+ readCurrent: f => git(["show", `HEAD:${f}`], root),
105
+ };
106
+ }
107
+ const diff = git(["diff", NAME_ONLY, "HEAD"], root);
108
+ if (diff === null) return null;
109
+ return {
110
+ files: onlyFiles ?? diff.split("\n").filter(Boolean),
111
+ baselineRef: "HEAD",
112
+ readCurrent: f => {
113
+ try {
114
+ return fs.readFileSync(path.join(root, f), "utf-8");
115
+ } catch {
116
+ return null;
117
+ }
118
+ },
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Run the ratchet for a mode, print the report, and return the exit code.
124
+ * @param {"hook"|"staged"|"base"} mode Comparison mode
125
+ * @param {string | undefined} [baseRef] Base ref (base mode only)
126
+ * @param {string[] | undefined} [onlyFiles] Restrict to these paths (hook mode)
127
+ * @returns {number} Process exit code (2 for hook mode, 1 otherwise; 0 clean)
128
+ */
129
+ function run(mode, baseRef, onlyFiles) {
130
+ const root = git(["rev-parse", "--show-toplevel"])?.trim();
131
+ if (!root) return 0;
132
+ const plan = resolvePlan(mode, root, baseRef, onlyFiles);
133
+ if (!plan) return 0;
134
+
135
+ const watched = plan.files.filter(f => familyFor(f));
136
+ if (watched.length === 0) return 0;
137
+
138
+ const findings = watched.flatMap(f =>
139
+ compareFile(
140
+ f,
141
+ git(["show", `${plan.baselineRef}:${f}`], root),
142
+ plan.readCurrent(f)
143
+ )
144
+ );
145
+ if (findings.length === 0) return 0;
146
+
147
+ const baselineConfig = parseJson(
148
+ git(["show", `${plan.baselineRef}:.lisa.config.json`], root)
149
+ );
150
+ const { blocked, allowed } = applyAllowList(
151
+ findings,
152
+ extractAllowEntries(baselineConfig)
153
+ );
154
+ for (const finding of allowed) {
155
+ process.stdout.write(
156
+ `threshold-ratchet: allowed by .lisa.config.json exception — ${finding.message}\n`
157
+ );
158
+ }
159
+ if (blocked.length === 0) return 0;
160
+ process.stderr.write(`${formatReport(blocked)}\n`);
161
+ return mode === "hook" ? 2 : 1;
162
+ }
163
+
164
+ /**
165
+ * Handle `--hook` mode: parse the tool-use event from stdin and scope the
166
+ * check to the edited file (Edit/Write/NotebookEdit) or every changed
167
+ * watched file (Bash).
168
+ * @returns {number} Process exit code
169
+ */
170
+ function runHookMode() {
171
+ const state = { stdin: "" };
172
+ try {
173
+ state.stdin = fs.readFileSync(0, "utf-8");
174
+ } catch {
175
+ return 0;
176
+ }
177
+ const input = parseJson(state.stdin);
178
+ if (!input || typeof input !== "object") return 0;
179
+ if (input.tool_name === "Bash") return run("hook");
180
+ if (!["Edit", "Write", "NotebookEdit"].includes(input.tool_name)) return 0;
181
+ const filePath = input.tool_input?.file_path;
182
+ if (typeof filePath !== "string") return 0;
183
+ const root = git(["rev-parse", "--show-toplevel"])?.trim();
184
+ if (!root) return 0;
185
+ const rel = path
186
+ .relative(root, path.resolve(filePath))
187
+ .split(path.sep)
188
+ .join("/");
189
+ if (rel.startsWith("..") || !familyFor(rel)) return 0;
190
+ return run("hook", undefined, [rel]);
191
+ }
192
+
193
+ /**
194
+ * CLI entrypoint.
195
+ * @returns {number} Process exit code
196
+ */
197
+ function main() {
198
+ const args = process.argv.slice(2);
199
+ if (args[0] === "--staged") return run("staged");
200
+ if (args[0] === "--base") return run("base", args[1]);
201
+ if (args[0] === "--hook") return runHookMode();
202
+ process.stderr.write(
203
+ "usage: threshold-ratchet.mjs --hook | --staged | --base <ref>\n"
204
+ );
205
+ return 0;
206
+ }
207
+
208
+ const isDirectRun =
209
+ process.argv[1] &&
210
+ fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
211
+ if (isDirectRun) {
212
+ process.exit(main());
213
+ }