@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +13 -0
- package/bin/README.md +6 -1
- package/bin/cli/approvals.py +486 -474
- package/bin/cli/brief.py +13 -0
- package/bin/cli/doctor.py +1 -1
- package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
- package/dist/gaia-ops/hooks/adapters/claude_code.py +92 -86
- package/dist/gaia-ops/hooks/modules/agents/handoff_persister.py +13 -2
- package/dist/gaia-ops/hooks/modules/context/context_injector.py +23 -7
- package/dist/gaia-ops/hooks/modules/events/event_writer.py +63 -96
- package/dist/gaia-ops/hooks/modules/security/__init__.py +0 -2
- package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +238 -69
- package/dist/gaia-ops/hooks/modules/security/approval_grants.py +506 -1103
- package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +24 -1
- package/dist/gaia-ops/hooks/modules/session/pending_scanner.py +150 -90
- package/dist/gaia-ops/hooks/modules/session/session_manifest.py +257 -28
- package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +19 -0
- package/dist/gaia-ops/hooks/post_compact.py +1 -0
- package/dist/gaia-ops/hooks/pre_compact.py +1 -0
- package/dist/gaia-ops/hooks/user_prompt_submit.py +20 -0
- package/dist/gaia-ops/skills/agent-approval-protocol/SKILL.md +50 -14
- package/dist/gaia-ops/skills/agent-approval-protocol/reference.md +16 -9
- package/dist/gaia-ops/skills/agent-protocol/examples.md +12 -1
- package/dist/gaia-ops/skills/gaia-patterns/reference.md +2 -2
- package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +69 -22
- package/dist/gaia-ops/skills/orchestrator-present-approval/reference.md +16 -3
- package/dist/gaia-ops/skills/orchestrator-present-approval/template.md +20 -14
- package/dist/gaia-ops/skills/pending-approvals/SKILL.md +16 -11
- package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +28 -3
- package/dist/gaia-ops/skills/subagent-request-approval/reference.md +34 -8
- package/dist/gaia-ops/tools/migration/README.md +10 -12
- package/dist/gaia-ops/tools/scan/orchestrator.py +194 -10
- package/dist/gaia-ops/tools/scan/tests/test_integration.py +1 -2
- package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
- package/dist/gaia-security/hooks/adapters/claude_code.py +92 -86
- package/dist/gaia-security/hooks/modules/agents/handoff_persister.py +13 -2
- package/dist/gaia-security/hooks/modules/context/context_injector.py +23 -7
- package/dist/gaia-security/hooks/modules/events/event_writer.py +63 -96
- package/dist/gaia-security/hooks/modules/security/__init__.py +0 -2
- package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +238 -69
- package/dist/gaia-security/hooks/modules/security/approval_grants.py +506 -1103
- package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +24 -1
- package/dist/gaia-security/hooks/modules/session/pending_scanner.py +150 -90
- package/dist/gaia-security/hooks/modules/session/session_manifest.py +257 -28
- package/dist/gaia-security/hooks/modules/tools/bash_validator.py +19 -0
- package/dist/gaia-security/hooks/user_prompt_submit.py +20 -0
- package/gaia/approvals/__init__.py +2 -1
- package/gaia/approvals/store.py +165 -15
- package/gaia/store/schema.sql +38 -1
- package/gaia/store/writer.py +400 -0
- package/hooks/adapters/claude_code.py +92 -86
- package/hooks/elicitation_result.py +20 -75
- package/hooks/modules/agents/handoff_persister.py +13 -2
- package/hooks/modules/context/context_injector.py +23 -7
- package/hooks/modules/events/event_writer.py +63 -96
- package/hooks/modules/security/__init__.py +0 -2
- package/hooks/modules/security/approval_cleanup.py +238 -69
- package/hooks/modules/security/approval_grants.py +506 -1103
- package/hooks/modules/security/mutative_verbs.py +24 -1
- package/hooks/modules/session/pending_scanner.py +150 -90
- package/hooks/modules/session/session_manifest.py +257 -28
- package/hooks/modules/tools/bash_validator.py +19 -0
- package/hooks/post_compact.py +1 -0
- package/hooks/pre_compact.py +1 -0
- package/hooks/user_prompt_submit.py +20 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/bootstrap_database.sh +66 -17
- package/scripts/migrations/README.md +26 -14
- package/scripts/migrations/schema.checksum +2 -2
- package/scripts/migrations/v18_to_v19.sql +36 -0
- package/scripts/migrations/v19_to_v20.sql +20 -0
- package/skills/agent-approval-protocol/SKILL.md +50 -14
- package/skills/agent-approval-protocol/reference.md +16 -9
- package/skills/agent-protocol/examples.md +12 -1
- package/skills/gaia-patterns/reference.md +2 -2
- package/skills/orchestrator-present-approval/SKILL.md +69 -22
- package/skills/orchestrator-present-approval/reference.md +16 -3
- package/skills/orchestrator-present-approval/template.md +20 -14
- package/skills/pending-approvals/SKILL.md +16 -11
- package/skills/subagent-request-approval/SKILL.md +28 -3
- package/skills/subagent-request-approval/reference.md +34 -8
- package/tools/migration/README.md +10 -12
- package/tools/scan/orchestrator.py +194 -10
- package/tools/scan/tests/test_integration.py +1 -2
- package/bin/cli/plans.py +0 -517
- package/dist/gaia-ops/tools/context/deep_merge.py +0 -159
- package/dist/gaia-ops/tools/migration/migrate_04_harness_events.py +0 -132
- package/dist/gaia-ops/tools/migration/migrate_04_harness_events.sh +0 -23
- package/dist/gaia-ops/tools/scan/merge.py +0 -213
- package/dist/gaia-ops/tools/scan/tests/test_merge.py +0 -269
- package/gaia/approvals/revert.py +0 -282
- package/tools/context/deep_merge.py +0 -159
- package/tools/migration/migrate_04_harness_events.py +0 -132
- package/tools/migration/migrate_04_harness_events.sh +0 -23
- package/tools/scan/merge.py +0 -213
- 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 =
|
|
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.
|
|
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
|
-
|
|
1049
|
-
the
|
|
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
|
-
#
|
|
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
|
|
1179
|
-
|
|
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
|
-
|
|
456
|
-
|
|
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("
|
|
461
|
-
etype = evt.get("type"
|
|
462
|
-
agent_name = evt.get("agent"
|
|
463
|
-
result_str = evt.get("
|
|
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
|
|
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:
|
|
5
|
-
- read_events():
|
|
6
|
-
|
|
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
|
-
"""
|
|
64
|
+
"""Non-blocking DB event writer.
|
|
37
65
|
|
|
38
|
-
All writes are wrapped in try/except -- events are non-critical and
|
|
39
|
-
|
|
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
|
|
86
|
+
"""Append a single event to the ``harness_events`` DB table.
|
|
57
87
|
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|