@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
+ }
@@ -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
+ }
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env bash
2
+ # PostToolUse hook for Edit|Write|NotebookEdit|Bash: the threshold ratchet.
3
+ # Quality thresholds (coverage minimums, complexity maximums, mutation break
4
+ # score, e2e route floors, k6 bounds, audit-ignore lists) may tighten but never
5
+ # weaken. The deterministic comparator lives in threshold-ratchet.mjs and is
6
+ # shared with the pre-commit (husky/lefthook --staged) and CI (--base) layers,
7
+ # so this hook is fast feedback — not the only line of defense.
8
+ #
9
+ # Exit 2 on weakening (soft block: the agent gets the report on stderr and can
10
+ # fix the code or escalate to a human). Every infrastructure gap — no node, no
11
+ # git repo, unreadable stdin — exits 0: the CI layer still guarantees the gate,
12
+ # and a broken hook must never wedge an agent session.
13
+ set -euo pipefail
14
+
15
+ input="$(cat)"
16
+
17
+ command -v node >/dev/null 2>&1 || exit 0
18
+ git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
19
+
20
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21
+
22
+ printf '%s' "$input" | node "$script_dir/threshold-ratchet.mjs" --hook
@@ -0,0 +1,147 @@
1
+ ---
2
+ description: "Claim-Time Archaeology"
3
+ alwaysApply: false
4
+ ---
5
+
6
+ # Claim-Time Archaeology
7
+
8
+ Lisa lifecycles are ONE-WAY — a done issue never reopens, so residual failures come back as NEW issues, and the causal link "issue B exists because issue A was done wrong" is invisible unless someone digs. Reopening terminal issues is out of scope, so claim-time archaeology is the only way to recover that link: at claim time, determine whether the item being claimed is round 2 of a past failure, and if so, what specifically went wrong the first time.
9
+
10
+ It is a **single vendor-neutral contract** consumed by all three build-intake skills (`lisa-jira-build-intake`, `lisa-github-build-intake`, `lisa-linear-build-intake`). Each vendor arm cites this slug in its claim step rather than growing its own archaeology, exactly as the arms cite `leaf-only-lifecycle`, `repo-scope-split`, and `rejection-detection`. One slug is what keeps an ancestor found on JIRA from being missed on Linear.
11
+
12
+ ## Seam and sequencing — after rejection detection, before the claim transition
13
+
14
+ The three build-intake skills share a uniform claim phase: `3a.0` repo-scope gate → `3a` leaf-only claim gate → `3b` Claim → `3c` run lifecycle (culminating in `lisa-implement`) → `3d` transition to done.
15
+
16
+ Within `3b`, the pre-transition window runs two passes in a fixed order:
17
+
18
+ 1. **`rejection-detection` runs first** (top of `3b`, before the relabel — it needs the current-lane signal that the relabel destroys).
19
+ 2. **Archaeology runs second** — after the rejection classification exists, still **before the relabel/transition** `$READY → $CLAIMED`.
20
+
21
+ The ordering is load-bearing: rejection-detection's classification is an **input** to archaeology's. A `rejection-reclaim` detected in pass 1 flows straight into archaeology's classification — it is reused, **not re-derived**. Archaeology never re-reads transition history to second-guess the rejection detector; forking that signal would guarantee drift between the two passes.
22
+
23
+ **`lisa-implement` is NOT the seam** — it never sees the claim. Archaeology belongs to the build-intake claim phase, like the two gates before it.
24
+
25
+ ## Ancestry signals
26
+
27
+ Three signal sources, tried in order of cheapness. Every query counts against the cost budget below.
28
+
29
+ ### 1. Tracker metadata (typed relations)
30
+
31
+ The cheapest and most reliable signal: the relations the vendor read skills already parse. Read them from the context bundle the intake flow already fetched — do not re-fetch:
32
+
33
+ - The typed relation lines — `Blocks` / `Blocked by` / `Relates to` / `Duplicates` / `Cloned from` — that `lisa-github-read-issue`, `lisa-jira-read-ticket`, and `lisa-linear-read-issue` parse into the relations table of their context bundles.
34
+ - GitHub's native `closingIssuesReferences` (PR↔issue closure links) and timeline cross-references, surfaced by the same `lisa-github-read-issue` GraphQL read. JIRA issue links and Linear native relations (`blocks` / `blocked_by` / `relates_to` / `duplicates`) are the vendor equivalents, read through the access layers (`integration-access-layer`) — never a direct vendor API call.
35
+
36
+ A relation pointing at a **closed, done** issue whose shipped work plausibly covers this issue's surface is an ancestor candidate. An "introduced by"-shaped link (this issue references the PR or issue that shipped the defect) is the strongest form.
37
+
38
+ ### 2. Text similarity (bounded, lexical)
39
+
40
+ **The honest bound, stated plainly: no embedding machinery exists in Lisa, and none is introduced here. This signal is lexical overlap over tracker search primitives — not semantic similarity — and it will miss paraphrased descriptions.** That is acceptable: it exists to catch the common case of a new issue describing a defect in something recently shipped, using roughly the words the shipping issue used.
41
+
42
+ Scope: **recently-closed** issues (closed within the recent window the budget affords, newest first) **touching the same implicated files** where file paths are named or inferable, ranked by **title/label overlap** with the issue being claimed. The primitives:
43
+
44
+ - **GitHub** — `gh search issues "<key terms>" --repo <org>/<repo> --state closed --sort updated` (and `--label` narrowing where labels overlap).
45
+ - **JIRA** — `lisa-atlassian-access operation: search-issues jql: "project = <P> AND statusCategory = Done AND resolved >= -30d AND text ~ \"<key terms>\" ORDER BY resolved DESC"`.
46
+ - **Linear** — `lisa-linear-access operation: list-issues` filtered to completed state types, matched client-side on title/label overlap.
47
+
48
+ A hit is a candidate only when the overlap is specific (shared distinctive terms, same component labels, same files named) — generic word overlap alone never promotes an ancestor.
49
+
50
+ ### 3. Git ancestry (deterministic, machine-readable)
51
+
52
+ For the files the issue implicates (named in the body, or inferred from the similarity hits), answer "which PR last shipped this file" with **direct deterministic git commands**:
53
+
54
+ ```bash
55
+ git log --follow --format='%H %aI %s' -n 5 -- <file> # last commits touching the file
56
+ git blame -L <range> --line-porcelain <file> # who last shipped the implicated lines
57
+ # The PR that shipped the file. --full-history is required: path-limited git log
58
+ # simplifies away merge commits by default, silently dropping the merge-PR answer.
59
+ # Two --grep patterns (OR'd) cover both merge conventions: classic merge commits
60
+ # ("Merge pull request #<n>") and squash/rebase merges (subject ending "(#<n>)").
61
+ # POSIX BRE only — GNU-only \+ silently matches nothing on BSD/macOS git.
62
+ git log --full-history --grep "Merge pull request #" --grep "(#[0-9][0-9]*)" --format='%H %aI %s' -n 5 -- <file>
63
+ ```
64
+
65
+ Keep the result **parseable**: a `{file, sha, pr, date}` tuple per implicated file (PR number extracted from the subject — `Merge pull request #<n>` for merge commits, the trailing `(#<n>)` for squash/rebase merges; empty when the history matches neither convention). The PR maps back to its issue via `closingIssuesReferences` / the PR body's issue reference.
66
+
67
+ **Do NOT delegate this to the `git-history-analyzer` agent.** That agent can answer the question, but it returns a **prose report with no machine-readable contract** (nothing downstream can reliably parse it), it is explicitly forbidden from judging past decisions, and it reads the local repo only. For programmatic claim-time archaeology, run the deterministic query directly and keep the `{file, sha, pr, date}` result.
68
+
69
+ ## Learning-loop exclusion (scan-side — a learning artifact is never an ancestor)
70
+
71
+ This flow produces learning PRs, candidate comments, and upstream handoffs. Those artifacts touch the same files and reference the same issues as the failures they describe — which makes them **near-perfect false-positive ancestors**. Without an explicit exclusion the flow learns from itself, recursively.
72
+
73
+ Before any candidate is promoted to ancestor, exclude every artifact carrying any of these markers or labels — such an artifact is **never an ancestor**, no matter how strong its other signals:
74
+
75
+ - `[lisa-learning-drop]`
76
+ - `[lisa-learning-pr]`
77
+ - `[lisa-learning-upstream-handoff]`
78
+ - `[lisa-rejection-candidate]`
79
+ - `[lisa-archaeology-candidate]` (this rule's own producer tag — archaeology's output must not seed the next claim's input)
80
+ - the `learning:needs-triage` label
81
+
82
+ This is the **scan-side** half of the no-learning-loops guard; `rejection-detection` carries the symmetric **trigger-side** half ("a learning artifact is never a rejection-reflection trigger").
83
+
84
+ ## Classification
85
+
86
+ Exactly one of three states:
87
+
88
+ | Classification | Condition |
89
+ |---|---|
90
+ | `rejection-reclaim` | The `rejection-detection` pass classified this claim `rejection-reclaim`. Taken directly from that result — reused, never re-derived here. Its reflection path (the `[lisa-rejection-candidate]` candidate) already covers the learning; archaeology adds nothing on top. |
91
+ | `retry-of-done-issue` | Not a rejection-reclaim, AND an ancestry signal (§ above, post-exclusion) names a closed done issue whose shipped work this issue exists to fix. |
92
+ | `fresh` | Everything else: no ancestor, weak/inconclusive signals, budget exhausted, or the pass errored. |
93
+
94
+ Classification itself is **stateless** — a pure function of the signals read this pass, holding no cache or stored state between claims. Re-running it on the same inputs yields the same answer; idempotency of the *side effect* (the candidate) is carried by marker dedupe below.
95
+
96
+ ## Candidate derivation (`retry-of-done-issue` only)
97
+
98
+ An ancestor alone teaches nothing — "B relates to A" is trivia. The learning lives in the **delta**: the gap between what agent A actually did and what issue B proves was actually needed.
99
+
100
+ 1. **Reconstruct what the ancestor shipped**, through the access layers: its merged PR (diff, description), the review threads on that PR, and the evidence comments on the ancestor issue.
101
+ 2. **Derive ONE candidate learning citing the delta** — **what was done** versus **what this issue proves was needed**. The shape is "A shipped X; B proves Y was required; the mistake was assuming X sufficed" — never "A had a bug". A **vague summary** that does not name the specific mistake is worthless and must be rejected (produce nothing rather than noise).
102
+ 3. **Route it to `lisa-persist-learning`** exactly like the rejection-reflection path: candidate fields (rule, why, provenance linking the ancestor issue + its PR + this issue, evidence links, scope hint, triggering issue) with fingerprint `sll4-sha1(rule\ntriggering_issue)[:12]`.
103
+
104
+ ### Graceful degrade — `lisa-persist-learning` unavailable
105
+
106
+ Same fallback pattern as the rejection path, with this rule's own distinct marker. Record the candidate as a comment on the claimed item carrying a **visible prose line** plus the marker (a bare marker renders as an empty comment bubble):
107
+
108
+ ```text
109
+ Recorded a candidate learning from this retry's ancestry (queued for the judgment gate): <one-line candidate rule>.
110
+ <!-- [lisa-archaeology-candidate] key=<issue>::<ancestor> -->
111
+ ```
112
+
113
+ The marker line is verbatim — the dedupe contract keys on it, not on the prose.
114
+
115
+ ### Idempotency — marker dedupe
116
+
117
+ The key is `<issue>::<ancestor>` (the claimed item's ref, `::`, the ancestor's ref — the `::` separator keeps the key unambiguous when vendor refs themselves contain hyphens, e.g. `PROJ-123`; it is stable across re-claims of the same pair). Before producing a candidate, search for an existing `[lisa-archaeology-candidate]` comment/artifact carrying this exact key — match on the **marker, never the title** (the `lisa-github-write-prd` Phase 2 discipline). Dedupe is per **(issue, ancestor) pair**: re-claiming an issue whose archaeology already resolved the same ancestor finds the marker and short-circuits — no duplicate candidate for that pair. A re-claim that resolves a **different** ancestor is new evidence and may legitimately produce a second candidate under its own key — that is intended, not a dedupe failure.
118
+
119
+ ### `fresh` produces silence
120
+
121
+ A `fresh` classification produces **no candidate and zero comments**. Silence is the correct output — emitting a low-value candidate on every claim is precisely the rule-pollution failure mode the learning loop names as its existential risk.
122
+
123
+ ## Cost budget — enforced here, configured in one place
124
+
125
+ Archaeology is speculative digging on the critical path of every claim. The budget is what makes that safe.
126
+
127
+ - **`archaeology.maxSteps`** — the maximum number of tracker/git queries one archaeology pass may spend, read from `.lisa.config.json`:
128
+
129
+ ```bash
130
+ MAX_STEPS=$(jq -r '.archaeology.maxSteps // 8' .lisa.config.json 2>/dev/null || echo 8)
131
+ ```
132
+
133
+ The conservative default is **8** — enough for the metadata read (free, already fetched), one or two similarity searches, and git ancestry over a handful of implicated files, and small enough that a fruitless dig on a large repo ends quickly. `lisa sync` seeds the key (registry default), and **this rule pair is the single documented place** for what the budget means — do not restate its semantics in the vendor skills.
134
+ - **`archaeology.maxSeconds`** — optional wall-clock ceiling for the whole pass, read the same way (`jq -r '.archaeology.maxSeconds // empty'`); unset means steps alone bound the pass.
135
+
136
+ **Budget exhaustion is a NORMAL outcome, not an error.** When the pass hits either ceiling with no confident ancestor, it classifies `fresh` and the claim proceeds immediately — no retry, no escalation, no blocking warning.
137
+
138
+ ## Never block the claim
139
+
140
+ The invariant everything above hangs on: **archaeology never blocks the claim**. By construction:
141
+
142
+ - Weak or inconclusive signals → degrade to `fresh`, claim proceeds.
143
+ - Budget exhausted → degrade to `fresh`, claim proceeds.
144
+ - The pass throws or errors (tracker outage, malformed history, missing config) → the exception is caught, classification degrades to `fresh`, and the **claim still proceeds** — a crash inside a speculative bonus feature must never strand a ready issue in the queue.
145
+ - Unreadable ancestor evidence on a genuine retry → no candidate produced, the item is still implemented — degraded, not stopped.
146
+
147
+ Headless-safe throughout: no interactive prompts, safe under intake crons.