@codyswann/lisa 4.17.12 → 4.17.14

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