@codyswann/lisa 4.17.12 → 4.17.13

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 (59) hide show
  1. package/all/copy-overwrite/scripts/lisa-enforcement-fallback.sh +438 -50
  2. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  3. package/dist/core/lisa-owned-hash-ledger.js +6 -0
  4. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  5. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  6. package/dist/core/upstream-evidence-manifest.js +6 -2
  7. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  8. package/package.json +2 -2
  9. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  10. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  11. package/plugins/lisa-agy/plugin.json +1 -1
  12. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  13. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  14. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  15. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  17. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  21. package/plugins/lisa-expo-agy/plugin.json +1 -1
  22. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  26. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  27. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  31. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  32. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  36. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  37. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  41. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  42. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  46. package/plugins/lisa-rails-agy/plugin.json +1 -1
  47. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  51. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  52. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  56. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  57. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  59. package/scripts/lisa-enforcement-fallback.sh +438 -50
@@ -77,67 +77,274 @@ fi
77
77
  # Recovering it belongs in the guards — a marker keyed on session and payload, so
78
78
  # whichever layer fires first does the work — not in a liveness guess here.
79
79
 
80
+
80
81
  # Where the guards live depends on which repository this is.
81
82
  #
82
83
  # `plugins/lisa/hooks/` exists only in the Lisa monorepo. A host project gets
83
- # the same three scripts written into its checkout by `lisa apply`, because a
84
- # host project whose plugin install fails has exactly the same hole and no
84
+ # the same scripts written into its checkout by `lisa apply`, because a host
85
+ # project whose plugin install fails has exactly the same hole and no
85
86
  # `plugins/` directory to fall back on.
86
- status=0
87
- resolved=0
87
+ #
88
+ # Resolution is first-wins PER GUARD: `scripts/lisa-hooks/` shadows
89
+ # `plugins/lisa/hooks/` outright, and the shadowed copy never runs at all. So
90
+ # the aggregate below spans the six guards, not two copies of one guard. The
91
+ # distinction is worth stating because it decides what "the oldest copy
92
+ # governs" means here: a guard is governed by whichever tree is FIRST, never by
93
+ # whichever tree is newest, and the loser is silent by construction.
94
+ host_tree="$repo_root/scripts/lisa-hooks"
95
+ plugin_tree="$repo_root/plugins/lisa/hooks"
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Vintage
99
+ #
100
+ # Enforcement resolves from THE CHECKOUT, never from npm. Publishing a guard
101
+ # fix and refreshing the marketplace does not reach the copy governing an agent
102
+ # working in a branch cut before that fix — and because the aggregate takes the
103
+ # strongest refusal, the oldest resolved copy governs (CodySwannGT/lisa#3205).
104
+ # One guard measured 22/22 on `main`, 22/22 in the installed clone, and 19/22
105
+ # on the copy actually in force on the machine the fix had been written on.
106
+ #
107
+ # None of that was observable from inside a refusal, which is the defect. A
108
+ # stale copy's block reads as the guard being WRONG rather than OLD, so the
109
+ # operator's available move is to route around it — and a guard routed around
110
+ # protects nothing. Dating every copy, and naming the producing copy in every
111
+ # refusal, turns that into a one-line diagnosis.
112
+ #
113
+ # Three constraints shape how the dating is done, and each rules something out:
114
+ #
115
+ # - It runs on EVERY tool call, so nothing here may fork. Versions are read
116
+ # with the `read` builtin, matched with bash's own regex, and returned
117
+ # through globals rather than `$(...)`, which would fork a subshell per
118
+ # lookup.
119
+ # - It must work offline, and there is no network lookup anywhere below. A
120
+ # guard that stalls or fails when the network does is a guard whoever it
121
+ # slows down switches off.
122
+ # - It compares only against evidence on the same disk. Staleness is claimed
123
+ # only when a demonstrably newer Lisa can be pointed at, so the worst case
124
+ # is silence rather than a fleet-wide false alarm.
125
+ #
126
+ # Bash 3.2 is the floor — macOS ships it as /bin/bash, and that is what the
127
+ # `.claude/settings.json` entry invokes — so there are no associative arrays
128
+ # here. There are exactly two possible trees, which is what makes plain
129
+ # variables enough.
130
+
131
+ # Result of the last read_json_version call. A global because command
132
+ # substitution forks and this runs on every tool call.
133
+ json_version=""
134
+
135
+ # The version a JSON file states, without forking or requiring jq.
136
+ #
137
+ # Deliberately the FIRST occurrence of the key: in all three files read here —
138
+ # a package manifest, a plugin manifest, the apply receipt — that occurrence is
139
+ # the top-level one. A file that does not state the key leaves the result
140
+ # empty, which is reported as an unknown vintage rather than guessed at.
141
+ read_json_version() {
142
+ json_version=""
143
+ [ -f "$1" ] || return 1
144
+ # Built as a variable and matched unquoted: in bash 3.2 a quoted portion of
145
+ # an `=~` pattern is literal text, so an inline pattern would stop being a
146
+ # regex on exactly the interpreter this has to work under.
147
+ #
148
+ # Anchored to at most three leading spaces so a NESTED key cannot answer for
149
+ # a top-level one. `node_modules/@codyswann/lisa/package.json` carries its
150
+ # `"version"` at line 118 of 255 — measured, not assumed — with a dependency
151
+ # block below it, and a package literally named `version` would otherwise
152
+ # match first and report its range as a Lisa version.
153
+ local pattern="^[[:space:]]{0,3}\"$2\"[[:space:]]*:[[:space:]]*\"([^\"]+)\""
154
+ local line
155
+ while IFS= read -r line || [ -n "$line" ]; do
156
+ if [[ "$line" =~ $pattern ]]; then
157
+ json_version="${BASH_REMATCH[1]}"
158
+ return 0
159
+ fi
160
+ done <"$1"
161
+ return 1
162
+ }
163
+
164
+ # Numeric release fields of the last split_version call.
165
+ v1=0
166
+ v2=0
167
+ v3=0
168
+
169
+ # Split a dotted version into its three numeric fields.
170
+ #
171
+ # A field that is not a plain number becomes 0 rather than erroring the
172
+ # comparison, so a version string this does not understand is never treated as
173
+ # newer than one it does — the direction that produces silence instead of a
174
+ # false staleness claim.
175
+ split_version() {
176
+ local rest="${1%%-*}"
177
+ rest="${rest%%+*}"
178
+ v1="${rest%%.*}"
179
+ case "$rest" in *.*) rest="${rest#*.}" ;; *) rest="" ;; esac
180
+ v2="${rest%%.*}"
181
+ case "$rest" in *.*) rest="${rest#*.}" ;; *) rest="" ;; esac
182
+ v3="${rest%%.*}"
183
+ case "$v1" in '' | *[!0-9]*) v1=0 ;; esac
184
+ case "$v2" in '' | *[!0-9]*) v2=0 ;; esac
185
+ case "$v3" in '' | *[!0-9]*) v3=0 ;; esac
186
+ }
187
+
188
+ # Whether the first version names an older Lisa than the second. Any
189
+ # prerelease or build suffix is ignored; only the release fields are compared.
190
+ version_older() {
191
+ local a1 a2 a3
192
+ split_version "$1"
193
+ a1="$v1"
194
+ a2="$v2"
195
+ a3="$v3"
196
+ split_version "$2"
197
+ if [ "$a1" -ne "$v1" ]; then
198
+ [ "$a1" -lt "$v1" ]
199
+ return
200
+ fi
201
+ if [ "$a2" -ne "$v2" ]; then
202
+ [ "$a2" -lt "$v2" ]
203
+ return
204
+ fi
205
+ [ "$a3" -lt "$v3" ]
206
+ }
207
+
208
+ # The newest Lisa this machine can be SHOWN to have, and the file proving it.
209
+ #
210
+ # A maximum over every local source, rather than one nominated reference, and
211
+ # that choice is load-bearing in both directions. In the Lisa monorepo
212
+ # `node_modules/@codyswann/lisa` is a fixture pinned majors behind the repo
213
+ # itself, so treating it as "the installed release" would report every current
214
+ # checkout as ahead and never report a stale one. In a host project it is the
215
+ # most meaningful reference available. A maximum needs no ranking between them,
216
+ # and it cannot invent staleness: a copy is behind only when something newer is
217
+ # sitting on the same disk.
218
+ newest_version=""
219
+ newest_source=""
220
+
221
+ # Whether resolve_vintages has already run.
222
+ vintages_resolved=0
223
+
224
+ # Fold one candidate version into the maximum, ignoring an empty one.
225
+ note_version() {
226
+ [ -n "$1" ] || return 0
227
+ if [ -z "$newest_version" ] || version_older "$newest_version" "$1"; then
228
+ newest_version="$1"
229
+ newest_source="$2"
230
+ fi
231
+ }
232
+
233
+ host_tree_version=""
234
+ plugin_tree_version=""
235
+
236
+ # Date every tree, and find the newest Lisa on this disk.
237
+ #
238
+ # Called lazily — at most once, and only when something is actually going to be
239
+ # said. On the overwhelmingly common path (a permitted call in a session that
240
+ # has already printed its notice) it never runs at all, so those three file
241
+ # reads leave the hot path entirely.
242
+ resolve_vintages() {
243
+ [ "$vintages_resolved" -eq 0 ] || return 0
244
+ vintages_resolved=1
245
+
246
+ # The marketplace clone is the installed release in the literal sense — it is
247
+ # the copy `claude plugin` put on this machine, and it is a full checkout of
248
+ # Lisa, so it dates itself.
249
+ local config_dir="${CLAUDE_CONFIG_DIR-}"
250
+ [ -n "$config_dir" ] || config_dir="${HOME-}/.claude"
251
+ local marketplace_manifest="$config_dir/plugins/marketplaces/lisa/plugins/lisa/.claude-plugin/plugin.json"
252
+ if read_json_version "$marketplace_manifest" version; then
253
+ note_version "$json_version" "$marketplace_manifest"
254
+ fi
255
+
256
+ local installed_manifest="$repo_root/node_modules/@codyswann/lisa/package.json"
257
+ if read_json_version "$installed_manifest" version; then
258
+ note_version "$json_version" "$installed_manifest"
259
+ fi
260
+
261
+ # `scripts/lisa-hooks/` is written into a host by `lisa apply`, and the apply
262
+ # receipt records which Lisa version performed that write. The receipt IS
263
+ # that tree's vintage: the same run produced both, so they cannot disagree.
264
+ if read_json_version "$repo_root/.lisa/apply-receipt.json" lisa_version; then
265
+ host_tree_version="$json_version"
266
+ fi
267
+ note_version "$host_tree_version" "$host_tree"
268
+
269
+ # `plugins/lisa/hooks/` is the Lisa monorepo's own copy, dated by the plugin
270
+ # manifest beside it, which the release bumps in lockstep with the package.
271
+ if read_json_version "$repo_root/plugins/lisa/.claude-plugin/plugin.json" version; then
272
+ plugin_tree_version="$json_version"
273
+ fi
274
+ note_version "$plugin_tree_version" "$plugin_tree"
275
+ }
276
+
277
+ # Description of the last describe_vintage call.
278
+ vintage_label=""
279
+
280
+ # A one-line description of a copy's age, used by both the notice and the
281
+ # attribution line.
282
+ #
283
+ # An absent version is reported, not skipped. A copy with no dateable manifest
284
+ # beside it cannot be shown to be current, and reading it as current is exactly
285
+ # how a stale copy stays invisible — the failure mode this whole section is
286
+ # here to end.
287
+ describe_vintage() {
288
+ if [ -z "$1" ]; then
289
+ vintage_label="vintage unknown"
290
+ elif [ -n "$newest_version" ] && version_older "$1" "$newest_version"; then
291
+ vintage_label="lisa $1, STALE — $newest_version is on this machine"
292
+ else
293
+ vintage_label="lisa $1"
294
+ fi
295
+ }
296
+
297
+ # ---------------------------------------------------------------------------
298
+ # Resolution
299
+ #
300
+ # Resolving every guard before running any of them is what lets the staleness
301
+ # notice be printed BEFORE the first refusal rather than after it. An operator
302
+ # who learns a copy is old only once it has already blocked something has been
303
+ # told too late to act on it.
304
+ guard_count=0
305
+ guard_names=()
306
+ guard_scripts=()
307
+ guard_trees=()
88
308
  missing=""
309
+ shadowed=""
310
+ host_tree_used=0
311
+ plugin_tree_used=0
312
+
89
313
  for guard in block-no-verify parity-safety-net block-shell-json-parsing \
90
314
  block-instruction-file-edits block-direct-issue-create \
91
315
  block-managed-file-edits; do
92
- script=""
93
- for candidate in \
94
- "$repo_root/scripts/lisa-hooks/$guard.sh" \
95
- "$repo_root/plugins/lisa/hooks/$guard.sh"; do
96
- if [ -f "$candidate" ]; then
97
- script="$candidate"
98
- break
316
+ if [ -f "$host_tree/$guard.sh" ]; then
317
+ guard_names+=("$guard")
318
+ guard_scripts+=("$host_tree/$guard.sh")
319
+ # The TREE, not its version: vintages are resolved lazily, so what a guard
320
+ # records here is which tree to ask about it later.
321
+ guard_trees+=("host")
322
+ guard_count=$((guard_count + 1))
323
+ host_tree_used=1
324
+ # The shadowed copy never runs, and nothing used to say so. Two copies of
325
+ # one guard on a disk are two vintages of it more often than not, and the
326
+ # one in force is the one that is first in this list — which is not a
327
+ # statement about which is newer.
328
+ if [ -f "$plugin_tree/$guard.sh" ]; then
329
+ shadowed="${shadowed:+$shadowed, }$guard"
99
330
  fi
100
- done
101
- if [ -z "$script" ]; then
331
+ elif [ -f "$plugin_tree/$guard.sh" ]; then
332
+ guard_names+=("$guard")
333
+ guard_scripts+=("$plugin_tree/$guard.sh")
334
+ guard_trees+=("plugin")
335
+ guard_count=$((guard_count + 1))
336
+ plugin_tree_used=1
337
+ else
102
338
  missing="${missing:+$missing, }$guard"
103
- continue
104
- fi
105
- resolved=$((resolved + 1))
106
- # Each guard reads the tool payload on stdin and signals a refusal with exit
107
- # 2. The payload is replayed to every one of them, and the strongest refusal
108
- # is returned — a guard that declines must not be able to clear one that did
109
- # not.
110
- #
111
- # The status is captured from the pipeline directly rather than through `if !`,
112
- # where `$?` is the negation and every refusal read as success: the guard
113
- # printed its objection and the command ran anyway, which is the same
114
- # fail-open this file exists to close.
115
- printf '%s' "$payload" | bash "$script"
116
- guard_status=$?
117
- # 2 is the ONLY status Claude Code treats as a refusal. Every other non-zero
118
- # is a non-blocking error: it is surfaced, and the tool call proceeds anyway.
119
- # So the aggregate cannot be the numerically largest status — under `-gt`, a
120
- # guard erroring with 3, or dying on a missing interpreter with 127,
121
- # outranks another guard's 2 and silently downgrades a refusal into a
122
- # warning. That is the precise fail-open this file exists to close,
123
- # reintroduced one layer up.
124
- #
125
- # 2 therefore dominates and is sticky; a lesser non-zero is only carried when
126
- # no guard has refused, so a genuine error is still reported when nothing
127
- # blocked.
128
- if [ "$guard_status" -eq 2 ]; then
129
- status=2
130
- elif [ "$guard_status" -ne 0 ] && [ "$status" -ne 2 ]; then
131
- status="$guard_status"
132
339
  fi
133
340
  done
134
341
 
135
342
  # Zero guards resolved is a refusal, not a pass.
136
343
  #
137
- # Six `continue`s used to leave `status` at 0, so nothing distinguished "every
138
- # guard ran and none objected" from "no guard was found". That is the same
139
- # silent fail-open this file was written to close, reproduced one layer down: a
140
- # host whose `scripts/lisa-hooks/` was never written by `lisa apply`, or was
344
+ # Six misses used to leave the status at 0, so nothing distinguished "every
345
+ # guard ran and none objected" from "no guard was found". That is the silent
346
+ # fail-open this file was written to close, reproduced one layer down: a host
347
+ # whose `scripts/lisa-hooks/` was never written by `lisa apply`, or was
141
348
  # deleted, or drifted — precisely the state this file exists for — got the same
142
349
  # green as a clean session.
143
350
  #
@@ -161,8 +368,9 @@ done
161
368
  # The scope is deliberately "zero", not "fewer than six". A partial resolution
162
369
  # means some enforcement ran, and version skew across an interrupted apply is a
163
370
  # real enough way to reach it that refusing there would trade a silent hole for
164
- # a noisy outage.
165
- if [ "$resolved" -eq 0 ]; then
371
+ # a noisy outage. What a partial resolution DOES get is the notice below, which
372
+ # names the vintage of each tree that did resolve.
373
+ if [ "$guard_count" -eq 0 ]; then
166
374
  cat >&2 <<EOF
167
375
  Blocked: Lisa's enforcement guards are missing from this repository, so this
168
376
  tool call was checked by nothing at all.
@@ -171,8 +379,8 @@ This hook is registered in .claude/settings.json but resolved none of the
171
379
  guards it dispatches: $missing
172
380
 
173
381
  Searched:
174
- $repo_root/scripts/lisa-hooks/<guard>.sh
175
- $repo_root/plugins/lisa/hooks/<guard>.sh
382
+ $host_tree/<guard>.sh
383
+ $plugin_tree/<guard>.sh
176
384
 
177
385
  Refused rather than allowed on purpose. A dispatcher that resolves nothing is
178
386
  indistinguishable from one that was never installed, and letting the call
@@ -184,4 +392,184 @@ EOF
184
392
  exit 2
185
393
  fi
186
394
 
395
+ # ---------------------------------------------------------------------------
396
+ # The staleness notice, printed before any guard can refuse anything — ONCE
397
+ # PER SESSION, not once per tool call.
398
+ #
399
+ # The first draft of this fired whenever a resolved tree was behind, on the
400
+ # reasoning that a current checkout would stay silent. Measured on this
401
+ # repository and this fleet, that reasoning is wrong twice over:
402
+ #
403
+ # - `main` cut 80 releases in 24 hours, median gap 10 minutes. "Behind the
404
+ # newest Lisa on this disk" is the DEFAULT state of a checkout within
405
+ # minutes of being cut, through nothing anyone did wrong.
406
+ # - Across the 27 host checkouts in this fleet, 12 resolve guards at all, and
407
+ # ALL TWELVE are behind or undateable. None is current. Eight carry no
408
+ # apply receipt; four are behind by a whole MAJOR (3.23.0, 3.23.1, 3.46.3,
409
+ # 3.54.4 against 4.17.2).
410
+ #
411
+ # So no distance threshold rescues it — even the strictest version predicate is
412
+ # permanently true across the fleet. The noise is the REPETITION, not the
413
+ # distance: a banner on every tool call for a whole session is a banner people
414
+ # learn to skip past, and the guard whose output gets skipped past is the guard
415
+ # that stops being read.
416
+ #
417
+ # The rate limit therefore goes on repetition and the distance threshold stays
418
+ # at zero. Once per session an operator is told; every refusal after that still
419
+ # carries the full vintage in its attribution line, which costs nothing because
420
+ # it only prints when something has already been blocked.
421
+ #
422
+ # Failure is noisy, never silent: no session id, or a state directory that
423
+ # cannot be written, degrades to printing every time rather than to printing
424
+ # never.
425
+ session_id=""
426
+ # NOT anchored, unlike the file reads above: the hook payload arrives as a
427
+ # single line of JSON, so a line-start anchor would never match it. Measured —
428
+ # the first draft anchored this and silently found no session id at all, which
429
+ # degraded to printing the notice on every call: the exact behaviour the rate
430
+ # limit exists to remove, reintroduced by the rate limit itself.
431
+ session_pattern="\"session_id\"[[:space:]]*:[[:space:]]*\"([^\"]+)\""
432
+ if [[ "$payload" =~ $session_pattern ]]; then
433
+ session_id="${BASH_REMATCH[1]}"
434
+ fi
435
+ # Anything that is not a plain identifier is dropped rather than escaped, so a
436
+ # hostile id cannot reach outside the state directory.
437
+ case "$session_id" in *[!A-Za-z0-9._-]* | .* ) session_id="" ;; esac
438
+
439
+ notice_state_dir="${TMPDIR:-/tmp}/lisa-enforcement-notice"
440
+ notice_marker=""
441
+ [ -n "$session_id" ] && notice_marker="$notice_state_dir/$session_id"
442
+
443
+ # Whether this session has already been told. A marker that cannot be read —
444
+ # no session id, an unwritable state directory — leaves this at 1, so the
445
+ # failure mode is speaking every time rather than never.
446
+ notice_due=1
447
+ if [ -n "$notice_marker" ] && [ -f "$notice_marker" ]; then
448
+ notice_due=0
449
+ fi
450
+
451
+ stale_notice=""
452
+
453
+ # The repair differs by tree, and getting that wrong makes the notice useless.
454
+ #
455
+ # `lisa apply` refreshes `scripts/lisa-hooks/` because it wrote it. It does not
456
+ # touch `plugins/lisa/hooks/`, which IS the Lisa monorepo's own source: a
457
+ # checkout behind the release is behind because of its branch, and the only
458
+ # thing that moves it is moving the branch. Printing one repair for both would
459
+ # hand half the fleet an instruction that changes nothing — a refusal whose
460
+ # remedy cannot be followed (CodySwannGT/lisa#3191).
461
+ HOST_REPAIR="run \`npx @codyswann/lisa apply\` to rewrite these guards"
462
+ PLUGIN_REPAIR="update this checkout — these guards are its own source, so \`lisa apply\` does not refresh them"
463
+
464
+ # Add one tree to the notice, if there is anything to say about it.
465
+ note_tree_staleness() {
466
+ if [ -z "$2" ]; then
467
+ stale_notice="$stale_notice $1 — vintage unknown (no Lisa manifest beside it), so it cannot be shown current
468
+ repair: $3
469
+ "
470
+ elif [ -n "$newest_version" ] && version_older "$2" "$newest_version"; then
471
+ stale_notice="$stale_notice $1 — lisa $2, behind $newest_version at $newest_source
472
+ repair: $3
473
+ "
474
+ fi
475
+ }
476
+
477
+ # EVERYTHING the notice says is gated on the session, including the shadowing
478
+ # line. That line was the bug the rate-limit test caught: `shadowed` is computed
479
+ # in the resolution loop and depends on no vintage, so it printed on every call
480
+ # of a session while the staleness lines correctly stayed quiet — the rate limit
481
+ # bypassed by its one branch that needs nothing resolved.
482
+ if [ "$notice_due" -eq 1 ]; then
483
+ resolve_vintages
484
+ if [ "$host_tree_used" -eq 1 ]; then
485
+ note_tree_staleness "$host_tree" "$host_tree_version" "$HOST_REPAIR"
486
+ fi
487
+ if [ "$plugin_tree_used" -eq 1 ]; then
488
+ note_tree_staleness "$plugin_tree" "$plugin_tree_version" "$PLUGIN_REPAIR"
489
+ fi
490
+
491
+ if [ -n "$stale_notice" ] || [ -n "$shadowed" ]; then
492
+ # Claim the session BEFORE printing. A failed claim prints anyway; it must
493
+ # never swallow the notice.
494
+ if [ -n "$notice_marker" ]; then
495
+ mkdir -p "$notice_state_dir" 2>/dev/null || true
496
+ : >"$notice_marker" 2>/dev/null || true
497
+ # Stale markers are swept only here — once per session, off the hot path.
498
+ find "$notice_state_dir" -maxdepth 1 -type f -mmin +1440 -delete 2>/dev/null || true
499
+ fi
500
+ {
501
+ printf 'Lisa enforcement is running guards from this checkout, not from npm,\n'
502
+ printf 'so publishing a guard fix does not reach the copies below.\n'
503
+ if [ -n "$stale_notice" ]; then
504
+ printf '%s' "$stale_notice"
505
+ fi
506
+ if [ -n "$shadowed" ]; then
507
+ printf ' %s shadows %s for: %s (the shadowed copy never runs)\n' \
508
+ "$host_tree" "$plugin_tree" "$shadowed"
509
+ fi
510
+ } >&2
511
+ fi
512
+ fi
513
+
514
+ # ---------------------------------------------------------------------------
515
+ # Dispatch
516
+ status=0
517
+ index=0
518
+ while [ "$index" -lt "$guard_count" ]; do
519
+ script="${guard_scripts[$index]}"
520
+ # Each guard reads the tool payload on stdin and signals a refusal with exit
521
+ # 2. The payload is replayed to every one of them, and the strongest refusal
522
+ # is returned — a guard that declines must not be able to clear one that did
523
+ # not.
524
+ #
525
+ # The status is captured from the pipeline directly rather than through `if !`,
526
+ # where `$?` is the negation and every refusal read as success: the guard
527
+ # printed its objection and the command ran anyway, which is the same
528
+ # fail-open this file exists to close.
529
+ printf '%s' "$payload" | bash "$script"
530
+ guard_status=$?
531
+ # Attribution, printed immediately after the guard's own objection so the two
532
+ # arrive together.
533
+ #
534
+ # Without it a refusal is anonymous: six guards resolved from up to two trees
535
+ # of different ages produce one exit code between them, and an operator
536
+ # cannot tell which copy objected, how old that copy is, or that the copies
537
+ # disagreed at all. Naming the file and its vintage is the difference between
538
+ # "the guard is wrong" and "this copy is three releases behind", and only the
539
+ # second has an action attached to it.
540
+ if [ "$guard_status" -ne 0 ]; then
541
+ # Resolved here rather than up front: a refusal is the one moment the
542
+ # vintage is certainly worth its three file reads.
543
+ resolve_vintages
544
+ if [ "${guard_trees[$index]}" = "host" ]; then
545
+ describe_vintage "$host_tree_version"
546
+ else
547
+ describe_vintage "$plugin_tree_version"
548
+ fi
549
+ if [ "$guard_status" -eq 2 ]; then
550
+ printf 'Refused by %s (%s)\n' "$script" "$vintage_label" >&2
551
+ else
552
+ printf 'Guard %s exited %s — non-blocking error from %s (%s)\n' \
553
+ "${guard_names[$index]}" "$guard_status" "$script" "$vintage_label" >&2
554
+ fi
555
+ fi
556
+ # 2 is the ONLY status Claude Code treats as a refusal. Every other non-zero
557
+ # is a non-blocking error: it is surfaced, and the tool call proceeds anyway.
558
+ # So the aggregate cannot be the numerically largest status — under `-gt`, a
559
+ # guard erroring with 3, or dying on a missing interpreter with 127,
560
+ # outranks another guard's 2 and silently downgrades a refusal into a
561
+ # warning. That is the precise fail-open this file exists to close,
562
+ # reintroduced one layer up.
563
+ #
564
+ # 2 therefore dominates and is sticky; a lesser non-zero is only carried when
565
+ # no guard has refused, so a genuine error is still reported when nothing
566
+ # blocked.
567
+ if [ "$guard_status" -eq 2 ]; then
568
+ status=2
569
+ elif [ "$guard_status" -ne 0 ] && [ "$status" -ne 2 ]; then
570
+ status="$guard_status"
571
+ fi
572
+ index=$((index + 1))
573
+ done
574
+
187
575
  exit "$status"