@jaguilar87/gaia 5.0.8 → 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 (108) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +13 -0
  4. package/bin/README.md +10 -3
  5. package/bin/cli/_install_helpers.py +0 -3
  6. package/bin/cli/approvals.py +341 -238
  7. package/bin/cli/brief.py +45 -4
  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/claude_code.py +19 -85
  13. package/dist/gaia-ops/hooks/modules/context/context_injector.py +23 -7
  14. package/dist/gaia-ops/hooks/modules/core/plugin_setup.py +0 -5
  15. package/dist/gaia-ops/hooks/modules/events/event_writer.py +63 -96
  16. package/dist/gaia-ops/hooks/modules/security/__init__.py +0 -2
  17. package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +238 -69
  18. package/dist/gaia-ops/hooks/modules/security/approval_grants.py +506 -1103
  19. package/dist/gaia-ops/hooks/modules/security/capability_classes.py +83 -6
  20. package/dist/gaia-ops/hooks/modules/security/inline_ast_analyzer.py +237 -0
  21. package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +434 -1
  22. package/dist/gaia-ops/hooks/modules/session/pending_scanner.py +150 -90
  23. package/dist/gaia-ops/hooks/modules/session/session_manifest.py +257 -28
  24. package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +177 -20
  25. package/dist/gaia-ops/hooks/post_compact.py +1 -0
  26. package/dist/gaia-ops/hooks/pre_compact.py +1 -0
  27. package/dist/gaia-ops/hooks/user_prompt_submit.py +20 -0
  28. package/dist/gaia-ops/skills/agent-approval-protocol/SKILL.md +27 -7
  29. package/dist/gaia-ops/skills/agent-approval-protocol/reference.md +11 -6
  30. package/dist/gaia-ops/skills/gaia-patterns/reference.md +2 -2
  31. package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +69 -28
  32. package/dist/gaia-ops/skills/orchestrator-present-approval/reference.md +16 -3
  33. package/dist/gaia-ops/skills/orchestrator-present-approval/template.md +10 -5
  34. package/dist/gaia-ops/skills/pending-approvals/SKILL.md +16 -11
  35. package/dist/gaia-ops/skills/security-tiers/SKILL.md +1 -1
  36. package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +20 -6
  37. package/dist/gaia-ops/skills/subagent-request-approval/reference.md +23 -15
  38. package/dist/gaia-ops/tools/migration/README.md +10 -12
  39. package/dist/gaia-ops/tools/scan/orchestrator.py +194 -10
  40. package/dist/gaia-ops/tools/scan/tests/test_integration.py +1 -2
  41. package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
  42. package/dist/gaia-security/hooks/adapters/claude_code.py +19 -85
  43. package/dist/gaia-security/hooks/modules/context/context_injector.py +23 -7
  44. package/dist/gaia-security/hooks/modules/core/plugin_setup.py +0 -5
  45. package/dist/gaia-security/hooks/modules/events/event_writer.py +63 -96
  46. package/dist/gaia-security/hooks/modules/security/__init__.py +0 -2
  47. package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +238 -69
  48. package/dist/gaia-security/hooks/modules/security/approval_grants.py +506 -1103
  49. package/dist/gaia-security/hooks/modules/security/capability_classes.py +83 -6
  50. package/dist/gaia-security/hooks/modules/security/inline_ast_analyzer.py +237 -0
  51. package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +434 -1
  52. package/dist/gaia-security/hooks/modules/session/pending_scanner.py +150 -90
  53. package/dist/gaia-security/hooks/modules/session/session_manifest.py +257 -28
  54. package/dist/gaia-security/hooks/modules/tools/bash_validator.py +177 -20
  55. package/dist/gaia-security/hooks/user_prompt_submit.py +20 -0
  56. package/gaia/approvals/store.py +87 -9
  57. package/gaia/briefs/__init__.py +4 -0
  58. package/gaia/briefs/store.py +91 -0
  59. package/gaia/store/schema.sql +38 -1
  60. package/gaia/store/writer.py +400 -0
  61. package/hooks/adapters/claude_code.py +19 -85
  62. package/hooks/elicitation_result.py +20 -75
  63. package/hooks/modules/context/context_injector.py +23 -7
  64. package/hooks/modules/core/plugin_setup.py +0 -5
  65. package/hooks/modules/events/event_writer.py +63 -96
  66. package/hooks/modules/security/__init__.py +0 -2
  67. package/hooks/modules/security/approval_cleanup.py +238 -69
  68. package/hooks/modules/security/approval_grants.py +506 -1103
  69. package/hooks/modules/security/capability_classes.py +83 -6
  70. package/hooks/modules/security/inline_ast_analyzer.py +237 -0
  71. package/hooks/modules/security/mutative_verbs.py +434 -1
  72. package/hooks/modules/session/pending_scanner.py +150 -90
  73. package/hooks/modules/session/session_manifest.py +257 -28
  74. package/hooks/modules/tools/bash_validator.py +177 -20
  75. package/hooks/post_compact.py +1 -0
  76. package/hooks/pre_compact.py +1 -0
  77. package/hooks/user_prompt_submit.py +20 -0
  78. package/package.json +1 -1
  79. package/pyproject.toml +20 -1
  80. package/scripts/bootstrap_database.sh +66 -17
  81. package/scripts/migrations/README.md +26 -14
  82. package/scripts/migrations/schema.checksum +2 -2
  83. package/scripts/migrations/v18_to_v19.sql +36 -0
  84. package/scripts/migrations/v19_to_v20.sql +20 -0
  85. package/skills/agent-approval-protocol/SKILL.md +27 -7
  86. package/skills/agent-approval-protocol/reference.md +11 -6
  87. package/skills/gaia-patterns/reference.md +2 -2
  88. package/skills/orchestrator-present-approval/SKILL.md +69 -28
  89. package/skills/orchestrator-present-approval/reference.md +16 -3
  90. package/skills/orchestrator-present-approval/template.md +10 -5
  91. package/skills/pending-approvals/SKILL.md +16 -11
  92. package/skills/security-tiers/SKILL.md +1 -1
  93. package/skills/subagent-request-approval/SKILL.md +20 -6
  94. package/skills/subagent-request-approval/reference.md +23 -15
  95. package/tools/migration/README.md +10 -12
  96. package/tools/scan/orchestrator.py +194 -10
  97. package/tools/scan/tests/test_integration.py +1 -2
  98. package/bin/cli/plans.py +0 -517
  99. package/dist/gaia-ops/tools/context/deep_merge.py +0 -159
  100. package/dist/gaia-ops/tools/migration/migrate_04_harness_events.py +0 -132
  101. package/dist/gaia-ops/tools/migration/migrate_04_harness_events.sh +0 -23
  102. package/dist/gaia-ops/tools/scan/merge.py +0 -213
  103. package/dist/gaia-ops/tools/scan/tests/test_merge.py +0 -269
  104. package/tools/context/deep_merge.py +0 -159
  105. package/tools/migration/migrate_04_harness_events.py +0 -132
  106. package/tools/migration/migrate_04_harness_events.sh +0 -23
  107. package/tools/scan/merge.py +0 -213
  108. package/tools/scan/tests/test_merge.py +0 -269
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))
@@ -431,10 +459,23 @@ def _cmd_list(args) -> int:
431
459
 
432
460
  def _cmd_close(args) -> int:
433
461
  from gaia.briefs import close_brief
462
+ from gaia.briefs.store import verify_brief
434
463
  workspace = _resolve_workspace(getattr(args, "workspace", None))
435
464
  name = args.name
436
465
  if close_brief(workspace, name):
437
466
  print(f"Closed brief '{name}'")
467
+ # AC-3 advisory: run invariant checker and surface any inconsistencies
468
+ # as non-blocking stderr warnings (mirrors D11 pattern in plan CLI).
469
+ # Close always succeeds (exit 0); warnings never gate the operation.
470
+ try:
471
+ result = verify_brief(workspace, name)
472
+ for issue in result.get("inconsistencies", []):
473
+ print(
474
+ f"Warning: [{issue['kind']}] {issue['detail']}",
475
+ file=sys.stderr,
476
+ )
477
+ except Exception:
478
+ pass # advisory failure must never abort the close
438
479
  return 0
439
480
  return _err(f"brief '{name}' not found in workspace '{workspace}'")
440
481
 
@@ -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 = 18
188
+ EXPECTED_SCHEMA_VERSION = 20
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.8",
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",
@@ -1117,19 +1117,15 @@ class ClaudeCodeAdapter(HookAdapter):
1117
1117
  2. Load the specific pending file by prefix (any session).
1118
1118
  3. Activate the grant under the CURRENT session.
1119
1119
 
1120
- Falls back to session-wide activation when no nonce is present in
1121
- the answer (backward compatibility with older approval labels).
1120
+ DB-only since the grant-lifecycle FS retirement: REQUESTED writes go
1121
+ to the DB, so the approved pending is resolved by nonce prefix straight
1122
+ from the DB via ``activate_db_pending_by_prefix()``.
1122
1123
 
1123
1124
  Never blocks (no exceptions raised to caller).
1124
1125
  """
1125
1126
  from modules.security.approval_grants import (
1126
- activate_cross_session_pending,
1127
1127
  activate_db_pending_by_prefix,
1128
- activate_grants_for_session,
1129
- activate_pending_approval,
1130
1128
  extract_nonce_from_label,
1131
- get_pending_approvals_for_session,
1132
- load_pending_by_nonce_prefix,
1133
1129
  )
1134
1130
 
1135
1131
  session_id = hook_data.get("session_id", "") or os.environ.get("CLAUDE_SESSION_ID", "")
@@ -1163,93 +1159,31 @@ class ClaudeCodeAdapter(HookAdapter):
1163
1159
  logger.info("AskUserQuestion: no session_id available, skipping grant activation")
1164
1160
  return
1165
1161
 
1166
- # Try nonce-targeted activation first: extract nonce from answer labels
1162
+ # Nonce-targeted activation: extract the nonce from answer labels.
1167
1163
  nonce_prefix = None
1168
1164
  for v in answers.values():
1169
1165
  nonce_prefix = extract_nonce_from_label(str(v))
1170
1166
  if nonce_prefix:
1171
1167
  break
1172
1168
 
1173
- if nonce_prefix:
1174
- # Nonce-targeted: load this specific pending regardless of session
1175
- pending_data = load_pending_by_nonce_prefix(nonce_prefix)
1176
- if pending_data:
1177
- pending_session = pending_data.get("session_id", "")
1178
- full_nonce = pending_data.get("nonce", "")
1179
-
1180
- if pending_session == session_id:
1181
- # Same session -- use standard activation
1182
- result = activate_pending_approval(
1183
- nonce=full_nonce,
1184
- session_id=session_id,
1185
- )
1186
- else:
1187
- # Cross session -- activate under current session
1188
- result = activate_cross_session_pending(
1189
- pending_data,
1190
- session_id=session_id,
1191
- )
1192
-
1193
- if result.success:
1194
- logger.info(
1195
- "AskUserQuestion nonce-targeted activation: prefix=%s, "
1196
- "pending_session=%s, current_session=%s, status=%s",
1197
- nonce_prefix, pending_session[:12], session_id[:12],
1198
- getattr(result.status, "value", str(result.status)),
1199
- )
1200
- return
1201
- else:
1202
- logger.warning(
1203
- "AskUserQuestion nonce-targeted activation failed: "
1204
- "prefix=%s, status=%s, reason=%s",
1205
- nonce_prefix,
1206
- getattr(result.status, "value", str(result.status)),
1207
- result.reason,
1208
- )
1209
- else:
1210
- # Filesystem pending not found -- try DB lookup (M2 bridge).
1211
- # Since M2, REQUESTED writes go to DB only; no pending-{nonce}.json
1212
- # is written to the filesystem any more.
1213
- logger.info(
1214
- "AskUserQuestion: nonce prefix %s found in label but no "
1215
- "matching pending file -- trying DB lookup (M2 bridge)",
1216
- nonce_prefix,
1217
- )
1218
- result = activate_db_pending_by_prefix(
1219
- nonce_prefix, current_session_id=session_id,
1220
- )
1221
- if result.success:
1222
- logger.info(
1223
- "AskUserQuestion DB-bridge activation: prefix=%s status=%s",
1224
- nonce_prefix,
1225
- getattr(result.status, "value", str(result.status)),
1226
- )
1227
- return
1228
- else:
1229
- logger.warning(
1230
- "AskUserQuestion DB-bridge activation failed: "
1231
- "prefix=%s status=%s reason=%s -- falling back to session-wide",
1232
- nonce_prefix,
1233
- getattr(result.status, "value", str(result.status)),
1234
- result.reason,
1235
- )
1236
- # Fall through to session-wide activation below
1237
- nonce_prefix = None
1238
-
1239
1169
  if not nonce_prefix:
1240
- # No nonce in label (or all targeted paths failed) -- fall back to
1241
- # session-wide activation for backward compatibility
1242
- pending = get_pending_approvals_for_session(session_id)
1243
- if not pending:
1244
- logger.info("AskUserQuestion: no pending grants for session %s", session_id)
1245
- return
1246
-
1247
- results = activate_grants_for_session(session_id)
1248
- activated = sum(1 for r in results if r.success)
1249
1170
  logger.info(
1250
- "AskUserQuestion session-wide activation: %d/%d pending grants for session %s",
1251
- activated, len(results), session_id,
1171
+ "AskUserQuestion: no nonce prefix in answer labels -- "
1172
+ "nothing to activate for session %s", session_id[:12],
1252
1173
  )
1174
+ return
1175
+
1176
+ # Resolve the approved pending straight from the DB.
1177
+ result = activate_db_pending_by_prefix(
1178
+ nonce_prefix, current_session_id=session_id,
1179
+ )
1180
+ logger.info(
1181
+ "AskUserQuestion DB activation: prefix=%s success=%s status=%s reason=%s",
1182
+ nonce_prefix,
1183
+ result.success,
1184
+ getattr(result.status, "value", str(result.status)),
1185
+ result.reason,
1186
+ )
1253
1187
 
1254
1188
  except Exception as e:
1255
1189
  logger.error("Error in _handle_ask_user_question_result: %s", e, exc_info=True)