@jaguilar87/gaia 5.0.9 → 5.0.10

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 (32) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +2 -0
  4. package/bin/README.md +4 -2
  5. package/bin/cli/_install_helpers.py +0 -3
  6. package/bin/cli/brief.py +32 -4
  7. package/bin/cli/cleanup.py +304 -4
  8. package/bin/cli/doctor.py +0 -4
  9. package/bin/cli/uninstall.py +20 -0
  10. package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
  11. package/dist/gaia-ops/hooks/modules/core/plugin_setup.py +0 -5
  12. package/dist/gaia-ops/hooks/modules/security/capability_classes.py +83 -6
  13. package/dist/gaia-ops/hooks/modules/security/inline_ast_analyzer.py +237 -0
  14. package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +410 -0
  15. package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +177 -20
  16. package/dist/gaia-ops/skills/security-tiers/SKILL.md +1 -1
  17. package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
  18. package/dist/gaia-security/hooks/modules/core/plugin_setup.py +0 -5
  19. package/dist/gaia-security/hooks/modules/security/capability_classes.py +83 -6
  20. package/dist/gaia-security/hooks/modules/security/inline_ast_analyzer.py +237 -0
  21. package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +410 -0
  22. package/dist/gaia-security/hooks/modules/tools/bash_validator.py +177 -20
  23. package/gaia/briefs/__init__.py +4 -0
  24. package/gaia/briefs/store.py +91 -0
  25. package/hooks/modules/core/plugin_setup.py +0 -5
  26. package/hooks/modules/security/capability_classes.py +83 -6
  27. package/hooks/modules/security/inline_ast_analyzer.py +237 -0
  28. package/hooks/modules/security/mutative_verbs.py +410 -0
  29. package/hooks/modules/tools/bash_validator.py +177 -20
  30. package/package.json +1 -1
  31. package/pyproject.toml +20 -1
  32. package/skills/security-tiers/SKILL.md +1 -1
@@ -8,7 +8,7 @@
8
8
  {
9
9
  "name": "gaia-ops",
10
10
  "description": "Full DevOps orchestration for Claude Code. Eight specialized agents handle the complete development lifecycle — analysis, planning, execution, and deployment. Gaia-Ops scans your codebase to understand it and injects the right context into each sub-agent. Every command is classified by risk: read-only runs freely, state changes pause for your approval, and irreversible operations are permanently blocked.",
11
- "version": "5.0.9",
11
+ "version": "5.0.10",
12
12
  "category": "devops",
13
13
  "author": {
14
14
  "name": "jaguilar87",
@@ -20,7 +20,7 @@
20
20
  {
21
21
  "name": "gaia-security",
22
22
  "description": "Keeps you in the loop only when it matters. Gaia Security analyzes every command and classifies it into risk tiers: read-only queries run freely, simulations and validations pass through, and state-changing operations (create, delete, apply, push) pause for your explicit approval before executing. Irreversible commands like dropping databases or deleting cloud infrastructure are permanently blocked.",
23
- "version": "5.0.9",
23
+ "version": "5.0.10",
24
24
  "category": "security",
25
25
  "author": {
26
26
  "name": "jaguilar87",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gaia-ops",
3
- "version": "5.0.9",
3
+ "version": "5.0.10",
4
4
  "description": "Security-first orchestrator with specialized agents, hooks, and governance for AI coding",
5
5
  "author": {
6
6
  "name": "jaguilar87",
package/CHANGELOG.md CHANGED
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [5.0.10] - 2026-06-29
11
+
10
12
  ## [5.0.9] - 2026-06-25
11
13
 
12
14
  ### Changed
package/bin/README.md CHANGED
@@ -33,7 +33,9 @@ npm uninstall @jaguilar87/gaia
33
33
  |
34
34
  preuninstall script -> python3 bin/gaia uninstall --preuninstall
35
35
  |
36
- Cleans temporary caches, old logs, __pycache__, preserves .claude/ symlinks
36
+ Removes Gaia-owned symlinks (agents, hooks, skills, …), cleans caches /
37
+ logs / __pycache__, and surgically removes only Gaia's contributions from
38
+ settings.local.json and plugin-registry.json
37
39
  ```
38
40
 
39
41
  No Claude Code session is involved in either case. The subcommands run in a normal Python process and interact with the filesystem directly.
@@ -97,7 +99,7 @@ Modules whose name starts with `_` (e.g. `_install_helpers.py`) are private help
97
99
 
98
100
  **Exit codes:** `0` on success, `1` on warnings, `2` on errors. The release pipeline's sandbox harness relies on these -- do not print a success line and exit non-zero, or vice versa.
99
101
 
100
- **Preserved on cleanup:** `.claude/` symlinks are never touched by `gaia cleanup`. Project context is canonical in `~/.gaia/gaia.db` and persists across reinstalls independently of the filesystem. The preservation list for legacy filesystem artifacts lives in `cli/cleanup.py`.
102
+ **Cleanup footprint:** Full cleanup (the default, used by `gaia uninstall`) removes everything `gaia install` wrote: `CLAUDE.md`, `.claude/settings.json`, all Gaia-owned symlinks (`.claude/agents`, `.claude/hooks`, `.claude/skills`, and siblings), and the `.claude/.plugin-initialized` marker. Two files are handled surgically because they are shared with Claude Code: `settings.local.json` has only Gaia-injected keys removed (agent identity, two env vars, Gaia's permission entries; user content is preserved); `plugin-registry.json` has only Gaia's `installed[]` entry removed and is deleted only if it contained nothing else. The user DB at `~/.gaia/gaia.db` is never touched; pass `--purge` to `gaia uninstall` to remove it. The canonical source for what gets removed is `cli/cleanup.py` (`SYMLINKS_TO_REMOVE`, `_clean_settings_local_json`, `_remove_plugin_registry_entry`).
101
103
 
102
104
  **`package.json` `bin` field:**
103
105
 
@@ -175,9 +175,6 @@ def merge_local_permissions(
175
175
 
176
176
  # env vars (smart merge -- preserve user values)
177
177
  env = existing.setdefault("env", {})
178
- if "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS" not in env:
179
- env["CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"] = "1"
180
- changed_fields.append("env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS")
181
178
  if "CLAUDE_CODE_DISABLE_AUTO_MEMORY" not in env:
182
179
  env["CLAUDE_CODE_DISABLE_AUTO_MEMORY"] = "1"
183
180
  changed_fields.append("env.CLAUDE_CODE_DISABLE_AUTO_MEMORY")
package/bin/cli/brief.py CHANGED
@@ -380,16 +380,44 @@ def _cmd_edit(args) -> int:
380
380
 
381
381
 
382
382
  def _cmd_show(args) -> int:
383
- from gaia.briefs import get_brief, serialize_brief_to_markdown
383
+ from gaia.briefs import (
384
+ get_brief,
385
+ get_brief_by_id,
386
+ find_brief_workspaces,
387
+ serialize_brief_to_markdown,
388
+ )
384
389
  workspace = _resolve_workspace(getattr(args, "workspace", None))
385
390
  name = args.name
391
+ as_json = getattr(args, "json", False)
392
+
393
+ # FIX 1: when the argument is all-digits, resolve by numeric id first.
394
+ if name.isdigit():
395
+ brief = get_brief_by_id(int(name))
396
+ if brief is not None:
397
+ if as_json:
398
+ out = {k: v for k, v in brief.items() if k != "id"}
399
+ print(json.dumps(out, indent=2, default=str))
400
+ return 0
401
+ print(serialize_brief_to_markdown(brief))
402
+ return 0
403
+ # Numeric id not found -- fall through to the slug path so that a
404
+ # name that happens to look like a number still gets a useful error.
386
405
 
387
406
  brief = get_brief(workspace, name)
388
407
  if brief is None:
389
- return _err(f"brief '{name}' not found in workspace '{workspace}'",
390
- as_json=getattr(args, "json", False))
408
+ # FIX 2: cross-workspace hint instead of bare "not found".
409
+ other_workspaces = find_brief_workspaces(name)
410
+ if other_workspaces:
411
+ hint = ", ".join(repr(w) for w in other_workspaces)
412
+ msg = (
413
+ f"brief '{name}' not found in workspace '{workspace}', "
414
+ f"but exists in: {hint} -- use --workspace=<workspace> to show it"
415
+ )
416
+ else:
417
+ msg = f"brief '{name}' not found in workspace '{workspace}'"
418
+ return _err(msg, as_json=as_json)
391
419
 
392
- if getattr(args, "json", False):
420
+ if as_json:
393
421
  # Drop internal SQL columns for cleanliness
394
422
  out = {k: v for k, v in brief.items() if k != "id"}
395
423
  print(json.dumps(out, indent=2, default=str))
@@ -1,10 +1,19 @@
1
1
  """
2
- gaia cleanup -- Remove temporary caches, old logs, and `__pycache__`;
3
- preserves project-context.json and .claude/ symlinks.
2
+ gaia cleanup -- Remove Gaia's workspace footprint and apply data retention.
3
+
4
+ The full-cleanup footprint mirrors what `gaia install` writes, in reverse:
5
+ - CLAUDE.md and .claude/settings.json (removed outright -- Gaia-owned)
6
+ - .claude/ symlinks incl. skills (removed -- Gaia-owned)
7
+ - .claude/.plugin-initialized marker (removed -- Gaia-owned)
8
+ - .claude/plugin-registry.json (surgical: only Gaia's installed[] entry is
9
+ removed; the file is shared with Claude Code's plugin system)
10
+ - .claude/settings.local.json (surgical: only Gaia-injected keys are removed
11
+ -- agent, two env vars, Gaia's permission entries; user config preserved)
12
+ The user DB at ~/.gaia/gaia.db is never touched here.
4
13
 
5
14
  Modes:
6
- --prune / --retain Apply data retention policy only (no symlink/settings removal)
7
- (default) Remove CLAUDE.md, settings.json, symlinks + run retention
15
+ --prune / --retain Apply data retention policy only (no footprint removal)
16
+ (default) Remove the footprint above + run retention
8
17
 
9
18
  Flags:
10
19
  --dry-run Print what would be pruned/removed without modifying files
@@ -16,6 +25,11 @@ import os
16
25
  import sys
17
26
  from pathlib import Path
18
27
 
28
+ # bin/cli/cleanup.py -> bin/cli -> bin -> gaia/
29
+ _PACKAGE_ROOT = Path(__file__).resolve().parent.parent.parent
30
+ if str(_PACKAGE_ROOT) not in sys.path:
31
+ sys.path.insert(0, str(_PACKAGE_ROOT))
32
+
19
33
 
20
34
  # ---------------------------------------------------------------------------
21
35
  # Project root detection (mirrors JS findProjectRoot)
@@ -334,6 +348,7 @@ SYMLINKS_TO_REMOVE = [
334
348
  ".claude/hooks",
335
349
  ".claude/commands",
336
350
  ".claude/config",
351
+ ".claude/skills",
337
352
  ".claude/CHANGELOG.md",
338
353
  ".claude/README.en.md",
339
354
  ".claude/README.md",
@@ -358,6 +373,271 @@ def _remove_settings_json(root: Path, dry_run: bool) -> dict:
358
373
  return {"found": True, "removed": not dry_run, "dry_run": dry_run}
359
374
 
360
375
 
376
+ # ---------------------------------------------------------------------------
377
+ # Gaia-owned data-dir markers (.plugin-initialized, plugin-registry.json)
378
+ #
379
+ # Both live in get_plugin_data_dir(), which falls back to .claude/ when
380
+ # CLAUDE_PLUGIN_DATA is unset (the common npm-install case). The marker is a
381
+ # pure Gaia artifact -- removed outright. The registry is shared with Claude
382
+ # Code's plugin system, so only Gaia's own entry is removed surgically.
383
+ # ---------------------------------------------------------------------------
384
+
385
+ # Plugin names Gaia registers in plugin-registry.json (see _read_plugin_name
386
+ # in _install_helpers.py: package "gaia" maps to the canonical "gaia-ops").
387
+ _GAIA_PLUGIN_NAMES = {"gaia-ops", "gaia-security", "gaia"}
388
+
389
+
390
+ def _remove_plugin_initialized(root: Path, dry_run: bool) -> dict:
391
+ """Remove the .plugin-initialized first-run marker.
392
+
393
+ Written by plugin_setup.mark_initialized() into get_plugin_data_dir().
394
+ A pure Gaia artifact (timestamp + mode), safe to delete outright.
395
+ """
396
+ path = root / ".claude" / ".plugin-initialized"
397
+ if not path.exists():
398
+ return {"found": False}
399
+ if not dry_run:
400
+ try:
401
+ path.unlink()
402
+ except OSError as exc:
403
+ return {"found": True, "removed": False, "error": str(exc)}
404
+ return {"found": True, "removed": not dry_run, "dry_run": dry_run}
405
+
406
+
407
+ def _remove_plugin_registry_entry(root: Path, dry_run: bool) -> dict:
408
+ """Remove Gaia's entry from plugin-registry.json, preserving other plugins.
409
+
410
+ plugin-registry.json is shared with Claude Code's plugin system. Install
411
+ (register_plugin / ensure_plugin_registry) writes Gaia's entry into the
412
+ ``installed`` list. Uninstall is symmetric: drop only Gaia-owned entries
413
+ from ``installed``. If that empties the registry of all plugins, the file
414
+ is equivalent to its pre-Gaia state and is removed; otherwise it is
415
+ rewritten with the surviving entries.
416
+
417
+ Idempotent: a registry with no Gaia entry returns found=False.
418
+ """
419
+ path = root / ".claude" / "plugin-registry.json"
420
+ if not path.exists():
421
+ return {"found": False}
422
+
423
+ data = _read_json_file(path)
424
+ if not isinstance(data, dict):
425
+ # Malformed/unexpected shape -- leave it untouched rather than risk
426
+ # destroying a file Gaia did not write.
427
+ return {"found": False, "skipped": "registry not a JSON object"}
428
+
429
+ installed = data.get("installed")
430
+ if not isinstance(installed, list):
431
+ return {"found": False, "skipped": "no installed[] array"}
432
+
433
+ kept = [
434
+ e for e in installed
435
+ if not (isinstance(e, dict) and e.get("name") in _GAIA_PLUGIN_NAMES)
436
+ ]
437
+ removed_entries = [
438
+ e for e in installed
439
+ if isinstance(e, dict) and e.get("name") in _GAIA_PLUGIN_NAMES
440
+ ]
441
+
442
+ if not removed_entries:
443
+ return {"found": False}
444
+
445
+ # Only Gaia keys present (installed + source) and nothing survives ->
446
+ # the file existed solely for Gaia; remove it entirely.
447
+ only_gaia_keys = set(data.keys()) <= {"installed", "source"}
448
+ delete_file = not kept and only_gaia_keys
449
+
450
+ result = {
451
+ "found": True,
452
+ "removed_entries": [e.get("name") for e in removed_entries],
453
+ "file_removed": delete_file and not dry_run,
454
+ "dry_run": dry_run,
455
+ }
456
+
457
+ if dry_run:
458
+ return result
459
+
460
+ try:
461
+ if delete_file:
462
+ path.unlink()
463
+ else:
464
+ data["installed"] = kept
465
+ _write_json_file(path, data)
466
+ except OSError as exc:
467
+ result["error"] = str(exc)
468
+ return result
469
+
470
+
471
+ # ---------------------------------------------------------------------------
472
+ # settings.local.json -- surgical removal of Gaia-injected config
473
+ #
474
+ # Install (merge_local_permissions) MERGES into a user-owned file: it sets
475
+ # agent=gaia-orchestrator, adds two env vars, and authoritative-merges its
476
+ # permission entries. Uninstall must mirror that injection key-for-key and
477
+ # leave everything the user added intact -- never delete the whole file.
478
+ #
479
+ # Note on permissions: install's authoritative merge already overwrote any
480
+ # user-scoped variants of tool names Gaia manages (e.g. a prior Edit(/tmp/*)
481
+ # became Edit). That loss is not recoverable at uninstall time; the symmetric
482
+ # action is to drop the tool names Gaia manages and its deny rules, leaving
483
+ # only entries for tools Gaia never touched.
484
+ # ---------------------------------------------------------------------------
485
+
486
+ _GAIA_ENV_KEYS = {
487
+ "CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1",
488
+ }
489
+
490
+
491
+ def _gaia_managed_permission_sets():
492
+ """Return (managed_tool_names, deny_entries) Gaia injects into settings.
493
+
494
+ Pulled from the canonical OPS/SECURITY permission constants in
495
+ plugin_setup.py (the same source install merges from). Falls back to a
496
+ minimal set if the hooks package cannot be imported (partial install).
497
+ """
498
+ try:
499
+ from hooks.modules.core.plugin_setup import ( # type: ignore
500
+ OPS_PERMISSIONS,
501
+ SECURITY_PERMISSIONS,
502
+ _tool_name,
503
+ )
504
+ allow = (
505
+ set(OPS_PERMISSIONS["permissions"]["allow"])
506
+ | set(SECURITY_PERMISSIONS["permissions"]["allow"])
507
+ )
508
+ deny = (
509
+ set(OPS_PERMISSIONS["permissions"]["deny"])
510
+ | set(SECURITY_PERMISSIONS["permissions"]["deny"])
511
+ )
512
+ managed_names = {_tool_name(e) for e in allow}
513
+ return managed_names, deny
514
+ except Exception: # noqa: BLE001
515
+ return {"Bash"}, set()
516
+
517
+
518
+ def _clean_settings_local_json(root: Path, dry_run: bool) -> dict:
519
+ """Remove only Gaia-injected keys from settings.local.json; preserve user config.
520
+
521
+ Mirrors merge_local_permissions:
522
+ - agent: removed only if it equals "gaia-orchestrator".
523
+ - env: removes the two Gaia keys only when their value matches what
524
+ install set (a user override of the value is preserved).
525
+ - permissions.allow: drops entries whose tool name Gaia manages.
526
+ - permissions.deny: drops Gaia's deny rules.
527
+ - Empty containers (env / permissions / allow / deny) are pruned so the
528
+ file does not retain hollow Gaia scaffolding.
529
+
530
+ If nothing Gaia-owned remains and the file becomes ``{}``, it is removed.
531
+ Idempotent: a file with no Gaia keys returns found=False.
532
+ """
533
+ path = root / ".claude" / "settings.local.json"
534
+ if not path.exists():
535
+ return {"found": False}
536
+
537
+ data = _read_json_file(path)
538
+ if not isinstance(data, dict):
539
+ return {"found": False, "skipped": "settings.local.json not a JSON object"}
540
+
541
+ removed_fields: list[str] = []
542
+
543
+ # agent identity
544
+ if data.get("agent") == "gaia-orchestrator":
545
+ removed_fields.append("agent")
546
+ if not dry_run:
547
+ del data["agent"]
548
+
549
+ # env vars (only when value still matches what install set)
550
+ env = data.get("env")
551
+ if isinstance(env, dict):
552
+ for key, expected in _GAIA_ENV_KEYS.items():
553
+ if env.get(key) == expected:
554
+ removed_fields.append(f"env.{key}")
555
+ if not dry_run:
556
+ del env[key]
557
+ if not dry_run and not env:
558
+ del data["env"]
559
+
560
+ # permissions
561
+ perms = data.get("permissions")
562
+ if isinstance(perms, dict):
563
+ managed_names, gaia_deny = _gaia_managed_permission_sets()
564
+
565
+ allow = perms.get("allow")
566
+ if isinstance(allow, list):
567
+ kept_allow = [e for e in allow if _perm_tool_name(e) not in managed_names]
568
+ if len(kept_allow) != len(allow):
569
+ removed_fields.append("permissions.allow")
570
+ if not dry_run:
571
+ if kept_allow:
572
+ perms["allow"] = kept_allow
573
+ else:
574
+ del perms["allow"]
575
+
576
+ deny = perms.get("deny")
577
+ if isinstance(deny, list):
578
+ kept_deny = [e for e in deny if e not in gaia_deny]
579
+ if len(kept_deny) != len(deny):
580
+ removed_fields.append("permissions.deny")
581
+ if not dry_run:
582
+ if kept_deny:
583
+ perms["deny"] = kept_deny
584
+ else:
585
+ del perms["deny"]
586
+
587
+ # Drop hollow empty lists (ask/allow/deny) and an empty permissions
588
+ # block -- these are Gaia scaffolding, not user data.
589
+ if not dry_run:
590
+ for empty_key in ("ask", "allow", "deny"):
591
+ if perms.get(empty_key) == []:
592
+ del perms[empty_key]
593
+ if not perms:
594
+ del data["permissions"]
595
+
596
+ if not removed_fields:
597
+ return {"found": False}
598
+
599
+ result = {
600
+ "found": True,
601
+ "removed_fields": removed_fields,
602
+ "file_removed": False,
603
+ "dry_run": dry_run,
604
+ }
605
+
606
+ if dry_run:
607
+ return result
608
+
609
+ try:
610
+ if not data:
611
+ path.unlink()
612
+ result["file_removed"] = True
613
+ else:
614
+ _write_json_file(path, data)
615
+ except OSError as exc:
616
+ result["error"] = str(exc)
617
+ return result
618
+
619
+
620
+ def _perm_tool_name(entry: str) -> str:
621
+ """Base tool name from a permission entry (mirror of plugin_setup._tool_name)."""
622
+ if not isinstance(entry, str):
623
+ return ""
624
+ paren = entry.find("(")
625
+ return entry[:paren] if paren != -1 else entry
626
+
627
+
628
+ def _read_json_file(path: Path):
629
+ """Read JSON, returning None on any error."""
630
+ try:
631
+ return json.loads(path.read_text(encoding="utf-8"))
632
+ except (OSError, json.JSONDecodeError):
633
+ return None
634
+
635
+
636
+ def _write_json_file(path: Path, data) -> None:
637
+ """Write JSON with indent=2 + trailing newline (gaia convention)."""
638
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
639
+
640
+
361
641
  def _remove_symlinks(root: Path, dry_run: bool) -> dict:
362
642
  removed = []
363
643
  skipped = []
@@ -513,11 +793,17 @@ def cmd_cleanup(args) -> int:
513
793
 
514
794
  claude_md = _remove_claude_md(root, dry_run)
515
795
  settings = _remove_settings_json(root, dry_run)
796
+ settings_local = _clean_settings_local_json(root, dry_run)
797
+ plugin_initialized = _remove_plugin_initialized(root, dry_run)
798
+ plugin_registry = _remove_plugin_registry_entry(root, dry_run)
516
799
  symlinks = _remove_symlinks(root, dry_run)
517
800
  retention_actions = _apply_retention_policy(root, dry_run)
518
801
 
519
802
  result["claude_md"] = claude_md
520
803
  result["settings_json"] = settings
804
+ result["settings_local_json"] = settings_local
805
+ result["plugin_initialized"] = plugin_initialized
806
+ result["plugin_registry"] = plugin_registry
521
807
  result["symlinks"] = symlinks
522
808
  result["retention_actions"] = retention_actions
523
809
  result["retention_policy"] = retention_policy_info
@@ -529,6 +815,9 @@ def cmd_cleanup(args) -> int:
529
815
  anything_done = (
530
816
  claude_md.get("found")
531
817
  or settings.get("found")
818
+ or settings_local.get("found")
819
+ or plugin_initialized.get("found")
820
+ or plugin_registry.get("found")
532
821
  or symlinks.get("removed")
533
822
  or retention_actions
534
823
  )
@@ -539,6 +828,17 @@ def cmd_cleanup(args) -> int:
539
828
  if settings.get("found"):
540
829
  verb = "Would remove" if dry_run else "Removed"
541
830
  print(f" {verb}: .claude/settings.json")
831
+ if settings_local.get("found"):
832
+ verb = "Would clean" if dry_run else "Cleaned"
833
+ fields = ", ".join(settings_local.get("removed_fields", []))
834
+ print(f" {verb}: .claude/settings.local.json ({fields})")
835
+ if plugin_initialized.get("found"):
836
+ verb = "Would remove" if dry_run else "Removed"
837
+ print(f" {verb}: .claude/.plugin-initialized")
838
+ if plugin_registry.get("found"):
839
+ verb = "Would remove" if dry_run else "Removed"
840
+ entries = ", ".join(plugin_registry.get("removed_entries", []))
841
+ print(f" {verb}: plugin-registry.json entry ({entries})")
542
842
  for rel in symlinks.get("removed", []):
543
843
  verb = "Would remove symlink" if dry_run else "Removed symlink"
544
844
  print(f" {verb}: {rel}")
package/bin/cli/doctor.py CHANGED
@@ -854,10 +854,6 @@ def check_settings(project_root: Path) -> dict:
854
854
  if deny_count == 0:
855
855
  issues.append("No deny rules (destructive commands not blocked)")
856
856
 
857
- env = data.get("env", {})
858
- if not env.get("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"):
859
- infos.append("AGENT_TEAMS env not set")
860
-
861
857
  if issues:
862
858
  return _result("Settings", "error", "; ".join(issues), "Run `gaia scan` or `gaia update`")
863
859
 
@@ -38,8 +38,11 @@ from pathlib import Path
38
38
  # duplicating retention policy, symlink lists, or root detection.
39
39
  from cli.cleanup import ( # type: ignore # noqa: E402
40
40
  _apply_retention_policy,
41
+ _clean_settings_local_json,
41
42
  _find_project_root,
42
43
  _remove_claude_md,
44
+ _remove_plugin_initialized,
45
+ _remove_plugin_registry_entry,
43
46
  _remove_settings_json,
44
47
  _remove_symlinks,
45
48
  )
@@ -250,6 +253,9 @@ def cmd_uninstall(args: argparse.Namespace) -> int:
250
253
  try:
251
254
  result["claude_md"] = _remove_claude_md(workspace, dry_run)
252
255
  result["settings_json"] = _remove_settings_json(workspace, dry_run)
256
+ result["settings_local_json"] = _clean_settings_local_json(workspace, dry_run)
257
+ result["plugin_initialized"] = _remove_plugin_initialized(workspace, dry_run)
258
+ result["plugin_registry"] = _remove_plugin_registry_entry(workspace, dry_run)
253
259
  result["symlinks"] = _remove_symlinks(workspace, dry_run)
254
260
  result["retention_actions"] = _apply_retention_policy(workspace, dry_run)
255
261
  except Exception as exc: # noqa: BLE001
@@ -345,6 +351,9 @@ def _print_human(result: dict, *, preuninstall: bool, purge: bool, dry_run: bool
345
351
 
346
352
  claude_md = result.get("claude_md") or {}
347
353
  settings = result.get("settings_json") or {}
354
+ settings_local = result.get("settings_local_json") or {}
355
+ plugin_initialized = result.get("plugin_initialized") or {}
356
+ plugin_registry = result.get("plugin_registry") or {}
348
357
  symlinks = result.get("symlinks") or {}
349
358
  retention = result.get("retention_actions") or []
350
359
  db = result.get("db") or {}
@@ -355,6 +364,17 @@ def _print_human(result: dict, *, preuninstall: bool, purge: bool, dry_run: bool
355
364
  if settings.get("found"):
356
365
  verb = "Would remove" if dry_run else "Removed"
357
366
  print(f" {verb}: .claude/settings.json")
367
+ if settings_local.get("found"):
368
+ verb = "Would clean" if dry_run else "Cleaned"
369
+ fields = ", ".join(settings_local.get("removed_fields", []))
370
+ print(f" {verb}: .claude/settings.local.json ({fields})")
371
+ if plugin_initialized.get("found"):
372
+ verb = "Would remove" if dry_run else "Removed"
373
+ print(f" {verb}: .claude/.plugin-initialized")
374
+ if plugin_registry.get("found"):
375
+ verb = "Would remove" if dry_run else "Removed"
376
+ entries = ", ".join(plugin_registry.get("removed_entries", []))
377
+ print(f" {verb}: plugin-registry.json entry ({entries})")
358
378
  for rel in symlinks.get("removed", []):
359
379
  verb = "Would remove symlink" if dry_run else "Removed symlink"
360
380
  print(f" {verb}: {rel}")
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gaia-ops",
3
- "version": "5.0.9",
3
+ "version": "5.0.10",
4
4
  "description": "Full DevOps orchestration for Claude Code. Eight specialized agents handle the complete development lifecycle \u2014 analysis, planning, execution, and deployment. Gaia-Ops scans your codebase to understand it and injects the right context into each sub-agent. Every command is classified by risk: read-only runs freely, state changes pause for your approval, and irreversible operations are permanently blocked.",
5
5
  "author": {
6
6
  "name": "jaguilar87",
@@ -347,11 +347,6 @@ def setup_project_permissions() -> bool:
347
347
  existing["permissions"]["deny"] = merged_deny
348
348
  existing["permissions"].setdefault("ask", [])
349
349
 
350
- # Add env vars (smart merge: add if not present, don't overwrite)
351
- env = existing.setdefault("env", {})
352
- if "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS" not in env:
353
- env["CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"] = "1"
354
-
355
350
  claude_dir.mkdir(parents=True, exist_ok=True)
356
351
  settings_path.write_text(json.dumps(existing, indent=2) + "\n")
357
352
  logger.info("Merged gaia %s permissions and env into %s", mode, settings_path)