cursorfleet 0.1.0__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.
- cursorfleet/__init__.py +9 -0
- cursorfleet/_templates/cursor/agents/architect.md +18 -0
- cursorfleet/_templates/cursor/agents/implementer.md +20 -0
- cursorfleet/_templates/cursor/agents/patcher.md +17 -0
- cursorfleet/_templates/cursor/agents/qa.md +21 -0
- cursorfleet/_templates/cursor/agents/reviewer.md +20 -0
- cursorfleet/_templates/cursor/agents/scout.md +17 -0
- cursorfleet/_templates/cursor/agents/tester.md +20 -0
- cursorfleet/_templates/cursor/agents-md/root.md +7 -0
- cursorfleet/_templates/cursor/agents-md/work.md +7 -0
- cursorfleet/_templates/cursor/config/config.toml +47 -0
- cursorfleet/_templates/cursor/hooks/entry.json +4 -0
- cursorfleet/_templates/cursor/rules/cursorfleet-core.mdc +10 -0
- cursorfleet/_templates/cursor/rules/cursorfleet-handoff.mdc +15 -0
- cursorfleet/_templates/cursor/skills/cursorfleet-artifacts/SKILL.md +57 -0
- cursorfleet/_templates/cursor/skills/cursorfleet-coordinator/SKILL.md +35 -0
- cursorfleet/adapters/__init__.py +0 -0
- cursorfleet/adapters/cursor/__init__.py +0 -0
- cursorfleet/adapters/cursor/blocks.py +104 -0
- cursorfleet/adapters/cursor/diagnostics.py +547 -0
- cursorfleet/adapters/cursor/fsutil.py +156 -0
- cursorfleet/adapters/cursor/hook_main.py +428 -0
- cursorfleet/adapters/cursor/hook_normalize.py +527 -0
- cursorfleet/adapters/cursor/hook_policy.py +76 -0
- cursorfleet/adapters/cursor/hook_sanitize.py +474 -0
- cursorfleet/adapters/cursor/hooksjson.py +305 -0
- cursorfleet/adapters/cursor/installer.py +626 -0
- cursorfleet/adapters/cursor/kit.py +210 -0
- cursorfleet/adapters/cursor/kit_probe.py +54 -0
- cursorfleet/adapters/cursor/lock.py +145 -0
- cursorfleet/adapters/cursor/templates.py +60 -0
- cursorfleet/adapters/cursor/validation.py +365 -0
- cursorfleet/adapters/cursor/workspace.py +80 -0
- cursorfleet/cli/__init__.py +0 -0
- cursorfleet/cli/commands/__init__.py +1 -0
- cursorfleet/cli/commands/_common.py +82 -0
- cursorfleet/cli/commands/_resolution.py +56 -0
- cursorfleet/cli/commands/doctor.py +81 -0
- cursorfleet/cli/commands/events.py +197 -0
- cursorfleet/cli/commands/index.py +55 -0
- cursorfleet/cli/commands/init.py +71 -0
- cursorfleet/cli/commands/migrate.py +43 -0
- cursorfleet/cli/commands/replay.py +76 -0
- cursorfleet/cli/commands/run.py +97 -0
- cursorfleet/cli/commands/status.py +210 -0
- cursorfleet/cli/commands/tui.py +47 -0
- cursorfleet/cli/commands/uninstall.py +73 -0
- cursorfleet/cli/commands/validate.py +69 -0
- cursorfleet/cli/main.py +81 -0
- cursorfleet/config/__init__.py +44 -0
- cursorfleet/config/io.py +27 -0
- cursorfleet/config/models.py +154 -0
- cursorfleet/config/roster.py +245 -0
- cursorfleet/events/__init__.py +39 -0
- cursorfleet/events/forbidden.py +45 -0
- cursorfleet/events/ids.py +109 -0
- cursorfleet/events/kinds.py +132 -0
- cursorfleet/events/models.py +213 -0
- cursorfleet/events/pathcheck.py +45 -0
- cursorfleet/git/__init__.py +0 -0
- cursorfleet/git/collector.py +226 -0
- cursorfleet/git/runner.py +135 -0
- cursorfleet/git/worktrees.py +71 -0
- cursorfleet/policy/__init__.py +0 -0
- cursorfleet/py.typed +0 -0
- cursorfleet/safeexe.py +70 -0
- cursorfleet/schema_gen.py +73 -0
- cursorfleet/state/__init__.py +0 -0
- cursorfleet/state/artifact_scan.py +309 -0
- cursorfleet/state/context.py +38 -0
- cursorfleet/state/event_store.py +227 -0
- cursorfleet/state/indexer.py +443 -0
- cursorfleet/state/lock.py +74 -0
- cursorfleet/state/models.py +268 -0
- cursorfleet/state/reducer.py +588 -0
- cursorfleet/state/retention.py +218 -0
- cursorfleet/state/run_builder.py +136 -0
- cursorfleet/state/run_model.py +43 -0
- cursorfleet/state/run_store.py +95 -0
- cursorfleet/state/runtime.py +645 -0
- cursorfleet/state/spool.py +186 -0
- cursorfleet/state/spool_read.py +242 -0
- cursorfleet/state/status_doc.py +293 -0
- cursorfleet/tui/__init__.py +0 -0
- cursorfleet/tui/app.py +581 -0
- cursorfleet/tui/data.py +545 -0
- cursorfleet/tui/filters.py +82 -0
- cursorfleet/tui/gates.py +255 -0
- cursorfleet/tui/help.py +101 -0
- cursorfleet/tui/layout.py +26 -0
- cursorfleet/tui/theme.py +176 -0
- cursorfleet/tui/views.py +1077 -0
- cursorfleet/tui/watch.py +33 -0
- cursorfleet/workflow/__init__.py +0 -0
- cursorfleet/workflow/artifacts.py +148 -0
- cursorfleet/workflow/frontmatter.py +232 -0
- cursorfleet-0.1.0.dist-info/METADATA +161 -0
- cursorfleet-0.1.0.dist-info/RECORD +101 -0
- cursorfleet-0.1.0.dist-info/WHEEL +4 -0
- cursorfleet-0.1.0.dist-info/entry_points.txt +3 -0
- cursorfleet-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
"""Allowlist normalizer: Cursor hook payload -> CursorFleet event dicts. Stdlib only.
|
|
2
|
+
|
|
3
|
+
Every handler reads an explicit list of payload fields; nothing is copied through
|
|
4
|
+
(no ``**payload``). Never read, so never persisted: ``user_email``, ``transcript_path``,
|
|
5
|
+
``agent_transcript_path``, ``cwd``, ``workspace_roots`` (only used to relativize paths in
|
|
6
|
+
``hook_main``), ``tool_input`` (except ``tool_input.command`` of Shell), ``tool_output``
|
|
7
|
+
(except the integer ``exitCode`` of a Shell result), ``edits``, ``task``, ``summary``,
|
|
8
|
+
``description``, ``error_message``, ``modified_files``, ``output``, ``agent_message``.
|
|
9
|
+
|
|
10
|
+
The output dicts are contract-tested against ``cursorfleet.events.models.Event``
|
|
11
|
+
(tests/unit/test_hook_normalize.py); the strings below mirror ``EventKind`` and friends
|
|
12
|
+
so this module need not import ``enum`` on the hot path.
|
|
13
|
+
|
|
14
|
+
PROVISIONAL (ADR 0001 section B, none of it observed live):
|
|
15
|
+
- Q1: whether tool hooks inside a subagent carry ``subagent_id``/``subagent_type``. If
|
|
16
|
+
they do, events are attributed ``exact`` (id present) or ``inferred`` (type only); if
|
|
17
|
+
not, they stay unattributed (``attribution: unknown``). We never guess from timing.
|
|
18
|
+
- Q2: how custom subagent names appear in ``subagent_type`` (the raw value is kept).
|
|
19
|
+
- ``subagentStop`` has no documented ``subagent_id``: it is attributed ``inferred`` and
|
|
20
|
+
the reducer pairs it with a start by type, then ordering/duration.
|
|
21
|
+
- Whether ``postToolUse.tool_output`` for Shell is JSON with ``exitCode`` (the docs
|
|
22
|
+
show this shape in an example only).
|
|
23
|
+
- ``session_id`` equals ``conversation_id`` for subagents (``parent_conversation_id`` is
|
|
24
|
+
not modelled).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import math
|
|
31
|
+
import time
|
|
32
|
+
|
|
33
|
+
from cursorfleet.adapters.cursor.hook_sanitize import (
|
|
34
|
+
PathResolver,
|
|
35
|
+
is_verify_command,
|
|
36
|
+
sanitize_command,
|
|
37
|
+
)
|
|
38
|
+
from cursorfleet.events.ids import derive_agent_id, new_event_id, safe_id
|
|
39
|
+
|
|
40
|
+
TYPE_CHECKING = False # avoids importing ``typing`` (~4 ms) on the hook hot path
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from collections.abc import Callable
|
|
43
|
+
|
|
44
|
+
SCHEMA_VERSION = "1.0"
|
|
45
|
+
PRODUCER = "cursor_hook"
|
|
46
|
+
_MAX_TOOL_OUTPUT_PARSE = 256 * 1024
|
|
47
|
+
_MAX_INT = 10**12
|
|
48
|
+
|
|
49
|
+
# Shared with the status/reducer layer: tool-name classes (lower-case).
|
|
50
|
+
SHELL_TOOLS = frozenset({"shell", "bash", "terminal"})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class HookContext:
|
|
54
|
+
"""Everything the normalizer needs besides the payload. Injectable for tests."""
|
|
55
|
+
|
|
56
|
+
__slots__ = (
|
|
57
|
+
"branch",
|
|
58
|
+
"commit",
|
|
59
|
+
"display_max",
|
|
60
|
+
"entropy",
|
|
61
|
+
"hash_commands",
|
|
62
|
+
"key",
|
|
63
|
+
"now_ns",
|
|
64
|
+
"producer_version",
|
|
65
|
+
"resolver",
|
|
66
|
+
"store_display",
|
|
67
|
+
"worktree_id",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
*,
|
|
73
|
+
now_ns: int,
|
|
74
|
+
producer_version: str,
|
|
75
|
+
resolver: PathResolver,
|
|
76
|
+
worktree_id: str | None = None,
|
|
77
|
+
branch: str | None = None,
|
|
78
|
+
commit: str | None = None,
|
|
79
|
+
key: bytes | None = None,
|
|
80
|
+
store_display: bool = True,
|
|
81
|
+
display_max: int = 200,
|
|
82
|
+
hash_commands: bool = True,
|
|
83
|
+
entropy: Callable[[], bytes] | None = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
self.now_ns = now_ns
|
|
86
|
+
self.producer_version = producer_version
|
|
87
|
+
self.resolver = resolver
|
|
88
|
+
self.worktree_id = worktree_id
|
|
89
|
+
self.branch = branch
|
|
90
|
+
self.commit = commit
|
|
91
|
+
self.key = key
|
|
92
|
+
self.store_display = store_display
|
|
93
|
+
self.display_max = display_max
|
|
94
|
+
self.hash_commands = hash_commands
|
|
95
|
+
self.entropy = entropy # returns 10 bytes; None means os.urandom
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------- scalar helpers
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def iso_utc(now_ns: int) -> str:
|
|
102
|
+
seconds, rem = divmod(now_ns // 1_000_000, 1000)
|
|
103
|
+
return time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(seconds)) + f".{rem:03d}Z"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _nonneg_int(value: object) -> int | None:
|
|
107
|
+
if isinstance(value, bool):
|
|
108
|
+
return None
|
|
109
|
+
if isinstance(value, int):
|
|
110
|
+
return value if 0 <= value <= _MAX_INT else None
|
|
111
|
+
if isinstance(value, float) and math.isfinite(value) and 0 <= value <= _MAX_INT:
|
|
112
|
+
return round(value)
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _percent(value: object) -> float | None:
|
|
117
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
118
|
+
return None
|
|
119
|
+
if not math.isfinite(value) or not 0 <= value <= 1000:
|
|
120
|
+
return None
|
|
121
|
+
return float(value)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _version(value: object) -> str | None:
|
|
125
|
+
if not isinstance(value, str) or not 1 <= len(value) <= 64:
|
|
126
|
+
return None
|
|
127
|
+
ok = value[0].isalnum() and value.isascii()
|
|
128
|
+
return value if ok and all(c.isalnum() or c in "._+-" for c in value) else None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _tool_name(value: object) -> str | None:
|
|
132
|
+
if not isinstance(value, str) or not value:
|
|
133
|
+
return None
|
|
134
|
+
cleaned = "".join(c if (c.isascii() and (c.isalnum() or c in "_:.-")) else "_" for c in value)
|
|
135
|
+
return cleaned[:128] or None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _branch(value: object) -> str | None:
|
|
139
|
+
if not isinstance(value, str) or not 1 <= len(value) <= 255 or not value.isprintable():
|
|
140
|
+
return None
|
|
141
|
+
return value
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _put(target: dict[str, object], key: str, value: object) -> None:
|
|
145
|
+
if value is not None:
|
|
146
|
+
target[key] = value
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _metrics(**values: int | float | None) -> dict[str, object] | None:
|
|
150
|
+
metrics: dict[str, object] = {k: v for k, v in values.items() if v is not None}
|
|
151
|
+
return metrics or None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _is_shell(tool_name: str | None) -> bool:
|
|
155
|
+
return tool_name is not None and tool_name.lower() in SHELL_TOOLS
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ---------------------------------------------------------------- event construction
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class _Builder:
|
|
162
|
+
"""Assembles event dicts that share one payload and context."""
|
|
163
|
+
|
|
164
|
+
def __init__(self, hook: str, payload: dict[str, object], ctx: HookContext) -> None:
|
|
165
|
+
self.hook = hook
|
|
166
|
+
self.payload = payload
|
|
167
|
+
self.ctx = ctx
|
|
168
|
+
self.index = 0
|
|
169
|
+
raw_session = payload.get("conversation_id")
|
|
170
|
+
if hook in {"sessionStart", "sessionEnd"} and not isinstance(raw_session, str):
|
|
171
|
+
raw_session = payload.get("session_id")
|
|
172
|
+
if not isinstance(raw_session, str) or not raw_session:
|
|
173
|
+
raw_session = payload.get("session_id")
|
|
174
|
+
self.session_id = safe_id(raw_session)
|
|
175
|
+
|
|
176
|
+
def event(
|
|
177
|
+
self,
|
|
178
|
+
kind: str,
|
|
179
|
+
*,
|
|
180
|
+
source: str = "observed",
|
|
181
|
+
identity: bool = True,
|
|
182
|
+
**fields: object,
|
|
183
|
+
) -> dict[str, object]:
|
|
184
|
+
ctx = self.ctx
|
|
185
|
+
now_ns = ctx.now_ns + self.index * 1_000_000
|
|
186
|
+
self.index += 1
|
|
187
|
+
entropy = ctx.entropy() if ctx.entropy is not None else None
|
|
188
|
+
event: dict[str, object] = {
|
|
189
|
+
"schema_version": SCHEMA_VERSION,
|
|
190
|
+
"event_id": new_event_id(now_ns // 1_000_000, entropy),
|
|
191
|
+
"ts": iso_utc(now_ns),
|
|
192
|
+
"producer": PRODUCER,
|
|
193
|
+
"producer_version": ctx.producer_version,
|
|
194
|
+
"source": source,
|
|
195
|
+
"kind": kind,
|
|
196
|
+
"session_id": self.session_id,
|
|
197
|
+
"hook": self.hook,
|
|
198
|
+
}
|
|
199
|
+
_put(event, "cursor_version", _version(self.payload.get("cursor_version")))
|
|
200
|
+
_put(event, "generation_id", safe_id(self.payload.get("generation_id")))
|
|
201
|
+
_put(event, "worktree_id", ctx.worktree_id)
|
|
202
|
+
if identity:
|
|
203
|
+
self._identity(event)
|
|
204
|
+
for key, value in fields.items():
|
|
205
|
+
_put(event, key, value)
|
|
206
|
+
return event
|
|
207
|
+
|
|
208
|
+
def _identity(self, event: dict[str, object]) -> None:
|
|
209
|
+
payload = self.payload
|
|
210
|
+
instance = safe_id(payload.get("subagent_id"))
|
|
211
|
+
role = safe_id(payload.get("subagent_type"))
|
|
212
|
+
if instance is None and role is None:
|
|
213
|
+
return
|
|
214
|
+
_put(event, "agent_role", role)
|
|
215
|
+
_put(event, "agent_instance_id", instance)
|
|
216
|
+
_put(event, "agent_id", derive_agent_id(role, instance))
|
|
217
|
+
event["attribution"] = "exact" if instance is not None else "inferred"
|
|
218
|
+
|
|
219
|
+
def command(self, raw: object, **kw: int | None) -> dict[str, object] | None:
|
|
220
|
+
ctx = self.ctx
|
|
221
|
+
return sanitize_command(
|
|
222
|
+
raw,
|
|
223
|
+
key=ctx.key,
|
|
224
|
+
store_display=ctx.store_display,
|
|
225
|
+
display_max=ctx.display_max,
|
|
226
|
+
hash_commands=ctx.hash_commands,
|
|
227
|
+
exit_code=kw.get("exit_code"),
|
|
228
|
+
duration_ms=kw.get("duration_ms"),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _shell_command_text(payload: dict[str, object]) -> object:
|
|
233
|
+
tool_input = payload.get("tool_input")
|
|
234
|
+
if isinstance(tool_input, dict):
|
|
235
|
+
return tool_input.get("command")
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _exit_code_from_output(payload: dict[str, object]) -> int | None:
|
|
240
|
+
"""Extract only an integer ``exitCode`` from a Shell ``tool_output``; drop the rest."""
|
|
241
|
+
output = payload.get("tool_output")
|
|
242
|
+
parsed: object = None
|
|
243
|
+
if isinstance(output, dict):
|
|
244
|
+
parsed = output
|
|
245
|
+
elif isinstance(output, str) and len(output) <= _MAX_TOOL_OUTPUT_PARSE:
|
|
246
|
+
stripped = output.lstrip()
|
|
247
|
+
if stripped.startswith("{"):
|
|
248
|
+
try:
|
|
249
|
+
parsed = json.loads(stripped)
|
|
250
|
+
except ValueError:
|
|
251
|
+
parsed = None
|
|
252
|
+
if isinstance(parsed, dict):
|
|
253
|
+
code = parsed.get("exitCode", parsed.get("exit_code"))
|
|
254
|
+
if isinstance(code, int) and not isinstance(code, bool) and -1024 <= code <= 1024:
|
|
255
|
+
return code
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
_STOP_STATUS: dict[str, tuple[str, str]] = {
|
|
260
|
+
"completed": ("waiting", "ok"),
|
|
261
|
+
"aborted": ("idle", "interrupted"),
|
|
262
|
+
"error": ("error", "failed"),
|
|
263
|
+
}
|
|
264
|
+
_END_OUTCOME: dict[str, str] = {
|
|
265
|
+
"completed": "ok",
|
|
266
|
+
"error": "failed",
|
|
267
|
+
"aborted": "interrupted",
|
|
268
|
+
"window_close": "interrupted",
|
|
269
|
+
"user_close": "interrupted",
|
|
270
|
+
}
|
|
271
|
+
_FAILURE_OUTCOME: dict[str, str] = {
|
|
272
|
+
"error": "failed",
|
|
273
|
+
"timeout": "timeout",
|
|
274
|
+
"permission_denied": "permission_denied",
|
|
275
|
+
}
|
|
276
|
+
_SUBAGENT_OUTCOME: dict[str, str] = {"completed": "ok", "aborted": "interrupted", "error": "failed"}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ---------------------------------------------------------------- handlers
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _h_session_start(b: _Builder) -> list[dict[str, object]]:
|
|
283
|
+
return [b.event("session.started", branch=b.ctx.branch, commit=b.ctx.commit)]
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _h_session_end(b: _Builder) -> list[dict[str, object]]:
|
|
287
|
+
p = b.payload
|
|
288
|
+
final = p.get("final_status")
|
|
289
|
+
key = final if isinstance(final, str) and final in _END_OUTCOME else p.get("reason")
|
|
290
|
+
outcome = _END_OUTCOME.get(key, "unknown") if isinstance(key, str) else "unknown"
|
|
291
|
+
status = {"ok": "done", "failed": "error"}.get(outcome)
|
|
292
|
+
return [
|
|
293
|
+
b.event(
|
|
294
|
+
"session.stopped",
|
|
295
|
+
outcome=outcome,
|
|
296
|
+
status=status,
|
|
297
|
+
metrics=_metrics(duration_ms=_nonneg_int(p.get("duration_ms"))),
|
|
298
|
+
)
|
|
299
|
+
]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _h_stop(b: _Builder) -> list[dict[str, object]]:
|
|
303
|
+
p = b.payload
|
|
304
|
+
raw_status = p.get("status")
|
|
305
|
+
status, outcome = (
|
|
306
|
+
_STOP_STATUS.get(raw_status, ("unknown", "unknown"))
|
|
307
|
+
if isinstance(raw_status, str)
|
|
308
|
+
else ("unknown", "unknown")
|
|
309
|
+
)
|
|
310
|
+
return [
|
|
311
|
+
b.event(
|
|
312
|
+
"status.changed",
|
|
313
|
+
status=status,
|
|
314
|
+
outcome=outcome,
|
|
315
|
+
metrics=_metrics(loop_count=_nonneg_int(p.get("loop_count"))),
|
|
316
|
+
)
|
|
317
|
+
]
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _h_pre_tool(b: _Builder) -> list[dict[str, object]]:
|
|
321
|
+
name = _tool_name(b.payload.get("tool_name"))
|
|
322
|
+
command = b.command(_shell_command_text(b.payload)) if _is_shell(name) else None
|
|
323
|
+
return [
|
|
324
|
+
b.event(
|
|
325
|
+
"tool.started",
|
|
326
|
+
tool_name=name,
|
|
327
|
+
tool_use_id=safe_id(b.payload.get("tool_use_id")),
|
|
328
|
+
command=command,
|
|
329
|
+
)
|
|
330
|
+
]
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _verify_event(
|
|
334
|
+
b: _Builder, command: dict[str, object] | None, outcome: str, name: str | None
|
|
335
|
+
) -> list[dict[str, object]]:
|
|
336
|
+
if command is None:
|
|
337
|
+
return []
|
|
338
|
+
display = command.get("display")
|
|
339
|
+
argv0, sub = command.get("argv0"), command.get("subcommand")
|
|
340
|
+
if not is_verify_command(
|
|
341
|
+
argv0 if isinstance(argv0, str) else None,
|
|
342
|
+
sub if isinstance(sub, str) else None,
|
|
343
|
+
display if isinstance(display, str) else None,
|
|
344
|
+
):
|
|
345
|
+
return []
|
|
346
|
+
return [
|
|
347
|
+
b.event(
|
|
348
|
+
"verification.observed",
|
|
349
|
+
source="derived",
|
|
350
|
+
tool_name=name,
|
|
351
|
+
tool_use_id=safe_id(b.payload.get("tool_use_id")),
|
|
352
|
+
outcome=outcome,
|
|
353
|
+
command=command,
|
|
354
|
+
)
|
|
355
|
+
]
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _h_post_tool(b: _Builder) -> list[dict[str, object]]:
|
|
359
|
+
p = b.payload
|
|
360
|
+
name = _tool_name(p.get("tool_name"))
|
|
361
|
+
duration = _nonneg_int(p.get("duration"))
|
|
362
|
+
command = None
|
|
363
|
+
exit_code = None
|
|
364
|
+
if _is_shell(name):
|
|
365
|
+
exit_code = _exit_code_from_output(p)
|
|
366
|
+
command = b.command(_shell_command_text(p), exit_code=exit_code, duration_ms=duration)
|
|
367
|
+
events = [
|
|
368
|
+
b.event(
|
|
369
|
+
"tool.completed",
|
|
370
|
+
tool_name=name,
|
|
371
|
+
tool_use_id=safe_id(p.get("tool_use_id")),
|
|
372
|
+
outcome="ok",
|
|
373
|
+
command=command,
|
|
374
|
+
metrics=_metrics(duration_ms=duration),
|
|
375
|
+
)
|
|
376
|
+
]
|
|
377
|
+
test_outcome = "unknown" if exit_code is None else ("ok" if exit_code == 0 else "failed")
|
|
378
|
+
return events + _verify_event(b, command, test_outcome, name)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _h_post_tool_failure(b: _Builder) -> list[dict[str, object]]:
|
|
382
|
+
p = b.payload
|
|
383
|
+
name = _tool_name(p.get("tool_name"))
|
|
384
|
+
duration = _nonneg_int(p.get("duration"))
|
|
385
|
+
failure = p.get("failure_type")
|
|
386
|
+
outcome = _FAILURE_OUTCOME.get(failure, "failed") if isinstance(failure, str) else "failed"
|
|
387
|
+
if p.get("is_interrupt") is True:
|
|
388
|
+
outcome = "interrupted"
|
|
389
|
+
command = None
|
|
390
|
+
if _is_shell(name):
|
|
391
|
+
command = b.command(_shell_command_text(p), duration_ms=duration)
|
|
392
|
+
events = [
|
|
393
|
+
b.event(
|
|
394
|
+
"tool.failed",
|
|
395
|
+
tool_name=name,
|
|
396
|
+
tool_use_id=safe_id(p.get("tool_use_id")),
|
|
397
|
+
outcome=outcome,
|
|
398
|
+
command=command,
|
|
399
|
+
metrics=_metrics(duration_ms=duration),
|
|
400
|
+
)
|
|
401
|
+
]
|
|
402
|
+
return events + _verify_event(b, command, outcome, name)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _h_before_shell(b: _Builder) -> list[dict[str, object]]:
|
|
406
|
+
return [b.event("tool.started", tool_name="Shell", command=b.command(b.payload.get("command")))]
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _h_after_shell(b: _Builder) -> list[dict[str, object]]:
|
|
410
|
+
duration = _nonneg_int(b.payload.get("duration"))
|
|
411
|
+
return [
|
|
412
|
+
b.event(
|
|
413
|
+
"tool.completed",
|
|
414
|
+
tool_name="Shell",
|
|
415
|
+
command=b.command(b.payload.get("command"), duration_ms=duration),
|
|
416
|
+
metrics=_metrics(duration_ms=duration),
|
|
417
|
+
)
|
|
418
|
+
]
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _h_file_edit(b: _Builder) -> list[dict[str, object]]:
|
|
422
|
+
path = b.ctx.resolver.relativize(b.payload.get("file_path"))
|
|
423
|
+
return [b.event("file.changed", paths=[{"path": path, "op": "unknown"}])]
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _h_pre_compact(b: _Builder) -> list[dict[str, object]]:
|
|
427
|
+
p = b.payload
|
|
428
|
+
return [
|
|
429
|
+
b.event(
|
|
430
|
+
"context.compacted",
|
|
431
|
+
metrics=_metrics(
|
|
432
|
+
context_usage_percent=_percent(p.get("context_usage_percent")),
|
|
433
|
+
context_tokens=_nonneg_int(p.get("context_tokens")),
|
|
434
|
+
context_window_size=_nonneg_int(p.get("context_window_size")),
|
|
435
|
+
message_count=_nonneg_int(p.get("message_count")),
|
|
436
|
+
),
|
|
437
|
+
)
|
|
438
|
+
]
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _h_subagent_start(b: _Builder) -> list[dict[str, object]]:
|
|
442
|
+
p = b.payload
|
|
443
|
+
return [
|
|
444
|
+
b.event(
|
|
445
|
+
"subagent.started",
|
|
446
|
+
branch=_branch(p.get("git_branch")) or b.ctx.branch,
|
|
447
|
+
commit=b.ctx.commit if p.get("git_branch") is None else None,
|
|
448
|
+
tool_use_id=safe_id(p.get("tool_call_id")),
|
|
449
|
+
)
|
|
450
|
+
]
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _h_subagent_stop(b: _Builder) -> list[dict[str, object]]:
|
|
454
|
+
p = b.payload
|
|
455
|
+
raw = p.get("status")
|
|
456
|
+
outcome = _SUBAGENT_OUTCOME.get(raw, "unknown") if isinstance(raw, str) else "unknown"
|
|
457
|
+
return [
|
|
458
|
+
b.event(
|
|
459
|
+
"subagent.stopped",
|
|
460
|
+
outcome=outcome,
|
|
461
|
+
metrics=_metrics(
|
|
462
|
+
duration_ms=_nonneg_int(p.get("duration_ms")),
|
|
463
|
+
message_count=_nonneg_int(p.get("message_count")),
|
|
464
|
+
tool_call_count=_nonneg_int(p.get("tool_call_count")),
|
|
465
|
+
loop_count=_nonneg_int(p.get("loop_count")),
|
|
466
|
+
),
|
|
467
|
+
)
|
|
468
|
+
]
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
_HANDLERS = {
|
|
472
|
+
"sessionStart": _h_session_start,
|
|
473
|
+
"sessionEnd": _h_session_end,
|
|
474
|
+
"stop": _h_stop,
|
|
475
|
+
"preToolUse": _h_pre_tool,
|
|
476
|
+
"postToolUse": _h_post_tool,
|
|
477
|
+
"postToolUseFailure": _h_post_tool_failure,
|
|
478
|
+
"beforeShellExecution": _h_before_shell,
|
|
479
|
+
"afterShellExecution": _h_after_shell,
|
|
480
|
+
"afterFileEdit": _h_file_edit,
|
|
481
|
+
"preCompact": _h_pre_compact,
|
|
482
|
+
"subagentStart": _h_subagent_start,
|
|
483
|
+
"subagentStop": _h_subagent_stop,
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def handled_hooks() -> frozenset[str]:
|
|
488
|
+
"""Hook names this module can normalize (must equal ``ALLOWED_V01_HOOKS``; tested)."""
|
|
489
|
+
return frozenset(_HANDLERS)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def session_id_of(hook: str, payload: dict[str, object]) -> str | None:
|
|
493
|
+
return _Builder(hook, payload, _NULL_CTX).session_id
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def writer_of(hook: str, payload: dict[str, object]) -> str:
|
|
497
|
+
"""Spool writer name: the subagent instance id when a tool hook carries one, else ``main``.
|
|
498
|
+
|
|
499
|
+
``subagentStart``/``subagentStop`` are emitted on behalf of the parent, so they go to
|
|
500
|
+
``main`` (PROVISIONAL, ADR 0002: collapses to one file per session if Q1 is refuted).
|
|
501
|
+
"""
|
|
502
|
+
if hook in {"subagentStart", "subagentStop"}:
|
|
503
|
+
return "main"
|
|
504
|
+
return safe_id(payload.get("subagent_id")) or "main"
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def normalize(hook: str, payload: dict[str, object], ctx: HookContext) -> list[dict[str, object]]:
|
|
508
|
+
"""Return zero or more event dicts for an allowed hook. Never copies unlisted fields."""
|
|
509
|
+
handler = _HANDLERS.get(hook)
|
|
510
|
+
if handler is None:
|
|
511
|
+
return []
|
|
512
|
+
builder = _Builder(hook, payload, ctx)
|
|
513
|
+
if builder.session_id is None:
|
|
514
|
+
return []
|
|
515
|
+
return handler(builder)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
_NULL_CTX = HookContext(now_ns=0, producer_version="0", resolver=PathResolver([]))
|
|
519
|
+
|
|
520
|
+
__all__ = [
|
|
521
|
+
"HookContext",
|
|
522
|
+
"handled_hooks",
|
|
523
|
+
"iso_utc",
|
|
524
|
+
"normalize",
|
|
525
|
+
"session_id_of",
|
|
526
|
+
"writer_of",
|
|
527
|
+
]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Which Cursor hooks CursorFleet v0.1 may register. Stdlib only (hot-path safe).
|
|
2
|
+
|
|
3
|
+
This module is the single source of truth for the installer and for tests.
|
|
4
|
+
Policy: see ``docs/adr/0004-no-chain-of-thought-and-hook-policy.md``.
|
|
5
|
+
|
|
6
|
+
PROVISIONAL: the inventory and the permission-hook classification come from the
|
|
7
|
+
Cursor docs (ADR 0001, section A); no live capture has confirmed them.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
# The 12 passive hooks v0.1 registers. They never block and always fail open.
|
|
13
|
+
ALLOWED_V01_HOOKS: frozenset[str] = frozenset(
|
|
14
|
+
{
|
|
15
|
+
"sessionStart",
|
|
16
|
+
"sessionEnd",
|
|
17
|
+
"preToolUse",
|
|
18
|
+
"postToolUse",
|
|
19
|
+
"postToolUseFailure",
|
|
20
|
+
"subagentStart",
|
|
21
|
+
"subagentStop",
|
|
22
|
+
"beforeShellExecution",
|
|
23
|
+
"afterShellExecution",
|
|
24
|
+
"afterFileEdit",
|
|
25
|
+
"preCompact",
|
|
26
|
+
"stop",
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Hooks whose payload carries prompts, thinking, responses or file contents.
|
|
31
|
+
# The installer must NEVER emit them: the content would reach our process even
|
|
32
|
+
# if the parser discarded it. Do not remove entries without a superseding ADR.
|
|
33
|
+
FORBIDDEN_HOOKS: frozenset[str] = frozenset(
|
|
34
|
+
{
|
|
35
|
+
"afterAgentThought",
|
|
36
|
+
"afterAgentResponse",
|
|
37
|
+
"beforeSubmitPrompt",
|
|
38
|
+
"beforeReadFile",
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Other content-bearing or unneeded hooks that v0.1 also does not register.
|
|
43
|
+
# Registering one needs an ADR amendment plus a threat-model review.
|
|
44
|
+
UNREGISTERED_V01_HOOKS: frozenset[str] = frozenset(
|
|
45
|
+
{
|
|
46
|
+
"beforeTabFileRead",
|
|
47
|
+
"afterTabFileEdit",
|
|
48
|
+
"beforeMCPExecution",
|
|
49
|
+
"afterMCPExecution",
|
|
50
|
+
"workspaceOpen",
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Registered hooks that Cursor treats as permission hooks: invalid or
|
|
55
|
+
# schema-mismatching output blocks the action, so the fail-open reply must be
|
|
56
|
+
# ``{"permission": "allow"}`` rather than ``{}`` (ADR 0001, A2 and A3).
|
|
57
|
+
PERMISSION_HOOKS: frozenset[str] = frozenset(
|
|
58
|
+
{
|
|
59
|
+
"preToolUse",
|
|
60
|
+
"subagentStart",
|
|
61
|
+
"beforeShellExecution",
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
_PERMISSION_ALLOW = '{"permission":"allow"}'
|
|
66
|
+
_EMPTY = "{}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def is_registrable(hook_name: str) -> bool:
|
|
70
|
+
"""Return True only for hooks on the v0.1 allowlist (and not forbidden)."""
|
|
71
|
+
return hook_name in ALLOWED_V01_HOOKS and hook_name not in FORBIDDEN_HOOKS
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def fail_open_response(hook_name: str) -> str:
|
|
75
|
+
"""JSON text a hook prints when it cannot or should not do anything else."""
|
|
76
|
+
return _PERMISSION_ALLOW if hook_name in PERMISSION_HOOKS else _EMPTY
|