@codyswann/lisa 3.51.2 → 3.51.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/all/copy-overwrite/scripts/lisa-hooks/block-direct-issue-create.sh +271 -12
  2. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  3. package/dist/core/lisa-owned-hash-ledger.js +2 -0
  4. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  5. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  6. package/dist/core/upstream-evidence-manifest.js +9 -7
  7. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  8. package/dist/opencode/plugin-templates/lisa-block-direct-issue-create.ts +162 -9
  9. package/package.json +1 -1
  10. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  11. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  12. package/plugins/lisa/.codex-plugin/skills/lisa-sonarcloud-access/SKILL.md +33 -15
  13. package/plugins/lisa/hooks/block-direct-issue-create.sh +271 -12
  14. package/plugins/lisa/rules/eager/ready-role-filing.md +2 -0
  15. package/plugins/lisa/rules/reference/credential-substrate-precedence.md +11 -5
  16. package/plugins/lisa/rules/reference/integration-access-layer.md +12 -9
  17. package/plugins/lisa/rules/reference/ready-role-filing.md +27 -0
  18. package/plugins/lisa/skills/lisa-sonarcloud-access/SKILL.md +34 -16
  19. package/plugins/lisa-agy/hooks/block-direct-issue-create.sh +271 -12
  20. package/plugins/lisa-agy/plugin.json +1 -1
  21. package/plugins/lisa-agy/skills/lisa-sonarcloud-access/SKILL.md +34 -16
  22. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  24. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  25. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-copilot/hooks/block-direct-issue-create.sh +271 -12
  29. package/plugins/lisa-copilot/rules/eager/ready-role-filing.md +2 -0
  30. package/plugins/lisa-copilot/rules/reference/credential-substrate-precedence.md +11 -5
  31. package/plugins/lisa-copilot/rules/reference/integration-access-layer.md +12 -9
  32. package/plugins/lisa-copilot/rules/reference/ready-role-filing.md +27 -0
  33. package/plugins/lisa-copilot/skills/lisa-sonarcloud-access/SKILL.md +34 -16
  34. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-cursor/hooks/block-direct-issue-create.sh +271 -12
  36. package/plugins/lisa-cursor/rules/credential-substrate-precedence-reference.mdc +11 -5
  37. package/plugins/lisa-cursor/rules/integration-access-layer-reference.mdc +12 -9
  38. package/plugins/lisa-cursor/rules/ready-role-filing-reference.mdc +27 -0
  39. package/plugins/lisa-cursor/rules/ready-role-filing.mdc +2 -0
  40. package/plugins/lisa-cursor/skills/lisa-sonarcloud-access/SKILL.md +34 -16
  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-agy/plugin.json +1 -1
  44. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  48. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  49. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  53. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  54. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  58. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  59. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  63. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  64. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  68. package/plugins/lisa-rails-agy/plugin.json +1 -1
  69. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  73. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  74. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  78. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  79. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  81. package/plugins/src/base/hooks/block-direct-issue-create.sh +271 -12
  82. package/plugins/src/base/rules/eager/ready-role-filing.md +2 -0
  83. package/plugins/src/base/rules/reference/credential-substrate-precedence.md +11 -5
  84. package/plugins/src/base/rules/reference/integration-access-layer.md +12 -9
  85. package/plugins/src/base/rules/reference/ready-role-filing.md +27 -0
  86. package/plugins/src/base/skills/lisa-sonarcloud-access/SKILL.md +34 -16
@@ -123,6 +123,7 @@ fi
123
123
  # The build-ready role is read from config, never hard-coded: a project that
124
124
  # renamed its ready lane must still be able to satisfy the guard, and the
125
125
  # refusal has to name the token that project actually uses.
126
+ default_ready_role="status:ready"
126
127
  case "$tracker" in
127
128
  github) ready_role="$(read_config_value '.github.labels.build.ready')" ;;
128
129
  jira) ready_role="$(read_config_value '.jira.workflow.ready')" ;;
@@ -130,7 +131,63 @@ case "$tracker" in
130
131
  *) ready_role="" ;;
131
132
  esac
132
133
  if [ -z "$ready_role" ]; then
133
- ready_role="status:ready"
134
+ ready_role="$default_ready_role"
135
+ fi
136
+
137
+ # WHICH REPOSITORY'S VOCABULARY ANSWERS FOR THIS FILING
138
+ #
139
+ # The role above is the CALLING project's. For a same-repo filing that is the
140
+ # right question. For a filing addressed at a DIFFERENT repository — which
141
+ # Lisa ships a first-class, cron-driven path for in `lisa-persist-learning` —
142
+ # it is the wrong repository's vocabulary, and the guard demanded a token the
143
+ # target does not carry:
144
+ #
145
+ # - a JIRA or Linear caller's ready role is a workflow STATE. Demanded as a
146
+ # `gh --label` on another repo it is unsatisfiable, because that label does
147
+ # not exist there and `gh` rejects an unknown one. Obeying the guard made
148
+ # the command fail, which is not the same thing as being refused.
149
+ # - a GitHub caller that renamed its ready lane demanded its own token of a
150
+ # repository that never had it.
151
+ # - a GitHub caller on the stock lane worked only because both repositories
152
+ # happened to choose the same string. That is a coincidence, not routing.
153
+ #
154
+ # The one escape that IS satisfiable cross-repo, `[lisa-human-gate]`, is a lie
155
+ # about the item: it stamps a build-ready defect report as held for a human
156
+ # product call, and the target's build queue scans the ready role and nothing
157
+ # else. The report is filed and never picked up — precisely the incomplete
158
+ # handoff this guard exists to prevent, committed one repository over.
159
+ #
160
+ # So the target's own role answers, resolved from CONFIG rather than the
161
+ # network. A live `gh api repos/<o>/<r>/labels` lookup would be more general
162
+ # and is the wrong trade for a PreToolUse hook: a network round-trip on every
163
+ # intercepted command, and a new fail-open surface when it errors.
164
+ own_org="$(read_config_value '.github.org')"
165
+ own_name="$(read_config_value '.github.repo')"
166
+ own_repo=""
167
+ if [ -n "$own_org" ] && [ -n "$own_name" ]; then
168
+ own_repo="$own_org/$own_name"
169
+ fi
170
+
171
+ # `hardening.upstreamRepo` already names the upstream repository across Lisa's
172
+ # filing skills; `hardening.upstreamReadyRole` is its sibling, so the guard
173
+ # keeps its existing discipline — read the role from config, never hard-code it
174
+ # — while reading it from the RIGHT repository's config.
175
+ upstream_repo="$(read_config_value '.hardening.upstreamRepo')"
176
+ if [ -z "$upstream_repo" ]; then
177
+ upstream_repo="CodySwannGT/lisa"
178
+ fi
179
+ upstream_ready_role="$(read_config_value '.hardening.upstreamReadyRole')"
180
+ if [ -z "$upstream_ready_role" ]; then
181
+ upstream_ready_role="$default_ready_role"
182
+ fi
183
+
184
+ # A non-GitHub caller's ready role is a workflow STATE, so it is never the right
185
+ # vocabulary for a GitHub target no matter what the target turns out to be. The
186
+ # classifier needs to know that even when this project declares no repo of its
187
+ # own to compare against.
188
+ caller_is_github="0"
189
+ if [ "$tracker" = "github" ]; then
190
+ caller_is_github="1"
134
191
  fi
135
192
 
136
193
  # Ambient-only override. Deliberately read here, from the hook process's own
@@ -139,6 +196,11 @@ ambient_override="${LISA_ALLOW_DIRECT_ISSUE_CREATE:-}"
139
196
 
140
197
  refuse() {
141
198
  local signature="$1"
199
+ local roles="$2"
200
+ local target="$3"
201
+ if [ -n "$target" ]; then
202
+ refuse_cross_repo "$signature" "$roles" "$target"
203
+ fi
142
204
  cat >&2 <<EOF
143
205
  BLOCKED: refusing \`$signature\` — this filing declares no readiness.
144
206
 
@@ -184,6 +246,61 @@ EOF
184
246
  exit 2
185
247
  }
186
248
 
249
+ # The cross-repo refusal is a separate message, not a variable swapped into the
250
+ # one above, because its REMEDIATION is different. The local filing flow writes
251
+ # to this project's own tracker and structurally cannot reach another
252
+ # repository, so naming it here would send the agent to a path that cannot do
253
+ # the thing it was just refused for. Name the route that reaches the target,
254
+ # and name the target's role rather than this project's.
255
+ refuse_cross_repo() {
256
+ local signature="$1"
257
+ local roles="$2"
258
+ local target="$3"
259
+ cat >&2 <<EOF
260
+ BLOCKED: refusing \`$signature\` — this filing declares no readiness.
261
+
262
+ WHY: a work item filed without the build-ready role is an incomplete handoff.
263
+ Build-intake scans the ready lane and nothing else, so nothing will ever pick
264
+ it up: the write succeeds and the work still dies.
265
+
266
+ THIS FILING IS ADDRESSED AT ANOTHER REPOSITORY: \`$target\`.
267
+ That repository runs its own build queue off its own ready role, so this
268
+ project's role does not answer for it — and this project's filing flow writes
269
+ to this project's tracker, so it cannot reach the target at all.
270
+
271
+ FILE IT THE SANCTIONED WAY:
272
+
273
+ 1. An upstream defect or hardening report — the highest-signal report there is,
274
+ because it is reproduced and attributed rather than guessed at. Use the
275
+ upstream filing path, which composes a redacted, public-safe body through an
276
+ allowlist projection instead of free-form prose:
277
+
278
+ bunx @codyswann/lisa file-upstream --input <filing-event>.json
279
+
280
+ \`lisa-persist-learning\` step 6 runs exactly this, headless, on a cron, and
281
+ files the result with explicit \`build_ready: true\` so the target's queue
282
+ picks it up.
283
+
284
+ 2. If you must run the CLI directly, the command has to carry the TARGET
285
+ repository's build-ready role — \`$roles\` — as the value of a \`--label\`
286
+ flag. Configure it as \`hardening.upstreamReadyRole\` when the target renamed
287
+ its lane.
288
+
289
+ DO NOT reach for \`[lisa-human-gate]\` to get past this one. It still satisfies
290
+ the guard — it is a real declaration — but on an upstream defect report it is a
291
+ false one: it stamps the item as held for a human product call, and the
292
+ target's build queue scans the ready role and nothing else. The report is filed
293
+ and never picked up, which is the incomplete handoff this guard exists to
294
+ prevent, committed one repository over. Use it only when a human product call
295
+ is genuinely pending on the target.
296
+
297
+ OPERATOR ESCAPE: a human can export \`LISA_ALLOW_DIRECT_ISSUE_CREATE=1\` in the
298
+ environment before starting the session. It is deliberately not reachable by
299
+ setting it inline on this command — an inline assignment is refused.
300
+ EOF
301
+ exit 2
302
+ }
303
+
187
304
  # The classifier is read into a variable with a top-level here-document rather
188
305
  # than piped straight in from inside `$( … )`. bash 3.2 — which is what macOS
189
306
  # still ships as /bin/bash, and therefore what this fleet's hooks run under —
@@ -202,6 +319,11 @@ import sys
202
319
  command = os.environ.get("LISA_GUARD_COMMAND", "")
203
320
  ready_role = os.environ.get("LISA_GUARD_READY_ROLE", "")
204
321
  ambient_override = os.environ.get("LISA_GUARD_AMBIENT_OVERRIDE", "")
322
+ default_ready_role = os.environ.get("LISA_GUARD_DEFAULT_READY_ROLE", "")
323
+ own_repo = os.environ.get("LISA_GUARD_OWN_REPO", "").strip().lower()
324
+ upstream_repo = os.environ.get("LISA_GUARD_UPSTREAM_REPO", "").strip().lower()
325
+ upstream_ready_role = os.environ.get("LISA_GUARD_UPSTREAM_READY_ROLE", "")
326
+ caller_is_github = os.environ.get("LISA_GUARD_CALLER_IS_GITHUB", "") == "1"
205
327
 
206
328
  OVERRIDE_NAME = "LISA_ALLOW_DIRECT_ISSUE_CREATE"
207
329
  HUMAN_GATE_MARKER = "[lisa-human-gate]"
@@ -269,6 +391,14 @@ PAYLOAD_VALUE_FLAGS = {"-f", "-F", "--raw-field", "--field"}
269
391
 
270
392
  GITHUB_ISSUES_PATH = re.compile(r"repos/[^/\s]+/[^/\s]+/issues/?$")
271
393
  GITHUB_ISSUES_URL = re.compile(r"api\.github\.com/repos/[^/\s]+/[^/\s]+/issues")
394
+ # The repository a creation is ADDRESSED at, which decides whose ready role
395
+ # answers for it. `gh` accepts the flag before or after the subcommand and in
396
+ # either spelling, and the REST paths carry the same pair positionally.
397
+ REPO_FLAGS = {"--repo", "-R"}
398
+ GITHUB_ISSUES_PATH_REPO = re.compile(r"repos/([^/\s]+)/([^/\s]+)/issues/?$")
399
+ GITHUB_ISSUES_URL_REPO = re.compile(
400
+ r"api\.github\.com/repos/([^/\s]+)/([^/\s]+)/issues"
401
+ )
272
402
  JIRA_ISSUE_URL = re.compile(r"atlassian\.net/rest/api/[^/\s]+/issue")
273
403
  GRAPHQL_CREATE = re.compile(r"createIssue|issueCreate")
274
404
 
@@ -655,20 +785,118 @@ def body_file_paths(args):
655
785
  return paths
656
786
 
657
787
 
658
- def declares_readiness(raw_args):
788
+ def normalise_repo(value):
789
+ """A `--repo` value reduced to a comparable `owner/name`.
790
+
791
+ `gh` accepts `OWNER/REPO`, `HOST/OWNER/REPO`, and a full browser URL, and
792
+ GitHub itself is case-insensitive about both halves — so comparing the raw
793
+ token would call the same repository two different places depending on how
794
+ it was typed.
795
+
796
+ Args:
797
+ value: The raw token.
798
+
799
+ Returns:
800
+ A lowercased `owner/name`, or None when the token names no repository.
801
+ """
802
+ text = value.strip().strip("'\"")
803
+ if text.endswith(".git"):
804
+ text = text[: -len(".git")]
805
+ parts = [part for part in text.split("/") if part and not part.endswith(":")]
806
+ if len(parts) < 2:
807
+ return None
808
+ return ("%s/%s" % (parts[-2], parts[-1])).lower()
809
+
810
+
811
+ def target_repository(args):
812
+ """The repository this creation is addressed at, when it names one.
813
+
814
+ Read only from positions that actually reach the created item: a flag
815
+ before the end-of-options marker, or the endpoint the write is posted to.
816
+ A `-f repo=o/r` payload field is data being SENT, not the address being
817
+ posted to, and `endpoint_tokens` already excludes it.
818
+
819
+ Args:
820
+ args: A creating command's arguments.
821
+
822
+ Returns:
823
+ A lowercased `owner/name`, or None when the calling project is the
824
+ target — which is the overwhelmingly common case and today's behaviour.
825
+ """
826
+ scoped = before_end_of_options(args)
827
+ for index, token in enumerate(scoped):
828
+ if token in REPO_FLAGS and index + 1 < len(scoped):
829
+ return normalise_repo(scoped[index + 1])
830
+ if "=" in token:
831
+ head, value = token.split("=", 1)
832
+ if head in REPO_FLAGS:
833
+ return normalise_repo(value)
834
+ for token in endpoint_tokens(scoped, GITHUB_ISSUES_PATH):
835
+ match = GITHUB_ISSUES_PATH_REPO.search(token)
836
+ if match:
837
+ return normalise_repo("%s/%s" % (match.group(1), match.group(2)))
838
+ for token in scoped:
839
+ match = GITHUB_ISSUES_URL_REPO.search(token)
840
+ if match:
841
+ return normalise_repo("%s/%s" % (match.group(1), match.group(2)))
842
+ return None
843
+
844
+
845
+ def roles_for(target):
846
+ """Which ready-role tokens satisfy a creation addressed at `target`.
847
+
848
+ The guard demands a declaration either way; this decides only WHOSE
849
+ vocabulary the declaration is written in.
850
+
851
+ The indeterminate case is the last branch: a GitHub-tracked project that
852
+ declares no `github.org`/`github.repo` cannot be compared against a target,
853
+ so both roles are accepted rather than inventing a refusal. That is
854
+ permissive about which token, never about whether one is required.
855
+
856
+ Args:
857
+ target: The addressed repository, or None.
858
+
859
+ Returns:
860
+ A (roles, cross_repo_target) pair. The target is None when the calling
861
+ project is the one being written to.
862
+ """
863
+ if target is None or (own_repo and target == own_repo):
864
+ return [ready_role], None
865
+ if upstream_repo and target == upstream_repo:
866
+ role = upstream_ready_role
867
+ else:
868
+ # Another repository Lisa has no configuration for. Its lane is
869
+ # whatever GitHub's stock one is; the caller's token is categorically
870
+ # not it.
871
+ role = default_ready_role
872
+ if own_repo or not caller_is_github:
873
+ return [role], target
874
+ # Indeterminate, and the target is deliberately NOT reported. The refusal
875
+ # would otherwise say "this filing is addressed at another repository" and
876
+ # "this project's role does not answer for it" — the first unproven and the
877
+ # second flatly false, since this branch accepts the project's role. A
878
+ # message naming a token that does not work is the remediation pointing
879
+ # away from the fix, which is the defect being repaired here.
880
+ return [ready_role, role], None
881
+
882
+
883
+ def declares_readiness(raw_args, roles):
659
884
  """Whether the create carries one of the two required declarations.
660
885
 
661
886
  Args:
662
887
  raw_args: The creating command's arguments.
888
+ roles: The build-ready role tokens that satisfy this filing.
663
889
 
664
890
  Returns:
665
- True when the build-ready role or a human-gate marker is present.
891
+ True when a build-ready role or a human-gate marker is present.
666
892
  """
667
893
  args = before_end_of_options(raw_args)
668
- if ready_role:
894
+ for role in roles:
895
+ if not role:
896
+ continue
669
897
  for raw in flag_values(args, LABEL_FLAGS):
670
898
  candidates = [part.strip().strip("'\"") for part in raw.split(",")]
671
- if ready_role in candidates:
899
+ if role in candidates:
672
900
  return True
673
901
  # The human-gate marker is matched anywhere, and that asymmetry is
674
902
  # deliberate: it is a marker with no other meaning, so its presence in the
@@ -746,7 +974,8 @@ def scan(text, depth):
746
974
  depth: Current nesting depth.
747
975
 
748
976
  Returns:
749
- A refusal signature, or None when nothing creation-shaped was found.
977
+ A (signature, roles, cross_repo_target) triple, or None when nothing
978
+ creation-shaped was found.
750
979
  """
751
980
  try:
752
981
  stripped = strip_heredocs(text)
@@ -757,7 +986,11 @@ def scan(text, depth):
757
986
  # raises on the unbalanced quote. Two appended characters, no binary
758
987
  # required. "I could not parse it" must never mean "it is fine".
759
988
  if UNPARSEABLE_CREATION.search(text):
760
- return "an unparseable command that reads as a tracker creation"
989
+ return (
990
+ "an unparseable command that reads as a tracker creation",
991
+ [ready_role],
992
+ None,
993
+ )
761
994
  return None
762
995
 
763
996
  for argv in segment(tokens):
@@ -774,9 +1007,10 @@ def scan(text, depth):
774
1007
  # disqualifies the override rather than supplying it.
775
1008
  if ambient_override and not inline_override:
776
1009
  continue
777
- if declares_readiness(args):
1010
+ roles, target = roles_for(target_repository(args))
1011
+ if declares_readiness(args, roles):
778
1012
  continue
779
- return signature
1013
+ return signature, roles, target
780
1014
 
781
1015
  for operand in nested_operands(argv):
782
1016
  if depth >= MAX_NESTING_DEPTH:
@@ -784,7 +1018,11 @@ def scan(text, depth):
784
1018
  # made a creation inside a 4th `bash -c` layer pass, which is
785
1019
  # the depth cap being used as the bypass.
786
1020
  if UNPARSEABLE_CREATION.search(operand):
787
- return "a tracker creation nested past the inspection depth"
1021
+ return (
1022
+ "a tracker creation nested past the inspection depth",
1023
+ [ready_role],
1024
+ None,
1025
+ )
788
1026
  continue
789
1027
  nested = scan(operand, depth + 1)
790
1028
  if nested is not None:
@@ -802,7 +1040,14 @@ inline_override = (OVERRIDE_NAME + "=") in command
802
1040
 
803
1041
  found = scan(command, 0)
804
1042
  if found is not None:
805
- print("REFUSE %s" % found)
1043
+ signature, roles, target = found
1044
+ # Key=value lines rather than one delimited string: a signature contains
1045
+ # spaces and slashes, and a role may contain a colon, so anything the shell
1046
+ # would have to split on appears inside a value already.
1047
+ print("REFUSE")
1048
+ print("signature=%s" % signature)
1049
+ print("roles=%s" % ", ".join(role for role in roles if role))
1050
+ print("target=%s" % (target or ""))
806
1051
  sys.exit(0)
807
1052
 
808
1053
  print("ALLOW")
@@ -815,6 +1060,11 @@ verdict="$(
815
1060
  LISA_GUARD_COMMAND="$command_str" \
816
1061
  LISA_GUARD_READY_ROLE="$ready_role" \
817
1062
  LISA_GUARD_AMBIENT_OVERRIDE="$ambient_override" \
1063
+ LISA_GUARD_DEFAULT_READY_ROLE="$default_ready_role" \
1064
+ LISA_GUARD_OWN_REPO="$own_repo" \
1065
+ LISA_GUARD_UPSTREAM_REPO="$upstream_repo" \
1066
+ LISA_GUARD_UPSTREAM_READY_ROLE="$upstream_ready_role" \
1067
+ LISA_GUARD_CALLER_IS_GITHUB="$caller_is_github" \
818
1068
  python3 -
819
1069
  )"
820
1070
  python_status=$?
@@ -827,8 +1077,17 @@ if [ "$python_status" -ne 0 ]; then
827
1077
  exit 0
828
1078
  fi
829
1079
 
1080
+ verdict_field() {
1081
+ printf '%s\n' "$verdict" | sed -n "s/^$1=//p" | head -1
1082
+ }
1083
+
830
1084
  case "$verdict" in
831
- REFUSE*) refuse "${verdict#REFUSE }" ;;
1085
+ REFUSE*)
1086
+ refuse \
1087
+ "$(verdict_field signature)" \
1088
+ "$(verdict_field roles)" \
1089
+ "$(verdict_field target)"
1090
+ ;;
832
1091
  esac
833
1092
 
834
1093
  exit 0
@@ -23,4 +23,6 @@ Filed, not ready, and no `human_gate` is the **incomplete handoff** case — wri
23
23
 
24
24
  The PreToolUse guard `block-direct-issue-create.sh` refuses a direct tracker-creation command (`gh issue create`, `gh api` POST to an issues endpoint, `linear`/`jira`/`acli` creates, equivalent `curl` posts) that carries **no readiness declaration** — no configured build-ready role and no `[lisa-human-gate]` marker. It shipped because this rule as prose did not bind: 13 of 13 issues filed during the session that merged it bypassed it, several by the agent that wrote it, while the one obligation backed by a git hook was honored 50 of 50 times. It stands down where no tracker is configured, and for an operator who set `LISA_ALLOW_DIRECT_ISSUE_CREATE` **in the ambient environment** — never from an inline assignment on the refused command.
25
25
 
26
+ **The role is the TARGET repository's, not the caller's.** A creation addressed at another repository — `--repo` / `-R`, or a `repos/<org>/<repo>/issues` endpoint — is judged against that repository's build-ready role: `hardening.upstreamReadyRole` (default `status:ready`) when the target is `hardening.upstreamRepo`, and the stock `status:ready` otherwise. A creation naming no repository, or naming the project's own `github.org`/`github.repo`, is judged against the project's own role exactly as before. A declaration is required either way; only its vocabulary changes.
27
+
26
28
  Full contract (the per-vendor before/after table, marker format, and recovery sweep): [reference/ready-role-filing.md](../reference/ready-role-filing.md).
@@ -179,8 +179,14 @@ the chokepoint cannot answer for is a setup gap to fix, not a store to add.
179
179
  4. Keep MCP adapters and name the operations for which MCP is the only substrate.
180
180
  5. Make the terminal failure name the exact credential and remediation command.
181
181
 
182
- Single-substrate access skills are conformant when their one substrate is
183
- provider-credential-authenticated (`lisa-sonarcloud-access`: the official SonarQube MCP
184
- authenticates headlessly from `SONARQUBE_CLI_TOKEN`, so it *is* the tier 1 substrate and
185
- needs no separate REST tier). Reserve a multi-tier ladder for vendors whose MCP is
186
- browser-OAuth or keychain-bound and therefore dead headless.
182
+ An access skill whose MCP is provider-credential-authenticated has no interactive
183
+ tier to demote — that MCP *is* the tier 1 substrate (`lisa-sonarcloud-access`: the
184
+ official SonarQube MCP authenticates headlessly from `SONARQUBE_CLI_TOKEN`).
185
+ Reserve a browser-OAuth demotion for vendors whose MCP is keychain-bound and
186
+ therefore dead headless.
187
+
188
+ That is a claim about tier ORDER and never about exclusivity. "Tier 1 needs no
189
+ demotion" does not license "there is no other substrate": where the vendor exposes
190
+ a token-authenticated Web API, it remains a sanctioned read-only fallback for a
191
+ surface on which the MCP is not wired, and a missing MCP there is a demotion rather
192
+ than a terminal tool-access failure.
@@ -33,14 +33,17 @@ unavailable" means.
33
33
  Do not blind-retry a failed or absent substrate, and never silently no-op when no
34
34
  tier is available.
35
35
 
36
- Some MCPs authenticate headlessly from an env token and need no separate REST
37
- tier — such an MCP **is** the configured-provider substrate on both developer
38
- machines and cloud routines, so a single-substrate access skill is conformant. The
39
- official SonarQube MCP is one such case (`SONARQUBE_CLI_TOKEN`
40
- [+ `SONARQUBE_CLI_ORG`/`SONARQUBE_CLI_SERVER`]): `lisa-sonarcloud-access` resolves it as a
41
- single substrate with no hand-rolled REST fallback. Reserve the multi-tier ladder
42
- for vendors whose MCP is browser-OAuth or keychain-bound and therefore dead
43
- headless.
36
+ Some MCPs authenticate headlessly from an env token such an MCP **is** the
37
+ configured-provider substrate on both developer machines and cloud routines, so it
38
+ sits at tier 1 with nothing above it to demote. The official SonarQube MCP is one
39
+ such case (`SONARQUBE_CLI_TOKEN` [+ `SONARQUBE_CLI_ORG`/`SONARQUBE_CLI_SERVER`]):
40
+ `lisa-sonarcloud-access` prefers it and falls back to the token-authenticated Sonar
41
+ Web API for reads on a surface where it is not wired. Reserve the browser-OAuth
42
+ demotion for vendors whose MCP is keychain-bound and therefore dead headless.
43
+
44
+ Being top of the ladder is not the same as being the whole ladder. An access layer
45
+ may say "prefer this substrate"; it may not say "this is the only one" while the
46
+ vendor exposes a token-authenticated API the same credential already opens.
44
47
 
45
48
  ## Access Skills
46
49
 
@@ -66,7 +69,7 @@ Source audit date: 2026-06-23. Scope: `plugins/src/**/skills/**` and
66
69
  | Notion | setup-only references | `notion-access` | Done. |
67
70
  | Linear | Multiple base queue/read/write/verify skills | Raw Linear MCP | Access layer added; consumers still need migration to `linear-access`. |
68
71
  | Jam | None in `plugins/src` at audit time | No access layer | Access layer added for host rules that include Jam triage. |
69
- | SonarCloud | Routed through `sonarcloud-access` | Official SonarQube MCP (single substrate) | Migrated to the official token-authed SonarQube MCP; bespoke REST dispatch removed. |
72
+ | SonarCloud | Routed through `sonarcloud-access` | Official SonarQube MCP preferred; token-authed Web API as the read-only fallback | Migrated to the official token-authed SonarQube MCP; the bespoke REST *dispatch* was removed, which is not the same as forbidding a Web API read. |
70
73
  | Sentry | No Sentry MCP references in `plugins/src`; CLI/REST mentions only | CLI/REST scattered in observability docs | Access layer added for future MCP-backed consumers. |
71
74
  | PostHog | No PostHog MCP references in `plugins/src`; observability docs mention PostHog detection | No access layer | Access layer added for future MCP-backed consumers. |
72
75
  | Google Play | No MCP surface; EAS submit docs only | `play-store-access` for Expo post-submit release visibility | Done for Expo stack. |
@@ -67,6 +67,33 @@ Two ways out, both deliberate:
67
67
  - **No tracker configured** — `.lisa.config.json` absent or carrying no `tracker`. There is no `lisa-tracker-write` to route through, so the guard stands down. This is the bootstrapping case, and it is *detected* rather than asserted: nobody has to remember an env var to bring up a new repo.
68
68
  - **`LISA_ALLOW_DIRECT_ISSUE_CREATE`** in the ambient environment — the human operator's override, mirroring `LISA_ALLOW_INSTRUCTION_FILE_WRITE`. It is honored **only** from the environment the hook inherits and is refused outright when it appears as an inline assignment on the intercepted command. A tool-call shell is fresh each time and its exports never reach the hook, so the ambient variable can only have been set by a human before the session began. An escape the governed agent reaches by typing one more token in front of the command it was just refused is not an escape hatch; it is this rule's original failure with extra steps.
69
69
 
70
+ ### Which repository's vocabulary answers
71
+
72
+ The guard demands a build-ready role. Whose role that is depends on **where the item lands**, not on whose config file is nearest.
73
+
74
+ Resolving it from the calling project unconditionally made the highest-value report Lisa receives unfileable. Filing upstream is not a hypothetical: `lisa-persist-learning` step 6 files a redacted hardening ticket at `hardening.upstreamRepo` on a cron, and Lisa's own design already states that repository runs *its own* build queue off *its own* ready role. The guard was the one component assuming there is only one. What actually happened, per calling tracker:
75
+
76
+ | Caller's tracker | What was demanded of another repository | Outcome |
77
+ |---|---|---|
78
+ | `jira` / `linear` | a workflow **state**, as the value of a `gh --label` | **unsatisfiable** — that label does not exist there and `gh` rejects an unknown one, so obeying the guard made the command fail |
79
+ | `github`, renamed lane | the caller's own label | refused; the target never carried that token |
80
+ | `github`, stock lane | `status:ready` | worked — because both repositories happened to pick the same string. A coincidence, not a routing decision, which is why the defect was intermittent across the fleet rather than universal |
81
+
82
+ Unsatisfiable is not the same as refused. The only escape that *did* work cross-repo was `[lisa-human-gate]`, and using it there is a lie about the item: it stamps a build-ready defect report as held for a human product call, and the target's build queue scans the ready role and nothing else. The report is filed and never picked up — the exact incomplete handoff this guard exists to prevent, committed one repository over. (The workaround actually in use was worse still: ask a second agent, in a repo with the right label, to type the command. A control routinely satisfied by delegating around it is misrouting, not enforcing.)
83
+
84
+ So the target is extracted from the command — `--repo` / `-R` / `--repo=o/r` in any position before a bare `--`, and the `repos/<org>/<repo>/issues` endpoint on the `gh api` and `curl` paths — and the role resolved for **it**:
85
+
86
+ - **No target named, or the project's own `github.org`/`github.repo`** — the project's own role, unchanged.
87
+ - **The configured `hardening.upstreamRepo`** — `hardening.upstreamReadyRole`, defaulting to `status:ready`.
88
+ - **Any other repository** — the stock `status:ready`. Lisa has no configuration describing it, and the caller's token is categorically not its lane.
89
+ - **A GitHub-tracked project that declares no `github.org`/`github.repo`** cannot be compared against a target at all, so both roles are accepted. Permissive about *which* token, never about *whether* one is required.
90
+
91
+ The source is config, not the network. A live `gh api repos/<org>/<repo>/labels` lookup would be more general and is the wrong trade for a `PreToolUse` hook: a round-trip on every intercepted command, and a new fail-open surface when it errors.
92
+
93
+ The refusal changes with it. `/lisa:track` routes through `lisa-tracker-write` to the **caller's own** tracker and therefore cannot file upstream at all, so recommending it for a cross-repo filing sends the agent to a path that structurally cannot do what it was refused for. The cross-repo message names `bunx @codyswann/lisa file-upstream` — the path Lisa actually ships, which composes a public-safe body through an allowlist projection — and names the target's ready role.
94
+
95
+ What did **not** change: an undeclared cross-repo create is still refused. Only which token satisfies the declaration moved.
96
+
70
97
  ### Where the guard's authority ends
71
98
 
72
99
  Two limits, stated rather than implied, because a control that documents its own boundary is worth more than one that suggests completeness it does not have.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: lisa-sonarcloud-access
3
- description: "Vendor-neutral access layer for SonarQube Cloud/Server. Sonar triage skills MUST delegate through this skill rather than calling the SonarQube MCP tools directly. Single substrate: the official SonarQube MCP server (mcp__sonarqube__*), authenticated headlessly from SONARQUBE_CLI_TOKEN (+ SONARQUBE_CLI_ORG for Cloud, SONARQUBE_CLI_SERVER for Server)."
3
+ description: "Vendor-neutral access layer for SonarQube Cloud/Server. Sonar triage skills MUST delegate through this skill rather than calling the SonarQube MCP tools directly. Preferred substrate: the official SonarQube MCP server (mcp__sonarqube__*), authenticated headlessly from SONARQUBE_CLI_TOKEN (+ SONARQUBE_CLI_ORG for Cloud, SONARQUBE_CLI_SERVER for Server); the Sonar Web API, authenticated with the same token, is the sanctioned fallback when the MCP is not wired on this surface."
4
4
  allowed-tools: ["Bash", "Read", "Skill"]
5
5
  ---
6
6
 
@@ -12,16 +12,26 @@ this skill owns the tool selection.
12
12
 
13
13
  ## Substrate
14
14
 
15
- There is exactly one substrate: the **official SonarQube MCP server**, provided by
15
+ The **preferred** substrate is the **official SonarQube MCP server**, provided by
16
16
  the `sonarqube` plugin and launched by the `sonar` CLI (`sonar run mcp`). It
17
17
  authenticates headlessly from environment variables — no browser, no OS keychain —
18
18
  so it is the same substrate on a developer machine and in a headless cloud routine.
19
-
20
- That makes this skill conformant with `credential-substrate-precedence` as a
21
- single-substrate access layer: this MCP **is** the configured-provider substrate
22
- (it is token-authenticated, not browser-OAuth), so there is no interactive tier to
23
- demote and no second REST tier to add. Identity-match against the configured org
24
- remains mandatory, as on every substrate.
19
+ Prefer it because it owns the tool selection this skill exists to centralise.
20
+
21
+ It is not the only sanctioned substrate. The **Sonar Web API**, authenticated with
22
+ the same token, is a sanctioned fallback for read-only operations when the MCP is
23
+ not wired on this surface. An earlier revision of this file said otherwise — that
24
+ the MCP was "the only sanctioned substrate" and a missing MCP was "not a reason to
25
+ curl the Web API" — and that was wrong on its face: `.github/workflows/quality.yml`
26
+ already reads `api/ce/task` with a token to turn an opaque failed scan into an
27
+ operator-readable one. A rule the shipping repository contradicts is documentation,
28
+ not a constraint.
29
+
30
+ The distinction that does hold: a token-authenticated MCP **is** the
31
+ configured-provider substrate under `credential-substrate-precedence` (it is not
32
+ browser-OAuth), so there is no interactive tier to demote here. That is a statement
33
+ about tier ORDER, not about exclusivity. Identity-match against the configured org
34
+ remains mandatory on either substrate.
25
35
 
26
36
  - `SONARQUBE_CLI_TOKEN` — required (Sonar user/analysis token).
27
37
  - `SONARQUBE_CLI_ORG` — required for SonarQube Cloud.
@@ -89,16 +99,21 @@ being on PATH is not access. Probe by searching projects (the `projects` toolset
89
99
  always enabled):
90
100
 
91
101
  - If a `mcp__sonarqube__*` project-search tool returns, access is proven.
92
- - If no `mcp__sonarqube__*` tool is present, or the call fails authentication, fail
93
- loudly and do not improvise a substitute:
102
+ - If no `mcp__sonarqube__*` tool is present on this surface, that is a **demotion,
103
+ not a blocker**. Fall back to the Sonar Web API with the same resolved token for
104
+ the read-only operation at hand.
105
+ - Only when neither substrate can prove access — no MCP tool *and* no usable token
106
+ — is this a terminal tool-access-gate failure. Say so, and do not improvise a
107
+ third route:
94
108
 
95
109
  ```text
96
110
  Error: no SonarQube access. Run /lisa:setup:sonar (or `sonar integrate <agent>`), and set SONARQUBE_CLI_TOKEN (+ SONARQUBE_CLI_ORG for Cloud / SONARQUBE_CLI_SERVER for Server).
97
111
  ```
98
112
 
99
- There is no hand-rolled REST fallback: the official MCP is headless-capable via the
100
- token env vars, so it is the only sanctioned substrate. A missing MCP is a
101
- tool-access-gate failure to surface, not a reason to curl the Web API.
113
+ The distinction matters because the two cases have different remedies. An absent
114
+ MCP on a surface that holds a valid credential is a wiring gap the agent can work
115
+ around; treating it as terminal manufactures a tool-access failure on a surface
116
+ that has access.
102
117
 
103
118
  ## Operation → toolset map
104
119
 
@@ -127,14 +142,17 @@ tool accepts them.
127
142
  Every operation in the map above is **read-only** — quality, coverage, and
128
143
  security data. So the `credential-substrate-precedence` guarded fallback for
129
144
  mutating operations (write, read back, assert the tenant from the response, roll
130
- back on mismatch) is not engaged here; with one substrate there is nothing to
131
- fall back to in any case. A future operation that mutates Sonar state marking
145
+ back on mismatch) is not engaged here, because nothing in the map writes. The
146
+ Web API fallback is sanctioned for READS only; it does not open a mutation path.
147
+ A future operation that mutates Sonar state — marking
132
148
  a hotspot safe, changing an issue's status — is a write and MUST reconcile by
133
149
  read-back before any retry.
134
150
 
135
151
  ## Invariants
136
152
 
137
- - The official SonarQube MCP is the only substrate; there is no REST fallback.
153
+ - The official SonarQube MCP is the preferred substrate, not the only one. The
154
+ Sonar Web API with the same token is a sanctioned read-only fallback, and a
155
+ missing MCP is a demotion rather than a terminal failure.
138
156
  - `SONARQUBE_CLI_*` values are resolved through `lisa-secrets-access` and
139
157
  exported in-process, with the bare environment variables as the documented
140
158
  fallback. Never write them to a dotfile or a `.env` on a local surface.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "3.51.2",
3
+ "version": "3.51.4",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"