@jaguilar87/gaia 5.0.10 → 5.0.11

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 (88) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +15 -0
  4. package/bin/cli/ac.py +2 -2
  5. package/bin/cli/brief.py +10 -3
  6. package/bin/cli/doctor.py +1 -1
  7. package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
  8. package/dist/gaia-ops/hooks/adapters/__init__.py +12 -2
  9. package/dist/gaia-ops/hooks/adapters/base.py +122 -5
  10. package/dist/gaia-ops/hooks/adapters/claude_code.py +175 -53
  11. package/dist/gaia-ops/hooks/adapters/host_session.py +53 -0
  12. package/dist/gaia-ops/hooks/adapters/host_transcript.py +75 -0
  13. package/dist/gaia-ops/hooks/adapters/registry.py +87 -0
  14. package/dist/gaia-ops/hooks/adapters/types.py +134 -6
  15. package/dist/gaia-ops/hooks/modules/agents/transcript_reader.py +34 -71
  16. package/dist/gaia-ops/hooks/modules/core/hook_entry.py +6 -4
  17. package/dist/gaia-ops/hooks/modules/core/state.py +12 -10
  18. package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +2 -2
  19. package/dist/gaia-ops/hooks/modules/security/approval_grants.py +7 -7
  20. package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +4 -3
  21. package/dist/gaia-ops/hooks/modules/session/pending_scanner.py +4 -3
  22. package/dist/gaia-ops/hooks/modules/session/session_manager.py +6 -15
  23. package/dist/gaia-ops/hooks/modules/session/session_manifest.py +3 -3
  24. package/dist/gaia-ops/hooks/modules/session/session_registry.py +3 -3
  25. package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +14 -12
  26. package/dist/gaia-ops/hooks/modules/tools/hook_response.py +14 -12
  27. package/dist/gaia-ops/hooks/post_tool_use.py +2 -2
  28. package/dist/gaia-ops/hooks/pre_tool_use.py +9 -8
  29. package/dist/gaia-ops/hooks/stop_hook.py +2 -2
  30. package/dist/gaia-ops/hooks/subagent_start.py +2 -2
  31. package/dist/gaia-ops/hooks/subagent_stop.py +2 -2
  32. package/dist/gaia-ops/hooks/task_completed.py +2 -2
  33. package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
  34. package/dist/gaia-security/hooks/adapters/__init__.py +12 -2
  35. package/dist/gaia-security/hooks/adapters/base.py +122 -5
  36. package/dist/gaia-security/hooks/adapters/claude_code.py +175 -53
  37. package/dist/gaia-security/hooks/adapters/host_session.py +53 -0
  38. package/dist/gaia-security/hooks/adapters/host_transcript.py +75 -0
  39. package/dist/gaia-security/hooks/adapters/registry.py +87 -0
  40. package/dist/gaia-security/hooks/adapters/types.py +134 -6
  41. package/dist/gaia-security/hooks/modules/agents/transcript_reader.py +34 -71
  42. package/dist/gaia-security/hooks/modules/core/hook_entry.py +6 -4
  43. package/dist/gaia-security/hooks/modules/core/state.py +12 -10
  44. package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +2 -2
  45. package/dist/gaia-security/hooks/modules/security/approval_grants.py +7 -7
  46. package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +4 -3
  47. package/dist/gaia-security/hooks/modules/session/pending_scanner.py +4 -3
  48. package/dist/gaia-security/hooks/modules/session/session_manager.py +6 -15
  49. package/dist/gaia-security/hooks/modules/session/session_manifest.py +3 -3
  50. package/dist/gaia-security/hooks/modules/session/session_registry.py +3 -3
  51. package/dist/gaia-security/hooks/modules/tools/bash_validator.py +14 -12
  52. package/dist/gaia-security/hooks/modules/tools/hook_response.py +14 -12
  53. package/dist/gaia-security/hooks/post_tool_use.py +2 -2
  54. package/dist/gaia-security/hooks/pre_tool_use.py +9 -8
  55. package/dist/gaia-security/hooks/stop_hook.py +2 -2
  56. package/gaia/briefs/store.py +53 -1
  57. package/gaia/state/__init__.py +8 -1
  58. package/gaia/state/transitions.py +18 -4
  59. package/gaia/store/schema.sql +5 -1
  60. package/hooks/adapters/__init__.py +12 -2
  61. package/hooks/adapters/base.py +122 -5
  62. package/hooks/adapters/claude_code.py +175 -53
  63. package/hooks/adapters/host_session.py +53 -0
  64. package/hooks/adapters/host_transcript.py +75 -0
  65. package/hooks/adapters/registry.py +87 -0
  66. package/hooks/adapters/types.py +134 -6
  67. package/hooks/modules/agents/transcript_reader.py +34 -71
  68. package/hooks/modules/core/hook_entry.py +6 -4
  69. package/hooks/modules/core/state.py +12 -10
  70. package/hooks/modules/security/approval_cleanup.py +2 -2
  71. package/hooks/modules/security/approval_grants.py +7 -7
  72. package/hooks/modules/security/mutative_verbs.py +4 -3
  73. package/hooks/modules/session/pending_scanner.py +4 -3
  74. package/hooks/modules/session/session_manager.py +6 -15
  75. package/hooks/modules/session/session_manifest.py +3 -3
  76. package/hooks/modules/session/session_registry.py +3 -3
  77. package/hooks/modules/tools/bash_validator.py +14 -12
  78. package/hooks/modules/tools/hook_response.py +14 -12
  79. package/hooks/post_tool_use.py +2 -2
  80. package/hooks/pre_tool_use.py +9 -8
  81. package/hooks/stop_hook.py +2 -2
  82. package/hooks/subagent_start.py +2 -2
  83. package/hooks/subagent_stop.py +2 -2
  84. package/hooks/task_completed.py +2 -2
  85. package/package.json +1 -1
  86. package/pyproject.toml +1 -1
  87. package/scripts/migrations/schema.checksum +2 -2
  88. package/scripts/migrations/v20_to_v21.sql +68 -0
@@ -0,0 +1,53 @@
1
+ """
2
+ Host Session ID Access for Gaia-Ops.
3
+
4
+ Adapter-owned utility that encapsulates how the host CLI exposes the current
5
+ session identifier via the environment. The host-specific env var name lives
6
+ ONLY here (inside ``hooks/adapters/``); business logic modules call these
7
+ helpers instead of reading the environment directly, so the core stays
8
+ agnostic to the host CLI's session-propagation convention.
9
+
10
+ Mirrors the ``channel.py`` pattern: a small standalone module under
11
+ ``adapters/`` that reads a host env var and is imported by business logic,
12
+ with no dependency on the heavier ``ClaudeCodeAdapter`` (avoids any
13
+ circular-import or instantiation concern in low-level modules).
14
+ """
15
+
16
+ import hashlib
17
+ import logging
18
+ import os
19
+ from datetime import datetime
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # The environment variable the host CLI (Claude Code) uses to advertise the
24
+ # current session id. Confined to this adapter module by design.
25
+ _HOST_SESSION_ENV_VAR = "CLAUDE_SESSION_ID"
26
+
27
+
28
+ def read_host_session_id(default: str = "default") -> str:
29
+ """Return the host session id from the environment, or ``default``.
30
+
31
+ The host CLI does not guarantee the session env var is exported into a
32
+ hook subprocess. Callers that have the parsed stdin event in hand should
33
+ prefer the event's ``session_id`` and use this only as a fallback.
34
+ """
35
+ return os.environ.get(_HOST_SESSION_ENV_VAR, default)
36
+
37
+
38
+ def get_or_create_host_session_id() -> str:
39
+ """Return the host session id, generating and storing one if absent.
40
+
41
+ Checks the host session env var first. If absent, generates a new id from
42
+ the current time and PID, stores it back into the env var (so subsequent
43
+ reads in the same process are stable), and returns it.
44
+ """
45
+ session_id = os.environ.get(_HOST_SESSION_ENV_VAR)
46
+ if not session_id:
47
+ timestamp = datetime.now().strftime("%H%M%S")
48
+ hash_input = f"{timestamp}-{os.getpid()}"
49
+ session_hash = hashlib.sha256(hash_input.encode()).hexdigest()[:8]
50
+ session_id = f"session-{timestamp}-{session_hash}"
51
+ os.environ[_HOST_SESSION_ENV_VAR] = session_id
52
+ logger.debug("Generated new session_id: %s", session_id)
53
+ return session_id
@@ -0,0 +1,75 @@
1
+ """
2
+ Host Transcript Access for Gaia-Ops.
3
+
4
+ Adapter-owned utility that encapsulates how the host CLI (Claude Code) persists
5
+ a subagent transcript on disk. The host-specific format -- a JSONL file whose
6
+ lines are JSON objects with the role/content nested inside a ``message`` field
7
+ -- lives ONLY here (inside ``hooks/adapters/``). Business logic modules iterate
8
+ over normalized ``(role, content)`` entries via :func:`iter_transcript_entries`
9
+ instead of opening the file and calling ``json.loads`` themselves, so the core
10
+ stays agnostic to the host CLI's transcript-serialization convention.
11
+
12
+ Mirrors the ``host_session.py`` pattern: a small standalone module under
13
+ ``adapters/`` that owns a single host-specific detail and is imported by
14
+ business logic, with no dependency on the heavier ``ClaudeCodeAdapter`` (avoids
15
+ any circular-import or instantiation concern in low-level modules).
16
+
17
+ If a future host advertises its transcript in a different shape (e.g. a single
18
+ JSON array, or a different nesting), only this module changes; the readers in
19
+ ``modules/agents/transcript_reader.py`` keep iterating normalized entries.
20
+ """
21
+
22
+ import json
23
+ import logging
24
+ from pathlib import Path
25
+ from typing import Iterator, Tuple
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # A normalized transcript entry as seen by business logic: (role, content).
30
+ # ``content`` is the raw host content -- a str, a list of content blocks, or
31
+ # None -- left for the reader to normalize per its own needs.
32
+ TranscriptEntry = Tuple[str, object]
33
+
34
+
35
+ def iter_transcript_entries(transcript_path: str) -> Iterator[TranscriptEntry]:
36
+ """Yield ``(role, content)`` for each message entry in the host transcript.
37
+
38
+ Encapsulates the host CLI's transcript format: the file at
39
+ ``transcript_path`` is JSONL (one JSON object per line); each object nests
40
+ the role/content inside a ``message`` field, falling back to the object
41
+ itself for a flat ``{role, content}`` shape. Lines that are blank or fail
42
+ to parse as JSON are skipped silently so a partially-written transcript
43
+ never crashes a hook.
44
+
45
+ Performs path expansion (``~``) and an existence check. A missing/empty
46
+ path or a nonexistent file yields nothing. Callers receive a uniform
47
+ stream of normalized entries and never see JSONL or ``json.loads``.
48
+ """
49
+ if not transcript_path:
50
+ return
51
+ try:
52
+ path = Path(transcript_path).expanduser()
53
+ if not path.exists():
54
+ logger.debug("Transcript file not found: %s", path)
55
+ return
56
+ with open(path, "r") as f:
57
+ for line in f:
58
+ line = line.strip()
59
+ if not line:
60
+ continue
61
+ try:
62
+ entry = json.loads(line)
63
+ except (json.JSONDecodeError, TypeError):
64
+ continue
65
+ if not isinstance(entry, dict):
66
+ continue
67
+ # Host format: role/content nested inside ``message``; fall
68
+ # back to the entry itself for a flat shape.
69
+ msg = entry.get("message", entry)
70
+ if not isinstance(msg, dict):
71
+ continue
72
+ yield msg.get("role", ""), msg.get("content", "")
73
+ except Exception as e: # pragma: no cover - defensive, never crash a hook
74
+ logger.debug("Failed to read transcript from %s: %s", transcript_path, e)
75
+ return
@@ -0,0 +1,87 @@
1
+ """
2
+ Adapter Registry / Factory for Gaia-Ops Hooks.
3
+
4
+ The single construction point for the host :class:`HookAdapter`. Every entry
5
+ point (``pre_tool_use``, ``post_tool_use``, ``stop_hook``, ``subagent_start``,
6
+ ``subagent_stop``, ``task_completed``, ``hook_entry``) and the shared
7
+ ``hook_response`` builder obtain their adapter through :func:`get_adapter`
8
+ instead of calling ``ClaudeCodeAdapter()`` directly. Concentrating the
9
+ ``ClaudeCodeAdapter`` reference here means the core never names a concrete host
10
+ class at a call site (AC-5): supporting a new host is a one-line
11
+ :func:`register_adapter` call, not an edit to every entry point one by one
12
+ (AC-7 / brief #88 "Desacoplar la lógica de Gaia de Claude Code").
13
+
14
+ Mirrors the ``channel.py`` / ``host_session.py`` / ``host_transcript.py``
15
+ pattern: a small standalone module under ``adapters/`` that owns a single
16
+ host-coupling concern -- here, *which adapter class to build* -- and is imported
17
+ by callers. The adapter is stateless (no ``__init__``, no mutable instance
18
+ attributes), so a single cached instance is shared process-wide; this matches
19
+ the long-standing module-level ``_adapter = ClaudeCodeAdapter()`` singleton in
20
+ ``modules/tools/hook_response.py`` that this registry now subsumes.
21
+
22
+ Host selection
23
+ --------------
24
+ The active host is keyed by :data:`DEFAULT_HOST` (``"claude_code"``). A future
25
+ host registers its class and, when more than one is installed, ``get_adapter``
26
+ can be extended to resolve the key from a host-detection signal -- without any
27
+ entry point changing.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from typing import Dict, Optional, Type
33
+
34
+ from .base import HookAdapter
35
+ from .claude_code import ClaudeCodeAdapter
36
+
37
+ # The only host Gaia ships an adapter for today. Confined to this module so the
38
+ # concrete class name appears at exactly one call site in the whole core.
39
+ DEFAULT_HOST = "claude_code"
40
+
41
+ # host key -> adapter class. A new host appends one entry here (or via
42
+ # register_adapter); nothing else in the codebase references the class.
43
+ _REGISTRY: Dict[str, Type[HookAdapter]] = {
44
+ DEFAULT_HOST: ClaudeCodeAdapter,
45
+ }
46
+
47
+ # Cache of constructed adapters, keyed by host. The adapter is stateless, so a
48
+ # single instance per host is reused for the life of the process.
49
+ _INSTANCES: Dict[str, HookAdapter] = {}
50
+
51
+
52
+ def register_adapter(host: str, adapter_cls: Type[HookAdapter]) -> None:
53
+ """Register ``adapter_cls`` as the adapter for host key ``host``.
54
+
55
+ Supporting a new host CLI is this call plus the new ``HookAdapter``
56
+ subclass -- no entry point changes. Re-registering a host replaces the
57
+ class and drops any cached instance so the next :func:`get_adapter` builds
58
+ the new one.
59
+
60
+ Raises:
61
+ TypeError: If ``adapter_cls`` is not a ``HookAdapter`` subclass.
62
+ """
63
+ if not (isinstance(adapter_cls, type) and issubclass(adapter_cls, HookAdapter)):
64
+ raise TypeError(
65
+ f"adapter_cls must be a HookAdapter subclass, got {adapter_cls!r}"
66
+ )
67
+ _REGISTRY[host] = adapter_cls
68
+ _INSTANCES.pop(host, None)
69
+
70
+
71
+ def get_adapter(host: Optional[str] = None) -> HookAdapter:
72
+ """Return the shared :class:`HookAdapter` instance for ``host``.
73
+
74
+ The single construction point for the host adapter. Lazily builds and
75
+ caches one instance per host (the adapter is stateless, so the instance is
76
+ safe to share). Defaults to :data:`DEFAULT_HOST` when ``host`` is omitted.
77
+
78
+ Raises:
79
+ KeyError: If ``host`` has no registered adapter class.
80
+ """
81
+ key = host or DEFAULT_HOST
82
+ instance = _INSTANCES.get(key)
83
+ if instance is None:
84
+ adapter_cls = _REGISTRY[key]
85
+ instance = adapter_cls()
86
+ _INSTANCES[key] = instance
87
+ return instance
@@ -59,11 +59,70 @@ class PermissionDecision(enum.Enum):
59
59
  ASK = "ask"
60
60
 
61
61
 
62
- class DistributionChannel(enum.Enum):
63
- """How gaia-ops was installed and is being invoked."""
62
+ @dataclass(frozen=True)
63
+ class HostDistribution:
64
+ """How a host distributes and invokes gaia-ops -- declared by the adapter.
65
+
66
+ The CLI-agnostic value object that replaces a core-owned enumeration of
67
+ distribution channels. The core never enumerates a host's channels nor
68
+ names a host-specific "root"; it carries an opaque :class:`HostDistribution`
69
+ that the concrete adapter PRODUCES from its own distribution model (see
70
+ :meth:`HookAdapter.detect_distribution`). A host with a distribution model
71
+ the core has never heard of declares it here without any change to the core
72
+ vocabulary -- the same seam established for capabilities
73
+ (:class:`HostCapability`) and consent (:class:`ConsentRequest`).
74
+
75
+ The concrete channel names (Claude Code's "npm" / "plugin") and the meaning
76
+ of ``root`` (Claude Code's plugin root) live ONLY in the host's adapter; the
77
+ core treats both as opaque values.
78
+
79
+ Fields:
80
+ channel: The host's own opaque channel identifier (e.g. Claude Code
81
+ uses "npm" / "plugin"). The core compares or logs it but never
82
+ branches on a value it enumerated -- the host owns the vocabulary.
83
+ root: The distribution root for this channel when the host has the
84
+ notion of one (Claude Code: the plugin root directory); ``None``
85
+ when the channel has no such root. The core does not interpret it.
86
+ """
64
87
 
65
- NPM = "npm"
66
- PLUGIN = "plugin"
88
+ channel: str
89
+ root: Optional[Path] = None
90
+
91
+
92
+ class HostCapability(enum.Enum):
93
+ """A named capability a host (CLI backend) may or may not offer.
94
+
95
+ The CLI-agnostic vocabulary business logic uses to ASK a host whether it
96
+ can do a thing -- without naming any host. Each concrete adapter DECLARES
97
+ which of these it supports (see :meth:`HookAdapter.capabilities`); the core
98
+ queries that declaration via :meth:`HookAdapter.supports` and, when a
99
+ capability is absent, degrades in a *declared* way (a
100
+ :class:`CapabilityDegradation`) rather than crashing or branching on the
101
+ host's identity. Claude Code supports all of these today; a future host
102
+ (Codex, Antigravity) that lacks one drives the degradation path.
103
+
104
+ Members:
105
+ INTERACTIVE_CONSENT: the host can gather the user's consent inline,
106
+ in-session (Claude Code: the native AskUserQuestion prompt).
107
+ OUT_OF_BAND_APPROVAL: the host can run an approval cycle keyed to a
108
+ persisted identifier the decision is later matched against
109
+ (Claude Code: the orchestrator approval-id hand-off).
110
+ STRUCTURED_PERMISSION_DECISION: the host accepts a structured
111
+ allow/deny/ask permission decision (vs. only an exit code).
112
+ UPDATED_INPUT: the host can apply adapter-modified tool input
113
+ transparently (e.g. a footer-stripped command).
114
+ CONTEXT_INJECTION: the host can inject additional context into the
115
+ session at hook time (SessionStart / SubagentStart context).
116
+ TRANSCRIPT_ACCESS: the host exposes the agent transcript for
117
+ post-hoc inspection (contract / anomaly analysis).
118
+ """
119
+
120
+ INTERACTIVE_CONSENT = "interactive_consent"
121
+ OUT_OF_BAND_APPROVAL = "out_of_band_approval"
122
+ STRUCTURED_PERMISSION_DECISION = "structured_permission_decision"
123
+ UPDATED_INPUT = "updated_input"
124
+ CONTEXT_INJECTION = "context_injection"
125
+ TRANSCRIPT_ACCESS = "transcript_access"
67
126
 
68
127
 
69
128
  @dataclass(frozen=True)
@@ -71,13 +130,18 @@ class HookEvent:
71
130
  """Normalized hook event, CLI-agnostic.
72
131
 
73
132
  Produced by the adapter's parse_event() method.
133
+
134
+ ``distribution`` is the host-declared :class:`HostDistribution` (the host's
135
+ own channel and, when applicable, its distribution root). It replaces the
136
+ former ``channel`` / ``plugin_root`` pair so the core never enumerates a
137
+ host's distribution channels. It is ``None`` when the adapter does not
138
+ declare a distribution model for the event.
74
139
  """
75
140
 
76
141
  event_type: HookEventType
77
142
  session_id: str
78
143
  payload: Dict[str, Any]
79
- channel: DistributionChannel
80
- plugin_root: Optional[Path] = None
144
+ distribution: Optional[HostDistribution] = None
81
145
 
82
146
 
83
147
  @dataclass(frozen=True)
@@ -105,6 +169,70 @@ class ValidationResult:
105
169
  nonce: Optional[str] = None
106
170
 
107
171
 
172
+ @dataclass(frozen=True)
173
+ class ConsentRequest:
174
+ """CLI-agnostic description of an operation that needs the user's consent.
175
+
176
+ Business logic produces this when it has classified an operation as
177
+ requiring approval (a T3 mutation, a protected-path write). It states only
178
+ the *facts* of what needs consent -- never how to ask. The adapter's
179
+ :meth:`HookAdapter.request_consent` turns it into the host's consent
180
+ mechanism (a native permission prompt, an approval-id hand-off, ...), so the
181
+ core never names the host's specific consent flow.
182
+
183
+ Fields:
184
+ operation: The thing needing consent -- a shell command, or a file path.
185
+ kind: Coarse classification of ``operation`` ("bash", "file", ...).
186
+ Lets the adapter tailor the prompt wording without parsing.
187
+ reason: Human-readable explanation of why consent is required, already
188
+ assembled by business logic (tier banner, verb, command excerpt).
189
+ tier: The security tier string (e.g. "T3_BLOCKED"); informational.
190
+ approval_id: When the host runs an out-of-band approval flow (an
191
+ orchestrator driving the approval cycle), this is the persisted
192
+ identifier the user's decision is keyed to. None means the host
193
+ should gather consent inline (e.g. a native prompt).
194
+ updated_input: Optional modified tool input (e.g. footer-stripped
195
+ command) the host must preserve through the consent step.
196
+ """
197
+
198
+ operation: str
199
+ kind: str = "bash"
200
+ reason: str = ""
201
+ tier: str = "T3_BLOCKED"
202
+ approval_id: Optional[str] = None
203
+ updated_input: Optional[Dict[str, Any]] = None
204
+
205
+
206
+ @dataclass(frozen=True)
207
+ class CapabilityDegradation:
208
+ """The DECLARED outcome of querying a host for a capability.
209
+
210
+ Returned by :meth:`HookAdapter.degrade_when_missing`. It is the explicit,
211
+ observable answer to "does this host offer capability X, and if not, what
212
+ safe thing happens instead?" -- the controlled alternative to a crash or an
213
+ implicit ``if host == "claude_code"`` branch. Business logic receives this
214
+ value, reads :attr:`available`, and follows :attr:`fallback` when the
215
+ capability is absent. Nothing here knows which host produced it.
216
+
217
+ Fields:
218
+ capability: The :class:`HostCapability` that was queried.
219
+ available: True when the host declared support for ``capability``.
220
+ When True, ``fallback`` is the empty string and ``reason`` is
221
+ informational only -- the caller uses the full capability.
222
+ fallback: The semantic name of the safe behavior to take when the
223
+ capability is NOT available (e.g. "deny", "skip", "log_only").
224
+ Chosen by the caller and echoed back so the degradation is a
225
+ value the caller declared, not a side effect it must remember.
226
+ reason: Human-readable explanation of the degradation, suitable for
227
+ surfacing in a log or a denial message.
228
+ """
229
+
230
+ capability: HostCapability
231
+ available: bool
232
+ fallback: str = ""
233
+ reason: str = ""
234
+
235
+
108
236
  @dataclass(frozen=True)
109
237
  class ToolResult:
110
238
  """Post-tool-use result data extracted from a HookEvent."""
@@ -10,57 +10,39 @@ Provides:
10
10
 
11
11
  import json
12
12
  import logging
13
+ import os
13
14
  from pathlib import Path
14
15
  from typing import Any, Dict, List, Optional
15
16
 
17
+ from adapters.host_transcript import iter_transcript_entries
18
+
16
19
  logger = logging.getLogger(__name__)
17
20
 
18
21
 
19
22
  def read_transcript(transcript_path: str) -> str:
20
- """Read agent transcript from file path provided by Claude Code.
23
+ """Read assistant messages from the host transcript provided by the CLI.
21
24
 
22
- Claude Code provides ``agent_transcript_path`` pointing to a JSONL file.
23
- Each line has the structure:
24
- {"type": "assistant", "message": {"role": "assistant", "content": [...]}, ...}
25
- The role/content are nested inside the ``message`` field.
25
+ The host CLI advertises ``agent_transcript_path``; the on-disk format
26
+ (JSONL, ``message``-nesting) is owned by ``adapters/host_transcript.py``.
27
+ This reader iterates normalized ``(role, content)`` entries from that
28
+ adapter and joins the text of every ``assistant`` message -- it makes no
29
+ assumption about how the host serializes the transcript.
26
30
 
27
31
  Falls back to empty string on any error so the hook never crashes.
28
32
  """
29
33
  try:
30
- # Expand ~ to home directory (Claude Code may use ~ in paths)
31
- path = Path(transcript_path).expanduser()
32
- logger.debug("Reading transcript from: %s", path)
33
-
34
- if not path.exists():
35
- logger.warning("Transcript file not found: %s", path)
36
- return ""
37
-
38
- lines = path.read_text().strip().splitlines()
39
-
40
34
  text_parts: List[str] = []
41
- for line in lines:
42
- if not line.strip():
43
- continue
44
- try:
45
- entry = json.loads(line)
46
-
47
- # Claude Code transcript format: content is inside entry["message"]
48
- msg = entry.get("message", entry) # fallback to entry itself for simple format
49
- role = msg.get("role", "")
50
- if role != "assistant":
51
- continue
52
-
53
- content = msg.get("content", "")
54
- if isinstance(content, str):
55
- text_parts.append(content)
56
- elif isinstance(content, list):
57
- for block in content:
58
- if isinstance(block, dict) and block.get("type") == "text":
59
- text_parts.append(block.get("text", ""))
60
- elif isinstance(block, str):
61
- text_parts.append(block)
62
- except (json.JSONDecodeError, TypeError):
35
+ for role, content in iter_transcript_entries(transcript_path):
36
+ if role != "assistant":
63
37
  continue
38
+ if isinstance(content, str):
39
+ text_parts.append(content)
40
+ elif isinstance(content, list):
41
+ for block in content:
42
+ if isinstance(block, dict) and block.get("type") == "text":
43
+ text_parts.append(block.get("text", ""))
44
+ elif isinstance(block, str):
45
+ text_parts.append(block)
64
46
 
65
47
  result = "\n".join(text_parts)
66
48
  logger.debug("Extracted %d text parts, total length: %d chars", len(text_parts), len(result))
@@ -72,41 +54,24 @@ def read_transcript(transcript_path: str) -> str:
72
54
 
73
55
 
74
56
  def read_first_user_content_from_transcript(transcript_path: str) -> Optional[str]:
75
- """Read the raw content string of the first user message from a transcript JSONL.
57
+ """Read the raw content of the first user message from the host transcript.
76
58
 
77
- Handles: empty path guard, path expansion, existence check, JSONL iteration,
78
- JSON parse, role=="user" check, content normalization (str vs list).
79
- Returns the raw content string or None.
59
+ Iterates normalized ``(role, content)`` entries from the adapter (which
60
+ owns the host transcript format) and returns the content of the first
61
+ ``user`` message, normalized to a string. Returns None when there is no
62
+ user message (or the path is empty/missing).
80
63
  """
81
- if not transcript_path:
64
+ for role, content in iter_transcript_entries(transcript_path):
65
+ if role != "user":
66
+ continue
67
+ if isinstance(content, str):
68
+ return content
69
+ elif isinstance(content, list):
70
+ return " ".join(
71
+ b.get("text", "") for b in content
72
+ if isinstance(b, dict) and b.get("type") == "text"
73
+ )
82
74
  return None
83
- try:
84
- path = Path(transcript_path).expanduser()
85
- if not path.exists():
86
- return None
87
- with open(path, "r") as f:
88
- for line in f:
89
- line = line.strip()
90
- if not line:
91
- continue
92
- try:
93
- entry = json.loads(line)
94
- msg = entry.get("message", entry)
95
- if msg.get("role") != "user":
96
- continue
97
- content = msg.get("content", "")
98
- if isinstance(content, str):
99
- return content
100
- elif isinstance(content, list):
101
- return " ".join(
102
- b.get("text", "") for b in content
103
- if isinstance(b, dict) and b.get("type") == "text"
104
- )
105
- return None
106
- except (json.JSONDecodeError, TypeError):
107
- continue
108
- except Exception as e:
109
- logger.debug("Failed to read first user content from transcript: %s", e)
110
75
  return None
111
76
 
112
77
 
@@ -137,8 +102,6 @@ def extract_injected_context_payload_from_transcript(
137
102
  Context is delivered via additionalContext and the payload is persisted to
138
103
  disk by context_injector. Prompts do not contain embedded payloads.
139
104
  """
140
- import os
141
-
142
105
  # Empty/None path guard. Without it, Path("").stem == "" and the substring
143
106
  # match below (``candidate.stem in "" or "" in candidate.stem``) is ALWAYS
144
107
  # True because ``"" in any_string`` is True -- so an empty path would match
@@ -38,7 +38,7 @@ def run_hook(
38
38
  hook_name: str = "hook",
39
39
  usage_message: str | None = None,
40
40
  ) -> None:
41
- """Read stdin, parse via ClaudeCodeAdapter, and delegate to *handler*.
41
+ """Read stdin, parse via the registry adapter, and delegate to *handler*.
42
42
 
43
43
  Args:
44
44
  handler: Callable that receives an ``adapters.types.HookEvent``.
@@ -58,10 +58,12 @@ def run_hook(
58
58
 
59
59
  try:
60
60
  # Deferred adapter import avoids circular dependencies at module level;
61
- # the adapter package is a sibling of modules/.
62
- from adapters.claude_code import ClaudeCodeAdapter
61
+ # the adapter package is a sibling of modules/. get_adapter() is the
62
+ # single construction point (registry), so entry points never name the
63
+ # concrete host class.
64
+ from adapters.registry import get_adapter
63
65
 
64
- adapter = ClaudeCodeAdapter()
66
+ adapter = get_adapter()
65
67
  stdin_data = sys.stdin.read()
66
68
  event = adapter.parse_event(stdin_data)
67
69
  handler(event)
@@ -5,7 +5,6 @@ Uses a temporary file to pass information from pre_tool_use to post_tool_use,
5
5
  since they run in separate processes.
6
6
  """
7
7
 
8
- import os
9
8
  import json
10
9
  import logging
11
10
  import time
@@ -14,6 +13,8 @@ from datetime import datetime
14
13
  from typing import Dict, Any, Optional
15
14
  from dataclasses import dataclass, asdict, field
16
15
 
16
+ from adapters.host_session import read_host_session_id
17
+
17
18
  from .paths import find_claude_dir
18
19
 
19
20
  logger = logging.getLogger(__name__)
@@ -23,15 +24,16 @@ STATE_FILE_NAME = ".hooks_state.json"
23
24
 
24
25
 
25
26
  def get_session_id() -> str:
26
- """Return the current Claude session ID, defaulting to 'default'.
27
+ """Return the current host session ID, defaulting to 'default'.
27
28
 
28
- Reads only CLAUDE_SESSION_ID. Hook entry points that have the parsed
29
+ Reads only the host session environment variable (via the adapter-owned
30
+ ``read_host_session_id`` helper). Hook entry points that have the parsed
29
31
  stdin event in hand should prefer ``resolve_session_id(event_data)``
30
- because Claude Code does not always set CLAUDE_SESSION_ID in the hook
31
- subprocess; it does, however, always include ``session_id`` in the
32
+ because the host CLI does not always export the session env var into the
33
+ hook subprocess; it does, however, always include ``session_id`` in the
32
34
  JSON event piped to stdin.
33
35
  """
34
- return os.environ.get("CLAUDE_SESSION_ID", "default")
36
+ return read_host_session_id()
35
37
 
36
38
 
37
39
  def resolve_session_id(event_data: Optional[Dict[str, Any]] = None) -> str:
@@ -39,20 +41,20 @@ def resolve_session_id(event_data: Optional[Dict[str, Any]] = None) -> str:
39
41
 
40
42
  Order:
41
43
  1. ``event_data["session_id"]`` when present and non-empty.
42
- 2. ``CLAUDE_SESSION_ID`` environment variable.
44
+ 2. The host session environment variable (via the adapter helper).
43
45
  3. Literal ``"default"`` (matches ``get_session_id()`` for back-compat).
44
46
 
45
47
  Hook entry points should call this immediately after parsing stdin so
46
48
  downstream calls (``register_session``, ``touch_session``,
47
- ``unregister_session``) reach the registry with the real id.
48
- ``CLAUDE_SESSION_ID`` is not guaranteed to be exported into the hook
49
+ ``unregister_session``) reach the registry with the real id. The host
50
+ session env var is not guaranteed to be exported into the hook
49
51
  subprocess; the stdin event always carries ``session_id``.
50
52
  """
51
53
  if isinstance(event_data, dict):
52
54
  candidate = event_data.get("session_id")
53
55
  if isinstance(candidate, str) and candidate:
54
56
  return candidate
55
- return os.environ.get("CLAUDE_SESSION_ID", "default")
57
+ return read_host_session_id()
56
58
 
57
59
 
58
60
  @dataclass
@@ -219,8 +219,8 @@ def cleanup(
219
219
 
220
220
  Args:
221
221
  agent_type: The agent type that just completed (provenance + logging).
222
- session_id: Session ID to scope the TTL sweep (defaults to
223
- CLAUDE_SESSION_ID).
222
+ session_id: Session ID to scope the TTL sweep (defaults to the
223
+ current host session id).
224
224
  preserve_nonces: Optional set of approval_id strings the agent's final
225
225
  agent_contract_handoff still references via APPROVAL_REQUEST. With
226
226
  Fix A these are protected by their TTL already (they are fresh by
@@ -887,7 +887,7 @@ def write_pending_approval_for_file(
887
887
  nonce: Cryptographic nonce from generate_nonce(). The DB row is stored
888
888
  under approval_id = "P-" + nonce.
889
889
  file_path: The absolute path of the file being written/edited.
890
- session_id: Session ID (defaults to CLAUDE_SESSION_ID env var).
890
+ session_id: Session ID (defaults to the current host session id).
891
891
  ttl_minutes: How long the pending approval is valid before expiry
892
892
  (0 = no expiry; ignored by DB which uses TTL at query time).
893
893
  context: Optional dict with enriched context (source, description,
@@ -1015,8 +1015,8 @@ def find_pending_for_file(
1015
1015
  return None
1016
1016
 
1017
1017
  # DB path: query all pending rows (all_sessions=True -- see scan_pending_db
1018
- # for the rationale: CLAUDE_SESSION_ID inside a subagent is the subagent's id,
1019
- # not the orchestrator's, so session-scoping would silently miss the row).
1018
+ # for the rationale: the host session id inside a subagent is the subagent's
1019
+ # id, not the orchestrator's, so session-scoping would silently miss the row).
1020
1020
  try:
1021
1021
  from gaia.approvals.store import list_pending
1022
1022
  rows = list_pending(all_sessions=True)
@@ -1564,7 +1564,7 @@ def create_command_set_grant(
1564
1564
  Args:
1565
1565
  command_set: List of dicts [{"command": str, "rationale": str}, ...].
1566
1566
  approval_id: Unique nonce (32-char hex from generate_nonce()).
1567
- session_id: CLAUDE_SESSION_ID (defaults to current session).
1567
+ session_id: Host session id (defaults to current session).
1568
1568
  agent_id: Agent identifier for audit trail.
1569
1569
  ttl_minutes: Grant lifetime (default 10 min). Enforced at query time.
1570
1570
  db_path: Optional explicit DB path override (used by tests).
@@ -1634,9 +1634,9 @@ def match_command_set_grant(
1634
1634
 
1635
1635
  The lookup is SESSION-AGNOSTIC (Brief 71), exactly like the singular path
1636
1636
  (``check_db_semantic_grant``). The block-approve-retry flow legitimately
1637
- spans sessions, and CLAUDE_SESSION_ID is not guaranteed to be exported into
1638
- the bash subprocess -- where ``get_session_id()`` falls back to the literal
1639
- ``"default"``. A session_id filter therefore silently dropped every grant
1637
+ spans sessions, and the host session id is not guaranteed to be exported
1638
+ into the bash subprocess -- where ``get_session_id()`` falls back to the
1639
+ literal ``"default"``. A session_id filter therefore silently dropped every grant
1640
1640
  created under the real session, letting approved COMMAND_SET commands run
1641
1641
  WITHOUT being consumed (the consumption-bypass bug). Replay protection is
1642
1642
  preserved by the conjunction of the byte-for-byte match, status='PENDING'