@jaguilar87/gaia 5.0.9 → 5.0.11

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 (104) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +17 -0
  4. package/bin/README.md +4 -2
  5. package/bin/cli/_install_helpers.py +0 -3
  6. package/bin/cli/ac.py +2 -2
  7. package/bin/cli/brief.py +42 -7
  8. package/bin/cli/cleanup.py +304 -4
  9. package/bin/cli/doctor.py +1 -5
  10. package/bin/cli/uninstall.py +20 -0
  11. package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
  12. package/dist/gaia-ops/hooks/adapters/__init__.py +12 -2
  13. package/dist/gaia-ops/hooks/adapters/base.py +122 -5
  14. package/dist/gaia-ops/hooks/adapters/claude_code.py +175 -53
  15. package/dist/gaia-ops/hooks/adapters/host_session.py +53 -0
  16. package/dist/gaia-ops/hooks/adapters/host_transcript.py +75 -0
  17. package/dist/gaia-ops/hooks/adapters/registry.py +87 -0
  18. package/dist/gaia-ops/hooks/adapters/types.py +134 -6
  19. package/dist/gaia-ops/hooks/modules/agents/transcript_reader.py +34 -71
  20. package/dist/gaia-ops/hooks/modules/core/hook_entry.py +6 -4
  21. package/dist/gaia-ops/hooks/modules/core/plugin_setup.py +0 -5
  22. package/dist/gaia-ops/hooks/modules/core/state.py +12 -10
  23. package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +2 -2
  24. package/dist/gaia-ops/hooks/modules/security/approval_grants.py +7 -7
  25. package/dist/gaia-ops/hooks/modules/security/capability_classes.py +83 -6
  26. package/dist/gaia-ops/hooks/modules/security/inline_ast_analyzer.py +237 -0
  27. package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +414 -3
  28. package/dist/gaia-ops/hooks/modules/session/pending_scanner.py +4 -3
  29. package/dist/gaia-ops/hooks/modules/session/session_manager.py +6 -15
  30. package/dist/gaia-ops/hooks/modules/session/session_manifest.py +3 -3
  31. package/dist/gaia-ops/hooks/modules/session/session_registry.py +3 -3
  32. package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +191 -32
  33. package/dist/gaia-ops/hooks/modules/tools/hook_response.py +14 -12
  34. package/dist/gaia-ops/hooks/post_tool_use.py +2 -2
  35. package/dist/gaia-ops/hooks/pre_tool_use.py +9 -8
  36. package/dist/gaia-ops/hooks/stop_hook.py +2 -2
  37. package/dist/gaia-ops/hooks/subagent_start.py +2 -2
  38. package/dist/gaia-ops/hooks/subagent_stop.py +2 -2
  39. package/dist/gaia-ops/hooks/task_completed.py +2 -2
  40. package/dist/gaia-ops/skills/security-tiers/SKILL.md +1 -1
  41. package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
  42. package/dist/gaia-security/hooks/adapters/__init__.py +12 -2
  43. package/dist/gaia-security/hooks/adapters/base.py +122 -5
  44. package/dist/gaia-security/hooks/adapters/claude_code.py +175 -53
  45. package/dist/gaia-security/hooks/adapters/host_session.py +53 -0
  46. package/dist/gaia-security/hooks/adapters/host_transcript.py +75 -0
  47. package/dist/gaia-security/hooks/adapters/registry.py +87 -0
  48. package/dist/gaia-security/hooks/adapters/types.py +134 -6
  49. package/dist/gaia-security/hooks/modules/agents/transcript_reader.py +34 -71
  50. package/dist/gaia-security/hooks/modules/core/hook_entry.py +6 -4
  51. package/dist/gaia-security/hooks/modules/core/plugin_setup.py +0 -5
  52. package/dist/gaia-security/hooks/modules/core/state.py +12 -10
  53. package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +2 -2
  54. package/dist/gaia-security/hooks/modules/security/approval_grants.py +7 -7
  55. package/dist/gaia-security/hooks/modules/security/capability_classes.py +83 -6
  56. package/dist/gaia-security/hooks/modules/security/inline_ast_analyzer.py +237 -0
  57. package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +414 -3
  58. package/dist/gaia-security/hooks/modules/session/pending_scanner.py +4 -3
  59. package/dist/gaia-security/hooks/modules/session/session_manager.py +6 -15
  60. package/dist/gaia-security/hooks/modules/session/session_manifest.py +3 -3
  61. package/dist/gaia-security/hooks/modules/session/session_registry.py +3 -3
  62. package/dist/gaia-security/hooks/modules/tools/bash_validator.py +191 -32
  63. package/dist/gaia-security/hooks/modules/tools/hook_response.py +14 -12
  64. package/dist/gaia-security/hooks/post_tool_use.py +2 -2
  65. package/dist/gaia-security/hooks/pre_tool_use.py +9 -8
  66. package/dist/gaia-security/hooks/stop_hook.py +2 -2
  67. package/gaia/briefs/__init__.py +4 -0
  68. package/gaia/briefs/store.py +144 -1
  69. package/gaia/state/__init__.py +8 -1
  70. package/gaia/state/transitions.py +18 -4
  71. package/gaia/store/schema.sql +5 -1
  72. package/hooks/adapters/__init__.py +12 -2
  73. package/hooks/adapters/base.py +122 -5
  74. package/hooks/adapters/claude_code.py +175 -53
  75. package/hooks/adapters/host_session.py +53 -0
  76. package/hooks/adapters/host_transcript.py +75 -0
  77. package/hooks/adapters/registry.py +87 -0
  78. package/hooks/adapters/types.py +134 -6
  79. package/hooks/modules/agents/transcript_reader.py +34 -71
  80. package/hooks/modules/core/hook_entry.py +6 -4
  81. package/hooks/modules/core/plugin_setup.py +0 -5
  82. package/hooks/modules/core/state.py +12 -10
  83. package/hooks/modules/security/approval_cleanup.py +2 -2
  84. package/hooks/modules/security/approval_grants.py +7 -7
  85. package/hooks/modules/security/capability_classes.py +83 -6
  86. package/hooks/modules/security/inline_ast_analyzer.py +237 -0
  87. package/hooks/modules/security/mutative_verbs.py +414 -3
  88. package/hooks/modules/session/pending_scanner.py +4 -3
  89. package/hooks/modules/session/session_manager.py +6 -15
  90. package/hooks/modules/session/session_manifest.py +3 -3
  91. package/hooks/modules/session/session_registry.py +3 -3
  92. package/hooks/modules/tools/bash_validator.py +191 -32
  93. package/hooks/modules/tools/hook_response.py +14 -12
  94. package/hooks/post_tool_use.py +2 -2
  95. package/hooks/pre_tool_use.py +9 -8
  96. package/hooks/stop_hook.py +2 -2
  97. package/hooks/subagent_start.py +2 -2
  98. package/hooks/subagent_stop.py +2 -2
  99. package/hooks/task_completed.py +2 -2
  100. package/package.json +1 -1
  101. package/pyproject.toml +20 -1
  102. package/scripts/migrations/schema.checksum +2 -2
  103. package/scripts/migrations/v20_to_v21.sql +68 -0
  104. 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.11",
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.11",
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.11",
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,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [5.0.11] - 2026-06-30
11
+
12
+ ### Changed
13
+
14
+ - Host decoupling (#88): la lógica del core (clasificación T0–T3, grants, validación, audit) queda desacoplada de Claude Code tras la capa adapter. Lo específico del host vive en seams: `host_session`, `host_transcript`, `registry`/`get_adapter`, `request_consent`/`ConsentRequest`, `HostCapability`/degradación, `HostDistribution`. Soportar un host nuevo de la familia hook-interception = escribir un adapter + declarar capacidades, sin tocar el core.
15
+
16
+ ### Added
17
+
18
+ - Estado terminal `descoped` para acceptance criteria (descope deliberado, hard-terminal) más invariantes de `verify_brief` (`closed_brief_nonterminal_ac`, `closed_brief_open_plan`) para coherencia brief/plan/AC al cerrar.
19
+
20
+ ### Fixed
21
+
22
+ - Endurecimiento del security-core a 100% killable (mutation testing) en `blocked_commands`, `mutative_verbs`, `tiers` y `approval_grants`. Arreglado el mecanismo de skip-file de equivalentes para casar por identidad estable (`operator|posición|occurrence`) en vez de `job_ids` regenerados — elimina la exclusión-cero silenciosa ("falso 100%") tras cada `cosmic-ray init`.
23
+ - Corregido el help de `brief close` (verify advisory, sin cascade de estado).
24
+
25
+ ## [5.0.10] - 2026-06-29
26
+
10
27
  ## [5.0.9] - 2026-06-25
11
28
 
12
29
  ### 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/ac.py CHANGED
@@ -229,8 +229,8 @@ def register(subparsers) -> None:
229
229
  setstatus_p.add_argument("ac_id", metavar="AC_ID", help="AC identifier.")
230
230
  setstatus_p.add_argument(
231
231
  "status",
232
- choices=("pending", "done", "blocked"),
233
- help="Target status.",
232
+ choices=("pending", "done", "blocked", "descoped"),
233
+ help="Target status ('descoped' is a hard-terminal drop; no reopen).",
234
234
  )
235
235
  setstatus_p.add_argument("--workspace", default=None, metavar="W")
236
236
  setstatus_p.add_argument("--json", action="store_true", default=False,
package/bin/cli/brief.py CHANGED
@@ -14,7 +14,9 @@ Subcommands:
14
14
  gaia brief show <name> [--json] Print brief as markdown
15
15
  gaia brief list [--status=...] List briefs in the workspace
16
16
  [--format=table|count|json]
17
- gaia brief close <name> Set status -> closed
17
+ gaia brief close <name> Set status -> closed (advisory: runs
18
+ verify_brief and prints inconsistencies;
19
+ does NOT change AC/milestone/plan status)
18
20
  gaia brief set-status <name> <status> Validated state-machine transition
19
21
  (DB-only)
20
22
  gaia brief deps <name> [--json] Print dependency graph
@@ -380,16 +382,44 @@ def _cmd_edit(args) -> int:
380
382
 
381
383
 
382
384
  def _cmd_show(args) -> int:
383
- from gaia.briefs import get_brief, serialize_brief_to_markdown
385
+ from gaia.briefs import (
386
+ get_brief,
387
+ get_brief_by_id,
388
+ find_brief_workspaces,
389
+ serialize_brief_to_markdown,
390
+ )
384
391
  workspace = _resolve_workspace(getattr(args, "workspace", None))
385
392
  name = args.name
393
+ as_json = getattr(args, "json", False)
394
+
395
+ # FIX 1: when the argument is all-digits, resolve by numeric id first.
396
+ if name.isdigit():
397
+ brief = get_brief_by_id(int(name))
398
+ if brief is not None:
399
+ if as_json:
400
+ out = {k: v for k, v in brief.items() if k != "id"}
401
+ print(json.dumps(out, indent=2, default=str))
402
+ return 0
403
+ print(serialize_brief_to_markdown(brief))
404
+ return 0
405
+ # Numeric id not found -- fall through to the slug path so that a
406
+ # name that happens to look like a number still gets a useful error.
386
407
 
387
408
  brief = get_brief(workspace, name)
388
409
  if brief is None:
389
- return _err(f"brief '{name}' not found in workspace '{workspace}'",
390
- as_json=getattr(args, "json", False))
410
+ # FIX 2: cross-workspace hint instead of bare "not found".
411
+ other_workspaces = find_brief_workspaces(name)
412
+ if other_workspaces:
413
+ hint = ", ".join(repr(w) for w in other_workspaces)
414
+ msg = (
415
+ f"brief '{name}' not found in workspace '{workspace}', "
416
+ f"but exists in: {hint} -- use --workspace=<workspace> to show it"
417
+ )
418
+ else:
419
+ msg = f"brief '{name}' not found in workspace '{workspace}'"
420
+ return _err(msg, as_json=as_json)
391
421
 
392
- if getattr(args, "json", False):
422
+ if as_json:
393
423
  # Drop internal SQL columns for cleanliness
394
424
  out = {k: v for k, v in brief.items() if k != "id"}
395
425
  print(json.dumps(out, indent=2, default=str))
@@ -725,8 +755,13 @@ def register(subparsers) -> None:
725
755
  # -- close --------------------------------------------------------------
726
756
  close_p = actions.add_parser(
727
757
  "close",
728
- help="Set brief status to closed",
729
- description="Shortcut for set-status closed.",
758
+ help="Set brief status to closed (advisory verify, no cascade)",
759
+ description=(
760
+ "Set the brief's status to 'closed', then run verify_brief and "
761
+ "print any inconsistencies as warnings. ADVISORY ONLY: it does NOT "
762
+ "change AC, milestone, or plan status, and performs no cascade. To "
763
+ "resolve a flagged AC, use 'gaia ac set-status' (done / descoped)."
764
+ ),
730
765
  formatter_class=argparse.RawDescriptionHelpFormatter,
731
766
  epilog="Examples:\n gaia brief close <name>\n gaia brief close my-feature --workspace=me\n",
732
767
  )
@@ -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
@@ -185,7 +185,7 @@ def _package_root() -> Path:
185
185
  # in lock-step with the INSERT it adds to bootstrap_database.sh. If a user
186
186
  # upgrades the CLI past a schema bump but does not re-run `gaia install`,
187
187
  # `check_schema_version` raises a warning telling them how to repair.
188
- EXPECTED_SCHEMA_VERSION = 20
188
+ EXPECTED_SCHEMA_VERSION = 21
189
189
 
190
190
  # Locations the doctor reads outside the workspace.
191
191
  _INSTALL_ERROR_MARKER = Path("~/.gaia/last-install-error.json").expanduser()
@@ -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.11",
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",
@@ -13,10 +13,13 @@ Modules:
13
13
  from .types import (
14
14
  HookEventType,
15
15
  PermissionDecision,
16
- DistributionChannel,
16
+ HostDistribution,
17
+ HostCapability,
17
18
  HookEvent,
18
19
  ValidationRequest,
19
20
  ValidationResult,
21
+ ConsentRequest,
22
+ CapabilityDegradation,
20
23
  ToolResult,
21
24
  AgentCompletion,
22
25
  CompletionResult,
@@ -28,15 +31,19 @@ from .types import (
28
31
  )
29
32
  from .base import HookAdapter
30
33
  from .claude_code import ClaudeCodeAdapter
34
+ from .registry import get_adapter, register_adapter, DEFAULT_HOST
31
35
  from .utils import has_stdin_data, warn_if_dual_channel
32
36
 
33
37
  __all__ = [
34
38
  "HookEventType",
35
39
  "PermissionDecision",
36
- "DistributionChannel",
40
+ "HostDistribution",
41
+ "HostCapability",
37
42
  "HookEvent",
38
43
  "ValidationRequest",
39
44
  "ValidationResult",
45
+ "ConsentRequest",
46
+ "CapabilityDegradation",
40
47
  "ToolResult",
41
48
  "AgentCompletion",
42
49
  "CompletionResult",
@@ -47,6 +54,9 @@ __all__ = [
47
54
  "HookResponse",
48
55
  "HookAdapter",
49
56
  "ClaudeCodeAdapter",
57
+ "get_adapter",
58
+ "register_adapter",
59
+ "DEFAULT_HOST",
50
60
  "has_stdin_data",
51
61
  "warn_if_dual_channel",
52
62
  ]