@codyswann/lisa 4.19.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 (133) 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 +24 -7
  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/.codex-plugin/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +349 -0
  54. package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-aws/SKILL.md +23 -5
  55. package/plugins/lisa/agents/learner.md +7 -6
  56. package/plugins/lisa/rules/reference/project-learnings.md +34 -1
  57. package/plugins/lisa/skills/lisa-debrief-apply/SKILL.md +5 -4
  58. package/plugins/lisa/skills/lisa-persist-learning/SKILL.md +2 -2
  59. package/plugins/lisa/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +349 -0
  60. package/plugins/lisa/skills/lisa-setup-remote-aws/SKILL.md +23 -5
  61. package/plugins/lisa-agy/agents/learner.md +7 -6
  62. package/plugins/lisa-agy/plugin.json +1 -1
  63. package/plugins/lisa-agy/skills/lisa-debrief-apply/SKILL.md +5 -4
  64. package/plugins/lisa-agy/skills/lisa-persist-learning/SKILL.md +2 -2
  65. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +349 -0
  66. package/plugins/lisa-agy/skills/lisa-setup-remote-aws/SKILL.md +23 -5
  67. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  69. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  70. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-copilot/agents/learner.agent.md +7 -6
  74. package/plugins/lisa-copilot/rules/reference/project-learnings.md +34 -1
  75. package/plugins/lisa-copilot/skills/lisa-debrief-apply/SKILL.md +5 -4
  76. package/plugins/lisa-copilot/skills/lisa-persist-learning/SKILL.md +2 -2
  77. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +349 -0
  78. package/plugins/lisa-copilot/skills/lisa-setup-remote-aws/SKILL.md +23 -5
  79. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-cursor/agents/learner.md +7 -6
  81. package/plugins/lisa-cursor/rules/project-learnings-reference.mdc +34 -1
  82. package/plugins/lisa-cursor/skills/lisa-debrief-apply/SKILL.md +5 -4
  83. package/plugins/lisa-cursor/skills/lisa-persist-learning/SKILL.md +2 -2
  84. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +349 -0
  85. package/plugins/lisa-cursor/skills/lisa-setup-remote-aws/SKILL.md +23 -5
  86. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  87. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  88. package/plugins/lisa-expo-agy/plugin.json +1 -1
  89. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  92. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  93. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  94. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  95. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  96. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  97. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  98. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  99. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  100. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  101. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  102. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  103. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  104. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  105. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  106. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  107. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  108. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  109. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  110. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  111. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  112. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  113. package/plugins/lisa-rails-agy/plugin.json +1 -1
  114. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  115. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  116. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  117. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  118. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  119. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  120. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  121. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  122. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  123. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  124. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  125. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  126. package/plugins/src/base/agents/learner.md +7 -6
  127. package/plugins/src/base/rules/reference/project-learnings.md +34 -1
  128. package/plugins/src/base/skills/lisa-debrief-apply/SKILL.md +5 -4
  129. package/plugins/src/base/skills/lisa-persist-learning/SKILL.md +2 -2
  130. package/plugins/src/base/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +349 -0
  131. package/plugins/src/base/skills/lisa-setup-remote-aws/SKILL.md +23 -5
  132. package/scripts/verify-learner-frontmatter-built.mjs +1 -1
  133. package/scripts/verify-packed-learnings-contract.mjs +268 -0
package/package.json CHANGED
@@ -74,6 +74,7 @@
74
74
  "verify:health-contract": "bun run build:dist && node scripts/verify-health-contract-built.mjs",
75
75
  "verify:health-deterministic": "bun run build:dist && node scripts/verify-health-deterministic-built.mjs",
76
76
  "verify:learner-frontmatter-built": "bun run build:dist && node scripts/verify-learner-frontmatter-built.mjs",
77
+ "verify:packed-learnings-contract": "bun run build:dist && node scripts/verify-packed-learnings-contract.mjs",
77
78
  "verify:health-agentic": "bun run build:dist && node scripts/verify-health-agentic-built.mjs",
78
79
  "verify:health-consumer": "bun run build:dist && node scripts/verify-health-consumer-built.mjs",
79
80
  "lint:staged": "lint-staged --config .lintstagedrc.json"
@@ -158,7 +159,7 @@
158
159
  "zod-validation-error": "^4.0.0"
159
160
  },
160
161
  "name": "@codyswann/lisa",
161
- "version": "4.19.0",
162
+ "version": "4.21.0",
162
163
  "description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
163
164
  "main": "dist/index.js",
164
165
  "exports": {
@@ -297,5 +298,5 @@
297
298
  "vite": "^8.0.16"
298
299
  },
299
300
  "type": "module",
300
- "lisaReleaseCommit": "41fe63822ee1b74abcd798e71c25ebd33e12278b"
301
+ "lisaReleaseCommit": "f6790eefec944b90abd6c8536b01ca41c2203354"
301
302
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.19.0",
3
+ "version": "4.21.0",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.19.0",
3
+ "version": "4.21.0",
4
4
  "description": "Universal governance: agents, skills, commands, hooks, and rules for all projects.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -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:
@@ -0,0 +1,349 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Publish a newly emitted remote-agent AWS bootstrap bundle to the configured
4
+ * secrets provider without creating a silent split brain.
5
+ *
6
+ * Infrastructure owns the IAM access key and usually emits the complete bundle
7
+ * to AWS Secrets Manager. Remote coding environments cannot use that store as
8
+ * their bootstrap provider: reading it already requires AWS credentials. The
9
+ * provider configured by lisa-secrets-access therefore needs the same bundle,
10
+ * and a manual copy leaves two ways for a rotation to stop halfway.
11
+ *
12
+ * This command makes that handoff one fail-closed operation:
13
+ *
14
+ * 1. prove the existing provider record is writable with a no-op write;
15
+ * 2. validate the candidate and assume every declared role with it;
16
+ * 3. replace the provider value;
17
+ * 4. read the value back exactly and assume every role again; and
18
+ * 5. restore the previous provider value if a post-write check fails.
19
+ *
20
+ * The candidate arrives on stdin so it never appears in a shell command or a
21
+ * temporary file. No credential value is written to stdout or stderr.
22
+ *
23
+ * Usage:
24
+ * aws secretsmanager get-secret-value ... --query SecretString --output text \
25
+ * | publish-aws-bootstrap.mjs publish
26
+ * publish-aws-bootstrap.mjs verify
27
+ * publish-aws-bootstrap.mjs preflight
28
+ * @module publish-aws-bootstrap
29
+ */
30
+
31
+ import { readFileSync, realpathSync } from "node:fs";
32
+ import { fileURLToPath } from "node:url";
33
+
34
+ import {
35
+ BOOTSTRAP_KEY,
36
+ parseBootstrap,
37
+ readProfiles,
38
+ } from "./aws-bootstrap.mjs";
39
+ import { fetchAll, writeSecret } from "./providers.mjs";
40
+ import { readConfig } from "./surfaces.mjs";
41
+
42
+ import { boundedChildOutput } from "../../lisa-setup-workstation/scripts/bounded-child.mjs";
43
+
44
+ const PROFILE_NAME = /^[\w.@-]+$/;
45
+ const ROLE_ARN =
46
+ /^arn:(?:aws|aws-us-gov|aws-cn):iam::\d{12}:role\/[\w+=,.@/-]+$/;
47
+
48
+ /**
49
+ * Validate and normalize one complete bundle without changing its serialized
50
+ * representation.
51
+ * @param {string} raw Candidate JSON from stdin or the provider.
52
+ * @returns {{raw: string, bundle: object, profiles: Array<{name: string, roleArn: string, region: string}>}}
53
+ * Validated candidate and its role entries.
54
+ */
55
+ export function validateAwsBootstrap(raw) {
56
+ if (typeof raw !== "string" || !raw.trim()) {
57
+ throw new Error(
58
+ `${BOOTSTRAP_KEY} is empty; refusing to replace a working credential with nothing`
59
+ );
60
+ }
61
+
62
+ // AWS CLI's text output ends with one newline. It is transport framing, not
63
+ // part of the SecretString, so do not make every publication differ by one
64
+ // invisible byte from the value AWS emitted.
65
+ const normalized = raw.trim();
66
+ const bundle = parseBootstrap(normalized);
67
+ if (!bundle || Array.isArray(bundle)) {
68
+ throw new Error(`${BOOTSTRAP_KEY} is not a JSON object`);
69
+ }
70
+
71
+ const accessKeyId = bundle.accessKeyId ?? bundle.aws_access_key_id;
72
+ const secretAccessKey =
73
+ bundle.secretAccessKey ?? bundle.aws_secret_access_key;
74
+ for (const [field, value] of [
75
+ ["accessKeyId", accessKeyId],
76
+ ["secretAccessKey", secretAccessKey],
77
+ ["externalId", bundle.externalId],
78
+ ["roleName", bundle.roleName],
79
+ ]) {
80
+ if (typeof value !== "string" || !value.trim()) {
81
+ throw new Error(`${BOOTSTRAP_KEY}.${field} must be a non-empty string`);
82
+ }
83
+ }
84
+
85
+ const entries = Object.entries(readProfiles(bundle));
86
+ if (entries.length === 0) {
87
+ throw new Error(`${BOOTSTRAP_KEY}.profiles must declare at least one role`);
88
+ }
89
+
90
+ const profiles = entries.map(([name, entry]) => {
91
+ if (!PROFILE_NAME.test(name)) {
92
+ throw new Error(`AWS bootstrap profile name is not safe: ${name}`);
93
+ }
94
+ const roleArn = entry?.roleArn ?? entry?.role_arn;
95
+ if (typeof roleArn !== "string" || !ROLE_ARN.test(roleArn)) {
96
+ throw new Error(`AWS bootstrap profile ${name} has an invalid roleArn`);
97
+ }
98
+ if (typeof entry?.region !== "string" || !entry.region.trim()) {
99
+ throw new Error(`AWS bootstrap profile ${name} has no region`);
100
+ }
101
+ return { name, roleArn, region: entry.region };
102
+ });
103
+
104
+ return { raw: normalized, bundle, profiles };
105
+ }
106
+
107
+ /**
108
+ * Build the isolated environment used to prove one candidate. Ambient AWS
109
+ * variables are removed so a green result cannot come from the host identity.
110
+ * @param {object} bundle Validated bundle.
111
+ * @returns {NodeJS.ProcessEnv} Child environment containing only this identity.
112
+ */
113
+ export function candidateAwsEnvironment(bundle) {
114
+ const env = { ...process.env };
115
+ for (const key of [
116
+ "AWS_PROFILE",
117
+ "AWS_DEFAULT_PROFILE",
118
+ "AWS_SESSION_TOKEN",
119
+ "AWS_SECURITY_TOKEN",
120
+ ]) {
121
+ delete env[key];
122
+ }
123
+ env.AWS_ACCESS_KEY_ID = String(
124
+ bundle.accessKeyId ?? bundle.aws_access_key_id
125
+ );
126
+ env.AWS_SECRET_ACCESS_KEY = String(
127
+ bundle.secretAccessKey ?? bundle.aws_secret_access_key
128
+ );
129
+ env.AWS_PAGER = "";
130
+ return env;
131
+ }
132
+
133
+ /**
134
+ * Prove that the candidate authenticates and can assume every declared role.
135
+ * Captures all child output so account ids and credential metadata never reach
136
+ * a shared log.
137
+ * @param {{bundle: object, profiles: Array<object>}} candidate Validated bundle.
138
+ * @param {(bin: string, args: string[], options: object) => string} [run]
139
+ * Injectable child runner for tests.
140
+ */
141
+ export function verifyAwsBootstrap(candidate, run = boundedChildOutput) {
142
+ const env = candidateAwsEnvironment(candidate.bundle);
143
+ for (const profile of candidate.profiles) {
144
+ const sessionName = `lisa-publish-${profile.name}`
145
+ .replace(/[^A-Za-z0-9_+=,.@-]/g, "-")
146
+ .slice(0, 64);
147
+ try {
148
+ run(
149
+ "aws",
150
+ [
151
+ "sts",
152
+ "assume-role",
153
+ "--role-arn",
154
+ profile.roleArn,
155
+ "--role-session-name",
156
+ sessionName,
157
+ "--external-id",
158
+ String(candidate.bundle.externalId),
159
+ "--region",
160
+ profile.region,
161
+ "--query",
162
+ "Credentials.Expiration",
163
+ "--output",
164
+ "text",
165
+ ],
166
+ { encoding: "utf8", env, stdio: ["ignore", "pipe", "pipe"] }
167
+ );
168
+ } catch (error) {
169
+ const reason =
170
+ error instanceof Error ? error.message.split("\n")[0] : String(error);
171
+ throw new Error(
172
+ `AWS bootstrap profile ${profile.name} failed STS verification: ${reason}`
173
+ );
174
+ }
175
+ }
176
+ return candidate.profiles.map(profile => profile.name);
177
+ }
178
+
179
+ /**
180
+ * Locate the provider record that owns the bundle.
181
+ * @param {object} cfg Resolved lisa-secrets-access configuration.
182
+ * @param {(cfg: object) => Map<string, object>} fetch Provider reader.
183
+ * @returns {{id: string, value: string}} Writable provider entry.
184
+ */
185
+ function currentEntry(cfg, fetch) {
186
+ const entry = fetch(cfg).get(BOOTSTRAP_KEY);
187
+ if (!entry) {
188
+ throw new Error(
189
+ `${BOOTSTRAP_KEY} is not available through the configured provider boundary`
190
+ );
191
+ }
192
+ if (!entry.id) {
193
+ throw new Error(
194
+ `${BOOTSTRAP_KEY} has no provider identifier, so it cannot be published`
195
+ );
196
+ }
197
+ return { id: entry.id, value: entry.value };
198
+ }
199
+
200
+ /**
201
+ * Exercise the exact provider write permission without changing its value.
202
+ * @param {object} cfg Resolved configuration.
203
+ * @param {object} [operations] Injectable provider operations for tests.
204
+ * @returns {{id: string, value: string}} Current provider entry.
205
+ */
206
+ export function preflightAwsBootstrap(cfg, operations = {}) {
207
+ const fetch = operations.fetch ?? fetchAll;
208
+ const write = operations.write ?? writeSecret;
209
+ const entry = currentEntry(cfg, fetch);
210
+ try {
211
+ write(cfg, entry.id, entry.value);
212
+ } catch (error) {
213
+ const reason =
214
+ error instanceof Error ? error.message.split("\n")[0] : String(error);
215
+ throw new Error(
216
+ `no write access to ${BOOTSTRAP_KEY}: ${reason}\n` +
217
+ `Refusing to continue because a rotated key that cannot be published would strand every remote session.`
218
+ );
219
+ }
220
+ return entry;
221
+ }
222
+
223
+ /**
224
+ * Restore the previous provider value and prove the restoration.
225
+ * @param {object} cfg Resolved configuration.
226
+ * @param {{id: string, value: string}} previous Previous entry.
227
+ * @param {Function} fetch Provider reader.
228
+ * @param {Function} write Provider writer.
229
+ * @returns {string} Human-readable rollback outcome with no secret material.
230
+ */
231
+ function rollback(cfg, previous, fetch, write) {
232
+ try {
233
+ write(cfg, previous.id, previous.value);
234
+ const restored = currentEntry(cfg, fetch);
235
+ if (restored.value !== previous.value) {
236
+ return "the previous provider value could not be verified after rollback";
237
+ }
238
+ return "the previous provider value was restored";
239
+ } catch (error) {
240
+ const reason =
241
+ error instanceof Error ? error.message.split("\n")[0] : String(error);
242
+ return `rollback failed: ${reason}`;
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Publish, read back, and verify a candidate. Any failure after the candidate
248
+ * write triggers a restoration of the previous provider value.
249
+ * @param {string} raw Candidate JSON.
250
+ * @param {object} cfg Resolved configuration.
251
+ * @param {object} [operations] Injectable provider/AWS operations for tests.
252
+ * @returns {{changed: boolean, profiles: string[]}} Publication outcome.
253
+ */
254
+ export function publishAwsBootstrap(raw, cfg, operations = {}) {
255
+ const fetch = operations.fetch ?? fetchAll;
256
+ const write = operations.write ?? writeSecret;
257
+ const verify = operations.verify ?? verifyAwsBootstrap;
258
+ const previous = preflightAwsBootstrap(cfg, { fetch, write });
259
+ const candidate = validateAwsBootstrap(raw);
260
+
261
+ // Verify before the write. A candidate that cannot assume one of its roles
262
+ // never becomes the value every new remote session receives.
263
+ verify(candidate);
264
+
265
+ if (candidate.raw === previous.value) {
266
+ return {
267
+ changed: false,
268
+ profiles: candidate.profiles.map(profile => profile.name),
269
+ };
270
+ }
271
+
272
+ try {
273
+ write(cfg, previous.id, candidate.raw);
274
+ const stored = currentEntry(cfg, fetch);
275
+ if (stored.value !== candidate.raw) {
276
+ throw new Error("provider read-back did not match the candidate exactly");
277
+ }
278
+ const storedCandidate = validateAwsBootstrap(stored.value);
279
+ verify(storedCandidate);
280
+ return {
281
+ changed: true,
282
+ profiles: storedCandidate.profiles.map(profile => profile.name),
283
+ };
284
+ } catch (error) {
285
+ const reason =
286
+ error instanceof Error ? error.message.split("\n")[0] : String(error);
287
+ const rollbackOutcome = rollback(cfg, previous, fetch, write);
288
+ throw new Error(
289
+ `${BOOTSTRAP_KEY} publication failed: ${reason}; ${rollbackOutcome}`
290
+ );
291
+ }
292
+ }
293
+
294
+ /** Read a candidate from stdin without ever placing it in argv. */
295
+ function readCandidate() {
296
+ return readFileSync(0, "utf8");
297
+ }
298
+
299
+ function main() {
300
+ const [operation] = process.argv.slice(2);
301
+ const cfg = readConfig();
302
+
303
+ if (operation === "preflight") {
304
+ const current = preflightAwsBootstrap(cfg);
305
+ const candidate = validateAwsBootstrap(current.value);
306
+ const profiles = verifyAwsBootstrap(candidate);
307
+ console.log(
308
+ `${BOOTSTRAP_KEY}: provider write path and ${profiles.length} AWS profile(s) verified; no value changed`
309
+ );
310
+ return;
311
+ }
312
+
313
+ if (operation === "verify") {
314
+ const current = currentEntry(cfg, fetchAll);
315
+ const candidate = validateAwsBootstrap(current.value);
316
+ const profiles = verifyAwsBootstrap(candidate);
317
+ console.log(`${BOOTSTRAP_KEY}: ${profiles.length} AWS profile(s) verified`);
318
+ return;
319
+ }
320
+
321
+ if (operation === "publish") {
322
+ const result = publishAwsBootstrap(readCandidate(), cfg);
323
+ console.log(
324
+ `${BOOTSTRAP_KEY}: ${result.changed ? "published" : "already current"}; ` +
325
+ `${result.profiles.length} AWS profile(s) verified`
326
+ );
327
+ return;
328
+ }
329
+
330
+ throw new Error("usage: publish-aws-bootstrap.mjs preflight|verify|publish");
331
+ }
332
+
333
+ function invokedAsScript(moduleUrl, argv1 = process.argv[1]) {
334
+ if (!argv1) return false;
335
+ try {
336
+ return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
337
+ } catch {
338
+ return false;
339
+ }
340
+ }
341
+
342
+ if (invokedAsScript(import.meta.url)) {
343
+ try {
344
+ main();
345
+ } catch (error) {
346
+ console.error(error instanceof Error ? error.message : String(error));
347
+ process.exit(1);
348
+ }
349
+ }
@@ -49,11 +49,29 @@ it to AWS Secrets Manager as `remote-agent-credentials`, but that is where it is
49
49
  *emitted*, not where it must *live*. Copy it once into the project's configured
50
50
  provider and read it from there.
51
51
 
52
- **Do not keep it in two stores.** A bundle sitting in Secrets Manager *and* in
53
- the platform's secret store is two live copies of one credential: rotate the
54
- IAM key and whichever copy you forget keeps authenticating until it doesn't,
55
- with nothing to say which is current. That is the drift the single-store rule in
56
- `lisa-secrets-access` exists to prevent, and this credential is not exempt.
52
+ **Do not maintain two stores by hand.** A bundle sitting in Secrets Manager
53
+ and in the platform's secret store is two live copies of one credential: rotate
54
+ the IAM key and whichever copy you forget becomes stale, with nothing to say
55
+ which is current. Treat the infrastructure secret as a short-lived deployment
56
+ emission and the configured provider as the consumer-facing source of truth.
57
+
58
+ Publish the emission as part of the same rotation automation. The publisher
59
+ reads the candidate on stdin, proves every role before writing, reads the
60
+ provider value back exactly, proves every stored role again, and restores the
61
+ previous provider value if a post-write check fails:
62
+
63
+ ```bash
64
+ aws --profile shared secretsmanager get-secret-value \
65
+ --secret-id remote-agent-credentials \
66
+ --query SecretString \
67
+ --output text \
68
+ | node .claude/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs publish
69
+ ```
70
+
71
+ Resolve the script from the installed runtime or `node_modules` when the
72
+ `.claude/skills` copy is not present. Never print or place the candidate in an
73
+ argument or temporary file. A CDK deployment followed later by a human copy is
74
+ not a completed rotation.
57
75
 
58
76
  **One provider cannot serve this particular secret.** If `secrets.provider` is
59
77
  `aws`, reading the bundle requires AWS credentials — and the bundle *is* the AWS
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: learner
3
- description: "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."
3
+ description: "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."
4
4
  ---
5
5
 
6
6
  # Learner Agent
@@ -22,11 +22,12 @@ Why this shape: promotion without a human gate lets agents unilaterally rewrite
22
22
 
23
23
  If no learnings exist, report "No learnings to process" and complete.
24
24
 
25
- ### Step 2: Build the Seven-Field Entry
25
+ ### Step 2: Build the Eight-Field Entry
26
26
 
27
27
  For each `mistake`/`learning` candidate, build the ledger entry the executable contract validates. The `LEARNINGS_CONTRACT` caps apply — an over-cap entry cannot persist; tighten it or drop it, never truncate by hand:
28
28
 
29
- - `id` — a stable dedupe key. Use `learner-` + the first 12 hex chars of `sha1(normalized_rule)` so the same rule always yields the same id (this is what makes re-runs idempotent — the writer throws on a duplicate id).
29
+ - `fingerprint` — the deterministic content-version token: `learner-` + the first 12 hex chars of `sha1(normalized_rule)`. Recompute it from the fully consolidated rule; never estimate it.
30
+ - `id` — set `id = fingerprint` for a new capture. On an exact stamped consolidation the writer carries forward the deterministic primary target id, while `fingerprint` changes with the new content.
30
31
  - `rule` — the actionable learning, **≤ 240 characters and ≤ 2 lines** per `LEARNINGS_CONTRACT`.
31
32
  - `why` — the causal claim (why the rule holds).
32
33
  - `provenance` — stable refs behind the candidate: the originating task id(s), plus any PR/issue/commit refs the task recorded. At most 20 entries. This is also where scope markers live (below).
@@ -47,9 +48,9 @@ LEARNINGS_FILE=$(node -e 'import("@codyswann/lisa/learnings").then(async m => {
47
48
  For each candidate entry:
48
49
 
49
50
  1. **Consolidation check (mandatory before writing).** Parse existing entries with `parseLearningsFile` from `@codyswann/lisa/learnings` and look for a related entry — same failure class, overlapping topic, or near-duplicate wording.
50
- - **Related entry found** → consolidate, do not sibling. Write via `persistConsolidatedLearning(projectRoot, entry, { supersede: [<related ids>] })`, merging the still-true content of the superseded entry into the new rule and keeping the earliest `first_learned`. A near-duplicate sibling is a bug, not an entry.
51
+ - **Related entry found** → consolidate, do not sibling. Copy each target's exact parsed version stamp and write via `persistConsolidatedLearning(projectRoot, entry, { supersede: [{ id: <related id>, fingerprint: <related fingerprint> }], onStaleSupersede: targets => report(targets) })`, merging the still-true content of the superseded entry into the new rule and keeping the earliest `first_learned`. Stamps are checked together inside the lock: if any is stale, the writer removes none, safely appends the new fingerprint, and reports the mismatch. A near-duplicate sibling is a bug, not an entry.
51
52
  - **No related entry** → append via `persistLearningEntry(projectRoot, entry)`.
52
- - **Already present (same id)** → the writer throws on a duplicate id; treat that as a dropped-duplicate no-op and record it as such. Re-running over the same tasks leaves the ledger unchanged.
53
+ - **Already present (same fingerprint)** → the writer throws on a duplicate fingerprint before mutation; treat that as a dropped-duplicate no-op and record it as such. Re-running over the same tasks leaves the ledger unchanged.
53
54
  2. The writer re-asserts the entry and document budgets. An over-budget failure means consolidate harder or drop — never truncate by hand.
54
55
 
55
56
  Persistence rides the implement flow's normal branch/PR: the code change and its learnings land in the same PR (satisfying "every persistence is a PR" with no new machinery). Never commit the ledger straight to the default branch and never hand-edit it.
@@ -75,7 +76,7 @@ One row per collected learning, mapping it to its terminal disposition:
75
76
  - **Capture-only.** Create no skills, append to no human-authored rules file, file no issue anywhere — take no promotion decision of any kind. Every promotion (skill, eager rule, executable control, upstream ticket) is the gardener's ticket-gated job, and the ledger is your only output surface.
76
77
  - **One write path.** The ledger changes only through `persistLearningEntry` / `persistConsolidatedLearning` from `@codyswann/lisa/learnings`, inside a PR. Never hand-edit the file.
77
78
  - **Consolidate, never sibling.** A related existing entry is merged or superseded at write time (SLL-6 discipline), never duplicated.
78
- - **Idempotent.** Deduplicate before persisting; stable ids and consolidation guarantee re-runs over the same tasks leave the ledger unchanged.
79
+ - **Idempotent.** Deduplicate before persisting; the deterministic fingerprint and stamped consolidation guarantee re-runs over the same tasks leave the ledger unchanged.
79
80
  - **Headless-safe.** No interactive prompts; runs identically under an intake cron.
80
81
  - **Never block the build.** If persistence fails, report the failure and let the primary flow continue — shipping the work outranks recording a learning about it.
81
82
  - **No learning loops about learning.** Gardener tickets and learning PRs are not themselves learnings; never capture them.
@@ -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: