@codyswann/lisa 4.20.0 → 4.21.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 (121) hide show
  1. package/all/copy-contents/.gitattributes +2 -1
  2. package/all/create-only/.lisa/PROJECT_LEARNINGS.md +1 -1
  3. package/dist/core/learnings-alias.d.ts +26 -45
  4. package/dist/core/learnings-alias.d.ts.map +1 -1
  5. package/dist/core/learnings-alias.js +29 -48
  6. package/dist/core/learnings-alias.js.map +1 -1
  7. package/dist/core/learnings-budget-check.d.ts +2 -1
  8. package/dist/core/learnings-budget-check.d.ts.map +1 -1
  9. package/dist/core/learnings-budget-check.js +13 -11
  10. package/dist/core/learnings-budget-check.js.map +1 -1
  11. package/dist/core/learnings-contract.d.ts +27 -10
  12. package/dist/core/learnings-contract.d.ts.map +1 -1
  13. package/dist/core/learnings-contract.js +37 -9
  14. package/dist/core/learnings-contract.js.map +1 -1
  15. package/dist/core/learnings-document.d.ts +20 -1
  16. package/dist/core/learnings-document.d.ts.map +1 -1
  17. package/dist/core/learnings-document.js +81 -14
  18. package/dist/core/learnings-document.js.map +1 -1
  19. package/dist/core/learnings-entry.d.ts +11 -1
  20. package/dist/core/learnings-entry.d.ts.map +1 -1
  21. package/dist/core/learnings-entry.js +98 -24
  22. package/dist/core/learnings-entry.js.map +1 -1
  23. package/dist/core/learnings-merge-driver.d.ts +4 -2
  24. package/dist/core/learnings-merge-driver.d.ts.map +1 -1
  25. package/dist/core/learnings-merge-driver.js +6 -3
  26. package/dist/core/learnings-merge-driver.js.map +1 -1
  27. package/dist/core/learnings-merge.d.ts.map +1 -1
  28. package/dist/core/learnings-merge.js +96 -10
  29. package/dist/core/learnings-merge.js.map +1 -1
  30. package/dist/core/learnings-overflow.d.ts.map +1 -1
  31. package/dist/core/learnings-overflow.js +72 -3
  32. package/dist/core/learnings-overflow.js.map +1 -1
  33. package/dist/core/learnings-supersede.d.ts +56 -0
  34. package/dist/core/learnings-supersede.d.ts.map +1 -0
  35. package/dist/core/learnings-supersede.js +199 -0
  36. package/dist/core/learnings-supersede.js.map +1 -0
  37. package/dist/core/learnings-writer.d.ts +9 -7
  38. package/dist/core/learnings-writer.d.ts.map +1 -1
  39. package/dist/core/learnings-writer.js +31 -58
  40. package/dist/core/learnings-writer.js.map +1 -1
  41. package/dist/core/learnings.d.ts +3 -0
  42. package/dist/core/learnings.d.ts.map +1 -1
  43. package/dist/core/learnings.js +3 -0
  44. package/dist/core/learnings.js.map +1 -1
  45. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  46. package/dist/core/upstream-evidence-manifest.js +15 -6
  47. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  48. package/package.json +3 -2
  49. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  51. package/plugins/lisa/.codex-plugin/skills/lisa-debrief-apply/SKILL.md +5 -4
  52. package/plugins/lisa/.codex-plugin/skills/lisa-persist-learning/SKILL.md +2 -2
  53. package/plugins/lisa/agents/learner.md +7 -6
  54. package/plugins/lisa/rules/reference/project-learnings.md +34 -1
  55. package/plugins/lisa/skills/lisa-debrief-apply/SKILL.md +5 -4
  56. package/plugins/lisa/skills/lisa-persist-learning/SKILL.md +2 -2
  57. package/plugins/lisa-agy/agents/learner.md +7 -6
  58. package/plugins/lisa-agy/plugin.json +1 -1
  59. package/plugins/lisa-agy/skills/lisa-debrief-apply/SKILL.md +5 -4
  60. package/plugins/lisa-agy/skills/lisa-persist-learning/SKILL.md +2 -2
  61. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  63. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  64. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-copilot/agents/learner.agent.md +7 -6
  68. package/plugins/lisa-copilot/rules/reference/project-learnings.md +34 -1
  69. package/plugins/lisa-copilot/skills/lisa-debrief-apply/SKILL.md +5 -4
  70. package/plugins/lisa-copilot/skills/lisa-persist-learning/SKILL.md +2 -2
  71. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-cursor/agents/learner.md +7 -6
  73. package/plugins/lisa-cursor/rules/project-learnings-reference.mdc +34 -1
  74. package/plugins/lisa-cursor/skills/lisa-debrief-apply/SKILL.md +5 -4
  75. package/plugins/lisa-cursor/skills/lisa-persist-learning/SKILL.md +2 -2
  76. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  78. package/plugins/lisa-expo-agy/plugin.json +1 -1
  79. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  83. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  84. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  87. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  88. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  89. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  92. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  93. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  94. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  95. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  96. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  97. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  98. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  99. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  100. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  101. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  102. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  103. package/plugins/lisa-rails-agy/plugin.json +1 -1
  104. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  105. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  106. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  107. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  108. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  109. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  110. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  111. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  112. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  113. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  114. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  115. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  116. package/plugins/src/base/agents/learner.md +7 -6
  117. package/plugins/src/base/rules/reference/project-learnings.md +34 -1
  118. package/plugins/src/base/skills/lisa-debrief-apply/SKILL.md +5 -4
  119. package/plugins/src/base/skills/lisa-persist-learning/SKILL.md +2 -2
  120. package/scripts/verify-learner-frontmatter-built.mjs +1 -1
  121. package/scripts/verify-packed-learnings-contract.mjs +268 -0
@@ -17,9 +17,10 @@ then recency — that fit the token/entry budget, plus how many were omitted).
17
17
  the bounded projection, not the full document. Do not duplicate numeric caps in
18
18
  rules or prompts; the exported contract is the source of truth.
19
19
 
20
- Each persisted entry has seven fields:
20
+ Each persisted v2 entry has eight fields:
21
21
 
22
22
  - `id`
23
+ - `fingerprint`
23
24
  - `rule`
24
25
  - `why`
25
26
  - `provenance`
@@ -27,6 +28,36 @@ Each persisted entry has seven fields:
27
28
  - `last_confirmed`
28
29
  - `confidence`
29
30
 
31
+ `fingerprint` is the content-version token; `id` is the stable public identity.
32
+ A new entry starts with `id = fingerprint`. Consolidation passes exact
33
+ `{ id, fingerprint }` stamps copied from parsed entries. The writer checks the
34
+ whole target set inside its lock: an exact set is replaced together while the
35
+ deterministic primary id carries forward; any stale stamp removes nothing and
36
+ the new fingerprint is appended safely. Fingerprints are unique, so an
37
+ identical re-capture fails before mutation rather than gaining a sibling.
38
+
39
+ ## v1 compatibility and mutation-only migration
40
+
41
+ `parseLearningsDocument` accepts both contract versions and reports
42
+ `sourceVersion`, normalized `entries`, `canonicalSource`, and
43
+ `sourceMaxTokens`. A v1 entry is normalized in memory with `fingerprint = id`,
44
+ which records the version-token job its old id already performed. A read never
45
+ rewrites the ledger. The next real writer mutation — append, exact stamped
46
+ consolidation, confirmation, overflow drain, or merge — publishes one atomic v2
47
+ document. Each source version is validated against its own derived budget; do
48
+ not copy either numeric ceiling into instructions. Stable ids and fingerprints
49
+ are bounded machine tokens, and the v2 budget is derived to absorb the maximum
50
+ per-entry fingerprint expansion for every v1 document the compatibility parser
51
+ accepts. That migration-closure invariant prevents an ordinary later mutation
52
+ from discovering that a previously accepted legacy document cannot be
53
+ published.
54
+
55
+ The three-way merge understands mixed v1/v2 sides. When two branches both
56
+ legitimately replace the same stable id from the same observed fingerprint, it
57
+ keeps both successors deterministically: one retains the stable id and the
58
+ other is keyed by its unique fingerprint. It never guesses when fingerprints
59
+ collide.
60
+
30
61
  ## Who writes the ledger
31
62
 
32
63
  **Contract-mediated writers** — all going through the executable contract
@@ -34,6 +65,8 @@ Each persisted entry has seven fields:
34
65
 
35
66
  - The **learner agent** at capture time appends or consolidates new entries
36
67
  (`persistLearningEntry` / `persistConsolidatedLearning`) from task learnings.
68
+ Consolidations pass exact parsed `{ id, fingerprint }` stamps and report a
69
+ stale snapshot through `onStaleSupersede` instead of overwriting its winner.
37
70
  It is capture-only: it never appends to host rules, creates skills, or
38
71
  files upstream issues — promotion is the gardener's ticket-gated job.
39
72
  - **`lisa-debrief-apply`** routes accepted debrief findings in the three
@@ -51,15 +51,16 @@ For each such Accepted row:
51
51
  ```
52
52
 
53
53
  2. **Consolidation check (mandatory before writing).** Parse the existing entries with `parseLearningsFile` and look for one related to the row (same failure class, overlapping topic, or near-duplicate wording). Then write through the contract:
54
- - **Related entry found** → consolidate via `persistConsolidatedLearning(projectRoot, entry, { supersede: [<related ids>] })`, merging the old entry's still-true content into the new rule. Never append a near-duplicate sibling — a sibling is a bug.
54
+ - **Related entry found** → consolidate with the exact parsed versions: `persistConsolidatedLearning(projectRoot, entry, { supersede: [{ id: <related id>, fingerprint: <related fingerprint> }], onStaleSupersede: targets => report(targets) })`. Every stamp is checked together inside the writer lock. If any is stale, the writer removes none, safely appends the new fingerprint, and reports the mismatch. Never append a near-duplicate sibling by choice — a sibling is a bug; the stale append is the deliberate concurrent-writer preservation path.
55
55
  - **No related entry** → append via `persistLearningEntry(projectRoot, entry)`.
56
56
 
57
- 3. **Entry mapping (seven fields).**
58
- - `id` — the entry id returned by the contract writer (report it in the run summary).
57
+ 3. **Entry mapping (eight fields).**
58
+ - `fingerprint` — `debrief-` + the first 12 hex characters of `sha1(normalized_rule + "\n" + sorted_provenance.join("\n"))`, where the rule is lowercased with whitespace collapsed and provenance is sorted by codepoint. Compute it mechanically, never estimate it.
59
+ - `id` — initially `id = fingerprint`. On an exact stamped consolidation the writer carries forward the deterministic primary target id; report the returned id in the run summary.
59
60
  - `rule` / `why` — from the row's `Summary` and the category-specific guidance in the routing table above (≤240 chars, ≤2 lines).
60
61
  - `provenance` — **the triage-doc row's evidence links**. This is the same evidence link that doubles as the idempotency fingerprint below, so the row's provenance is what a later re-apply scans for.
61
62
  - `first_learned` = `last_confirmed` = today (ISO date; on consolidation keep the superseded entry's earliest `first_learned`).
62
- - `confidence` = **`high`**. A human marking the row **Accept** is corroboration — an independent human judgement that the learning is real — so a debrief-accepted entry starts higher than the learner's single-occurrence auto-capture (which defaults to `low`). The writer re-asserts the entry and token budgets; an over-budget failure means consolidate harder or drop, never truncate by hand.
63
+ - `confidence` = **`high`**. A human marking the row **Accept** is corroboration — an independent human judgement that the learning is real — so a debrief-accepted entry starts higher than the learner's single-occurrence auto-capture (which defaults to `low`). A duplicate fingerprint fails before mutation. The writer re-asserts the entry and token budgets; an over-budget failure means consolidate harder or drop, never truncate by hand.
63
64
 
64
65
  **Machine-local memory is no longer a knowledge destination.** Auto-memory (`project_*.md`, `MEMORY.md`) remains available only for the assistant's *personal* collaboration notes — it is invisible to cloud runs and to teammates, so it can never hold shared project knowledge. **`AGENTS.md` is human-authored** agent operating instruction — it is the source of truth, and `CLAUDE.md` is only a one-line `@AGENTS.md` pointer — and the host-rules directory `.agents/rules/` is durable human-authored guidance; `apply` never writes to any of the three for these categories.
65
66
 
@@ -171,9 +171,9 @@ No learning content is ever committed without a PR — there is no other write p
171
171
  ```
172
172
 
173
173
  3. **Consolidation check (mandatory before writing).** Parse the existing entries (`parseLearningsFile` from `@codyswann/lisa/learnings`) and look for entries related to the new rule (same failure class, overlapping topic, or near-duplicate wording). Then write through the executable contract — **never hand-edit the markdown**:
174
- - **Related entry found** → consolidate via `persistConsolidatedLearning(projectRoot, entry, { supersede: [<related ids>] })`, merging the old entry's still-true content into the new rule. Never append a near-duplicate sibling — a sibling is a bug that fails review.
174
+ - **Related entry found** → consolidate with the exact versions returned by the parser: `persistConsolidatedLearning(projectRoot, entry, { supersede: [{ id: <related id>, fingerprint: <related fingerprint> }], onStaleSupersede: targets => report(targets) })`. The writer checks every stamp together inside the lock. If any target is absent or its fingerprint changed, the whole supersede is stale: it removes nothing, safely appends the new fingerprint, and reports the mismatch. Never append a near-duplicate sibling by choice — a sibling is a bug that fails review; the writer's stale append is the deliberate race-safe preservation path.
175
175
  - **No related entry** → append via `persistLearningEntry(projectRoot, entry)` and state in the PR body why appending was correct.
176
- - Entry mapping: `id` = the fingerprint; `rule`/`why`/`provenance` from the candidate; `first_learned` = `last_confirmed` = today (ISO date; on consolidation keep the superseded entry's earliest `first_learned`); `confidence` = the judge's `high`/`low`. The writer re-asserts the entry and token budgets — an over-budget failure means consolidate harder or drop, never truncate by hand.
176
+ - Entry mapping (eight fields): `fingerprint` = the deterministic Phase 0 fingerprint and initial `id = fingerprint`; `rule`/`why`/`provenance` from the candidate; `first_learned` = `last_confirmed` = today (ISO date; on consolidation keep the superseded entry's earliest `first_learned`); `confidence` = the judge's `high`/`low`. On an exact stamped consolidation the writer carries forward the deterministic primary target id while persisting the new fingerprint as the version token. A duplicate fingerprint fails before mutation. The writer re-asserts the entry and token budgets — an over-budget failure means consolidate harder or drop, never truncate by hand.
177
177
  - **On a budget-forced drop, signal saturation once (never silent).** When the writer's budget re-assertion cannot fit the entry even after consolidating harder — a durable capture has to be DROPPED for budget — the ledger is saturated, and that pressure must be visible to an operator instead of swallowed. Emit **exactly one** tracker signal via `lisa-tracker-write` (`issue_type: Task`; GitHub trackers carry the `type:Task` label), then continue — dropping the capture never blocks the build. Follow the same marker-dedupe discipline as every other comment here (match on the **marker, never the title**; **exactly one marker per body**; the eventual-consistency guard when the search index is stale), with **one deliberate difference: dedupe against OPEN signals only.** A *closed* saturation ticket means room was already reclaimed, so a fresh saturation is a new actionable event — searching closed tickets too (as the drop/upstream markers do) would permanently suppress every later saturation.
178
178
 
179
179
  The saturation fingerprint keys on the **ledger, not the candidate**, so the signal fires once per saturation episode — never once per capture:
@@ -12,7 +12,7 @@ import { parse as parseToml } from "smol-toml";
12
12
  import { discoverAndInstallAgents as installOpencodeAgents } from "../dist/opencode/agent-installer.js";
13
13
 
14
14
  const EXPECTED_DESCRIPTION =
15
- "Post-implementation learning agent. Capture-only — collects task learnings, builds seven-field entries, and persists them to the machine-managed ledger through the executable contract with provenance. Never promotes: it creates no skills, appends no rules, files no upstream issues; promotion is exclusively the gardener's ticket-gated job.";
15
+ "Post-implementation learning agent. Capture-only — collects task learnings, builds eight-field entries, and persists them to the machine-managed ledger through the executable contract with provenance. Never promotes: it creates no skills, appends no rules, files no upstream issues; promotion is exclusively the gardener's ticket-gated job.";
16
16
  const executeFile = promisify(execFile);
17
17
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
18
18
  const destination = await mkdtemp(
@@ -0,0 +1,268 @@
1
+ #!/usr/bin/env node
2
+ /** Empirical proof that the packed public learnings API preserves v2 semantics. */
3
+ import assert from "node:assert/strict";
4
+ import { execFile } from "node:child_process";
5
+ import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { pathToFileURL } from "node:url";
8
+ import { promisify } from "node:util";
9
+
10
+ const executeFile = promisify(execFile);
11
+ const root = path.resolve(import.meta.dirname, "..");
12
+ const temporary = await mkdtemp(path.join(root, ".packed-learnings-proof-"));
13
+
14
+ try {
15
+ const archive = path.join(temporary, "lisa-packed.tgz");
16
+ await executeFile(
17
+ "bun",
18
+ ["pm", "pack", "--ignore-scripts", "--filename", archive, "--quiet"],
19
+ { cwd: root }
20
+ );
21
+ await executeFile("/usr/bin/tar", ["-xzf", archive, "-C", temporary]);
22
+ const packedRoot = path.join(temporary, "package");
23
+ const learnings = await import(
24
+ pathToFileURL(path.join(packedRoot, "dist", "core", "learnings.js")).href
25
+ );
26
+
27
+ assert.equal(learnings.LEARNINGS_CONTRACT.version, 2);
28
+ assert.equal(learnings.LEARNINGS_CONTRACT.fields.length, 8);
29
+ assert.equal(learnings.MAX_STABLE_TOKEN_BYTES, 128);
30
+ assert.equal(learnings.LEARNINGS_CONTRACT.maxTokens, 14_900);
31
+
32
+ const packedVictim = "packed-victim";
33
+ const packedProvenance = "issue:#2015";
34
+ const packedDate = "2026-08-26";
35
+ const packedEntry = {
36
+ id: packedVictim,
37
+ fingerprint: "packed-victim-fingerprint",
38
+ rule: "Keep packed entry validation inert.",
39
+ why: "Consumers pass untrusted values through the public writer.",
40
+ provenance: [packedProvenance],
41
+ first_learned: packedDate,
42
+ last_confirmed: packedDate,
43
+ confidence: "high",
44
+ };
45
+ let entryGetterCalls = 0;
46
+ const accessorEntry = { ...packedEntry };
47
+ Object.defineProperty(accessorEntry, "why", {
48
+ get: () => {
49
+ entryGetterCalls += 1;
50
+ return "hostile";
51
+ },
52
+ });
53
+ assert.throws(
54
+ () => learnings.validateLearningEntry(accessorEntry),
55
+ /why.*accessor/i
56
+ );
57
+ assert.equal(entryGetterCalls, 0);
58
+ const hostileProvenance = [packedProvenance];
59
+ hostileProvenance.unexpected = true;
60
+ assert.throws(
61
+ () =>
62
+ learnings.validateLearningEntry({
63
+ ...packedEntry,
64
+ provenance: hostileProvenance,
65
+ }),
66
+ /provenance array/i
67
+ );
68
+
69
+ let stampGetterCalls = 0;
70
+ const accessorStamps = [];
71
+ accessorStamps.length = 1;
72
+ Object.defineProperty(accessorStamps, "0", {
73
+ get: () => {
74
+ stampGetterCalls += 1;
75
+ return { id: packedVictim, fingerprint: packedVictim };
76
+ },
77
+ });
78
+ assert.throws(
79
+ () => learnings.validateLearningEntryStamps(accessorStamps),
80
+ /supersede array/i
81
+ );
82
+ assert.equal(stampGetterCalls, 0);
83
+ Object.defineProperties(Object.prototype, {
84
+ id: { configurable: true, value: { value: packedVictim } },
85
+ fingerprint: {
86
+ configurable: true,
87
+ value: { value: packedVictim },
88
+ },
89
+ });
90
+ try {
91
+ const pollutedEntry = { ...packedEntry };
92
+ delete pollutedEntry.id;
93
+ delete pollutedEntry.fingerprint;
94
+ pollutedEntry.unrelatedId = "ignored";
95
+ pollutedEntry.unrelatedFingerprint = "ignored";
96
+ assert.throws(
97
+ () => learnings.validateLearningEntry(pollutedEntry),
98
+ /entry fields.*exactly/i
99
+ );
100
+ assert.throws(
101
+ () =>
102
+ learnings.validateLearningEntryStamps([
103
+ { harmless: true, stillHarmless: true },
104
+ ]),
105
+ /exactly.*id.*fingerprint/i
106
+ );
107
+ } finally {
108
+ delete Object.prototype.id;
109
+ delete Object.prototype.fingerprint;
110
+ }
111
+ assert.throws(
112
+ () =>
113
+ learnings.validateLearningEntryStamps([
114
+ { id: "A".repeat(129), fingerprint: packedVictim },
115
+ ]),
116
+ /id exceeds max stable token bytes 128/i
117
+ );
118
+ const inheritedProvenance = [];
119
+ inheritedProvenance.length = 1;
120
+ Object.defineProperty(Object.prototype, "0", {
121
+ configurable: true,
122
+ writable: true,
123
+ value: { value: "issue:#forged" },
124
+ });
125
+ try {
126
+ assert.throws(
127
+ () =>
128
+ learnings.validateLearningEntry({
129
+ ...packedEntry,
130
+ provenance: inheritedProvenance,
131
+ }),
132
+ /provenance array/i
133
+ );
134
+ } finally {
135
+ delete Object.prototype["0"];
136
+ }
137
+
138
+ const legacy = `# Project Learnings\n\n<!-- lisa-learnings-contract:v1 -->\n\n\`\`\`jsonl\n{"id":"learner-base","rule":"Keep packed compatibility executable.","why":"Consumers install the tarball, not the source tree.","provenance":["issue:#2015"],"first_learned":"2026-08-26","last_confirmed":"2026-08-26","confidence":"high"}\n\`\`\`\n`;
139
+ const normalized = learnings.parseLearningsDocument(legacy);
140
+ assert.equal(normalized.sourceVersion, 1);
141
+ assert.equal(normalized.entries[0].fingerprint, "learner-base");
142
+ const oversizedLegacyId = "a".repeat(7007);
143
+ const oversizedLegacy = `# Project Learnings\n\n<!-- lisa-learnings-contract:v1 -->\n\n\`\`\`jsonl\n${JSON.stringify({ ...JSON.parse(legacy.split("\n")[5]), id: oversizedLegacyId, rule: "r", why: "w".repeat(20), provenance: ["p"] })}\n\`\`\`\n`;
144
+ assert.equal(Buffer.byteLength(oversizedLegacy, "utf8"), 7226);
145
+ assert.throws(
146
+ () => learnings.parseLearningsDocument(oversizedLegacy),
147
+ /id exceeds max stable token bytes 128/i
148
+ );
149
+
150
+ const project = path.join(temporary, "consumer");
151
+ await mkdir(project);
152
+ const base = {
153
+ ...normalized.entries[0],
154
+ fingerprint: "learner-base-fingerprint",
155
+ };
156
+ await learnings.persistLearningEntry(project, base);
157
+ const observed = { id: base.id, fingerprint: base.fingerprint };
158
+ const stale = [];
159
+ const candidates = Array.from({ length: 9 }, (_unused, index) => ({
160
+ ...base,
161
+ id: `learner-candidate-${index}`,
162
+ fingerprint: `learner-candidate-fingerprint-${index}`,
163
+ rule: `Packed writer candidate ${index}.`,
164
+ }));
165
+ await Promise.all(
166
+ candidates.map(candidate =>
167
+ learnings.persistConsolidatedLearning(project, candidate, {
168
+ supersede: [observed],
169
+ onStaleSupersede: targets => stale.push(targets),
170
+ })
171
+ )
172
+ );
173
+
174
+ const ledgerPath = path.join(project, ".lisa", "PROJECT_LEARNINGS.md");
175
+ const afterRace = learnings.parseLearningsFile(
176
+ await readFile(ledgerPath, "utf8")
177
+ );
178
+ assert.equal(afterRace.length, 9);
179
+ assert.equal(stale.length, 8);
180
+ const current = afterRace.find(entry => entry.id === base.id);
181
+ assert.ok(current);
182
+
183
+ const chained = {
184
+ ...current,
185
+ id: "learner-chained-candidate",
186
+ fingerprint: "learner-chained-fingerprint",
187
+ rule: "A current stamp may rewrite the stable identity.",
188
+ };
189
+ await learnings.persistConsolidatedLearning(project, chained, {
190
+ supersede: [{ id: current.id, fingerprint: current.fingerprint }],
191
+ });
192
+ const beforeCollision = await readFile(ledgerPath, "utf8");
193
+ await assert.rejects(
194
+ learnings.persistLearningEntry(project, {
195
+ ...chained,
196
+ id: "learner-duplicate-content",
197
+ }),
198
+ /duplicate learning fingerprint/i
199
+ );
200
+ assert.equal(await readFile(ledgerPath, "utf8"), beforeCollision);
201
+
202
+ const ancestor = normalized.entries[0];
203
+ const ours = { ...ancestor, fingerprint: "learner-ours-fingerprint" };
204
+ const theirs = { ...ancestor, fingerprint: "learner-theirs-fingerprint" };
205
+ const merged = learnings.mergeLearningsDocuments(
206
+ legacy,
207
+ learnings.renderLearningsFile([ours]),
208
+ learnings.renderLearningsFile([theirs])
209
+ );
210
+ assert.equal(merged.kind, "merged");
211
+ const mergedEntries = learnings.parseLearningsFile(merged.content);
212
+ assert.equal(mergedEntries.length, 2);
213
+ assert.equal(new Set(mergedEntries.map(entry => entry.fingerprint)).size, 2);
214
+ assert.equal(learnings.projectLearnings(mergedEntries).omittedCount, 0);
215
+
216
+ const overflowProject = path.join(temporary, "overflow-consumer");
217
+ await mkdir(overflowProject);
218
+ const overflowEntry = index => ({
219
+ id: `packed-overflow-${index}`,
220
+ fingerprint: `packed-overflow-${index}`,
221
+ rule: `Packed overflow entry ${index}.`,
222
+ why: "Compact packed proof.",
223
+ provenance: [`issue:#${index}`],
224
+ first_learned: packedDate,
225
+ last_confirmed: packedDate,
226
+ confidence: "high",
227
+ });
228
+ for (
229
+ let index = 0;
230
+ index < learnings.LEARNINGS_CONTRACT.maxEntries;
231
+ index += 1
232
+ ) {
233
+ await learnings.persistLearningEntry(overflowProject, overflowEntry(index));
234
+ }
235
+ const firstDrop = overflowEntry(20);
236
+ await assert.rejects(
237
+ learnings.persistLearningEntry(overflowProject, firstDrop),
238
+ /was preserved/
239
+ );
240
+ const sameIdFork = { ...overflowEntry(21), id: firstDrop.id };
241
+ await assert.rejects(
242
+ learnings.persistLearningEntry(overflowProject, sameIdFork),
243
+ /was preserved/
244
+ );
245
+ const overflow = await learnings.readLearningsOverflow(overflowProject);
246
+ assert.deepEqual(
247
+ overflow.entries.map(entry => [entry.id, entry.fingerprint]),
248
+ [
249
+ [firstDrop.id, firstDrop.fingerprint],
250
+ [sameIdFork.fingerprint, sameIdFork.fingerprint],
251
+ ]
252
+ );
253
+ const beforeOverflowCollision = await readFile(overflow.file, "utf8");
254
+ await assert.rejects(
255
+ learnings.persistLearningEntry(overflowProject, {
256
+ ...overflowEntry(22),
257
+ fingerprint: firstDrop.fingerprint,
258
+ }),
259
+ /duplicate learning fingerprint/i
260
+ );
261
+ assert.equal(await readFile(overflow.file, "utf8"), beforeOverflowCollision);
262
+
263
+ console.log(
264
+ "[EVIDENCE: packed-learnings-contract] v1=migratable legacy=bounded entries=hardened stamps=hardened race=9 stale=8 chain=stable collision=atomic overflow=fork merge=2 projection=bounded"
265
+ );
266
+ } finally {
267
+ await rm(temporary, { recursive: true, force: true });
268
+ }