@jaguilar87/gaia 5.1.0 → 5.1.1

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 (92) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +11 -0
  4. package/agents/gaia-orchestrator.md +4 -10
  5. package/bin/README.md +1 -1
  6. package/bin/cli/approvals.py +0 -144
  7. package/bin/cli/cleanup.py +1 -30
  8. package/bin/cli/context.py +379 -6
  9. package/bin/cli/doctor.py +1 -1
  10. package/bin/cli/history.py +18 -6
  11. package/bin/cli/install.py +4 -2
  12. package/bin/cli/memory.py +86 -10
  13. package/bin/cli/metrics.py +1216 -275
  14. package/bin/cli/query.py +89 -0
  15. package/bin/cli/scan.py +68 -2
  16. package/bin/cli/uninstall.py +9 -5
  17. package/bin/pre-publish-validate.js +7 -4
  18. package/gaia/project.py +49 -20
  19. package/gaia/store/reader.py +175 -11
  20. package/gaia/store/schema.sql +180 -1
  21. package/gaia/store/writer.py +1146 -165
  22. package/hooks/README.md +2 -2
  23. package/hooks/adapters/claude_code.py +6 -12
  24. package/hooks/elicitation_result.py +4 -9
  25. package/hooks/modules/agents/contract_validator.py +2 -1
  26. package/hooks/modules/agents/handoff_persister.py +5 -220
  27. package/hooks/modules/agents/task_info_builder.py +9 -2
  28. package/hooks/modules/agents/transcript_reader.py +41 -21
  29. package/hooks/modules/audit/logger.py +8 -3
  30. package/hooks/modules/context/context_injector.py +10 -3
  31. package/hooks/modules/core/__init__.py +5 -2
  32. package/hooks/modules/core/logging_setup.py +60 -0
  33. package/hooks/modules/core/paths.py +0 -12
  34. package/hooks/modules/security/approval_grants.py +24 -92
  35. package/hooks/modules/security/gaia_db_write_guard.py +7 -2
  36. package/hooks/modules/security/mutative_verbs.py +194 -11
  37. package/hooks/modules/session/pending_scanner.py +24 -222
  38. package/hooks/modules/session/session_manifest.py +13 -288
  39. package/hooks/post_compact.py +4 -9
  40. package/hooks/post_tool_use.py +4 -9
  41. package/hooks/pre_compact.py +5 -10
  42. package/hooks/pre_tool_use.py +4 -11
  43. package/hooks/session_end_hook.py +4 -9
  44. package/hooks/session_start.py +18 -21
  45. package/hooks/stop_hook.py +5 -10
  46. package/hooks/subagent_start.py +5 -10
  47. package/hooks/subagent_stop.py +9 -23
  48. package/hooks/task_completed.py +5 -10
  49. package/hooks/user_prompt_submit.py +4 -9
  50. package/package.json +1 -1
  51. package/pyproject.toml +1 -1
  52. package/scripts/migrations/schema.checksum +2 -2
  53. package/scripts/migrations/v21_to_v22.sql +80 -0
  54. package/scripts/migrations/v22_to_v23.sql +20 -0
  55. package/scripts/migrations/v23_to_v24.sql +30 -0
  56. package/scripts/migrations/v24_to_v25.sql +77 -0
  57. package/scripts/migrations/v25_to_v26.sql +116 -0
  58. package/skills/README.md +8 -0
  59. package/skills/agent-approval-protocol/SKILL.md +27 -27
  60. package/skills/agent-approval-protocol/reference.md +20 -12
  61. package/skills/agent-protocol/SKILL.md +1 -1
  62. package/skills/agent-protocol/examples.md +17 -9
  63. package/skills/agent-response/SKILL.md +1 -1
  64. package/skills/diagram-builder/GLOSSARY.md +77 -0
  65. package/skills/diagram-builder/SKILL.md +156 -0
  66. package/skills/diagram-builder/assets/README.md +45 -0
  67. package/skills/diagram-builder/assets/data/data.generated.js +77 -0
  68. package/skills/diagram-builder/assets/data/document.yaml +12 -0
  69. package/skills/diagram-builder/assets/data/pages/overview.yaml +47 -0
  70. package/skills/diagram-builder/assets/engine/build-data.mjs +51 -0
  71. package/skills/diagram-builder/assets/engine/engine.js +863 -0
  72. package/skills/diagram-builder/assets/index.html +638 -0
  73. package/skills/diagram-builder/assets/package.json +15 -0
  74. package/skills/diagram-builder/assets/tools/verify.mjs +128 -0
  75. package/skills/diagram-builder/reference.md +444 -0
  76. package/skills/execution/SKILL.md +3 -3
  77. package/skills/gaia-patterns/reference.md +2 -2
  78. package/skills/memory/SKILL.md +49 -0
  79. package/skills/orchestrator-present-approval/SKILL.md +90 -79
  80. package/skills/orchestrator-present-approval/reference.md +59 -42
  81. package/skills/orchestrator-present-approval/template.md +16 -14
  82. package/skills/pending-approvals/SKILL.md +80 -29
  83. package/skills/pending-approvals/reference.md +48 -60
  84. package/skills/security-tiers/SKILL.md +1 -1
  85. package/skills/subagent-request-approval/SKILL.md +65 -68
  86. package/skills/subagent-request-approval/reference.md +63 -63
  87. package/skills/visual-verify/SKILL.md +107 -0
  88. package/skills/visual-verify/scripts/screenshot.js +210 -0
  89. package/tools/scan/classify.py +584 -15
  90. package/tools/scan/migrate_workspace.py +9 -4
  91. package/tools/scan/store_populator.py +231 -2
  92. package/tools/scan/tests/conftest.py +31 -0
package/hooks/README.md CHANGED
@@ -4,7 +4,7 @@ Hooks are the event-driven spine of Gaia. Every significant moment in a Claude C
4
4
 
5
5
  Each hook is a Python script that reads a JSON event from stdin, processes it, and writes a JSON response to stdout. Claude Code calls these scripts synchronously before or after each tool execution, which means the hook can allow, modify, or block the operation. The hook cannot do complex async work — it runs inline, in the critical path, so every module it calls must complete quickly.
6
6
 
7
- The hooks form a pipeline. A session opens at `session_start.py`, which emits a one-shot `additionalContext` manifest (Environment, Active Agentic Loop, [ACTIONABLE] pending approvals) for the orchestrator. Each prompt then enters at `user_prompt_submit.py`, gets routed to an agent, triggers `pre_tool_use.py` before each tool call, generates audit records in `post_tool_use.py`, and closes out in `subagent_stop.py` when the agent finishes. The session closes at `session_end_hook.py`. The remaining event handlers (`stop_hook.py`, `subagent_start.py`, `task_completed.py`, `pre_compact.py`, `post_compact.py`, `elicitation_result.py`) fire at lifecycle transitions and carry lighter responsibilities.
7
+ The hooks form a pipeline. A session opens at `session_start.py`, which emits a one-shot `additionalContext` manifest (Environment, Active Agentic Loop) for the orchestrator. Each prompt then enters at `user_prompt_submit.py`, gets routed to an agent, triggers `pre_tool_use.py` before each tool call, generates audit records in `post_tool_use.py`, and closes out in `subagent_stop.py` when the agent finishes. The session closes at `session_end_hook.py`. The remaining event handlers (`stop_hook.py`, `subagent_start.py`, `task_completed.py`, `pre_compact.py`, `post_compact.py`, `elicitation_result.py`) fire at lifecycle transitions and carry lighter responsibilities.
8
8
 
9
9
  ## Cuándo se activa
10
10
 
@@ -15,7 +15,7 @@ Session opens
15
15
  | Registers session in heartbeat-based session_registry
16
16
  | Sweeps stale registry entries and expired approval files
17
17
  | Emits one-shot hookSpecificOutput.additionalContext manifest
18
- | (Environment + Active Agentic Loop + [ACTIONABLE] pendings)
18
+ | (Environment + Active Agentic Loop)
19
19
  v
20
20
  User sends prompt
21
21
  |
@@ -1433,18 +1433,12 @@ class ClaudeCodeAdapter(HookAdapter):
1433
1433
  preserve_nonces=preserved_nonces if preserved_nonces else None,
1434
1434
  )
1435
1435
 
1436
- # Consume all confirmed grants for this session -- the subagent
1437
- # is done, so grants should not survive past its lifetime.
1438
- try:
1439
- from modules.security.approval_grants import consume_session_grants
1440
- consumed = consume_session_grants(session_id)
1441
- if consumed:
1442
- logger.info(
1443
- "SubagentStop consumed %d grant(s) for session %s",
1444
- consumed, session_id[:12],
1445
- )
1446
- except Exception as exc:
1447
- logger.debug("Grant consumption at SubagentStop failed (non-fatal): %s", exc)
1436
+ # NOTE (approvals redesign, M1): grants are consumed AT THE MATCH by
1437
+ # bash_validator (PENDING->CONSUMED when the command is authorized in
1438
+ # PreToolUse), NOT swept at SubagentStop. A grant that was never
1439
+ # presented to a matching retry stays PENDING and expires on its own
1440
+ # short (5m) TTL, so it must survive the subagent ending. The former
1441
+ # consume_session_grants() sweep has been removed.
1448
1442
 
1449
1443
  commands_executed = extract_commands_from_evidence(agent_output)
1450
1444
 
@@ -14,7 +14,6 @@ import sys
14
14
  import json
15
15
  import logging
16
16
  import os
17
- from datetime import datetime
18
17
  from pathlib import Path
19
18
 
20
19
  _hooks_dir = Path(__file__).resolve().parent
@@ -23,16 +22,12 @@ _pkg_root = str(_hooks_dir.parent)
23
22
  if _pkg_root not in sys.path:
24
23
  sys.path.insert(0, _pkg_root)
25
24
 
26
- from modules.core.paths import get_logs_dir
25
+ from modules.core.logging_setup import configure_hook_logging
27
26
  from modules.core.stdin import has_stdin_data
28
27
 
29
- # Configure logging -- file only, no stderr
30
- _log_file = get_logs_dir() / f"hooks-{datetime.now().strftime('%Y-%m-%d')}.log"
31
- logging.basicConfig(
32
- level=logging.INFO,
33
- format='%(asctime)s [elicitation_result] %(name)s - %(levelname)s - %(message)s',
34
- handlers=[logging.FileHandler(_log_file)],
35
- )
28
+ # Configure logging -- file handler only when GAIA_DEBUG is set; no
29
+ # hooks-*.log is written by default (see modules.core.logging_setup).
30
+ configure_hook_logging("elicitation_result")
36
31
  logger = logging.getLogger(__name__)
37
32
 
38
33
 
@@ -384,7 +384,8 @@ def requires_consolidation_report(task_info: Dict[str, Any]) -> bool:
384
384
  # Fallback: read from transcript if injected_context was not pre-extracted
385
385
  from .transcript_reader import extract_injected_context_payload_from_transcript
386
386
  payload = extract_injected_context_payload_from_transcript(
387
- task_info.get("agent_transcript_path", "")
387
+ task_info.get("agent_transcript_path", ""),
388
+ task_info.get("agent", ""),
388
389
  )
389
390
  if not payload:
390
391
  return False
@@ -17,191 +17,6 @@ import logging
17
17
  logger = logging.getLogger(__name__)
18
18
 
19
19
 
20
- def _normalize_command_set(raw) -> list:
21
- """Coerce a raw ``command_set`` into the canonical ``[{command, rationale}]``.
22
-
23
- Mirrors the normalization in ``bash_validator._build_sealed_payload`` and
24
- ``approval_grants.activate_db_pending_by_prefix`` so the intake writes the
25
- exact shape the activation/consume sides expect. Items without a non-empty
26
- ``command`` are dropped; ``rationale`` defaults to "".
27
- """
28
- out: list = []
29
- if isinstance(raw, list):
30
- for item in raw:
31
- if isinstance(item, dict) and item.get("command"):
32
- out.append(
33
- {
34
- "command": item["command"],
35
- "rationale": item.get("rationale", ""),
36
- }
37
- )
38
- return out
39
-
40
-
41
- def _filter_mutative_command_set(items: list) -> list:
42
- """Keep only the command_set items whose command is mutative/T3.
43
-
44
- The consume side (``bash_validator._validate_single_command``) gates the
45
- whole COMMAND_SET match path on ``detect_mutative_command(command).is_mutative``:
46
- a command that the matcher does not see as mutative NEVER reaches
47
- ``match_command_set_grant`` and its index is therefore NEVER consumed. If
48
- such a command is included in the grant's ``command_set``, ``len(consumed)``
49
- can never reach ``len(command_set)`` and the grant is stuck PENDING forever
50
- (it never flips to CONSUMED). To stay in lockstep with the consume gate, the
51
- intake filters with the EXACT same predicate, dropping non-mutative commands
52
- (e.g. ``touch``, ``ls``, ``cat``) before the grant is ever minted.
53
-
54
- Items that fail to classify (import error, unexpected exception) are kept --
55
- failing open here is safer than silently dropping a command from a consent
56
- batch the user is about to approve.
57
- """
58
- try:
59
- from modules.security.mutative_verbs import detect_mutative_command
60
- except ImportError:
61
- import pathlib as _pl
62
- import sys as _sys
63
-
64
- _hooks_root = _pl.Path(__file__).resolve().parent.parent.parent
65
- _sys.path.insert(0, str(_hooks_root))
66
- from modules.security.mutative_verbs import detect_mutative_command
67
-
68
- kept: list = []
69
- for item in items:
70
- command = item.get("command", "")
71
- try:
72
- if detect_mutative_command(command).is_mutative:
73
- kept.append(item)
74
- except Exception:
75
- # Fail open: if classification raises, keep the item rather than
76
- # silently dropping a command from the user's consent batch.
77
- kept.append(item)
78
- return kept
79
-
80
-
81
- def _intake_command_set_pending(
82
- approval_req: dict,
83
- *,
84
- agent_id,
85
- session_id: str,
86
- ) -> str | None:
87
- """INTAKE bridge: plan-first COMMAND_SET envelope -> ONE pending row.
88
-
89
- When a subagent emits an ``APPROVAL_REQUEST`` whose ``approval_request``
90
- carries a ``command_set`` of >= 2 ``{command, rationale}`` items and NO
91
- ``approval_id`` (plan-first: the batch is declared up-front, before any
92
- command was attempted/blocked), this persists exactly ONE pending approval
93
- whose ``payload_json`` contains the ``command_set`` key. That is the signal
94
- ``activate_db_pending_by_prefix`` reads (Step 3b) to branch into
95
- ``create_command_set_grant`` on user approval.
96
-
97
- Mutative filtering (Thread a): the command_set is first reduced to ONLY the
98
- commands the consume side will treat as mutative/T3 -- see
99
- ``_filter_mutative_command_set``. Non-mutative commands (``touch``, ``ls``,
100
- ...) never reach the bash_validator matcher, so leaving them in the grant
101
- would strand its ``consumed_indexes_json`` short of completion and pin the
102
- grant at PENDING forever. After filtering:
103
-
104
- * >= 2 mutative items -> mint the COMMAND_SET over exactly those items.
105
- * exactly 1 mutative -> NOT a batch. Return None; the caller falls
106
- through to the singular ``approval_id`` path and the lone command is
107
- gated by the normal hook-block / SCOPE_SEMANTIC_SIGNATURE flow when the
108
- agent attempts it. We deliberately do NOT degrade-to-singular here: this
109
- function's contract is "mint a COMMAND_SET or stand aside", and the
110
- singular flow is owned end-to-end by the hook block path -- minting a
111
- singular row from here would duplicate that ownership.
112
- * 0 mutative -> nothing to approve. Return None (no pending).
113
-
114
- A raw ``command_set`` of <= 1 item is likewise not a batch and returns None
115
- before filtering, preserving the original contract (never mint for one
116
- command, never degrade a batch the other way) and the working plan-first
117
- flow for genuine multi-command mutative batches.
118
-
119
- Returns the minted ``approval_id`` (``P-{uuid4hex}``) on success, or None
120
- when this is not a plan-first command_set envelope (no action taken).
121
- """
122
- if not isinstance(approval_req, dict):
123
- return None
124
- # Plan-first is defined by command_set present AND no approval_id. A request
125
- # that already carries an approval_id was minted by the hook block path; it
126
- # is the singular flow and must not be re-intaken here.
127
- if approval_req.get("approval_id"):
128
- return None
129
-
130
- raw_items = _normalize_command_set(approval_req.get("command_set"))
131
- if len(raw_items) < 2:
132
- # 0 or 1 item: not a batch. Singular path owns it.
133
- return None
134
-
135
- # Reduce to the mutative/T3 commands only -- the exact predicate the consume
136
- # side uses to decide whether a command reaches the COMMAND_SET matcher.
137
- command_set_items = _filter_mutative_command_set(raw_items)
138
- if len(command_set_items) < 2:
139
- # After filtering there is no batch left: either every command was
140
- # non-mutative (0 -> nothing to approve) or just one mutative command
141
- # remained (1 -> singular path owns it). Either way, no COMMAND_SET.
142
- logger.info(
143
- "INTAKE: command_set not minted -- %d/%d items mutative after filter "
144
- "(need >= 2 for a batch)",
145
- len(command_set_items), len(raw_items),
146
- )
147
- return None
148
-
149
- # Build a sealed_payload that mirrors bash_validator._build_sealed_payload's
150
- # COMMAND_SET shape: command_set verbatim + commands listing every string.
151
- # Carry through the subagent's operation/risk fields when present so the
152
- # orchestrator's presentation has real values, falling back to neutral
153
- # COMMAND_SET defaults otherwise.
154
- first_command = command_set_items[0]["command"]
155
- sealed_payload = {
156
- "operation": approval_req.get("operation")
157
- or f"COMMAND_SET intercepted: {len(command_set_items)} commands under one consent",
158
- "exact_content": approval_req.get("exact_content") or first_command,
159
- "scope": approval_req.get("scope")
160
- or (first_command.split()[0] if first_command.strip() else "unknown"),
161
- "risk_level": approval_req.get("risk_level") or "medium",
162
- "rollback_hint": approval_req.get("rollback") or approval_req.get("rollback_hint"),
163
- "rationale": approval_req.get("rationale")
164
- or (
165
- f"A batch of {len(command_set_items)} related T3 commands requires user "
166
- "approval under one consent per the COMMAND_SET policy."
167
- ),
168
- "commands": [it["command"] for it in command_set_items],
169
- "command_set": command_set_items,
170
- }
171
-
172
- try:
173
- from gaia.approvals.store import derive_command_set_id, insert_requested
174
- except ImportError:
175
- import pathlib as _pl
176
- import sys as _sys
177
-
178
- _repo_root = _pl.Path(__file__).resolve().parent.parent.parent.parent
179
- _sys.path.insert(0, str(_repo_root))
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
- )
191
-
192
- approval_id = insert_requested(
193
- sealed_payload,
194
- agent_id=agent_id,
195
- session_id=session_id or None,
196
- approval_id=derived_id,
197
- )
198
- logger.info(
199
- "INTAKE: plan-first COMMAND_SET pending created approval_id=%s items=%d",
200
- (approval_id or "")[:16], len(command_set_items),
201
- )
202
- return approval_id
203
-
204
-
205
20
  def persist_handoff(
206
21
  parsed_contract,
207
22
  agent_output: str,
@@ -227,36 +42,6 @@ def persist_handoff(
227
42
 
228
43
  agent_id = task_info.get("agent_id") or task_info.get("agent") or "unknown"
229
44
 
230
- # ---------------------------------------------------------------------
231
- # INTAKE bridge (plan-first COMMAND_SET) -- run FIRST and INDEPENDENTLY.
232
- #
233
- # Minting the pending COMMAND_SET approval is the security-critical path:
234
- # it is the consent the user must act on. It must not be coupled to the
235
- # audit handoff-row write below -- if insert_agent_contract_handoff fails
236
- # for any reason, the user must still get the approval to review. So the
237
- # intake runs in its own isolated try, before the handoff-row write.
238
- #
239
- # Only plan-first envelopes act here: command_set >= 2 items AND no
240
- # approval_id. A <= 1 item set or a request that already carries an
241
- # approval_id (hook-block / singular path) is a no-op for the intake.
242
- # ---------------------------------------------------------------------
243
- minted_command_set_id = None
244
- if parsed_contract is not None:
245
- _env = parsed_contract if isinstance(parsed_contract, dict) else {}
246
- _approval_req = _env.get("approval_request")
247
- if isinstance(_approval_req, dict):
248
- try:
249
- minted_command_set_id = _intake_command_set_pending(
250
- _approval_req,
251
- agent_id=agent_id,
252
- session_id=session_id,
253
- )
254
- except Exception as _intake_exc:
255
- logger.warning(
256
- "M4: COMMAND_SET intake failed (non-blocking): %s",
257
- _intake_exc,
258
- )
259
-
260
45
  try:
261
46
  # Prefer a sibling gaia package if installed; fall back to the repo
262
47
  # layout where gaia/ lives two levels above hooks/.
@@ -317,11 +102,11 @@ def persist_handoff(
317
102
  envelope = parsed_contract if isinstance(parsed_contract, dict) else {}
318
103
  approval_req = envelope.get("approval_request")
319
104
  if approval_req and isinstance(approval_req, dict):
320
- # The approval_id is either the one the subagent relayed (hook-block
321
- # / singular path) or the one the INTAKE bridge just minted for a
322
- # plan-first COMMAND_SET. Either way it points at the pending row
323
- # the handoff_approvals audit row should link to.
324
- approval_id = approval_req.get("approval_id") or minted_command_set_id
105
+ # The approval_id is the one the subagent relayed (the hook-block
106
+ # / singular path, or a compound-command COMMAND_SET minted by
107
+ # bash_validator). It points at the pending row the
108
+ # handoff_approvals audit row should link to.
109
+ approval_id = approval_req.get("approval_id")
325
110
 
326
111
  if approval_id:
327
112
  # Look up the grant to determine the decision at stop time.
@@ -55,8 +55,15 @@ def build_task_info_from_hook_data(
55
55
  # Extract real task description from the first user message in the transcript
56
56
  transcript_path = hook_data.get("agent_transcript_path", "")
57
57
  task_description = extract_task_description_from_transcript(transcript_path)
58
- injected_context = extract_injected_context_payload_from_transcript(transcript_path)
59
- agent_type = hook_data.get("agent_type", "") or "unknown"
58
+ # Resolve agent name first: it is the key the context payload is stored
59
+ # under (context_injector writes f"{subagent_type}.json"), so the reader
60
+ # needs it to find the payload. The bare hook_data value (not the "unknown"
61
+ # default) is the write-side key.
62
+ agent_type_raw = hook_data.get("agent_type", "")
63
+ injected_context = extract_injected_context_payload_from_transcript(
64
+ transcript_path, agent_type_raw
65
+ )
66
+ agent_type = agent_type_raw or "unknown"
60
67
  if not task_description:
61
68
  task_description = f"SubagentStop for {agent_type}"
62
69
 
@@ -96,35 +96,55 @@ def extract_task_description_from_transcript(transcript_path: str) -> str:
96
96
 
97
97
  def extract_injected_context_payload_from_transcript(
98
98
  transcript_path: str,
99
+ agent_type: str = "",
99
100
  ) -> Dict[str, Any]:
100
101
  """Extract the auto-injected context payload from disk cache.
101
102
 
102
103
  Context is delivered via additionalContext and the payload is persisted to
103
104
  disk by context_injector. Prompts do not contain embedded payloads.
104
- """
105
- # Empty/None path guard. Without it, Path("").stem == "" and the substring
106
- # match below (``candidate.stem in "" or "" in candidate.stem``) is ALWAYS
107
- # True because ``"" in any_string`` is True -- so an empty path would match
108
- # (and return) the FIRST payload sitting in gaia-context-payloads/, making
109
- # the result depend on whatever happens to be in that directory. Mirror the
110
- # guard in read_first_user_content_from_transcript: no path, no match.
111
- if not transcript_path:
112
- return {}
113
105
 
106
+ The payload is written by context_injector.build_project_context keyed by
107
+ agent name: ``gaia-context-payloads/{agent_name}.json``. The reliable way to
108
+ find it is therefore ``agent_type`` (the SubagentStop event's ``agent_type``
109
+ / task_info ``agent``), which equals the write side's ``subagent_type``.
110
+
111
+ The legacy transcript-stem substring match is retained as a fallback for
112
+ callers that cannot supply ``agent_type`` -- but it never intersects the
113
+ write key (a transcript stem like ``agent-ae190a4da68d626d4`` shares no
114
+ substring with an agent name like ``developer``), so passing ``agent_type``
115
+ is what actually populates the context-snapshot telemetry.
116
+ """
114
117
  try:
115
118
  payload_dir = Path(os.environ.get("TMPDIR", "/tmp")) / "gaia-context-payloads"
116
- if payload_dir.exists():
117
- agent_file = Path(transcript_path).stem # e.g. "agent-ae190a4da68d626d4"
118
- # A stem that came out empty (e.g. path was "/" or "."): nothing to
119
- # match against, so the substring test would again degrade to the
120
- # always-true ``"" in candidate.stem``. Bail rather than grab an
121
- # arbitrary payload.
122
- if not agent_file:
123
- return {}
124
- # Match by agent ID substring
125
- for candidate in payload_dir.glob("*.json"):
126
- if candidate.stem in agent_file or agent_file in candidate.stem:
127
- return json.loads(candidate.read_text())
119
+ if not payload_dir.exists():
120
+ return {}
121
+
122
+ # Primary lookup: keyed by agent name, matching the write side.
123
+ if agent_type:
124
+ candidate = payload_dir / f"{agent_type}.json"
125
+ if candidate.exists():
126
+ return json.loads(candidate.read_text())
127
+
128
+ # Legacy fallback: transcript-stem substring match.
129
+ # Empty/None path guard. Without it, Path("").stem == "" and the
130
+ # substring match below (``candidate.stem in "" or "" in
131
+ # candidate.stem``) is ALWAYS True because ``"" in any_string`` is
132
+ # True -- so an empty path would match (and return) the FIRST payload
133
+ # sitting in gaia-context-payloads/, making the result depend on
134
+ # whatever happens to be in that directory. Mirror the guard in
135
+ # read_first_user_content_from_transcript: no path, no match.
136
+ if not transcript_path:
137
+ return {}
138
+ agent_file = Path(transcript_path).stem # e.g. "agent-ae190a4da68d626d4"
139
+ # A stem that came out empty (e.g. path was "/" or "."): nothing to
140
+ # match against, so the substring test would again degrade to the
141
+ # always-true ``"" in candidate.stem``. Bail rather than grab an
142
+ # arbitrary payload.
143
+ if not agent_file:
144
+ return {}
145
+ for candidate in payload_dir.glob("*.json"):
146
+ if candidate.stem in agent_file or agent_file in candidate.stem:
147
+ return json.loads(candidate.read_text())
128
148
  except Exception:
129
149
  pass
130
150
  return {}
@@ -10,7 +10,7 @@ import os
10
10
  import json
11
11
  import logging
12
12
  from pathlib import Path
13
- from datetime import datetime
13
+ from datetime import datetime, timezone
14
14
  from typing import Dict, Any, Optional
15
15
 
16
16
  from ..core.paths import get_logs_dir
@@ -56,7 +56,11 @@ class AuditLogger:
56
56
  exit_code: Exit code (0 = success)
57
57
  tier: Security tier
58
58
  """
59
- timestamp = datetime.now().isoformat()
59
+ # UTC, not local time: gaia metrics (bin/cli/metrics.py) filters
60
+ # "today" / anomaly windows by comparing against
61
+ # datetime.now(timezone.utc) -- a naive local timestamp here would
62
+ # silently drift the Activity Today section by the host's UTC offset.
63
+ timestamp = datetime.now(timezone.utc).isoformat()
60
64
 
61
65
  # Extract command for bash tools
62
66
  command = ""
@@ -76,7 +80,8 @@ class AuditLogger:
76
80
  }
77
81
 
78
82
  # Write to daily audit log (session log removed — was always "session-default.jsonl")
79
- daily_log_file = self.log_dir / f"audit-{datetime.now().strftime('%Y-%m-%d')}.jsonl"
83
+ # Filename rotates on UTC midnight, matching `timestamp` above.
84
+ daily_log_file = self.log_dir / f"audit-{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.jsonl"
80
85
  self._write_record(daily_log_file, audit_record)
81
86
 
82
87
  logger.debug(f"Logged execution: {tool_name} - {command[:50]} - {duration:.2f}s")
@@ -403,12 +403,19 @@ def build_project_context(
403
403
  orientation_lines.append("- **Metadata** -- session and environment metadata; use for debugging and traceability")
404
404
  orientation_section = "\n".join(orientation_lines) + "\n"
405
405
 
406
- # Save context_payload to disk for downstream hooks (SubagentStop)
406
+ # Save context_payload to disk for downstream hooks (SubagentStop).
407
+ # Keyed by agent name (subagent_type): it is the ONLY identifier that
408
+ # both this PreToolUse write side and the SubagentStop read side share
409
+ # reliably. The subagent's transcript hash does not exist yet at
410
+ # dispatch time, and the previously-used parameters['_agent_id'] was
411
+ # never populated anywhere in the codebase (it always fell through to
412
+ # subagent_type), so it was not a usable key. The read side
413
+ # (transcript_reader.extract_injected_context_payload_from_transcript)
414
+ # must resolve the same f"{agent_name}.json" name.
407
415
  try:
408
416
  payload_dir = Path(os.environ.get("TMPDIR", "/tmp")) / "gaia-context-payloads"
409
417
  payload_dir.mkdir(parents=True, exist_ok=True)
410
- agent_id = parameters.get("_agent_id", "") or subagent_type
411
- payload_path = payload_dir / f"{agent_id}.json"
418
+ payload_path = payload_dir / f"{subagent_type}.json"
412
419
  payload_path.write_text(json.dumps(context_payload, separators=(',', ':')))
413
420
  logger.debug(f"Context payload saved to {payload_path}")
414
421
  except Exception as exc:
@@ -6,20 +6,21 @@ Provides:
6
6
  - plugin_mode: Plugin registry membership check (has_plugin)
7
7
  - state: Pre/post hook state sharing
8
8
  - stdin: Stdin availability check (has_stdin_data)
9
+ - logging_setup: Shared hook logging config (configure_hook_logging)
9
10
  """
10
11
 
11
- from .paths import find_claude_dir, get_plugin_data_dir, get_logs_dir, get_metrics_dir, get_memory_dir
12
+ from .paths import find_claude_dir, get_plugin_data_dir, get_logs_dir, get_memory_dir
12
13
  from .plugin_mode import has_plugin
13
14
  from .state import HookState, get_hook_state, save_hook_state, clear_hook_state, get_session_id
14
15
  from .stdin import has_stdin_data
15
16
  from .hook_entry import run_hook
17
+ from .logging_setup import configure_hook_logging
16
18
 
17
19
  __all__ = [
18
20
  # Paths
19
21
  "find_claude_dir",
20
22
  "get_plugin_data_dir",
21
23
  "get_logs_dir",
22
- "get_metrics_dir",
23
24
  "get_memory_dir",
24
25
  # Plugin registry
25
26
  "has_plugin",
@@ -33,4 +34,6 @@ __all__ = [
33
34
  "has_stdin_data",
34
35
  # Hook entry
35
36
  "run_hook",
37
+ # Logging
38
+ "configure_hook_logging",
36
39
  ]
@@ -0,0 +1,60 @@
1
+ """
2
+ Shared logging setup for hook entry points.
3
+
4
+ Every hook entry point (pre_tool_use, post_tool_use, session_start, ...)
5
+ used to wire its own ``logging.basicConfig(handlers=[FileHandler(...)])``
6
+ writing to ``.claude/logs/hooks-YYYY-MM-DD.log``. That log is plain-text
7
+ Python debug output with no schema and no consumer -- unlike the audit log
8
+ (``modules.audit.logger``), nothing in Gaia reads it back. Writing it on
9
+ every turn, by default, in every installation, was pure noise.
10
+
11
+ ``configure_hook_logging()`` centralizes that wiring in one place and gates
12
+ the file handler behind ``GAIA_DEBUG``: by default, hook loggers attach a
13
+ ``NullHandler`` (``logger.info(...)`` / ``.error(...)`` calls become cheap
14
+ no-ops and no file is created). Set ``GAIA_DEBUG=1`` to opt into the old
15
+ behavior for local troubleshooting.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import os
21
+ from datetime import datetime
22
+
23
+ from .paths import get_logs_dir
24
+
25
+ _TRUE_VALUES = {"1", "true", "yes", "on"}
26
+
27
+
28
+ def _debug_enabled() -> bool:
29
+ """Whether GAIA_DEBUG opts the caller into file-based hook logging."""
30
+ return os.environ.get("GAIA_DEBUG", "").strip().lower() in _TRUE_VALUES
31
+
32
+
33
+ def configure_hook_logging(hook_name: str) -> None:
34
+ """Configure root logging for a hook entry point.
35
+
36
+ Args:
37
+ hook_name: Human-readable hook name used in the log format's
38
+ bracketed prefix when file logging is enabled (matches the
39
+ entry point's file name, e.g. "pre_tool_use").
40
+
41
+ By default (``GAIA_DEBUG`` unset), attaches a ``NullHandler`` to the
42
+ root logger so hook code that calls ``logger.info``/``.warning``/
43
+ ``.error`` is a cheap no-op and no ``hooks-*.log`` file is created.
44
+ When ``GAIA_DEBUG`` is truthy, attaches a ``FileHandler`` writing to
45
+ ``.claude/logs/hooks-YYYY-MM-DD.log``, matching the previous default
46
+ behavior, for local debugging.
47
+ """
48
+ root = logging.getLogger()
49
+ if _debug_enabled():
50
+ log_file = get_logs_dir() / f"hooks-{datetime.now().strftime('%Y-%m-%d')}.log"
51
+ logging.basicConfig(
52
+ level=logging.INFO,
53
+ format=f"%(asctime)s [{hook_name}] %(name)s - %(levelname)s - %(message)s",
54
+ handlers=[logging.FileHandler(log_file)],
55
+ )
56
+ elif not root.handlers:
57
+ root.addHandler(logging.NullHandler())
58
+
59
+
60
+ __all__ = ["configure_hook_logging"]
@@ -96,18 +96,6 @@ def get_logs_dir() -> Path:
96
96
  return logs_dir
97
97
 
98
98
 
99
- def get_metrics_dir() -> Path:
100
- """
101
- Get the metrics directory, creating it if necessary.
102
-
103
- Returns:
104
- Path to .claude/metrics/
105
- """
106
- metrics_dir = get_plugin_data_dir() / "metrics"
107
- metrics_dir.mkdir(parents=True, exist_ok=True)
108
- return metrics_dir
109
-
110
-
111
99
  def get_memory_dir(subdir: Optional[str] = None) -> Path:
112
100
  """
113
101
  Get the memory directory, creating it if necessary.