@jaguilar87/gaia 5.0.8 → 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 (89) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +11 -0
  4. package/bin/README.md +6 -1
  5. package/bin/cli/approvals.py +341 -238
  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 +19 -85
  10. package/dist/gaia-ops/hooks/modules/context/context_injector.py +23 -7
  11. package/dist/gaia-ops/hooks/modules/events/event_writer.py +63 -96
  12. package/dist/gaia-ops/hooks/modules/security/__init__.py +0 -2
  13. package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +238 -69
  14. package/dist/gaia-ops/hooks/modules/security/approval_grants.py +506 -1103
  15. package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +24 -1
  16. package/dist/gaia-ops/hooks/modules/session/pending_scanner.py +150 -90
  17. package/dist/gaia-ops/hooks/modules/session/session_manifest.py +257 -28
  18. package/dist/gaia-ops/hooks/post_compact.py +1 -0
  19. package/dist/gaia-ops/hooks/pre_compact.py +1 -0
  20. package/dist/gaia-ops/hooks/user_prompt_submit.py +20 -0
  21. package/dist/gaia-ops/skills/agent-approval-protocol/SKILL.md +27 -7
  22. package/dist/gaia-ops/skills/agent-approval-protocol/reference.md +11 -6
  23. package/dist/gaia-ops/skills/gaia-patterns/reference.md +2 -2
  24. package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +69 -28
  25. package/dist/gaia-ops/skills/orchestrator-present-approval/reference.md +16 -3
  26. package/dist/gaia-ops/skills/orchestrator-present-approval/template.md +10 -5
  27. package/dist/gaia-ops/skills/pending-approvals/SKILL.md +16 -11
  28. package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +20 -6
  29. package/dist/gaia-ops/skills/subagent-request-approval/reference.md +23 -15
  30. package/dist/gaia-ops/tools/migration/README.md +10 -12
  31. package/dist/gaia-ops/tools/scan/orchestrator.py +194 -10
  32. package/dist/gaia-ops/tools/scan/tests/test_integration.py +1 -2
  33. package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
  34. package/dist/gaia-security/hooks/adapters/claude_code.py +19 -85
  35. package/dist/gaia-security/hooks/modules/context/context_injector.py +23 -7
  36. package/dist/gaia-security/hooks/modules/events/event_writer.py +63 -96
  37. package/dist/gaia-security/hooks/modules/security/__init__.py +0 -2
  38. package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +238 -69
  39. package/dist/gaia-security/hooks/modules/security/approval_grants.py +506 -1103
  40. package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +24 -1
  41. package/dist/gaia-security/hooks/modules/session/pending_scanner.py +150 -90
  42. package/dist/gaia-security/hooks/modules/session/session_manifest.py +257 -28
  43. package/dist/gaia-security/hooks/user_prompt_submit.py +20 -0
  44. package/gaia/approvals/store.py +87 -9
  45. package/gaia/store/schema.sql +38 -1
  46. package/gaia/store/writer.py +400 -0
  47. package/hooks/adapters/claude_code.py +19 -85
  48. package/hooks/elicitation_result.py +20 -75
  49. package/hooks/modules/context/context_injector.py +23 -7
  50. package/hooks/modules/events/event_writer.py +63 -96
  51. package/hooks/modules/security/__init__.py +0 -2
  52. package/hooks/modules/security/approval_cleanup.py +238 -69
  53. package/hooks/modules/security/approval_grants.py +506 -1103
  54. package/hooks/modules/security/mutative_verbs.py +24 -1
  55. package/hooks/modules/session/pending_scanner.py +150 -90
  56. package/hooks/modules/session/session_manifest.py +257 -28
  57. package/hooks/post_compact.py +1 -0
  58. package/hooks/pre_compact.py +1 -0
  59. package/hooks/user_prompt_submit.py +20 -0
  60. package/package.json +1 -1
  61. package/pyproject.toml +1 -1
  62. package/scripts/bootstrap_database.sh +66 -17
  63. package/scripts/migrations/README.md +26 -14
  64. package/scripts/migrations/schema.checksum +2 -2
  65. package/scripts/migrations/v18_to_v19.sql +36 -0
  66. package/scripts/migrations/v19_to_v20.sql +20 -0
  67. package/skills/agent-approval-protocol/SKILL.md +27 -7
  68. package/skills/agent-approval-protocol/reference.md +11 -6
  69. package/skills/gaia-patterns/reference.md +2 -2
  70. package/skills/orchestrator-present-approval/SKILL.md +69 -28
  71. package/skills/orchestrator-present-approval/reference.md +16 -3
  72. package/skills/orchestrator-present-approval/template.md +10 -5
  73. package/skills/pending-approvals/SKILL.md +16 -11
  74. package/skills/subagent-request-approval/SKILL.md +20 -6
  75. package/skills/subagent-request-approval/reference.md +23 -15
  76. package/tools/migration/README.md +10 -12
  77. package/tools/scan/orchestrator.py +194 -10
  78. package/tools/scan/tests/test_integration.py +1 -2
  79. package/bin/cli/plans.py +0 -517
  80. package/dist/gaia-ops/tools/context/deep_merge.py +0 -159
  81. package/dist/gaia-ops/tools/migration/migrate_04_harness_events.py +0 -132
  82. package/dist/gaia-ops/tools/migration/migrate_04_harness_events.sh +0 -23
  83. package/dist/gaia-ops/tools/scan/merge.py +0 -213
  84. package/dist/gaia-ops/tools/scan/tests/test_merge.py +0 -269
  85. package/tools/context/deep_merge.py +0 -159
  86. package/tools/migration/migrate_04_harness_events.py +0 -132
  87. package/tools/migration/migrate_04_harness_events.sh +0 -23
  88. package/tools/scan/merge.py +0 -213
  89. package/tools/scan/tests/test_merge.py +0 -269
@@ -11,23 +11,26 @@ Pipeline:
11
11
  3. Collect and combine scanner sections (handling environment sub-keys)
12
12
  4. Cross-populate derived fields
13
13
  5. Return ScanOutput
14
+
15
+ Section ownership rules (inlined from the retired tools/scan/merge.py):
16
+ Rule 1: Scanner-owned sections -> full replace
17
+ Rule 2: Agent-enriched sections -> never touch
18
+ Rule 3: Mixed sections -> selective update at sub-key level
19
+ Rule 4: Unknown/user-custom sections -> preserve
20
+ Rule 5: Metadata -> always update
14
21
  """
15
22
 
23
+ import copy
16
24
  import logging
17
25
  import time
18
26
  from concurrent.futures import ThreadPoolExecutor, as_completed
19
27
  from dataclasses import dataclass, field
20
28
  from datetime import datetime, timezone
21
29
  from pathlib import Path
22
- from typing import Any, Dict, List, Optional
30
+ from typing import Any, Dict, List, Optional, Set
23
31
 
24
32
  from tools.scan import __version__ as scanner_package_version
25
33
  from tools.scan.config import ScanConfig
26
- from tools.scan.merge import (
27
- AGENT_ENRICHED_SECTIONS,
28
- collect_scanner_sections,
29
- merge_context,
30
- )
31
34
  from tools.scan.registry import ScannerRegistry
32
35
  from tools.scan.scanners.base import BaseScanner, ScanResult
33
36
  from tools.scan.workspace import WorkspaceInfo, detect_workspace_type
@@ -35,6 +38,190 @@ from tools.scan.workspace import WorkspaceInfo, detect_workspace_type
35
38
  logger = logging.getLogger(__name__)
36
39
 
37
40
 
41
+ # ---------------------------------------------------------------------------
42
+ # Section ownership constants (Rule 1 / Rule 2 / Rule 3)
43
+ # ---------------------------------------------------------------------------
44
+
45
+ # Sections fully owned by scanners -- replaced entirely on each scan (Rule 1)
46
+ # Top-level sections only; sub-key ownership handled separately
47
+ SCANNER_OWNED_TOP_LEVEL: Dict[str, str] = {
48
+ "project_identity": "stack",
49
+ "stack": "stack",
50
+ "git": "git",
51
+ "infrastructure": "infrastructure",
52
+ "orchestration": "orchestration",
53
+ # "environment" is NOT listed here because it has sub-key ownership
54
+ }
55
+
56
+ # Sub-key ownership within the `environment` section (Rule 4 / sub-section)
57
+ # Maps environment sub-key -> owning scanner name
58
+ ENVIRONMENT_SUBKEY_OWNERS: Dict[str, str] = {
59
+ "tools": "tools",
60
+ "tool_preferences": "tools",
61
+ "os": "environment",
62
+ "runtimes": "environment",
63
+ "env_files": "environment",
64
+ }
65
+
66
+ # Agent-enriched sections -- never modified by scanners (Rule 2)
67
+ AGENT_ENRICHED_SECTIONS: frozenset = frozenset([
68
+ "operational_guidelines",
69
+ "cluster_details",
70
+ "infrastructure_topology",
71
+ "monitoring_observability",
72
+ "architecture_overview",
73
+ "gcp_services",
74
+ "workload_identity",
75
+ ])
76
+
77
+ # Mixed sections with partial scanner ownership (Rule 3)
78
+ # Maps section_name -> set of scanner-owned field names
79
+ MIXED_SECTION_SCANNER_FIELDS: Dict[str, Set[str]] = {
80
+ "terraform_infrastructure": {"layout"},
81
+ "gitops_configuration": {"repository"},
82
+ "application_services": {"base_path", "services"},
83
+ }
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Section collection and merge helpers
88
+ # ---------------------------------------------------------------------------
89
+
90
+ def collect_scanner_sections(
91
+ scanner_results: Dict[str, Any],
92
+ ) -> Dict[str, Any]:
93
+ """Collect and combine sections from all scanner results.
94
+
95
+ Handles the environment section specially: both `tools` and `environment`
96
+ scanners produce sub-keys under `environment`, so their outputs are
97
+ combined into a single `environment` section.
98
+
99
+ Args:
100
+ scanner_results: Mapping of scanner_name -> ScanResult (must have
101
+ a `sections` attribute that is a dict).
102
+
103
+ Returns:
104
+ Combined sections dict from all scanners.
105
+ """
106
+ combined: Dict[str, Any] = {}
107
+ environment_parts: Dict[str, Any] = {}
108
+
109
+ for _scanner_name, scan_result in scanner_results.items():
110
+ sections = scan_result.sections if hasattr(scan_result, "sections") else {}
111
+
112
+ for section_name, section_data in sections.items():
113
+ if section_name == "environment":
114
+ # Merge environment sub-keys from both scanners
115
+ if isinstance(section_data, dict):
116
+ for key, value in section_data.items():
117
+ if key != "_source":
118
+ environment_parts[key] = value
119
+ else:
120
+ # Non-environment sections: direct assignment (last scanner wins,
121
+ # but each section should have exactly one owner)
122
+ combined[section_name] = section_data
123
+
124
+ # Reassemble environment section if we got any parts
125
+ if environment_parts:
126
+ combined["environment"] = {
127
+ "_source": "scanner:environment+tools",
128
+ **environment_parts,
129
+ }
130
+
131
+ return combined
132
+
133
+
134
+ def _merge_environment_section(
135
+ result: Dict[str, Any],
136
+ scan_sections: Dict[str, Any],
137
+ ) -> None:
138
+ """Merge the `environment` section with sub-key level ownership.
139
+
140
+ Two scanners contribute to the `environment` section:
141
+ - `tools` scanner owns: tools, tool_preferences
142
+ - `environment` scanner owns: os, runtimes, env_files
143
+
144
+ Each scanner's sub-keys replace their owned portion; the other scanner's
145
+ sub-keys are preserved. The `_source` field gets a combined tag.
146
+
147
+ Args:
148
+ result: The result dict being built (mutated in place).
149
+ scan_sections: Combined sections from all scanners.
150
+ """
151
+ if "environment" not in scan_sections:
152
+ return
153
+
154
+ scan_env = scan_sections["environment"]
155
+
156
+ if "environment" not in result:
157
+ result["environment"] = {}
158
+
159
+ env = result["environment"]
160
+
161
+ # Replace each sub-key based on ownership
162
+ for subkey in ENVIRONMENT_SUBKEY_OWNERS:
163
+ if subkey in scan_env:
164
+ env[subkey] = copy.deepcopy(scan_env[subkey])
165
+
166
+ # Set combined _source tag
167
+ env["_source"] = "scanner:environment+tools"
168
+
169
+
170
+ def _merge_sections(
171
+ existing: Dict[str, Any],
172
+ scan_sections: Dict[str, Any],
173
+ ) -> Dict[str, Any]:
174
+ """Merge scanner results with existing project-context sections.
175
+
176
+ Applies the ownership rules to produce the final merged sections dict.
177
+ Called with existing={} in normal scan runs (display-only path).
178
+
179
+ Args:
180
+ existing: Current sections (may be empty when called from scan).
181
+ scan_sections: Combined sections from all scanners.
182
+
183
+ Returns:
184
+ Merged sections dict. The merge is deterministic: same inputs always
185
+ produce the same output.
186
+ """
187
+ result = copy.deepcopy(existing)
188
+
189
+ # --- Rule 1: Scanner-owned top-level sections -> full replace ---
190
+ for section_name in SCANNER_OWNED_TOP_LEVEL:
191
+ if section_name in scan_sections:
192
+ result[section_name] = copy.deepcopy(scan_sections[section_name])
193
+
194
+ # --- Sub-section level ownership for `environment` ---
195
+ _merge_environment_section(result, scan_sections)
196
+
197
+ # --- Rule 2: Agent-enriched sections -> never touch ---
198
+ # These are already in `result` from the deepcopy of `existing`.
199
+ # (No action needed -- they are preserved by the deepcopy.)
200
+
201
+ # --- Rule 3: Mixed sections -> selective update ---
202
+ for section_name, scanner_fields in MIXED_SECTION_SCANNER_FIELDS.items():
203
+ if section_name in scan_sections:
204
+ scan_data = scan_sections[section_name]
205
+ if section_name not in result:
206
+ result[section_name] = {}
207
+ # Only update scanner-owned fields; preserve agent fields
208
+ for field_name in scanner_fields:
209
+ if field_name in scan_data:
210
+ result[section_name][field_name] = copy.deepcopy(
211
+ scan_data[field_name]
212
+ )
213
+
214
+ # --- Rule 5: Unknown/user-custom sections -> preserve ---
215
+ # Any section in `existing` not covered above is preserved by the deepcopy.
216
+ # We do NOT add new unknown sections from scan_sections.
217
+
218
+ return result
219
+
220
+
221
+ # ---------------------------------------------------------------------------
222
+ # ScanOutput dataclass
223
+ # ---------------------------------------------------------------------------
224
+
38
225
  @dataclass(frozen=True)
39
226
  class ScanOutput:
40
227
  """Aggregated output from all scanners.
@@ -187,11 +374,9 @@ class ScanOrchestrator:
187
374
  scan_sections = collect_scanner_sections(scanner_results)
188
375
 
189
376
  # Merge with empty existing context (no JSON persistence)
190
- section_owners = self.registry.get_section_owners()
191
- merged_sections = merge_context(
377
+ merged_sections = _merge_sections(
192
378
  existing={},
193
379
  scan_sections=scan_sections,
194
- section_owners=section_owners,
195
380
  )
196
381
 
197
382
  # Determine which sections were updated vs preserved
@@ -210,7 +395,6 @@ class ScanOrchestrator:
210
395
  self._cross_populate_monorepo(merged_sections)
211
396
 
212
397
  # Remove empty {} placeholders for agent-enriched and mixed sections
213
- from tools.scan.merge import MIXED_SECTION_SCANNER_FIELDS
214
398
  remove_if_empty = (
215
399
  AGENT_ENRICHED_SECTIONS
216
400
  | frozenset(MIXED_SECTION_SCANNER_FIELDS.keys())
@@ -23,8 +23,7 @@ from unittest.mock import patch
23
23
  import pytest
24
24
 
25
25
  from tools.scan.config import ScanConfig
26
- from tools.scan.merge import AGENT_ENRICHED_SECTIONS
27
- from tools.scan.orchestrator import ScanOrchestrator, ScanOutput
26
+ from tools.scan.orchestrator import AGENT_ENRICHED_SECTIONS, ScanOrchestrator, ScanOutput
28
27
  from tools.scan.registry import ScannerRegistry
29
28
  from tools.scan.scanners.base import BaseScanner, ScanResult
30
29
  from tools.scan.tests.conftest import create_git_dir
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gaia-security",
3
- "version": "5.0.8",
3
+ "version": "5.0.9",
4
4
  "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.",
5
5
  "author": {
6
6
  "name": "jaguilar87",
@@ -1117,19 +1117,15 @@ class ClaudeCodeAdapter(HookAdapter):
1117
1117
  2. Load the specific pending file by prefix (any session).
1118
1118
  3. Activate the grant under the CURRENT session.
1119
1119
 
1120
- Falls back to session-wide activation when no nonce is present in
1121
- the answer (backward compatibility with older approval labels).
1120
+ DB-only since the grant-lifecycle FS retirement: REQUESTED writes go
1121
+ to the DB, so the approved pending is resolved by nonce prefix straight
1122
+ from the DB via ``activate_db_pending_by_prefix()``.
1122
1123
 
1123
1124
  Never blocks (no exceptions raised to caller).
1124
1125
  """
1125
1126
  from modules.security.approval_grants import (
1126
- activate_cross_session_pending,
1127
1127
  activate_db_pending_by_prefix,
1128
- activate_grants_for_session,
1129
- activate_pending_approval,
1130
1128
  extract_nonce_from_label,
1131
- get_pending_approvals_for_session,
1132
- load_pending_by_nonce_prefix,
1133
1129
  )
1134
1130
 
1135
1131
  session_id = hook_data.get("session_id", "") or os.environ.get("CLAUDE_SESSION_ID", "")
@@ -1163,93 +1159,31 @@ class ClaudeCodeAdapter(HookAdapter):
1163
1159
  logger.info("AskUserQuestion: no session_id available, skipping grant activation")
1164
1160
  return
1165
1161
 
1166
- # Try nonce-targeted activation first: extract nonce from answer labels
1162
+ # Nonce-targeted activation: extract the nonce from answer labels.
1167
1163
  nonce_prefix = None
1168
1164
  for v in answers.values():
1169
1165
  nonce_prefix = extract_nonce_from_label(str(v))
1170
1166
  if nonce_prefix:
1171
1167
  break
1172
1168
 
1173
- if nonce_prefix:
1174
- # Nonce-targeted: load this specific pending regardless of session
1175
- pending_data = load_pending_by_nonce_prefix(nonce_prefix)
1176
- if pending_data:
1177
- pending_session = pending_data.get("session_id", "")
1178
- full_nonce = pending_data.get("nonce", "")
1179
-
1180
- if pending_session == session_id:
1181
- # Same session -- use standard activation
1182
- result = activate_pending_approval(
1183
- nonce=full_nonce,
1184
- session_id=session_id,
1185
- )
1186
- else:
1187
- # Cross session -- activate under current session
1188
- result = activate_cross_session_pending(
1189
- pending_data,
1190
- session_id=session_id,
1191
- )
1192
-
1193
- if result.success:
1194
- logger.info(
1195
- "AskUserQuestion nonce-targeted activation: prefix=%s, "
1196
- "pending_session=%s, current_session=%s, status=%s",
1197
- nonce_prefix, pending_session[:12], session_id[:12],
1198
- getattr(result.status, "value", str(result.status)),
1199
- )
1200
- return
1201
- else:
1202
- logger.warning(
1203
- "AskUserQuestion nonce-targeted activation failed: "
1204
- "prefix=%s, status=%s, reason=%s",
1205
- nonce_prefix,
1206
- getattr(result.status, "value", str(result.status)),
1207
- result.reason,
1208
- )
1209
- else:
1210
- # Filesystem pending not found -- try DB lookup (M2 bridge).
1211
- # Since M2, REQUESTED writes go to DB only; no pending-{nonce}.json
1212
- # is written to the filesystem any more.
1213
- logger.info(
1214
- "AskUserQuestion: nonce prefix %s found in label but no "
1215
- "matching pending file -- trying DB lookup (M2 bridge)",
1216
- nonce_prefix,
1217
- )
1218
- result = activate_db_pending_by_prefix(
1219
- nonce_prefix, current_session_id=session_id,
1220
- )
1221
- if result.success:
1222
- logger.info(
1223
- "AskUserQuestion DB-bridge activation: prefix=%s status=%s",
1224
- nonce_prefix,
1225
- getattr(result.status, "value", str(result.status)),
1226
- )
1227
- return
1228
- else:
1229
- logger.warning(
1230
- "AskUserQuestion DB-bridge activation failed: "
1231
- "prefix=%s status=%s reason=%s -- falling back to session-wide",
1232
- nonce_prefix,
1233
- getattr(result.status, "value", str(result.status)),
1234
- result.reason,
1235
- )
1236
- # Fall through to session-wide activation below
1237
- nonce_prefix = None
1238
-
1239
1169
  if not nonce_prefix:
1240
- # No nonce in label (or all targeted paths failed) -- fall back to
1241
- # session-wide activation for backward compatibility
1242
- pending = get_pending_approvals_for_session(session_id)
1243
- if not pending:
1244
- logger.info("AskUserQuestion: no pending grants for session %s", session_id)
1245
- return
1246
-
1247
- results = activate_grants_for_session(session_id)
1248
- activated = sum(1 for r in results if r.success)
1249
1170
  logger.info(
1250
- "AskUserQuestion session-wide activation: %d/%d pending grants for session %s",
1251
- activated, len(results), session_id,
1171
+ "AskUserQuestion: no nonce prefix in answer labels -- "
1172
+ "nothing to activate for session %s", session_id[:12],
1252
1173
  )
1174
+ return
1175
+
1176
+ # Resolve the approved pending straight from the DB.
1177
+ result = activate_db_pending_by_prefix(
1178
+ nonce_prefix, current_session_id=session_id,
1179
+ )
1180
+ logger.info(
1181
+ "AskUserQuestion DB activation: prefix=%s success=%s status=%s reason=%s",
1182
+ nonce_prefix,
1183
+ result.success,
1184
+ getattr(result.status, "value", str(result.status)),
1185
+ result.reason,
1186
+ )
1253
1187
 
1254
1188
  except Exception as e:
1255
1189
  logger.error("Error in _handle_ask_user_question_result: %s", e, exc_info=True)
@@ -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