@christang/keel 5.74.0 → 5.75.0

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.
package/README.md CHANGED
@@ -55,8 +55,12 @@ claude plugin marketplace add TanglmChris/keel
55
55
  claude plugin install keel@keel-marketplace
56
56
  ```
57
57
 
58
- After an update, `/reload-plugins` applies it in the running session; otherwise it applies at the
59
- next start.
58
+ Updates arrive by themselves once a project is set up: `keel --init --target claude` (and
59
+ `keel --install`) declares auto-update for `keel-marketplace` in the project's
60
+ `.claude/settings.json`, which Claude reads before its own default of off. A new release is fetched
61
+ in the background after a session's first message; `/reload-plugins` applies it in the running
62
+ session, and otherwise it applies at the next start. To opt out, set that entry's `autoUpdate` to
63
+ `false`; Keel keeps a value the project states, and `keel --doctor` reports which one is declared.
60
64
 
61
65
  **Codex, and your own terminal** — install the CLI as well (it also installs the bundled
62
66
  OpenSpec CLI):
package/README.zh-CN.md CHANGED
@@ -46,7 +46,10 @@ claude plugin marketplace add TanglmChris/keel
46
46
  claude plugin install keel@keel-marketplace
47
47
  ```
48
48
 
49
- 更新之后,在当前会话执行 `/reload-plugins` 即可生效;不执行的话,下次启动时生效。
49
+ 项目初始化后,更新会自己到来:`keel --init --target claude`(以及 `keel --install`)会在项目的
50
+ `.claude/settings.json` 里为 `keel-marketplace` 声明自动更新,Claude 先读这个声明,而不是它默认的"关闭"。
51
+ 新版本会在会话发出第一条消息后在后台下载;执行 `/reload-plugins` 即在当前会话生效,否则下次启动时生效。
52
+ 不想自动更新,就把那一项的 `autoUpdate` 设为 `false`;项目写明的值 Keel 会保留,`keel --doctor` 会报告当前声明的是哪一个。
50
53
 
51
54
  **Codex,以及你自己的终端** —— 另外装一份 CLI(同时装上捆绑的 OpenSpec CLI):
52
55
 
@@ -1,4 +1,4 @@
1
- <!-- keel:start version=5.74.0 -->
1
+ <!-- keel:start version=5.75.0 -->
2
2
  ## Keel Bootstrap
3
3
 
4
4
  - Start every session with `keel context`; OpenSpec artifacts and Git are the only durable authority — never native memory, goals, or transcripts.
package/bin/keel.js CHANGED
@@ -755,6 +755,46 @@ function openspecReportedVersion(command) {
755
755
  return match ? match[0] : null;
756
756
  }
757
757
 
758
+ // What the project declares about Keel plugin auto-update (#164). Claude reads
759
+ // it first from `autoUpdate` on the marketplace's `extraKnownMarketplaces`
760
+ // entry, and `keel --install --target claude` writes it. The host reports no
761
+ // auto-update state Keel could read, so this is the declaration only; whether
762
+ // updates actually arrive is the host's to show.
763
+ function pluginAutoUpdateDeclaration(repo) {
764
+ const settingsPath = path.join(repo, ".claude", "settings.json");
765
+ const observation =
766
+ "Keel reads the declaration; the host does not expose whether updates run";
767
+ let entry;
768
+ try {
769
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
770
+ entry = ((settings && settings.extraKnownMarketplaces) || {})[
771
+ "keel-marketplace"
772
+ ];
773
+ } catch {
774
+ entry = undefined;
775
+ }
776
+ if (entry && entry.autoUpdate === true) {
777
+ return [
778
+ "ok",
779
+ `.claude/settings.json declares keel-marketplace autoUpdate: true; ${observation}`,
780
+ ];
781
+ }
782
+ if (entry && entry.autoUpdate === false) {
783
+ return [
784
+ "manual",
785
+ ".claude/settings.json declares keel-marketplace autoUpdate: false, "
786
+ + "which is the project's choice; updates arrive only through "
787
+ + "`claude plugin update`",
788
+ ];
789
+ }
790
+ return [
791
+ "manual",
792
+ "not declared, so Claude leaves auto-update off for this marketplace; "
793
+ + "run keel --install --target claude, or enable it under /plugin → "
794
+ + "Marketplaces",
795
+ ];
796
+ }
797
+
758
798
  // The root is the repository under diagnosis, never PACKAGE_ROOT. Rooting this
759
799
  // at Keel's own install location made the line a statement about a repository
760
800
  // the reader was never shown: a consumer pinning 9.9.9 was told the version
@@ -1572,6 +1612,10 @@ function printTargetSurface(repo, target) {
1572
1612
  : "claude plugin install keel@<marketplace>"
1573
1613
  } and verify in a fresh session`
1574
1614
  );
1615
+ if (target === "claude") {
1616
+ const [status, detail] = pluginAutoUpdateDeclaration(repo);
1617
+ printDoctorLine("plugin auto-update", status, detail);
1618
+ }
1575
1619
  }
1576
1620
 
1577
1621
  const openspecSkillRoot = openspecSkillRootForTarget(target);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@christang/keel",
3
- "version": "5.74.0",
3
+ "version": "5.75.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@christang/keel",
9
- "version": "5.74.0",
9
+ "version": "5.75.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@fission-ai/openspec": "^1.4.1"
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@christang/keel",
3
3
  "displayName": "Keel",
4
4
  "description": "Keel OpenSpec execution discipline CLI for Claude Code, Codex, and OpenCode.",
5
- "version": "5.74.0",
5
+ "version": "5.75.0",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keel",
3
- "version": "5.74.0",
3
+ "version": "5.75.0",
4
4
  "description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
5
5
  "author": {
6
6
  "name": "TanglmChris",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keel",
3
- "version": "5.74.0",
3
+ "version": "5.75.0",
4
4
  "description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
5
5
  "author": {
6
6
  "name": "TanglmChris",
@@ -532,6 +532,13 @@ def collect_actions(repo: Path, target: str) -> list[InstallAction]:
532
532
  )
533
533
  if "claude" in targets and not unmanaged_keel_content_warning(repo, "CLAUDE.md"):
534
534
  actions.append(managed_content_action("CLAUDE.md", CLAUDE_IMPORT_BLOCK))
535
+ if "claude" in targets:
536
+ actions.append(
537
+ InstallAction(
538
+ relative_path=Path(".claude/settings.json"),
539
+ strategy="claude-marketplace-settings",
540
+ )
541
+ )
535
542
 
536
543
  actions.append(openspec_config_action())
537
544
  actions.append(keel_config_action())
@@ -923,6 +930,82 @@ def action_source_content(action: InstallAction) -> str:
923
930
  return action.content or ""
924
931
 
925
932
 
933
+ # Claude reads a marketplace's auto-update setting first from `autoUpdate` on
934
+ # its `extraKnownMarketplaces` entry in any settings file, and defaults it off
935
+ # for every marketplace that is not Anthropic's own (#164). The project's
936
+ # committed `.claude/settings.json` is such a file, so declaring it here is what
937
+ # lets a Keel release reach a project with nobody toggling anything.
938
+ KEEL_MARKETPLACE_NAME = "keel-marketplace"
939
+ KEEL_MARKETPLACE_ENTRY = {
940
+ "source": {"source": "github", "repo": "TanglmChris/keel"},
941
+ "autoUpdate": True,
942
+ }
943
+
944
+
945
+ def load_settings_object(existing: str, purpose: str) -> dict:
946
+ if not existing.strip():
947
+ return {}
948
+ try:
949
+ settings = json.loads(existing)
950
+ except json.JSONDecodeError as exc:
951
+ raise ValueError(
952
+ f"cannot {purpose} .claude/settings.json because it is not valid JSON: {exc}"
953
+ ) from exc
954
+ if not isinstance(settings, dict):
955
+ raise ValueError(
956
+ f"cannot {purpose} .claude/settings.json because it is not a JSON object"
957
+ )
958
+ return settings
959
+
960
+
961
+ def merge_claude_marketplace_settings(existing: str) -> tuple[str, str]:
962
+ """Declare auto-update for the Keel marketplace, keeping the project's say.
963
+
964
+ An existing entry keeps its own source — a developer may have added the
965
+ marketplace from a local directory under this name — and any `autoUpdate`
966
+ it states, because a project that wrote `false` has decided.
967
+ """
968
+ settings = load_settings_object(existing, "declare plugin auto-update in")
969
+ markets = settings.get("extraKnownMarketplaces", {})
970
+ if not isinstance(markets, dict):
971
+ raise ValueError(
972
+ "cannot declare plugin auto-update because .claude/settings.json "
973
+ "field 'extraKnownMarketplaces' is not an object"
974
+ )
975
+ entry = markets.get(KEEL_MARKETPLACE_NAME)
976
+ if isinstance(entry, dict):
977
+ declared = dict(entry)
978
+ declared.setdefault("autoUpdate", True)
979
+ else:
980
+ declared = json.loads(json.dumps(KEEL_MARKETPLACE_ENTRY))
981
+ merged_settings = dict(settings)
982
+ merged_settings["extraKnownMarketplaces"] = {**markets, KEEL_MARKETPLACE_NAME: declared}
983
+ merged = json.dumps(merged_settings, indent=2, ensure_ascii=False) + "\n"
984
+ before = (
985
+ json.dumps(settings, indent=2, ensure_ascii=False) + "\n"
986
+ if existing.strip()
987
+ else ""
988
+ )
989
+ return merged, "skip" if before == merged else "update"
990
+
991
+
992
+ def remove_claude_marketplace_settings(existing: str) -> tuple[str | None, str]:
993
+ """Remove the entry only when it is exactly the one Keel writes."""
994
+ settings = load_settings_object(existing, "remove plugin auto-update from")
995
+ markets = settings.get("extraKnownMarketplaces")
996
+ if not isinstance(markets, dict) or markets.get(KEEL_MARKETPLACE_NAME) != KEEL_MARKETPLACE_ENTRY:
997
+ return existing, "skip"
998
+ remaining = {k: v for k, v in markets.items() if k != KEEL_MARKETPLACE_NAME}
999
+ updated = dict(settings)
1000
+ if remaining:
1001
+ updated["extraKnownMarketplaces"] = remaining
1002
+ else:
1003
+ updated.pop("extraKnownMarketplaces", None)
1004
+ if not updated:
1005
+ return None, "remove"
1006
+ return json.dumps(updated, indent=2, ensure_ascii=False) + "\n", "remove-managed"
1007
+
1008
+
926
1009
  def load_hook_config(content: str) -> dict:
927
1010
  try:
928
1011
  config = json.loads(content)
@@ -1047,6 +1130,15 @@ def plan_action(
1047
1130
  destination = require_inside_repo(repo, action.relative_path)
1048
1131
  source_content = action_source_content(action)
1049
1132
 
1133
+ if action.strategy == "claude-marketplace-settings":
1134
+ existing = destination.read_text(encoding="utf-8") if destination.exists() else ""
1135
+ merged, kind = merge_claude_marketplace_settings(existing)
1136
+ return PlannedAction(
1137
+ "create" if not destination.exists() else kind,
1138
+ action.relative_path,
1139
+ merged if kind != "skip" or not destination.exists() else None,
1140
+ )
1141
+
1050
1142
  if action.strategy == "keel-hook-settings":
1051
1143
  existing = destination.read_text(encoding="utf-8") if destination.exists() else ""
1052
1144
  merged, kind = merge_keel_hook_settings(existing, source_content)
@@ -1113,6 +1205,17 @@ def plan_uninstall_managed(repo: Path, relative_path: str) -> PlannedAction:
1113
1205
  return PlannedAction("remove-managed", Path(relative_path), updated)
1114
1206
 
1115
1207
 
1208
+ def plan_uninstall_claude_marketplace_settings(repo: Path) -> PlannedAction:
1209
+ relative = Path(".claude/settings.json")
1210
+ path = require_inside_repo(repo, relative)
1211
+ if not path.is_file():
1212
+ return PlannedAction("skip", relative)
1213
+ updated, kind = remove_claude_marketplace_settings(path.read_text(encoding="utf-8"))
1214
+ if kind == "skip":
1215
+ return PlannedAction("skip", relative)
1216
+ return PlannedAction(kind, relative, updated)
1217
+
1218
+
1116
1219
  def plan_uninstall_template(
1117
1220
  repo: Path,
1118
1221
  relative_path: str,
@@ -1242,6 +1345,10 @@ def plan_uninstall_actions(repo: Path, target: str) -> list[PlannedAction]:
1242
1345
  actions.append(plan_uninstall_managed(repo, "AGENTS.md"))
1243
1346
  if "claude" in targets:
1244
1347
  actions.append(plan_uninstall_managed(repo, "CLAUDE.md"))
1348
+ actions.append(plan_uninstall_claude_marketplace_settings(repo))
1349
+ # Removing the settings file can leave `.claude/` holding nothing that
1350
+ # anyone wrote, and uninstall leaves no empty directory of its own.
1351
+ actions.append(rmdir_if_empty_action(".claude"))
1245
1352
 
1246
1353
  for action in openspec_schema_actions():
1247
1354
  if action.source_path is not None:
@@ -38,8 +38,8 @@ REQUIRED_SCRIPTS = [
38
38
  "scripts/validate_plugin.py",
39
39
  ]
40
40
 
41
- PACKAGE_VERSION = "5.74.0"
42
- PROTOCOL_VERSION = "5.74.0"
41
+ PACKAGE_VERSION = "5.75.0"
42
+ PROTOCOL_VERSION = "5.75.0"
43
43
  LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
44
44
  OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
45
45
  # Mirrors KEEL_PACKAGE_NAME in scripts/install_to_repo.py, one of the two
@@ -16040,6 +16040,188 @@ def installed_elsewhere(config: Path, market_name: str, sentinel: str) -> list |
16040
16040
  return None if any((Path(p) / sentinel).is_file() for p in paths) else paths
16041
16041
 
16042
16042
 
16043
+ # Issue #164: project setup declares Claude plugin auto-update. Claude reads a
16044
+ # marketplace's auto-update first from `autoUpdate` on its
16045
+ # `extraKnownMarketplaces` entry in any settings file, and defaults it off for
16046
+ # every marketplace that is not Anthropic's own.
16047
+ KEEL_MARKETPLACE_NAME = "keel-marketplace"
16048
+ KEEL_MARKETPLACE_ENTRY = {
16049
+ "source": {"source": "github", "repo": "TanglmChris/keel"},
16050
+ "autoUpdate": True,
16051
+ }
16052
+ AUTO_UPDATE_LABEL = "init-declares-plugin-auto-update"
16053
+
16054
+
16055
+ def _settings(repo: Path) -> dict | None:
16056
+ path = repo / ".claude/settings.json"
16057
+ if not path.is_file():
16058
+ return None
16059
+ return json.loads(path.read_text(encoding="utf-8"))
16060
+
16061
+
16062
+ def _marketplace_entry(repo: Path) -> dict | None:
16063
+ settings = _settings(repo) or {}
16064
+ return (settings.get("extraKnownMarketplaces") or {}).get(KEEL_MARKETPLACE_NAME)
16065
+
16066
+
16067
+ def _auto_update_m1(tmp: Path) -> str | None:
16068
+ repo = tmp / "fresh"
16069
+ repo.mkdir()
16070
+ init = run_keel(repo, "--init", "--target", "claude")
16071
+ if init.returncode != 0:
16072
+ return f"M1 keel --init failed: {(init.stderr or init.stdout).strip()}"
16073
+ entry = _marketplace_entry(repo)
16074
+ if entry != KEEL_MARKETPLACE_ENTRY:
16075
+ return (
16076
+ f"M1 keel --init declared no plugin auto-update: {KEEL_MARKETPLACE_NAME} "
16077
+ f"in .claude/settings.json is {entry!r}, expected "
16078
+ f"{KEEL_MARKETPLACE_ENTRY!r}"
16079
+ )
16080
+ return None
16081
+
16082
+
16083
+ def _auto_update_m2(tmp: Path) -> str | None:
16084
+ permissions = {"allow": ["Bash(git:*)"]}
16085
+ local_source = {"source": "directory", "path": "/somewhere/keel"}
16086
+ cases = {
16087
+ "permissions-only": ({"permissions": permissions}, None),
16088
+ "own-source": (
16089
+ {"extraKnownMarketplaces": {KEEL_MARKETPLACE_NAME: {"source": local_source}}},
16090
+ {"source": local_source, "autoUpdate": True},
16091
+ ),
16092
+ "declared-off": (
16093
+ {
16094
+ "extraKnownMarketplaces": {
16095
+ KEEL_MARKETPLACE_NAME: {**KEEL_MARKETPLACE_ENTRY, "autoUpdate": False}
16096
+ }
16097
+ },
16098
+ {**KEEL_MARKETPLACE_ENTRY, "autoUpdate": False},
16099
+ ),
16100
+ }
16101
+ for name, (existing, expected) in cases.items():
16102
+ repo = tmp / name
16103
+ write_text(repo / ".claude/settings.json", json.dumps(existing, indent=2) + "\n")
16104
+ installed = run_keel(repo, "--install", "--target", "claude")
16105
+ if installed.returncode != 0:
16106
+ return f"M2 {name} keel --install failed: {(installed.stderr or installed.stdout).strip()}"
16107
+ settings = _settings(repo) or {}
16108
+ want = expected if expected is not None else KEEL_MARKETPLACE_ENTRY
16109
+ if _marketplace_entry(repo) != want:
16110
+ return (
16111
+ f"M2 {name}: install did not merge into an existing "
16112
+ f".claude/settings.json as declared; entry is "
16113
+ f"{_marketplace_entry(repo)!r}, expected {want!r}"
16114
+ )
16115
+ if name == "permissions-only" and settings.get("permissions") != permissions:
16116
+ return (
16117
+ f"M2 {name}: the merge into an existing .claude/settings.json "
16118
+ f"lost what the project wrote: {settings!r}"
16119
+ )
16120
+ again = run_keel(repo, "--install", "--target", "claude")
16121
+ if "skip .claude/settings.json" not in (again.stdout or ""):
16122
+ return (
16123
+ f"M2 {name}: a second install did not skip the merge into an "
16124
+ f"existing .claude/settings.json: {again.stdout!r}"
16125
+ )
16126
+ return None
16127
+
16128
+
16129
+ def _auto_update_m3(tmp: Path) -> str | None:
16130
+ permissions = {"allow": ["Bash(git:*)"]}
16131
+ ours = tmp / "uninstall-ours"
16132
+ write_text(
16133
+ ours / ".claude/settings.json",
16134
+ json.dumps(
16135
+ {
16136
+ "permissions": permissions,
16137
+ "extraKnownMarketplaces": {KEEL_MARKETPLACE_NAME: KEEL_MARKETPLACE_ENTRY},
16138
+ },
16139
+ indent=2,
16140
+ )
16141
+ + "\n",
16142
+ )
16143
+ run_keel(ours, "--uninstall", "--target", "claude")
16144
+ settings = _settings(ours) or {}
16145
+ if _marketplace_entry(ours) is not None:
16146
+ return f"M3 uninstall left Keel's own marketplace entry behind: {settings!r}"
16147
+ if settings.get("permissions") != permissions:
16148
+ return f"M3 uninstall left the file without the project's permissions: {settings!r}"
16149
+ changed = {**KEEL_MARKETPLACE_ENTRY, "autoUpdate": False}
16150
+ theirs = tmp / "uninstall-theirs"
16151
+ write_text(
16152
+ theirs / ".claude/settings.json",
16153
+ json.dumps({"extraKnownMarketplaces": {KEEL_MARKETPLACE_NAME: changed}}, indent=2)
16154
+ + "\n",
16155
+ )
16156
+ run_keel(theirs, "--uninstall", "--target", "claude")
16157
+ if _marketplace_entry(theirs) != changed:
16158
+ return (
16159
+ "M3 uninstall left the file without the entry the project changed; "
16160
+ f"it removed what Keel did not write: {_settings(theirs)!r}"
16161
+ )
16162
+ return None
16163
+
16164
+
16165
+ def _auto_update_m4(tmp: Path) -> str | None:
16166
+ states = {
16167
+ "on": (KEEL_MARKETPLACE_ENTRY, "plugin auto-update: ok"),
16168
+ "off": ({**KEEL_MARKETPLACE_ENTRY, "autoUpdate": False}, "plugin auto-update: manual"),
16169
+ "absent": (None, "plugin auto-update: manual"),
16170
+ }
16171
+ for name, (entry, prefix) in states.items():
16172
+ repo = tmp / f"doctor-{name}"
16173
+ settings = {"extraKnownMarketplaces": {KEEL_MARKETPLACE_NAME: entry}} if entry else {}
16174
+ write_text(repo / ".claude/settings.json", json.dumps(settings) + "\n")
16175
+ doctor = run_keel(repo, "--doctor", "--target", "claude")
16176
+ lines = [
16177
+ line
16178
+ for line in (doctor.stdout or "").splitlines()
16179
+ if line.startswith("plugin auto-update:")
16180
+ ]
16181
+ if len(lines) != 1:
16182
+ return f"M4 {name}: doctor printed no plugin auto-update line: {lines!r}"
16183
+ line = lines[0]
16184
+ if not line.startswith(prefix):
16185
+ return f"M4 {name}: doctor's plugin auto-update line is not {prefix!r}: {line!r}"
16186
+ if name == "off" and "false" not in line:
16187
+ return f"M4 off: the line does not name the project's false: {line!r}"
16188
+ if name == "absent" and "keel --install --target claude" not in line:
16189
+ return f"M4 absent: the line does not name the command that declares it: {line!r}"
16190
+ return None
16191
+
16192
+
16193
+ def _auto_update_m5(tmp: Path) -> str | None:
16194
+ repo = tmp / "codex"
16195
+ repo.mkdir()
16196
+ run_keel(repo, "--init", "--target", "codex")
16197
+ if (repo / ".claude/settings.json").exists():
16198
+ return (
16199
+ "M5 keel --init --target codex wrote .claude/settings.json for codex: "
16200
+ f"{_settings(repo)!r}"
16201
+ )
16202
+ return None
16203
+
16204
+
16205
+ def validate_init_declares_plugin_auto_update_scenario() -> int:
16206
+ with tempfile.TemporaryDirectory(
16207
+ prefix="keel-auto-update-", ignore_cleanup_errors=True
16208
+ ) as raw:
16209
+ tmp = Path(raw)
16210
+ for check in (
16211
+ _auto_update_m1,
16212
+ _auto_update_m2,
16213
+ _auto_update_m3,
16214
+ _auto_update_m4,
16215
+ _auto_update_m5,
16216
+ ):
16217
+ problem = check(tmp)
16218
+ if problem:
16219
+ report(f"{AUTO_UPDATE_LABEL} {problem}")
16220
+ return 1
16221
+ report(f"{AUTO_UPDATE_LABEL} scenario passed.")
16222
+ return 0
16223
+
16224
+
16043
16225
  def validate_native_plugin_marketplaces_scenario() -> int:
16044
16226
  codex = shutil.which("codex")
16045
16227
  claude = claude_cli()
@@ -30987,6 +31169,7 @@ SCENARIOS: tuple = (
30987
31169
  ("native-plugin-session-start", validate_native_plugin_session_start_scenario),
30988
31170
  ("runtime-version-drift", validate_runtime_version_drift_scenario),
30989
31171
  ("plugin-runs-its-own-cli", validate_plugin_runs_its_own_cli_scenario),
31172
+ ("init-declares-plugin-auto-update", validate_init_declares_plugin_auto_update_scenario),
30990
31173
  ("native-plugin-marketplaces", validate_native_plugin_marketplaces_scenario),
30991
31174
  ("native-plugin-install-matrix", validate_native_plugin_install_matrix_scenario),
30992
31175
  ("native-goal-projection", validate_native_goal_projection_scenario),