@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/gaia/approvals/revert.py
DELETED
|
@@ -1,282 +0,0 @@
|
|
|
1
|
-
"""gaia.approvals.revert -- Inverse-command derivation for approval revert.
|
|
2
|
-
|
|
3
|
-
Per D14 (plan design decision), revert works by querying EXECUTED events
|
|
4
|
-
for an approval, deriving candidate inverse commands using a hardcoded
|
|
5
|
-
best-effort mapping, and presenting them for user confirmation.
|
|
6
|
-
|
|
7
|
-
Public API:
|
|
8
|
-
InverseCommand -- dataclass for a candidate inverse operation
|
|
9
|
-
derive_inverse(event) -> InverseCommand | None
|
|
10
|
-
derive_inverses_for_approval(approval_id, con) -> list[InverseCommand]
|
|
11
|
-
|
|
12
|
-
The InverseCommand dataclass carries:
|
|
13
|
-
event_id: int -- the original EXECUTED event id
|
|
14
|
-
original_command: str -- the original command (from payload or metadata)
|
|
15
|
-
inverse_command: str | None -- the derived inverse, or None if NOT REVERSIBLE
|
|
16
|
-
reversible: bool -- False when no inverse can be derived
|
|
17
|
-
notes: str -- human-readable explanation
|
|
18
|
-
"""
|
|
19
|
-
|
|
20
|
-
from __future__ import annotations
|
|
21
|
-
|
|
22
|
-
import json
|
|
23
|
-
import re
|
|
24
|
-
import sqlite3
|
|
25
|
-
from dataclasses import dataclass, field
|
|
26
|
-
from pathlib import Path
|
|
27
|
-
from typing import Any, Dict, List, Optional
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
@dataclass
|
|
31
|
-
class InverseCommand:
|
|
32
|
-
"""Candidate inverse operation for an EXECUTED approval event."""
|
|
33
|
-
|
|
34
|
-
event_id: int
|
|
35
|
-
original_command: str
|
|
36
|
-
inverse_command: Optional[str]
|
|
37
|
-
reversible: bool
|
|
38
|
-
notes: str
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
# ---------------------------------------------------------------------------
|
|
42
|
-
# Hardcoded verb -> inverse mapping (D14)
|
|
43
|
-
# ---------------------------------------------------------------------------
|
|
44
|
-
|
|
45
|
-
# Pattern-based inverse rules. Each entry is (pattern_re, inverse_template_fn).
|
|
46
|
-
# The pattern is matched against the original command. The template function
|
|
47
|
-
# receives the re.Match object and returns the inverse command string.
|
|
48
|
-
_INVERSE_RULES: list[tuple[re.Pattern, Any]] = []
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def _rule(pattern: str):
|
|
52
|
-
"""Decorator to register an inverse rule."""
|
|
53
|
-
def decorator(fn):
|
|
54
|
-
_INVERSE_RULES.append((re.compile(pattern), fn))
|
|
55
|
-
return fn
|
|
56
|
-
return decorator
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
@_rule(r"^gaia\s+brief\s+set-status\s+(\S+)\s+done\s*$")
|
|
60
|
-
def _brief_done_to_pending(m):
|
|
61
|
-
brief_id = m.group(1)
|
|
62
|
-
return f"gaia brief set-status {brief_id} pending"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
@_rule(r"^gaia\s+brief\s+set-status\s+(\S+)\s+active\s*$")
|
|
66
|
-
def _brief_active_to_draft(m):
|
|
67
|
-
brief_id = m.group(1)
|
|
68
|
-
return f"gaia brief set-status {brief_id} draft"
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
@_rule(r"^gaia\s+brief\s+set-status\s+(\S+)\s+pending\s*$")
|
|
72
|
-
def _brief_pending_to_draft(m):
|
|
73
|
-
brief_id = m.group(1)
|
|
74
|
-
return f"gaia brief set-status {brief_id} draft"
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
@_rule(r"^git\s+branch\s+(\S+)\s*$")
|
|
78
|
-
def _git_branch_create_to_delete(m):
|
|
79
|
-
branch = m.group(1)
|
|
80
|
-
if branch.startswith("-"):
|
|
81
|
-
# Already a delete flag -- not a create
|
|
82
|
-
return None
|
|
83
|
-
return f"git branch -D {branch}"
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
@_rule(r"^git\s+branch\s+-[bB]\s+(\S+)\s*$")
|
|
87
|
-
def _git_branch_b_to_delete(m):
|
|
88
|
-
branch = m.group(1)
|
|
89
|
-
return f"git branch -D {branch}"
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
@_rule(r"^rm\s+(.+)\s*$")
|
|
93
|
-
def _rm_not_reversible(_m):
|
|
94
|
-
# rm has no generic inverse; caller sees NOT REVERSIBLE
|
|
95
|
-
return None
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def _derive_from_command_string(command: str) -> InverseCommand | None:
|
|
99
|
-
"""Apply hardcoded rules to a single command string.
|
|
100
|
-
|
|
101
|
-
Returns an InverseCommand on the first match, or None when no rule matches.
|
|
102
|
-
"""
|
|
103
|
-
cmd = command.strip()
|
|
104
|
-
for pattern, fn in _INVERSE_RULES:
|
|
105
|
-
m = pattern.match(cmd)
|
|
106
|
-
if m:
|
|
107
|
-
inverse = fn(m)
|
|
108
|
-
if inverse is not None:
|
|
109
|
-
return InverseCommand(
|
|
110
|
-
event_id=0,
|
|
111
|
-
original_command=cmd,
|
|
112
|
-
inverse_command=inverse,
|
|
113
|
-
reversible=True,
|
|
114
|
-
notes=f"Derived from pattern: {pattern.pattern}",
|
|
115
|
-
)
|
|
116
|
-
else:
|
|
117
|
-
return InverseCommand(
|
|
118
|
-
event_id=0,
|
|
119
|
-
original_command=cmd,
|
|
120
|
-
inverse_command=None,
|
|
121
|
-
reversible=False,
|
|
122
|
-
notes="NOT REVERSIBLE -- matched pattern has no safe inverse",
|
|
123
|
-
)
|
|
124
|
-
return None
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def _is_file_create(payload: dict, original_cmd: str) -> bool:
|
|
128
|
-
"""Heuristic: detect if the payload represents a file creation."""
|
|
129
|
-
commands = payload.get("commands") or []
|
|
130
|
-
scope = payload.get("scope") or ""
|
|
131
|
-
# A Write tool on a new path is stored with 'write' or 'create' in operation.
|
|
132
|
-
operation = (payload.get("operation") or "").lower()
|
|
133
|
-
if "write" in operation or "create" in operation:
|
|
134
|
-
return True
|
|
135
|
-
# If the scope looks like a file path and command is a write-like verb
|
|
136
|
-
if scope and ("write" in original_cmd.lower() or "create" in original_cmd.lower()):
|
|
137
|
-
return True
|
|
138
|
-
return False
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def derive_inverse(event: Dict[str, Any]) -> InverseCommand:
|
|
142
|
-
"""Derive a candidate inverse command for a single EXECUTED event.
|
|
143
|
-
|
|
144
|
-
Best-effort approach per D14:
|
|
145
|
-
- Tries hardcoded verb patterns first.
|
|
146
|
-
- Detects file create -> suggests rm <path>.
|
|
147
|
-
- Falls through to "NOT REVERSIBLE" with original command for reference.
|
|
148
|
-
|
|
149
|
-
Args:
|
|
150
|
-
event: A dict from store.replay_for_approval() representing an
|
|
151
|
-
EXECUTED event row. Must have 'id', 'payload_json', and
|
|
152
|
-
optionally 'metadata_json'.
|
|
153
|
-
|
|
154
|
-
Returns:
|
|
155
|
-
InverseCommand with reversible=True if an inverse was derived, or
|
|
156
|
-
reversible=False with inverse_command=None and a "NOT REVERSIBLE"
|
|
157
|
-
note.
|
|
158
|
-
"""
|
|
159
|
-
event_id = event.get("id", 0)
|
|
160
|
-
payload_json = event.get("payload_json") or ""
|
|
161
|
-
metadata_json = event.get("metadata_json") or ""
|
|
162
|
-
|
|
163
|
-
payload: dict = {}
|
|
164
|
-
if payload_json:
|
|
165
|
-
try:
|
|
166
|
-
payload = json.loads(payload_json)
|
|
167
|
-
except (json.JSONDecodeError, TypeError):
|
|
168
|
-
pass
|
|
169
|
-
|
|
170
|
-
metadata: dict = {}
|
|
171
|
-
if metadata_json:
|
|
172
|
-
try:
|
|
173
|
-
metadata = json.loads(metadata_json)
|
|
174
|
-
except (json.JSONDecodeError, TypeError):
|
|
175
|
-
pass
|
|
176
|
-
|
|
177
|
-
# Collect commands to try inverting.
|
|
178
|
-
commands = payload.get("commands") or []
|
|
179
|
-
exact_content = payload.get("exact_content") or ""
|
|
180
|
-
|
|
181
|
-
# Build list of individual command strings to invert.
|
|
182
|
-
cmd_strings: list[str] = []
|
|
183
|
-
if commands:
|
|
184
|
-
cmd_strings = [str(c).strip() for c in commands if c]
|
|
185
|
-
elif exact_content:
|
|
186
|
-
# Split newline-separated commands per D13.
|
|
187
|
-
cmd_strings = [l.strip() for l in exact_content.splitlines() if l.strip()]
|
|
188
|
-
|
|
189
|
-
if not cmd_strings:
|
|
190
|
-
# No commands found -- check operation field.
|
|
191
|
-
operation = payload.get("operation") or ""
|
|
192
|
-
if operation:
|
|
193
|
-
cmd_strings = [operation]
|
|
194
|
-
|
|
195
|
-
if not cmd_strings:
|
|
196
|
-
return InverseCommand(
|
|
197
|
-
event_id=event_id,
|
|
198
|
-
original_command="(no command recorded)",
|
|
199
|
-
inverse_command=None,
|
|
200
|
-
reversible=False,
|
|
201
|
-
notes="NOT REVERSIBLE -- no command data found in event payload",
|
|
202
|
-
)
|
|
203
|
-
|
|
204
|
-
# For multi-command events, try to invert each command.
|
|
205
|
-
# If ALL have inverses, return a compound inverse. If any fails, NOT REVERSIBLE.
|
|
206
|
-
inverses = []
|
|
207
|
-
original_summary = "; ".join(cmd_strings)
|
|
208
|
-
|
|
209
|
-
for cmd in cmd_strings:
|
|
210
|
-
result = _derive_from_command_string(cmd)
|
|
211
|
-
if result is None:
|
|
212
|
-
# No rule matched -- check for file create heuristic.
|
|
213
|
-
scope = payload.get("scope") or ""
|
|
214
|
-
if scope and _is_file_create(payload, cmd):
|
|
215
|
-
# Suggest rm <scope> as the inverse.
|
|
216
|
-
scope_path = scope.strip()
|
|
217
|
-
inverses.append(
|
|
218
|
-
InverseCommand(
|
|
219
|
-
event_id=event_id,
|
|
220
|
-
original_command=cmd,
|
|
221
|
-
inverse_command=f"rm {scope_path}",
|
|
222
|
-
reversible=True,
|
|
223
|
-
notes=f"File create detected -- inverse is rm {scope_path} (requires confirm)",
|
|
224
|
-
)
|
|
225
|
-
)
|
|
226
|
-
else:
|
|
227
|
-
return InverseCommand(
|
|
228
|
-
event_id=event_id,
|
|
229
|
-
original_command=original_summary,
|
|
230
|
-
inverse_command=None,
|
|
231
|
-
reversible=False,
|
|
232
|
-
notes=f"NOT REVERSIBLE -- no inverse rule matches: {cmd!r}",
|
|
233
|
-
)
|
|
234
|
-
else:
|
|
235
|
-
result.event_id = event_id
|
|
236
|
-
inverses.append(result)
|
|
237
|
-
|
|
238
|
-
if len(inverses) == 1:
|
|
239
|
-
return inverses[0]
|
|
240
|
-
|
|
241
|
-
# Multiple inverses -- combine into a compound inverse.
|
|
242
|
-
combined_inverse = " && ".join(ic.inverse_command for ic in inverses if ic.inverse_command)
|
|
243
|
-
combined_notes = "; ".join(ic.notes for ic in inverses)
|
|
244
|
-
return InverseCommand(
|
|
245
|
-
event_id=event_id,
|
|
246
|
-
original_command=original_summary,
|
|
247
|
-
inverse_command=combined_inverse if combined_inverse else None,
|
|
248
|
-
reversible=bool(combined_inverse),
|
|
249
|
-
notes=combined_notes,
|
|
250
|
-
)
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
def derive_inverses_for_approval(
|
|
254
|
-
approval_id: str,
|
|
255
|
-
con: sqlite3.Connection,
|
|
256
|
-
) -> List[InverseCommand]:
|
|
257
|
-
"""Return a list of InverseCommand for all EXECUTED events of an approval.
|
|
258
|
-
|
|
259
|
-
Args:
|
|
260
|
-
approval_id: The P-{uuid4} approval identifier.
|
|
261
|
-
con: An open sqlite3.Connection.
|
|
262
|
-
|
|
263
|
-
Returns:
|
|
264
|
-
List of InverseCommand, one per EXECUTED event, in insertion order.
|
|
265
|
-
Empty list if no EXECUTED events exist.
|
|
266
|
-
"""
|
|
267
|
-
cur = con.execute(
|
|
268
|
-
"SELECT id, payload_json, metadata_json FROM approval_events "
|
|
269
|
-
"WHERE approval_id = ? AND event_type = 'EXECUTED' "
|
|
270
|
-
"ORDER BY id ASC",
|
|
271
|
-
(approval_id,),
|
|
272
|
-
)
|
|
273
|
-
rows = cur.fetchall()
|
|
274
|
-
result = []
|
|
275
|
-
for row in rows:
|
|
276
|
-
event = {
|
|
277
|
-
"id": row[0],
|
|
278
|
-
"payload_json": row[1],
|
|
279
|
-
"metadata_json": row[2],
|
|
280
|
-
}
|
|
281
|
-
result.append(derive_inverse(event))
|
|
282
|
-
return result
|
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Deep merge utility for project-context.json updates.
|
|
3
|
-
|
|
4
|
-
Merges two dicts recursively following the gaia-ops merge decision tree:
|
|
5
|
-
1. Key missing in current -> ADD
|
|
6
|
-
2. Both values are dicts -> RECURSE (deep merge)
|
|
7
|
-
3. Both values are lists -> UNION (primitives: sorted set union;
|
|
8
|
-
dicts with "name": merge by name;
|
|
9
|
-
other dicts: concatenate + deduplicate)
|
|
10
|
-
4. Both values are scalars -> OVERWRITE (new replaces old)
|
|
11
|
-
5. Type mismatch -> OVERWRITE with warning
|
|
12
|
-
|
|
13
|
-
No-Delete Policy: keys in current but NOT in update are always preserved.
|
|
14
|
-
"""
|
|
15
|
-
|
|
16
|
-
import copy
|
|
17
|
-
import json
|
|
18
|
-
import logging
|
|
19
|
-
|
|
20
|
-
logger = logging.getLogger(__name__)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def deep_merge(current: dict, update: dict) -> tuple[dict, dict]:
|
|
24
|
-
"""Merge *update* into *current* returning ``(merged, diff)``.
|
|
25
|
-
|
|
26
|
-
Parameters
|
|
27
|
-
----------
|
|
28
|
-
current:
|
|
29
|
-
The existing data (will NOT be mutated).
|
|
30
|
-
update:
|
|
31
|
-
New data to merge on top of *current*.
|
|
32
|
-
|
|
33
|
-
Returns
|
|
34
|
-
-------
|
|
35
|
-
tuple[dict, dict]
|
|
36
|
-
``merged`` – the result of the merge.
|
|
37
|
-
``diff`` – audit trail recording changes (``{key: {old, new}}``).
|
|
38
|
-
"""
|
|
39
|
-
merged = copy.deepcopy(current)
|
|
40
|
-
diff: dict = {}
|
|
41
|
-
|
|
42
|
-
for key, new_value in update.items():
|
|
43
|
-
if key not in merged:
|
|
44
|
-
# Rule 1: ADD missing key
|
|
45
|
-
merged[key] = copy.deepcopy(new_value)
|
|
46
|
-
continue
|
|
47
|
-
|
|
48
|
-
old_value = merged[key]
|
|
49
|
-
|
|
50
|
-
# Rule 2: Both dicts -> recurse
|
|
51
|
-
if isinstance(old_value, dict) and isinstance(new_value, dict):
|
|
52
|
-
sub_merged, sub_diff = deep_merge(old_value, new_value)
|
|
53
|
-
merged[key] = sub_merged
|
|
54
|
-
if sub_diff:
|
|
55
|
-
diff[key] = sub_diff
|
|
56
|
-
continue
|
|
57
|
-
|
|
58
|
-
# Rule 3: Both lists -> union strategy
|
|
59
|
-
if isinstance(old_value, list) and isinstance(new_value, list):
|
|
60
|
-
merged_list = _merge_lists(old_value, new_value)
|
|
61
|
-
if merged_list != old_value:
|
|
62
|
-
diff[key] = {"old": old_value, "new": merged_list}
|
|
63
|
-
merged[key] = merged_list
|
|
64
|
-
continue
|
|
65
|
-
|
|
66
|
-
# Rule 5: Type mismatch -> overwrite with warning
|
|
67
|
-
if type(old_value) is not type(new_value):
|
|
68
|
-
logger.warning(
|
|
69
|
-
"Type mismatch for key '%s': %s -> %s. New value wins.",
|
|
70
|
-
key,
|
|
71
|
-
type(old_value).__name__,
|
|
72
|
-
type(new_value).__name__,
|
|
73
|
-
)
|
|
74
|
-
diff[key] = {"old": old_value, "new": new_value}
|
|
75
|
-
merged[key] = copy.deepcopy(new_value)
|
|
76
|
-
continue
|
|
77
|
-
|
|
78
|
-
# Rule 4: Both scalars -> overwrite
|
|
79
|
-
if old_value != new_value:
|
|
80
|
-
diff[key] = {"old": old_value, "new": new_value}
|
|
81
|
-
merged[key] = copy.deepcopy(new_value)
|
|
82
|
-
|
|
83
|
-
return merged, diff
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
# ---------------------------------------------------------------------------
|
|
87
|
-
# List merge helpers
|
|
88
|
-
# ---------------------------------------------------------------------------
|
|
89
|
-
|
|
90
|
-
def _merge_lists(current: list, update: list) -> list:
|
|
91
|
-
"""Merge two lists following the union strategy.
|
|
92
|
-
|
|
93
|
-
a) All items are primitives (str, int, float, bool) -> sorted set union.
|
|
94
|
-
b) Items are dicts with a ``"name"`` key -> merge by name, preserve missing.
|
|
95
|
-
c) Otherwise -> concatenate, deduplicate by JSON equality.
|
|
96
|
-
"""
|
|
97
|
-
if _all_primitives(current) and _all_primitives(update):
|
|
98
|
-
return sorted(set(current) | set(update))
|
|
99
|
-
|
|
100
|
-
if _all_dicts_with_name(current) and _all_dicts_with_name(update):
|
|
101
|
-
return _merge_named_dicts(current, update)
|
|
102
|
-
|
|
103
|
-
# Fallback: concatenate + deduplicate by JSON equality
|
|
104
|
-
return _concat_deduplicate(current, update)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def _all_primitives(items: list) -> bool:
|
|
108
|
-
"""Return True if every item is a primitive (str, int, float, bool)."""
|
|
109
|
-
return all(isinstance(i, (str, int, float, bool)) for i in items)
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
def _all_dicts_with_name(items: list) -> bool:
|
|
113
|
-
"""Return True if every item is a dict containing a ``"name"`` key."""
|
|
114
|
-
return bool(items) and all(
|
|
115
|
-
isinstance(i, dict) and "name" in i for i in items
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def _merge_named_dicts(current: list[dict], update: list[dict]) -> list[dict]:
|
|
120
|
-
"""Merge lists of dicts by their ``"name"`` field.
|
|
121
|
-
|
|
122
|
-
- Matching names: deep-merge the dict fields.
|
|
123
|
-
- Names only in current: preserved (no-delete).
|
|
124
|
-
- Names only in update: appended.
|
|
125
|
-
"""
|
|
126
|
-
result_by_name: dict[str, dict] = {}
|
|
127
|
-
order: list[str] = []
|
|
128
|
-
|
|
129
|
-
# Seed with current entries (preserves order + no-delete)
|
|
130
|
-
for item in current:
|
|
131
|
-
name = item["name"]
|
|
132
|
-
result_by_name[name] = copy.deepcopy(item)
|
|
133
|
-
order.append(name)
|
|
134
|
-
|
|
135
|
-
# Merge / add from update
|
|
136
|
-
for item in update:
|
|
137
|
-
name = item["name"]
|
|
138
|
-
if name in result_by_name:
|
|
139
|
-
merged_item, _ = deep_merge(result_by_name[name], item)
|
|
140
|
-
result_by_name[name] = merged_item
|
|
141
|
-
else:
|
|
142
|
-
result_by_name[name] = copy.deepcopy(item)
|
|
143
|
-
order.append(name)
|
|
144
|
-
|
|
145
|
-
return [result_by_name[n] for n in order]
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
def _concat_deduplicate(current: list, update: list) -> list:
|
|
149
|
-
"""Concatenate two lists, deduplicating by JSON equality."""
|
|
150
|
-
seen: list[str] = []
|
|
151
|
-
result: list = []
|
|
152
|
-
|
|
153
|
-
for item in current + update:
|
|
154
|
-
serialized = json.dumps(item, sort_keys=True)
|
|
155
|
-
if serialized not in seen:
|
|
156
|
-
seen.append(serialized)
|
|
157
|
-
result.append(copy.deepcopy(item))
|
|
158
|
-
|
|
159
|
-
return result
|
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
migrate_04_harness_events.py
|
|
4
|
-
|
|
5
|
-
Convierte events.jsonl -> archivo SQL con INSERT batched.
|
|
6
|
-
|
|
7
|
-
Reglas:
|
|
8
|
-
- Solo I/O sobre filesystem.
|
|
9
|
-
- NO importa sqlite3.
|
|
10
|
-
- `id` es AUTOINCREMENT en la tabla; NO lo insertamos.
|
|
11
|
-
- `payload` = json.dumps(record) entero -- preserva todos los campos.
|
|
12
|
-
- Idempotencia: harness_events NO tiene UNIQUE constraint útil.
|
|
13
|
-
Aplicar una sola vez. Re-ejecuciones requieren DELETE WHERE project=...
|
|
14
|
-
|
|
15
|
-
CLI args (parametrización cross-workspace):
|
|
16
|
-
--project workspace name (default: 'me')
|
|
17
|
-
--src path al events.jsonl (default: ws/me)
|
|
18
|
-
--out path al SQL de salida (default: /tmp/migrate_04_harness_events.sql)
|
|
19
|
-
--fragment emite solo INSERTs (sin BEGIN/COMMIT)
|
|
20
|
-
"""
|
|
21
|
-
from __future__ import annotations
|
|
22
|
-
|
|
23
|
-
import argparse
|
|
24
|
-
import json
|
|
25
|
-
import sys
|
|
26
|
-
from pathlib import Path
|
|
27
|
-
|
|
28
|
-
DEFAULT_PROJECT = "me"
|
|
29
|
-
DEFAULT_SRC = Path("/home/jorge/ws/me/.claude/events/events.jsonl")
|
|
30
|
-
DEFAULT_OUT = Path("/tmp/migrate_04_harness_events.sql")
|
|
31
|
-
BATCH_SIZE = 200
|
|
32
|
-
|
|
33
|
-
COLUMNS = ["project", "ts", "type", "source", "agent", "result", "severity", "payload"]
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def sql_quote(value) -> str:
|
|
37
|
-
if value is None:
|
|
38
|
-
return "NULL"
|
|
39
|
-
if isinstance(value, bool):
|
|
40
|
-
return "1" if value else "0"
|
|
41
|
-
if isinstance(value, (int, float)):
|
|
42
|
-
if isinstance(value, float) and (value != value or value in (float("inf"), float("-inf"))):
|
|
43
|
-
return "NULL"
|
|
44
|
-
return str(value)
|
|
45
|
-
s = str(value)
|
|
46
|
-
return "'" + s.replace("'", "''") + "'"
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def extract_row(record: dict, project: str) -> dict:
|
|
50
|
-
return {
|
|
51
|
-
"project": project,
|
|
52
|
-
"ts": record.get("ts"),
|
|
53
|
-
"type": record.get("type"),
|
|
54
|
-
"source": record.get("source"),
|
|
55
|
-
"agent": record.get("agent"),
|
|
56
|
-
"result": record.get("result"),
|
|
57
|
-
"severity": record.get("severity"),
|
|
58
|
-
"payload": json.dumps(record, ensure_ascii=False, separators=(",", ":")),
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def row_values_sql(row: dict) -> str:
|
|
63
|
-
return "(" + ",".join(sql_quote(row.get(col)) for col in COLUMNS) + ")"
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def main() -> int:
|
|
67
|
-
parser = argparse.ArgumentParser(description="Generate INSERT SQL for harness_events table.")
|
|
68
|
-
parser.add_argument("--project", default=DEFAULT_PROJECT)
|
|
69
|
-
parser.add_argument("--src", default=str(DEFAULT_SRC), help="path to events.jsonl")
|
|
70
|
-
parser.add_argument("--out", default=str(DEFAULT_OUT))
|
|
71
|
-
parser.add_argument("--fragment", action="store_true")
|
|
72
|
-
args = parser.parse_args()
|
|
73
|
-
|
|
74
|
-
project = args.project
|
|
75
|
-
src = Path(args.src)
|
|
76
|
-
out = Path(args.out)
|
|
77
|
-
fragment = args.fragment
|
|
78
|
-
|
|
79
|
-
if not src.exists():
|
|
80
|
-
print(f"[migrate_04:{project}] ERROR: source not found: {src}", file=sys.stderr)
|
|
81
|
-
return 1
|
|
82
|
-
|
|
83
|
-
rows = []
|
|
84
|
-
skipped = 0
|
|
85
|
-
total_lines = 0
|
|
86
|
-
|
|
87
|
-
with src.open("r", encoding="utf-8") as f:
|
|
88
|
-
for line in f:
|
|
89
|
-
total_lines += 1
|
|
90
|
-
s = line.strip()
|
|
91
|
-
if not s:
|
|
92
|
-
continue
|
|
93
|
-
try:
|
|
94
|
-
rec = json.loads(s)
|
|
95
|
-
except json.JSONDecodeError:
|
|
96
|
-
skipped += 1
|
|
97
|
-
continue
|
|
98
|
-
if not rec.get("ts") or not rec.get("type"):
|
|
99
|
-
skipped += 1
|
|
100
|
-
continue
|
|
101
|
-
rows.append(extract_row(rec, project))
|
|
102
|
-
|
|
103
|
-
cols_csv = ",".join(COLUMNS)
|
|
104
|
-
insert_prefix = f"INSERT INTO harness_events ({cols_csv}) VALUES\n"
|
|
105
|
-
|
|
106
|
-
with out.open("w", encoding="utf-8") as fh:
|
|
107
|
-
fh.write(f"-- Generated by migrate_04_harness_events.py\n")
|
|
108
|
-
fh.write(f"-- Project: {project}\n")
|
|
109
|
-
fh.write(f"-- Source: {src}\n")
|
|
110
|
-
fh.write(f"-- Total source lines: {total_lines}\n")
|
|
111
|
-
fh.write(f"-- Records to insert: {len(rows)}\n")
|
|
112
|
-
fh.write(f"-- Skipped: {skipped}\n")
|
|
113
|
-
fh.write("--\n")
|
|
114
|
-
fh.write("-- WARNING: harness_events sin PK natural; aplicar 2 veces duplica filas.\n")
|
|
115
|
-
if not fragment:
|
|
116
|
-
fh.write("BEGIN TRANSACTION;\n")
|
|
117
|
-
|
|
118
|
-
for i in range(0, len(rows), BATCH_SIZE):
|
|
119
|
-
batch = rows[i : i + BATCH_SIZE]
|
|
120
|
-
fh.write(insert_prefix)
|
|
121
|
-
fh.write(",\n".join(row_values_sql(r) for r in batch))
|
|
122
|
-
fh.write(";\n")
|
|
123
|
-
|
|
124
|
-
if not fragment:
|
|
125
|
-
fh.write("COMMIT;\n")
|
|
126
|
-
|
|
127
|
-
print(f"[migrate_04:{project}] wrote {out} ({len(rows)} rows, {skipped} skipped)")
|
|
128
|
-
return 0
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if __name__ == "__main__":
|
|
132
|
-
sys.exit(main())
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# migrate_04_harness_events.sh
|
|
3
|
-
# Wrapper: regenera el .sql desde events.jsonl y lo carga en ~/.gaia/gaia.db.
|
|
4
|
-
#
|
|
5
|
-
# OJO: harness_events no tiene PK natural. Re-ejecutar este wrapper duplica
|
|
6
|
-
# filas. Si necesitas re-ejecutar limpio, primero elimina las filas con:
|
|
7
|
-
# sqlite3 ~/.gaia/gaia.db "DELETE FROM harness_events WHERE project='me';"
|
|
8
|
-
set -euo pipefail
|
|
9
|
-
|
|
10
|
-
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
11
|
-
PY_SCRIPT="${HERE}/migrate_04_harness_events.py"
|
|
12
|
-
SQL_FILE="/tmp/migrate_04_harness_events.sql"
|
|
13
|
-
DB_PATH="${HOME}/.gaia/gaia.db"
|
|
14
|
-
|
|
15
|
-
# Paso 1: regenerar el .sql.
|
|
16
|
-
echo "[migrate_04] regenerando ${SQL_FILE} ..."
|
|
17
|
-
python3 "${PY_SCRIPT}"
|
|
18
|
-
|
|
19
|
-
# Paso 2: aplicar el SQL (interceptado por el hook).
|
|
20
|
-
echo "[migrate_04] aplicando ${SQL_FILE} en ${DB_PATH} ..."
|
|
21
|
-
sqlite3 "${DB_PATH}" < "${SQL_FILE}"
|
|
22
|
-
|
|
23
|
-
echo "[migrate_04] OK"
|