@codyswann/lisa 4.34.15 → 4.34.17

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 (91) hide show
  1. package/all/copy-overwrite/scripts/lib/gate-failure-diagnosis.mjs +3 -5
  2. package/all/copy-overwrite/scripts/lisa-hooks/block-direct-issue-create.sh +20 -3
  3. package/all/copy-overwrite/scripts/lisa-hooks/block-managed-file-edits.sh +33 -8
  4. package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +49 -4
  5. package/all/copy-overwrite/scripts/lisa-work-item.mjs +1 -1
  6. package/dist/codex/scripts/block-no-verify.sh +49 -4
  7. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  8. package/dist/core/lisa-owned-hash-ledger.js +18 -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 +17 -14
  13. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  14. package/dist/opencode/plugin-templates/block-no-verify.sh +49 -4
  15. package/expo/copy-overwrite/scripts/check-lighthouse-details.mjs +13 -7
  16. package/package.json +4 -4
  17. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  19. package/plugins/lisa/.codex-plugin/skills/lisa-drive-pr-to-merge/SKILL.md +90 -0
  20. package/plugins/lisa/hooks/block-direct-issue-create.sh +20 -3
  21. package/plugins/lisa/hooks/block-managed-file-edits.sh +33 -8
  22. package/plugins/lisa/hooks/block-no-verify.agy.sh +49 -4
  23. package/plugins/lisa/hooks/block-no-verify.sh +49 -4
  24. package/plugins/lisa/skills/lisa-drive-pr-to-merge/SKILL.md +90 -0
  25. package/plugins/lisa-agy/hooks/block-direct-issue-create.sh +20 -3
  26. package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +49 -4
  27. package/plugins/lisa-agy/plugin.json +1 -1
  28. package/plugins/lisa-agy/skills/lisa-drive-pr-to-merge/SKILL.md +90 -0
  29. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  31. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  32. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-copilot/hooks/block-direct-issue-create.sh +20 -3
  36. package/plugins/lisa-copilot/hooks/block-managed-file-edits.sh +33 -8
  37. package/plugins/lisa-copilot/hooks/block-no-verify.sh +49 -4
  38. package/plugins/lisa-copilot/skills/lisa-drive-pr-to-merge/SKILL.md +90 -0
  39. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-cursor/hooks/block-direct-issue-create.sh +20 -3
  41. package/plugins/lisa-cursor/hooks/block-managed-file-edits.sh +33 -8
  42. package/plugins/lisa-cursor/hooks/block-no-verify.sh +49 -4
  43. package/plugins/lisa-cursor/skills/lisa-drive-pr-to-merge/SKILL.md +90 -0
  44. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  46. package/plugins/lisa-expo-agy/plugin.json +1 -1
  47. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  51. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  52. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  56. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  57. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  61. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  62. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  66. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  67. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  71. package/plugins/lisa-rails-agy/plugin.json +1 -1
  72. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  76. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  77. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  81. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  82. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  84. package/plugins/src/base/hooks/block-direct-issue-create.sh +20 -3
  85. package/plugins/src/base/hooks/block-managed-file-edits.sh +33 -8
  86. package/plugins/src/base/hooks/block-no-verify.agy.sh +49 -4
  87. package/plugins/src/base/hooks/block-no-verify.sh +49 -4
  88. package/plugins/src/base/skills/lisa-drive-pr-to-merge/SKILL.md +90 -0
  89. package/rails/create-only/.github/workflows/deploy.yml +30 -0
  90. package/typescript/copy-overwrite/.claude/hooks/worktree-create.sh +3 -1
  91. package/typescript/copy-overwrite/scripts/lisa-mutation.mjs +14 -54
@@ -460,6 +460,50 @@ passed off as success.
460
460
  `blocked:unreviewed`. This fallback lives inside the gated section above —
461
461
  with `auto_merge=false` it never fires; the PR remains open awaiting a human.
462
462
 
463
+ ### The mergeability gate — never arm a PR that cannot merge
464
+
465
+ The arm gate above asks *did the review context do work?* This one asks a
466
+ different question — *is there a verdict standing in the way?* — and they are
467
+ not the same. A `CHANGES_REQUESTED` review unambiguously **did work**, so it
468
+ sails through the arm gate and the latch goes on over the top of it.
469
+
470
+ **Read `reviewDecision` explicitly. Never infer it from a check count.**
471
+
472
+ ```bash
473
+ gh pr view <pr> --json reviewDecision,reviewThreads \
474
+ --jq '{decision: .reviewDecision, unresolved: [.reviewThreads[]? | select(.isResolved == false)] | length}'
475
+ ```
476
+
477
+ `reviewDecision` is **not part of `statusCheckRollup`**. That is the whole reason
478
+ this stayed invisible: a failing-check count reads **zero** on a PR that can
479
+ never merge, and the checks tab is entirely green. Any rule keyed on "no red
480
+ checks" is satisfied by exactly the PR this gate exists to catch (#3720).
481
+
482
+ **Do not arm while `reviewDecision == CHANGES_REQUESTED`.** Arming is a claim
483
+ that this PR will merge, and that claim is false here. Clear the verdict through
484
+ step (e) first — which discriminates a live objection from a stranded one by
485
+ unresolved thread count, not by `reviewDecision` — and arm afterwards.
486
+
487
+ **Two ruleset settings decide whether a standing verdict ever clears itself, and
488
+ in this repository both say no.** Verify them for the repo you are in rather
489
+ than assuming, with `gh api repos/<owner>/<repo>/rulesets`:
490
+
491
+ - `dismiss_stale_reviews_on_push: false` — the review stays attached to the
492
+ commit it was made on, so **pushing the fix never clears it**. The intuition
493
+ that a new head resets the verdict is wrong here.
494
+ - `required_approving_review_count: 0` — no approval is required to merge, so a
495
+ standing `CHANGES_REQUESTED` is not holding a slot for a review that would
496
+ otherwise arrive. Nothing is scheduled to clear it.
497
+
498
+ Together those mean the block is **indefinite, not slow**. Waiting is not a
499
+ strategy; something has to act.
500
+
501
+ **Unresolved threads block independently of the verdict.** This repository sets
502
+ `required_review_thread_resolution: true`, so a PR can have a clear
503
+ `reviewDecision` and still be unmergeable on threads alone — a second blocker
504
+ that is equally absent from the check rollup. Read both, and name whichever one
505
+ is standing; reporting the wrong one sends the operator to the wrong place.
506
+
463
507
  ## 2. The watch loop
464
508
 
465
509
  Poll the live state each iteration:
@@ -1078,6 +1122,14 @@ Loop until one of:
1078
1122
  the one thing nobody would then do is investigate it. Usually a missing
1079
1123
  `issues: read` / label-read permission, which is fixable and invisible from
1080
1124
  the merge outcome alone.
1125
+ - **`blocked:armed-unmergeable`** — the PR is OPEN, auto-merge is ON, every check
1126
+ is green, and it still cannot merge: `reviewDecision == CHANGES_REQUESTED`, or
1127
+ unresolved review threads under `required_review_thread_resolution`. Report the
1128
+ PR URL, **which** of the two is standing, and that it will not clear itself.
1129
+ For example: *"PR #123 looks ready — every check is green and auto-merge is on
1130
+ — but a review asked for changes and that is still on record, so it will sit
1131
+ there forever. Someone needs to clear that review or resolve the open
1132
+ comments."* Never report this PR as merging.
1081
1133
  - **`CLOSED`** → report (PR was closed without merge).
1082
1134
  - **Hard block needing a human**: an unresolvable conflict, a failing check that
1083
1135
  needs design input, or genuine unresolved human objection (not a bot gate). Stop
@@ -1087,6 +1139,44 @@ Loop until one of:
1087
1139
  adversarial-review substitute is posted (step d); it never extends to any
1088
1140
  other gate.
1089
1141
 
1142
+ ### Before you stop: never leave a PR armed and unmergeable in silence
1143
+
1144
+ **This applies at every exit that leaves the PR OPEN** — not only the terminal
1145
+ states above, but any point where this run concludes it is finished, hands back,
1146
+ or gives up. Re-read, from the live PR, immediately before reporting:
1147
+
1148
+ ```bash
1149
+ gh pr view <pr> --json state,autoMergeRequest,reviewDecision,reviewThreads \
1150
+ --jq '{state, armed: (.autoMergeRequest != null), decision: .reviewDecision,
1151
+ unresolved: [.reviewThreads[]? | select(.isResolved == false)] | length}'
1152
+ ```
1153
+
1154
+ If `state == OPEN` and `armed` and either `decision == "CHANGES_REQUESTED"` or
1155
+ `unresolved > 0`, the outcome is **`blocked:armed-unmergeable`** — never a
1156
+ success, and never silence.
1157
+
1158
+ **Why this exists rather than trusting the loop.** While the loop runs this is
1159
+ already covered: the watch poll reads `reviewDecision` and step (e) handles it.
1160
+ The failure is a run that **stops** while the PR is armed and blocked — the
1161
+ session ends, or the agent decides the work is done because arming looked like
1162
+ completion. Measured (#3720): three PRs were left armed, green and permanently
1163
+ unmergeable, and every one was found only because a person queried
1164
+ `reviewDecision` by hand. An agent that arms auto-merge and reports "armed, will
1165
+ merge" is reporting something false and has no reason to know it.
1166
+
1167
+ **Arming is not completion.** It is a claim about the future that this check is
1168
+ what makes honest. The claim costs nothing when true — one read, and the report
1169
+ says merging — and when false it is the only thing standing between a parked PR
1170
+ and nobody noticing for hours.
1171
+
1172
+ **Read the two fields; do not substitute a check count.** Zero failing checks is
1173
+ the symptom, not the signal — that is precisely why this class was invisible.
1174
+
1175
+ In `on_blocker=report` mode the same condition returns the existing
1176
+ `blocked:changes_requested` classification rather than this terminal state: that
1177
+ mode returns a blocker for the caller to act on, and does not drive. The two
1178
+ names describe the same PR seen from the two modes.
1179
+
1090
1180
  At every terminal state, release the babysitter lease
1091
1181
  (`gh pr edit <pr> --remove-label "lisa:babysitter-on-duty"`) so the CI
1092
1182
  auto-fix workflow can take over as fixer of last resort if the branch goes
@@ -1432,8 +1432,20 @@ EXEC_WRAPPERS = {
1432
1432
  }
1433
1433
 
1434
1434
  # Interpreter options that mean "no script file follows": a command string or a
1435
- # module name, both of which the guard reads by other means or not at all.
1436
- NO_SCRIPT_OPTIONS = {"-c", "-e", "-m", "--eval", "--command", "--module"}
1435
+ # module name, both of which the guard reads by other means or not at all. These
1436
+ # are interpreter-specific on purpose. `bash -e create.sh` and `sh -m create.sh`
1437
+ # still execute the following file; treating Python and Node flags as universal
1438
+ # silently stopped the scan before those shell operands.
1439
+ NO_SCRIPT_OPTIONS = {
1440
+ "bun": {"-e", "--eval"},
1441
+ "node": {"-e", "--eval"},
1442
+ "nodejs": {"-e", "--eval"},
1443
+ "perl": {"-e"},
1444
+ "php": {"-r"},
1445
+ "python": {"-c", "-m", "--command", "--module"},
1446
+ "python3": {"-c", "-m", "--command", "--module"},
1447
+ "ruby": {"-e"},
1448
+ }
1437
1449
 
1438
1450
  # Programs that READ their operands and never execute them as programs.
1439
1451
  #
@@ -1569,7 +1581,12 @@ def executed_operand(argv):
1569
1581
  if argument.startswith("-"):
1570
1582
  # A command string or a module name; no script file follows.
1571
1583
  head = argument.split("=", 1)[0]
1572
- if argument in NO_SCRIPT_OPTIONS or head in NO_SCRIPT_OPTIONS:
1584
+ if program in {"bash", "dash", "ksh", "sh", "zsh"}:
1585
+ # Shell `-c` may be clustered (`-ec`), while `-e` and `-m` are
1586
+ # ordinary shell options and do not consume the script path.
1587
+ if not head.startswith("--") and "c" in head[1:]:
1588
+ return None
1589
+ elif head in NO_SCRIPT_OPTIONS.get(program, set()):
1573
1590
  return None
1574
1591
  continue
1575
1592
  return argument
@@ -28,7 +28,7 @@
28
28
  # not JSON). Malformed/empty stdin → allow (fail open, never crash the tool).
29
29
  #
30
30
  # Capability names below are the canonical guard's, kept identical on every agent.
31
- # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, dispatcher-exec-position
31
+ # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, dispatcher-exec-position, tilde-script-reach, shell-option-stdin-reach
32
32
  set -uo pipefail
33
33
 
34
34
  allow() {
@@ -937,7 +937,7 @@ def resolve_script(token):
937
937
  """
938
938
  if COMPUTED_VALUE.search(token):
939
939
  return (None, "a computed path the guard cannot resolve before the shell does")
940
- text = token.strip().strip("'\"")
940
+ text = os.path.expanduser(token.strip().strip("'\""))
941
941
  # `bash -` and a bare `-` read the script from stdin; there is no file.
942
942
  if not text or text == "-":
943
943
  return (None, None)
@@ -959,6 +959,48 @@ def resolve_script(token):
959
959
  return (None, "a script that does not exist or cannot be read")
960
960
 
961
961
 
962
+ SHELL_COMMAND_STRING = object()
963
+ SHELL_STDIN = object()
964
+
965
+
966
+ def shell_execution_operand(args):
967
+ """The file a shell runs, or a sentinel for `-c` and stdin modes.
968
+
969
+ `shell_script_operand` intentionally collapses both cases to None for the
970
+ nested-command scanner. Reach cannot: `bash -c '...'` has already exposed
971
+ its text, while `bash -x`, `bash -e`, and `bash --` still await a script on
972
+ stdin and must remain linked to a following `< file` separator.
973
+ """
974
+ index = 0
975
+ opaque_option_seen = False
976
+ while index < len(args):
977
+ token = args[index]
978
+ if token == "--":
979
+ index += 1
980
+ break
981
+ if not token.startswith(("-", "+")):
982
+ if not opaque_option_seen:
983
+ break
984
+ index += 1
985
+ opaque_option_seen = False
986
+ continue
987
+ if token in SHELL_OPTIONS_SEPARATE_VALUE:
988
+ index += 2
989
+ continue
990
+ if token.startswith(SHELL_OPTIONS_INLINE_VALUE):
991
+ index += 1
992
+ continue
993
+ if token.startswith("--"):
994
+ if token not in SHELL_LONG_OPTIONS_NO_VALUE:
995
+ opaque_option_seen = True
996
+ index += 1
997
+ continue
998
+ if "c" in token[1:]:
999
+ return SHELL_COMMAND_STRING
1000
+ index += 1
1001
+ return args[index] if index < len(args) else SHELL_STDIN
1002
+
1003
+
962
1004
  def executed_scripts(tokens):
963
1005
  """The files a command line will RUN, and the executions it cannot follow.
964
1006
 
@@ -993,9 +1035,12 @@ def executed_scripts(tokens):
993
1035
  if name in SOURCE_BUILTINS:
994
1036
  operand = arguments[0] if arguments else None
995
1037
  elif name in SHELL_PROGRAMS:
996
- operand = shell_script_operand(arguments)
997
- if operand is None and not arguments:
1038
+ operand = shell_execution_operand(arguments)
1039
+ if operand is SHELL_STDIN:
998
1040
  previous_shell_awaiting_stdin = True
1041
+ continue
1042
+ if operand is SHELL_COMMAND_STRING:
1043
+ continue
999
1044
  else:
1000
1045
  continue
1001
1046
  if operand is None:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.34.15",
3
+ "version": "4.34.17",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -460,6 +460,50 @@ passed off as success.
460
460
  `blocked:unreviewed`. This fallback lives inside the gated section above —
461
461
  with `auto_merge=false` it never fires; the PR remains open awaiting a human.
462
462
 
463
+ ### The mergeability gate — never arm a PR that cannot merge
464
+
465
+ The arm gate above asks *did the review context do work?* This one asks a
466
+ different question — *is there a verdict standing in the way?* — and they are
467
+ not the same. A `CHANGES_REQUESTED` review unambiguously **did work**, so it
468
+ sails through the arm gate and the latch goes on over the top of it.
469
+
470
+ **Read `reviewDecision` explicitly. Never infer it from a check count.**
471
+
472
+ ```bash
473
+ gh pr view <pr> --json reviewDecision,reviewThreads \
474
+ --jq '{decision: .reviewDecision, unresolved: [.reviewThreads[]? | select(.isResolved == false)] | length}'
475
+ ```
476
+
477
+ `reviewDecision` is **not part of `statusCheckRollup`**. That is the whole reason
478
+ this stayed invisible: a failing-check count reads **zero** on a PR that can
479
+ never merge, and the checks tab is entirely green. Any rule keyed on "no red
480
+ checks" is satisfied by exactly the PR this gate exists to catch (#3720).
481
+
482
+ **Do not arm while `reviewDecision == CHANGES_REQUESTED`.** Arming is a claim
483
+ that this PR will merge, and that claim is false here. Clear the verdict through
484
+ step (e) first — which discriminates a live objection from a stranded one by
485
+ unresolved thread count, not by `reviewDecision` — and arm afterwards.
486
+
487
+ **Two ruleset settings decide whether a standing verdict ever clears itself, and
488
+ in this repository both say no.** Verify them for the repo you are in rather
489
+ than assuming, with `gh api repos/<owner>/<repo>/rulesets`:
490
+
491
+ - `dismiss_stale_reviews_on_push: false` — the review stays attached to the
492
+ commit it was made on, so **pushing the fix never clears it**. The intuition
493
+ that a new head resets the verdict is wrong here.
494
+ - `required_approving_review_count: 0` — no approval is required to merge, so a
495
+ standing `CHANGES_REQUESTED` is not holding a slot for a review that would
496
+ otherwise arrive. Nothing is scheduled to clear it.
497
+
498
+ Together those mean the block is **indefinite, not slow**. Waiting is not a
499
+ strategy; something has to act.
500
+
501
+ **Unresolved threads block independently of the verdict.** This repository sets
502
+ `required_review_thread_resolution: true`, so a PR can have a clear
503
+ `reviewDecision` and still be unmergeable on threads alone — a second blocker
504
+ that is equally absent from the check rollup. Read both, and name whichever one
505
+ is standing; reporting the wrong one sends the operator to the wrong place.
506
+
463
507
  ## 2. The watch loop
464
508
 
465
509
  Poll the live state each iteration:
@@ -1078,6 +1122,14 @@ Loop until one of:
1078
1122
  the one thing nobody would then do is investigate it. Usually a missing
1079
1123
  `issues: read` / label-read permission, which is fixable and invisible from
1080
1124
  the merge outcome alone.
1125
+ - **`blocked:armed-unmergeable`** — the PR is OPEN, auto-merge is ON, every check
1126
+ is green, and it still cannot merge: `reviewDecision == CHANGES_REQUESTED`, or
1127
+ unresolved review threads under `required_review_thread_resolution`. Report the
1128
+ PR URL, **which** of the two is standing, and that it will not clear itself.
1129
+ For example: *"PR #123 looks ready — every check is green and auto-merge is on
1130
+ — but a review asked for changes and that is still on record, so it will sit
1131
+ there forever. Someone needs to clear that review or resolve the open
1132
+ comments."* Never report this PR as merging.
1081
1133
  - **`CLOSED`** → report (PR was closed without merge).
1082
1134
  - **Hard block needing a human**: an unresolvable conflict, a failing check that
1083
1135
  needs design input, or genuine unresolved human objection (not a bot gate). Stop
@@ -1087,6 +1139,44 @@ Loop until one of:
1087
1139
  adversarial-review substitute is posted (step d); it never extends to any
1088
1140
  other gate.
1089
1141
 
1142
+ ### Before you stop: never leave a PR armed and unmergeable in silence
1143
+
1144
+ **This applies at every exit that leaves the PR OPEN** — not only the terminal
1145
+ states above, but any point where this run concludes it is finished, hands back,
1146
+ or gives up. Re-read, from the live PR, immediately before reporting:
1147
+
1148
+ ```bash
1149
+ gh pr view <pr> --json state,autoMergeRequest,reviewDecision,reviewThreads \
1150
+ --jq '{state, armed: (.autoMergeRequest != null), decision: .reviewDecision,
1151
+ unresolved: [.reviewThreads[]? | select(.isResolved == false)] | length}'
1152
+ ```
1153
+
1154
+ If `state == OPEN` and `armed` and either `decision == "CHANGES_REQUESTED"` or
1155
+ `unresolved > 0`, the outcome is **`blocked:armed-unmergeable`** — never a
1156
+ success, and never silence.
1157
+
1158
+ **Why this exists rather than trusting the loop.** While the loop runs this is
1159
+ already covered: the watch poll reads `reviewDecision` and step (e) handles it.
1160
+ The failure is a run that **stops** while the PR is armed and blocked — the
1161
+ session ends, or the agent decides the work is done because arming looked like
1162
+ completion. Measured (#3720): three PRs were left armed, green and permanently
1163
+ unmergeable, and every one was found only because a person queried
1164
+ `reviewDecision` by hand. An agent that arms auto-merge and reports "armed, will
1165
+ merge" is reporting something false and has no reason to know it.
1166
+
1167
+ **Arming is not completion.** It is a claim about the future that this check is
1168
+ what makes honest. The claim costs nothing when true — one read, and the report
1169
+ says merging — and when false it is the only thing standing between a parked PR
1170
+ and nobody noticing for hours.
1171
+
1172
+ **Read the two fields; do not substitute a check count.** Zero failing checks is
1173
+ the symptom, not the signal — that is precisely why this class was invisible.
1174
+
1175
+ In `on_blocker=report` mode the same condition returns the existing
1176
+ `blocked:changes_requested` classification rather than this terminal state: that
1177
+ mode returns a blocker for the caller to act on, and does not drive. The two
1178
+ names describe the same PR seen from the two modes.
1179
+
1090
1180
  At every terminal state, release the babysitter lease
1091
1181
  (`gh pr edit <pr> --remove-label "lisa:babysitter-on-duty"`) so the CI
1092
1182
  auto-fix workflow can take over as fixer of last resort if the branch goes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.34.15",
3
+ "version": "4.34.17",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.34.15",
3
+ "version": "4.34.17",
4
4
  "description": "AWS CDK-specific Lisa plugin.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.34.15",
3
+ "version": "4.34.17",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.34.15",
3
+ "version": "4.34.17",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.34.15",
3
+ "version": "4.34.17",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.34.15",
3
+ "version": "4.34.17",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1432,8 +1432,20 @@ EXEC_WRAPPERS = {
1432
1432
  }
1433
1433
 
1434
1434
  # Interpreter options that mean "no script file follows": a command string or a
1435
- # module name, both of which the guard reads by other means or not at all.
1436
- NO_SCRIPT_OPTIONS = {"-c", "-e", "-m", "--eval", "--command", "--module"}
1435
+ # module name, both of which the guard reads by other means or not at all. These
1436
+ # are interpreter-specific on purpose. `bash -e create.sh` and `sh -m create.sh`
1437
+ # still execute the following file; treating Python and Node flags as universal
1438
+ # silently stopped the scan before those shell operands.
1439
+ NO_SCRIPT_OPTIONS = {
1440
+ "bun": {"-e", "--eval"},
1441
+ "node": {"-e", "--eval"},
1442
+ "nodejs": {"-e", "--eval"},
1443
+ "perl": {"-e"},
1444
+ "php": {"-r"},
1445
+ "python": {"-c", "-m", "--command", "--module"},
1446
+ "python3": {"-c", "-m", "--command", "--module"},
1447
+ "ruby": {"-e"},
1448
+ }
1437
1449
 
1438
1450
  # Programs that READ their operands and never execute them as programs.
1439
1451
  #
@@ -1569,7 +1581,12 @@ def executed_operand(argv):
1569
1581
  if argument.startswith("-"):
1570
1582
  # A command string or a module name; no script file follows.
1571
1583
  head = argument.split("=", 1)[0]
1572
- if argument in NO_SCRIPT_OPTIONS or head in NO_SCRIPT_OPTIONS:
1584
+ if program in {"bash", "dash", "ksh", "sh", "zsh"}:
1585
+ # Shell `-c` may be clustered (`-ec`), while `-e` and `-m` are
1586
+ # ordinary shell options and do not consume the script path.
1587
+ if not head.startswith("--") and "c" in head[1:]:
1588
+ return None
1589
+ elif head in NO_SCRIPT_OPTIONS.get(program, set()):
1573
1590
  return None
1574
1591
  continue
1575
1592
  return argument
@@ -90,7 +90,7 @@
90
90
  # from mine", and guessing "behind" is how a fork's stronger guard gets silently
91
91
  # replaced by a weaker upstream one. Add a name here in the same commit that
92
92
  # closes a vector.
93
- # lisa-guard-capabilities: managed-path-resolution, lisaignore-precedence, generated-path-rebuild, redirect-target, tee-target, sed-in-place-all-spellings, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand
93
+ # lisa-guard-capabilities: managed-path-resolution, lisaignore-precedence, generated-path-rebuild, redirect-target, tee-target, sed-in-place-all-spellings, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, direct-script-command-reach, analyzer-failure-visible
94
94
  set -euo pipefail
95
95
 
96
96
  input="$(cat)"
@@ -607,7 +607,9 @@ def command_word(statement):
607
607
  statement: One statement's tokens.
608
608
 
609
609
  Returns:
610
- A pair (program, args); (None, []) when no program is named.
610
+ A triple (token, program, args); (None, None, []) when no program is
611
+ named. The raw token is retained because `./edit.sh` has no interpreter:
612
+ its command word is itself the executed script.
611
613
  """
612
614
  index = 0
613
615
  while index < len(statement):
@@ -617,7 +619,7 @@ def command_word(statement):
617
619
  continue
618
620
  program = token.rsplit("/", 1)[-1]
619
621
  if program not in FOLLOW_WRAPPERS:
620
- return (program, statement[index + 1 :])
622
+ return (token, program, statement[index + 1 :])
621
623
  separate, positional = FOLLOW_WRAPPERS[program]
622
624
  index += 1
623
625
  while index < len(statement) and statement[index].startswith("-"):
@@ -627,7 +629,7 @@ def command_word(statement):
627
629
  break
628
630
  index += 2 if option in separate else 1
629
631
  index += positional
630
- return (None, [])
632
+ return (None, None, [])
631
633
 
632
634
 
633
635
  def shell_script_operand(args):
@@ -674,7 +676,7 @@ def write_targets(statement):
674
676
  Candidate paths this statement writes.
675
677
  """
676
678
  found = []
677
- program, args = command_word(statement)
679
+ _token, program, args = command_word(statement)
678
680
  for index, token in enumerate(statement):
679
681
  if token in REDIRECT_OUT and index + 1 < len(statement):
680
682
  found.append(statement[index + 1])
@@ -697,7 +699,7 @@ def executed_script(statement):
697
699
  Returns:
698
700
  The operand token naming a script that will run, or None.
699
701
  """
700
- program, args = command_word(statement)
702
+ token, program, args = command_word(statement)
701
703
  if program is None:
702
704
  return None
703
705
  if program in SHELL_PROGRAMS:
@@ -708,6 +710,12 @@ def executed_script(statement):
708
710
  return shell_script_operand(args)
709
711
  if program in SOURCE_BUILTINS:
710
712
  return args[0] if args else None
713
+ # A script in command position executes through its shebang. This also
714
+ # covers a direct path after wrappers such as `sudo ./edit.sh`; `command_word`
715
+ # deliberately keeps the raw token while using its basename for wrapper and
716
+ # shell classification.
717
+ if resolve(token) is not None:
718
+ return token
711
719
  return None
712
720
 
713
721
 
@@ -798,10 +806,27 @@ for target in targets:
798
806
  if cleaned:
799
807
  print(cleaned)
800
808
  PY
801
- write_targets="$(printf '%s' "$analyzer" |
809
+ analyzer_stderr="$(mktemp "${TMPDIR:-/tmp}/lisa-managed-edit-analyzer.XXXXXX")" || {
810
+ printf 'block-managed-file-edits: could not allocate analyzer stderr capture; Bash write protection is NOT active\n' >&2
811
+ exit 0
812
+ }
813
+ if write_targets="$(printf '%s' "$analyzer" |
802
814
  MANAGED_EDIT_COMMAND="$command_str" \
803
815
  MANAGED_EDIT_PROJECT="$project_root" \
804
- python3 - 2>/dev/null || true)"
816
+ python3 - 2>"$analyzer_stderr")"; then
817
+ analyzer_status=0
818
+ else
819
+ analyzer_status=$?
820
+ fi
821
+ if [ "$analyzer_status" -ne 0 ]; then
822
+ printf 'block-managed-file-edits: Bash analyzer failed (exit %s); Bash write protection is NOT active\n' "$analyzer_status" >&2
823
+ while IFS= read -r analyzer_line; do
824
+ [ -n "$analyzer_line" ] && printf ' python3: %s\n' "$analyzer_line" >&2
825
+ done <"$analyzer_stderr"
826
+ rm -f "$analyzer_stderr"
827
+ exit 0
828
+ fi
829
+ rm -f "$analyzer_stderr"
805
830
  while IFS= read -r token; do
806
831
  [ -n "$token" ] || continue
807
832
  if source_path="$(managed_source "$token")"; then
@@ -33,7 +33,7 @@
33
33
  # Add a name here in the same commit that closes a vector. A hardening that
34
34
  # forgets to is invisible to refresh, and shows up as an unexplained diff at
35
35
  # review time instead of a named capability.
36
- # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, dispatcher-exec-position
36
+ # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, dispatcher-exec-position, tilde-script-reach, shell-option-stdin-reach
37
37
  #
38
38
  # Shell-token matching avoids false positives from issue bodies, heredocs, and
39
39
  # commit-message prose while still catching quoted real argv values such as
@@ -1007,7 +1007,7 @@ def resolve_script(token):
1007
1007
  """
1008
1008
  if COMPUTED_VALUE.search(token):
1009
1009
  return (None, "a computed path the guard cannot resolve before the shell does")
1010
- text = token.strip().strip("'\"")
1010
+ text = os.path.expanduser(token.strip().strip("'\""))
1011
1011
  # `bash -` and a bare `-` read the script from stdin; there is no file.
1012
1012
  if not text or text == "-":
1013
1013
  return (None, None)
@@ -1029,6 +1029,48 @@ def resolve_script(token):
1029
1029
  return (None, "a script that does not exist or cannot be read")
1030
1030
 
1031
1031
 
1032
+ SHELL_COMMAND_STRING = object()
1033
+ SHELL_STDIN = object()
1034
+
1035
+
1036
+ def shell_execution_operand(args):
1037
+ """The file a shell runs, or a sentinel for `-c` and stdin modes.
1038
+
1039
+ `shell_script_operand` intentionally collapses both cases to None for the
1040
+ nested-command scanner. Reach cannot: `bash -c '...'` has already exposed
1041
+ its text, while `bash -x`, `bash -e`, and `bash --` still await a script on
1042
+ stdin and must remain linked to a following `< file` separator.
1043
+ """
1044
+ index = 0
1045
+ opaque_option_seen = False
1046
+ while index < len(args):
1047
+ token = args[index]
1048
+ if token == "--":
1049
+ index += 1
1050
+ break
1051
+ if not token.startswith(("-", "+")):
1052
+ if not opaque_option_seen:
1053
+ break
1054
+ index += 1
1055
+ opaque_option_seen = False
1056
+ continue
1057
+ if token in SHELL_OPTIONS_SEPARATE_VALUE:
1058
+ index += 2
1059
+ continue
1060
+ if token.startswith(SHELL_OPTIONS_INLINE_VALUE):
1061
+ index += 1
1062
+ continue
1063
+ if token.startswith("--"):
1064
+ if token not in SHELL_LONG_OPTIONS_NO_VALUE:
1065
+ opaque_option_seen = True
1066
+ index += 1
1067
+ continue
1068
+ if "c" in token[1:]:
1069
+ return SHELL_COMMAND_STRING
1070
+ index += 1
1071
+ return args[index] if index < len(args) else SHELL_STDIN
1072
+
1073
+
1032
1074
  def executed_scripts(tokens):
1033
1075
  """The files a command line will RUN, and the executions it cannot follow.
1034
1076
 
@@ -1063,9 +1105,12 @@ def executed_scripts(tokens):
1063
1105
  if name in SOURCE_BUILTINS:
1064
1106
  operand = arguments[0] if arguments else None
1065
1107
  elif name in SHELL_PROGRAMS:
1066
- operand = shell_script_operand(arguments)
1067
- if operand is None and not arguments:
1108
+ operand = shell_execution_operand(arguments)
1109
+ if operand is SHELL_STDIN:
1068
1110
  previous_shell_awaiting_stdin = True
1111
+ continue
1112
+ if operand is SHELL_COMMAND_STRING:
1113
+ continue
1069
1114
  else:
1070
1115
  continue
1071
1116
  if operand is None: