@codyswann/lisa 4.37.2 → 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 (127) hide show
  1. package/all/copy-contents/.gitattributes +9 -0
  2. package/all/copy-contents/gitignore +6 -0
  3. package/dist/core/learnings-merge-driver.d.ts +8 -0
  4. package/dist/core/learnings-merge-driver.d.ts.map +1 -1
  5. package/dist/core/learnings-merge-driver.js +17 -0
  6. package/dist/core/learnings-merge-driver.js.map +1 -1
  7. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  8. package/dist/core/lisa-owned-hash-ledger.js +4 -0
  9. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  10. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  11. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  12. package/dist/core/upstream-evidence-manifest.js +64 -11
  13. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  14. package/package.json +6 -4
  15. package/plugins/lisa/.claude-plugin/plugin.json +28 -1
  16. package/plugins/lisa/.codex-plugin/hooks.json +27 -0
  17. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  18. package/plugins/lisa/.codex-plugin/skills/lisa-github-write-issue/SKILL.md +11 -0
  19. package/plugins/lisa/.codex-plugin/skills/lisa-jira-write-ticket/SKILL.md +11 -0
  20. package/plugins/lisa/.codex-plugin/skills/lisa-linear-write-issue/SKILL.md +11 -0
  21. package/plugins/lisa/.codex-plugin/skills/lisa-repair-intake/SKILL.md +30 -2
  22. package/plugins/lisa/hooks/inject-resolved-config.mjs +26 -7
  23. package/plugins/lisa/hooks/withdrawn-rulings.mjs +525 -0
  24. package/plugins/lisa/hooks/withdrawn-rulings.sh +24 -0
  25. package/plugins/lisa/rules/eager/config-resolution.md +3 -1
  26. package/plugins/lisa/rules/eager/withdrawn-rulings.md +69 -0
  27. package/plugins/lisa/rules/reference/config-resolution.md +1 -1
  28. package/plugins/lisa/rules/reference/withdrawn-rulings.md +114 -0
  29. package/plugins/lisa/scripts/intake-blocker-reprobe.mjs +120 -3
  30. package/plugins/lisa/skills/lisa-github-write-issue/SKILL.md +11 -0
  31. package/plugins/lisa/skills/lisa-jira-write-ticket/SKILL.md +11 -0
  32. package/plugins/lisa/skills/lisa-linear-write-issue/SKILL.md +11 -0
  33. package/plugins/lisa/skills/lisa-repair-intake/SKILL.md +30 -2
  34. package/plugins/lisa-agy/plugin.json +1 -1
  35. package/plugins/lisa-agy/scripts/intake-blocker-reprobe.mjs +120 -3
  36. package/plugins/lisa-agy/skills/lisa-github-write-issue/SKILL.md +11 -0
  37. package/plugins/lisa-agy/skills/lisa-jira-write-ticket/SKILL.md +11 -0
  38. package/plugins/lisa-agy/skills/lisa-linear-write-issue/SKILL.md +11 -0
  39. package/plugins/lisa-agy/skills/lisa-repair-intake/SKILL.md +30 -2
  40. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  42. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  43. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-copilot/.claude-plugin/plugin.json +19 -1
  46. package/plugins/lisa-copilot/hooks/inject-resolved-config.mjs +26 -7
  47. package/plugins/lisa-copilot/hooks/withdrawn-rulings.mjs +525 -0
  48. package/plugins/lisa-copilot/hooks/withdrawn-rulings.sh +24 -0
  49. package/plugins/lisa-copilot/rules/eager/config-resolution.md +3 -1
  50. package/plugins/lisa-copilot/rules/eager/withdrawn-rulings.md +69 -0
  51. package/plugins/lisa-copilot/rules/reference/config-resolution.md +1 -1
  52. package/plugins/lisa-copilot/rules/reference/withdrawn-rulings.md +114 -0
  53. package/plugins/lisa-copilot/scripts/intake-blocker-reprobe.mjs +120 -3
  54. package/plugins/lisa-copilot/skills/lisa-github-write-issue/SKILL.md +11 -0
  55. package/plugins/lisa-copilot/skills/lisa-jira-write-ticket/SKILL.md +11 -0
  56. package/plugins/lisa-copilot/skills/lisa-linear-write-issue/SKILL.md +11 -0
  57. package/plugins/lisa-copilot/skills/lisa-repair-intake/SKILL.md +30 -2
  58. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-cursor/hooks/hooks.json +10 -0
  60. package/plugins/lisa-cursor/hooks/inject-resolved-config.mjs +26 -7
  61. package/plugins/lisa-cursor/hooks/withdrawn-rulings.mjs +525 -0
  62. package/plugins/lisa-cursor/hooks/withdrawn-rulings.sh +24 -0
  63. package/plugins/lisa-cursor/rules/config-resolution-reference.mdc +1 -1
  64. package/plugins/lisa-cursor/rules/config-resolution.mdc +3 -1
  65. package/plugins/lisa-cursor/rules/withdrawn-rulings-reference.mdc +119 -0
  66. package/plugins/lisa-cursor/rules/withdrawn-rulings.mdc +74 -0
  67. package/plugins/lisa-cursor/scripts/intake-blocker-reprobe.mjs +120 -3
  68. package/plugins/lisa-cursor/skills/lisa-github-write-issue/SKILL.md +11 -0
  69. package/plugins/lisa-cursor/skills/lisa-jira-write-ticket/SKILL.md +11 -0
  70. package/plugins/lisa-cursor/skills/lisa-linear-write-issue/SKILL.md +11 -0
  71. package/plugins/lisa-cursor/skills/lisa-repair-intake/SKILL.md +30 -2
  72. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  74. package/plugins/lisa-expo-agy/plugin.json +1 -1
  75. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  79. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  80. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  84. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  85. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  87. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  89. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  90. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  92. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  93. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  94. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  95. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  96. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  97. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  98. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  99. package/plugins/lisa-rails-agy/plugin.json +1 -1
  100. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  101. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  102. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  103. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  104. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  105. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  106. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  107. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  108. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  109. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  110. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  111. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  112. package/plugins/src/base/.claude-plugin/plugin.json +27 -0
  113. package/plugins/src/base/hooks/inject-resolved-config.mjs +26 -7
  114. package/plugins/src/base/hooks/withdrawn-rulings.mjs +525 -0
  115. package/plugins/src/base/hooks/withdrawn-rulings.sh +24 -0
  116. package/plugins/src/base/rules/eager/config-resolution.md +3 -1
  117. package/plugins/src/base/rules/eager/withdrawn-rulings.md +69 -0
  118. package/plugins/src/base/rules/reference/config-resolution.md +1 -1
  119. package/plugins/src/base/rules/reference/withdrawn-rulings.md +114 -0
  120. package/plugins/src/base/scripts/intake-blocker-reprobe.mjs +120 -3
  121. package/plugins/src/base/skills/lisa-github-write-issue/SKILL.md +11 -0
  122. package/plugins/src/base/skills/lisa-jira-write-ticket/SKILL.md +11 -0
  123. package/plugins/src/base/skills/lisa-linear-write-issue/SKILL.md +11 -0
  124. package/plugins/src/base/skills/lisa-repair-intake/SKILL.md +30 -2
  125. package/scripts/lib/hook-registration-audit.mjs +612 -0
  126. package/scripts/measure-tmpdir-growth.mjs +925 -0
  127. package/typescript/copy-contents/.husky/pre-push +98 -26
@@ -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
+ }
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env bash
2
+ # Hook wrapper for the withdrawn-rulings ledger (CodySwannGT/lisa#3752).
3
+ #
4
+ # Two events, one script:
5
+ # --session-start SessionStart / SubagentStart — stamp what this session is
6
+ # born knowing, and replay the recent withdrawals.
7
+ # --hook PostToolUse — announce ONLY what was withdrawn since this
8
+ # session started, once each.
9
+ #
10
+ # Advisory by construction — ALWAYS exit 0. A session that cannot read the
11
+ # ledger must keep working; a broken hook that wedges every tool call would be a
12
+ # far worse defect than the one this closes. Every infrastructure gap (no node,
13
+ # unreadable stdin) is also exit 0, and `bun run check:withdrawn-rulings` is
14
+ # what guarantees the ledger itself still parses.
15
+ set -uo pipefail
16
+
17
+ input="$(cat 2>/dev/null || true)"
18
+
19
+ command -v node >/dev/null 2>&1 || exit 0
20
+
21
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
22
+
23
+ printf '%s' "$input" | node "$script_dir/withdrawn-rulings.mjs" "$@" || true
24
+ exit 0
@@ -1,6 +1,8 @@
1
1
  # Config Resolution (load-bearing)
2
2
 
3
- **The resolved configuration is already in your context.** `inject-resolved-config.sh` runs at every session and subagent start and injects the EFFECTIVE values — `.lisa.config.local.json` already merged over `.lisa.config.json` — inside a `<lisa-resolved-config>` block, marking anything that is a Lisa built-in rather than something this project declared. Read the values there and act on them; do not re-derive them from the files, and do not act against them.
3
+ **The resolved configuration is already in your context.** `inject-resolved-config.sh` runs at every session and subagent start and injects the EFFECTIVE values — `.lisa.config.local.json` already merged over `.lisa.config.json` — inside a `<lisa-resolved-config>` block, which **names the directory it resolved for** and marks anything that is a Lisa built-in rather than something that directory declared. Read the values there and act on them, and do not act against them.
4
+
5
+ They are resolved for **that working directory only**. For work in it, do not re-derive them from the files — that is the saving this block exists for. If your work targets a **different** repository, the block does not describe it: read that repository's config instead. A block with no name on it is how six agent cycles acted on one repository's settings while doing another's work.
4
6
 
5
7
  That block replaces the paragraph that used to sit here saying where configuration lives and which file wins. Restating it is what nine eager rules were already doing while agents acted against a config they never opened; if the block is ever missing, the two files are the source and the local one wins where they overlap.
6
8
 
@@ -0,0 +1,69 @@
1
+ # Withdrawn Rulings — a Retraction Travels by the Same Mechanism as the Finding (load-bearing)
2
+
3
+ **A session is a snapshot, and nothing reaches back into it.** Rules are injected once at session
4
+ start. The plugin version a session executes is resolved once at startup. A fix merged to the
5
+ default branch is not in effect for anything already running. So guidance that is **added** after a
6
+ session starts can at least be found on disk — `settled-decisions` sends you to re-read the
7
+ instruction file at the escalation boundary. Guidance that is **withdrawn** cannot be found at all:
8
+ deleting the source is a push, and a push reaches no existing reader.
9
+
10
+ The result is one-directional propagation. **New guidance travels; corrections do not.**
11
+
12
+ ## The test for any correction
13
+
14
+ > Through which channel did the original claim reach its audience, and has the withdrawal gone
15
+ > through that same channel?
16
+
17
+ - Written to a durable file → the retraction is written to that file.
18
+ - Posted as a work-item comment → edit the original comment, do not only append. A reader who
19
+ arrives later and forms a conclusion never scrolls to the reply.
20
+ - Relayed in a message → it reaches the same recipients, which requires knowing who they were.
21
+ You usually do not, which is why the ledger below exists.
22
+
23
+ ## Mandatory
24
+
25
+ - **Record every withdrawal in the ledger, with one command.** Retracting a claim you circulated,
26
+ wrote down, or published is not done until this has run:
27
+
28
+ ```
29
+ node "$CLAUDE_PLUGIN_ROOT/hooks/withdrawn-rulings.mjs" --withdraw <id> \
30
+ --claim "<the withdrawn claim, verbatim>" \
31
+ --because "<what disproved it>" \
32
+ [--superseded-by <id>] [--reached <channel> ...]
33
+ ```
34
+
35
+ The verbatim claim is required and the command refuses without it: **a tombstone the holder of the
36
+ claim cannot recognise reaches them without reaching them.**
37
+
38
+ - **The ledger is the retraction path, precisely because it is cheap.** Writing a finding feels
39
+ productive; writing a retraction is a chore produced mid-task, after being corrected, when
40
+ attention has moved on. A rule saying "always record retractions durably" is a discipline, and
41
+ disciplines do not reach that moment. One command does.
42
+
43
+ - **Check it at the escalation boundary**, alongside the instruction-file re-read
44
+ `settled-decisions` already requires: `node "$CLAUDE_PLUGIN_ROOT/hooks/withdrawn-rulings.mjs"
45
+ --list`. This is what harnesses with no PostToolUse hook rely on.
46
+
47
+ - **Verify before propagating.** Every retraction is a claim that was relayed before it was checked.
48
+ Relaying a finding makes you a publisher of it, and a publisher owes the correction.
49
+
50
+ ## What arrives on its own
51
+
52
+ On Claude Code the ledger is read for you: it is stamped at session start and re-checked after every
53
+ tool call, so a ruling withdrawn **while you are running** arrives attached to your next tool result
54
+ — naming the claim, why it fell, and what replaced it. Two `stat` calls when nothing changed.
55
+
56
+ It announces only what was withdrawn **since this session started**, and only once each. An
57
+ un-withdrawn ruling is never flagged; a mechanism that made you distrust everything would be worse
58
+ than the gap it closes.
59
+
60
+ ## The failure mode that makes this urgent
61
+
62
+ **A technique that never works can still look like it worked**, because the condition it was
63
+ supposed to create arrives later on its own. That is worse than intermittency: an intermittent
64
+ technique has a success rate an observer can become suspicious of; this has none, only a delayed
65
+ coincidence that reads as confirmation. Such a claim will not retract itself, and everyone holding
66
+ it believes they have seen it succeed.
67
+
68
+ Full prose, the two measured instances, and the ledger's two tiers:
69
+ [reference/withdrawn-rulings.md](../reference/withdrawn-rulings.md).
@@ -505,7 +505,7 @@ Resolution keys:
505
505
  - Linear: `linear.labels.build.human_needed` (default `human-needed`). Applied as a Linear **label**, for the same reason as JIRA — an Issue holds exactly one workflow state but any number of labels, so an additive marker cannot be a state. The `blocked` **state** still drives the lifecycle; `human_needed` is the marker on top of it. This is the only build-lane key left in `linear.labels`.
506
506
  - `spec_defect` resolves the same way on each vendor — `jira.labels.spec_defect` (default `Spec Defect`), `github.labels.build.spec_defect` and `linear.labels.build.spec_defect` (default `blocked:spec-defect`).
507
507
 
508
- **When to apply it.** Apply `human_needed` only when a human must act before the item can move — the pre-flight gate failures that bounce a ticket back to its reporter are exactly this case, but **only after** the agent has run the `pre-flight-autofill` draft-then-block procedure (drafting the authorable gaps — acceptance criteria, validation journey, repository, relationship search, etc. — into the ticket as labeled assumptions). What then remains for the human is to **confirm those assumptions** or supply a genuinely human-only input (real missing credentials, an irreducible product/scoping decision). The marker means "a human must confirm or decide," not "a human must author from scratch."
508
+ **When to apply it.** There are two application sites. The first is **at filing**: a leaf filed with a `human_gate` reason under `ready-role-filing` carries the `[lisa-human-gate]` body marker *and* this label, so the hold is legible to a path that reads bodies and to a path that reads labels alike. Such an item sits in no lifecycle role at all rather than in `blocked`, which is the one case where this marker stands beside no role — and it is deliberate: a hold recorded on only one surface is a half-armed gate, and the label-keyed sweeps promoted exactly that population into the build queue (#3805). The second site is a **block a human must clear**: apply `human_needed` only when a human must act before the item can move — the pre-flight gate failures that bounce a ticket back to its reporter are exactly this case, but **only after** the agent has run the `pre-flight-autofill` draft-then-block procedure (drafting the authorable gaps — acceptance criteria, validation journey, repository, relationship search, etc. — into the ticket as labeled assumptions). What then remains for the human is to **confirm those assumptions** or supply a genuinely human-only input (real missing credentials, an irreducible product/scoping decision). The marker means "a human must confirm or decide," not "a human must author from scratch."
509
509
 
510
510
  **Why `spec_defect` is separate from `human_needed`.** Both name a hold a person must clear, but they are different asks and the rollup must not merge them. `human_needed` waits on an input from *outside* the item — a credential, an access grant, a product decision — and the item's own text is fine. `spec_defect` waits on the item's text being *rewritten*, and no external event will ever supply that. An item carrying `spec_defect` will sit forever until somebody edits it; that is exactly the class that accumulated silently when the rollup rendered both as plain `blocked` (issue #3045: 32 identical hold comments over six weeks on one Epic). Where both markers are present, `spec_defect` wins — it is the more specific record, and the only one carrying a judgment nothing else can supply.
511
511