@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +11 -0
- package/agents/gaia-orchestrator.md +4 -10
- package/bin/README.md +1 -1
- package/bin/cli/approvals.py +0 -144
- package/bin/cli/cleanup.py +1 -30
- package/bin/cli/context.py +379 -6
- package/bin/cli/doctor.py +1 -1
- package/bin/cli/history.py +18 -6
- package/bin/cli/install.py +4 -2
- package/bin/cli/memory.py +86 -10
- package/bin/cli/metrics.py +1216 -275
- package/bin/cli/query.py +89 -0
- package/bin/cli/scan.py +68 -2
- package/bin/cli/uninstall.py +9 -5
- package/bin/pre-publish-validate.js +7 -4
- package/gaia/project.py +49 -20
- package/gaia/store/reader.py +175 -11
- package/gaia/store/schema.sql +180 -1
- package/gaia/store/writer.py +1146 -165
- package/hooks/README.md +2 -2
- package/hooks/adapters/claude_code.py +6 -12
- package/hooks/elicitation_result.py +4 -9
- package/hooks/modules/agents/contract_validator.py +2 -1
- package/hooks/modules/agents/handoff_persister.py +5 -220
- package/hooks/modules/agents/task_info_builder.py +9 -2
- package/hooks/modules/agents/transcript_reader.py +41 -21
- package/hooks/modules/audit/logger.py +8 -3
- package/hooks/modules/context/context_injector.py +10 -3
- package/hooks/modules/core/__init__.py +5 -2
- package/hooks/modules/core/logging_setup.py +60 -0
- package/hooks/modules/core/paths.py +0 -12
- package/hooks/modules/security/approval_grants.py +24 -92
- package/hooks/modules/security/gaia_db_write_guard.py +7 -2
- package/hooks/modules/security/mutative_verbs.py +194 -11
- package/hooks/modules/session/pending_scanner.py +24 -222
- package/hooks/modules/session/session_manifest.py +13 -288
- package/hooks/post_compact.py +4 -9
- package/hooks/post_tool_use.py +4 -9
- package/hooks/pre_compact.py +5 -10
- package/hooks/pre_tool_use.py +4 -11
- package/hooks/session_end_hook.py +4 -9
- package/hooks/session_start.py +18 -21
- package/hooks/stop_hook.py +5 -10
- package/hooks/subagent_start.py +5 -10
- package/hooks/subagent_stop.py +9 -23
- package/hooks/task_completed.py +5 -10
- package/hooks/user_prompt_submit.py +4 -9
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/migrations/schema.checksum +2 -2
- package/scripts/migrations/v21_to_v22.sql +80 -0
- package/scripts/migrations/v22_to_v23.sql +20 -0
- package/scripts/migrations/v23_to_v24.sql +30 -0
- package/scripts/migrations/v24_to_v25.sql +77 -0
- package/scripts/migrations/v25_to_v26.sql +116 -0
- package/skills/README.md +8 -0
- package/skills/agent-approval-protocol/SKILL.md +27 -27
- package/skills/agent-approval-protocol/reference.md +20 -12
- package/skills/agent-protocol/SKILL.md +1 -1
- package/skills/agent-protocol/examples.md +17 -9
- package/skills/agent-response/SKILL.md +1 -1
- package/skills/diagram-builder/GLOSSARY.md +77 -0
- package/skills/diagram-builder/SKILL.md +156 -0
- package/skills/diagram-builder/assets/README.md +45 -0
- package/skills/diagram-builder/assets/data/data.generated.js +77 -0
- package/skills/diagram-builder/assets/data/document.yaml +12 -0
- package/skills/diagram-builder/assets/data/pages/overview.yaml +47 -0
- package/skills/diagram-builder/assets/engine/build-data.mjs +51 -0
- package/skills/diagram-builder/assets/engine/engine.js +863 -0
- package/skills/diagram-builder/assets/index.html +638 -0
- package/skills/diagram-builder/assets/package.json +15 -0
- package/skills/diagram-builder/assets/tools/verify.mjs +128 -0
- package/skills/diagram-builder/reference.md +444 -0
- package/skills/execution/SKILL.md +3 -3
- package/skills/gaia-patterns/reference.md +2 -2
- package/skills/memory/SKILL.md +49 -0
- package/skills/orchestrator-present-approval/SKILL.md +90 -79
- package/skills/orchestrator-present-approval/reference.md +59 -42
- package/skills/orchestrator-present-approval/template.md +16 -14
- package/skills/pending-approvals/SKILL.md +80 -29
- package/skills/pending-approvals/reference.md +48 -60
- package/skills/security-tiers/SKILL.md +1 -1
- package/skills/subagent-request-approval/SKILL.md +65 -68
- package/skills/subagent-request-approval/reference.md +63 -63
- package/skills/visual-verify/SKILL.md +107 -0
- package/skills/visual-verify/scripts/screenshot.js +210 -0
- package/tools/scan/classify.py +584 -15
- package/tools/scan/migrate_workspace.py +9 -4
- package/tools/scan/store_populator.py +231 -2
- 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
|
|
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
|
|
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
|
-
#
|
|
1437
|
-
#
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
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.
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
321
|
-
# / singular path
|
|
322
|
-
#
|
|
323
|
-
#
|
|
324
|
-
approval_id = approval_req.get("approval_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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if
|
|
123
|
-
return
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|