@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
package/bin/cli/brief.py CHANGED
@@ -431,10 +431,23 @@ def _cmd_list(args) -> int:
431
431
 
432
432
  def _cmd_close(args) -> int:
433
433
  from gaia.briefs import close_brief
434
+ from gaia.briefs.store import verify_brief
434
435
  workspace = _resolve_workspace(getattr(args, "workspace", None))
435
436
  name = args.name
436
437
  if close_brief(workspace, name):
437
438
  print(f"Closed brief '{name}'")
439
+ # AC-3 advisory: run invariant checker and surface any inconsistencies
440
+ # as non-blocking stderr warnings (mirrors D11 pattern in plan CLI).
441
+ # Close always succeeds (exit 0); warnings never gate the operation.
442
+ try:
443
+ result = verify_brief(workspace, name)
444
+ for issue in result.get("inconsistencies", []):
445
+ print(
446
+ f"Warning: [{issue['kind']}] {issue['detail']}",
447
+ file=sys.stderr,
448
+ )
449
+ except Exception:
450
+ pass # advisory failure must never abort the close
438
451
  return 0
439
452
  return _err(f"brief '{name}' not found in workspace '{workspace}'")
440
453
 
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()
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gaia-ops",
3
- "version": "5.0.7",
3
+ "version": "5.0.9",
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",
@@ -603,13 +603,18 @@ class ClaudeCodeAdapter(HookAdapter):
603
603
  exit_code=2,
604
604
  )
605
605
 
606
- # Save state for post-hook
606
+ # Save state for post-hook. When the command was allowed by consuming a
607
+ # T3 approval grant, carry that approval_id forward so PostToolUse can
608
+ # append an EXECUTED/FAILED event to the approval_events chain (the grant
609
+ # is consumed here at PreToolUse and flips to CONSUMED, so PostToolUse
610
+ # cannot re-discover it via check_approval_grant).
607
611
  effective_command = result.modified_input.get("command", command) if result.modified_input else command
608
612
  state = create_pre_hook_state(
609
613
  tool_name=tool_name,
610
614
  command=effective_command,
611
615
  tier=str(result.tier),
612
616
  allowed=True,
617
+ consumed_approval_id=result.consumed_approval_id,
613
618
  )
614
619
  save_hook_state(state)
615
620
 
@@ -1003,6 +1008,26 @@ class ClaudeCodeAdapter(HookAdapter):
1003
1008
  "T3 grant confirmed (will be consumed at SubagentStop): %s", command[:80],
1004
1009
  )
1005
1010
 
1011
+ # Close the audit-log cycle for an APPROVED T3 command that just ran.
1012
+ # PreToolUse stashed the consumed grant's approval_id in HookState
1013
+ # when it matched (and consumed) the grant; append EXECUTED on a clean
1014
+ # exit, FAILED otherwise. This continues the approval_events hash chain
1015
+ # via the canonical store.record_event() helper -- the only authorized
1016
+ # writer for the chain (it routes through chain.insert_event(), which
1017
+ # links prev_hash -> this_hash before INSERT).
1018
+ if tool_name == "Bash":
1019
+ consumed_approval_id = (
1020
+ pre_state.metadata.get("consumed_approval_id") if pre_state else None
1021
+ )
1022
+ if consumed_approval_id:
1023
+ self._record_t3_outcome_event(
1024
+ consumed_approval_id,
1025
+ command=parameters.get("command", ""),
1026
+ success=success,
1027
+ exit_code=tool_result_data.exit_code,
1028
+ session_id=hook_data.get("session_id", ""),
1029
+ )
1030
+
1006
1031
  events = detect_critical_event(tool_name, parameters, output, success)
1007
1032
  if events:
1008
1033
  writer = SessionContextWriter()
@@ -1031,6 +1056,53 @@ class ClaudeCodeAdapter(HookAdapter):
1031
1056
 
1032
1057
  return HookResponse(output={}, exit_code=0)
1033
1058
 
1059
+ def _record_t3_outcome_event(
1060
+ self,
1061
+ approval_id: str,
1062
+ *,
1063
+ command: str,
1064
+ success: bool,
1065
+ exit_code: int,
1066
+ session_id: str = "",
1067
+ ) -> None:
1068
+ """Append an EXECUTED or FAILED event for an approved T3 command.
1069
+
1070
+ Closes the audit-log cycle: once a command runs under a consumed grant,
1071
+ the approval_events chain records whether it succeeded (EXECUTED) or
1072
+ failed (FAILED). Writes through gaia.approvals.store.record_event(), the
1073
+ canonical chain writer -- never a raw INSERT -- so prev_hash -> this_hash
1074
+ linkage is preserved and validate_chain() stays intact end to end.
1075
+
1076
+ Best-effort and non-fatal: the approval store lives in gaia.db and may be
1077
+ unavailable in some hook contexts; any failure is logged and swallowed so
1078
+ a chain-write hiccup never breaks tool execution.
1079
+ """
1080
+ event_type = "EXECUTED" if success else "FAILED"
1081
+ try:
1082
+ from gaia.approvals import store as _approval_store
1083
+
1084
+ payload = {
1085
+ "command": command,
1086
+ "exit_code": exit_code,
1087
+ "outcome": "success" if success else "failure",
1088
+ }
1089
+ _approval_store.record_event(
1090
+ approval_id,
1091
+ event_type,
1092
+ session_id=session_id or None,
1093
+ payload_json=json.dumps(payload, sort_keys=True, separators=(",", ":")),
1094
+ metadata_json=json.dumps({"source": "post_tool_use"}),
1095
+ )
1096
+ logger.info(
1097
+ "Recorded %s event for approval_id=%s (exit=%d)",
1098
+ event_type, approval_id[:16], exit_code,
1099
+ )
1100
+ except Exception as exc:
1101
+ logger.warning(
1102
+ "Failed to record %s event for approval_id=%s (non-fatal): %s",
1103
+ event_type, approval_id[:16], exc,
1104
+ )
1105
+
1034
1106
  # ------------------------------------------------------------------ #
1035
1107
  # _handle_ask_user_question_result: grant activation from user answer
1036
1108
  # ------------------------------------------------------------------ #
@@ -1045,19 +1117,15 @@ class ClaudeCodeAdapter(HookAdapter):
1045
1117
  2. Load the specific pending file by prefix (any session).
1046
1118
  3. Activate the grant under the CURRENT session.
1047
1119
 
1048
- Falls back to session-wide activation when no nonce is present in
1049
- 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()``.
1050
1123
 
1051
1124
  Never blocks (no exceptions raised to caller).
1052
1125
  """
1053
1126
  from modules.security.approval_grants import (
1054
- activate_cross_session_pending,
1055
1127
  activate_db_pending_by_prefix,
1056
- activate_grants_for_session,
1057
- activate_pending_approval,
1058
1128
  extract_nonce_from_label,
1059
- get_pending_approvals_for_session,
1060
- load_pending_by_nonce_prefix,
1061
1129
  )
1062
1130
 
1063
1131
  session_id = hook_data.get("session_id", "") or os.environ.get("CLAUDE_SESSION_ID", "")
@@ -1091,93 +1159,31 @@ class ClaudeCodeAdapter(HookAdapter):
1091
1159
  logger.info("AskUserQuestion: no session_id available, skipping grant activation")
1092
1160
  return
1093
1161
 
1094
- # Try nonce-targeted activation first: extract nonce from answer labels
1162
+ # Nonce-targeted activation: extract the nonce from answer labels.
1095
1163
  nonce_prefix = None
1096
1164
  for v in answers.values():
1097
1165
  nonce_prefix = extract_nonce_from_label(str(v))
1098
1166
  if nonce_prefix:
1099
1167
  break
1100
1168
 
1101
- if nonce_prefix:
1102
- # Nonce-targeted: load this specific pending regardless of session
1103
- pending_data = load_pending_by_nonce_prefix(nonce_prefix)
1104
- if pending_data:
1105
- pending_session = pending_data.get("session_id", "")
1106
- full_nonce = pending_data.get("nonce", "")
1107
-
1108
- if pending_session == session_id:
1109
- # Same session -- use standard activation
1110
- result = activate_pending_approval(
1111
- nonce=full_nonce,
1112
- session_id=session_id,
1113
- )
1114
- else:
1115
- # Cross session -- activate under current session
1116
- result = activate_cross_session_pending(
1117
- pending_data,
1118
- session_id=session_id,
1119
- )
1120
-
1121
- if result.success:
1122
- logger.info(
1123
- "AskUserQuestion nonce-targeted activation: prefix=%s, "
1124
- "pending_session=%s, current_session=%s, status=%s",
1125
- nonce_prefix, pending_session[:12], session_id[:12],
1126
- getattr(result.status, "value", str(result.status)),
1127
- )
1128
- return
1129
- else:
1130
- logger.warning(
1131
- "AskUserQuestion nonce-targeted activation failed: "
1132
- "prefix=%s, status=%s, reason=%s",
1133
- nonce_prefix,
1134
- getattr(result.status, "value", str(result.status)),
1135
- result.reason,
1136
- )
1137
- else:
1138
- # Filesystem pending not found -- try DB lookup (M2 bridge).
1139
- # Since M2, REQUESTED writes go to DB only; no pending-{nonce}.json
1140
- # is written to the filesystem any more.
1141
- logger.info(
1142
- "AskUserQuestion: nonce prefix %s found in label but no "
1143
- "matching pending file -- trying DB lookup (M2 bridge)",
1144
- nonce_prefix,
1145
- )
1146
- result = activate_db_pending_by_prefix(
1147
- nonce_prefix, current_session_id=session_id,
1148
- )
1149
- if result.success:
1150
- logger.info(
1151
- "AskUserQuestion DB-bridge activation: prefix=%s status=%s",
1152
- nonce_prefix,
1153
- getattr(result.status, "value", str(result.status)),
1154
- )
1155
- return
1156
- else:
1157
- logger.warning(
1158
- "AskUserQuestion DB-bridge activation failed: "
1159
- "prefix=%s status=%s reason=%s -- falling back to session-wide",
1160
- nonce_prefix,
1161
- getattr(result.status, "value", str(result.status)),
1162
- result.reason,
1163
- )
1164
- # Fall through to session-wide activation below
1165
- nonce_prefix = None
1166
-
1167
1169
  if not nonce_prefix:
1168
- # No nonce in label (or all targeted paths failed) -- fall back to
1169
- # session-wide activation for backward compatibility
1170
- pending = get_pending_approvals_for_session(session_id)
1171
- if not pending:
1172
- logger.info("AskUserQuestion: no pending grants for session %s", session_id)
1173
- return
1174
-
1175
- results = activate_grants_for_session(session_id)
1176
- activated = sum(1 for r in results if r.success)
1177
1170
  logger.info(
1178
- "AskUserQuestion session-wide activation: %d/%d pending grants for session %s",
1179
- activated, len(results), session_id,
1171
+ "AskUserQuestion: no nonce prefix in answer labels -- "
1172
+ "nothing to activate for session %s", session_id[:12],
1180
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
+ )
1181
1187
 
1182
1188
  except Exception as e:
1183
1189
  logger.error("Error in _handle_ask_user_question_result: %s", e, exc_info=True)
@@ -170,19 +170,30 @@ def _intake_command_set_pending(
170
170
  }
171
171
 
172
172
  try:
173
- from gaia.approvals.store import insert_requested
173
+ from gaia.approvals.store import derive_command_set_id, insert_requested
174
174
  except ImportError:
175
175
  import pathlib as _pl
176
176
  import sys as _sys
177
177
 
178
178
  _repo_root = _pl.Path(__file__).resolve().parent.parent.parent.parent
179
179
  _sys.path.insert(0, str(_repo_root))
180
- from gaia.approvals.store import insert_requested
180
+ from gaia.approvals.store import derive_command_set_id, insert_requested
181
+
182
+ # Derive the PUBLIC approval_id deterministically from the post-filter
183
+ # mutative command strings. Because the id is content-derived (not uuid4),
184
+ # the orchestrator reproduces the SAME id from the command_set it reads in
185
+ # the contract via `gaia approvals derive-id` -- no DB search, no
186
+ # cross-session miss. The list passed here is the SAME list the CLI helper
187
+ # derives over (post-mutative-filter), so both sides agree.
188
+ derived_id = derive_command_set_id(
189
+ [it["command"] for it in command_set_items]
190
+ )
181
191
 
182
192
  approval_id = insert_requested(
183
193
  sealed_payload,
184
194
  agent_id=agent_id,
185
195
  session_id=session_id or None,
196
+ approval_id=derived_id,
186
197
  )
187
198
  logger.info(
188
199
  "INTAKE: plan-first COMMAND_SET pending created approval_id=%s items=%d",
@@ -450,17 +450,33 @@ def build_project_context(
450
450
  if critical_summary:
451
451
  context_string += critical_summary
452
452
 
453
- # Inject recent operational events (non-blocking)
453
+ # Inject recent operational events (non-blocking).
454
+ # Brief 54 / Task 2.2: read from the harness_events DB table via
455
+ # gaia.store.reader.cross_surface_query instead of the legacy
456
+ # events.jsonl reader. The reader returns rows shaped as
457
+ # {surface, timestamp, type, agent, summary, raw} -- NOT the old
458
+ # {ts, type, agent, result} JSONL shape -- so the formatting loop
459
+ # below is remapped to those keys (audit Risk 4: without the remap
460
+ # the "Recent Events" block silently goes blank).
454
461
  try:
455
- from ..events.event_writer import read_events
456
- recent = read_events(hours=24, limit=20)
462
+ import sys as _sys
463
+ from pathlib import Path as _Path
464
+ try:
465
+ from gaia.store import reader as _reader
466
+ except ImportError:
467
+ _repo_root = _Path(__file__).resolve().parents[3]
468
+ _sys.path.insert(0, str(_repo_root))
469
+ from gaia.store import reader as _reader
470
+ recent = _reader.cross_surface_query(
471
+ surface="harness_events", since="24h", last=20,
472
+ )
457
473
  if recent:
458
474
  lines = ["\n# Recent Events (last 24h)"]
459
475
  for evt in recent:
460
- ts_short = evt.get("ts", "")[:16]
461
- etype = evt.get("type", "")
462
- agent_name = evt.get("agent", "")
463
- result_str = evt.get("result", "")
476
+ ts_short = (evt.get("timestamp") or "")[:16]
477
+ etype = evt.get("type") or ""
478
+ agent_name = evt.get("agent") or ""
479
+ result_str = evt.get("summary") or ""
464
480
  label = f"{agent_name}: " if agent_name else ""
465
481
  lines.append(f"- [{ts_short}] {etype}: {label}{result_str}")
466
482
  context_string += "\n".join(lines) + "\n"
@@ -1,16 +1,27 @@
1
- """Event writer and reader for the GAIA Event Context system.
1
+ """Event writer for the GAIA Event Context system.
2
+
3
+ As of Brief 54 / Task 2.2 the event pipeline writes to the ``harness_events``
4
+ table in the Gaia SQLite substrate (``~/.gaia/gaia.db``) instead of the legacy
5
+ ``events.jsonl`` file. This is an ATOMIC cutover: ``write_event`` no longer
6
+ touches ``events.jsonl`` in any code path -- there is NO dual-write.
2
7
 
3
8
  Provides:
4
- - EventWriter: append-only JSONL writer with file locking
5
- - read_events(): read events from last N hours with optional filtering
6
- - cleanup_old_events(): remove events older than N days
9
+ - EventWriter: non-blocking, silent-on-failure DB event writer
10
+ - read_events(): legacy JSONL reader (read-only; retained until Task 2.3
11
+ removes events.jsonl entirely -- no longer the canonical read path)
7
12
  - Event type constants
13
+
14
+ The DB write delegates to ``gaia.store.writer.write_harness_event``, which
15
+ resolves the DB path the same way every other gaia DB writer does (via
16
+ ``gaia.paths.db_path()`` -> ``GAIA_DATA_DIR`` / ``gaia.db``, falling back to
17
+ ``~/.gaia/gaia.db``). The hook subprocess imports the ``gaia`` package via the
18
+ repo-root fallback already established by handoff_persister.
8
19
  """
9
20
 
10
- import fcntl
11
21
  import json
12
22
  import logging
13
23
  import os
24
+ import sys
14
25
  from datetime import datetime, timedelta, timezone
15
26
  from pathlib import Path
16
27
  from typing import Any, Dict, List, Optional
@@ -32,17 +43,36 @@ HEARTBEAT = "heartbeat"
32
43
  USER_NOTE = "user.note"
33
44
 
34
45
 
46
+ def _import_store_writer():
47
+ """Import gaia.store.writer, falling back to the repo layout.
48
+
49
+ Mirrors the import contract used by
50
+ hooks/modules/agents/handoff_persister.py: prefer a sibling ``gaia``
51
+ package if installed; otherwise add the repo root (two levels above
52
+ ``hooks/``) to ``sys.path`` and import from there.
53
+ """
54
+ try:
55
+ from gaia.store import writer as _writer
56
+ except ImportError:
57
+ _repo_root = Path(__file__).resolve().parents[3]
58
+ sys.path.insert(0, str(_repo_root))
59
+ from gaia.store import writer as _writer
60
+ return _writer
61
+
62
+
35
63
  class EventWriter:
36
- """Append-only JSONL event writer with file locking.
64
+ """Non-blocking DB event writer.
37
65
 
38
- All writes are wrapped in try/except -- events are non-critical and
39
- must never block the hook pipeline.
66
+ All writes are wrapped in try/except -- events are non-critical and must
67
+ never block the hook pipeline. The ``events_dir`` argument is retained for
68
+ backward compatibility (legacy JSONL reads still resolve it) but is no
69
+ longer used for writes, which target the ``harness_events`` DB table.
40
70
  """
41
71
 
42
72
  def __init__(self, events_dir: Optional[Path] = None):
73
+ # Retained for compatibility with the legacy reader; not used for
74
+ # writes. Resolved lazily-safe (never raises here).
43
75
  self.events_dir = events_dir or get_events_dir()
44
- self.events_file = self.events_dir / "events.jsonl"
45
- self.lock_file = self.events_dir / "events.jsonl.lock"
46
76
 
47
77
  def write_event(
48
78
  self,
@@ -53,10 +83,10 @@ class EventWriter:
53
83
  severity: str = "info",
54
84
  meta: Optional[Dict[str, Any]] = None,
55
85
  ) -> None:
56
- """Append a single event to the JSONL log.
86
+ """Append a single event to the ``harness_events`` DB table.
57
87
 
58
- Thread-safe via exclusive file lock. Fails silently on any error
59
- to avoid disrupting the hook pipeline.
88
+ Fails silently on any error to avoid disrupting the hook pipeline --
89
+ same contract as the historical file writer.
60
90
 
61
91
  Args:
62
92
  event_type: Dotted event category (e.g. "agent.dispatch").
@@ -64,30 +94,21 @@ class EventWriter:
64
94
  agent: Agent involved, or empty string for non-agent events.
65
95
  result: Outcome summary string.
66
96
  severity: info | warning | error.
67
- meta: Optional type-specific structured data.
97
+ meta: Optional type-specific structured data (stored as JSON in
98
+ the ``payload`` column).
68
99
  """
69
100
  try:
70
- self.events_dir.mkdir(parents=True, exist_ok=True)
71
-
72
- record: Dict[str, Any] = {
73
- "ts": datetime.now(timezone.utc).isoformat(),
74
- "type": event_type,
75
- "source": source,
76
- "agent": agent,
77
- "result": result,
78
- "severity": severity,
79
- }
80
- if meta:
81
- record["meta"] = meta
82
-
83
- with open(self.lock_file, "w") as lf:
84
- fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
85
- try:
86
- with open(self.events_file, "a") as f:
87
- f.write(json.dumps(record, separators=(",", ":")) + "\n")
88
- finally:
89
- fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
90
-
101
+ writer = _import_store_writer()
102
+ workspace = os.environ.get("GAIA_WORKSPACE") or None
103
+ writer.write_harness_event(
104
+ event_type=event_type,
105
+ source=source,
106
+ agent=agent,
107
+ result=result,
108
+ severity=severity,
109
+ meta=meta,
110
+ workspace=workspace,
111
+ )
91
112
  except Exception as exc:
92
113
  logger.debug("Event write failed (non-fatal): %s", exc)
93
114
 
@@ -98,7 +119,13 @@ def read_events(
98
119
  limit: int = 50,
99
120
  events_dir: Optional[Path] = None,
100
121
  ) -> List[Dict[str, Any]]:
101
- """Read recent events from the JSONL log.
122
+ """Read recent events from the legacy JSONL log.
123
+
124
+ NOTE: As of Task 2.2 this is no longer the canonical read path -- new
125
+ events are written to the ``harness_events`` DB table. This reader is
126
+ retained read-only until Task 2.3 removes ``events.jsonl`` entirely, so
127
+ historical pre-cutover events remain consultable. New callers should use
128
+ ``gaia.store.reader.cross_surface_query(surface="harness_events")``.
102
129
 
103
130
  Args:
104
131
  hours: How far back to look (default 24h).
@@ -148,63 +175,3 @@ def read_events(
148
175
  except Exception as exc:
149
176
  logger.debug("Event read failed (non-fatal): %s", exc)
150
177
  return []
151
-
152
-
153
- def cleanup_old_events(
154
- days: int = 7,
155
- events_dir: Optional[Path] = None,
156
- ) -> int:
157
- """Remove events older than *days* from the JSONL log.
158
-
159
- Uses file locking to avoid conflicts with concurrent writers.
160
- Retains lines that cannot be parsed (conservative).
161
-
162
- Args:
163
- days: Retention window in days (default 7).
164
- events_dir: Override events directory (for testing).
165
-
166
- Returns:
167
- Number of events removed.
168
- """
169
- try:
170
- edir = events_dir or get_events_dir()
171
- events_file = edir / "events.jsonl"
172
- lock_file = edir / "events.jsonl.lock"
173
-
174
- if not events_file.exists():
175
- return 0
176
-
177
- retention_days = int(os.environ.get("GAIA_EVENT_RETENTION_DAYS", str(days)))
178
- cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
179
- kept: List[str] = []
180
- removed = 0
181
-
182
- with open(lock_file, "w") as lf:
183
- fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
184
- try:
185
- with open(events_file, "r") as f:
186
- for line in f:
187
- line = line.strip()
188
- if not line:
189
- continue
190
- try:
191
- evt = json.loads(line)
192
- ts = datetime.fromisoformat(evt["ts"])
193
- if ts < cutoff:
194
- removed += 1
195
- continue
196
- except (json.JSONDecodeError, KeyError, ValueError):
197
- pass # Keep unparseable lines
198
- kept.append(line)
199
-
200
- with open(events_file, "w") as f:
201
- for line in kept:
202
- f.write(line + "\n")
203
- finally:
204
- fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
205
-
206
- return removed
207
-
208
- except Exception as exc:
209
- logger.debug("Event cleanup failed (non-fatal): %s", exc)
210
- return 0
@@ -45,7 +45,6 @@ from .approval_scopes import (
45
45
  from .approval_grants import (
46
46
  check_approval_grant,
47
47
  cleanup_expired_grants,
48
- get_latest_pending_approval,
49
48
  last_check_found_expired,
50
49
  ApprovalGrant,
51
50
  )
@@ -93,7 +92,6 @@ __all__ = [
93
92
  # Approval Grants
94
93
  "check_approval_grant",
95
94
  "cleanup_expired_grants",
96
- "get_latest_pending_approval",
97
95
  "last_check_found_expired",
98
96
  "ApprovalGrant",
99
97
  # Shell unwrapper