@codyswann/lisa 4.34.14 → 4.34.16

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 (94) hide show
  1. package/all/copy-overwrite/scripts/check-npm-publish-landed.mjs +250 -0
  2. package/all/copy-overwrite/scripts/lib/gate-failure-diagnosis.mjs +3 -5
  3. package/all/copy-overwrite/scripts/lisa-hooks/block-direct-issue-create.sh +20 -3
  4. package/all/copy-overwrite/scripts/lisa-hooks/block-managed-file-edits.sh +33 -8
  5. package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +80 -5
  6. package/all/copy-overwrite/scripts/lisa-work-item.mjs +1 -1
  7. package/dist/codex/scripts/block-no-verify.sh +80 -5
  8. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  9. package/dist/core/lisa-owned-hash-ledger.js +28 -0
  10. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  11. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  12. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  13. package/dist/core/upstream-evidence-manifest.js +24 -15
  14. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  15. package/dist/opencode/plugin-templates/block-no-verify.sh +80 -5
  16. package/expo/copy-overwrite/scripts/check-lighthouse-details.mjs +13 -7
  17. package/package.json +5 -5
  18. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  20. package/plugins/lisa/.codex-plugin/skills/lisa-drive-pr-to-merge/SKILL.md +90 -0
  21. package/plugins/lisa/hooks/block-direct-issue-create.sh +20 -3
  22. package/plugins/lisa/hooks/block-managed-file-edits.sh +33 -8
  23. package/plugins/lisa/hooks/block-no-verify.agy.sh +80 -5
  24. package/plugins/lisa/hooks/block-no-verify.sh +80 -5
  25. package/plugins/lisa/skills/lisa-drive-pr-to-merge/SKILL.md +90 -0
  26. package/plugins/lisa-agy/hooks/block-direct-issue-create.sh +20 -3
  27. package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +80 -5
  28. package/plugins/lisa-agy/plugin.json +1 -1
  29. package/plugins/lisa-agy/skills/lisa-drive-pr-to-merge/SKILL.md +90 -0
  30. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  32. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  33. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-copilot/hooks/block-direct-issue-create.sh +20 -3
  37. package/plugins/lisa-copilot/hooks/block-managed-file-edits.sh +33 -8
  38. package/plugins/lisa-copilot/hooks/block-no-verify.sh +80 -5
  39. package/plugins/lisa-copilot/skills/lisa-drive-pr-to-merge/SKILL.md +90 -0
  40. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-cursor/hooks/block-direct-issue-create.sh +20 -3
  42. package/plugins/lisa-cursor/hooks/block-managed-file-edits.sh +33 -8
  43. package/plugins/lisa-cursor/hooks/block-no-verify.sh +80 -5
  44. package/plugins/lisa-cursor/skills/lisa-drive-pr-to-merge/SKILL.md +90 -0
  45. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  47. package/plugins/lisa-expo-agy/plugin.json +1 -1
  48. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  53. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  58. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  62. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  63. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  67. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  68. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  72. package/plugins/lisa-rails-agy/plugin.json +1 -1
  73. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  77. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  78. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  82. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  83. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  85. package/plugins/src/base/hooks/block-direct-issue-create.sh +20 -3
  86. package/plugins/src/base/hooks/block-managed-file-edits.sh +33 -8
  87. package/plugins/src/base/hooks/block-no-verify.agy.sh +80 -5
  88. package/plugins/src/base/hooks/block-no-verify.sh +80 -5
  89. package/plugins/src/base/skills/lisa-drive-pr-to-merge/SKILL.md +90 -0
  90. package/scripts/check-npm-publish-landed.mjs +30 -0
  91. package/scripts/two-channel-couplings.json +17 -4
  92. package/typescript/copy-overwrite/.claude/hooks/worktree-create.sh +3 -1
  93. package/typescript/copy-overwrite/.prettierignore +1 -0
  94. package/typescript/copy-overwrite/scripts/lisa-mutation.mjs +14 -54
@@ -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() {
@@ -195,8 +195,38 @@ ENV_LONG_SEPARATE_VALUE = {"--argv0", "--chdir", "--split-string", "--unset"}
195
195
 
196
196
  # env's single-letter options, by whether the rest of a cluster is more
197
197
  # options or the option's value.
198
+ #
199
+ # BOTH PLATFORMS' SETS, not one. These began as GNU's, and an option that is
200
+ # in neither set falls through to `ambiguous`, which this guard treats as
201
+ # suspicious. That default is correct and stays — an unknown option genuinely
202
+ # could take a value and swallow the command name. What is wrong is a
203
+ # well-known option being unknown, and on a macOS fleet `env -P` is exactly
204
+ # that: BSD's "search altpath for the utility", refused on every ordinary
205
+ # command it prefixes.
206
+ #
207
+ # Measured, one option at a time, against the guard before this change. BSD's
208
+ # complete short set is `0 i v u C P S`, from the synopsis in env(1):
209
+ #
210
+ # allowed -0 -i -v -u NAME -C DIR already covered
211
+ # BLOCKED -P altpath the defect, and the ONLY one
212
+ # BLOCKED -S string correct: split-string reparses
213
+ # an opaque payload, so what it
214
+ # runs cannot be proven here
215
+ # BLOCKED -Z -Q correct: unknown stays suspicious
216
+ #
217
+ # So the population is one letter, not a class. The two platforms' short
218
+ # options coincide everywhere else, which is why "detect the platform and
219
+ # choose a table" would be machinery for a single character — and would fail
220
+ # on a machine running GNU coreutils under a BSD userland, or the reverse.
221
+ # Recognising the union is both smaller and more robust.
222
+ #
223
+ # Adding a letter here is a WEAKENING, so it needs the same evidence as any
224
+ # other: ten bypass shapes routed through `-P` — long flag, attached value,
225
+ # short cluster, abbreviation, an assignment, `HUSKY=0`, a hooksPath disable,
226
+ # a nested `-c` shell, an executed script, and `-P` swallowing the command
227
+ # word — were all refused before this change and are all still refused after.
198
228
  ENV_SHORT_NO_VALUE = frozenset("i0v")
199
- ENV_SHORT_TAKES_VALUE = frozenset("uCaS")
229
+ ENV_SHORT_TAKES_VALUE = frozenset("uCaSP")
200
230
 
201
231
  # Builtins and wrappers that run whatever FOLLOWS them without changing what
202
232
  # it is: `command env -S ...` runs exactly the `env` that `env -S ...` runs,
@@ -907,7 +937,7 @@ def resolve_script(token):
907
937
  """
908
938
  if COMPUTED_VALUE.search(token):
909
939
  return (None, "a computed path the guard cannot resolve before the shell does")
910
- text = token.strip().strip("'\"")
940
+ text = os.path.expanduser(token.strip().strip("'\""))
911
941
  # `bash -` and a bare `-` read the script from stdin; there is no file.
912
942
  if not text or text == "-":
913
943
  return (None, None)
@@ -929,6 +959,48 @@ def resolve_script(token):
929
959
  return (None, "a script that does not exist or cannot be read")
930
960
 
931
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
+
932
1004
  def executed_scripts(tokens):
933
1005
  """The files a command line will RUN, and the executions it cannot follow.
934
1006
 
@@ -963,9 +1035,12 @@ def executed_scripts(tokens):
963
1035
  if name in SOURCE_BUILTINS:
964
1036
  operand = arguments[0] if arguments else None
965
1037
  elif name in SHELL_PROGRAMS:
966
- operand = shell_script_operand(arguments)
967
- if operand is None and not arguments:
1038
+ operand = shell_execution_operand(arguments)
1039
+ if operand is SHELL_STDIN:
968
1040
  previous_shell_awaiting_stdin = True
1041
+ continue
1042
+ if operand is SHELL_COMMAND_STRING:
1043
+ continue
969
1044
  else:
970
1045
  continue
971
1046
  if operand is None:
@@ -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
@@ -265,8 +265,38 @@ ENV_LONG_SEPARATE_VALUE = {"--argv0", "--chdir", "--split-string", "--unset"}
265
265
 
266
266
  # env's single-letter options, by whether the rest of a cluster is more
267
267
  # options or the option's value.
268
+ #
269
+ # BOTH PLATFORMS' SETS, not one. These began as GNU's, and an option that is
270
+ # in neither set falls through to `ambiguous`, which this guard treats as
271
+ # suspicious. That default is correct and stays — an unknown option genuinely
272
+ # could take a value and swallow the command name. What is wrong is a
273
+ # well-known option being unknown, and on a macOS fleet `env -P` is exactly
274
+ # that: BSD's "search altpath for the utility", refused on every ordinary
275
+ # command it prefixes.
276
+ #
277
+ # Measured, one option at a time, against the guard before this change. BSD's
278
+ # complete short set is `0 i v u C P S`, from the synopsis in env(1):
279
+ #
280
+ # allowed -0 -i -v -u NAME -C DIR already covered
281
+ # BLOCKED -P altpath the defect, and the ONLY one
282
+ # BLOCKED -S string correct: split-string reparses
283
+ # an opaque payload, so what it
284
+ # runs cannot be proven here
285
+ # BLOCKED -Z -Q correct: unknown stays suspicious
286
+ #
287
+ # So the population is one letter, not a class. The two platforms' short
288
+ # options coincide everywhere else, which is why "detect the platform and
289
+ # choose a table" would be machinery for a single character — and would fail
290
+ # on a machine running GNU coreutils under a BSD userland, or the reverse.
291
+ # Recognising the union is both smaller and more robust.
292
+ #
293
+ # Adding a letter here is a WEAKENING, so it needs the same evidence as any
294
+ # other: ten bypass shapes routed through `-P` — long flag, attached value,
295
+ # short cluster, abbreviation, an assignment, `HUSKY=0`, a hooksPath disable,
296
+ # a nested `-c` shell, an executed script, and `-P` swallowing the command
297
+ # word — were all refused before this change and are all still refused after.
268
298
  ENV_SHORT_NO_VALUE = frozenset("i0v")
269
- ENV_SHORT_TAKES_VALUE = frozenset("uCaS")
299
+ ENV_SHORT_TAKES_VALUE = frozenset("uCaSP")
270
300
 
271
301
  # Builtins and wrappers that run whatever FOLLOWS them without changing what
272
302
  # it is: `command env -S ...` runs exactly the `env` that `env -S ...` runs,
@@ -977,7 +1007,7 @@ def resolve_script(token):
977
1007
  """
978
1008
  if COMPUTED_VALUE.search(token):
979
1009
  return (None, "a computed path the guard cannot resolve before the shell does")
980
- text = token.strip().strip("'\"")
1010
+ text = os.path.expanduser(token.strip().strip("'\""))
981
1011
  # `bash -` and a bare `-` read the script from stdin; there is no file.
982
1012
  if not text or text == "-":
983
1013
  return (None, None)
@@ -999,6 +1029,48 @@ def resolve_script(token):
999
1029
  return (None, "a script that does not exist or cannot be read")
1000
1030
 
1001
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
+
1002
1074
  def executed_scripts(tokens):
1003
1075
  """The files a command line will RUN, and the executions it cannot follow.
1004
1076
 
@@ -1033,9 +1105,12 @@ def executed_scripts(tokens):
1033
1105
  if name in SOURCE_BUILTINS:
1034
1106
  operand = arguments[0] if arguments else None
1035
1107
  elif name in SHELL_PROGRAMS:
1036
- operand = shell_script_operand(arguments)
1037
- if operand is None and not arguments:
1108
+ operand = shell_execution_operand(arguments)
1109
+ if operand is SHELL_STDIN:
1038
1110
  previous_shell_awaiting_stdin = True
1111
+ continue
1112
+ if operand is SHELL_COMMAND_STRING:
1113
+ continue
1039
1114
  else:
1040
1115
  continue
1041
1116
  if operand is None:
@@ -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
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Lisa's own entry point for the shipped npm-publish verification.
4
+ *
5
+ * `publish-to-npm.yml` runs `node scripts/check-npm-publish-landed.mjs`, and
6
+ * that path is the CALLER's tree: in a host project `lisa apply` writes it from
7
+ * `all/copy-overwrite/scripts/`, and in this repository it is this file. So the
8
+ * step needs the path to exist here too, or Lisa's own release is the one
9
+ * release the check never runs on.
10
+ *
11
+ * A re-export rather than a second copy. The sibling in the same workflow,
12
+ * `check-release-package-identity.mjs`, is duplicated byte-for-byte across both
13
+ * channels, and duplicated checks are exactly what drifts unnoticed — the whole
14
+ * `hook-copy-parity` subsystem exists because two copies of one check cannot be
15
+ * kept in step by intention. There is one implementation, and both paths reach
16
+ * it.
17
+ * @module scripts/check-npm-publish-landed
18
+ */
19
+ import process from "node:process";
20
+
21
+ import { main } from "../all/copy-overwrite/scripts/check-npm-publish-landed.mjs";
22
+
23
+ main(process.argv.slice(2))
24
+ .then(code => {
25
+ process.exitCode = code;
26
+ })
27
+ .catch(error => {
28
+ process.stderr.write(`❌ ${error.message}\n`);
29
+ process.exitCode = 1;
30
+ });
@@ -5,13 +5,13 @@
5
5
  },
6
6
  "inspected": {
7
7
  "workflows": 22,
8
- "steps": 715,
9
- "couplings": 33,
10
- "inventory": 70
8
+ "steps": 716,
9
+ "couplings": 34,
10
+ "inventory": 71
11
11
  },
12
12
  "counts": {
13
13
  "package-backed": 10,
14
- "apply-lagged": 21,
14
+ "apply-lagged": 22,
15
15
  "never-delivered": 0,
16
16
  "undelivered": 2
17
17
  },
@@ -133,6 +133,19 @@
133
133
  ],
134
134
  "detail": "`nightly-e2e-tracking.yml` is delivered at `@main`, so this step is live in every consumer on their next run. It reads `scripts/reconcile-nightly-e2e-tracking.mjs` from the CALLER's tree with no package-relative candidate, and Lisa delivers that path on the `lisa apply` channel (typescript/copy-overwrite). The two halves land in the wrong order: this step is live everywhere immediately, the file it needs is live only where somebody applied. The read is unguarded, so an absent path fails the job loudly. Visible, which makes it the better of the two failures."
135
135
  },
136
+ {
137
+ "key": "publish-to-npm.yml::scripts/check-npm-publish-landed.mjs",
138
+ "workflow": "publish-to-npm.yml",
139
+ "path": "scripts/check-npm-publish-landed.mjs",
140
+ "channel": "apply",
141
+ "verdict": "apply-lagged",
142
+ "remedy": "run-lisa-apply",
143
+ "guarded": false,
144
+ "lanes": [
145
+ "all/copy-overwrite"
146
+ ],
147
+ "detail": "`publish-to-npm.yml` is delivered at `@main`, so this step is live in every consumer on their next run. It reads `scripts/check-npm-publish-landed.mjs` from the CALLER's tree with no package-relative candidate, and Lisa delivers that path on the `lisa apply` channel (all/copy-overwrite). The two halves land in the wrong order: this step is live everywhere immediately, the file it needs is live only where somebody applied. The read is unguarded, so an absent path fails the job loudly. Visible, which makes it the better of the two failures."
148
+ },
136
149
  {
137
150
  "key": "publish-to-npm.yml::scripts/check-release-package-identity.mjs",
138
151
  "workflow": "publish-to-npm.yml",
@@ -199,12 +199,14 @@ fi
199
199
  if git -C "$_root" show-ref --verify --quiet "refs/heads/$_wt_branch" 2>/dev/null; then
200
200
  _reused=1
201
201
  _add_err="$(git -C "$_root" worktree add "$_path" "$_wt_branch" 2>&1 >/dev/null)"
202
+ _add_status=$?
202
203
  else
203
204
  _reused=0
204
205
  _add_err="$(git -C "$_root" worktree add --no-track -b "$_wt_branch" "$_path" "$_base_ref" 2>&1 >/dev/null)"
206
+ _add_status=$?
205
207
  fi
206
208
 
207
- if [ ! -d "$_path" ]; then
209
+ if [ "$_add_status" -ne 0 ] || [ ! -d "$_path" ]; then
208
210
  _log "WorktreeCreate: git worktree add failed for $_path."
209
211
  [ -n "$_add_err" ] && _log " git: $_add_err"
210
212
  exit 1
@@ -69,6 +69,7 @@ scripts/check-orphaned-branches.mjs
69
69
  scripts/check-e2e-coverage.mjs
70
70
  scripts/check-lighthouse-details.mjs
71
71
  scripts/check-nightly-e2e-health.mjs
72
+ scripts/check-npm-publish-landed.mjs
72
73
  scripts/check-release-package-identity.mjs
73
74
  scripts/check-skipped-required-checks.mjs
74
75
  scripts/check-state-classification.mjs
@@ -197,46 +197,6 @@ export const OUTCOMES = Object.freeze({
197
197
  noFloorApplied: "mutation-gate: no-floor-applied",
198
198
  });
199
199
 
200
- /**
201
- * The outcomes under which Stryker generated mutants and produced a score.
202
- *
203
- * THE POINT OF #3668. Every other outcome in {@link OUTCOMES} ends a run that
204
- * MEASURED NOTHING, and until this set existed the exit-0 members of that group
205
- * were indistinguishable from a real pass at the only layer a merge decision
206
- * consults: `🔍 Quality Checks / 🧬 Mutation Testing Gate` printed `pass` for a
207
- * pull request no mutant had touched. Measured on #3664 — a 400-line change to
208
- * a shipped guard script, `nothing-to-mutate`, `pass`.
209
- *
210
- * The file said so itself, above {@link OUTCOMES}, and had for months: "both
211
- * exit 0, and only the marker says which one happened." The marker lives in a
212
- * log; a log is not a control. This set is what lets the workflow render the
213
- * difference.
214
- *
215
- * MEMBERSHIP IS THE STRYKER RUN, not the verdict. `scoped` and `wholeList` are
216
- * the two outcomes that carry a `reportRun`, so they are the two under which
217
- * mutants existed — whether the score then cleared the floor or not is a
218
- * different question, answered by the exit code. A run that measured and FAILED
219
- * is still a run that measured.
220
- */
221
- export const MEASURED_OUTCOMES = Object.freeze([
222
- OUTCOMES.scoped,
223
- OUTCOMES.wholeList,
224
- ]);
225
-
226
- /**
227
- * Whether an outcome means mutants were generated against this change.
228
- *
229
- * An unknown marker answers `false`, and that direction is deliberate: a new
230
- * outcome added without being classified must not inherit "measured". Failing
231
- * closed here costs a `skipping` row on a run that did measure; failing open
232
- * costs exactly the false green this issue is about.
233
- *
234
- * @param {string} outcome - One {@link OUTCOMES} marker
235
- * @returns {boolean} True when Stryker produced mutants
236
- */
237
- export const measuredAnything = outcome =>
238
- MEASURED_OUTCOMES.includes(String(outcome));
239
-
240
200
  /**
241
201
  * Publishes the outcome where the workflow can render it, then returns the code.
242
202
  *
@@ -259,16 +219,17 @@ export const measuredAnything = outcome =>
259
219
  *
260
220
  * @param {string} outcome - The {@link OUTCOMES} marker this run ended on
261
221
  * @param {number} code - The exit code to return unchanged
222
+ * @param {boolean} [measured] - Whether this concrete run produced a score
262
223
  * @param {NodeJS.ProcessEnv} [env] - Environment, injectable for tests
263
224
  * @returns {number} `code`, unchanged
264
225
  */
265
- export const finish = (outcome, code, env = process.env) => {
226
+ export const finish = (outcome, code, measured = false, env = process.env) => {
266
227
  const target = env.GITHUB_OUTPUT;
267
228
  if (target === undefined || target === "") return code;
268
229
  try {
269
230
  fs.appendFileSync(
270
231
  target,
271
- `mutation_outcome=${outcome}\nmutation_measured=${measuredAnything(outcome)}\n`
232
+ `mutation_outcome=${outcome}\nmutation_measured=${measured === true}\n`
272
233
  );
273
234
  } catch (error) {
274
235
  console.error(
@@ -2107,10 +2068,10 @@ export const WHOLE_LIST_FLAG = "--all";
2107
2068
  * measured, printed, and — when it is what carried the run over the floor —
2108
2069
  * failed on. See {@link timeoutAccounting}.
2109
2070
  * @param {string} cwd - Project root.
2110
- * @param {{code: number, output: string|null}} result - From `runStryker`.
2111
- * @returns {number} The exit code the caller should use.
2071
+ * @param {{code: number, output: string|null, killedBy?: string}} result - From `runStryker`.
2072
+ * @returns {{code: number, measured: boolean}} The exit code and whether a score was produced.
2112
2073
  */
2113
- const reportRun = (cwd, result) => {
2074
+ export const reportRun = (cwd, result) => {
2114
2075
  const accounting = accountForTimeouts(result.output, cwd);
2115
2076
  if (result.killedBy === CHILD_DEADLINE) {
2116
2077
  // A gate that ran and failed measured something; a gate that was KILLED
@@ -2119,7 +2080,7 @@ const reportRun = (cwd, result) => {
2119
2080
  // arm comes before the classification below because that reads Stryker's
2120
2081
  // transcript, and a killed run's transcript stops wherever the kill landed.
2121
2082
  console.error(childDeadlineBlock(resolveChildDeadline()));
2122
- return result.code;
2083
+ return { code: result.code, measured: false };
2123
2084
  }
2124
2085
  if (result.code !== 0) {
2125
2086
  // Stryker's own verdict stands; what is added is WHICH failure it was. The
@@ -2135,14 +2096,14 @@ const reportRun = (cwd, result) => {
2135
2096
  // that DID produce one still gets the honest recomputation: a run under the
2136
2097
  // floor is under it by more than Stryker said.
2137
2098
  if (accounting.measured) console.error(accounting.message);
2138
- return result.code;
2099
+ return { code: result.code, measured: accounting.measured };
2139
2100
  }
2140
2101
  if (accounting.failed) {
2141
2102
  console.error(accounting.message);
2142
- return 1;
2103
+ return { code: 1, measured: accounting.measured };
2143
2104
  }
2144
2105
  console.log(accounting.message);
2145
- return 0;
2106
+ return { code: 0, measured: accounting.measured };
2146
2107
  };
2147
2108
 
2148
2109
  /**
@@ -2216,7 +2177,8 @@ export const runGate = (cwd = process.cwd(), argv = []) => {
2216
2177
  `🧬 ${OUTCOMES.wholeList} — Stryker over every pattern in ` +
2217
2178
  `${declaration.source}, with no diff scoping.`
2218
2179
  );
2219
- return finish(OUTCOMES.wholeList, reportRun(cwd, runStryker(cwd, [])));
2180
+ const reported = reportRun(cwd, runStryker(cwd, []));
2181
+ return finish(OUTCOMES.wholeList, reported.code, reported.measured);
2220
2182
  }
2221
2183
 
2222
2184
  const base = resolveDiffBase(cwd, since);
@@ -2324,10 +2286,8 @@ export const runGate = (cwd = process.cwd(), argv = []) => {
2324
2286
  );
2325
2287
  for (const file of scope.selected) console.log(` • ${file}`);
2326
2288
 
2327
- return finish(
2328
- OUTCOMES.scoped,
2329
- reportRun(cwd, runStryker(cwd, scope.selected))
2330
- );
2289
+ const reported = reportRun(cwd, runStryker(cwd, scope.selected));
2290
+ return finish(OUTCOMES.scoped, reported.code, reported.measured);
2331
2291
  };
2332
2292
 
2333
2293
  /**