@codyswann/lisa 4.37.3 → 4.38.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 (81) hide show
  1. package/all/copy-contents/.gitattributes +9 -0
  2. package/dist/core/learnings-merge-driver.d.ts +8 -0
  3. package/dist/core/learnings-merge-driver.d.ts.map +1 -1
  4. package/dist/core/learnings-merge-driver.js +17 -0
  5. package/dist/core/learnings-merge-driver.js.map +1 -1
  6. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  7. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  8. package/dist/core/upstream-evidence-manifest.js +28 -2
  9. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  10. package/package.json +5 -4
  11. package/plugins/lisa/.claude-plugin/plugin.json +28 -1
  12. package/plugins/lisa/.codex-plugin/hooks.json +27 -0
  13. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  14. package/plugins/lisa/hooks/withdrawn-rulings.mjs +525 -0
  15. package/plugins/lisa/hooks/withdrawn-rulings.sh +24 -0
  16. package/plugins/lisa/rules/eager/withdrawn-rulings.md +69 -0
  17. package/plugins/lisa/rules/reference/withdrawn-rulings.md +114 -0
  18. package/plugins/lisa-agy/plugin.json +1 -1
  19. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  21. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  22. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-copilot/.claude-plugin/plugin.json +19 -1
  25. package/plugins/lisa-copilot/hooks/withdrawn-rulings.mjs +525 -0
  26. package/plugins/lisa-copilot/hooks/withdrawn-rulings.sh +24 -0
  27. package/plugins/lisa-copilot/rules/eager/withdrawn-rulings.md +69 -0
  28. package/plugins/lisa-copilot/rules/reference/withdrawn-rulings.md +114 -0
  29. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-cursor/hooks/hooks.json +10 -0
  31. package/plugins/lisa-cursor/hooks/withdrawn-rulings.mjs +525 -0
  32. package/plugins/lisa-cursor/hooks/withdrawn-rulings.sh +24 -0
  33. package/plugins/lisa-cursor/rules/withdrawn-rulings-reference.mdc +119 -0
  34. package/plugins/lisa-cursor/rules/withdrawn-rulings.mdc +74 -0
  35. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  37. package/plugins/lisa-expo-agy/plugin.json +1 -1
  38. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  42. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  43. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  47. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  48. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  53. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  58. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  62. package/plugins/lisa-rails-agy/plugin.json +1 -1
  63. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  67. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  68. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  72. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  73. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  75. package/plugins/src/base/.claude-plugin/plugin.json +27 -0
  76. package/plugins/src/base/hooks/withdrawn-rulings.mjs +525 -0
  77. package/plugins/src/base/hooks/withdrawn-rulings.sh +24 -0
  78. package/plugins/src/base/rules/eager/withdrawn-rulings.md +69 -0
  79. package/plugins/src/base/rules/reference/withdrawn-rulings.md +114 -0
  80. package/scripts/lib/hook-registration-audit.mjs +612 -0
  81. package/typescript/copy-contents/.husky/pre-push +98 -26
@@ -0,0 +1,114 @@
1
+ # Withdrawn Rulings — reference
2
+
3
+ Long-form body for the eager rule `withdrawn-rulings`. Origin: CodySwannGT/lisa#3752.
4
+
5
+ ## The general property
6
+
7
+ A session is a snapshot. Three independent instances of it were measured on one day:
8
+
9
+ 1. **Plugin version.** A session executes the plugin cache directory resolved *when the session
10
+ started*. A session roughly eleven hours old was running one version while the default branch and
11
+ the marketplace clone were two releases ahead — with the newer version already sitting in that
12
+ cache. Not a distribution failure: updating the cache does not help a running session, only a
13
+ restart re-resolves. That session reported an already-fixed defect as live, persuasively, because
14
+ it really had observed the behaviour. It was reporting the truth about a stale binary.
15
+ 2. **Rules injection.** The rule injector reads `rules/eager/*.md` and concatenates them into
16
+ context at SessionStart and SubagentStart. No metadata, no expiry, no filter — a rule withdrawn
17
+ upstream stays in the context of every session that started before the withdrawal, for that
18
+ session's whole life.
19
+ 3. **Shipped versus in effect.** A fix merged to the default branch is not in effect for any session
20
+ already running.
21
+
22
+ **A fix has two facts behind it: that it exists, and that it is in effect.** Testing either one and
23
+ reporting "fixed" is the error. Two agents made opposite halves of that mistake in a single evening,
24
+ neither carelessly, each holding a real measurement.
25
+
26
+ ## The two instances that produced this rule
27
+
28
+ **A correction that was itself wrong, published.** A claim was refuted with specific file-and-line
29
+ evidence. The refutation was propagated to two agents and into a public work-item comment before a
30
+ third agent, reading the source directly, established that the original claim was right and the
31
+ refutation had been produced by grepping a stale checkout. The wrong correction stood for roughly
32
+ forty minutes. Anyone who read that item in the interval holds the wrong version and has no signal
33
+ it changed — the retraction is a later comment, which a reader who has already formed a conclusion
34
+ will not necessarily see.
35
+
36
+ **A workaround circulated, then retracted.** A technique was recommended to three agents, then
37
+ disproved by measurement and withdrawn. The three had no mechanism to learn it was withdrawn.
38
+
39
+ And the technique had **zero observed successes but still read as having worked**: the agent
40
+ performed it, the very next operation was refused, it worked around the failure by other means, and
41
+ later the condition the technique was supposed to create arrived on its own for unrelated reasons.
42
+ Nothing about that sequence looks like a failure from outside.
43
+
44
+ ## Why durable memory is the worst case
45
+
46
+ A superseded finding survives longest exactly where it was best recorded. A claim made in passing
47
+ decays with the conversation; a claim written to a memory file persists and is re-read
48
+ authoritatively by future sessions. **The more responsibly a finding was captured, the more durable
49
+ its error becomes** — and the retraction, being conversational, decays while the claim does not.
50
+
51
+ ## What makes this hard
52
+
53
+ - **A session cannot detect that its own context is stale.** Context completeness is not observable
54
+ from inside. Any fix that asks an agent to assess its own context is the trap, not the remedy.
55
+ - **A retraction is usually not written to any canonical surface.** It is a reply, produced under
56
+ exactly the conditions least likely to bear a chore.
57
+ - **The audience is not enumerable.** Whoever received the original may have relayed it onward, and
58
+ a session that has gone quiet is unreachable by any message.
59
+
60
+ The third point is why the mechanism is a **re-check by the reader**, not a broadcast by the
61
+ publisher. The publisher records once; every live session re-checks on its own.
62
+
63
+ ## The ledger's two tiers
64
+
65
+ | tier | path | who it reaches |
66
+ |---|---|---|
67
+ | repository | `.lisa/WITHDRAWN.jsonl` (committed, union-merged) | every future reader of this project, forever |
68
+ | machine | `${LISA_STATE_HOME:-~/.lisa}/withdrawn-rulings.jsonl` | sibling sessions in **other worktrees on this machine, now** |
69
+
70
+ A withdrawal is written to both by one command; reads are the union, de-duplicated by id. The
71
+ machine tier exists because a committed file cannot reach a sibling agent until it is merged and
72
+ pulled, and the audience that needs the retraction soonest is the one working beside you.
73
+
74
+ JSONL rather than prose: each line is independent, so concurrent appends from many agents do not
75
+ collide and `merge=union` reconstructs the union exactly. A prose ledger would need a bespoke merge
76
+ driver to survive the same traffic.
77
+
78
+ ## How a running session is reached
79
+
80
+ `--session-start` stamps a per-session mark recording the ids the session was born already knowing.
81
+ `--hook` runs after every tool result and announces exactly the entries that appeared **since**,
82
+ once each.
83
+
84
+ The common case is the cheap case: when neither ledger's `size:mtime` has moved, `--hook` returns
85
+ after two `stat` calls without parsing anything. It fires on every tool call in every session and
86
+ every subagent, so anything expensive multiplies.
87
+
88
+ A **missing mark is deliberately silent**. A session with no mark is one whose starting knowledge is
89
+ unknown; announcing every historical withdrawal to it would degrade the mechanism into distrusting
90
+ everything, which is the failure the control case in #3752 exists to prevent.
91
+
92
+ ## Surface parity
93
+
94
+ | surface | session stamp | mid-session announcement | `--list` at the escalation boundary |
95
+ |---|---|---|---|
96
+ | Claude Code | yes — SessionStart and SubagentStart | yes — PostToolUse | yes |
97
+ | Codex | yes, interactive only | yes, interactive only | yes |
98
+ | Cursor | yes — both start events | yes | yes |
99
+ | Copilot | main sessions only — it has no subagent-start event, so a Copilot subagent is stamp-less and therefore silent | yes — `postToolUse` | yes |
100
+ | Antigravity | no — its plugin ships blocking `PreToolUse` guards only, and headless hooks do not fire | no | yes |
101
+ | OpenCode | no — it has no plugin hook runtime, only rules and skills | no | yes |
102
+
103
+ Where a surface cannot carry the automatic half, the eager rule's `--list` instruction is the
104
+ carried half: it is the same pointer shape #3592 shipped, which survives precisely because it does
105
+ not travel inside the thing it points at. `codex exec` runs no plugin hooks at all, so on that
106
+ surface the ledger is a read the agent performs, not one performed for it. Antigravity and OpenCode
107
+ are the same shape, permanently rather than per-invocation: neither carries a session-lifecycle hook
108
+ today, which is the identical accommodation `failure-signature-index` makes on those two surfaces.
109
+
110
+ ## What deliberately does not belong here
111
+
112
+ A disagreement that has not been settled. The ledger is for a claim its own author has **withdrawn**
113
+ — not for a claim someone doubts. A tombstone for a live dispute converts the ledger into an
114
+ argument, and an argument is not something a session can act on in one line.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.37.3",
3
+ "version": "4.38.0",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -18,6 +18,10 @@
18
18
  {
19
19
  "command": "${CURSOR_PLUGIN_ROOT}/hooks/threshold-ratchet.sh",
20
20
  "matcher": "Edit|Write|NotebookEdit|Bash"
21
+ },
22
+ {
23
+ "command": "${CURSOR_PLUGIN_ROOT}/hooks/withdrawn-rulings.sh --hook",
24
+ "matcher": "Bash|Edit|Write|Read|Task|Grep|Glob"
21
25
  }
22
26
  ],
23
27
  "preToolUse": [
@@ -67,6 +71,9 @@
67
71
  "command": "${CURSOR_PLUGIN_ROOT}/hooks/install-pkgs.sh",
68
72
  "matcher": "startup"
69
73
  },
74
+ {
75
+ "command": "${CURSOR_PLUGIN_ROOT}/hooks/withdrawn-rulings.sh --session-start"
76
+ },
70
77
  {
71
78
  "command": "${CURSOR_PLUGIN_ROOT}/hooks/inject-resolved-config.sh"
72
79
  },
@@ -78,6 +85,9 @@
78
85
  }
79
86
  ],
80
87
  "subagentStart": [
88
+ {
89
+ "command": "${CURSOR_PLUGIN_ROOT}/hooks/withdrawn-rulings.sh --session-start"
90
+ },
81
91
  {
82
92
  "command": "${CURSOR_PLUGIN_ROOT}/hooks/inject-resolved-config.sh"
83
93
  },
@@ -0,0 +1,525 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Make a WITHDRAWN ruling reach a session that already read the old version.
4
+ *
5
+ * ## The defect this exists against
6
+ *
7
+ * A session is a snapshot and nothing reaches back into it. Rules are injected
8
+ * once at SessionStart/SubagentStart by `inject-rules.sh`; the plugin version a
9
+ * session executes is resolved once at startup; a fix merged to the default
10
+ * branch is not in effect for anything already running. So guidance ADDED after
11
+ * a session starts can at least be found on disk — CodySwannGT/lisa#3592 ships
12
+ * the pointer that says re-read the instruction file at the escalation
13
+ * boundary. Guidance WITHDRAWN after a session starts cannot be found at all:
14
+ * the withdrawal is a reply in a conversation, and deleting the source file is
15
+ * a push that reaches no existing reader (CodySwannGT/lisa#3752).
16
+ *
17
+ * Two instances in one working session: a refutation that was itself wrong,
18
+ * published to a public comment and propagated to two agents before a third
19
+ * agent disproved it; and a workaround circulated to three agents, then
20
+ * disproved by measurement and withdrawn, with no way to tell the three.
21
+ *
22
+ * ## Why a re-read, and not a broadcast
23
+ *
24
+ * The audience is not enumerable. Whoever received the claim may have relayed
25
+ * it onward, and a session that has gone quiet is unreachable by any message.
26
+ * So this inverts the direction: the publisher records the withdrawal ONCE in a
27
+ * durable place, and every live session re-checks that place on its own, from
28
+ * disk, at a cadence no discipline has to remember.
29
+ *
30
+ * Two ledger tiers, because they reach different audiences:
31
+ *
32
+ * - the repository ledger, `.lisa/WITHDRAWN.jsonl`, committed — reaches every
33
+ * future reader of this project, forever, and merges by union;
34
+ * - the machine ledger, `${LISA_STATE_HOME:-~/.lisa}/withdrawn-rulings.jsonl`
35
+ * — reaches sibling sessions in OTHER worktrees on this machine NOW, which
36
+ * a committed file cannot do until it is merged and pulled.
37
+ *
38
+ * A withdrawal is written to both. Reads are the union.
39
+ *
40
+ * ## How a running session is reached
41
+ *
42
+ * `--session-start` stamps a per-session mark recording the ids this session
43
+ * was born already knowing. `--hook` runs as a PostToolUse hook on every tool
44
+ * result and announces exactly the entries that appeared SINCE — once each.
45
+ *
46
+ * That is the discriminating case and it is the only one worth testing: a
47
+ * session that read the ruling BEFORE the retraction existed. A session that
48
+ * reads after the correction is satisfied by doing nothing.
49
+ *
50
+ * The cheap path is the common path. When neither ledger's `size:mtime` has
51
+ * moved since the last announcement, `--hook` exits after two `stat` calls
52
+ * without parsing anything — this fires on every tool call in every session, so
53
+ * anything expensive multiplies.
54
+ *
55
+ * A missing mark is deliberately silent: a session with no mark is one whose
56
+ * starting knowledge is unknown, and announcing every historical withdrawal to
57
+ * it would degrade the mechanism into distrusting everything.
58
+ *
59
+ * ## Why the retraction path is one command
60
+ *
61
+ * Writing a finding is cheap and feels productive; writing a retraction is a
62
+ * chore produced mid-task, after being corrected, when attention has moved on.
63
+ * A rule of the form "always record retractions durably" is a discipline, and
64
+ * this repository's own measurement is that prose rules land at roughly zero
65
+ * adoption. `--withdraw` is therefore one command with three required fields,
66
+ * and it refuses without the verbatim claim: a tombstone the holder of the
67
+ * claim cannot recognise reaches them without reaching them.
68
+ * @module withdrawn-rulings
69
+ */
70
+ import * as fs from "node:fs";
71
+ import * as os from "node:os";
72
+ import * as path from "node:path";
73
+ import { execFileSync } from "node:child_process";
74
+ import { fileURLToPath } from "node:url";
75
+
76
+ /** Repository-relative path of the committed, durable ledger. */
77
+ export const REPO_LEDGER = path.join(".lisa", "WITHDRAWN.jsonl");
78
+ /** Filename of the machine-local ledger inside the Lisa state home. */
79
+ export const MACHINE_LEDGER = "withdrawn-rulings.jsonl";
80
+ /** Most recent withdrawals replayed to a session at start. */
81
+ export const SESSION_START_LIMIT = 20;
82
+
83
+ /**
84
+ * The machine-local state directory. Overridable so tests never touch `$HOME`.
85
+ * @returns {string} An absolute directory path.
86
+ */
87
+ export function stateHome() {
88
+ return process.env.LISA_STATE_HOME || path.join(os.homedir(), ".lisa");
89
+ }
90
+
91
+ /**
92
+ * The repository root, or the working directory when there is no repository.
93
+ * @returns {string} An absolute directory path.
94
+ */
95
+ export function repoRoot() {
96
+ try {
97
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], {
98
+ encoding: "utf-8",
99
+ stdio: ["ignore", "pipe", "ignore"],
100
+ }).trim();
101
+ } catch {
102
+ return process.cwd();
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Both ledger paths, repository tier first.
108
+ * @param {string} root Repository root.
109
+ * @returns {readonly string[]} Absolute ledger paths.
110
+ */
111
+ export function ledgerPaths(root) {
112
+ return [path.join(root, REPO_LEDGER), path.join(stateHome(), MACHINE_LEDGER)];
113
+ }
114
+
115
+ /**
116
+ * A cheap identity for the ledger pair: size and mtime, no parsing.
117
+ *
118
+ * Two `stat` calls is the entire cost of the common case, where nothing has
119
+ * been withdrawn since the last tool call.
120
+ * @param {readonly string[]} paths Ledger paths.
121
+ * @returns {string} A fingerprint that changes whenever either file changes.
122
+ */
123
+ export function fingerprint(paths) {
124
+ return paths
125
+ .map(file => {
126
+ try {
127
+ const stat = fs.statSync(file);
128
+ return `${stat.size}:${stat.mtimeMs}`;
129
+ } catch {
130
+ return "-";
131
+ }
132
+ })
133
+ .join("|");
134
+ }
135
+
136
+ /**
137
+ * Parse one ledger file into records, reporting lines that do not parse.
138
+ * @param {string} file Absolute ledger path.
139
+ * @returns {{ entries: object[], malformed: string[] }} Parsed and rejected lines.
140
+ */
141
+ export function parseLedger(file) {
142
+ const entries = [];
143
+ const malformed = [];
144
+ let raw = "";
145
+ try {
146
+ raw = fs.readFileSync(file, "utf-8");
147
+ } catch {
148
+ return { entries, malformed };
149
+ }
150
+ for (const line of raw.split("\n")) {
151
+ if (line.trim() === "") continue;
152
+ try {
153
+ const record = JSON.parse(line);
154
+ if (typeof record?.id === "string" && record.id !== "")
155
+ entries.push(record);
156
+ else malformed.push(line);
157
+ } catch {
158
+ malformed.push(line);
159
+ }
160
+ }
161
+ return { entries, malformed };
162
+ }
163
+
164
+ /**
165
+ * The union of both ledger tiers, de-duplicated by id, oldest first.
166
+ * @param {string} root Repository root.
167
+ * @returns {{ entries: object[], malformed: string[] }} Live withdrawals.
168
+ */
169
+ export function readLedgers(root) {
170
+ const byId = new Map();
171
+ const malformed = [];
172
+ for (const file of ledgerPaths(root)) {
173
+ const parsed = parseLedger(file);
174
+ for (const entry of parsed.entries)
175
+ if (!byId.has(entry.id)) byId.set(entry.id, entry);
176
+ malformed.push(...parsed.malformed);
177
+ }
178
+ return { entries: [...byId.values()], malformed };
179
+ }
180
+
181
+ /**
182
+ * Where this session's delivery mark lives.
183
+ * @param {string} sessionId The harness-supplied session id.
184
+ * @returns {string} An absolute file path.
185
+ */
186
+ export function markPath(sessionId) {
187
+ return path.join(
188
+ stateHome(),
189
+ "withdrawal-marks",
190
+ `${sessionId.replace(/[^\w.-]/g, "_")}.json`
191
+ );
192
+ }
193
+
194
+ /**
195
+ * Write a JSON file, creating parents.
196
+ * @param {string} file Absolute path.
197
+ * @param {object} value Serialisable value.
198
+ * @returns {void}
199
+ */
200
+ export function writeJson(file, value) {
201
+ fs.mkdirSync(path.dirname(file), { recursive: true });
202
+ fs.writeFileSync(file, `${JSON.stringify(value)}\n`);
203
+ }
204
+
205
+ /**
206
+ * Render withdrawals as the notice a holder of the claim will recognise.
207
+ * @param {readonly object[]} entries Withdrawn rulings.
208
+ * @param {string} headline The opening line.
209
+ * @returns {string} Notice text, empty when there is nothing to say.
210
+ */
211
+ export function formatNotice(entries, headline) {
212
+ if (entries.length === 0) return "";
213
+ const body = entries.map(entry => {
214
+ const lines = [
215
+ `- ${entry.id} — withdrawn ${entry.withdrawnAt ?? "(undated)"}`,
216
+ ` claim: ${entry.claim ?? "(not recorded)"}`,
217
+ ` because: ${entry.because ?? "(not recorded)"}`,
218
+ ];
219
+ if (entry.supersededBy)
220
+ lines.push(` superseded by: ${entry.supersededBy}`);
221
+ if (Array.isArray(entry.reached) && entry.reached.length > 0)
222
+ lines.push(` originally reached: ${entry.reached.join(", ")}`);
223
+ return lines.join("\n");
224
+ });
225
+ return [
226
+ headline,
227
+ "",
228
+ ...body,
229
+ "",
230
+ "Stop acting on those claims and do not propagate them. If you already relayed or",
231
+ "recorded one, correct it on the same surface you put it on — a retraction must",
232
+ "travel by the same mechanism as the finding.",
233
+ ].join("\n");
234
+ }
235
+
236
+ /**
237
+ * Emit a hook result on stdout, and the same text on stderr for humans.
238
+ * @param {string} event The hook event name.
239
+ * @param {string} notice Notice text.
240
+ * @returns {number} An exit code.
241
+ */
242
+ export function emit(event, notice) {
243
+ if (notice === "") return 0;
244
+ process.stderr.write(`${notice}\n`);
245
+ process.stdout.write(
246
+ `${JSON.stringify({
247
+ hookSpecificOutput: { hookEventName: event, additionalContext: notice },
248
+ })}\n`
249
+ );
250
+ return 0;
251
+ }
252
+
253
+ /**
254
+ * Read the hook payload's session id.
255
+ * @param {string} raw Raw stdin.
256
+ * @returns {string} The session id, or the empty string.
257
+ */
258
+ export function sessionIdOf(raw) {
259
+ try {
260
+ const payload = JSON.parse(raw);
261
+ return typeof payload?.session_id === "string" ? payload.session_id : "";
262
+ } catch {
263
+ return "";
264
+ }
265
+ }
266
+
267
+ /**
268
+ * SessionStart / SubagentStart: stamp what this session is born knowing, and
269
+ * replay the recent withdrawals so it does not act on a source it may still
270
+ * find quoted elsewhere.
271
+ * @param {string} raw Raw stdin.
272
+ * @param {string} root Repository root.
273
+ * @returns {number} An exit code.
274
+ */
275
+ export function runSessionStart(raw, root) {
276
+ const sessionId = sessionIdOf(raw);
277
+ if (sessionId === "") return 0;
278
+ const paths = ledgerPaths(root);
279
+ const { entries } = readLedgers(root);
280
+ writeJson(markPath(sessionId), {
281
+ delivered: entries.map(entry => entry.id),
282
+ fingerprint: fingerprint(paths),
283
+ });
284
+ const recent = entries.slice(-SESSION_START_LIMIT);
285
+ return emit(
286
+ "SessionStart",
287
+ formatNotice(
288
+ recent,
289
+ `Lisa: ${entries.length} ruling(s) have been WITHDRAWN in this project.`
290
+ )
291
+ );
292
+ }
293
+
294
+ /**
295
+ * PostToolUse: announce only what was withdrawn AFTER this session started.
296
+ * @param {string} raw Raw stdin.
297
+ * @param {string} root Repository root.
298
+ * @returns {number} An exit code.
299
+ */
300
+ export function runHook(raw, root) {
301
+ const sessionId = sessionIdOf(raw);
302
+ if (sessionId === "") return 0;
303
+ const file = markPath(sessionId);
304
+ let mark = null;
305
+ try {
306
+ mark = JSON.parse(fs.readFileSync(file, "utf-8"));
307
+ } catch {
308
+ return 0;
309
+ }
310
+ const current = fingerprint(ledgerPaths(root));
311
+ if (current === mark.fingerprint) return 0;
312
+ const { entries } = readLedgers(root);
313
+ const delivered = new Set(
314
+ Array.isArray(mark.delivered) ? mark.delivered : []
315
+ );
316
+ const fresh = entries.filter(entry => !delivered.has(entry.id));
317
+ writeJson(file, {
318
+ delivered: entries.map(entry => entry.id),
319
+ fingerprint: current,
320
+ });
321
+ return emit(
322
+ "PostToolUse",
323
+ formatNotice(
324
+ fresh,
325
+ "Lisa: a ruling you may be holding has been WITHDRAWN since this session started."
326
+ )
327
+ );
328
+ }
329
+
330
+ /**
331
+ * Read a `--flag value` pair out of argv.
332
+ * @param {readonly string[]} argv Arguments.
333
+ * @param {string} flag The flag, including dashes.
334
+ * @returns {string} The value, or the empty string.
335
+ */
336
+ export function flagValue(argv, flag) {
337
+ const index = argv.indexOf(flag);
338
+ return index >= 0 && index + 1 < argv.length ? argv[index + 1] : "";
339
+ }
340
+
341
+ /**
342
+ * All values of a repeatable `--flag value` pair.
343
+ * @param {readonly string[]} argv Arguments.
344
+ * @param {string} flag The flag, including dashes.
345
+ * @returns {string[]} Every value given for the flag.
346
+ */
347
+ export function flagValues(argv, flag) {
348
+ const values = [];
349
+ argv.forEach((token, index) => {
350
+ if (token === flag && index + 1 < argv.length) values.push(argv[index + 1]);
351
+ });
352
+ return values;
353
+ }
354
+
355
+ /**
356
+ * Normalise the `--superseded-by` value: absent, empty and "none" all mean null.
357
+ * @param {string} value The raw flag value.
358
+ * @returns {string | null} A superseding id, or null.
359
+ */
360
+ export function normalizeSuperseded(value) {
361
+ const trimmed = value.trim();
362
+ return trimmed === "" || trimmed.toLowerCase() === "none" ? null : trimmed;
363
+ }
364
+
365
+ /**
366
+ * Build the record `--withdraw` appends, refusing an unrecognisable tombstone.
367
+ * @param {readonly string[]} argv Arguments.
368
+ * @param {string} now ISO-8601 timestamp.
369
+ * @returns {{ record?: object, error?: string }} The record, or why not.
370
+ */
371
+ export function buildWithdrawal(argv, now) {
372
+ const id = flagValue(argv, "--withdraw");
373
+ const claim = flagValue(argv, "--claim");
374
+ const because = flagValue(argv, "--because");
375
+ if (id === "") return { error: "--withdraw needs an id" };
376
+ if (claim === "")
377
+ return {
378
+ error:
379
+ "--claim is required: a tombstone the holder cannot recognise reaches nobody",
380
+ };
381
+ if (because === "")
382
+ return { error: "--because is required: say what disproved it" };
383
+ return {
384
+ record: {
385
+ id,
386
+ withdrawnAt: now,
387
+ claim,
388
+ because,
389
+ // "none" is what a person types when nothing replaced the claim, and a
390
+ // string "none" in the record would print as a superseding id that does
391
+ // not exist. Normalised here rather than at every reader.
392
+ supersededBy: normalizeSuperseded(flagValue(argv, "--superseded-by")),
393
+ reached: flagValues(argv, "--reached"),
394
+ },
395
+ };
396
+ }
397
+
398
+ /**
399
+ * `--withdraw`: append the tombstone to both tiers. One command, by design.
400
+ * @param {readonly string[]} argv Arguments.
401
+ * @param {string} root Repository root.
402
+ * @returns {number} An exit code.
403
+ */
404
+ export function runWithdraw(argv, root) {
405
+ const built = buildWithdrawal(argv, new Date().toISOString());
406
+ if (built.error !== undefined) {
407
+ process.stderr.write(`withdrawn-rulings: ${built.error}\n`);
408
+ return 2;
409
+ }
410
+ const line = `${JSON.stringify(built.record)}\n`;
411
+ for (const file of ledgerPaths(root)) {
412
+ fs.mkdirSync(path.dirname(file), { recursive: true });
413
+ fs.appendFileSync(file, line);
414
+ }
415
+ process.stdout.write(
416
+ `withdrawn-rulings: recorded ${built.record.id} in both ledger tiers\n`
417
+ );
418
+ return 0;
419
+ }
420
+
421
+ /**
422
+ * `--list`: the surface for harnesses with no PostToolUse hook, and for the
423
+ * escalation boundary that CodySwannGT/lisa#3592 already sends agents to.
424
+ * @param {string} root Repository root.
425
+ * @returns {number} An exit code.
426
+ */
427
+ export function runList(root) {
428
+ const { entries } = readLedgers(root);
429
+ if (entries.length === 0) {
430
+ process.stdout.write("withdrawn-rulings: nothing withdrawn\n");
431
+ return 0;
432
+ }
433
+ process.stdout.write(
434
+ `${formatNotice(entries, `withdrawn-rulings: ${entries.length} withdrawn`)}\n`
435
+ );
436
+ return 0;
437
+ }
438
+
439
+ /**
440
+ * `--check`: refuse a ledger that would swallow a withdrawal silently.
441
+ *
442
+ * A malformed line is the failure mode that matters: it drops a retraction
443
+ * while every reader reports all-clear.
444
+ * @param {string} root Repository root.
445
+ * @returns {number} An exit code.
446
+ */
447
+ export function runCheck(root) {
448
+ const { entries, malformed } = readLedgers(root);
449
+ const problems = malformed.map(
450
+ line => `unparseable ledger line: ${line.slice(0, 120)}`
451
+ );
452
+ for (const entry of entries) {
453
+ if (typeof entry.claim !== "string" || entry.claim === "")
454
+ problems.push(`${entry.id}: no verbatim claim recorded`);
455
+ if (
456
+ typeof entry.withdrawnAt !== "string" ||
457
+ Number.isNaN(Date.parse(entry.withdrawnAt))
458
+ )
459
+ problems.push(`${entry.id}: withdrawnAt is not an ISO-8601 timestamp`);
460
+ }
461
+ if (problems.length > 0) {
462
+ process.stderr.write(`withdrawn-rulings: ${problems.join("\n ")}\n`);
463
+ return 1;
464
+ }
465
+ process.stdout.write(
466
+ `withdrawn-rulings: ${entries.length} withdrawal(s), ledger well-formed\n`
467
+ );
468
+ return 0;
469
+ }
470
+
471
+ /**
472
+ * Dispatch on the requested mode.
473
+ * @param {readonly string[]} argv Command-line arguments.
474
+ * @param {string} raw Piped stdin, when the mode needs it.
475
+ * @param {string} root Repository root.
476
+ * @returns {number} An exit code.
477
+ */
478
+ export function main(argv, raw, root) {
479
+ if (argv.includes("--session-start")) return runSessionStart(raw, root);
480
+ if (argv.includes("--hook")) return runHook(raw, root);
481
+ if (argv.includes("--withdraw")) return runWithdraw(argv, root);
482
+ if (argv.includes("--list")) return runList(root);
483
+ return runCheck(root);
484
+ }
485
+
486
+ /**
487
+ * Whether this module is the process entry point.
488
+ *
489
+ * Both sides are realpath'd, because `import.meta.url` is the REAL path while
490
+ * `argv[1]` is whatever the caller typed — they disagree through a git worktree
491
+ * or any `/tmp` path on macOS, and for a CHECK that disagreement is a
492
+ * fail-OPEN. The one implementation lives at `scripts/lib/invoked-as-script.mjs`;
493
+ * this file cannot import it, being materialized into plugin payloads that have
494
+ * no `./lib/`, the same accommodation `failure-signature-index.mjs` makes.
495
+ * @param {string} moduleUrl The caller's own `import.meta.url`.
496
+ * @param {string | undefined} [argv1] Entry path; defaults to `process.argv[1]`.
497
+ * @returns {boolean} Whether the caller should run its CLI body.
498
+ */
499
+ export function invokedAsScript(moduleUrl, argv1 = process.argv[1]) {
500
+ if (!argv1) return false;
501
+ try {
502
+ return fs.realpathSync(argv1) === fs.realpathSync(fileURLToPath(moduleUrl));
503
+ } catch {
504
+ return false;
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Read all of stdin.
510
+ * @returns {Promise<string>} The piped text.
511
+ */
512
+ export async function readStdin() {
513
+ if (process.stdin.isTTY) return "";
514
+ const chunks = [];
515
+ for await (const chunk of process.stdin) chunks.push(chunk);
516
+ return Buffer.concat(chunks).toString("utf-8");
517
+ }
518
+
519
+ if (invokedAsScript(import.meta.url)) {
520
+ const argv = process.argv.slice(2);
521
+ const needsStdin =
522
+ argv.includes("--hook") || argv.includes("--session-start");
523
+ const raw = needsStdin ? await readStdin() : "";
524
+ process.exitCode = main(argv, raw, repoRoot());
525
+ }