@codyswann/lisa 2.241.0 → 2.243.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 (126) hide show
  1. package/dist/core/instruction-files-migration.d.ts +38 -0
  2. package/dist/core/instruction-files-migration.d.ts.map +1 -1
  3. package/dist/core/instruction-files-migration.js +76 -7
  4. package/dist/core/instruction-files-migration.js.map +1 -1
  5. package/dist/core/learnings-projection.d.ts +38 -0
  6. package/dist/core/learnings-projection.d.ts.map +1 -0
  7. package/dist/core/learnings-projection.js +94 -0
  8. package/dist/core/learnings-projection.js.map +1 -0
  9. package/dist/core/learnings.d.ts +2 -1
  10. package/dist/core/learnings.d.ts.map +1 -1
  11. package/dist/core/learnings.js +2 -1
  12. package/dist/core/learnings.js.map +1 -1
  13. package/dist/core/lisa.d.ts +40 -0
  14. package/dist/core/lisa.d.ts.map +1 -1
  15. package/dist/core/lisa.js +90 -12
  16. package/dist/core/lisa.js.map +1 -1
  17. package/dist/core/project-config.d.ts +36 -3
  18. package/dist/core/project-config.d.ts.map +1 -1
  19. package/dist/core/project-config.js +127 -10
  20. package/dist/core/project-config.js.map +1 -1
  21. package/dist/sync/registry.js +1 -1
  22. package/dist/sync/registry.js.map +1 -1
  23. package/package.json +1 -1
  24. package/plugins/lisa/.claude-plugin/plugin.json +10 -1
  25. package/plugins/lisa/.codex-plugin/hooks.json +9 -0
  26. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  27. package/plugins/lisa/.codex-plugin/skills/lisa-persist-learning/SKILL.md +1 -1
  28. package/plugins/lisa/hooks/threshold-ratchet-compare.mjs +315 -0
  29. package/plugins/lisa/hooks/threshold-ratchet-families.mjs +295 -0
  30. package/plugins/lisa/hooks/threshold-ratchet.mjs +213 -0
  31. package/plugins/lisa/hooks/threshold-ratchet.sh +22 -0
  32. package/plugins/lisa/rules/eager/config-resolution.md +6 -3
  33. package/plugins/lisa/rules/eager/project-learnings.md +11 -5
  34. package/plugins/lisa/rules/reference/config-resolution.md +2 -1
  35. package/plugins/lisa/rules/reference/project-learnings.md +16 -9
  36. package/plugins/lisa/skills/lisa-persist-learning/SKILL.md +1 -1
  37. package/plugins/lisa-agy/plugin.json +1 -1
  38. package/plugins/lisa-agy/skills/lisa-persist-learning/SKILL.md +1 -1
  39. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  41. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  42. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-copilot/.claude-plugin/plugin.json +10 -1
  45. package/plugins/lisa-copilot/hooks/threshold-ratchet-compare.mjs +315 -0
  46. package/plugins/lisa-copilot/hooks/threshold-ratchet-families.mjs +295 -0
  47. package/plugins/lisa-copilot/hooks/threshold-ratchet.mjs +213 -0
  48. package/plugins/lisa-copilot/hooks/threshold-ratchet.sh +22 -0
  49. package/plugins/lisa-copilot/rules/eager/config-resolution.md +6 -3
  50. package/plugins/lisa-copilot/rules/eager/project-learnings.md +11 -5
  51. package/plugins/lisa-copilot/rules/reference/config-resolution.md +2 -1
  52. package/plugins/lisa-copilot/rules/reference/project-learnings.md +16 -9
  53. package/plugins/lisa-copilot/skills/lisa-persist-learning/SKILL.md +1 -1
  54. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-cursor/hooks/hooks.json +4 -0
  56. package/plugins/lisa-cursor/hooks/threshold-ratchet-compare.mjs +315 -0
  57. package/plugins/lisa-cursor/hooks/threshold-ratchet-families.mjs +295 -0
  58. package/plugins/lisa-cursor/hooks/threshold-ratchet.mjs +213 -0
  59. package/plugins/lisa-cursor/hooks/threshold-ratchet.sh +22 -0
  60. package/plugins/lisa-cursor/rules/config-resolution-reference.mdc +2 -1
  61. package/plugins/lisa-cursor/rules/config-resolution.mdc +6 -3
  62. package/plugins/lisa-cursor/rules/project-learnings-reference.mdc +16 -9
  63. package/plugins/lisa-cursor/rules/project-learnings.mdc +11 -5
  64. package/plugins/lisa-cursor/skills/lisa-persist-learning/SKILL.md +1 -1
  65. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  67. package/plugins/lisa-expo-agy/plugin.json +1 -1
  68. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  72. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  73. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  77. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  78. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  82. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  83. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  87. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  88. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  92. package/plugins/lisa-rails-agy/plugin.json +1 -1
  93. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  94. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  95. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  96. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  97. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  98. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  99. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  100. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  101. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  102. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  103. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  104. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  105. package/plugins/src/base/.claude-plugin/plugin.json +9 -0
  106. package/plugins/src/base/hooks/threshold-ratchet-compare.mjs +315 -0
  107. package/plugins/src/base/hooks/threshold-ratchet-families.mjs +295 -0
  108. package/plugins/src/base/hooks/threshold-ratchet.mjs +213 -0
  109. package/plugins/src/base/hooks/threshold-ratchet.sh +22 -0
  110. package/plugins/src/base/rules/eager/config-resolution.md +6 -3
  111. package/plugins/src/base/rules/eager/project-learnings.md +11 -5
  112. package/plugins/src/base/rules/reference/config-resolution.md +2 -1
  113. package/plugins/src/base/rules/reference/project-learnings.md +16 -9
  114. package/plugins/src/base/skills/lisa-persist-learning/SKILL.md +1 -1
  115. package/rails/copy-overwrite/lefthook.yml +6 -0
  116. package/rails/copy-overwrite/scripts/check-threshold-ratchet.mjs +213 -0
  117. package/rails/copy-overwrite/scripts/threshold-ratchet-compare.mjs +315 -0
  118. package/rails/copy-overwrite/scripts/threshold-ratchet-families.mjs +295 -0
  119. package/scripts/build-plugins.sh +20 -0
  120. package/scripts/check-learnings-budget.ts +1 -2
  121. package/scripts/lib/per-agent-hook-filter.mjs +7 -0
  122. package/typescript/copy-contents/.husky/pre-commit +17 -0
  123. package/typescript/copy-overwrite/scripts/check-threshold-ratchet.mjs +213 -0
  124. package/typescript/copy-overwrite/scripts/threshold-ratchet-compare.mjs +315 -0
  125. package/typescript/copy-overwrite/scripts/threshold-ratchet-families.mjs +295 -0
  126. /package/all/create-only/{.claude/rules → .lisa}/PROJECT_LEARNINGS.md +0 -0
@@ -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
@@ -29,9 +29,12 @@ Project tracker (`jira` / `github` / `linear`) is read from `.lisa.config.json`
29
29
 
30
30
  Resolve hand-authored project rules from `.lisa.config.json`
31
31
  `projectRulesFile`, defaulting to `.claude/rules/PROJECT_RULES.md`. Automated
32
- learnings never append to that file: they use the separate
33
- `PROJECT_LEARNINGS.md` sibling derived from the configured rules directory.
34
- Both writers and budget checks import the executable contract from
32
+ learnings never append to that file: they use the separate machine-managed
33
+ ledger resolved from `.lisa.config.json` the optional `learnings.file`
34
+ override, else the default `.lisa/PROJECT_LEARNINGS.md`. The ledger lives in the
35
+ cold `.lisa/` tree (never an auto-loaded rules directory) and is consumed only
36
+ through the contract's bounded projection, never read raw wholesale. Both
37
+ writers and budget checks import the executable contract from
35
38
  `@codyswann/lisa/learnings`; they must not copy its numeric limits.
36
39
 
37
40
  ## Env → base branch
@@ -1,11 +1,17 @@
1
1
  # Project Learnings (load-bearing)
2
2
 
3
3
  Before normal task work, resolve this repository's committed `.lisa.config.json`
4
- and derive the canonical learnings file as the sibling of `projectRulesFile`
5
- (default: `.claude/rules/PROJECT_LEARNINGS.md`). If that file exists, consume it
6
- through the executable Lisa learnings contract exported by
7
- `@codyswann/lisa/learnings` before relying on ad-hoc memory or prior-session
8
- notes.
4
+ and derive the machine-managed learnings ledger: the optional `learnings.file`
5
+ override, else the default `.lisa/PROJECT_LEARNINGS.md`. The ledger lives cold in
6
+ `.lisa/`, NOT in an auto-loaded rules tree — so it is never injected raw into the
7
+ session.
8
+
9
+ Consume it ONLY through the executable Lisa learnings contract exported by
10
+ `@codyswann/lisa/learnings`: parse and validate with `parseLearningsFile`, then
11
+ take the bounded serving slice from `projectLearnings` (the highest-priority
12
+ entries within the token/entry budget). **Never read the raw ledger file
13
+ wholesale into context** — the whole point of the relocation is that sessions
14
+ receive the contract's bounded projection, not the full file.
9
15
 
10
16
  Missing learnings are a silent no-op. Malformed, non-canonical, unsafe, or
11
17
  over-budget learnings produce one readable warning, apply no entry, and must not
@@ -179,7 +179,8 @@ fi
179
179
  |-------|----------|---------|-------|
180
180
  | `tracker` | **yes** | — | Destination for ticket writes. One of `"jira"`, `"github"`, `"linear"`. Missing → fail with instruction to run the matching `/lisa:setup:*` skill. |
181
181
  | `source` | no | — | Default PRD source for batch skills (`/lisa:intake`) and arg-less single-PRD skills. One of `"notion"`, `"confluence"`, `"linear"`, `"github"`, `"jira"`. Explicit URLs/keys passed to a skill always win over `source`; this is a default, not a lock. |
182
- | `projectRulesFile` | no | `.claude/rules/PROJECT_RULES.md` | Safe repo-relative Markdown path for hand-authored project rules. The bounded learning writer derives a separate `PROJECT_LEARNINGS.md` sibling in the same directory; there is intentionally no second path setting. |
182
+ | `projectRulesFile` | no | `.claude/rules/PROJECT_RULES.md` | Safe repo-relative Markdown path for hand-authored project rules. Independent of the learnings ledger relocating the rules file never moves the ledger. |
183
+ | `learnings.file` | no | `.lisa/PROJECT_LEARNINGS.md` | Safe repo-relative Markdown path for the machine-managed learnings ledger, overriding the `.lisa/` default. Rejected if it resolves inside any auto-loaded rules tree (`.claude/rules`, `.cursor/rules`, `.github/instructions`, `.agents/rules`) — the ledger must stay out of eager context. |
183
184
  | `usage` | no | — | Optional token/cost pricing metadata consumed by the `usage-accounting` rule. Missing pricing never blocks a lifecycle flow; Lisa records token counts with `estimated_cost: null` when no trustworthy price source is configured. |
184
185
  | `wiki` | no | — | Wiki location for the `wiki-knowledge-source` rule. Omit for a local in-repo wiki (`wiki/`). See **Wiki source** below. |
185
186
 
@@ -1,14 +1,21 @@
1
1
  # Project Learnings
2
2
 
3
- Project learnings are Lisa's bounded, repo-local memory surface. They are stored
4
- in `PROJECT_LEARNINGS.md`, derived as the sibling of the configured
5
- `.lisa.config.json` `projectRulesFile` value. With no config override, the path
6
- is `.claude/rules/PROJECT_LEARNINGS.md`.
7
-
8
- Consume learnings before normal task work whenever the file exists. Use the
9
- executable contract from `@codyswann/lisa/learnings` to parse, validate, and
10
- budget-check the document. Do not duplicate numeric caps in rules or prompts;
11
- the exported contract is the source of truth.
3
+ Project learnings are Lisa's bounded, repo-local memory surface. The
4
+ machine-managed ledger is `PROJECT_LEARNINGS.md`, resolved from
5
+ `.lisa.config.json`: the optional `learnings.file` override, else the default
6
+ `.lisa/PROJECT_LEARNINGS.md`. The ledger deliberately lives in the cold `.lisa/`
7
+ directory — **not** under `.claude/rules/` or any other auto-loaded rules tree —
8
+ because anything in those trees is injected raw into every session, which
9
+ double-loads the file and bypasses the contract's budget and validation.
10
+
11
+ Consume learnings before normal task work whenever the file exists, and consume
12
+ them ONLY through the executable contract from `@codyswann/lisa/learnings`:
13
+ `parseLearningsFile` to parse and validate, then `projectLearnings` to take the
14
+ bounded serving slice (the highest-priority entries — ordered by confidence,
15
+ then recency — that fit the token/entry budget, plus how many were omitted).
16
+ **Never read the raw ledger file wholesale into context**; the session receives
17
+ the bounded projection, not the full document. Do not duplicate numeric caps in
18
+ rules or prompts; the exported contract is the source of truth.
12
19
 
13
20
  Each persisted entry has seven fields:
14
21
 
@@ -139,7 +139,7 @@ Continue to Phase 3.
139
139
  No learning content is ever committed without a PR — there is no other write path, and the PR must touch **only** the learnings surface (any other changed file is a bug).
140
140
 
141
141
  1. **PR dedupe.** Search all PRs for the marker `[lisa-learning-pr] key=<fingerprint>` in the body (`gh pr list --state all --search '"<marker>" in:body' --json number,url`), with the stale-index guard above. If one exists, reference it and stop — never open a duplicate.
142
- 2. **Resolve the learnings surface path — never hardcode it.** The canonical path is `resolveProjectLearningsFile` from `@codyswann/lisa/learnings`: the `PROJECT_LEARNINGS.md` sibling of the configured `.lisa.config.json` `projectRulesFile` (default `.claude/rules/PROJECT_LEARNINGS.md`):
142
+ 2. **Resolve the learnings surface path — never hardcode it.** The canonical path is `resolveProjectLearningsFile` from `@codyswann/lisa/learnings`: the machine-managed ledger resolved from `.lisa.config.json` (the `learnings.file` override, else the default `.lisa/PROJECT_LEARNINGS.md` — a cold path, never an auto-loaded rules tree):
143
143
 
144
144
  ```bash
145
145
  LEARNINGS_FILE=$(node -e 'import("@codyswann/lisa/learnings").then(async m => { const c = await m.readProjectConfig(process.cwd()); console.log(m.resolveProjectLearningsFile(c)); })')
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.241.0",
3
+ "version": "2.243.0",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -10,6 +10,10 @@
10
10
  {
11
11
  "command": "${CURSOR_PLUGIN_ROOT}/hooks/shell-write-nudge.sh",
12
12
  "matcher": "Bash"
13
+ },
14
+ {
15
+ "command": "${CURSOR_PLUGIN_ROOT}/hooks/threshold-ratchet.sh",
16
+ "matcher": "Edit|Write|NotebookEdit|Bash"
13
17
  }
14
18
  ],
15
19
  "preToolUse": [
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Threshold ratchet — comparison rules and reporting.
3
+ *
4
+ * Pure comparison layer: given a watched file's baseline and current
5
+ * contents, report every weakening. No filesystem or git access. See
6
+ * threshold-ratchet-families.mjs for extraction and threshold-ratchet.mjs
7
+ * for the CLI.
8
+ */
9
+ import {
10
+ extractAllowEntries,
11
+ extractExemptionEntries,
12
+ extractK6Constraints,
13
+ extractNumericLeaves,
14
+ extractRubocopThresholds,
15
+ extractStrykerConstraints,
16
+ extractStrykerMutate,
17
+ familyFor,
18
+ parseJson,
19
+ } from "./threshold-ratchet-families.mjs";
20
+
21
+ /** Finding type: a numeric bound or boolean gate moved the weakening way. */
22
+ const TYPE_WEAKENED = "weakened";
23
+ /** Finding type: an exemption entry was added (Tier 3). */
24
+ const TYPE_EXEMPTION_ADDED = "exemption-added";
25
+ /** Family kind for .lisa.config.json (the thresholdRatchet.allow carrier). */
26
+ const KIND_ALLOW_LIST = "allow-list";
27
+
28
+ /**
29
+ * @typedef {object} Finding
30
+ * @property {string} file Repo-relative path of the gate file
31
+ * @property {string} key Dotted key path within the file
32
+ * @property {"weakened"|"removed"|"exemption-added"|"file-deleted"|"allow-added"|"unparseable"} type
33
+ * Which ratchet rule the change violated
34
+ * @property {number|string} [base] Baseline value
35
+ * @property {number|string} [current] Current value
36
+ * @property {string} message Operator-readable explanation
37
+ */
38
+
39
+ /**
40
+ * Build the "file could not be parsed" finding.
41
+ * @param {string} relPath Repo-relative path
42
+ * @returns {Finding} The unparseable-file finding
43
+ */
44
+ function unparseable(relPath) {
45
+ return {
46
+ file: relPath,
47
+ key: "*",
48
+ type: "unparseable",
49
+ message: `${relPath} is no longer valid JSON — a broken gate file disables the gate.`,
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Compare two constraint maps: report removals and direction violations.
55
+ * @param {string} relPath Repo-relative path the constraints came from
56
+ * @param {Map<string, { value: number, direction: "min"|"max" }>} base
57
+ * Baseline constraints
58
+ * @param {Map<string, { value: number, direction: "min"|"max" }>} current
59
+ * Current constraints
60
+ * @returns {Finding[]} One finding per removed or weakened constraint
61
+ */
62
+ export function compareConstraints(relPath, base, current) {
63
+ const findings = [];
64
+ for (const [key, baseC] of base) {
65
+ const currentC = current.get(key);
66
+ if (!currentC) {
67
+ findings.push({
68
+ file: relPath,
69
+ key,
70
+ type: "removed",
71
+ base: baseC.value,
72
+ message: `${relPath}: ${key} was removed — the tuned floor would silently fall back to a default.`,
73
+ });
74
+ continue;
75
+ }
76
+ const weakened =
77
+ baseC.direction === "min"
78
+ ? currentC.value < baseC.value
79
+ : currentC.value > baseC.value;
80
+ if (weakened) {
81
+ const verb =
82
+ baseC.direction === "min" ? "may only increase" : "may only decrease";
83
+ findings.push({
84
+ file: relPath,
85
+ key,
86
+ type: TYPE_WEAKENED,
87
+ base: baseC.value,
88
+ current: currentC.value,
89
+ message: `${relPath}: ${key} changed ${baseC.value} → ${currentC.value} (this value ${verb}).`,
90
+ });
91
+ }
92
+ }
93
+ return findings;
94
+ }
95
+
96
+ /**
97
+ * Compare a stryker.conf.json pair: the break threshold plus mutate-list
98
+ * exemptions (new negations or removed targets shrink the gate).
99
+ * @param {string} relPath Repo-relative path
100
+ * @param {unknown} base Parsed baseline config
101
+ * @param {unknown} current Parsed current config
102
+ * @returns {Finding[]} Break-threshold and mutate-scope findings
103
+ */
104
+ function compareStryker(relPath, base, current) {
105
+ const findings = compareConstraints(
106
+ relPath,
107
+ extractStrykerConstraints(base),
108
+ extractStrykerConstraints(current)
109
+ );
110
+ const baseMutate = extractStrykerMutate(base);
111
+ const currentMutate = extractStrykerMutate(current);
112
+ for (const negation of currentMutate.negations) {
113
+ if (!baseMutate.negations.has(negation)) {
114
+ findings.push({
115
+ file: relPath,
116
+ key: `mutate ${negation}`,
117
+ type: TYPE_EXEMPTION_ADDED,
118
+ message: `${relPath}: new mutation-testing exclusion "${negation}" — excluding files from a gate is a weakening.`,
119
+ });
120
+ }
121
+ }
122
+ for (const positive of baseMutate.positives) {
123
+ if (!currentMutate.positives.has(positive)) {
124
+ findings.push({
125
+ file: relPath,
126
+ key: `mutate ${positive}`,
127
+ type: TYPE_EXEMPTION_ADDED,
128
+ message: `${relPath}: mutation-testing target "${positive}" was removed — shrinking a gate's coverage is a weakening.`,
129
+ });
130
+ }
131
+ }
132
+ return findings;
133
+ }
134
+
135
+ /**
136
+ * Compare a k6 thresholds pair: numeric bounds plus abortOnFail downgrades.
137
+ * @param {string} relPath Repo-relative path
138
+ * @param {unknown} base Parsed baseline thresholds
139
+ * @param {unknown} current Parsed current thresholds
140
+ * @returns {Finding[]} Bound and abortOnFail findings
141
+ */
142
+ function compareK6(relPath, base, current) {
143
+ const baseC = extractK6Constraints(base);
144
+ const currentC = extractK6Constraints(current);
145
+ const findings = compareConstraints(relPath, baseC.numeric, currentC.numeric);
146
+ for (const [key, wasOn] of baseC.booleans) {
147
+ // k6 defaults abortOnFail to false, so DELETING an explicit `true` is as
148
+ // much a weakening as flipping it — anything but a current `true` blocks.
149
+ if (wasOn && currentC.booleans.get(key) !== true) {
150
+ findings.push({
151
+ file: relPath,
152
+ key,
153
+ type: TYPE_WEAKENED,
154
+ base: "true",
155
+ current: "false",
156
+ message: `${relPath}: ${key} turned off — the gate no longer stops the run on failure.`,
157
+ });
158
+ }
159
+ }
160
+ return findings;
161
+ }
162
+
163
+ /**
164
+ * Compare an exemption-list pair (audit ignore files): report added entries.
165
+ * @param {string} relPath Repo-relative path
166
+ * @param {unknown} base Parsed baseline list
167
+ * @param {unknown} current Parsed current list
168
+ * @returns {Finding[]} One finding per newly added ignore entry
169
+ */
170
+ function compareExemptions(relPath, base, current) {
171
+ const baseEntries = extractExemptionEntries(base);
172
+ const findings = [];
173
+ for (const entry of extractExemptionEntries(current)) {
174
+ if (!baseEntries.has(entry)) {
175
+ findings.push({
176
+ file: relPath,
177
+ key: entry,
178
+ type: TYPE_EXEMPTION_ADDED,
179
+ message: `${relPath}: new security-audit ignore entry "${entry}" — ignoring a finding weakens the gate and needs a human's sign-off.`,
180
+ });
181
+ }
182
+ }
183
+ return findings;
184
+ }
185
+
186
+ /**
187
+ * Compare .lisa.config.json allow lists: report added exception entries so a
188
+ * change can never grant itself an exception.
189
+ * @param {string} relPath Repo-relative path
190
+ * @param {unknown} base Parsed baseline config
191
+ * @param {unknown} current Parsed current config
192
+ * @returns {Finding[]} One finding per newly added allow entry
193
+ */
194
+ function compareAllowList(relPath, base, current) {
195
+ const baseKeys = new Set(
196
+ extractAllowEntries(base).map(e => `${e.file} ${e.key}`)
197
+ );
198
+ const findings = [];
199
+ for (const entry of extractAllowEntries(current)) {
200
+ if (!baseKeys.has(`${entry.file} ${entry.key}`)) {
201
+ findings.push({
202
+ file: relPath,
203
+ key: `thresholdRatchet.allow ${entry.file}#${entry.key}`,
204
+ type: "allow-added",
205
+ message: `${relPath}: new threshold exception for ${entry.file} → ${entry.key}. Exceptions are a human decision: land this entry in its own human-approved change first, then make the threshold change.`,
206
+ });
207
+ }
208
+ }
209
+ return findings;
210
+ }
211
+
212
+ /**
213
+ * Compare one watched file's baseline and current contents and report every
214
+ * weakening. Pure: no filesystem or git access.
215
+ * @param {string} relPath Repo-relative path (forward slashes)
216
+ * @param {string | null} baselineText Baseline contents (null = file is new)
217
+ * @param {string | null} currentText Current contents (null = file deleted)
218
+ * @returns {Finding[]} Every ratchet violation in the change (empty = clean)
219
+ */
220
+ export function compareFile(relPath, baselineText, currentText) {
221
+ const family = familyFor(relPath);
222
+ if (!family || baselineText === null || baselineText === undefined) return [];
223
+
224
+ if (currentText === null || currentText === undefined) {
225
+ if (family.kind === KIND_ALLOW_LIST) return [];
226
+ return [
227
+ {
228
+ file: relPath,
229
+ key: "*",
230
+ type: "file-deleted",
231
+ message: `${relPath} was deleted — deleting a quality gate is a weakening.`,
232
+ },
233
+ ];
234
+ }
235
+
236
+ if (family.kind === "rubocop-yaml") {
237
+ return compareConstraints(
238
+ relPath,
239
+ extractRubocopThresholds(baselineText, family.direction),
240
+ extractRubocopThresholds(currentText, family.direction)
241
+ );
242
+ }
243
+
244
+ const base = parseJson(baselineText);
245
+ const current = parseJson(currentText);
246
+ if (base === undefined) return [];
247
+ if (current === undefined) {
248
+ return family.kind === KIND_ALLOW_LIST ? [] : [unparseable(relPath)];
249
+ }
250
+ switch (family.kind) {
251
+ case "json-num":
252
+ return compareConstraints(
253
+ relPath,
254
+ extractNumericLeaves(base, family.direction),
255
+ extractNumericLeaves(current, family.direction)
256
+ );
257
+ case "stryker":
258
+ return compareStryker(relPath, base, current);
259
+ case "k6":
260
+ return compareK6(relPath, base, current);
261
+ case "exemption-list":
262
+ return compareExemptions(relPath, base, current);
263
+ case KIND_ALLOW_LIST:
264
+ return compareAllowList(relPath, base, current);
265
+ default:
266
+ return [];
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Drop findings covered by baseline-side allow entries. `allow-added`
272
+ * findings are never dropped — an exception cannot approve its own creation.
273
+ * @param {Finding[]} findings All findings from the change
274
+ * @param {Array<{ file: string, key: string }>} allowEntries Baseline
275
+ * (already-merged) allow list
276
+ * @returns {{ blocked: Finding[], allowed: Finding[] }} Findings that still
277
+ * block vs. findings covered by a recorded exception
278
+ */
279
+ export function applyAllowList(findings, allowEntries) {
280
+ const blocked = [];
281
+ const allowed = [];
282
+ for (const finding of findings) {
283
+ const isAllowed =
284
+ finding.type !== "allow-added" &&
285
+ allowEntries.some(
286
+ e =>
287
+ (finding.file === e.file || finding.file.endsWith(`/${e.file}`)) &&
288
+ (e.key === "*" || e.key === finding.key)
289
+ );
290
+ if (isAllowed) allowed.push(finding);
291
+ else blocked.push(finding);
292
+ }
293
+ return { blocked, allowed };
294
+ }
295
+
296
+ /**
297
+ * Render the operator-facing block message.
298
+ * @param {Finding[]} findings Blocked findings
299
+ * @returns {string} Multi-line report explaining what weakened and the
300
+ * human-approved exception path
301
+ */
302
+ export function formatReport(findings) {
303
+ return [
304
+ "⛔ Quality gate weakened — blocked by the threshold ratchet.",
305
+ "",
306
+ ...findings.map(f => ` • ${f.message}`),
307
+ "",
308
+ "Quality thresholds are a one-way ratchet: they may tighten but never",
309
+ "loosen. Fix the code so it meets the current gate instead of lowering the",
310
+ "gate. If a human decides an exception is genuinely correct, they record it",
311
+ "in .lisa.config.json under thresholdRatchet.allow (with a reason) in a",
312
+ "separate human-approved change; this check honors exceptions only after",
313
+ "they are merged.",
314
+ ].join("\n");
315
+ }