@jaguilar87/gaia 5.0.7 → 5.0.9

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 (99) 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 +6 -1
  5. package/bin/cli/approvals.py +486 -474
  6. package/bin/cli/brief.py +13 -0
  7. package/bin/cli/doctor.py +1 -1
  8. package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
  9. package/dist/gaia-ops/hooks/adapters/claude_code.py +92 -86
  10. package/dist/gaia-ops/hooks/modules/agents/handoff_persister.py +13 -2
  11. package/dist/gaia-ops/hooks/modules/context/context_injector.py +23 -7
  12. package/dist/gaia-ops/hooks/modules/events/event_writer.py +63 -96
  13. package/dist/gaia-ops/hooks/modules/security/__init__.py +0 -2
  14. package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +238 -69
  15. package/dist/gaia-ops/hooks/modules/security/approval_grants.py +506 -1103
  16. package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +24 -1
  17. package/dist/gaia-ops/hooks/modules/session/pending_scanner.py +150 -90
  18. package/dist/gaia-ops/hooks/modules/session/session_manifest.py +257 -28
  19. package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +19 -0
  20. package/dist/gaia-ops/hooks/post_compact.py +1 -0
  21. package/dist/gaia-ops/hooks/pre_compact.py +1 -0
  22. package/dist/gaia-ops/hooks/user_prompt_submit.py +20 -0
  23. package/dist/gaia-ops/skills/agent-approval-protocol/SKILL.md +50 -14
  24. package/dist/gaia-ops/skills/agent-approval-protocol/reference.md +16 -9
  25. package/dist/gaia-ops/skills/agent-protocol/examples.md +12 -1
  26. package/dist/gaia-ops/skills/gaia-patterns/reference.md +2 -2
  27. package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +69 -22
  28. package/dist/gaia-ops/skills/orchestrator-present-approval/reference.md +16 -3
  29. package/dist/gaia-ops/skills/orchestrator-present-approval/template.md +20 -14
  30. package/dist/gaia-ops/skills/pending-approvals/SKILL.md +16 -11
  31. package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +28 -3
  32. package/dist/gaia-ops/skills/subagent-request-approval/reference.md +34 -8
  33. package/dist/gaia-ops/tools/migration/README.md +10 -12
  34. package/dist/gaia-ops/tools/scan/orchestrator.py +194 -10
  35. package/dist/gaia-ops/tools/scan/tests/test_integration.py +1 -2
  36. package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
  37. package/dist/gaia-security/hooks/adapters/claude_code.py +92 -86
  38. package/dist/gaia-security/hooks/modules/agents/handoff_persister.py +13 -2
  39. package/dist/gaia-security/hooks/modules/context/context_injector.py +23 -7
  40. package/dist/gaia-security/hooks/modules/events/event_writer.py +63 -96
  41. package/dist/gaia-security/hooks/modules/security/__init__.py +0 -2
  42. package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +238 -69
  43. package/dist/gaia-security/hooks/modules/security/approval_grants.py +506 -1103
  44. package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +24 -1
  45. package/dist/gaia-security/hooks/modules/session/pending_scanner.py +150 -90
  46. package/dist/gaia-security/hooks/modules/session/session_manifest.py +257 -28
  47. package/dist/gaia-security/hooks/modules/tools/bash_validator.py +19 -0
  48. package/dist/gaia-security/hooks/user_prompt_submit.py +20 -0
  49. package/gaia/approvals/__init__.py +2 -1
  50. package/gaia/approvals/store.py +165 -15
  51. package/gaia/store/schema.sql +38 -1
  52. package/gaia/store/writer.py +400 -0
  53. package/hooks/adapters/claude_code.py +92 -86
  54. package/hooks/elicitation_result.py +20 -75
  55. package/hooks/modules/agents/handoff_persister.py +13 -2
  56. package/hooks/modules/context/context_injector.py +23 -7
  57. package/hooks/modules/events/event_writer.py +63 -96
  58. package/hooks/modules/security/__init__.py +0 -2
  59. package/hooks/modules/security/approval_cleanup.py +238 -69
  60. package/hooks/modules/security/approval_grants.py +506 -1103
  61. package/hooks/modules/security/mutative_verbs.py +24 -1
  62. package/hooks/modules/session/pending_scanner.py +150 -90
  63. package/hooks/modules/session/session_manifest.py +257 -28
  64. package/hooks/modules/tools/bash_validator.py +19 -0
  65. package/hooks/post_compact.py +1 -0
  66. package/hooks/pre_compact.py +1 -0
  67. package/hooks/user_prompt_submit.py +20 -0
  68. package/package.json +1 -1
  69. package/pyproject.toml +1 -1
  70. package/scripts/bootstrap_database.sh +66 -17
  71. package/scripts/migrations/README.md +26 -14
  72. package/scripts/migrations/schema.checksum +2 -2
  73. package/scripts/migrations/v18_to_v19.sql +36 -0
  74. package/scripts/migrations/v19_to_v20.sql +20 -0
  75. package/skills/agent-approval-protocol/SKILL.md +50 -14
  76. package/skills/agent-approval-protocol/reference.md +16 -9
  77. package/skills/agent-protocol/examples.md +12 -1
  78. package/skills/gaia-patterns/reference.md +2 -2
  79. package/skills/orchestrator-present-approval/SKILL.md +69 -22
  80. package/skills/orchestrator-present-approval/reference.md +16 -3
  81. package/skills/orchestrator-present-approval/template.md +20 -14
  82. package/skills/pending-approvals/SKILL.md +16 -11
  83. package/skills/subagent-request-approval/SKILL.md +28 -3
  84. package/skills/subagent-request-approval/reference.md +34 -8
  85. package/tools/migration/README.md +10 -12
  86. package/tools/scan/orchestrator.py +194 -10
  87. package/tools/scan/tests/test_integration.py +1 -2
  88. package/bin/cli/plans.py +0 -517
  89. package/dist/gaia-ops/tools/context/deep_merge.py +0 -159
  90. package/dist/gaia-ops/tools/migration/migrate_04_harness_events.py +0 -132
  91. package/dist/gaia-ops/tools/migration/migrate_04_harness_events.sh +0 -23
  92. package/dist/gaia-ops/tools/scan/merge.py +0 -213
  93. package/dist/gaia-ops/tools/scan/tests/test_merge.py +0 -269
  94. package/gaia/approvals/revert.py +0 -282
  95. package/tools/context/deep_merge.py +0 -159
  96. package/tools/migration/migrate_04_harness_events.py +0 -132
  97. package/tools/migration/migrate_04_harness_events.sh +0 -23
  98. package/tools/scan/merge.py +0 -213
  99. package/tools/scan/tests/test_merge.py +0 -269
@@ -42,16 +42,12 @@ for _p in [str(_HOOKS_DIR), str(_PLUGIN_ROOT)]:
42
42
  def _import_approval_grants():
43
43
  """Import approval_grants lazily to allow mocking in tests."""
44
44
  from modules.security.approval_grants import (
45
- cleanup_expired_grants,
46
45
  get_pending_approvals_for_session,
47
46
  load_pending_by_nonce_prefix,
48
- reject_pending,
49
47
  )
50
48
  return {
51
- "cleanup_expired_grants": cleanup_expired_grants,
52
49
  "get_pending_approvals_for_session": get_pending_approvals_for_session,
53
50
  "load_pending_by_nonce_prefix": load_pending_by_nonce_prefix,
54
- "reject_pending": reject_pending,
55
51
  }
56
52
 
57
53
 
@@ -76,18 +72,6 @@ def _import_grants_dir():
76
72
  return _get_grants_dir()
77
73
 
78
74
 
79
- def _import_approval_grants_module():
80
- """Return the approval_grants module object for direct attribute access.
81
-
82
- Separate from _import_approval_grants() so cmd_clean can reset
83
- _last_cleanup_time and call cleanup_expired_grants atomically on the
84
- same module reference. Kept as a separate injectable function so tests
85
- can mock it without touching sys.modules.
86
- """
87
- import modules.security.approval_grants as ag_mod
88
- return ag_mod
89
-
90
-
91
75
  def _import_writer():
92
76
  """Import gaia.store.writer lazily to allow mocking in tests."""
93
77
  from gaia.store import writer
@@ -152,42 +136,96 @@ def _pending_to_display(p: dict) -> dict:
152
136
  def _scan_pending_shared(exclude_live_sessions: bool = False) -> list:
153
137
  """Return all non-expired, non-rejected pending approvals across all sessions.
154
138
 
155
- Thin wrapper around the shared ``scan_pending_approvals`` in
156
- ``modules.session.pending_scanner`` so CLI and hook consumers share one
157
- implementation of pending discovery + liveness filtering.
139
+ DB-primary since Task E: queries gaia.approvals.store (all_sessions=True).
140
+ All pending types (T3 commands, COMMAND_SET batches, SCOPE_FILE_PATH
141
+ file-write blocks) are now written exclusively to the DB.
158
142
 
159
143
  When ``exclude_live_sessions=True``, only pendings whose owning session
160
- is NOT currently alive (orphans) are returned this backs the
161
- ``--orphans-only`` flag.
144
+ is NOT currently alive (orphans) are returned -- this backs the
145
+ ``--orphans-only`` flag. Session liveness is checked via
146
+ session_registry.get_live_sessions() when available.
147
+
148
+ Returns a list of dicts in the shape _pending_to_display() expects.
162
149
 
163
150
  Raises:
164
- Exception: propagated from ``_import_grants_dir()`` so ``cmd_list``
165
- can catch it and return exit code 1 consistently.
151
+ Exception: propagated from the store import so cmd_list can catch it
152
+ and return exit code 1 consistently.
166
153
  """
167
- # Let ImportError / other failures from _import_grants_dir propagate up.
168
- grants_dir = _import_grants_dir()
154
+ store = _import_approval_store()
155
+ rows = store.list_pending(all_sessions=True)
169
156
 
170
- from modules.session.pending_scanner import scan_pending_approvals
171
-
172
- scanned = scan_pending_approvals(
173
- grants_dir, exclude_live_sessions=exclude_live_sessions
174
- )
157
+ # Optional liveness filter.
158
+ if exclude_live_sessions:
159
+ try:
160
+ import sys as _sys
161
+ import pathlib as _pl
162
+ # Ensure hooks/ is importable (mirrors the top-of-file sys.path setup).
163
+ _hooks_dir = str(_PLUGIN_ROOT / "hooks")
164
+ if _hooks_dir not in _sys.path:
165
+ _sys.path.insert(0, _hooks_dir)
166
+ from modules.session.session_registry import get_live_sessions
167
+ live = get_live_sessions(include_headless=False)
168
+ rows = [r for r in rows if r.get("session_id") not in live]
169
+ except Exception:
170
+ pass # Conservative: return all on registry failure
175
171
 
176
- # scan_pending_approvals returns a display-ish shape; we rehydrate each
177
- # scanned result back into the on-disk pending dict keys that
178
- # _pending_to_display expects. Single source of truth for the scan,
179
- # but the CLI's display contract is preserved unchanged.
180
172
  results = []
181
- for s in scanned:
173
+ for row in rows:
174
+ payload_json = row.get("payload_json") or "{}"
175
+ try:
176
+ payload = json.loads(payload_json)
177
+ except (json.JSONDecodeError, TypeError):
178
+ payload = {}
179
+
180
+ # Extract command: prefer exact_content, fall back to first command.
181
+ command = (
182
+ payload.get("exact_content")
183
+ or (payload.get("commands") or [None])[0]
184
+ or payload.get("operation")
185
+ or ""
186
+ )
187
+
188
+ # Extract verb and category from operation field.
189
+ operation = payload.get("operation", "")
190
+ danger_verb = "unknown"
191
+ danger_category = "MUTATIVE"
192
+ if ": " in operation:
193
+ danger_verb = operation.rsplit(": ", 1)[-1].strip()
194
+ if " command intercepted" in operation:
195
+ danger_category = operation.split(" command intercepted")[0].strip()
196
+
197
+ # Compute timestamp from created_at.
198
+ created_at_str = row.get("created_at", "")
199
+ ts: float = 0.0
200
+ if created_at_str:
201
+ try:
202
+ from datetime import datetime as _dt, timezone as _tz
203
+ dt = _dt.strptime(created_at_str, "%Y-%m-%dT%H:%M:%SZ").replace(
204
+ tzinfo=_tz.utc
205
+ )
206
+ ts = dt.timestamp()
207
+ except (ValueError, TypeError):
208
+ ts = 0.0
209
+
210
+ approval_id = row.get("id", "")
211
+ # nonce: strip the "P-" prefix so _pending_to_display's
212
+ # _approval_id_label("P-" + nonce_prefix) works correctly.
213
+ nonce = approval_id[2:] if approval_id.startswith("P-") else approval_id
214
+
182
215
  results.append({
183
- "nonce": s.get("nonce_full") or s.get("nonce_short", ""),
184
- "session_id": s.get("pending_session_id", ""),
185
- "command": s.get("command", ""),
186
- "danger_verb": s.get("verb", ""),
187
- "danger_category": s.get("category", ""),
188
- "scope_type": s.get("scope_type", ""),
189
- "timestamp": s.get("timestamp", 0),
190
- "context": s.get("context", {}),
216
+ "nonce": nonce,
217
+ "session_id": row.get("session_id", ""),
218
+ "command": command,
219
+ "danger_verb": danger_verb,
220
+ "danger_category": danger_category,
221
+ "scope_type": payload.get("scope", "semantic_signature"),
222
+ "timestamp": ts,
223
+ "context": {
224
+ "description": payload.get("rationale", ""),
225
+ "risk": payload.get("risk_level", "medium"),
226
+ "rollback": payload.get("rollback_hint"),
227
+ "source": "db",
228
+ },
191
229
  })
192
230
 
193
231
  results.sort(key=lambda d: d.get("timestamp", 0), reverse=True)
@@ -243,13 +281,13 @@ def _grant_to_display(g: dict) -> dict:
243
281
 
244
282
 
245
283
  def cmd_list(args) -> int:
246
- """List approval grants from the DB.
284
+ """List approval grants and pending approvals from the DB.
247
285
 
248
286
  Without ``--session``, all grants are shown. With ``--session SESSION_ID``,
249
287
  only that session's grants are shown.
250
288
 
251
- Also supports ``--orphans-only`` (filesystem pending approvals from dead
252
- sessions) via the legacy scan path for backward compatibility.
289
+ ``--orphans-only`` filters pending approvals to rows whose owning session
290
+ is no longer alive (orphaned pendings from dead sessions).
253
291
  """
254
292
  session_id = getattr(args, "session", None)
255
293
  orphans_only = getattr(args, "orphans_only", False)
@@ -264,22 +302,12 @@ def cmd_list(args) -> int:
264
302
  except Exception:
265
303
  db_grants = []
266
304
 
267
- # Legacy filesystem pending listing (for filesystem-based pending approvals)
305
+ # DB-backed pending listing (canonical since Task E FS retirement)
268
306
  fs_pending = []
269
- if not orphans_only:
270
- try:
271
- if session_id is None:
272
- fs_pending = _scan_pending_shared(exclude_live_sessions=False)
273
- else:
274
- ag = _import_approval_grants()
275
- fs_pending = ag["get_pending_approvals_for_session"](session_id)
276
- except Exception:
277
- pass
278
- else:
279
- try:
280
- fs_pending = _scan_pending_shared(exclude_live_sessions=True)
281
- except Exception:
282
- pass
307
+ try:
308
+ fs_pending = _scan_pending_shared(exclude_live_sessions=orphans_only)
309
+ except Exception:
310
+ pass
283
311
 
284
312
  db_items = [_grant_to_display(g) for g in db_grants]
285
313
  fs_items = [_pending_to_display(p) for p in fs_pending]
@@ -513,38 +541,45 @@ def cmd_reject(args) -> int:
513
541
  if nonce.upper().startswith("P-"):
514
542
  nonce = nonce[2:]
515
543
 
544
+ # DB-primary since Task E: find the pending DB row whose approval_id matches
545
+ # the prefix, then revoke it (pending -> revoked, append-only event chain).
546
+ session_id = os.environ.get("CLAUDE_SESSION_ID") or "cli-reject"
516
547
  try:
517
- ag = _import_approval_grants()
518
- ok = ag["reject_pending"](nonce)
548
+ store = _import_approval_store()
549
+ rows = store.list_pending(all_sessions=True)
550
+ matched_id = None
551
+ for row in rows:
552
+ row_id = row.get("id", "")
553
+ if row_id.startswith(f"P-{nonce}"):
554
+ matched_id = row_id
555
+ break
556
+ if matched_id is None:
557
+ _print_error(f"No pending approval found for P-{nonce}", args)
558
+ return 1
559
+ store.revoke(matched_id, session_id)
519
560
  except Exception as exc:
520
561
  _print_error(f"Failed to reject approval: {exc}", args)
521
562
  return 1
522
563
 
523
- if ok:
524
- msg = f"Rejected P-{nonce}"
525
- if reason:
526
- msg += f" (reason: {reason})"
527
- if getattr(args, "json", False):
528
- print(json.dumps({"status": "rejected", "nonce_prefix": nonce, "reason": reason}))
529
- else:
530
- print(msg)
531
- return 0
564
+ msg = f"Rejected P-{nonce}"
565
+ if reason:
566
+ msg += f" (reason: {reason})"
567
+ if getattr(args, "json", False):
568
+ print(json.dumps({"status": "rejected", "nonce_prefix": nonce, "reason": reason}))
532
569
  else:
533
- _print_error(f"No pending approval found for P-{nonce}", args)
534
- return 1
570
+ print(msg)
571
+ return 0
535
572
 
536
573
 
537
574
  def _cmd_reject_all(args, reason: str | None) -> int:
538
575
  """Reject all pending approvals across all sessions.
539
576
 
540
- Scans the same queue that ``gaia approvals list`` shows, then calls
541
- ``reject_pending`` for each non-expired, non-rejected pending approval.
542
- Exits 0 always -- an empty queue is not an error.
577
+ DB-primary since Task E: queries gaia.approvals.store for all pending
578
+ rows and revokes each via store.revoke(). Exits 0 always -- an empty
579
+ queue is not an error.
543
580
  """
544
581
  try:
545
- # Bulk reject operates on the full queue regardless of liveness;
546
- # we intentionally pass exclude_live_sessions=False so the operator
547
- # can clear orphaned and live-session pendings in one call.
582
+ # Bulk reject operates on the full queue regardless of liveness.
548
583
  raw = _scan_pending_shared(exclude_live_sessions=False)
549
584
  except Exception as exc:
550
585
  _print_error(f"Failed to load approvals: {exc}", args)
@@ -557,11 +592,11 @@ def _cmd_reject_all(args, reason: str | None) -> int:
557
592
  print("No pending approvals to reject.")
558
593
  return 0
559
594
 
595
+ session_id = os.environ.get("CLAUDE_SESSION_ID") or "cli-reject-all"
560
596
  try:
561
- ag = _import_approval_grants()
562
- reject_fn = ag["reject_pending"]
597
+ store = _import_approval_store()
563
598
  except Exception as exc:
564
- _print_error(f"Failed to load approval module: {exc}", args)
599
+ _print_error(f"Failed to load approval store: {exc}", args)
565
600
  return 1
566
601
 
567
602
  rejected_ids = []
@@ -569,12 +604,10 @@ def _cmd_reject_all(args, reason: str | None) -> int:
569
604
  for pending in raw:
570
605
  nonce = pending.get("nonce", "")
571
606
  nonce_prefix = _nonce_short(nonce)
607
+ approval_id = f"P-{nonce}"
572
608
  try:
573
- ok = reject_fn(nonce_prefix)
574
- if ok:
575
- rejected_ids.append(f"P-{nonce_prefix}")
576
- else:
577
- failed_ids.append(f"P-{nonce_prefix}")
609
+ store.revoke(approval_id, session_id)
610
+ rejected_ids.append(f"P-{nonce_prefix}")
578
611
  except Exception:
579
612
  failed_ids.append(f"P-{nonce_prefix}")
580
613
 
@@ -621,9 +654,9 @@ def _grants_dir_for_workspace(workspace: str | None):
621
654
  def cmd_reject_all(args) -> int:
622
655
  """Reject all active pending approvals in one pass.
623
656
 
624
- Scans the approval cache for every non-expired, non-rejected pending
625
- approval and calls ``reject_pending()`` on each nonce. This is the
626
- canonical subcommand surface documented in the pending-approvals skill.
657
+ Scans the DB for every non-expired, non-rejected pending approval and
658
+ calls ``store.revoke()`` on each approval_id. This is the canonical
659
+ subcommand surface documented in the pending-approvals skill.
627
660
 
628
661
  Flags:
629
662
  --dry-run Preview what would be rejected without writing changes.
@@ -632,23 +665,24 @@ def cmd_reject_all(args) -> int:
632
665
  dry_run: bool = getattr(args, "dry_run", False)
633
666
  workspace: str | None = getattr(args, "workspace", None)
634
667
 
635
- # Resolve grants directory, honoring --workspace if provided.
636
- try:
637
- grants_dir = _grants_dir_for_workspace(workspace)
638
- except Exception as exc:
639
- _print_error(f"Cannot resolve approvals directory: {exc}", args)
640
- return 1
668
+ # Scan pending approvals from the DB (all_sessions, no workspace filter
669
+ # needed -- the DB is per-machine, not per-workspace).
670
+ # When --workspace was supplied, emit an informational note that it is
671
+ # ignored (the DB is the authoritative store since Task E).
672
+ if workspace is not None:
673
+ import sys as _sys
674
+ print(
675
+ f"Note: --workspace is ignored; pending approvals are stored in "
676
+ f"~/.gaia/gaia.db (per-machine DB), not in the workspace FS.",
677
+ file=_sys.stderr,
678
+ )
641
679
 
642
- # Scan pending approvals using the resolved directory.
643
680
  try:
644
- from modules.session.pending_scanner import scan_pending_approvals
645
- scanned = scan_pending_approvals(grants_dir, exclude_live_sessions=False)
646
- raw: list = []
647
- for s in scanned:
648
- raw.append({
649
- "nonce": s.get("nonce_full") or s.get("nonce_short", ""),
650
- "command": s.get("command", ""),
651
- })
681
+ raw_pending = _scan_pending_shared(exclude_live_sessions=False)
682
+ raw: list = [
683
+ {"nonce": p.get("nonce", ""), "command": p.get("command", "")}
684
+ for p in raw_pending
685
+ ]
652
686
  except Exception as exc:
653
687
  _print_error(f"Failed to load approvals: {exc}", args)
654
688
  return 1
@@ -666,12 +700,12 @@ def cmd_reject_all(args) -> int:
666
700
  print(f"\n{len(raw)} pending(s) would be rejected.")
667
701
  return 0
668
702
 
669
- # Live rejection via the canonical reject_pending() path.
703
+ # Live rejection via store.revoke() (DB path -- all pendings are in DB now).
704
+ session_id = os.environ.get("CLAUDE_SESSION_ID") or "cli-reject-all"
670
705
  try:
671
- ag = _import_approval_grants()
672
- reject_fn = ag["reject_pending"]
706
+ store = _import_approval_store()
673
707
  except Exception as exc:
674
- _print_error(f"Failed to load approval module: {exc}", args)
708
+ _print_error(f"Failed to load approval store: {exc}", args)
675
709
  return 1
676
710
 
677
711
  rejected_ids = []
@@ -679,12 +713,10 @@ def cmd_reject_all(args) -> int:
679
713
  for item in raw:
680
714
  nonce = item["nonce"]
681
715
  nonce_prefix = _nonce_short(nonce)
716
+ approval_id = f"P-{nonce}"
682
717
  try:
683
- ok = reject_fn(nonce_prefix)
684
- if ok:
685
- rejected_ids.append(f"P-{nonce_prefix}")
686
- else:
687
- failed_ids.append(f"P-{nonce_prefix}")
718
+ store.revoke(approval_id, session_id)
719
+ rejected_ids.append(f"P-{nonce_prefix}")
688
720
  except Exception:
689
721
  failed_ids.append(f"P-{nonce_prefix}")
690
722
 
@@ -702,79 +734,128 @@ def cmd_reject_all(args) -> int:
702
734
  # ---------------------------------------------------------------------------
703
735
 
704
736
  def cmd_clean(args) -> int:
705
- """Remove expired and stale approvals."""
737
+ """Remove expired approvals and grants from the DB.
738
+
739
+ DB-only since FS retirement: all pending approvals and grants live in
740
+ gaia.db. Expired DB pending rows (status='pending', older than 24h TTL)
741
+ are transitioned to 'revoked' so the append-only event chain is preserved.
742
+ Expired approval_grants rows (status='PENDING', past expires_at) are
743
+ transitioned to 'EXPIRED'.
744
+ """
706
745
  dry_run = getattr(args, "dry_run", False)
707
746
 
708
747
  if dry_run:
709
- # Inspect without deleting -- count files that would be removed
748
+ # Count DB rows that would be cleaned (pending rows older than 24h).
749
+ db_expired = 0
710
750
  try:
711
- grants_dir = _import_grants_dir()
712
- except Exception as exc:
713
- _print_error(f"Cannot access approvals directory: {exc}", args)
714
- return 1
751
+ store = _import_approval_store()
752
+ rows = store.list_pending(all_sessions=True)
753
+ from datetime import datetime, timezone
754
+ now = datetime.now(timezone.utc)
755
+ for row in rows:
756
+ created_at_str = row.get("created_at", "")
757
+ if created_at_str:
758
+ try:
759
+ created_dt = datetime.strptime(
760
+ created_at_str, "%Y-%m-%dT%H:%M:%SZ"
761
+ ).replace(tzinfo=timezone.utc)
762
+ age_hours = (now - created_dt).total_seconds() / 3600
763
+ if age_hours > 24:
764
+ db_expired += 1
765
+ except (ValueError, TypeError):
766
+ pass
767
+ except Exception:
768
+ pass
715
769
 
716
- if not grants_dir.exists():
717
- msg = "Approvals directory does not exist. Nothing to clean."
718
- if getattr(args, "json", False):
719
- print(json.dumps({"dry_run": True, "would_remove": 0, "message": msg}))
720
- else:
721
- print(msg)
722
- return 0
770
+ # Count expired DB grant rows.
771
+ db_expired_grants = _count_expired_db_grants()
723
772
 
724
- would_remove = _count_stale_files(grants_dir)
773
+ would_remove = db_expired + db_expired_grants
774
+ msg = f"Dry run: {db_expired} expired DB pending(s) + {db_expired_grants} expired DB grant(s)"
725
775
  if getattr(args, "json", False):
726
- print(json.dumps({"dry_run": True, "would_remove": would_remove}))
776
+ print(json.dumps({
777
+ "dry_run": True,
778
+ "would_remove": would_remove,
779
+ "db_expired": db_expired,
780
+ "db_expired_grants": db_expired_grants,
781
+ "message": msg,
782
+ }))
727
783
  else:
728
- print(f"Dry run: {would_remove} expired/stale file(s) would be removed.")
784
+ print(msg)
729
785
  return 0
730
786
 
731
- # Real cleanup -- reset throttle to force run
787
+ # Real cleanup.
788
+ db_cleaned = 0
732
789
  try:
733
- ag_mod = _import_approval_grants_module()
734
- ag_mod._last_cleanup_time = 0.0
735
- cleaned = ag_mod.cleanup_expired_grants()
790
+ store = _import_approval_store()
791
+ rows = store.list_pending(all_sessions=True)
792
+ from datetime import datetime, timezone
793
+ now = datetime.now(timezone.utc)
794
+ session_id = os.environ.get("CLAUDE_SESSION_ID") or "cli-cleanup"
795
+ for row in rows:
796
+ created_at_str = row.get("created_at", "")
797
+ if not created_at_str:
798
+ continue
799
+ try:
800
+ created_dt = datetime.strptime(
801
+ created_at_str, "%Y-%m-%dT%H:%M:%SZ"
802
+ ).replace(tzinfo=timezone.utc)
803
+ age_hours = (now - created_dt).total_seconds() / 3600
804
+ if age_hours > 24:
805
+ try:
806
+ store.revoke(row["id"], session_id)
807
+ db_cleaned += 1
808
+ except Exception:
809
+ pass
810
+ except (ValueError, TypeError):
811
+ pass
736
812
  except Exception as exc:
737
- _print_error(f"Cleanup failed: {exc}", args)
738
- return 1
813
+ _print_error(f"DB cleanup failed: {exc}", args)
739
814
 
815
+ # Expire DB grant rows whose expires_at has passed.
816
+ db_grants_expired = 0
817
+ try:
818
+ from datetime import datetime, timezone
819
+ now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
820
+ writer = _import_writer()
821
+ pending_grants = writer.list_approval_grants(status="PENDING", limit=1000)
822
+ for row in pending_grants:
823
+ expires_at = row.get("expires_at")
824
+ if expires_at and expires_at < now_iso:
825
+ try:
826
+ writer.update_approval_grant_status(row["approval_id"], "EXPIRED")
827
+ db_grants_expired += 1
828
+ except Exception:
829
+ pass
830
+ except Exception:
831
+ pass
832
+
833
+ total = db_cleaned + db_grants_expired
740
834
  if getattr(args, "json", False):
741
- print(json.dumps({"status": "ok", "cleaned": cleaned}))
835
+ print(json.dumps({
836
+ "status": "ok",
837
+ "cleaned": total,
838
+ "db_cleaned": db_cleaned,
839
+ "db_grants_expired": db_grants_expired,
840
+ }))
742
841
  else:
743
- print(f"Cleaned {cleaned} expired/stale approval file(s).")
842
+ print(f"Cleaned {db_cleaned} expired DB pending(s) and {db_grants_expired} expired DB grant(s).")
744
843
  return 0
745
844
 
746
845
 
747
- def _count_stale_files(grants_dir: Path) -> int:
748
- """Count expired grant and pending files without deleting them."""
749
- count = 0
750
- now = time.time()
751
-
752
- for f in grants_dir.glob("grant-*.json"):
753
- try:
754
- data = json.loads(f.read_text())
755
- granted_at = float(data.get("granted_at", 0))
756
- ttl = int(data.get("ttl_minutes", 5))
757
- if ttl > 0 and (now - granted_at) / 60 > ttl:
758
- count += 1
759
- except Exception:
760
- count += 1
761
-
762
- for f in grants_dir.glob("pending-*.json"):
763
- if "index" in f.name:
764
- continue
765
- try:
766
- data = json.loads(f.read_text())
767
- if data.get("status") == "rejected":
768
- count += 1
769
- continue
770
- ts = float(data.get("timestamp", 0))
771
- ttl = int(data.get("ttl_minutes", 5))
772
- if ttl > 0 and (now - ts) / 60 > ttl:
773
- count += 1
774
- except Exception:
775
- count += 1
776
-
777
- return count
846
+ def _count_expired_db_grants() -> int:
847
+ """Count DB approval_grants rows with PENDING status whose expires_at has passed."""
848
+ try:
849
+ from datetime import datetime, timezone
850
+ now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
851
+ writer = _import_writer()
852
+ rows = writer.list_approval_grants(status="PENDING", limit=1000)
853
+ return sum(
854
+ 1 for r in rows
855
+ if r.get("expires_at") and r["expires_at"] < now_iso
856
+ )
857
+ except Exception:
858
+ return 0
778
859
 
779
860
 
780
861
  # ---------------------------------------------------------------------------
@@ -782,64 +863,61 @@ def _count_stale_files(grants_dir: Path) -> int:
782
863
  # ---------------------------------------------------------------------------
783
864
 
784
865
  def cmd_stats(args) -> int:
785
- """Show approval system statistics."""
866
+ """Show approval system statistics from the DB.
867
+
868
+ DB-only since FS retirement: all pending approvals and grants live in
869
+ gaia.db. Counts are derived from the approvals table (all statuses) and
870
+ the approval_grants table (active grants).
871
+ """
872
+ # DB counts.
873
+ db_pending = 0
874
+ db_approved = 0
875
+ db_rejected = 0
876
+ db_revoked = 0
877
+ verb_counts: dict = {}
786
878
  try:
787
- ag = _import_approval_grants()
788
- grants_dir = _import_grants_dir()
879
+ store = _import_approval_store()
880
+ all_rows = store.list_all(limit=1000)
881
+ for row in all_rows:
882
+ status = row.get("status", "")
883
+ if status == "pending":
884
+ db_pending += 1
885
+ # Extract verb from payload for breakdown.
886
+ payload_json = row.get("payload_json") or "{}"
887
+ try:
888
+ payload = json.loads(payload_json)
889
+ operation = payload.get("operation", "")
890
+ verb = "unknown"
891
+ if ": " in operation:
892
+ verb = operation.rsplit(": ", 1)[-1].strip()
893
+ verb_counts[verb] = verb_counts.get(verb, 0) + 1
894
+ except Exception:
895
+ pass
896
+ elif status == "approved":
897
+ db_approved += 1
898
+ elif status == "rejected":
899
+ db_rejected += 1
900
+ elif status == "revoked":
901
+ db_revoked += 1
789
902
  except Exception as exc:
790
- _print_error(f"Failed to access approval system: {exc}", args)
903
+ _print_error(f"Failed to query DB statistics: {exc}", args)
791
904
  return 1
792
905
 
793
- # Gather data
794
- all_sessions_pending = []
795
- active_grants = []
796
- rejected_count = 0
797
- expired_pending_count = 0
798
- now = time.time()
799
-
800
- if grants_dir.exists():
801
- for f in grants_dir.glob("pending-*.json"):
802
- if "index" in f.name:
803
- continue
804
- try:
805
- data = json.loads(f.read_text())
806
- if data.get("status") == "rejected":
807
- rejected_count += 1
808
- continue
809
- ts = float(data.get("timestamp", 0))
810
- ttl = int(data.get("ttl_minutes", 5))
811
- if ttl > 0 and (now - ts) / 60 > ttl:
812
- expired_pending_count += 1
813
- continue
814
- all_sessions_pending.append(data)
815
- except Exception:
816
- pass
817
-
818
- for f in grants_dir.glob("grant-*.json"):
819
- try:
820
- data = json.loads(f.read_text())
821
- granted_at = float(data.get("granted_at", 0))
822
- ttl = int(data.get("ttl_minutes", 5))
823
- if ttl == 0 or (now - granted_at) / 60 <= ttl:
824
- active_grants.append(data)
825
- except Exception:
826
- pass
827
-
828
- # Current session pending
829
- session_pending = ag["get_pending_approvals_for_session"]()
830
-
831
- # Verb breakdown
832
- verb_counts: dict = {}
833
- for p in all_sessions_pending:
834
- verb = p.get("danger_verb", "unknown")
835
- verb_counts[verb] = verb_counts.get(verb, 0) + 1
906
+ # Active DB grants.
907
+ db_active_grants = 0
908
+ try:
909
+ writer = _import_writer()
910
+ db_grants = writer.list_approval_grants(limit=500)
911
+ db_active_grants = len(db_grants)
912
+ except Exception:
913
+ pass
836
914
 
837
915
  stats = {
838
- "pending_current_session": len(session_pending),
839
- "pending_all_sessions": len(all_sessions_pending),
840
- "active_grants": len(active_grants),
841
- "rejected": rejected_count,
842
- "expired_pending": expired_pending_count,
916
+ "pending_all_sessions": db_pending,
917
+ "approved": db_approved,
918
+ "rejected": db_rejected,
919
+ "revoked": db_revoked,
920
+ "active_db_grants": db_active_grants,
843
921
  "verb_breakdown": verb_counts,
844
922
  }
845
923
 
@@ -849,13 +927,13 @@ def cmd_stats(args) -> int:
849
927
 
850
928
  print("Approval System Stats")
851
929
  print("---------------------")
852
- print(f" Pending (this session) : {stats['pending_current_session']}")
853
930
  print(f" Pending (all sessions) : {stats['pending_all_sessions']}")
854
- print(f" Active grants : {stats['active_grants']}")
855
- print(f" Rejected (pending) : {stats['rejected']}")
856
- print(f" Expired (pending) : {stats['expired_pending']}")
931
+ print(f" Approved : {stats['approved']}")
932
+ print(f" Rejected : {stats['rejected']}")
933
+ print(f" Revoked : {stats['revoked']}")
934
+ print(f" Active DB grants : {stats['active_db_grants']}")
857
935
  if verb_counts:
858
- print(" Verb breakdown:")
936
+ print(" Verb breakdown (pending):")
859
937
  for verb, cnt in sorted(verb_counts.items(), key=lambda x: -x[1]):
860
938
  print(f" {verb:<16} {cnt}")
861
939
  return 0
@@ -889,12 +967,6 @@ def _import_approval_display():
889
967
  return display
890
968
 
891
969
 
892
- def _import_approval_revert():
893
- """Import gaia.approvals.revert lazily."""
894
- from gaia.approvals import revert as revert_mod
895
- return revert_mod
896
-
897
-
898
970
  # ---------------------------------------------------------------------------
899
971
  # T3.1: gaia approvals pending -- shortcut for list --status=pending
900
972
  # ---------------------------------------------------------------------------
@@ -902,9 +974,17 @@ def _import_approval_revert():
902
974
  def cmd_pending(args) -> int:
903
975
  """Show pending approvals from the new approvals table.
904
976
 
905
- With ``--all-sessions``, returns pending approvals from all sessions
906
- (cross-session recovery, D9). Without it, returns pending for the
907
- current session when SESSION_ID env var is set, or all sessions.
977
+ With no arguments, returns all pending approvals from all sessions on this
978
+ machine (the DB is per-machine, so all-sessions is the correct default
979
+ scope). This avoids the Bug B / P-a11d14e0 silent-drop: inside a
980
+ subagent ``$CLAUDE_SESSION_ID`` is the subagent's own session id, not the
981
+ orchestrator session id stored on the approval row, so an exact-match
982
+ filter would silently return nothing.
983
+
984
+ With ``--session SESSION_ID``, filters to that explicit session id only
985
+ (useful when the caller holds a known-good orchestrator session id).
986
+ With ``--all-sessions``, same as the default (kept for backwards
987
+ compatibility with callers that pass the flag explicitly).
908
988
 
909
989
  Exits 0 on success, 1 on error.
910
990
  """
@@ -912,10 +992,12 @@ def cmd_pending(args) -> int:
912
992
  session_id = getattr(args, "session", None)
913
993
  output_json = getattr(args, "json", False)
914
994
 
915
- # If no session_id provided, default to all_sessions behavior.
916
- if session_id is None and not all_sessions:
917
- # Try to detect session from environment (mirrors hook behavior).
918
- session_id = os.environ.get("CLAUDE_SESSION_ID") or None
995
+ # No auto-derivation from $CLAUDE_SESSION_ID. Inside a subagent that env
996
+ # var holds the subagent's own session id, which does NOT match the
997
+ # orchestrator session_id stored on approval rows -- exact-match filtering
998
+ # would silently drop all pending rows. When no explicit --session is
999
+ # supplied, pass session_id=None so get_pending() uses the all-sessions
1000
+ # query (``WHERE status='pending'`` with no session filter).
919
1001
 
920
1002
  try:
921
1003
  store = _import_approval_store()
@@ -1209,199 +1291,6 @@ def cmd_history(args) -> int:
1209
1291
  return 0
1210
1292
 
1211
1293
 
1212
- # ---------------------------------------------------------------------------
1213
- # T3.5: gaia approvals revert <id> -- interactive inverse-command UX (D14)
1214
- # ---------------------------------------------------------------------------
1215
-
1216
- def cmd_revert(args) -> int:
1217
- """Revert an approval by executing inverse commands for its EXECUTED events.
1218
-
1219
- Per D14:
1220
- - Interactive by default: shows numbered list of candidate inverse commands,
1221
- user selects by number, comma-separated, 'all', or 'none'.
1222
- - ``--yes`` suppresses per-event confirmation.
1223
- - ``--file ids.txt`` reads event_ids from a file (one per line) for batch mode.
1224
- - ``--dry-run`` shows the inverse commands without executing them.
1225
-
1226
- Exits 0 on success or when no reversible events exist.
1227
- Exits 1 on error.
1228
- """
1229
- raw_id = _resolve_approval_id(args.approval_id)
1230
- skip_confirm = getattr(args, "yes", False)
1231
- dry_run = getattr(args, "dry_run", False)
1232
- batch_file = getattr(args, "file", None)
1233
- output_json = getattr(args, "json", False)
1234
-
1235
- # Resolve the approval and its EXECUTED events.
1236
- try:
1237
- store = _import_approval_store()
1238
- approval = store.get_by_id(raw_id)
1239
- if approval is None:
1240
- _print_error(f"No approval found for id: {raw_id}", args)
1241
- return 1
1242
- except Exception as exc:
1243
- _print_error(f"Failed to look up approval: {exc}", args)
1244
- return 1
1245
-
1246
- # Derive inverse commands.
1247
- try:
1248
- revert_mod = _import_approval_revert()
1249
- store = _import_approval_store()
1250
- con = store._open_db()
1251
- try:
1252
- inverses = revert_mod.derive_inverses_for_approval(raw_id, con)
1253
- finally:
1254
- con.close()
1255
- except Exception as exc:
1256
- _print_error(f"Failed to derive inverse commands: {exc}", args)
1257
- return 1
1258
-
1259
- if not inverses:
1260
- print(f"No EXECUTED events found for approval {raw_id}. Nothing to revert.")
1261
- return 0
1262
-
1263
- # Display the candidate inverse commands.
1264
- print(f"\nCandidate inverse commands for approval {raw_id}:")
1265
- print("-" * 60)
1266
- for i, ic in enumerate(inverses):
1267
- reversible_marker = "" if ic.reversible else " [NOT REVERSIBLE]"
1268
- inverse_display = ic.inverse_command if ic.inverse_command else "N/A"
1269
- print(f" [{i}] Original : {ic.original_command}")
1270
- print(f" Inverse : {inverse_display}{reversible_marker}")
1271
- print(f" Notes : {ic.notes}")
1272
- print()
1273
-
1274
- # Filter to only reversible ones.
1275
- reversible = [ic for ic in inverses if ic.reversible and ic.inverse_command]
1276
- if not reversible:
1277
- print("None of the events have derivable inverse commands.")
1278
- return 0
1279
-
1280
- if dry_run:
1281
- print("[dry-run] Would execute the following inverse commands:")
1282
- for ic in reversible:
1283
- print(f" {ic.inverse_command}")
1284
- return 0
1285
-
1286
- # Batch file mode: read event_ids to revert.
1287
- selected = reversible
1288
- if batch_file:
1289
- try:
1290
- with open(batch_file) as fh:
1291
- event_ids_str = {line.strip() for line in fh if line.strip()}
1292
- except OSError as exc:
1293
- _print_error(f"Cannot read batch file {batch_file!r}: {exc}", args)
1294
- return 1
1295
- event_ids = set()
1296
- for eid in event_ids_str:
1297
- try:
1298
- event_ids.add(int(eid))
1299
- except ValueError:
1300
- pass
1301
- selected = [ic for ic in reversible if ic.event_id in event_ids]
1302
- if not selected:
1303
- print(f"No matching reversible events found in batch file.")
1304
- return 0
1305
- elif not skip_confirm:
1306
- # Interactive selection.
1307
- print("Select events to revert (comma-separated numbers, 'all', or 'none'):")
1308
- try:
1309
- choice = input("> ").strip().lower()
1310
- except EOFError:
1311
- choice = "none"
1312
-
1313
- if choice == "none" or choice == "":
1314
- print("Revert cancelled.")
1315
- return 0
1316
- elif choice == "all":
1317
- selected = reversible
1318
- else:
1319
- try:
1320
- indices = [int(x.strip()) for x in choice.split(",") if x.strip()]
1321
- selected = [reversible[i] for i in indices if 0 <= i < len(reversible)]
1322
- except (ValueError, IndexError):
1323
- _print_error("Invalid selection. Use numbers, 'all', or 'none'.", args)
1324
- return 1
1325
-
1326
- if not selected:
1327
- print("No events selected. Revert cancelled.")
1328
- return 0
1329
-
1330
- # Final confirmation.
1331
- if not skip_confirm:
1332
- print("\nWill execute:")
1333
- for ic in selected:
1334
- print(f" {ic.inverse_command}")
1335
- try:
1336
- confirm = input("\nProceed? [y/N] ").strip().lower()
1337
- except EOFError:
1338
- confirm = "n"
1339
- if confirm not in ("y", "yes"):
1340
- print("Revert cancelled.")
1341
- return 0
1342
-
1343
- # Execute inverse commands.
1344
- import subprocess
1345
- results = []
1346
- session_id = os.environ.get("CLAUDE_SESSION_ID") or "cli-session"
1347
- all_ok = True
1348
-
1349
- for ic in selected:
1350
- print(f"Executing: {ic.inverse_command}")
1351
- try:
1352
- proc = subprocess.run(
1353
- ic.inverse_command,
1354
- shell=True,
1355
- capture_output=True,
1356
- text=True,
1357
- )
1358
- ok = proc.returncode == 0
1359
- results.append({
1360
- "event_id": ic.event_id,
1361
- "command": ic.inverse_command,
1362
- "exit_code": proc.returncode,
1363
- "stdout": proc.stdout.strip(),
1364
- "stderr": proc.stderr.strip(),
1365
- })
1366
- if ok:
1367
- print(f" OK (exit 0)")
1368
- # Record REVERTED event in the chain.
1369
- try:
1370
- store = _import_approval_store()
1371
- import json as _json
1372
- metadata = _json.dumps({
1373
- "original_event_id": ic.event_id,
1374
- "inverse_command": ic.inverse_command,
1375
- })
1376
- store.record_event(
1377
- raw_id,
1378
- "REVERTED",
1379
- session_id=session_id,
1380
- metadata_json=metadata,
1381
- )
1382
- except Exception:
1383
- pass # Chain write failure is non-fatal for the revert operation.
1384
- else:
1385
- print(f" FAILED (exit {proc.returncode})")
1386
- if proc.stderr:
1387
- print(f" stderr: {proc.stderr.strip()}")
1388
- all_ok = False
1389
- except Exception as exc:
1390
- print(f" ERROR: {exc}")
1391
- results.append({
1392
- "event_id": ic.event_id,
1393
- "command": ic.inverse_command,
1394
- "exit_code": -1,
1395
- "error": str(exc),
1396
- })
1397
- all_ok = False
1398
-
1399
- if output_json:
1400
- print(json.dumps({"results": results, "all_ok": all_ok}))
1401
-
1402
- return 0 if all_ok else 1
1403
-
1404
-
1405
1294
  # ---------------------------------------------------------------------------
1406
1295
  # T3.5: gaia approvals replay <id> [--dry-run]
1407
1296
  # ---------------------------------------------------------------------------
@@ -1522,6 +1411,114 @@ def cmd_replay(args) -> int:
1522
1411
  return 0 if all_ok else 1
1523
1412
 
1524
1413
 
1414
+ # ---------------------------------------------------------------------------
1415
+ # derive-id -- reproduce a plan-first COMMAND_SET approval_id from its commands
1416
+ # ---------------------------------------------------------------------------
1417
+
1418
+ def _read_command_set_input(args) -> list:
1419
+ """Resolve the command list for derive-id from args/stdin.
1420
+
1421
+ Accepts, in order of precedence:
1422
+ 1. ``--commands-json '[{"command": "..."}, ...]'`` or a bare list of
1423
+ strings ``["cmd a", "cmd b"]`` -- the command_set as the orchestrator
1424
+ reads it from the contract.
1425
+ 2. stdin (when ``--commands-json`` is omitted), same JSON shapes.
1426
+
1427
+ Returns the ordered list of command STRINGS (rationale is irrelevant to the
1428
+ derivation). Raises ValueError on malformed input.
1429
+ """
1430
+ raw = getattr(args, "commands_json", None)
1431
+ if raw is None:
1432
+ raw = sys.stdin.read()
1433
+ raw = (raw or "").strip()
1434
+ if not raw:
1435
+ raise ValueError("no command_set provided (use --commands-json or stdin)")
1436
+
1437
+ parsed = json.loads(raw)
1438
+
1439
+ # Accept either a top-level list, or {"command_set": [...]} / {"commands": [...]}.
1440
+ if isinstance(parsed, dict):
1441
+ parsed = parsed.get("command_set") or parsed.get("commands") or []
1442
+
1443
+ if not isinstance(parsed, list):
1444
+ raise ValueError("command_set must be a JSON array")
1445
+
1446
+ commands: list = []
1447
+ for item in parsed:
1448
+ if isinstance(item, str):
1449
+ if item:
1450
+ commands.append(item)
1451
+ elif isinstance(item, dict) and item.get("command"):
1452
+ commands.append(item["command"])
1453
+ return commands
1454
+
1455
+
1456
+ def cmd_derive_id(args) -> int:
1457
+ """Derive the deterministic COMMAND_SET approval_id from its commands.
1458
+
1459
+ This is the orchestrator-side mirror of the intake's mint: given the
1460
+ ``command_set`` the subagent emitted in its contract (no DB search), it
1461
+ reproduces the EXACT ``P-...`` id the SubagentStop intake wrote as the
1462
+ pending row, by applying the SAME mutative filter and the SAME
1463
+ ``derive_command_set_id`` canonicalization the intake uses.
1464
+
1465
+ The mutative filter is shared with the intake
1466
+ (``handoff_persister._filter_mutative_command_set``) so the CLI and the hook
1467
+ operate on the identical post-filter command list. When fewer than 2
1468
+ mutative commands remain, NO COMMAND_SET was minted (the singular path owns
1469
+ it) -- the helper reports that rather than emitting a bogus id.
1470
+
1471
+ Exits 0 on success, 1 on error.
1472
+ """
1473
+ output_json = getattr(args, "json", False)
1474
+ apply_filter = not getattr(args, "no_filter", False)
1475
+
1476
+ try:
1477
+ commands = _read_command_set_input(args)
1478
+ except Exception as exc:
1479
+ _print_error(f"Failed to parse command_set: {exc}", args)
1480
+ return 1
1481
+
1482
+ # Apply the SAME mutative filter the intake uses, so the orchestrator's
1483
+ # derivation operates on the identical post-filter list. Skippable via
1484
+ # --no-filter for callers that already hold the filtered list.
1485
+ if apply_filter:
1486
+ try:
1487
+ from modules.agents.handoff_persister import _filter_mutative_command_set
1488
+ filtered = _filter_mutative_command_set(
1489
+ [{"command": c, "rationale": ""} for c in commands]
1490
+ )
1491
+ commands = [it["command"] for it in filtered]
1492
+ except Exception as exc:
1493
+ _print_error(f"Failed to apply mutative filter: {exc}", args)
1494
+ return 1
1495
+
1496
+ if len(commands) < 2:
1497
+ msg = (
1498
+ f"Not a COMMAND_SET: {len(commands)} mutative command(s) after filter "
1499
+ "(need >= 2). No COMMAND_SET approval was minted -- the singular path "
1500
+ "owns this."
1501
+ )
1502
+ if output_json:
1503
+ print(json.dumps({"approval_id": None, "command_count": len(commands), "reason": msg}))
1504
+ else:
1505
+ _print_error(msg, args)
1506
+ return 1
1507
+
1508
+ try:
1509
+ store = _import_approval_store()
1510
+ approval_id = store.derive_command_set_id(commands)
1511
+ except Exception as exc:
1512
+ _print_error(f"Failed to derive id: {exc}", args)
1513
+ return 1
1514
+
1515
+ if output_json:
1516
+ print(json.dumps({"approval_id": approval_id, "command_count": len(commands)}))
1517
+ else:
1518
+ print(approval_id)
1519
+ return 0
1520
+
1521
+
1525
1522
  # ---------------------------------------------------------------------------
1526
1523
  # Plugin registration (called by bin/gaia dispatcher)
1527
1524
  # ---------------------------------------------------------------------------
@@ -1531,7 +1528,7 @@ def register(subparsers) -> None:
1531
1528
  p = subparsers.add_parser(
1532
1529
  "approvals",
1533
1530
  help="Manage T3 pending approvals",
1534
- description="View, approve, reject, revert, and replay Gaia approval requests.",
1531
+ description="View, approve, reject, and replay Gaia approval requests.",
1535
1532
  )
1536
1533
  sub = p.add_subparsers(dest="approvals_cmd", metavar="SUBCOMMAND")
1537
1534
  sub.required = True
@@ -1554,17 +1551,32 @@ def register(subparsers) -> None:
1554
1551
  help="List pending approvals from the new approvals table",
1555
1552
  description=(
1556
1553
  "Show pending T3 approvals from the DB-backed approvals table.\n\n"
1557
- "Use --all-sessions to see pending approvals from all sessions\n"
1558
- "(cross-session recovery -- local machine only)."
1554
+ "Default (no flags): returns ALL pending approvals on this machine\n"
1555
+ "across every session. The DB is per-machine so all-sessions is the\n"
1556
+ "correct default scope. This avoids a silent-drop that occurred when\n"
1557
+ "the command ran inside a subagent (whose $CLAUDE_SESSION_ID differs\n"
1558
+ "from the orchestrator session_id stored on the approval row).\n\n"
1559
+ "Use --session SESSION_ID to filter to one specific session when you\n"
1560
+ "hold a known-good orchestrator session id.\n\n"
1561
+ "--all-sessions is accepted for backwards compatibility but is\n"
1562
+ "equivalent to the default behaviour."
1559
1563
  ),
1560
1564
  )
1561
1565
  p_pending.add_argument("--json", action="store_true", help="JSON output")
1562
- p_pending.add_argument("--session", metavar="SESSION_ID", help="Filter by session ID")
1566
+ p_pending.add_argument(
1567
+ "--session",
1568
+ metavar="SESSION_ID",
1569
+ help=(
1570
+ "Filter to this exact session id. Pass an orchestrator session id;\n"
1571
+ "do NOT rely on $CLAUDE_SESSION_ID inside a subagent -- it holds the\n"
1572
+ "subagent's own id, not the orchestrator's."
1573
+ ),
1574
+ )
1563
1575
  p_pending.add_argument(
1564
1576
  "--all-sessions",
1565
1577
  action="store_true",
1566
1578
  dest="all_sessions",
1567
- help="Show pending from all sessions (cross-session recovery)",
1579
+ help="Show pending from all sessions (default; kept for backwards compatibility)",
1568
1580
  )
1569
1581
  p_pending.set_defaults(func=cmd_pending)
1570
1582
 
@@ -1652,33 +1664,6 @@ def register(subparsers) -> None:
1652
1664
  p_history.add_argument("--json", action="store_true", help="JSON output")
1653
1665
  p_history.set_defaults(func=cmd_history)
1654
1666
 
1655
- # revert (T3.5) -- interactive inverse-command UX
1656
- p_revert = sub.add_parser(
1657
- "revert",
1658
- help="Revert an approval by executing inverse commands (interactive)",
1659
- description=(
1660
- "Per D14: interactive inverse-command UX for reverting executed approvals.\n\n"
1661
- "Shows candidate inverse commands, prompts for selection, then executes\n"
1662
- "the selected inverses sequentially. Uses --yes to skip per-event prompts.\n"
1663
- "Use --dry-run to preview without executing."
1664
- ),
1665
- )
1666
- p_revert.add_argument(
1667
- "approval_id",
1668
- metavar="APPROVAL_ID",
1669
- help="P-{uuid4hex} of the approval to revert",
1670
- )
1671
- p_revert.add_argument("--yes", action="store_true", help="Skip confirmation prompts")
1672
- p_revert.add_argument("--dry-run", action="store_true", dest="dry_run", help="Preview only")
1673
- p_revert.add_argument(
1674
- "--file",
1675
- metavar="PATH",
1676
- default=None,
1677
- help="File of event_ids to revert (one per line) for batch mode",
1678
- )
1679
- p_revert.add_argument("--json", action="store_true", help="JSON output for results")
1680
- p_revert.set_defaults(func=cmd_revert)
1681
-
1682
1667
  # replay (T3.5) -- re-run commands from an executed approval
1683
1668
  p_replay = sub.add_parser(
1684
1669
  "replay",
@@ -1763,6 +1748,35 @@ def register(subparsers) -> None:
1763
1748
  p_stats.add_argument("--json", action="store_true", help="JSON output")
1764
1749
  p_stats.set_defaults(func=cmd_stats)
1765
1750
 
1751
+ # derive-id -- reproduce a plan-first COMMAND_SET id from its commands
1752
+ p_derive = sub.add_parser(
1753
+ "derive-id",
1754
+ help="Derive the deterministic COMMAND_SET approval_id from its commands",
1755
+ description=(
1756
+ "Reproduce the content-derived approval_id the SubagentStop intake\n"
1757
+ "minted for a plan-first COMMAND_SET, from the command_set in the\n"
1758
+ "contract -- no DB search. Pass the command_set as JSON via\n"
1759
+ "--commands-json or stdin (a list of strings, a list of\n"
1760
+ "{command, rationale} objects, or an object with a command_set/\n"
1761
+ "commands key). Applies the same mutative filter the intake uses."
1762
+ ),
1763
+ )
1764
+ p_derive.add_argument(
1765
+ "--commands-json",
1766
+ dest="commands_json",
1767
+ metavar="JSON",
1768
+ default=None,
1769
+ help="command_set as JSON (omit to read from stdin)",
1770
+ )
1771
+ p_derive.add_argument(
1772
+ "--no-filter",
1773
+ action="store_true",
1774
+ dest="no_filter",
1775
+ help="Skip the mutative filter (input is already the filtered list)",
1776
+ )
1777
+ p_derive.add_argument("--json", action="store_true", help="JSON output")
1778
+ p_derive.set_defaults(func=cmd_derive_id)
1779
+
1766
1780
  p.set_defaults(func=_approvals_default)
1767
1781
 
1768
1782
 
@@ -1788,13 +1802,13 @@ def _approvals_default(args) -> int:
1788
1802
  print(" approve APPROVAL_ID -- cross-session approve")
1789
1803
  print(" revoke APPROVAL_ID -- revoke a pending approval")
1790
1804
  print(" history [APPROVAL_ID] [--limit N] -- temporal history or per-approval chain")
1791
- print(" revert APPROVAL_ID [--dry-run] -- interactive inverse-command revert")
1792
1805
  print(" replay APPROVAL_ID [--dry-run] -- replay an executed approval")
1793
1806
  print(" list [--session S] [--orphans-only] -- list (legacy + DB grants)")
1794
1807
  print(" reject NONCE [--all] -- reject pending (legacy)")
1795
1808
  print(" reject-all [--dry-run] -- bulk reject (legacy)")
1796
1809
  print(" clean [--dry-run] -- remove expired approvals")
1797
1810
  print(" stats -- approval system statistics")
1811
+ print(" derive-id --commands-json JSON -- reproduce a COMMAND_SET id (no DB)")
1798
1812
  print("")
1799
1813
  print("Run 'gaia approvals --help' for more information.")
1800
1814
  return 0
@@ -1850,14 +1864,6 @@ def _build_standalone_parser() -> argparse.ArgumentParser:
1850
1864
  p_history.add_argument("--json", action="store_true")
1851
1865
  p_history.set_defaults(func=cmd_history)
1852
1866
 
1853
- p_revert = subparsers.add_parser("revert", help="Revert an approval (interactive)")
1854
- p_revert.add_argument("approval_id", metavar="APPROVAL_ID")
1855
- p_revert.add_argument("--yes", action="store_true")
1856
- p_revert.add_argument("--dry-run", action="store_true", dest="dry_run")
1857
- p_revert.add_argument("--file", metavar="PATH", default=None)
1858
- p_revert.add_argument("--json", action="store_true")
1859
- p_revert.set_defaults(func=cmd_revert)
1860
-
1861
1867
  p_replay = subparsers.add_parser("replay", help="Replay an executed approval")
1862
1868
  p_replay.add_argument("approval_id", metavar="APPROVAL_ID")
1863
1869
  p_replay.add_argument("--dry-run", action="store_true", dest="dry_run")
@@ -1886,6 +1892,12 @@ def _build_standalone_parser() -> argparse.ArgumentParser:
1886
1892
  p_stats.add_argument("--json", action="store_true")
1887
1893
  p_stats.set_defaults(func=cmd_stats)
1888
1894
 
1895
+ p_derive = subparsers.add_parser("derive-id", help="Derive a COMMAND_SET approval_id from its commands")
1896
+ p_derive.add_argument("--commands-json", dest="commands_json", metavar="JSON", default=None)
1897
+ p_derive.add_argument("--no-filter", action="store_true", dest="no_filter")
1898
+ p_derive.add_argument("--json", action="store_true")
1899
+ p_derive.set_defaults(func=cmd_derive_id)
1900
+
1889
1901
  return parser
1890
1902
 
1891
1903