@jaguilar87/gaia 5.0.6 → 5.0.8

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 (41) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +12 -0
  4. package/bin/cli/_install_helpers.py +1 -1
  5. package/bin/cli/approvals.py +145 -236
  6. package/bin/cli/doctor.py +19 -17
  7. package/bin/validate-sandbox.sh +8 -3
  8. package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
  9. package/dist/gaia-ops/hooks/adapters/claude_code.py +73 -1
  10. package/dist/gaia-ops/hooks/modules/agents/handoff_persister.py +13 -2
  11. package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +19 -0
  12. package/dist/gaia-ops/skills/agent-approval-protocol/SKILL.md +28 -12
  13. package/dist/gaia-ops/skills/agent-approval-protocol/reference.md +5 -3
  14. package/dist/gaia-ops/skills/agent-protocol/examples.md +12 -1
  15. package/dist/gaia-ops/skills/gaia-patterns/SKILL.md +2 -6
  16. package/dist/gaia-ops/skills/gaia-patterns/reference.md +2 -14
  17. package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +8 -2
  18. package/dist/gaia-ops/skills/orchestrator-present-approval/template.md +11 -10
  19. package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +11 -0
  20. package/dist/gaia-ops/skills/subagent-request-approval/reference.md +21 -3
  21. package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
  22. package/dist/gaia-security/hooks/adapters/claude_code.py +73 -1
  23. package/dist/gaia-security/hooks/modules/agents/handoff_persister.py +13 -2
  24. package/dist/gaia-security/hooks/modules/tools/bash_validator.py +19 -0
  25. package/gaia/approvals/__init__.py +2 -1
  26. package/gaia/approvals/store.py +78 -6
  27. package/hooks/adapters/claude_code.py +73 -1
  28. package/hooks/modules/agents/handoff_persister.py +13 -2
  29. package/hooks/modules/tools/bash_validator.py +19 -0
  30. package/package.json +1 -1
  31. package/pyproject.toml +1 -1
  32. package/skills/agent-approval-protocol/SKILL.md +28 -12
  33. package/skills/agent-approval-protocol/reference.md +5 -3
  34. package/skills/agent-protocol/examples.md +12 -1
  35. package/skills/gaia-patterns/SKILL.md +2 -6
  36. package/skills/gaia-patterns/reference.md +2 -14
  37. package/skills/orchestrator-present-approval/SKILL.md +8 -2
  38. package/skills/orchestrator-present-approval/template.md +11 -10
  39. package/skills/subagent-request-approval/SKILL.md +11 -0
  40. package/skills/subagent-request-approval/reference.md +21 -3
  41. package/gaia/approvals/revert.py +0 -282
@@ -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.6",
11
+ "version": "5.0.8",
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.6",
23
+ "version": "5.0.8",
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.6",
3
+ "version": "5.0.8",
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,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [5.0.8] - 2026-06-24
11
+
12
+ ## [5.0.7] - 2026-06-12
13
+
14
+ ### Fixed
15
+
16
+ - `gaia doctor` now passes (rc=0) on a clean install: the `commands` symlink (a removed surface) was dropped from the symlink checks, and `memory_fts5_count` now reads the canonical gaia.db store instead of the legacy search.db — eliminating the two warnings that made a fresh install report "degraded" in 5.0.5/5.0.6.
17
+
18
+ ### Changed
19
+
20
+ - Release pipeline: the sandbox validation harness is now a PRE-publish gate (runs against the packed tarball before `npm publish`), and the harness fails if `gaia doctor` returns rc>=1 — so a degraded build can no longer be published.
21
+
10
22
  ## [5.0.6] - 2026-06-12
11
23
 
12
24
  ### Fixed
@@ -345,7 +345,7 @@ def merge_local_hooks(
345
345
  # ---------------------------------------------------------------------------
346
346
 
347
347
  # Directories the package exposes via .claude/<name> symlinks
348
- _SYMLINK_NAMES = ["agents", "tools", "hooks", "commands", "config", "skills"]
348
+ _SYMLINK_NAMES = ["agents", "tools", "hooks", "config", "skills"]
349
349
  # Files (not dirs) we link or copy into .claude/
350
350
  _SYMLINK_FILES = ["CHANGELOG.md"]
351
351
 
@@ -889,12 +889,6 @@ def _import_approval_display():
889
889
  return display
890
890
 
891
891
 
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
892
  # ---------------------------------------------------------------------------
899
893
  # T3.1: gaia approvals pending -- shortcut for list --status=pending
900
894
  # ---------------------------------------------------------------------------
@@ -1209,199 +1203,6 @@ def cmd_history(args) -> int:
1209
1203
  return 0
1210
1204
 
1211
1205
 
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
1206
  # ---------------------------------------------------------------------------
1406
1207
  # T3.5: gaia approvals replay <id> [--dry-run]
1407
1208
  # ---------------------------------------------------------------------------
@@ -1522,6 +1323,114 @@ def cmd_replay(args) -> int:
1522
1323
  return 0 if all_ok else 1
1523
1324
 
1524
1325
 
1326
+ # ---------------------------------------------------------------------------
1327
+ # derive-id -- reproduce a plan-first COMMAND_SET approval_id from its commands
1328
+ # ---------------------------------------------------------------------------
1329
+
1330
+ def _read_command_set_input(args) -> list:
1331
+ """Resolve the command list for derive-id from args/stdin.
1332
+
1333
+ Accepts, in order of precedence:
1334
+ 1. ``--commands-json '[{"command": "..."}, ...]'`` or a bare list of
1335
+ strings ``["cmd a", "cmd b"]`` -- the command_set as the orchestrator
1336
+ reads it from the contract.
1337
+ 2. stdin (when ``--commands-json`` is omitted), same JSON shapes.
1338
+
1339
+ Returns the ordered list of command STRINGS (rationale is irrelevant to the
1340
+ derivation). Raises ValueError on malformed input.
1341
+ """
1342
+ raw = getattr(args, "commands_json", None)
1343
+ if raw is None:
1344
+ raw = sys.stdin.read()
1345
+ raw = (raw or "").strip()
1346
+ if not raw:
1347
+ raise ValueError("no command_set provided (use --commands-json or stdin)")
1348
+
1349
+ parsed = json.loads(raw)
1350
+
1351
+ # Accept either a top-level list, or {"command_set": [...]} / {"commands": [...]}.
1352
+ if isinstance(parsed, dict):
1353
+ parsed = parsed.get("command_set") or parsed.get("commands") or []
1354
+
1355
+ if not isinstance(parsed, list):
1356
+ raise ValueError("command_set must be a JSON array")
1357
+
1358
+ commands: list = []
1359
+ for item in parsed:
1360
+ if isinstance(item, str):
1361
+ if item:
1362
+ commands.append(item)
1363
+ elif isinstance(item, dict) and item.get("command"):
1364
+ commands.append(item["command"])
1365
+ return commands
1366
+
1367
+
1368
+ def cmd_derive_id(args) -> int:
1369
+ """Derive the deterministic COMMAND_SET approval_id from its commands.
1370
+
1371
+ This is the orchestrator-side mirror of the intake's mint: given the
1372
+ ``command_set`` the subagent emitted in its contract (no DB search), it
1373
+ reproduces the EXACT ``P-...`` id the SubagentStop intake wrote as the
1374
+ pending row, by applying the SAME mutative filter and the SAME
1375
+ ``derive_command_set_id`` canonicalization the intake uses.
1376
+
1377
+ The mutative filter is shared with the intake
1378
+ (``handoff_persister._filter_mutative_command_set``) so the CLI and the hook
1379
+ operate on the identical post-filter command list. When fewer than 2
1380
+ mutative commands remain, NO COMMAND_SET was minted (the singular path owns
1381
+ it) -- the helper reports that rather than emitting a bogus id.
1382
+
1383
+ Exits 0 on success, 1 on error.
1384
+ """
1385
+ output_json = getattr(args, "json", False)
1386
+ apply_filter = not getattr(args, "no_filter", False)
1387
+
1388
+ try:
1389
+ commands = _read_command_set_input(args)
1390
+ except Exception as exc:
1391
+ _print_error(f"Failed to parse command_set: {exc}", args)
1392
+ return 1
1393
+
1394
+ # Apply the SAME mutative filter the intake uses, so the orchestrator's
1395
+ # derivation operates on the identical post-filter list. Skippable via
1396
+ # --no-filter for callers that already hold the filtered list.
1397
+ if apply_filter:
1398
+ try:
1399
+ from modules.agents.handoff_persister import _filter_mutative_command_set
1400
+ filtered = _filter_mutative_command_set(
1401
+ [{"command": c, "rationale": ""} for c in commands]
1402
+ )
1403
+ commands = [it["command"] for it in filtered]
1404
+ except Exception as exc:
1405
+ _print_error(f"Failed to apply mutative filter: {exc}", args)
1406
+ return 1
1407
+
1408
+ if len(commands) < 2:
1409
+ msg = (
1410
+ f"Not a COMMAND_SET: {len(commands)} mutative command(s) after filter "
1411
+ "(need >= 2). No COMMAND_SET approval was minted -- the singular path "
1412
+ "owns this."
1413
+ )
1414
+ if output_json:
1415
+ print(json.dumps({"approval_id": None, "command_count": len(commands), "reason": msg}))
1416
+ else:
1417
+ _print_error(msg, args)
1418
+ return 1
1419
+
1420
+ try:
1421
+ store = _import_approval_store()
1422
+ approval_id = store.derive_command_set_id(commands)
1423
+ except Exception as exc:
1424
+ _print_error(f"Failed to derive id: {exc}", args)
1425
+ return 1
1426
+
1427
+ if output_json:
1428
+ print(json.dumps({"approval_id": approval_id, "command_count": len(commands)}))
1429
+ else:
1430
+ print(approval_id)
1431
+ return 0
1432
+
1433
+
1525
1434
  # ---------------------------------------------------------------------------
1526
1435
  # Plugin registration (called by bin/gaia dispatcher)
1527
1436
  # ---------------------------------------------------------------------------
@@ -1531,7 +1440,7 @@ def register(subparsers) -> None:
1531
1440
  p = subparsers.add_parser(
1532
1441
  "approvals",
1533
1442
  help="Manage T3 pending approvals",
1534
- description="View, approve, reject, revert, and replay Gaia approval requests.",
1443
+ description="View, approve, reject, and replay Gaia approval requests.",
1535
1444
  )
1536
1445
  sub = p.add_subparsers(dest="approvals_cmd", metavar="SUBCOMMAND")
1537
1446
  sub.required = True
@@ -1652,33 +1561,6 @@ def register(subparsers) -> None:
1652
1561
  p_history.add_argument("--json", action="store_true", help="JSON output")
1653
1562
  p_history.set_defaults(func=cmd_history)
1654
1563
 
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
1564
  # replay (T3.5) -- re-run commands from an executed approval
1683
1565
  p_replay = sub.add_parser(
1684
1566
  "replay",
@@ -1763,6 +1645,35 @@ def register(subparsers) -> None:
1763
1645
  p_stats.add_argument("--json", action="store_true", help="JSON output")
1764
1646
  p_stats.set_defaults(func=cmd_stats)
1765
1647
 
1648
+ # derive-id -- reproduce a plan-first COMMAND_SET id from its commands
1649
+ p_derive = sub.add_parser(
1650
+ "derive-id",
1651
+ help="Derive the deterministic COMMAND_SET approval_id from its commands",
1652
+ description=(
1653
+ "Reproduce the content-derived approval_id the SubagentStop intake\n"
1654
+ "minted for a plan-first COMMAND_SET, from the command_set in the\n"
1655
+ "contract -- no DB search. Pass the command_set as JSON via\n"
1656
+ "--commands-json or stdin (a list of strings, a list of\n"
1657
+ "{command, rationale} objects, or an object with a command_set/\n"
1658
+ "commands key). Applies the same mutative filter the intake uses."
1659
+ ),
1660
+ )
1661
+ p_derive.add_argument(
1662
+ "--commands-json",
1663
+ dest="commands_json",
1664
+ metavar="JSON",
1665
+ default=None,
1666
+ help="command_set as JSON (omit to read from stdin)",
1667
+ )
1668
+ p_derive.add_argument(
1669
+ "--no-filter",
1670
+ action="store_true",
1671
+ dest="no_filter",
1672
+ help="Skip the mutative filter (input is already the filtered list)",
1673
+ )
1674
+ p_derive.add_argument("--json", action="store_true", help="JSON output")
1675
+ p_derive.set_defaults(func=cmd_derive_id)
1676
+
1766
1677
  p.set_defaults(func=_approvals_default)
1767
1678
 
1768
1679
 
@@ -1788,13 +1699,13 @@ def _approvals_default(args) -> int:
1788
1699
  print(" approve APPROVAL_ID -- cross-session approve")
1789
1700
  print(" revoke APPROVAL_ID -- revoke a pending approval")
1790
1701
  print(" history [APPROVAL_ID] [--limit N] -- temporal history or per-approval chain")
1791
- print(" revert APPROVAL_ID [--dry-run] -- interactive inverse-command revert")
1792
1702
  print(" replay APPROVAL_ID [--dry-run] -- replay an executed approval")
1793
1703
  print(" list [--session S] [--orphans-only] -- list (legacy + DB grants)")
1794
1704
  print(" reject NONCE [--all] -- reject pending (legacy)")
1795
1705
  print(" reject-all [--dry-run] -- bulk reject (legacy)")
1796
1706
  print(" clean [--dry-run] -- remove expired approvals")
1797
1707
  print(" stats -- approval system statistics")
1708
+ print(" derive-id --commands-json JSON -- reproduce a COMMAND_SET id (no DB)")
1798
1709
  print("")
1799
1710
  print("Run 'gaia approvals --help' for more information.")
1800
1711
  return 0
@@ -1850,14 +1761,6 @@ def _build_standalone_parser() -> argparse.ArgumentParser:
1850
1761
  p_history.add_argument("--json", action="store_true")
1851
1762
  p_history.set_defaults(func=cmd_history)
1852
1763
 
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
1764
  p_replay = subparsers.add_parser("replay", help="Replay an executed approval")
1862
1765
  p_replay.add_argument("approval_id", metavar="APPROVAL_ID")
1863
1766
  p_replay.add_argument("--dry-run", action="store_true", dest="dry_run")
@@ -1886,6 +1789,12 @@ def _build_standalone_parser() -> argparse.ArgumentParser:
1886
1789
  p_stats.add_argument("--json", action="store_true")
1887
1790
  p_stats.set_defaults(func=cmd_stats)
1888
1791
 
1792
+ p_derive = subparsers.add_parser("derive-id", help="Derive a COMMAND_SET approval_id from its commands")
1793
+ p_derive.add_argument("--commands-json", dest="commands_json", metavar="JSON", default=None)
1794
+ p_derive.add_argument("--no-filter", action="store_true", dest="no_filter")
1795
+ p_derive.add_argument("--json", action="store_true")
1796
+ p_derive.set_defaults(func=cmd_derive_id)
1797
+
1889
1798
  return parser
1890
1799
 
1891
1800
 
package/bin/cli/doctor.py CHANGED
@@ -762,7 +762,7 @@ def _extract_check_values(
762
762
  @register_check("Symlinks", order=50)
763
763
  def check_symlinks(project_root: Path) -> dict:
764
764
  """Check .claude/ symlinks resolve to package content."""
765
- names = ["agents", "tools", "hooks", "commands", "config", "skills", "CHANGELOG.md"]
765
+ names = ["agents", "tools", "hooks", "config", "skills", "CHANGELOG.md"]
766
766
  critical = {"agents", "hooks", "skills"}
767
767
  valid = 0
768
768
  has_critical_missing = False
@@ -1053,34 +1053,36 @@ def check_memory_fts5_db(project_root: Path) -> dict:
1053
1053
 
1054
1054
  @register_check("memory_fts5_count", order=130)
1055
1055
  def check_memory_fts5_count(project_root: Path) -> dict:
1056
- """Check FTS5 indexed count against total episode count in index.json."""
1057
- index_path = project_root / ".claude" / "project-context" / "episodic-memory" / "index.json"
1058
-
1059
- if not index_path.is_file():
1060
- return _result("memory_fts5_count", "info", "index.json not found — no episodes yet")
1061
-
1062
- index_data = _read_json(index_path)
1063
- if not index_data:
1064
- return _result("memory_fts5_count", "info", "index.json unreadable")
1065
-
1066
- total = len(index_data.get("episodes") or [])
1056
+ """Check FTS5 indexed count against total episode count in gaia.db.
1067
1057
 
1058
+ T6 migration: replaced legacy search_store.count()/index.json check
1059
+ (which read search.db and ignored GAIA_DATA_DIR) with queries against
1060
+ the canonical gaia.db -- episodes_fts for indexed, episodes for total.
1061
+ """
1068
1062
  try:
1069
1063
  import sys as _sys
1070
- # Ensure package root is on path for lazy import
1071
1064
  pkg_root = str(_package_root())
1072
1065
  if pkg_root not in _sys.path:
1073
1066
  _sys.path.insert(0, pkg_root)
1074
- from tools.memory import search_store # noqa: PLC0415
1075
- indexed = search_store.count()
1067
+ from gaia.store.writer import _connect as _store_connect
1076
1068
  except ImportError:
1077
1069
  return _result(
1078
1070
  "memory_fts5_count",
1079
1071
  "info",
1080
- "tools.memory.search_store not importable — FTS5 count skipped",
1072
+ "gaia.store.writer not importable — FTS5 count skipped",
1081
1073
  )
1074
+
1075
+ try:
1076
+ con = _store_connect()
1077
+ try:
1078
+ indexed_row = con.execute("SELECT COUNT(*) FROM episodes_fts").fetchone()
1079
+ indexed = indexed_row[0] if indexed_row else 0
1080
+ total_row = con.execute("SELECT COUNT(*) FROM episodes").fetchone()
1081
+ total = total_row[0] if total_row else 0
1082
+ finally:
1083
+ con.close()
1082
1084
  except Exception as exc:
1083
- return _result("memory_fts5_count", "info", f"Could not query FTS5 count: {exc}")
1085
+ return _result("memory_fts5_count", "info", f"Could not query FTS5 count from gaia.db: {exc}")
1084
1086
 
1085
1087
  if total == 0:
1086
1088
  return _result("memory_fts5_count", "pass", "No episodes to index")
@@ -590,11 +590,16 @@ else
590
590
  rc=$?
591
591
  fi
592
592
  ms=$(( $(now_ms) - t0 ))
593
- # doctor may exit 1 on warnings; allow rc=0 or rc=1 if json is parseable
594
- if python3 -c "import json,sys; d=json.loads(sys.argv[1]); c=d['checks']; p=sum(1 for r in c if r['severity']=='pass'); t=len(c); sys.exit(0 if t>=5 and p>=max(1,t-3) else 1)" "${out}" 2>/dev/null; then
593
+ # The harness gate must not pass a degraded doctor: a non-zero rc means at
594
+ # least one check is warning/error, so treat rc>=1 as a hard FAILURE. Only a
595
+ # clean rc=0 (all checks pass/info) with parseable JSON counts as PASS.
596
+ if [[ "${rc}" -ne 0 ]]; then
597
+ nonpass="$(python3 -c "import json,sys; d=json.loads(sys.argv[1]); print(', '.join(f\"{r['name']}={r['severity']}\" for r in d['checks'] if r['severity'] not in ('pass','info')))" "${out}" 2>/dev/null || echo "unparseable output")"
598
+ record "gaia doctor --json" "FAIL" "doctor returned rc=${rc}; non-pass: ${nonpass:-none}" "${ms}"
599
+ elif python3 -c "import json,sys; d=json.loads(sys.argv[1]); c=d['checks']; t=len(c); sys.exit(0 if t>=5 else 1)" "${out}" 2>/dev/null; then
595
600
  total=$(python3 -c "import json,sys; d=json.loads(sys.argv[1]); print(len(d['checks']))" "${out}")
596
601
  passed=$(python3 -c "import json,sys; d=json.loads(sys.argv[1]); print(sum(1 for r in d['checks'] if r['severity']=='pass'))" "${out}")
597
- record "gaia doctor --json" "PASS" "${passed}/${total} checks passed" "${ms}"
602
+ record "gaia doctor --json" "PASS" "rc=0, ${passed}/${total} checks passed" "${ms}"
598
603
  else
599
604
  record "gaia doctor --json" "FAIL" "parse/threshold failure (rc=${rc})" "${ms}"
600
605
  fi
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gaia-ops",
3
- "version": "5.0.6",
3
+ "version": "5.0.8",
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",