ww-agentic-workflows 1.0.0.dev3__py3-none-any.whl
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.
- ww/__init__.py +18 -0
- ww/_bundled_extensions/ww/git/extension.py +1728 -0
- ww/action_execution.py +887 -0
- ww/actions/__init__.py +94 -0
- ww/actions/command.py +444 -0
- ww/actions/contracts.py +699 -0
- ww/actions/extension.py +197 -0
- ww/actions/mcp.py +84 -0
- ww/actions/prompt.py +74 -0
- ww/actions/skill.py +62 -0
- ww/actions/slash_command.py +63 -0
- ww/agents.py +151 -0
- ww/amendments.py +54 -0
- ww/artifacts.py +93 -0
- ww/assessments.py +181 -0
- ww/assets/__init__.py +2 -0
- ww/assets/agent_instructions.md +49 -0
- ww/assets/docs/examples.md +879 -0
- ww/assets/docs/features.md +4639 -0
- ww/assets/docs/specification.md +1876 -0
- ww/assets/noww_skill.md +11 -0
- ww/assets/workflows/catchall.yaml +26 -0
- ww/assets/workflows/onboarding.yaml +586 -0
- ww/assets/workflows/scriptize.yaml +130 -0
- ww/assets/ww-automate_skill.md +23 -0
- ww/assets/ww-deduce-feedback_skill.md +38 -0
- ww/assets/ww-feedback-rules_skill.md +48 -0
- ww/assets/ww-learn-project_skill.md +22 -0
- ww/assets/ww-refresh_skill.md +26 -0
- ww/assets/ww-rule_skill.md +83 -0
- ww/assets/ww-rules-from-artifacts_skill.md +22 -0
- ww/assets/ww-scriptize_skill.md +33 -0
- ww/assets/ww-setup_skill.md +94 -0
- ww/assets/ww-solve_skill.md +23 -0
- ww/assets/ww-suggest_skill.md +32 -0
- ww/assets/ww-wizard_skill.md +105 -0
- ww/assets/ww_skill.md +59 -0
- ww/assignments.py +283 -0
- ww/bootstrap.py +405 -0
- ww/builtin_workflows.py +215 -0
- ww/changes.py +225 -0
- ww/child_coordination.py +482 -0
- ww/children.py +106 -0
- ww/claude_permissions.py +115 -0
- ww/cli/__init__.py +7 -0
- ww/cli/__main__.py +6 -0
- ww/cli/audit.py +129 -0
- ww/cli/catalogs.py +131 -0
- ww/cli/discover.py +607 -0
- ww/cli/initialization.py +898 -0
- ww/cli/lookup.py +287 -0
- ww/cli/main.py +1768 -0
- ww/cli/parser.py +1200 -0
- ww/cli/prompts.py +217 -0
- ww/cli/updates.py +117 -0
- ww/completion_artifacts.py +156 -0
- ww/completion_inputs.py +39 -0
- ww/config/__init__.py +582 -0
- ww/config/actions.py +591 -0
- ww/config/composition.py +571 -0
- ww/config/rules.py +511 -0
- ww/config/steps.py +1220 -0
- ww/config/values.py +223 -0
- ww/config_files.py +191 -0
- ww/config_writes.py +264 -0
- ww/contracts.py +155 -0
- ww/control.py +41 -0
- ww/defaults.py +130 -0
- ww/design_docs.py +32 -0
- ww/discovery.py +104 -0
- ww/documents.py +217 -0
- ww/errors.py +18 -0
- ww/executable.py +43 -0
- ww/execution_models/__init__.py +64 -0
- ww/execution_models/construction.py +148 -0
- ww/execution_models/decoding.py +38 -0
- ww/execution_models/plan_codec.py +565 -0
- ww/execution_models/records.py +1206 -0
- ww/execution_models/runs.py +266 -0
- ww/extensions/__init__.py +40 -0
- ww/extensions/api.py +559 -0
- ww/extensions/registry.py +864 -0
- ww/extensions/store.py +78 -0
- ww/feedback.py +342 -0
- ww/handler_repairs.py +57 -0
- ww/hooks/__init__.py +40 -0
- ww/hooks/agents.py +380 -0
- ww/hooks/install.py +168 -0
- ww/hooks/notices.py +206 -0
- ww/hooks/records.py +209 -0
- ww/hooks/runtime.py +266 -0
- ww/hooks/transcripts.py +183 -0
- ww/inspect.py +896 -0
- ww/instructions/__init__.py +17 -0
- ww/instructions/builder.py +1682 -0
- ww/instructions/commands.py +335 -0
- ww/instructions/handoff.py +149 -0
- ww/instructions/models.py +686 -0
- ww/instructions/policy.py +219 -0
- ww/instructions/text.py +168 -0
- ww/interactions.py +187 -0
- ww/interpolation.py +37 -0
- ww/item_passes.py +167 -0
- ww/items.py +99 -0
- ww/locking.py +207 -0
- ww/metadata_publication.py +230 -0
- ww/onboarding.py +229 -0
- ww/open_work.py +236 -0
- ww/operations.py +193 -0
- ww/operator_ui/__init__.py +16 -0
- ww/operator_ui/page.html +351 -0
- ww/operator_ui/server.py +215 -0
- ww/operator_ui/session.py +389 -0
- ww/operator_ui/sheet.py +104 -0
- ww/operator_ui/view.py +109 -0
- ww/output.py +339 -0
- ww/output_adapters/__init__.py +12 -0
- ww/output_adapters/base.py +25 -0
- ww/output_adapters/json_adapter.py +37 -0
- ww/output_adapters/markdown.py +2293 -0
- ww/output_adapters/rule_pages.py +337 -0
- ww/output_adapters/terminal.py +21 -0
- ww/package_updates.py +167 -0
- ww/plan/__init__.py +38 -0
- ww/plan/actions.py +207 -0
- ww/plan/compiler.py +1492 -0
- ww/plan/constructs.py +456 -0
- ww/plan/models.py +665 -0
- ww/project_config.py +752 -0
- ww/recovery.py +401 -0
- ww/replanning.py +367 -0
- ww/results.py +77 -0
- ww/rule_checks.py +230 -0
- ww/rule_conversion.py +331 -0
- ww/rule_disputes.py +148 -0
- ww/rule_store.py +456 -0
- ww/rule_verification.py +714 -0
- ww/rule_views.py +447 -0
- ww/rule_writes.py +920 -0
- ww/run_coordination.py +158 -0
- ww/runtimes.py +105 -0
- ww/service.py +4405 -0
- ww/setup_apply.py +428 -0
- ww/step_values.py +20 -0
- ww/storage.py +447 -0
- ww/storage_adapters/__init__.py +36 -0
- ww/storage_adapters/base.py +540 -0
- ww/storage_adapters/filesystem.py +370 -0
- ww/storage_adapters/memory.py +195 -0
- ww/storage_adapters/project_metadata.py +69 -0
- ww/storage_adapters/task_document.py +484 -0
- ww/task_ids.py +114 -0
- ww/task_references.py +124 -0
- ww/transitions.py +1619 -0
- ww/updates.py +399 -0
- ww/upgrade.py +95 -0
- ww/validation.py +168 -0
- ww/variables.py +275 -0
- ww/workflow_config.py +854 -0
- ww/workflow_update.py +239 -0
- ww/workflow_validation.py +1260 -0
- ww/workspace.py +50 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/METADATA +690 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/RECORD +167 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/WHEEL +4 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/entry_points.txt +2 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/licenses/LICENSE +674 -0
ww/hooks/agents.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Per-agent hook protocols: what each agent sends ww, and what it expects back.
|
|
3
|
+
|
|
4
|
+
Every decision a hook makes belongs to ww (see :mod:`ww.hooks.runtime`). An
|
|
5
|
+
adapter only translates: it reads the agent's JSON payload into a
|
|
6
|
+
:class:`HookPayload`, renders ww's answer in the agent's reply shape, and
|
|
7
|
+
says where and how the agent registers project hooks. Adding an agent is
|
|
8
|
+
one more adapter in :data:`HOOK_AGENTS`.
|
|
9
|
+
|
|
10
|
+
The protocols were taken from each agent's documentation (September 2026):
|
|
11
|
+
Claude Code https://code.claude.com/docs/en/hooks, Codex
|
|
12
|
+
https://learn.chatgpt.com/docs/hooks, Cursor https://cursor.com/docs/hooks,
|
|
13
|
+
and Antigravity https://antigravity.google/docs/hooks/.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Literal
|
|
22
|
+
|
|
23
|
+
HookEvent = Literal["session-start", "stop", "interrupt"]
|
|
24
|
+
HOOK_EVENTS: tuple[HookEvent, ...] = ("session-start", "stop", "interrupt")
|
|
25
|
+
# Every command ww registers carries this, so ww recognises its own entries
|
|
26
|
+
# among the agent's other hooks without any marker the agent would reject.
|
|
27
|
+
HOOK_SIGNATURE = "ww hook "
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class HookPayload:
|
|
32
|
+
"""What ww needs from one hook call, whatever agent sent it."""
|
|
33
|
+
|
|
34
|
+
# The directory the agent session works in, when the agent says.
|
|
35
|
+
directory: Path | None = None
|
|
36
|
+
# Why the session started: startup, resume, clear, compact, or unknown.
|
|
37
|
+
source: str | None = None
|
|
38
|
+
# ``False`` when the agent calls the session-start hook for a later turn
|
|
39
|
+
# that needs no context (Antigravity's pre-invocation hook runs per call).
|
|
40
|
+
wants_context: bool = True
|
|
41
|
+
# The agent already continued once because of a stop hook.
|
|
42
|
+
continued: bool = False
|
|
43
|
+
# The stop is really an interruption: the user aborted the turn.
|
|
44
|
+
interrupted: bool = False
|
|
45
|
+
# The stop is a worker's, which the session delegated a step to, rather
|
|
46
|
+
# than the session's own; only agents that tell the two apart set it.
|
|
47
|
+
from_worker: bool = False
|
|
48
|
+
# The agent's own word for why a session ended or was interrupted.
|
|
49
|
+
reason: str | None = None
|
|
50
|
+
# The session's own transcript file, which the ``interrupt`` hook reads
|
|
51
|
+
# to recover a conversation; only agents whose format ww reads set it.
|
|
52
|
+
transcript_path: Path | None = None
|
|
53
|
+
# The agent's final reply of the turn, which a transcript written
|
|
54
|
+
# asynchronously may not hold yet.
|
|
55
|
+
last_agent_message: str | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Registration:
|
|
60
|
+
"""One native event ww listens to, and the ww event it maps onto."""
|
|
61
|
+
|
|
62
|
+
native: str
|
|
63
|
+
event: HookEvent
|
|
64
|
+
timeout: int = 10
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class HookAgent:
|
|
68
|
+
"""The protocol of one agent; subclasses fill in what differs."""
|
|
69
|
+
|
|
70
|
+
name: str
|
|
71
|
+
# The project file the agent reads hooks from, relative to the root.
|
|
72
|
+
settings_file: str
|
|
73
|
+
# The project file the agent keeps out of version control, if it has one.
|
|
74
|
+
local_settings_file: str | None = None
|
|
75
|
+
# Where the agent reads hooks for every project, for agents without one.
|
|
76
|
+
user_settings_file: str | None = None
|
|
77
|
+
registrations: tuple[Registration, ...]
|
|
78
|
+
# The project file the agent reads command permissions from, for agents
|
|
79
|
+
# whose permission format ww knows; see :meth:`permissions`.
|
|
80
|
+
permissions_file: str | None = None
|
|
81
|
+
|
|
82
|
+
def permissions(self, commands: tuple[str, ...]) -> dict[str, Any] | None:
|
|
83
|
+
"""The ``permissions_file`` content that allows ``commands`` unasked.
|
|
84
|
+
|
|
85
|
+
``None`` for an agent whose permission format ww does not know.
|
|
86
|
+
"""
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
def command(self, event: HookEvent) -> str:
|
|
90
|
+
"""The command the agent runs; it must work from task worktrees too."""
|
|
91
|
+
return (
|
|
92
|
+
f'"$(git rev-parse --show-toplevel 2>/dev/null || pwd)"/ww hook '
|
|
93
|
+
f"{event} --agent {self.name}"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def owns(self, command: object) -> bool:
|
|
97
|
+
return (
|
|
98
|
+
isinstance(command, str)
|
|
99
|
+
and HOOK_SIGNATURE in command
|
|
100
|
+
and f"--agent {self.name}" in command
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def parse(self, event: HookEvent, payload: dict[str, Any]) -> HookPayload:
|
|
104
|
+
return HookPayload(directory=_path(payload.get("cwd")))
|
|
105
|
+
|
|
106
|
+
def context_reply(self, text: str) -> str:
|
|
107
|
+
return json.dumps(
|
|
108
|
+
{
|
|
109
|
+
"hookSpecificOutput": {
|
|
110
|
+
"hookEventName": "SessionStart",
|
|
111
|
+
"additionalContext": text,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def continue_reply(self, message: str) -> str:
|
|
117
|
+
return json.dumps({"decision": "block", "reason": message})
|
|
118
|
+
|
|
119
|
+
# Registration in the agent's hooks file
|
|
120
|
+
|
|
121
|
+
def entries(self) -> dict[str, Any]:
|
|
122
|
+
"""ww's own entries, in the file's shape, keyed by native event."""
|
|
123
|
+
return {
|
|
124
|
+
registration.native: [
|
|
125
|
+
{
|
|
126
|
+
"hooks": [
|
|
127
|
+
{
|
|
128
|
+
"type": "command",
|
|
129
|
+
"command": self.command(registration.event),
|
|
130
|
+
"timeout": registration.timeout,
|
|
131
|
+
}
|
|
132
|
+
]
|
|
133
|
+
}
|
|
134
|
+
]
|
|
135
|
+
for registration in self.registrations
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
def merged(self, document: dict[str, Any]) -> dict[str, Any]:
|
|
139
|
+
"""``document`` with ww's entries in place of any earlier ones."""
|
|
140
|
+
result = self.without(document)
|
|
141
|
+
hooks = result.setdefault("hooks", {})
|
|
142
|
+
for native, groups in self.entries().items():
|
|
143
|
+
hooks.setdefault(native, []).extend(groups)
|
|
144
|
+
return result
|
|
145
|
+
|
|
146
|
+
def without(self, document: dict[str, Any]) -> dict[str, Any]:
|
|
147
|
+
"""``document`` with every entry ww registered removed."""
|
|
148
|
+
result: dict[str, Any] = json.loads(json.dumps(document))
|
|
149
|
+
hooks = result.get("hooks")
|
|
150
|
+
if not isinstance(hooks, dict):
|
|
151
|
+
return result
|
|
152
|
+
for native in list(hooks):
|
|
153
|
+
groups = hooks[native]
|
|
154
|
+
if not isinstance(groups, list):
|
|
155
|
+
continue
|
|
156
|
+
kept = [group for group in groups if not self._own_group(group)]
|
|
157
|
+
if kept:
|
|
158
|
+
hooks[native] = kept
|
|
159
|
+
else:
|
|
160
|
+
del hooks[native]
|
|
161
|
+
return result
|
|
162
|
+
|
|
163
|
+
def _own_group(self, group: object) -> bool:
|
|
164
|
+
if not isinstance(group, dict):
|
|
165
|
+
return False
|
|
166
|
+
handlers = group.get("hooks")
|
|
167
|
+
return (
|
|
168
|
+
isinstance(handlers, list)
|
|
169
|
+
and bool(handlers)
|
|
170
|
+
and all(
|
|
171
|
+
isinstance(handler, dict) and self.owns(handler.get("command"))
|
|
172
|
+
for handler in handlers
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class ClaudeCode(HookAgent):
|
|
178
|
+
name = "claudecode"
|
|
179
|
+
settings_file = ".claude/settings.json"
|
|
180
|
+
local_settings_file = ".claude/settings.local.json"
|
|
181
|
+
permissions_file = ".claude/settings.json"
|
|
182
|
+
# No matcher on SessionStart, so it fires for every source, compaction
|
|
183
|
+
# included. SubagentStart is left alone: workers get only their bootstrap.
|
|
184
|
+
registrations = (
|
|
185
|
+
Registration("SessionStart", "session-start"),
|
|
186
|
+
Registration("Stop", "stop"),
|
|
187
|
+
Registration("SubagentStop", "stop"),
|
|
188
|
+
# Esc fires no hook in Claude Code; the end of a session does.
|
|
189
|
+
Registration("SessionEnd", "interrupt", 5),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def command(self, event: HookEvent) -> str:
|
|
193
|
+
return f'"$CLAUDE_PROJECT_DIR"/ww hook {event} --agent {self.name}'
|
|
194
|
+
|
|
195
|
+
def permissions(self, commands: tuple[str, ...]) -> dict[str, Any] | None:
|
|
196
|
+
# A trailing " *" allows the command with any arguments and alone
|
|
197
|
+
# (https://code.claude.com/docs/en/permissions, "Wildcard patterns").
|
|
198
|
+
allowed = [f"Bash({command} *)" for command in commands]
|
|
199
|
+
return {"permissions": {"allow": allowed}}
|
|
200
|
+
|
|
201
|
+
def parse(self, event: HookEvent, payload: dict[str, Any]) -> HookPayload:
|
|
202
|
+
return HookPayload(
|
|
203
|
+
directory=_path(payload.get("cwd")),
|
|
204
|
+
source=_text(payload.get("source")),
|
|
205
|
+
continued=payload.get("stop_hook_active") is True,
|
|
206
|
+
reason=_text(payload.get("reason")),
|
|
207
|
+
from_worker=payload.get("hook_event_name") == "SubagentStop",
|
|
208
|
+
transcript_path=_path(payload.get("transcript_path")),
|
|
209
|
+
last_agent_message=_text(payload.get("last_assistant_message")),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class Codex(HookAgent):
|
|
214
|
+
name = "codex"
|
|
215
|
+
settings_file = ".codex/hooks.json"
|
|
216
|
+
user_settings_file = "~/.codex/hooks.json"
|
|
217
|
+
registrations = (
|
|
218
|
+
Registration("SessionStart", "session-start"),
|
|
219
|
+
Registration("Stop", "stop"),
|
|
220
|
+
Registration("SubagentStop", "stop"),
|
|
221
|
+
# Codex allows its interrupt hook at most three seconds.
|
|
222
|
+
Registration("Interrupt", "interrupt", 3),
|
|
223
|
+
Registration("SessionEnd", "interrupt", 5),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def parse(self, event: HookEvent, payload: dict[str, Any]) -> HookPayload:
|
|
227
|
+
native = _text(payload.get("hook_event_name"))
|
|
228
|
+
return HookPayload(
|
|
229
|
+
directory=_path(payload.get("cwd")),
|
|
230
|
+
source=_text(payload.get("source")),
|
|
231
|
+
continued=payload.get("stop_hook_active") is True,
|
|
232
|
+
reason=(
|
|
233
|
+
"interrupted" if native == "Interrupt" else _text(payload.get("reason"))
|
|
234
|
+
),
|
|
235
|
+
from_worker=native == "SubagentStop",
|
|
236
|
+
transcript_path=_path(payload.get("transcript_path")),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class Cursor(HookAgent):
|
|
241
|
+
name = "cursor"
|
|
242
|
+
settings_file = ".cursor/hooks.json"
|
|
243
|
+
user_settings_file = "~/.cursor/hooks.json"
|
|
244
|
+
registrations = (
|
|
245
|
+
Registration("sessionStart", "session-start"),
|
|
246
|
+
Registration("stop", "stop"),
|
|
247
|
+
Registration("subagentStop", "stop"),
|
|
248
|
+
Registration("sessionEnd", "interrupt", 5),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def command(self, event: HookEvent) -> str:
|
|
252
|
+
# Cursor runs project hooks from the project root.
|
|
253
|
+
return f"./ww hook {event} --agent {self.name}"
|
|
254
|
+
|
|
255
|
+
def parse(self, event: HookEvent, payload: dict[str, Any]) -> HookPayload:
|
|
256
|
+
roots = payload.get("workspace_roots")
|
|
257
|
+
directory = _path(roots[0]) if isinstance(roots, list) and roots else None
|
|
258
|
+
status = _text(payload.get("status"))
|
|
259
|
+
loop_count = payload.get("loop_count")
|
|
260
|
+
return HookPayload(
|
|
261
|
+
directory=directory,
|
|
262
|
+
continued=isinstance(loop_count, int) and loop_count > 0,
|
|
263
|
+
interrupted=status == "aborted",
|
|
264
|
+
reason=_text(payload.get("reason")) or status,
|
|
265
|
+
from_worker=payload.get("hook_event_name") == "subagentStop",
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def context_reply(self, text: str) -> str:
|
|
269
|
+
return json.dumps({"additional_context": text})
|
|
270
|
+
|
|
271
|
+
def continue_reply(self, message: str) -> str:
|
|
272
|
+
return json.dumps({"followup_message": message})
|
|
273
|
+
|
|
274
|
+
def entries(self) -> dict[str, Any]:
|
|
275
|
+
return {
|
|
276
|
+
registration.native: [
|
|
277
|
+
{
|
|
278
|
+
"command": self.command(registration.event),
|
|
279
|
+
"timeout": registration.timeout,
|
|
280
|
+
}
|
|
281
|
+
]
|
|
282
|
+
for registration in self.registrations
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
def merged(self, document: dict[str, Any]) -> dict[str, Any]:
|
|
286
|
+
result = super().merged(document)
|
|
287
|
+
result.setdefault("version", 1)
|
|
288
|
+
return result
|
|
289
|
+
|
|
290
|
+
def without(self, document: dict[str, Any]) -> dict[str, Any]:
|
|
291
|
+
result: dict[str, Any] = json.loads(json.dumps(document))
|
|
292
|
+
hooks = result.get("hooks")
|
|
293
|
+
if not isinstance(hooks, dict):
|
|
294
|
+
return result
|
|
295
|
+
for native in list(hooks):
|
|
296
|
+
handlers = hooks[native]
|
|
297
|
+
if not isinstance(handlers, list):
|
|
298
|
+
continue
|
|
299
|
+
kept = [
|
|
300
|
+
handler
|
|
301
|
+
for handler in handlers
|
|
302
|
+
if not (isinstance(handler, dict) and self.owns(handler.get("command")))
|
|
303
|
+
]
|
|
304
|
+
if kept:
|
|
305
|
+
hooks[native] = kept
|
|
306
|
+
else:
|
|
307
|
+
del hooks[native]
|
|
308
|
+
return result
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# Termination reasons an Antigravity Stop hook reports for a normal end. The
|
|
312
|
+
# documentation names these; any reason naming a cancellation is treated as
|
|
313
|
+
# an interruption, and every other one as a normal stop.
|
|
314
|
+
_ANTIGRAVITY_INTERRUPTS = ("cancel", "interrupt", "abort", "user")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class Antigravity(HookAgent):
|
|
318
|
+
name = "antigravity"
|
|
319
|
+
settings_file = ".agents/hooks.json"
|
|
320
|
+
user_settings_file = "~/.gemini/config/hooks.json"
|
|
321
|
+
# Antigravity groups hooks under a name of the owner's choosing.
|
|
322
|
+
group = "ww"
|
|
323
|
+
# There is no session-start event: the first pre-invocation of a
|
|
324
|
+
# conversation is its start.
|
|
325
|
+
registrations = (
|
|
326
|
+
Registration("PreInvocation", "session-start"),
|
|
327
|
+
Registration("Stop", "stop"),
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
def parse(self, event: HookEvent, payload: dict[str, Any]) -> HookPayload:
|
|
331
|
+
paths = payload.get("workspacePaths")
|
|
332
|
+
directory = _path(paths[0]) if isinstance(paths, list) and paths else None
|
|
333
|
+
reason = _text(payload.get("terminationReason"))
|
|
334
|
+
return HookPayload(
|
|
335
|
+
directory=directory,
|
|
336
|
+
wants_context=payload.get("invocationNum", 0) == 0,
|
|
337
|
+
interrupted=(
|
|
338
|
+
reason is not None
|
|
339
|
+
and any(word in reason.lower() for word in _ANTIGRAVITY_INTERRUPTS)
|
|
340
|
+
),
|
|
341
|
+
reason=reason,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
def context_reply(self, text: str) -> str:
|
|
345
|
+
# An ephemeral message is not kept in the trajectory, so the context
|
|
346
|
+
# never accumulates even if the counter restarts every turn.
|
|
347
|
+
return json.dumps({"injectSteps": [{"ephemeralMessage": text}]})
|
|
348
|
+
|
|
349
|
+
def continue_reply(self, message: str) -> str:
|
|
350
|
+
return json.dumps({"decision": "continue", "reason": message})
|
|
351
|
+
|
|
352
|
+
def merged(self, document: dict[str, Any]) -> dict[str, Any]:
|
|
353
|
+
result: dict[str, Any] = json.loads(json.dumps(document))
|
|
354
|
+
result[self.group] = {"enabled": True, **self.entries()}
|
|
355
|
+
return result
|
|
356
|
+
|
|
357
|
+
def without(self, document: dict[str, Any]) -> dict[str, Any]:
|
|
358
|
+
result: dict[str, Any] = json.loads(json.dumps(document))
|
|
359
|
+
result.pop(self.group, None)
|
|
360
|
+
return result
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
HOOK_AGENTS: dict[str, HookAgent] = {
|
|
364
|
+
agent.name: agent for agent in (ClaudeCode(), Codex(), Cursor(), Antigravity())
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def hook_agent(name: str) -> HookAgent:
|
|
369
|
+
agent = HOOK_AGENTS.get(name)
|
|
370
|
+
if agent is None:
|
|
371
|
+
raise KeyError(name)
|
|
372
|
+
return agent
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _path(value: object) -> Path | None:
|
|
376
|
+
return Path(value) if isinstance(value, str) and value else None
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _text(value: object) -> str | None:
|
|
380
|
+
return value if isinstance(value, str) and value else None
|
ww/hooks/install.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Register ww's hooks in an agent's project hooks file, or take them out.
|
|
3
|
+
|
|
4
|
+
The shared project file is the default, since the hooks call the project's
|
|
5
|
+
tracked launcher; ``local`` writes the file an agent keeps out of version
|
|
6
|
+
control instead, for agents that have one (Claude Code).
|
|
7
|
+
|
|
8
|
+
Installing merges ww's entries into whatever the file already holds and
|
|
9
|
+
leaves every other entry alone; ww recognises its own entries by their
|
|
10
|
+
command, so installing twice changes nothing and uninstalling removes
|
|
11
|
+
exactly what ww added. When the file cannot be read or written, the error
|
|
12
|
+
carries the path and the snippet to add by hand.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Literal
|
|
21
|
+
|
|
22
|
+
from ww.errors import StateError
|
|
23
|
+
from ww.storage import Storage
|
|
24
|
+
|
|
25
|
+
from .agents import HookAgent
|
|
26
|
+
|
|
27
|
+
InstallAction = Literal["installed", "unchanged", "removed", "absent"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class HookInstallError(StateError):
|
|
31
|
+
"""The hooks file could not be updated; the message says how to do it."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class HookInstallation:
|
|
36
|
+
agent: str
|
|
37
|
+
path: str
|
|
38
|
+
action: InstallAction
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def changed(self) -> bool:
|
|
42
|
+
return self.action in {"installed", "removed"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def hook_snippet(agent: HookAgent) -> str:
|
|
46
|
+
"""ww's entries alone, in the shape the agent's hooks file takes."""
|
|
47
|
+
return json.dumps(agent.merged({}), indent=2) + "\n"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def hooks_file(agent: HookAgent, local: bool = False) -> str:
|
|
51
|
+
"""The project file ww writes the agent's hooks to.
|
|
52
|
+
|
|
53
|
+
``local`` chooses the file the agent keeps out of version control, so
|
|
54
|
+
the hooks stay one person's choice; only some agents have one.
|
|
55
|
+
"""
|
|
56
|
+
if not local:
|
|
57
|
+
return agent.settings_file
|
|
58
|
+
if agent.local_settings_file is None:
|
|
59
|
+
elsewhere = (
|
|
60
|
+
f"; hooks for every project live in {agent.user_settings_file}"
|
|
61
|
+
if agent.user_settings_file
|
|
62
|
+
else ""
|
|
63
|
+
)
|
|
64
|
+
raise HookInstallError(
|
|
65
|
+
f"{agent.name} has no project-local hooks file, so --local cannot "
|
|
66
|
+
f"apply to it; its project file is {agent.settings_file}{elsewhere}"
|
|
67
|
+
)
|
|
68
|
+
return agent.local_settings_file
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def manual_instructions(agent: HookAgent, local: bool = False) -> str:
|
|
72
|
+
"""How to add ww's hooks for ``agent`` when ww cannot do it."""
|
|
73
|
+
return (
|
|
74
|
+
f"Merge this into {hooks_file(agent, local)} (create the file if it does "
|
|
75
|
+
f"not exist):\n\n{hook_snippet(agent)}"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def install_hooks(
|
|
80
|
+
storage: Storage, agent: HookAgent, *, local: bool = False
|
|
81
|
+
) -> HookInstallation:
|
|
82
|
+
relative = hooks_file(agent, local)
|
|
83
|
+
path = storage.root / relative
|
|
84
|
+
document = _read(path, agent, local)
|
|
85
|
+
merged = agent.merged(document)
|
|
86
|
+
if merged == document:
|
|
87
|
+
return HookInstallation(agent.name, relative, "unchanged")
|
|
88
|
+
_write(storage, path, merged, agent, local)
|
|
89
|
+
return HookInstallation(agent.name, relative, "installed")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def uninstall_hooks(
|
|
93
|
+
storage: Storage, agent: HookAgent, *, local: bool = False
|
|
94
|
+
) -> HookInstallation:
|
|
95
|
+
relative = hooks_file(agent, local)
|
|
96
|
+
path = storage.root / relative
|
|
97
|
+
if not path.exists():
|
|
98
|
+
return HookInstallation(agent.name, relative, "absent")
|
|
99
|
+
document = _read(path, agent, local)
|
|
100
|
+
remaining = agent.without(document)
|
|
101
|
+
if remaining == document:
|
|
102
|
+
return HookInstallation(agent.name, relative, "absent")
|
|
103
|
+
_write(storage, path, remaining, agent, local)
|
|
104
|
+
return HookInstallation(agent.name, relative, "removed")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def hooks_installed(storage: Storage, agent: HookAgent, *, local: bool = False) -> bool:
|
|
108
|
+
"""Whether the agent's hooks file already holds ww's current entries."""
|
|
109
|
+
path = storage.root / hooks_file(agent, local)
|
|
110
|
+
try:
|
|
111
|
+
document = _read(path, agent, local)
|
|
112
|
+
except HookInstallError:
|
|
113
|
+
return False
|
|
114
|
+
return path.exists() and agent.merged(document) == document
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def registered_elsewhere(
|
|
118
|
+
storage: Storage, agent: HookAgent, *, local: bool = False
|
|
119
|
+
) -> str | None:
|
|
120
|
+
"""The agent's other project file, when it already holds ww's hooks.
|
|
121
|
+
|
|
122
|
+
Hooks registered in both the shared and the local file fire twice for
|
|
123
|
+
every event, so the session-start context lands twice in the context.
|
|
124
|
+
"""
|
|
125
|
+
if agent.local_settings_file is None:
|
|
126
|
+
return None
|
|
127
|
+
other = hooks_file(agent, not local)
|
|
128
|
+
path = storage.root / other
|
|
129
|
+
try:
|
|
130
|
+
document = _read(path, agent, not local)
|
|
131
|
+
except HookInstallError:
|
|
132
|
+
return None
|
|
133
|
+
return other if agent.without(document) != document else None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _read(path: Path, agent: HookAgent, local: bool) -> dict[str, Any]:
|
|
137
|
+
if not path.exists():
|
|
138
|
+
return {}
|
|
139
|
+
relative = hooks_file(agent, local)
|
|
140
|
+
try:
|
|
141
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
142
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
143
|
+
raise HookInstallError(
|
|
144
|
+
f"cannot read {relative}: {error}. " + manual_instructions(agent, local)
|
|
145
|
+
) from error
|
|
146
|
+
if not isinstance(value, dict):
|
|
147
|
+
raise HookInstallError(
|
|
148
|
+
f"{relative} does not hold a JSON object. "
|
|
149
|
+
+ manual_instructions(agent, local)
|
|
150
|
+
)
|
|
151
|
+
return value
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _write(
|
|
155
|
+
storage: Storage,
|
|
156
|
+
path: Path,
|
|
157
|
+
document: dict[str, Any],
|
|
158
|
+
agent: HookAgent,
|
|
159
|
+
local: bool,
|
|
160
|
+
) -> None:
|
|
161
|
+
try:
|
|
162
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
storage.locks.atomic_write(path, json.dumps(document, indent=2) + "\n")
|
|
164
|
+
except OSError as error:
|
|
165
|
+
raise HookInstallError(
|
|
166
|
+
f"cannot write {hooks_file(agent, local)}: {error}. "
|
|
167
|
+
+ manual_instructions(agent, local)
|
|
168
|
+
) from error
|