@codyswann/lisa 4.73.2 → 4.73.4

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