@codyswann/lisa 4.73.2 → 4.73.3

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 (67) hide show
  1. package/README.md +20 -0
  2. package/all/copy-overwrite/scripts/lisa-enforcement-fallback.sh +137 -163
  3. package/all/copy-overwrite/scripts/lisa-enforcement-freshness.mjs +180 -0
  4. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  5. package/dist/core/lisa-owned-hash-ledger.js +10 -0
  6. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  7. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  8. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  9. package/dist/core/upstream-evidence-manifest.js +17 -5
  10. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  11. package/package.json +4 -4
  12. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  13. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  14. package/plugins/lisa-agy/plugin.json +1 -1
  15. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  17. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  18. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  24. package/plugins/lisa-expo-agy/plugin.json +1 -1
  25. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  29. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  30. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  34. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  35. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  39. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  40. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  44. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  45. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  49. package/plugins/lisa-rails-agy/plugin.json +1 -1
  50. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  54. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  55. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  59. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  60. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  62. package/plugins/materialized-artifacts.json +1 -0
  63. package/scripts/build-plugins.sh +7 -0
  64. package/scripts/lisa-enforcement-fallback.sh +137 -163
  65. package/scripts/lisa-enforcement-freshness.mjs +177 -0
  66. package/scripts/two-channel-couplings.json +1 -1
  67. package/typescript/copy-overwrite/.prettierignore +1 -0
@@ -100,8 +100,8 @@ plugin_tree="$repo_root/plugins/lisa/hooks"
100
100
  #
101
101
  # Enforcement resolves from THE CHECKOUT, never from npm. Publishing a guard
102
102
  # fix and refreshing the marketplace does not reach the copy governing an agent
103
- # working in a branch cut before that fix — and because the aggregate takes the
104
- # strongest refusal, the oldest resolved copy governs (CodySwannGT/lisa#3205).
103
+ # working in a branch cut before that fix. First-wins resolution chooses each
104
+ # guard; the strongest refusal aggregates DIFFERENT guards (#3205).
105
105
  # One guard measured 22/22 on `main`, 22/22 in the installed clone, and 19/22
106
106
  # on the copy actually in force on the machine the fix had been written on.
107
107
  #
@@ -113,54 +113,29 @@ plugin_tree="$repo_root/plugins/lisa/hooks"
113
113
  #
114
114
  # Three constraints shape how the dating is done, and each rules something out:
115
115
  #
116
- # - It runs on EVERY tool call, so nothing here may fork. Versions are read
117
- # with the `read` builtin, matched with bash's own regex, and returned
118
- # through globals rather than `$(...)`, which would fork a subshell per
119
- # lookup.
116
+ # - Already-noticed permitted calls perform no diagnostic reads or forks.
117
+ # Notices/refusals use one bounded optional helper per process, off that
118
+ # hot path, to validate JSON and compare selected entry-file bytes.
120
119
  # - It must work offline, and there is no network lookup anywhere below. A
121
120
  # guard that stalls or fails when the network does is a guard whoever it
122
121
  # slows down switches off.
123
- # - It compares only against evidence on the same disk. Staleness is claimed
124
- # only when a demonstrably newer Lisa can be pointed at, so the worst case
125
- # is silence rather than a fleet-wide false alarm.
122
+ # - Host content differences are not a claim of older/newer code. Historical
123
+ # receipt versions never date current bytes. Plugin-channel age remains a
124
+ # distinct version-backed diagnostic; missing evidence is explicit unknown.
126
125
  #
127
126
  # Bash 3.2 is the floor — macOS ships it as /bin/bash, and that is what the
128
127
  # repository hook entry invokes — so there are no associative arrays
129
128
  # here. There are exactly two possible trees, which is what makes plain
130
129
  # variables enough.
131
130
 
132
- # Result of the last read_json_version call. A global because command
133
- # substitution forks and this runs on every tool call.
134
- json_version=""
135
-
136
- # The version a JSON file states, without forking or requiring jq.
137
- #
138
- # Deliberately the FIRST occurrence of the key: in all three files read here —
139
- # a package manifest, a plugin manifest, the apply receipt — that occurrence is
140
- # the top-level one. A file that does not state the key leaves the result
141
- # empty, which is reported as an unknown vintage rather than guessed at.
142
- read_json_version() {
143
- json_version=""
144
- [ -f "$1" ] || return 1
145
- # Built as a variable and matched unquoted: in bash 3.2 a quoted portion of
146
- # an `=~` pattern is literal text, so an inline pattern would stop being a
147
- # regex on exactly the interpreter this has to work under.
148
- #
149
- # Anchored to at most three leading spaces so a NESTED key cannot answer for
150
- # a top-level one. `node_modules/@codyswann/lisa/package.json` carries its
151
- # `"version"` at line 118 of 255 — measured, not assumed — with a dependency
152
- # block below it, and a package literally named `version` would otherwise
153
- # match first and report its range as a Lisa version.
154
- local pattern="^[[:space:]]{0,3}\"$2\"[[:space:]]*:[[:space:]]*\"([^\"]+)\""
155
- local line
156
- while IFS= read -r line || [ -n "$line" ]; do
157
- if [[ "$line" =~ $pattern ]]; then
158
- json_version="${BASH_REMATCH[1]}"
159
- return 0
160
- fi
161
- done <"$1"
162
- return 1
163
- }
131
+ # The receipt records history, not the current contents of a selected guard.
132
+ # Read strict bounded JSON and actual selected host/template pairs only when a
133
+ # notice or refusal needs evidence. The optional Node helper is shipped beside
134
+ # this dispatcher; its absence cannot alter enforcement, only make evidence
135
+ # unknown. There is no network, directory scan or persistent content cache.
136
+ installed_version=""
137
+ last_applied_version=""
138
+ host_guard_states=()
164
139
 
165
140
  # Numeric release fields of the last split_version call.
166
141
  v1=0
@@ -231,54 +206,41 @@ note_version() {
231
206
  fi
232
207
  }
233
208
 
234
- host_tree_version=""
235
209
  plugin_tree_version=""
236
210
 
237
- # Date every tree, and find the newest Lisa on this disk.
238
- #
239
- # Called lazily — at most once, and only when something is actually going to be
240
- # said. On the overwhelmingly common path (a permitted call in a session that
241
- # has already printed its notice) it never runs at all, so those three file
242
- # reads leave the hot path entirely.
211
+ # Resolve once PER PROCESS at the existing lazy diagnostic boundary. A later
212
+ # refusal in the same session gets fresh content evidence in its new process;
213
+ # an already-noticed permitted call invokes neither Node nor a comparison.
243
214
  resolve_vintages() {
244
215
  [ "$vintages_resolved" -eq 0 ] || return 0
245
216
  vintages_resolved=1
246
-
247
- # The marketplace clone is the installed release in the literal sense — it is
248
- # the copy `claude plugin` put on this machine, and it is a full checkout of
249
- # Lisa, so it dates itself.
250
217
  local config_dir="${CLAUDE_CONFIG_DIR-}"
251
218
  [ -n "$config_dir" ] || config_dir="${HOME-}/.claude"
252
- local marketplace_manifest="$config_dir/plugins/marketplaces/lisa/plugins/lisa/.claude-plugin/plugin.json"
253
- if read_json_version "$marketplace_manifest" version; then
254
- note_version "$json_version" "$marketplace_manifest"
255
- fi
256
-
257
- local installed_manifest="$repo_root/node_modules/@codyswann/lisa/package.json"
258
- if read_json_version "$installed_manifest" version; then
259
- note_version "$json_version" "$installed_manifest"
260
- fi
261
-
262
- # `scripts/lisa-hooks/` is written into a host by `lisa apply`, and the apply
263
- # receipt records which Lisa version performed that write. The receipt IS
264
- # that tree's vintage: the same run produced both, so they cannot disagree.
265
- if read_json_version "$repo_root/.lisa/apply-receipt.json" lisa_version; then
266
- host_tree_version="$json_version"
267
- fi
268
- note_version "$host_tree_version" "$host_tree"
269
-
270
- # `plugins/lisa/hooks/` is the Lisa monorepo's own copy, dated by the plugin
271
- # manifest beside it, which the release bumps in lockstep with the package.
272
- if read_json_version "$repo_root/plugins/lisa/.claude-plugin/plugin.json" version; then
273
- plugin_tree_version="$json_version"
219
+ local helper="${BASH_SOURCE[0]%/*}/lisa-enforcement-freshness.mjs"
220
+ local key value state_index
221
+ local state_count=0
222
+ while [ "$state_count" -lt "$guard_count" ]; do
223
+ host_guard_states+=("unknown")
224
+ state_count=$((state_count + 1))
225
+ done
226
+ if [ -f "$helper" ] && [ -r "$helper" ] && command -v node >/dev/null 2>&1; then
227
+ while IFS=$'\t' read -r key value; do
228
+ case "$key" in
229
+ installed) installed_version="$value" ;;
230
+ applied) last_applied_version="$value" ;;
231
+ plugin) plugin_tree_version="$value" ;;
232
+ marketplace) note_version "$value" "$config_dir/plugins/marketplaces/lisa/plugins/lisa/.claude-plugin/plugin.json" ;;
233
+ channel) plugin_channel_version="$value" ;;
234
+ channel_path) plugin_channel_path="$value" ;;
235
+ guard[0-7])
236
+ state_index="${key#guard}"
237
+ case "$value" in matching|different|unknown) host_guard_states[$state_index]="$value" ;; esac
238
+ ;;
239
+ esac
240
+ done < <(node "$helper" "$repo_root" "$config_dir" "${guard_evidence_names[@]}" 2>/dev/null)
274
241
  fi
242
+ note_version "$installed_version" "$repo_root/node_modules/@codyswann/lisa/package.json"
275
243
  note_version "$plugin_tree_version" "$plugin_tree"
276
-
277
- # The channel this dispatcher races. Deliberately NOT folded into
278
- # `note_version`: the newest-on-disk maximum answers "is this copy behind
279
- # something local", and the plugin channel's age is a separate question
280
- # about a copy that runs in parallel rather than instead.
281
- resolve_plugin_channel || true
282
244
  }
283
245
 
284
246
  # ---------------------------------------------------------------------------
@@ -327,51 +289,8 @@ resolve_vintages() {
327
289
  plugin_channel_version=""
328
290
  plugin_channel_path=""
329
291
 
330
- # Resolve the vintage of the guard channel the PLUGIN MANIFEST runs.
331
- #
332
- # `plugins/installed_plugins.json` records installs PER PROJECT DIRECTORY —
333
- # which is exactly why one checkout sits versions behind another on the same
334
- # disk — so the lookup is keyed on this repo root, not on the machine.
335
- #
336
- # A record is claimed only when its `installPath` sits under a `lisa/lisa/`
337
- # marketplace cache. A locally-installed plugin does not match and is left
338
- # UNRESOLVED on purpose: reporting "agree" about a copy that was never read is
339
- # the failure this whole section exists to end, and an honest "I could not
340
- # tell" is the safe direction.
341
- #
342
- # `grep` does the scanning because the record runs to tens of thousands of
343
- # lines; the bash loop then sees a handful. Called only from resolve_vintages,
344
- # so it inherits that latch and never runs on the silent allow path.
345
- resolve_plugin_channel() {
346
- local config_dir="${CLAUDE_CONFIG_DIR-}"
347
- [ -n "$config_dir" ] || config_dir="${HOME-}/.claude"
348
- local record="$config_dir/plugins/installed_plugins.json"
349
- [ -f "$record" ] || return 1
350
-
351
- local install_pattern="^[[:space:]]*\"installPath\"[[:space:]]*:[[:space:]]*\"(.*/lisa/lisa/[^\"]+)\""
352
- local version_pattern="^[[:space:]]*\"version\"[[:space:]]*:[[:space:]]*\"([^\"]+)\""
353
- local line
354
- local candidate=""
355
- while IFS= read -r line || [ -n "$line" ]; do
356
- # `grep -A` separates non-adjacent groups with `--`. A candidate must not
357
- # survive across that boundary or one plugin's installPath answers for
358
- # another plugin's version.
359
- if [ "$line" = "--" ]; then
360
- candidate=""
361
- continue
362
- fi
363
- if [[ "$line" =~ $install_pattern ]]; then
364
- candidate="${BASH_REMATCH[1]}"
365
- continue
366
- fi
367
- if [ -n "$candidate" ] && [[ "$line" =~ $version_pattern ]]; then
368
- plugin_channel_path="$candidate"
369
- plugin_channel_version="${BASH_REMATCH[1]}"
370
- return 0
371
- fi
372
- done < <(grep -F -A4 "\"projectPath\": \"$repo_root\"," "$record" 2>/dev/null)
373
- return 1
374
- }
292
+ # The bounded helper reads the runtime record for exactly lisa@lisa and this
293
+ # project. It reports installed version/path, never whether hooks are live.
375
294
 
376
295
  # Verdict of the last classify_channel_skew call: agree | skew | undetermined.
377
296
  #
@@ -408,43 +327,57 @@ channel_skew_line=""
408
327
  # of the channel the plugin manifest runs.
409
328
  classify_channel_skew() {
410
329
  local mine=""
411
- if [ "$host_tree_used" -eq 1 ]; then
412
- mine="$host_tree_version"
413
- elif [ "$plugin_tree_used" -eq 1 ]; then
414
- mine="$plugin_tree_version"
415
- fi
416
-
417
- if [ -z "$mine" ] || [ -z "$plugin_channel_version" ]; then
418
- channel_skew_verdict="undetermined"
419
- channel_skew_line=" cross-channel vintage UNDETERMINED — this dispatcher's guards could not be
420
- compared with the plugin manifest's copies. Not agreement: an unread copy
421
- can be any age, and a relaxation shipped to one channel stays inert until
422
- the other catches up.
330
+ local source=""
331
+ local tree
332
+ local channel_repair=""
333
+ local compared=0
334
+ channel_skew_line=""
335
+ channel_skew_verdict="undetermined"
336
+ for tree in host plugin; do
337
+ if [ "$tree" = host ] && [ "$host_tree_used" -eq 1 ]; then
338
+ mine="$installed_version"
339
+ source="installed Lisa package"
340
+ elif [ "$tree" = plugin ] && [ "$plugin_tree_used" -eq 1 ]; then
341
+ mine="$plugin_tree_version"
342
+ source="this checkout's plugin manifest"
343
+ else
344
+ continue
345
+ fi
346
+ if [ -z "$mine" ] || [ -z "$plugin_channel_version" ]; then
347
+ channel_skew_line="$channel_skew_line cross-channel vintage UNDETERMINED — $source could not be
348
+ compared with the plugin manifest's installed copies. Not agreement:
349
+ missing version evidence does not establish either channel's age or liveness.
423
350
  "
424
- return 0
425
- fi
426
-
427
- if [ "$mine" = "$plugin_channel_version" ]; then
428
- channel_skew_verdict="agree"
429
- channel_skew_line=""
430
- return 0
431
- fi
432
-
433
- channel_skew_verdict="skew"
434
- # The sentence is kept whole on one line on purpose: it is the finding, and
435
- # an operator greps for it.
436
- channel_skew_line=" cross-channel vintage SKEW — this dispatcher runs lisa $mine; the plugin
351
+ elif [ "$mine" != "$plugin_channel_version" ]; then
352
+ channel_skew_verdict="skew"
353
+ if version_older "$plugin_channel_version" "$mine"; then
354
+ channel_repair="update the installed plugin for this project"
355
+ elif version_older "$mine" "$plugin_channel_version"; then
356
+ if [ "$tree" = host ]; then
357
+ channel_repair="update the installed Lisa package, then inspect guard/template content differences before choosing a template refresh"
358
+ else
359
+ channel_repair="$PLUGIN_REPAIR"
360
+ fi
361
+ else
362
+ channel_repair="inspect the differing installed channel versions; their release fields do not establish an older channel"
363
+ fi
364
+ channel_skew_line="$channel_skew_line cross-channel vintage SKEW — $source is lisa $mine; the plugin
437
365
  installed for this project is lisa $plugin_channel_version at $plugin_channel_path.
438
- When both fire on one tool call the agent sees the UNION of their verdicts,
439
- which means:
366
+ These are installed channel versions, not a date for current host guard bytes.
367
+ When both fire on one tool call the agent sees the UNION of their verdicts:
440
368
  YOU CANNOT RETIRE A REFUSAL BY SHIPPING A FIX.
441
- A relaxation on the newer channel does nothing until the older one is
442
- refreshed, so a block you cannot explain from \`main\` is the older copy
443
- still enforcing.
444
- repair: refresh both — \`npx @codyswann/lisa apply\` for this checkout's
445
- guards, and update the installed plugin for the manifest's copies.
369
+ A relaxation on one channel stays inert until the other catches up.
370
+ repair: $channel_repair;
371
+ host content differences, if any, are reported separately below.
446
372
  "
447
- return 0
373
+ compared=1
374
+ else
375
+ compared=1
376
+ fi
377
+ done
378
+ if [ "$compared" -eq 1 ] && [ -z "$channel_skew_line" ]; then
379
+ channel_skew_verdict="agree"
380
+ fi
448
381
  }
449
382
 
450
383
  # Description of the last describe_vintage call.
@@ -476,6 +409,30 @@ describe_vintage() {
476
409
  fi
477
410
  }
478
411
 
412
+ # Content state is per selected guard. Neither an equal package/receipt
413
+ # version nor some OTHER matching guard can prove this refusing copy current.
414
+ # Unknown evidence suggests inspection, not applying over already-matching code.
415
+ describe_host_guard() {
416
+ vintage_is_stale=1
417
+ local facts="installed lisa ${installed_version:-unknown}; last applied lisa ${last_applied_version:-unknown}"
418
+ case "${host_guard_states[$1]-unknown}" in
419
+ matching)
420
+ vintage_label="matches installed template; $facts"
421
+ vintage_is_stale=0
422
+ ;;
423
+ different) vintage_label="DIFFERENT from installed template; $facts" ;;
424
+ *) vintage_label="host content unknown; $facts" ;;
425
+ esac
426
+ }
427
+
428
+ host_guard_repair() {
429
+ if [ "${host_guard_states[$1]-unknown}" = different ]; then
430
+ printf '%s' "$HOST_REPAIR"
431
+ else
432
+ printf 'inspect the selected guard and installed Lisa template evidence (missing, unreadable, invalid or beyond bounds); content freshness is unknown'
433
+ fi
434
+ }
435
+
479
436
  # ---------------------------------------------------------------------------
480
437
  # Resolution
481
438
  #
@@ -487,6 +444,7 @@ guard_count=0
487
444
  guard_names=()
488
445
  guard_scripts=()
489
446
  guard_trees=()
447
+ guard_evidence_names=()
490
448
  missing=""
491
449
  shadowed=""
492
450
  host_tree_used=0
@@ -501,6 +459,7 @@ for guard in block-no-verify parity-safety-net block-shell-json-parsing \
501
459
  # The TREE, not its version: vintages are resolved lazily, so what a guard
502
460
  # records here is which tree to ask about it later.
503
461
  guard_trees+=("host")
462
+ guard_evidence_names+=("$guard")
504
463
  guard_count=$((guard_count + 1))
505
464
  host_tree_used=1
506
465
  # The shadowed copy never runs, and nothing used to say so. Two copies of
@@ -514,6 +473,7 @@ for guard in block-no-verify parity-safety-net block-shell-json-parsing \
514
473
  guard_names+=("$guard")
515
474
  guard_scripts+=("$plugin_tree/$guard.sh")
516
475
  guard_trees+=("plugin")
476
+ guard_evidence_names+=("")
517
477
  guard_count=$((guard_count + 1))
518
478
  plugin_tree_used=1
519
479
  else
@@ -777,7 +737,19 @@ note_tree_staleness() {
777
737
  if [ "$notice_due" -eq 1 ]; then
778
738
  resolve_vintages
779
739
  if [ "$host_tree_used" -eq 1 ]; then
780
- note_tree_staleness "$host_tree" "$host_tree_version" "$HOST_REPAIR"
740
+ host_notice_index=0
741
+ while [ "$host_notice_index" -lt "$guard_count" ]; do
742
+ if [ "${guard_trees[$host_notice_index]}" = host ]; then
743
+ describe_host_guard "$host_notice_index"
744
+ stale_notice="$stale_notice ${guard_scripts[$host_notice_index]} — $vintage_label
745
+ "
746
+ if [ "$vintage_is_stale" -eq 1 ]; then
747
+ stale_notice="$stale_notice repair: $(host_guard_repair "$host_notice_index")
748
+ "
749
+ fi
750
+ fi
751
+ host_notice_index=$((host_notice_index + 1))
752
+ done
781
753
  fi
782
754
  if [ "$plugin_tree_used" -eq 1 ]; then
783
755
  note_tree_staleness "$plugin_tree" "$plugin_tree_version" "$PLUGIN_REPAIR"
@@ -810,7 +782,7 @@ if [ "$notice_due" -eq 1 ]; then
810
782
  if [ "$notice_should_print" -eq 1 ]; then
811
783
  {
812
784
  printf 'Lisa enforcement is running guards from this checkout, not from npm,\n'
813
- printf 'so publishing a guard fix does not reach the copies below.\n'
785
+ printf 'selected host contents are compared with installed templates only for diagnostics.\n'
814
786
  if [ -n "$stale_notice" ]; then
815
787
  printf '%s' "$stale_notice"
816
788
  fi
@@ -899,7 +871,7 @@ while [ "$index" -lt "$guard_count" ]; do
899
871
  # vintage is certainly worth its three file reads.
900
872
  resolve_vintages
901
873
  if [ "${guard_trees[$index]}" = "host" ]; then
902
- describe_vintage "$host_tree_version"
874
+ describe_host_guard "$index"
903
875
  else
904
876
  describe_vintage "$plugin_tree_version"
905
877
  fi
@@ -940,13 +912,15 @@ while [ "$index" -lt "$guard_count" ]; do
940
912
  printf '\nTHIS VERDICT MAY NOT REFLECT CURRENT SOURCE — the copy that produced it\n'
941
913
  printf 'is not provably current (%s).\n' "$vintage_label"
942
914
  if [ "${guard_trees[$index]}" = "host" ]; then
943
- printf ' repair: %s\n' "$HOST_REPAIR"
915
+ printf ' repair: '
916
+ host_guard_repair "$index"
917
+ printf '\n'
944
918
  else
945
919
  printf ' repair: %s\n' "$PLUGIN_REPAIR"
946
920
  fi
947
921
  printf 'Before filing a defect on this behaviour, read the guard on your\n'
948
922
  printf 'integration branch. Re-running the command confirms nothing: it asks\n'
949
- printf 'the same stale copy again.\n'
923
+ printf 'the same selected copy again.\n'
950
924
  } >&2
951
925
  fi
952
926
  fi
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Offline diagnostic evidence for the fallback, never an enforcement decision.
3
+ * Open descriptors bound reads even if a path changes after the size check.
4
+ * Missing Node/helper/evidence leaves Bash's initialized unknown verdicts.
5
+ * @module scripts/lisa-enforcement-freshness
6
+ */
7
+ import {
8
+ closeSync,
9
+ constants,
10
+ fstatSync,
11
+ openSync,
12
+ readSync,
13
+ statSync,
14
+ } from "node:fs";
15
+ import path from "node:path";
16
+
17
+ const METADATA_LIMIT = 64 * 1024;
18
+ const RECORD_LIMIT = 4 * 1024 * 1024;
19
+ const GUARD_LIMIT = 1024 * 1024;
20
+ const GUARDS = new Set([
21
+ "block-no-verify",
22
+ "parity-safety-net",
23
+ "block-shell-json-parsing",
24
+ "block-instruction-file-edits",
25
+ "block-direct-issue-create",
26
+ "block-managed-file-edits",
27
+ "block-blind-automerge",
28
+ "worktree-binding-guard",
29
+ ]);
30
+ const VERSION = /^\d{1,6}\.\d{1,6}\.\d{1,6}(?:[-+][A-Za-z0-9.-]{1,48})?$/u;
31
+
32
+ /** A finite regular, readable file, including when the caller is root. */
33
+ function readBounded(file, limit) {
34
+ let fd;
35
+ try {
36
+ // Nonblocking open also prevents a substituted FIFO from stalling a notice.
37
+ fd = openSync(file, constants.O_RDONLY | constants.O_NONBLOCK);
38
+ const before = fstatSync(fd);
39
+ if (!before.isFile() || !(before.mode & 0o444) || before.size > limit)
40
+ return null;
41
+ const buffer = Buffer.alloc(limit + 1);
42
+ let length = 0;
43
+ while (length < buffer.length) {
44
+ const count = readSync(fd, buffer, length, buffer.length - length, null);
45
+ if (!count) break;
46
+ length += count;
47
+ }
48
+ const after = fstatSync(fd);
49
+ const current = statSync(file);
50
+ if (
51
+ length > limit ||
52
+ length !== before.size ||
53
+ after.size !== before.size ||
54
+ after.mtimeMs !== before.mtimeMs ||
55
+ after.ctimeMs !== before.ctimeMs ||
56
+ after.mode !== before.mode ||
57
+ !current.isFile() ||
58
+ !(current.mode & 0o444) ||
59
+ current.size !== before.size ||
60
+ current.mtimeMs !== before.mtimeMs ||
61
+ current.ctimeMs !== before.ctimeMs ||
62
+ current.mode !== before.mode ||
63
+ current.dev !== before.dev ||
64
+ current.ino !== before.ino
65
+ )
66
+ return null;
67
+ return buffer.subarray(0, length);
68
+ } catch {
69
+ return null;
70
+ } finally {
71
+ if (fd !== undefined) closeSync(fd);
72
+ }
73
+ }
74
+
75
+ /** Malformed, oversized, unreadable and non-object JSON all remain unknown. */
76
+ function json(file, limit = METADATA_LIMIT) {
77
+ try {
78
+ const bytes = readBounded(file, limit);
79
+ const value =
80
+ bytes === null
81
+ ? null
82
+ : JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
83
+ return value && typeof value === "object" && !Array.isArray(value)
84
+ ? value
85
+ : null;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ function version(value) {
92
+ return typeof value === "string" && VERSION.test(value) ? value : "";
93
+ }
94
+
95
+ function emit(key, value) {
96
+ if (value) process.stdout.write(`${key}\t${value}\n`);
97
+ }
98
+
99
+ const [root, config, ...names] = process.argv.slice(2);
100
+ if (
101
+ root &&
102
+ config &&
103
+ names.length <= GUARDS.size &&
104
+ names.every(name => name === "" || GUARDS.has(name)) &&
105
+ new Set(names.filter(Boolean)).size === names.filter(Boolean).length
106
+ ) {
107
+ const installedRoot = path.join(root, "node_modules/@codyswann/lisa");
108
+ const installed = json(path.join(installedRoot, "package.json"));
109
+ const installedVersion =
110
+ installed?.name === "@codyswann/lisa" ? version(installed.version) : "";
111
+ emit("installed", installedVersion);
112
+ if (names.some(Boolean)) {
113
+ const receipt = json(path.join(root, ".lisa/apply-receipt.json"));
114
+ // Mirror readApplyReceipt's supported schema and required historical fields.
115
+ // Optional fields do not make older supported receipts unusable.
116
+ if (receipt?.schema_version === 1 && typeof receipt.applied_at === "string")
117
+ emit("applied", version(receipt.lisa_version));
118
+ }
119
+ const plugin = json(
120
+ path.join(root, "plugins/lisa/.claude-plugin/plugin.json")
121
+ );
122
+ emit("plugin", plugin?.name === "lisa" ? version(plugin.version) : "");
123
+ const marketplace = json(
124
+ path.join(
125
+ config,
126
+ "plugins/marketplaces/lisa/plugins/lisa/.claude-plugin/plugin.json"
127
+ )
128
+ );
129
+ emit(
130
+ "marketplace",
131
+ marketplace?.name === "lisa" ? version(marketplace.version) : ""
132
+ );
133
+
134
+ // Eight pairs / sixteen MiB, plus one overflow-probe byte per file. Only
135
+ // host-selected names are nonempty; plugin-selected guards are not compared.
136
+ for (const [index, name] of names.entries()) {
137
+ let state = "unknown";
138
+ if (installedVersion && name) {
139
+ const host = readBounded(
140
+ path.join(root, "scripts/lisa-hooks", `${name}.sh`),
141
+ GUARD_LIMIT
142
+ );
143
+ const template = readBounded(
144
+ path.join(
145
+ installedRoot,
146
+ "all/copy-overwrite/scripts/lisa-hooks",
147
+ `${name}.sh`
148
+ ),
149
+ GUARD_LIMIT
150
+ );
151
+ if (host !== null && template !== null)
152
+ state = host.equals(template) ? "matching" : "different";
153
+ }
154
+ emit(`guard${index}`, state);
155
+ }
156
+
157
+ const record = json(
158
+ path.join(config, "plugins/installed_plugins.json"),
159
+ RECORD_LIMIT
160
+ );
161
+ const entries = record?.plugins?.["lisa@lisa"];
162
+ if (Array.isArray(entries)) {
163
+ const entry = entries.find(
164
+ candidate =>
165
+ candidate?.projectPath === root &&
166
+ typeof candidate.installPath === "string" &&
167
+ candidate.installPath.length <= 4096 &&
168
+ !/[\x00-\x1f\x7f]/u.test(candidate.installPath) &&
169
+ candidate.installPath.includes("/lisa/lisa/") &&
170
+ version(candidate.version)
171
+ );
172
+ if (entry) {
173
+ emit("channel", version(entry.version));
174
+ emit("channel_path", entry.installPath);
175
+ }
176
+ }
177
+ }
@@ -105,7 +105,7 @@
105
105
  "workflows": 26,
106
106
  "steps": 808,
107
107
  "couplings": 36,
108
- "inventory": 177
108
+ "inventory": 178
109
109
  },
110
110
  "counts": {
111
111
  "package-backed": 11,
@@ -83,6 +83,7 @@ scripts/lisa-command-envelope.mjs
83
83
  scripts/lisa-commit-msg-gates.mjs
84
84
  scripts/lisa-cross-worktree-guard.mjs
85
85
  scripts/lisa-destructive-guard.mjs
86
+ scripts/lisa-enforcement-freshness.mjs
86
87
  scripts/lisa-environment-prepare.mjs
87
88
  scripts/lisa-floor-collisions.mjs
88
89
  scripts/lisa-gates.mjs