@codyswann/lisa 4.71.5 → 4.71.7

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/lisa-hooks/block-direct-issue-create.sh +20 -2
  2. package/all/copy-overwrite/scripts/lisa-hooks/block-managed-file-edits.sh +71 -10
  3. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  4. package/dist/core/lisa-owned-hash-ledger.js +10 -0
  5. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  6. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  7. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  8. package/dist/core/upstream-evidence-manifest.js +12 -8
  9. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  10. package/dist/opencode/plugin-templates/block-managed-file-edits.sh +71 -10
  11. package/dist/opencode/plugin-templates/lisa-block-direct-issue-create.ts +8 -2
  12. package/package.json +4 -4
  13. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  15. package/plugins/lisa/.codex-plugin/skills/lisa-drive-pr-to-merge/SKILL.md +89 -5
  16. package/plugins/lisa/.codex-plugin/skills/lisa-queue-status/SKILL.md +43 -3
  17. package/plugins/lisa/hooks/block-direct-issue-create.sh +20 -2
  18. package/plugins/lisa/hooks/block-managed-file-edits.sh +71 -10
  19. package/plugins/lisa/skills/lisa-drive-pr-to-merge/SKILL.md +89 -5
  20. package/plugins/lisa/skills/lisa-queue-status/SKILL.md +43 -3
  21. package/plugins/lisa-agy/hooks/block-direct-issue-create.sh +20 -2
  22. package/plugins/lisa-agy/hooks/block-managed-file-edits.sh +71 -10
  23. package/plugins/lisa-agy/plugin.json +1 -1
  24. package/plugins/lisa-agy/skills/lisa-drive-pr-to-merge/SKILL.md +89 -5
  25. package/plugins/lisa-agy/skills/lisa-queue-status/SKILL.md +43 -3
  26. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  28. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  29. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-copilot/hooks/block-direct-issue-create.sh +20 -2
  33. package/plugins/lisa-copilot/hooks/block-managed-file-edits.sh +71 -10
  34. package/plugins/lisa-copilot/skills/lisa-drive-pr-to-merge/SKILL.md +89 -5
  35. package/plugins/lisa-copilot/skills/lisa-queue-status/SKILL.md +43 -3
  36. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-cursor/hooks/block-direct-issue-create.sh +20 -2
  38. package/plugins/lisa-cursor/hooks/block-managed-file-edits.sh +71 -10
  39. package/plugins/lisa-cursor/skills/lisa-drive-pr-to-merge/SKILL.md +89 -5
  40. package/plugins/lisa-cursor/skills/lisa-queue-status/SKILL.md +43 -3
  41. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  43. package/plugins/lisa-expo/.codex-plugin/skills/expo-router-best-practices/references/static-export-verification.md +1 -1
  44. package/plugins/lisa-expo/skills/expo-router-best-practices/references/static-export-verification.md +1 -1
  45. package/plugins/lisa-expo-agy/plugin.json +1 -1
  46. package/plugins/lisa-expo-agy/skills/expo-router-best-practices/references/static-export-verification.md +1 -1
  47. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-expo-copilot/skills/expo-router-best-practices/references/static-export-verification.md +1 -1
  49. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-expo-cursor/skills/expo-router-best-practices/references/static-export-verification.md +1 -1
  51. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  53. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  54. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  58. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  59. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  63. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  64. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  68. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  69. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  73. package/plugins/lisa-rails-agy/plugin.json +1 -1
  74. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  78. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  79. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  83. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  84. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  86. package/plugins/src/base/hooks/block-direct-issue-create.sh +20 -2
  87. package/plugins/src/base/hooks/block-managed-file-edits.sh +71 -10
  88. package/plugins/src/base/skills/lisa-drive-pr-to-merge/SKILL.md +89 -5
  89. package/plugins/src/base/skills/lisa-queue-status/SKILL.md +43 -3
  90. package/plugins/src/expo/skills/expo-router-best-practices/references/static-export-verification.md +1 -1
  91. package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +22 -3
@@ -598,9 +598,25 @@ BACKSLASH = chr(92)
598
598
  NEWLINE = chr(10)
599
599
 
600
600
  STATEMENT_SEPARATORS = {";", "|", "||", "&", "&&", "(", ")", "&|"}
601
- # `>`, `>>`, and the noclobber override `>|`.
602
- REDIRECT_OUT = {">", ">>", ">|"}
601
+ # Ordinary output, noclobber override, and combined stdout/stderr output.
602
+ # shlex separates fd prefixes (e.g. 10>>) from the operator automatically.
603
+ REDIRECT_OUT = {">", ">>", ">|", "&>", "&>>", ">&"}
603
604
  REDIRECT_IN = "<"
605
+ SYNTAX_OPERATORS = STATEMENT_SEPARATORS | REDIRECT_OUT | {REDIRECT_IN}
606
+ # shlex groups adjacent punctuation even across distinct shell operators.
607
+ # Retain other shell operators intact so a here-string is not three `<` reads.
608
+ PUNCTUATION = re.compile(r"^[();<>|&]+$")
609
+ SHELL_OPERATOR = re.compile("|".join(
610
+ re.escape(operator) for operator in sorted(
611
+ SYNTAX_OPERATORS | {"<<<", "<<", "<&", "<>", ";;&", ";;", ";&", "|&"},
612
+ key=lambda operator: (-len(operator), operator),
613
+ )
614
+ ))
615
+
616
+
617
+ class LiteralOperator(str):
618
+ """Quoted or escaped operator text is an argument, never shell syntax."""
619
+
604
620
 
605
621
  SHELL_PROGRAMS = {"bash", "dash", "ksh", "sh", "zsh"}
606
622
  SOURCE_BUILTINS = {"source", "."}
@@ -664,8 +680,30 @@ def normalise_lines(text):
664
680
  Returns:
665
681
  The same text with line breaks spelled as separators.
666
682
  """
667
- joined = text.replace(BACKSLASH + NEWLINE, " ")
668
- return joined.replace(NEWLINE, " ; ")
683
+ output = []
684
+ quote = ""
685
+ position = 0
686
+ while position < len(text):
687
+ character = text[position]
688
+ if character == BACKSLASH and quote != "'" and position + 1 < len(text):
689
+ following = text[position + 1]
690
+ # The shell deletes continuations; inserting whitespace can split
691
+ # an operator or pathname and hide the actual output target.
692
+ if following != NEWLINE:
693
+ output.extend((character, following))
694
+ position += 2
695
+ continue
696
+ if quote:
697
+ if character == quote:
698
+ quote = ""
699
+ elif character in "'\"":
700
+ quote = character
701
+ if character == NEWLINE and not quote:
702
+ output.append(" ; ")
703
+ else:
704
+ output.append(character)
705
+ position += 1
706
+ return "".join(output)
669
707
 
670
708
 
671
709
  def tokenize(text):
@@ -677,13 +715,30 @@ def tokenize(text):
677
715
  Returns:
678
716
  The token list, or None when the text does not lex.
679
717
  """
680
- lexer = shlex.shlex(normalise_lines(text), posix=True, punctuation_chars=True)
718
+ normalized = normalise_lines(text)
719
+ lexer = shlex.shlex(normalized, posix=True, punctuation_chars=True)
681
720
  lexer.whitespace_split = True
682
721
  # `#` is not a comment introducer on a typed command line, and letting shlex
683
722
  # treat it as one silently truncates the rest of the line.
684
723
  lexer.commenters = ""
685
724
  try:
686
- return list(lexer)
725
+ tokens = []
726
+ start = 0
727
+ for token in lexer:
728
+ # shlex strips quotes/escapes and reads one character ahead when
729
+ # emitting punctuation. Exclude its character pushback from the
730
+ # consumed source span so a quoted operator retains its provenance.
731
+ end = lexer.instream.tell() - len(lexer._pushback_chars)
732
+ raw = normalized[start:end].strip()
733
+ start = end
734
+ if PUNCTUATION.fullmatch(token):
735
+ if raw != token:
736
+ tokens.append(LiteralOperator(token))
737
+ else:
738
+ tokens.extend(SHELL_OPERATOR.findall(token))
739
+ else:
740
+ tokens.append(token)
741
+ return tokens
687
742
  except ValueError:
688
743
  return None
689
744
 
@@ -745,7 +800,7 @@ def statements(tokens):
745
800
  grouped = []
746
801
  current = []
747
802
  for token in tokens:
748
- if token in STATEMENT_SEPARATORS:
803
+ if not isinstance(token, LiteralOperator) and token in STATEMENT_SEPARATORS:
749
804
  grouped.append(current)
750
805
  current = []
751
806
  continue
@@ -929,8 +984,14 @@ def write_targets(statement):
929
984
  found = []
930
985
  _token, program, args = command_word(statement)
931
986
  for index, token in enumerate(statement):
932
- if token in REDIRECT_OUT and index + 1 < len(statement):
933
- found.append(statement[index + 1])
987
+ if not isinstance(token, LiteralOperator) and token in REDIRECT_OUT and index + 1 < len(statement):
988
+ target = statement[index + 1]
989
+ # >&digit duplicates an fd, >&digit- moves it, and >&- closes it.
990
+ # Only the nonnumeric >&word form opens a pathname; &>1 still
991
+ # writes a file named 1 and must remain a write candidate.
992
+ if token == ">&" and re.fullmatch(r"(?:[0-9]+-?|-)", target):
993
+ continue
994
+ found.append(target)
934
995
  if program == "tee":
935
996
  found.extend(token for token in args if not token.startswith("-"))
936
997
  elif program == "sed" and any(IN_PLACE.match(token) for token in args):
@@ -956,7 +1017,7 @@ def executed_script(statement):
956
1017
  if program in SHELL_PROGRAMS:
957
1018
  for index, token in enumerate(statement):
958
1019
  # `bash < script.sh` runs that file as surely as `bash script.sh`.
959
- if token == REDIRECT_IN and index + 1 < len(statement):
1020
+ if not isinstance(token, LiteralOperator) and token == REDIRECT_IN and index + 1 < len(statement):
960
1021
  return statement[index + 1]
961
1022
  return shell_script_operand(args)
962
1023
  if program in SOURCE_BUILTINS:
@@ -705,8 +705,14 @@ const LisaBlockDirectIssueCreate = async () => {
705
705
  // server tool names — a list passes every row anyone thought of and misses
706
706
  // the first one nobody did.
707
707
  if (STRUCTURED_SKIP.has(tool)) return;
708
- if (!STRUCTURED_VERB.test(tool)) return;
709
- if (!STRUCTURED_NOUN.test(tool)) return;
708
+ // Comments, labels and reactions operate on existing items. Strip only the
709
+ // terminal action; preceding compound creations remain governed.
710
+ const creationTool = tool.replace(
711
+ /(?:[Aa]dd_[Ii]ssue_[Cc]omment|[Aa]dd[Cc]omment[Tt]o[Jj]ira[Ii]ssue|[Aa]dd_[Cc]omment_[Tt]o_[Ii]ssue|[Aa]dd_[Ii]ssue_[Ll]abels|[Aa]dd_[Ll]abels_[Tt]o_[Ii]ssue|[Aa]dd_[Rr]eaction_[Tt]o_[Ii]ssue_[Cc]omment)$/u,
712
+ ""
713
+ );
714
+ if (!STRUCTURED_VERB.test(creationTool)) return;
715
+ if (!STRUCTURED_NOUN.test(creationTool)) return;
710
716
  // The operator's ambient escape works on both substrates. There is no
711
717
  // inline form to disqualify here — a structured call has no shell in which
712
718
  // to assign one — so the override is simply honoured.
package/package.json CHANGED
@@ -189,7 +189,7 @@
189
189
  "qs": "^6.16.0"
190
190
  },
191
191
  "name": "@codyswann/lisa",
192
- "version": "4.71.5",
192
+ "version": "4.71.7",
193
193
  "description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
194
194
  "main": "dist/index.js",
195
195
  "exports": {
@@ -345,7 +345,7 @@
345
345
  "test": "tests"
346
346
  },
347
347
  "types": "./dist/index.d.ts",
348
- "lisaReleaseCommit": "f214c9ed88eb4a847a625d52dd1fcf78080d8b02",
349
- "gitHead": "f214c9ed88eb4a847a625d52dd1fcf78080d8b02",
350
- "lisaReleaseTag": "v4.71.5"
348
+ "lisaReleaseCommit": "db10f35b22905e61c8dd481e22737926be92efd0",
349
+ "gitHead": "db10f35b22905e61c8dd481e22737926be92efd0",
350
+ "lisaReleaseTag": "v4.71.7"
351
351
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.71.5",
3
+ "version": "4.71.7",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.71.5",
3
+ "version": "4.71.7",
4
4
  "description": "Universal governance: agents, skills, commands, hooks, and rules for all projects.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -582,10 +582,55 @@ sails through the arm gate and the latch goes on over the top of it.
582
582
  **Read `reviewDecision` explicitly. Never infer it from a check count.**
583
583
 
584
584
  ```bash
585
- gh pr view <pr> --json reviewDecision,reviewThreads \
586
- --jq '{decision: .reviewDecision, unresolved: [.reviewThreads[]? | select(.isResolved == false)] | length}'
585
+ review_pages=$(gh api graphql --paginate --slurp \
586
+ -F owner=<owner> -F repo=<repo> -F pr=<pr> \
587
+ -f query='query($owner:String!,$repo:String!,$pr:Int!,$endCursor:String){
588
+ repository(owner:$owner,name:$repo){pullRequest(number:$pr){
589
+ state reviewDecision autoMergeRequest{enabledAt}
590
+ reviewThreads(first:100,after:$endCursor){
591
+ nodes{isResolved isOutdated}
592
+ pageInfo{hasNextPage endCursor}
593
+ }
594
+ }}
595
+ }') || {
596
+ printf '%s\n' 'Could not read PR review threads; merge readiness is unknown.' >&2
597
+ exit 1
598
+ }
599
+ printf '%s\n' "$review_pages" | jq -e '
600
+ def complete_page:
601
+ try (
602
+ ((.errors // []) | length) == 0 and
603
+ (.data.repository.pullRequest | . as $pr |
604
+ has("state") and has("reviewDecision") and has("autoMergeRequest") and
605
+ (["OPEN", "CLOSED", "MERGED"] | index($pr.state)) != null and
606
+ ([null, "APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"] |
607
+ index($pr.reviewDecision)) != null and
608
+ (.autoMergeRequest == null or
609
+ (.autoMergeRequest.enabledAt | type) == "string") and
610
+ (.reviewThreads.nodes | type) == "array" and
611
+ all(.reviewThreads.nodes[];
612
+ (.isResolved | type) == "boolean" and (.isOutdated | type) == "boolean") and
613
+ (.reviewThreads.pageInfo.hasNextPage | type) == "boolean")
614
+ ) catch false;
615
+ if type != "array" then
616
+ error("Incomplete PR review response; merge readiness is unknown")
617
+ elif length == 0 or any(.[]; complete_page | not) or
618
+ .[-1].data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage != false
619
+ then error("Incomplete PR review response; merge readiness is unknown")
620
+ else {
621
+ state: .[-1].data.repository.pullRequest.state,
622
+ armed: (.[-1].data.repository.pullRequest.autoMergeRequest != null),
623
+ decision: .[-1].data.repository.pullRequest.reviewDecision,
624
+ unresolved: ([.[].data.repository.pullRequest.reviewThreads.nodes[] |
625
+ select(.isResolved == false)] | length)
626
+ } end'
587
627
  ```
588
628
 
629
+ `reviewThreads` is a GraphQL connection, not a supported `gh pr view --json`
630
+ field. Read every page, including outdated threads that remain unresolved.
631
+ If either the API request or response validation fails, stop with the diagnostic
632
+ above; a failed read must never become a count of zero unresolved threads.
633
+
589
634
  `reviewDecision` is **not part of `statusCheckRollup`**. That is the whole reason
590
635
  this stayed invisible: a failing-check count reads **zero** on a PR that can
591
636
  never merge, and the checks tab is entirely green. Any rule keyed on "no red
@@ -1440,9 +1485,48 @@ states above, but any point where this run concludes it is finished, hands back,
1440
1485
  or gives up. Re-read, from the live PR, immediately before reporting:
1441
1486
 
1442
1487
  ```bash
1443
- gh pr view <pr> --json state,autoMergeRequest,reviewDecision,reviewThreads \
1444
- --jq '{state, armed: (.autoMergeRequest != null), decision: .reviewDecision,
1445
- unresolved: [.reviewThreads[]? | select(.isResolved == false)] | length}'
1488
+ review_pages=$(gh api graphql --paginate --slurp \
1489
+ -F owner=<owner> -F repo=<repo> -F pr=<pr> \
1490
+ -f query='query($owner:String!,$repo:String!,$pr:Int!,$endCursor:String){
1491
+ repository(owner:$owner,name:$repo){pullRequest(number:$pr){
1492
+ state reviewDecision autoMergeRequest{enabledAt}
1493
+ reviewThreads(first:100,after:$endCursor){
1494
+ nodes{isResolved isOutdated}
1495
+ pageInfo{hasNextPage endCursor}
1496
+ }
1497
+ }}
1498
+ }') || {
1499
+ printf '%s\n' 'Could not read PR review threads; merge readiness is unknown.' >&2
1500
+ exit 1
1501
+ }
1502
+ printf '%s\n' "$review_pages" | jq -e '
1503
+ def complete_page:
1504
+ try (
1505
+ ((.errors // []) | length) == 0 and
1506
+ (.data.repository.pullRequest | . as $pr |
1507
+ has("state") and has("reviewDecision") and has("autoMergeRequest") and
1508
+ (["OPEN", "CLOSED", "MERGED"] | index($pr.state)) != null and
1509
+ ([null, "APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"] |
1510
+ index($pr.reviewDecision)) != null and
1511
+ (.autoMergeRequest == null or
1512
+ (.autoMergeRequest.enabledAt | type) == "string") and
1513
+ (.reviewThreads.nodes | type) == "array" and
1514
+ all(.reviewThreads.nodes[];
1515
+ (.isResolved | type) == "boolean" and (.isOutdated | type) == "boolean") and
1516
+ (.reviewThreads.pageInfo.hasNextPage | type) == "boolean")
1517
+ ) catch false;
1518
+ if type != "array" then
1519
+ error("Incomplete PR review response; merge readiness is unknown")
1520
+ elif length == 0 or any(.[]; complete_page | not) or
1521
+ .[-1].data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage != false
1522
+ then error("Incomplete PR review response; merge readiness is unknown")
1523
+ else {
1524
+ state: .[-1].data.repository.pullRequest.state,
1525
+ armed: (.[-1].data.repository.pullRequest.autoMergeRequest != null),
1526
+ decision: .[-1].data.repository.pullRequest.reviewDecision,
1527
+ unresolved: ([.[].data.repository.pullRequest.reviewThreads.nodes[] |
1528
+ select(.isResolved == false)] | length)
1529
+ } end'
1446
1530
  ```
1447
1531
 
1448
1532
  If `state == OPEN` and `armed` and either `decision == "CHANGES_REQUESTED"` or
@@ -86,11 +86,51 @@ Alongside the two work queues, report the **arming state of the repo's open pull
86
86
  Run the sweep rather than eyeballing the list — an LLM reading PR pages one at a time is exactly the shell-loop-by-prose failure #3512 records:
87
87
 
88
88
  ```bash
89
- gh pr list --state open --limit 200 \
90
- --json number,title,url,isDraft,labels,body,autoMergeRequest \
91
- | node "${CLAUDE_PLUGIN_ROOT}/scripts/pr-arming-sweep.mjs"
89
+ repository=$(gh repo view --json nameWithOwner --jq .nameWithOwner) || {
90
+ printf '%s\n' 'NOT_MEASURED: could not resolve the current repository.' >&2
91
+ exit 2
92
+ }
93
+ pages=$(gh api --paginate --slurp \
94
+ "repos/$repository/pulls?state=open&per_page=100") || {
95
+ printf '%s\n' 'NOT_MEASURED: could not read every open pull request page.' >&2
96
+ exit 2
97
+ }
98
+ prs=$(printf '%s\n' "$pages" | jq -ce '
99
+ def complete_pr:
100
+ try (
101
+ has("auto_merge") and
102
+ (.auto_merge == null or
103
+ (.auto_merge.merge_method as $method | ["merge", "squash", "rebase"] |
104
+ index($method)) != null) and
105
+ (.number | type) == "number" and .number > 0 and
106
+ (.title | type) == "string" and (.html_url | type) == "string" and
107
+ (.draft | type) == "boolean" and has("body") and
108
+ (.body == null or (.body | type) == "string") and
109
+ (.labels | type) == "array" and all(.labels[]; (.name | type) == "string")
110
+ ) catch false;
111
+ if type != "array" then error("Invalid PR page response")
112
+ elif length == 0 or any(.[]; type != "array") then error("Missing PR pages")
113
+ elif any(.[][]; complete_pr | not) then error("Incomplete PR metadata")
114
+ else [.[][] | {
115
+ number, title, url: .html_url, isDraft: .draft, labels, body,
116
+ autoMergeRequest: .auto_merge
117
+ }] end') || {
118
+ printf '%s\n' 'NOT_MEASURED: incomplete pull request response.' >&2
119
+ exit 2
120
+ }
121
+ printf '%s\n' "$prs" | node \
122
+ "${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-node_modules/@codyswann/lisa/plugins/lisa}}/scripts/pr-arming-sweep.mjs"
92
123
  ```
93
124
 
125
+ `gh api --paginate --slurp` follows every REST page before the sweep starts;
126
+ there is no repository-wide row cap. Keep `auto_merge` present during conversion:
127
+ an absent field is an unread measurement, while explicit `null` means unarmed.
128
+ On any API or response validation failure, report `NOT_MEASURED` and stop this
129
+ read. Never report a partial queue as clean. The script path honors an exported
130
+ plugin root and otherwise resolves from the current project's installed Lisa
131
+ package; bare `plugins/lisa` paths are relative to Lisa's own checkout and do not
132
+ work from a consuming project.
133
+
94
134
  Report the verdict verbatim, and never translate it into an absence:
95
135
 
96
136
  - `MEASURED_CLEAN` — a **positive assertion**: N open PRs were read and every one is armed or deliberately unarmed. Say the count. "Nothing to report" is not the same sentence and must not be substituted for it.
@@ -211,11 +211,29 @@ if [ "$tool_name" != "Bash" ]; then
211
211
  exit 0
212
212
  ;;
213
213
  esac
214
- case "$tool_name" in
214
+ # These terminal actions edit an existing item, not create the item.
215
+ # Strip only the measured action shape, then still inspect any preceding
216
+ # compound operation: create_issue_and_add_issue_comment remains a filing.
217
+ creation_name="$tool_name"
218
+ case "$creation_name" in
219
+ *[Aa]dd_[Ii]ssue_[Cc]omment)
220
+ creation_name="${creation_name%[Aa]dd_[Ii]ssue_[Cc]omment}" ;;
221
+ *[Aa]dd[Cc]omment[Tt]o[Jj]ira[Ii]ssue)
222
+ creation_name="${creation_name%[Aa]dd[Cc]omment[Tt]o[Jj]ira[Ii]ssue}" ;;
223
+ *[Aa]dd_[Cc]omment_[Tt]o_[Ii]ssue)
224
+ creation_name="${creation_name%[Aa]dd_[Cc]omment_[Tt]o_[Ii]ssue}" ;;
225
+ *[Aa]dd_[Ii]ssue_[Ll]abels)
226
+ creation_name="${creation_name%[Aa]dd_[Ii]ssue_[Ll]abels}" ;;
227
+ *[Aa]dd_[Ll]abels_[Tt]o_[Ii]ssue)
228
+ creation_name="${creation_name%[Aa]dd_[Ll]abels_[Tt]o_[Ii]ssue}" ;;
229
+ *[Aa]dd_[Rr]eaction_[Tt]o_[Ii]ssue_[Cc]omment)
230
+ creation_name="${creation_name%[Aa]dd_[Rr]eaction_[Tt]o_[Ii]ssue_[Cc]omment}" ;;
231
+ esac
232
+ case "$creation_name" in
215
233
  *[Cc]reate* | *[Nn]ew* | *[Aa]dd* | *[Ff]ile*) ;;
216
234
  *) exit 0 ;;
217
235
  esac
218
- case "$tool_name" in
236
+ case "$creation_name" in
219
237
  *[Ii]ssue* | *[Tt]icket* | *[Tt]ask* | *[Ss]tory* | *[Bb]ug* | *[Ee]pic* | *[Ww]ork*) ;;
220
238
  *) exit 0 ;;
221
239
  esac
@@ -598,9 +598,25 @@ BACKSLASH = chr(92)
598
598
  NEWLINE = chr(10)
599
599
 
600
600
  STATEMENT_SEPARATORS = {";", "|", "||", "&", "&&", "(", ")", "&|"}
601
- # `>`, `>>`, and the noclobber override `>|`.
602
- REDIRECT_OUT = {">", ">>", ">|"}
601
+ # Ordinary output, noclobber override, and combined stdout/stderr output.
602
+ # shlex separates fd prefixes (e.g. 10>>) from the operator automatically.
603
+ REDIRECT_OUT = {">", ">>", ">|", "&>", "&>>", ">&"}
603
604
  REDIRECT_IN = "<"
605
+ SYNTAX_OPERATORS = STATEMENT_SEPARATORS | REDIRECT_OUT | {REDIRECT_IN}
606
+ # shlex groups adjacent punctuation even across distinct shell operators.
607
+ # Retain other shell operators intact so a here-string is not three `<` reads.
608
+ PUNCTUATION = re.compile(r"^[();<>|&]+$")
609
+ SHELL_OPERATOR = re.compile("|".join(
610
+ re.escape(operator) for operator in sorted(
611
+ SYNTAX_OPERATORS | {"<<<", "<<", "<&", "<>", ";;&", ";;", ";&", "|&"},
612
+ key=lambda operator: (-len(operator), operator),
613
+ )
614
+ ))
615
+
616
+
617
+ class LiteralOperator(str):
618
+ """Quoted or escaped operator text is an argument, never shell syntax."""
619
+
604
620
 
605
621
  SHELL_PROGRAMS = {"bash", "dash", "ksh", "sh", "zsh"}
606
622
  SOURCE_BUILTINS = {"source", "."}
@@ -664,8 +680,30 @@ def normalise_lines(text):
664
680
  Returns:
665
681
  The same text with line breaks spelled as separators.
666
682
  """
667
- joined = text.replace(BACKSLASH + NEWLINE, " ")
668
- return joined.replace(NEWLINE, " ; ")
683
+ output = []
684
+ quote = ""
685
+ position = 0
686
+ while position < len(text):
687
+ character = text[position]
688
+ if character == BACKSLASH and quote != "'" and position + 1 < len(text):
689
+ following = text[position + 1]
690
+ # The shell deletes continuations; inserting whitespace can split
691
+ # an operator or pathname and hide the actual output target.
692
+ if following != NEWLINE:
693
+ output.extend((character, following))
694
+ position += 2
695
+ continue
696
+ if quote:
697
+ if character == quote:
698
+ quote = ""
699
+ elif character in "'\"":
700
+ quote = character
701
+ if character == NEWLINE and not quote:
702
+ output.append(" ; ")
703
+ else:
704
+ output.append(character)
705
+ position += 1
706
+ return "".join(output)
669
707
 
670
708
 
671
709
  def tokenize(text):
@@ -677,13 +715,30 @@ def tokenize(text):
677
715
  Returns:
678
716
  The token list, or None when the text does not lex.
679
717
  """
680
- lexer = shlex.shlex(normalise_lines(text), posix=True, punctuation_chars=True)
718
+ normalized = normalise_lines(text)
719
+ lexer = shlex.shlex(normalized, posix=True, punctuation_chars=True)
681
720
  lexer.whitespace_split = True
682
721
  # `#` is not a comment introducer on a typed command line, and letting shlex
683
722
  # treat it as one silently truncates the rest of the line.
684
723
  lexer.commenters = ""
685
724
  try:
686
- return list(lexer)
725
+ tokens = []
726
+ start = 0
727
+ for token in lexer:
728
+ # shlex strips quotes/escapes and reads one character ahead when
729
+ # emitting punctuation. Exclude its character pushback from the
730
+ # consumed source span so a quoted operator retains its provenance.
731
+ end = lexer.instream.tell() - len(lexer._pushback_chars)
732
+ raw = normalized[start:end].strip()
733
+ start = end
734
+ if PUNCTUATION.fullmatch(token):
735
+ if raw != token:
736
+ tokens.append(LiteralOperator(token))
737
+ else:
738
+ tokens.extend(SHELL_OPERATOR.findall(token))
739
+ else:
740
+ tokens.append(token)
741
+ return tokens
687
742
  except ValueError:
688
743
  return None
689
744
 
@@ -745,7 +800,7 @@ def statements(tokens):
745
800
  grouped = []
746
801
  current = []
747
802
  for token in tokens:
748
- if token in STATEMENT_SEPARATORS:
803
+ if not isinstance(token, LiteralOperator) and token in STATEMENT_SEPARATORS:
749
804
  grouped.append(current)
750
805
  current = []
751
806
  continue
@@ -929,8 +984,14 @@ def write_targets(statement):
929
984
  found = []
930
985
  _token, program, args = command_word(statement)
931
986
  for index, token in enumerate(statement):
932
- if token in REDIRECT_OUT and index + 1 < len(statement):
933
- found.append(statement[index + 1])
987
+ if not isinstance(token, LiteralOperator) and token in REDIRECT_OUT and index + 1 < len(statement):
988
+ target = statement[index + 1]
989
+ # >&digit duplicates an fd, >&digit- moves it, and >&- closes it.
990
+ # Only the nonnumeric >&word form opens a pathname; &>1 still
991
+ # writes a file named 1 and must remain a write candidate.
992
+ if token == ">&" and re.fullmatch(r"(?:[0-9]+-?|-)", target):
993
+ continue
994
+ found.append(target)
934
995
  if program == "tee":
935
996
  found.extend(token for token in args if not token.startswith("-"))
936
997
  elif program == "sed" and any(IN_PLACE.match(token) for token in args):
@@ -956,7 +1017,7 @@ def executed_script(statement):
956
1017
  if program in SHELL_PROGRAMS:
957
1018
  for index, token in enumerate(statement):
958
1019
  # `bash < script.sh` runs that file as surely as `bash script.sh`.
959
- if token == REDIRECT_IN and index + 1 < len(statement):
1020
+ if not isinstance(token, LiteralOperator) and token == REDIRECT_IN and index + 1 < len(statement):
960
1021
  return statement[index + 1]
961
1022
  return shell_script_operand(args)
962
1023
  if program in SOURCE_BUILTINS:
@@ -582,10 +582,55 @@ sails through the arm gate and the latch goes on over the top of it.
582
582
  **Read `reviewDecision` explicitly. Never infer it from a check count.**
583
583
 
584
584
  ```bash
585
- gh pr view <pr> --json reviewDecision,reviewThreads \
586
- --jq '{decision: .reviewDecision, unresolved: [.reviewThreads[]? | select(.isResolved == false)] | length}'
585
+ review_pages=$(gh api graphql --paginate --slurp \
586
+ -F owner=<owner> -F repo=<repo> -F pr=<pr> \
587
+ -f query='query($owner:String!,$repo:String!,$pr:Int!,$endCursor:String){
588
+ repository(owner:$owner,name:$repo){pullRequest(number:$pr){
589
+ state reviewDecision autoMergeRequest{enabledAt}
590
+ reviewThreads(first:100,after:$endCursor){
591
+ nodes{isResolved isOutdated}
592
+ pageInfo{hasNextPage endCursor}
593
+ }
594
+ }}
595
+ }') || {
596
+ printf '%s\n' 'Could not read PR review threads; merge readiness is unknown.' >&2
597
+ exit 1
598
+ }
599
+ printf '%s\n' "$review_pages" | jq -e '
600
+ def complete_page:
601
+ try (
602
+ ((.errors // []) | length) == 0 and
603
+ (.data.repository.pullRequest | . as $pr |
604
+ has("state") and has("reviewDecision") and has("autoMergeRequest") and
605
+ (["OPEN", "CLOSED", "MERGED"] | index($pr.state)) != null and
606
+ ([null, "APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"] |
607
+ index($pr.reviewDecision)) != null and
608
+ (.autoMergeRequest == null or
609
+ (.autoMergeRequest.enabledAt | type) == "string") and
610
+ (.reviewThreads.nodes | type) == "array" and
611
+ all(.reviewThreads.nodes[];
612
+ (.isResolved | type) == "boolean" and (.isOutdated | type) == "boolean") and
613
+ (.reviewThreads.pageInfo.hasNextPage | type) == "boolean")
614
+ ) catch false;
615
+ if type != "array" then
616
+ error("Incomplete PR review response; merge readiness is unknown")
617
+ elif length == 0 or any(.[]; complete_page | not) or
618
+ .[-1].data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage != false
619
+ then error("Incomplete PR review response; merge readiness is unknown")
620
+ else {
621
+ state: .[-1].data.repository.pullRequest.state,
622
+ armed: (.[-1].data.repository.pullRequest.autoMergeRequest != null),
623
+ decision: .[-1].data.repository.pullRequest.reviewDecision,
624
+ unresolved: ([.[].data.repository.pullRequest.reviewThreads.nodes[] |
625
+ select(.isResolved == false)] | length)
626
+ } end'
587
627
  ```
588
628
 
629
+ `reviewThreads` is a GraphQL connection, not a supported `gh pr view --json`
630
+ field. Read every page, including outdated threads that remain unresolved.
631
+ If either the API request or response validation fails, stop with the diagnostic
632
+ above; a failed read must never become a count of zero unresolved threads.
633
+
589
634
  `reviewDecision` is **not part of `statusCheckRollup`**. That is the whole reason
590
635
  this stayed invisible: a failing-check count reads **zero** on a PR that can
591
636
  never merge, and the checks tab is entirely green. Any rule keyed on "no red
@@ -1440,9 +1485,48 @@ states above, but any point where this run concludes it is finished, hands back,
1440
1485
  or gives up. Re-read, from the live PR, immediately before reporting:
1441
1486
 
1442
1487
  ```bash
1443
- gh pr view <pr> --json state,autoMergeRequest,reviewDecision,reviewThreads \
1444
- --jq '{state, armed: (.autoMergeRequest != null), decision: .reviewDecision,
1445
- unresolved: [.reviewThreads[]? | select(.isResolved == false)] | length}'
1488
+ review_pages=$(gh api graphql --paginate --slurp \
1489
+ -F owner=<owner> -F repo=<repo> -F pr=<pr> \
1490
+ -f query='query($owner:String!,$repo:String!,$pr:Int!,$endCursor:String){
1491
+ repository(owner:$owner,name:$repo){pullRequest(number:$pr){
1492
+ state reviewDecision autoMergeRequest{enabledAt}
1493
+ reviewThreads(first:100,after:$endCursor){
1494
+ nodes{isResolved isOutdated}
1495
+ pageInfo{hasNextPage endCursor}
1496
+ }
1497
+ }}
1498
+ }') || {
1499
+ printf '%s\n' 'Could not read PR review threads; merge readiness is unknown.' >&2
1500
+ exit 1
1501
+ }
1502
+ printf '%s\n' "$review_pages" | jq -e '
1503
+ def complete_page:
1504
+ try (
1505
+ ((.errors // []) | length) == 0 and
1506
+ (.data.repository.pullRequest | . as $pr |
1507
+ has("state") and has("reviewDecision") and has("autoMergeRequest") and
1508
+ (["OPEN", "CLOSED", "MERGED"] | index($pr.state)) != null and
1509
+ ([null, "APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"] |
1510
+ index($pr.reviewDecision)) != null and
1511
+ (.autoMergeRequest == null or
1512
+ (.autoMergeRequest.enabledAt | type) == "string") and
1513
+ (.reviewThreads.nodes | type) == "array" and
1514
+ all(.reviewThreads.nodes[];
1515
+ (.isResolved | type) == "boolean" and (.isOutdated | type) == "boolean") and
1516
+ (.reviewThreads.pageInfo.hasNextPage | type) == "boolean")
1517
+ ) catch false;
1518
+ if type != "array" then
1519
+ error("Incomplete PR review response; merge readiness is unknown")
1520
+ elif length == 0 or any(.[]; complete_page | not) or
1521
+ .[-1].data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage != false
1522
+ then error("Incomplete PR review response; merge readiness is unknown")
1523
+ else {
1524
+ state: .[-1].data.repository.pullRequest.state,
1525
+ armed: (.[-1].data.repository.pullRequest.autoMergeRequest != null),
1526
+ decision: .[-1].data.repository.pullRequest.reviewDecision,
1527
+ unresolved: ([.[].data.repository.pullRequest.reviewThreads.nodes[] |
1528
+ select(.isResolved == false)] | length)
1529
+ } end'
1446
1530
  ```
1447
1531
 
1448
1532
  If `state == OPEN` and `armed` and either `decision == "CHANGES_REQUESTED"` or