@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
@@ -43,6 +43,16 @@ const LisaBlockDirectIssueCreate = async () => {
43
43
  */
44
44
  const LABEL_FLAG =
45
45
  /--(?:label|labels|add-label|status|state)(?:=|\s+)(['"]?)([^'"\s]+)\1/g;
46
+ /**
47
+ * The repository a creation is ADDRESSED at, which decides whose ready role
48
+ * answers for it. `-R` is honored here even though short flags are refused
49
+ * for labels above: `--repo`/`-R` is one flag on one CLI with one meaning,
50
+ * where the label short forms collide across trackers.
51
+ */
52
+ const REPO_FLAG = /(?:^|\s)(?:--repo|-R)(?:=|\s+)(['"]?)([^'"\s]+)\1/;
53
+ const ISSUES_ENDPOINT = /repos\/([^/\s]+)\/([^/\s]+)\/issues\b/;
54
+ const DEFAULT_READY_ROLE = "status:ready";
55
+ const DEFAULT_UPSTREAM_REPO = "CodySwannGT/lisa";
46
56
  const CREATION_SIGNATURES: readonly {
47
57
  readonly re: RegExp;
48
58
  readonly name: string;
@@ -85,9 +95,35 @@ const LisaBlockDirectIssueCreate = async () => {
85
95
 
86
96
  interface LisaConfig {
87
97
  tracker?: string;
88
- github?: { labels?: { build?: { ready?: string } } };
98
+ github?: {
99
+ org?: string;
100
+ repo?: string;
101
+ labels?: { build?: { ready?: string } };
102
+ };
89
103
  jira?: { workflow?: { ready?: string } };
90
104
  linear?: { workflow?: { ready?: string } };
105
+ hardening?: { upstreamRepo?: string; upstreamReadyRole?: string };
106
+ }
107
+
108
+ /** Everything the guard needs to decide whose ready role answers. */
109
+ interface FilingPolicy {
110
+ /** The calling project's own build-ready role. */
111
+ readonly readyRole: string;
112
+ /** The calling project's own repository, when it declares one. */
113
+ readonly ownRepo: string | undefined;
114
+ /** The repository upstream defects are filed at. */
115
+ readonly upstreamRepo: string;
116
+ /** The ready role that repository runs its build queue off. */
117
+ readonly upstreamReadyRole: string;
118
+ /** Whether the caller's ready role is a GitHub label at all. */
119
+ readonly callerIsGithub: boolean;
120
+ }
121
+
122
+ /** Which roles satisfy a filing, and the target a refusal should name. */
123
+ interface Verdict {
124
+ readonly roles: readonly string[];
125
+ /** Set only when the filing is provably addressed at another repository. */
126
+ readonly named: string | undefined;
91
127
  }
92
128
 
93
129
  /**
@@ -104,7 +140,7 @@ const LisaBlockDirectIssueCreate = async () => {
104
140
  };
105
141
 
106
142
  /**
107
- * The configured build-ready role, or undefined when no tracker is set.
143
+ * The filing policy, or undefined when no tracker is set.
108
144
  *
109
145
  * Keyed off the resolved `tracker` rather than provider precedence: reading
110
146
  * whichever provider block happened to appear first could hand a GitHub label
@@ -117,9 +153,9 @@ const LisaBlockDirectIssueCreate = async () => {
117
153
  * Resolved once per session at plugin init. That is a deliberate snapshot: a
118
154
  * config edit mid-session needs a session restart to take effect, which is
119
155
  * the same lifetime as the rest of this plugin's state.
120
- * @returns The role token, or undefined.
156
+ * @returns The filing policy, or undefined.
121
157
  */
122
- const resolveReadyRole = async (): Promise<string | undefined> => {
158
+ const resolvePolicy = async (): Promise<FilingPolicy | undefined> => {
123
159
  const base = await readConfig(".lisa.config.json");
124
160
  const local = await readConfig(".lisa.config.local.json");
125
161
  const tracker = local.tracker ?? base.tracker;
@@ -132,10 +168,81 @@ const LisaBlockDirectIssueCreate = async () => {
132
168
  if (tracker === "linear") return config.linear?.workflow?.ready;
133
169
  return undefined;
134
170
  };
135
- return pick(local) ?? pick(base) ?? "status:ready";
171
+ const org = local.github?.org ?? base.github?.org;
172
+ const name = local.github?.repo ?? base.github?.repo;
173
+ const hardening = { ...base.hardening, ...local.hardening };
174
+ return {
175
+ readyRole: pick(local) ?? pick(base) ?? DEFAULT_READY_ROLE,
176
+ ownRepo: org && name ? `${org}/${name}`.toLowerCase() : undefined,
177
+ upstreamRepo: (
178
+ hardening.upstreamRepo ?? DEFAULT_UPSTREAM_REPO
179
+ ).toLowerCase(),
180
+ upstreamReadyRole: hardening.upstreamReadyRole ?? DEFAULT_READY_ROLE,
181
+ callerIsGithub: tracker === "github",
182
+ };
183
+ };
184
+
185
+ const policy = await resolvePolicy();
186
+
187
+ /**
188
+ * A repository token reduced to a comparable `owner/name`.
189
+ *
190
+ * gh accepts `OWNER/REPO`, `HOST/OWNER/REPO`, and a full browser URL, and
191
+ * GitHub is case-insensitive about both halves — so comparing raw tokens
192
+ * would call one repository two different places depending on how it was
193
+ * typed.
194
+ * @param value The raw token.
195
+ * @returns A lowercased `owner/name`, or undefined when it names no repo.
196
+ */
197
+ const normaliseRepo = (value: string): string | undefined => {
198
+ const text = value.replace(/\.git$/, "");
199
+ const parts = text.split("/").filter(part => part && !part.endsWith(":"));
200
+ if (parts.length < 2) return undefined;
201
+ return `${parts.at(-2)}/${parts.at(-1)}`.toLowerCase();
136
202
  };
137
203
 
138
- const readyRole = await resolveReadyRole();
204
+ /**
205
+ * The repository this creation is addressed at, when it names one.
206
+ * @param declarable The command text up to a bare `--`.
207
+ * @returns A lowercased `owner/name`, or undefined when the calling project
208
+ * is the target — the common case, and today's behaviour.
209
+ */
210
+ const targetRepository = (declarable: string): string | undefined => {
211
+ const flag = REPO_FLAG.exec(declarable);
212
+ if (flag?.[2]) return normaliseRepo(flag[2]);
213
+ const endpoint = ISSUES_ENDPOINT.exec(declarable);
214
+ if (endpoint) return normaliseRepo(`${endpoint[1]}/${endpoint[2]}`);
215
+ return undefined;
216
+ };
217
+
218
+ /**
219
+ * Which ready-role tokens satisfy a creation addressed at `target`.
220
+ *
221
+ * A declaration is demanded either way; this decides only WHOSE vocabulary
222
+ * it is written in. The last branch is the indeterminate case — a
223
+ * GitHub-tracked project declaring no `github.org`/`github.repo` cannot be
224
+ * compared against a target, so both roles are accepted rather than
225
+ * inventing a refusal, and no cross-repo target is reported: the cross-repo
226
+ * message would claim this project's role does not answer, which is false in
227
+ * exactly that branch.
228
+ * @param resolved The filing policy.
229
+ * @param target The addressed repository, or undefined.
230
+ * @returns The acceptable role tokens, and the target to name in a refusal.
231
+ */
232
+ const rolesFor = (
233
+ resolved: FilingPolicy,
234
+ target: string | undefined
235
+ ): Verdict => {
236
+ if (target === undefined || target === resolved.ownRepo)
237
+ return { roles: [resolved.readyRole], named: undefined };
238
+ const role =
239
+ target === resolved.upstreamRepo
240
+ ? resolved.upstreamReadyRole
241
+ : DEFAULT_READY_ROLE;
242
+ if (resolved.ownRepo !== undefined || !resolved.callerIsGithub)
243
+ return { roles: [role], named: target };
244
+ return { roles: [resolved.readyRole, role], named: undefined };
245
+ };
139
246
 
140
247
  return {
141
248
  "tool.execute.before": async (
@@ -143,7 +250,7 @@ const LisaBlockDirectIssueCreate = async () => {
143
250
  output: { args?: { command?: string } }
144
251
  ) => {
145
252
  if (input.tool !== "bash") return;
146
- if (readyRole === undefined) return;
253
+ if (policy === undefined) return;
147
254
  const command = String(output.args?.command ?? "");
148
255
  if (!command) return;
149
256
  if (/--help\b|\s-h(\s|$)/.test(command)) return;
@@ -168,13 +275,59 @@ const LisaBlockDirectIssueCreate = async () => {
168
275
  // post-`--` flags outright; acli parses straight past them and creates
169
276
  // the item with the flag silently unapplied (verified). Fails closed.
170
277
  const declarable = command.split(/(?:^|\s)--(?:\s|$)/)[0] ?? command;
278
+ // WHICH repository's vocabulary answers is decided by where the create
279
+ // is addressed, not by whose config file is nearest. A filing aimed at
280
+ // another repository used to be judged against this project's role,
281
+ // which that repository does not carry — and on a JIRA or Linear caller
282
+ // the demanded token was a workflow STATE, so there was no satisfiable
283
+ // answer at all. The property is unchanged: a declaration is still
284
+ // required, wherever the item lands.
285
+ const { roles, named } = rolesFor(policy, targetRepository(declarable));
171
286
  const declaresRole = [...declarable.matchAll(LABEL_FLAG)].some(match =>
172
287
  (match[2] ?? "")
173
288
  .split(",")
174
289
  .map(part => part.trim())
175
- .includes(readyRole)
290
+ .some(candidate => roles.includes(candidate))
176
291
  );
177
292
  if (declaresRole || declarable.includes(HUMAN_GATE_MARKER)) return;
293
+ if (named !== undefined)
294
+ throw new Error(
295
+ [
296
+ `block-direct-issue-create: refusing ${signature.name} — this filing declares no readiness.`,
297
+ "",
298
+ "WHY: a work item filed without the build-ready role is an incomplete",
299
+ "handoff. Build-intake scans the ready lane and nothing else, so nothing",
300
+ "will ever pick it up: the write succeeds and the work still dies.",
301
+ "",
302
+ `THIS FILING IS ADDRESSED AT ANOTHER REPOSITORY: ${named}.`,
303
+ "That repository runs its own build queue off its own ready role, so this",
304
+ "project's role does not answer for it — and this project's filing flow",
305
+ "writes to this project's tracker, so it cannot reach the target at all.",
306
+ "",
307
+ "FILE IT THE SANCTIONED WAY:",
308
+ "",
309
+ "1. An upstream defect or hardening report. Use the upstream filing path,",
310
+ " which composes a redacted, public-safe body through an allowlist",
311
+ " projection instead of free-form prose:",
312
+ "",
313
+ " bunx @codyswann/lisa file-upstream --input <filing-event>.json",
314
+ "",
315
+ " lisa-persist-learning step 6 runs exactly this, headless, on a cron.",
316
+ "2. If you must run the CLI directly, the command has to carry the TARGET",
317
+ ` repository's build-ready role — ${roles.join(", ")} — as the value of a`,
318
+ " --label flag. Configure it as hardening.upstreamReadyRole when the",
319
+ " target renamed its lane.",
320
+ "",
321
+ `DO NOT reach for ${HUMAN_GATE_MARKER} to get past this one. It still`,
322
+ "satisfies the guard, but on an upstream defect report it is a false",
323
+ "declaration: the target's build queue scans the ready role and nothing",
324
+ "else, so the report is filed and never picked up.",
325
+ "",
326
+ "OPERATOR ESCAPE: a human can export LISA_ALLOW_DIRECT_ISSUE_CREATE=1 in",
327
+ "the environment before starting the session. Setting it inline on this",
328
+ "command is deliberately refused.",
329
+ ].join("\n")
330
+ );
178
331
  throw new Error(
179
332
  [
180
333
  `block-direct-issue-create: refusing ${signature.name} — this filing declares no readiness.`,
@@ -197,7 +350,7 @@ const LisaBlockDirectIssueCreate = async () => {
197
350
  "the ready-role-filing rule for the full contract.",
198
351
  "",
199
352
  "If you must run the CLI directly, the command has to carry one of the",
200
- `two declarations itself: the configured build-ready role "${readyRole}",`,
353
+ `two declarations itself: the configured build-ready role "${policy.readyRole}",`,
201
354
  `or a ${HUMAN_GATE_MARKER} marker in the body it submits.`,
202
355
  "",
203
356
  "OPERATOR ESCAPE: a human can export LISA_ALLOW_DIRECT_ISSUE_CREATE=1 in",
package/package.json CHANGED
@@ -141,7 +141,7 @@
141
141
  "zod-validation-error": "^4.0.0"
142
142
  },
143
143
  "name": "@codyswann/lisa",
144
- "version": "3.51.2",
144
+ "version": "3.51.4",
145
145
  "description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
146
146
  "main": "dist/index.js",
147
147
  "exports": {
@@ -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"
@@ -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"
@@ -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.