@andespindola/crow-skills 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +6 -5
  2. package/crow_skills/__init__.py +1 -1
  3. package/crow_skills/autoupdate.py +398 -0
  4. package/crow_skills/cli.py +99 -3
  5. package/crow_skills/events.py +133 -0
  6. package/crow_skills/guide.py +7 -1
  7. package/crow_skills/hook.py +616 -0
  8. package/crow_skills/integrations.py +460 -63
  9. package/crow_skills/progress.py +157 -15
  10. package/crow_skills/resources.py +713 -0
  11. package/crow_skills/source.py +40 -0
  12. package/crow_skills/storage.py +5 -4
  13. package/docs/cli.md +144 -17
  14. package/package.json +1 -1
  15. package/release.json +56 -31
  16. package/skills/crow-accessibility/SKILL.md +2 -0
  17. package/skills/crow-api-test/SKILL.md +2 -0
  18. package/skills/crow-backend/SKILL.md +2 -0
  19. package/skills/crow-behaviour/SKILL.md +4 -3
  20. package/skills/crow-behaviour/references/orchestration.md +4 -0
  21. package/skills/crow-behaviour/references/progress-ledger.md +20 -3
  22. package/skills/crow-behaviour/references/resources.md +44 -0
  23. package/skills/crow-behaviour/scripts/crow_ledger.py +120 -4
  24. package/skills/crow-browser/SKILL.md +2 -0
  25. package/skills/crow-debug/SKILL.md +2 -0
  26. package/skills/crow-frontend/SKILL.md +2 -0
  27. package/skills/crow-infra/SKILL.md +2 -0
  28. package/skills/crow-interface/SKILL.md +2 -0
  29. package/skills/crow-media/SKILL.md +2 -0
  30. package/skills/crow-native-mobile/SKILL.md +2 -0
  31. package/skills/crow-performance/SKILL.md +2 -0
  32. package/skills/crow-qa/SKILL.md +2 -0
  33. package/skills/crow-release/SKILL.md +2 -0
  34. package/skills/crow-reverse/SKILL.md +2 -0
  35. package/skills/crow-security-scan/SKILL.md +2 -0
@@ -0,0 +1,616 @@
1
+ """crow-skills mode, per-prompt hook and event bus (crow-event/1).
2
+
3
+ Self-contained on purpose (standard library only, no package-relative
4
+ imports): the integrations copy this file next to the CLI state as
5
+ ``crow_hook.py`` and every agent CLI runs it from its hooks, so it keeps
6
+ working when the npm/Homebrew path of the CLI changes. The CLI imports the
7
+ same functions for ``crow-skills mode`` and ``crow-skills events``.
8
+
9
+ Contract for hooks: return fast, never block the agent, fail open. Any error
10
+ is swallowed and the hook exits 0 with no output. Nothing from a prompt,
11
+ message or tool input is ever stored or printed: only ids, host, cwd, model,
12
+ permission mode, event type, tool name, counters and timestamps.
13
+
14
+ crow_hook.py hook --host claude|codex|gemini|agy|devin --event NAME [--home DIR]
15
+ crow_hook.py mode on|off|status [--home DIR] [--by HOST] [--json]
16
+
17
+ On a prompt event the hook also appends one memory line to the context, only
18
+ when the machine is under pressure or the recommended parallelism is not
19
+ "parallel" (crow_resources.py, copied beside this file; cached for a few
20
+ seconds, so most prompts cost one small file read).
21
+ """
22
+ from datetime import datetime, timezone
23
+ import hashlib
24
+ import json
25
+ import os
26
+ from pathlib import Path
27
+ import re
28
+ import sys
29
+ import time
30
+ import uuid
31
+
32
+ MODE_SCHEMA = "crow-mode/1"
33
+ EVENT_SCHEMA = "crow-event/1"
34
+ SESSION_SCHEMA = "crow-session/1"
35
+ MODES = ("on", "off")
36
+ DEFAULT_MODE = "on"
37
+ HOSTS = ("claude", "codex", "gemini", "agy", "devin")
38
+ MAX_EVENTS_FILE = 1024 * 1024 # bytes before rotation
39
+ ROTATED = 2 # events.jsonl.1 .. events.jsonl.N are kept
40
+ SESSION_TTL = 24 * 3600 # snapshot files untouched for this long are pruned
41
+ MAX_SESSIONS = 200
42
+ MAX_STDIN = 4 * 1024 * 1024
43
+ TOKEN = re.compile(r"[^A-Za-z0-9_.:@/+-]")
44
+ COMMAND = re.compile(r"\s*/crow[ \t]+(on|off|status)\s*\Z", re.IGNORECASE)
45
+
46
+ # Host event name -> normalised event. Unknown names are recorded as "other".
47
+ EVENTS = {
48
+ "SessionStart": "session_start", "SessionEnd": "session_end",
49
+ "UserPromptSubmit": "prompt", "BeforeAgent": "prompt", "PreInvocation": "prompt",
50
+ "PreToolUse": "tool_start", "BeforeTool": "tool_start",
51
+ "PostToolUse": "tool_end", "AfterTool": "tool_end", "PostToolUseFailure": "tool_error",
52
+ "PermissionRequest": "permission_request", "Notification": "notification",
53
+ "SubagentStart": "subagent_start", "SubagentStop": "subagent_stop",
54
+ "Stop": "stop", "AfterAgent": "stop", "PostInvocation": "stop", "StopFailure": "error",
55
+ "PreCompact": "compact", "PostCompact": "compact", "PreCompress": "compact", "PostCompaction": "compact",
56
+ "Interrupt": "interrupt",
57
+ }
58
+ PROMPT_EVENTS = ("UserPromptSubmit", "BeforeAgent", "PreInvocation")
59
+
60
+
61
+ # --- paths and small helpers ------------------------------------------------------------
62
+
63
+ def mode_path(home):
64
+ return Path(home) / ".config" / "crow-skills" / "mode.json"
65
+
66
+
67
+ def events_dir(home):
68
+ return Path(home) / ".local" / "state" / "crow-skills" / "events"
69
+
70
+
71
+ def now_iso():
72
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
73
+
74
+
75
+ def token(value, limit=128):
76
+ """An id, model or tool name as a short single token; anything else is dropped."""
77
+ if value is None or isinstance(value, (dict, list, bool)):
78
+ return None
79
+ text = TOKEN.sub("", str(value))[:limit]
80
+ return text or None
81
+
82
+
83
+ def plain_path(value):
84
+ if not isinstance(value, str) or not value.startswith("/") or len(value) > 4096:
85
+ return None
86
+ return "".join(c for c in value if c.isprintable())
87
+
88
+
89
+ def no_follow_dir(path):
90
+ """Create a private directory chain; refuse when any component is a symlink."""
91
+ path = Path(os.path.abspath(path))
92
+ for part in reversed((path, *path.parents)):
93
+ if part.is_symlink():
94
+ raise OSError("symlink in state path")
95
+ path.mkdir(parents=True, exist_ok=True, mode=0o700)
96
+ return path
97
+
98
+
99
+ def write_json(path, data, mode=0o600):
100
+ path = Path(path)
101
+ no_follow_dir(path.parent)
102
+ temp = path.parent / ("." + path.name + "." + uuid.uuid4().hex)
103
+ try:
104
+ fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), mode)
105
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
106
+ json.dump(data, handle, ensure_ascii=False, sort_keys=True, indent=2)
107
+ handle.write("\n")
108
+ handle.flush()
109
+ os.fsync(handle.fileno())
110
+ os.replace(temp, path)
111
+ finally:
112
+ if os.path.lexists(temp):
113
+ os.unlink(temp)
114
+
115
+
116
+ def read_json(path, limit=1024 * 1024):
117
+ path = Path(path)
118
+ if path.is_symlink() or not path.is_file() or path.stat().st_size > limit:
119
+ return None
120
+ try:
121
+ return json.loads(path.read_text(encoding="utf-8"))
122
+ except (OSError, ValueError):
123
+ return None
124
+
125
+
126
+ # --- mode -----------------------------------------------------------------------------------
127
+
128
+ def read_mode(home):
129
+ """Current mode; a missing or invalid file means the default (on)."""
130
+ data = read_json(mode_path(home), 64 * 1024)
131
+ if isinstance(data, dict) and data.get("mode") in MODES:
132
+ return {"mode": data["mode"], "updatedAt": data.get("updatedAt"), "updatedBy": data.get("updatedBy"),
133
+ "source": "file", "path": str(mode_path(home))}
134
+ return {"mode": DEFAULT_MODE, "updatedAt": None, "updatedBy": None, "source": "default", "path": str(mode_path(home))}
135
+
136
+
137
+ def write_mode(home, value, by=None):
138
+ if value not in MODES:
139
+ raise ValueError("mode must be on or off")
140
+ data = {"schema": MODE_SCHEMA, "mode": value, "updatedAt": now_iso(), "updatedBy": token(by, 40) or "cli"}
141
+ write_json(mode_path(home), data, 0o644)
142
+ record(home, {"event": "mode_change", "host": token(by, 20) or "cli", "mode": value})
143
+ return read_mode(home)
144
+
145
+
146
+ def indicator(mode, unicode=True):
147
+ return ("▸▸ " if unicode else ">> ") + "crow " + mode
148
+
149
+
150
+ def context_line(mode):
151
+ """The additionalContext a prompt hook injects; short, so it costs little per prompt."""
152
+ if mode == "on":
153
+ return ("[crow-skills] modo crow: ON. Modo de orquestração permanente declarado pelo usuário: em toda tarefa "
154
+ "substantiva desta sessão, carregue e siga a skill crow-behaviour (conversa e ajuste trivial seguem diretos). "
155
+ "Vale até o usuário desligar com /crow off ou crow-skills mode off.")
156
+ return ("[crow-skills] modo crow: OFF. Não carregue nem aplique a skill crow-behaviour, salvo se o usuário a invocar "
157
+ "explicitamente neste pedido. Religar: /crow on ou crow-skills mode on.")
158
+
159
+
160
+ # --- machine resources ------------------------------------------------------------------
161
+
162
+ _RESOURCES = None
163
+
164
+
165
+ def resources_module():
166
+ """The resource probe: the package module, or crow_resources.py beside this file.
167
+
168
+ Loaded by path because the hooks run with ``python -I`` (the script's directory is
169
+ not on sys.path). None when it is missing: the hook then adds nothing.
170
+ """
171
+ global _RESOURCES
172
+ if _RESOURCES is None:
173
+ _RESOURCES = False
174
+ try:
175
+ if __package__:
176
+ from . import resources as module # type: ignore[attr-defined]
177
+ _RESOURCES = module
178
+ else:
179
+ import importlib.util
180
+ here = Path(os.path.abspath(__file__)).parent
181
+ for name in ("crow_resources.py", "resources.py"):
182
+ path = here / name
183
+ if path.is_file() and not path.is_symlink():
184
+ spec = importlib.util.spec_from_file_location("crow_resources", path)
185
+ module = importlib.util.module_from_spec(spec)
186
+ # Bytecode is cached in __pycache__ beside it (~5 ms less per prompt);
187
+ # the integrations uninstall removes that directory with the scripts.
188
+ spec.loader.exec_module(module)
189
+ _RESOURCES = module
190
+ break
191
+ except Exception: # noqa: BLE001 - a broken probe never breaks the hook
192
+ _RESOURCES = False
193
+ return _RESOURCES or None
194
+
195
+
196
+ def resource_hint(home):
197
+ """The one-line memory hint, or None (no pressure, no probe, or any failure)."""
198
+ module = resources_module()
199
+ if module is None:
200
+ return None
201
+ try:
202
+ return module.hint(module.probe(home, processes=False, use_cache=True))
203
+ except Exception: # noqa: BLE001
204
+ return None
205
+
206
+
207
+ # --- event bus ----------------------------------------------------------------------------
208
+
209
+ def append_event(home, event):
210
+ """Append one compact JSON line; rotate the file at MAX_EVENTS_FILE, keeping ROTATED files."""
211
+ directory = no_follow_dir(events_dir(home))
212
+ path = directory / "events.jsonl"
213
+ line = (json.dumps(event, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + "\n").encode("utf-8")
214
+ try:
215
+ size = path.stat().st_size if path.exists() else 0
216
+ except OSError:
217
+ size = 0
218
+ if size + len(line) > MAX_EVENTS_FILE:
219
+ rotate(directory)
220
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND | getattr(os, "O_NOFOLLOW", 0), 0o600)
221
+ try:
222
+ os.write(fd, line) # one write per line: concurrent hooks never interleave a line
223
+ finally:
224
+ os.close(fd)
225
+
226
+
227
+ def rotate(directory):
228
+ try:
229
+ import fcntl
230
+ except ImportError: # pragma: no cover - POSIX only
231
+ return
232
+ lock = os.open(directory / ".rotate.lock", os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
233
+ try:
234
+ try:
235
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
236
+ except OSError:
237
+ return # another hook is rotating; this line goes to the current file
238
+ path = directory / "events.jsonl"
239
+ if not path.exists() or path.stat().st_size <= MAX_EVENTS_FILE // 2:
240
+ return
241
+ for index in range(ROTATED, 0, -1):
242
+ older = directory / ("events.jsonl." + str(index))
243
+ if index == ROTATED and older.exists():
244
+ older.unlink()
245
+ elif older.exists():
246
+ os.replace(older, directory / ("events.jsonl." + str(index + 1)))
247
+ os.replace(path, directory / "events.jsonl.1")
248
+ finally:
249
+ os.close(lock)
250
+
251
+
252
+ def session_key(host, session, cwd):
253
+ basis = (host or "other") + ":" + (session or ("cwd:" + (cwd or "")))
254
+ return hashlib.sha256(basis.encode("utf-8")).hexdigest()[:20]
255
+
256
+
257
+ def git_branch(cwd):
258
+ """Branch from .git/HEAD walking up from cwd; no subprocess, so it costs microseconds."""
259
+ if not cwd:
260
+ return None
261
+ path = Path(cwd)
262
+ for directory in (path, *path.parents):
263
+ dot = directory / ".git"
264
+ try:
265
+ if dot.is_file():
266
+ text = dot.read_text(encoding="utf-8", errors="replace").strip()
267
+ if text.startswith("gitdir:"):
268
+ gitdir = Path(text[7:].strip())
269
+ dot = gitdir if gitdir.is_absolute() else directory / gitdir
270
+ if dot.is_dir():
271
+ head = (dot / "HEAD").read_text(encoding="utf-8", errors="replace").strip()
272
+ if head.startswith("ref: refs/heads/"):
273
+ return token(head[16:], 120)
274
+ return "detached:" + head[:12] if re.fullmatch(r"[0-9a-f]{7,64}", head) else None
275
+ except OSError:
276
+ return None
277
+ return None
278
+
279
+
280
+ def update_session(home, event, extra=None, fallback_cwd=None):
281
+ """Fold one event into the per-session snapshot (state, counters, last action)."""
282
+ directory = no_follow_dir(events_dir(home) / "sessions")
283
+ key = session_key(event.get("host"), event.get("session"), event.get("cwd"))
284
+ path = directory / (key + ".json")
285
+ try:
286
+ import fcntl
287
+ except ImportError: # pragma: no cover
288
+ fcntl = None
289
+ lock = os.open(directory / (key + ".lock"), os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
290
+ try:
291
+ if fcntl:
292
+ fcntl.flock(lock, fcntl.LOCK_EX)
293
+ snap = read_json(path) or {}
294
+ if snap.get("schema") != SESSION_SCHEMA:
295
+ snap = {"schema": SESSION_SCHEMA, "key": key, "host": event.get("host"), "session": event.get("session"),
296
+ "startedAt": event.get("ts") or now_iso(), "state": "idle", "subagents": 0, "shellsStarted": 0, "tools": 0,
297
+ "blocked": False}
298
+ for field in ("cwd", "model", "permissionMode"):
299
+ if event.get(field):
300
+ snap[field] = event[field]
301
+ if not snap.get("cwd") and fallback_cwd:
302
+ snap["cwd"] = fallback_cwd # hosts that send no cwd: the hook runs in the session's directory
303
+ kind = event["event"]
304
+ if kind in ("session_start", "prompt") and snap.get("cwd"):
305
+ snap["branch"] = git_branch(snap["cwd"])
306
+ if kind == "prompt":
307
+ snap.update(state="working", blocked=False, currentTool=None)
308
+ elif kind == "tool_start":
309
+ snap.update(state="working", blocked=False, currentTool=event.get("tool"))
310
+ snap["tools"] = int(snap.get("tools") or 0) + 1
311
+ if event.get("background"):
312
+ snap["shellsStarted"] = int(snap.get("shellsStarted") or 0) + 1
313
+ elif kind in ("tool_end", "tool_error"):
314
+ snap.update(currentTool=None, blocked=False)
315
+ if snap.get("state") == "blocked":
316
+ snap["state"] = "working"
317
+ elif kind == "permission_request" or (kind == "notification" and event.get("blocked")):
318
+ snap.update(state="blocked", blocked=True)
319
+ elif kind == "subagent_start":
320
+ snap["subagents"] = int(snap.get("subagents") or 0) + 1
321
+ elif kind == "subagent_stop":
322
+ snap["subagents"] = max(0, int(snap.get("subagents") or 0) - 1)
323
+ elif kind == "stop":
324
+ snap.update(state="idle", blocked=False, currentTool=None)
325
+ elif kind in ("error",):
326
+ snap.update(state="error", blocked=False, currentTool=None)
327
+ elif kind == "session_end":
328
+ snap.update(state="ended", blocked=False, currentTool=None, endedAt=event.get("ts") or now_iso(), subagents=0)
329
+ if kind != "other":
330
+ snap["lastEvent"] = kind
331
+ snap["lastAction"] = kind + (":" + event["tool"] if event.get("tool") else "")
332
+ snap["updatedAt"] = event.get("ts") or now_iso()
333
+ if extra:
334
+ snap.update(extra)
335
+ write_json(path, snap)
336
+ return snap
337
+ finally:
338
+ os.close(lock)
339
+
340
+
341
+ def prune_sessions(home, now=None):
342
+ directory = events_dir(home) / "sessions"
343
+ if not directory.is_dir() or directory.is_symlink():
344
+ return
345
+ now = now or time.time()
346
+ files = sorted(directory.glob("*.json"), key=lambda p: p.stat().st_mtime)
347
+ for index, path in enumerate(files):
348
+ try:
349
+ if now - path.stat().st_mtime > SESSION_TTL or index < len(files) - MAX_SESSIONS:
350
+ path.unlink() # the .lock stays: a hook may hold it; a lock file is empty
351
+ except OSError:
352
+ pass
353
+
354
+
355
+ def record(home, fields, snapshot=False, fallback_cwd=None):
356
+ """Stamp and append an event; optionally fold it into the session snapshot."""
357
+ event = {"v": 1, "schema": EVENT_SCHEMA, "ts": now_iso()}
358
+ event.update({k: v for k, v in fields.items() if v is not None})
359
+ append_event(home, event)
360
+ if snapshot:
361
+ return event, update_session(home, event, fallback_cwd=fallback_cwd)
362
+ return event, None
363
+
364
+
365
+ def sessions(home, live_only=True, now=None):
366
+ directory = events_dir(home) / "sessions"
367
+ result = []
368
+ if not directory.is_dir() or directory.is_symlink():
369
+ return result
370
+ now = now or time.time()
371
+ for path in directory.glob("*.json"):
372
+ try:
373
+ snap = read_json(path)
374
+ age = now - path.stat().st_mtime
375
+ except OSError:
376
+ continue # pruned meanwhile
377
+ if not isinstance(snap, dict) or snap.get("schema") != SESSION_SCHEMA:
378
+ continue
379
+ if live_only and (age > SESSION_TTL or (snap.get("state") == "ended" and age > 300)):
380
+ continue
381
+ snap["ageSeconds"] = int(age)
382
+ result.append(snap)
383
+ return sorted(result, key=lambda s: s.get("updatedAt") or "", reverse=True)
384
+
385
+
386
+ def tail(home, limit=50):
387
+ directory = events_dir(home)
388
+ files = [directory / ("events.jsonl." + str(i)) for i in range(ROTATED, 0, -1)] + [directory / "events.jsonl"]
389
+ lines = []
390
+ for path in files:
391
+ if path.is_file() and not path.is_symlink():
392
+ with open(path, "rb") as handle:
393
+ lines.extend(handle.read().splitlines())
394
+ events = []
395
+ for raw in lines[-limit:] if limit else lines:
396
+ try:
397
+ events.append(json.loads(raw))
398
+ except ValueError:
399
+ continue
400
+ return events
401
+
402
+
403
+ # --- host payloads ------------------------------------------------------------------------
404
+
405
+ def first(payload, *keys):
406
+ for key in keys:
407
+ value = payload.get(key)
408
+ if value not in (None, ""):
409
+ return value
410
+ return None
411
+
412
+
413
+ def genuine_claude(payload, env, home):
414
+ """The claude-installed hook is also read by Devin (it imports ~/.claude/settings.json
415
+ hooks). Run only when the caller really is Claude Code: its transcript lives under
416
+ ~/.claude/projects (or $CLAUDE_CONFIG_DIR/projects); without a transcript, Claude Code
417
+ exports CLAUDECODE=1 to every child. Devin-only fields mean Devin."""
418
+ if "tool_provenance" in payload:
419
+ return False
420
+ transcript = payload.get("transcript_path")
421
+ if isinstance(transcript, str) and transcript:
422
+ roots = [Path(home) / ".claude" / "projects"]
423
+ if env.get("CLAUDE_CONFIG_DIR"):
424
+ roots.append(Path(env["CLAUDE_CONFIG_DIR"]).expanduser() / "projects")
425
+ real = os.path.abspath(os.path.expanduser(transcript))
426
+ return any(real.startswith(os.path.abspath(r) + os.sep) for r in roots)
427
+ return env.get("CLAUDECODE") == "1"
428
+
429
+
430
+ def foreign(host, payload, env, home):
431
+ """True when this installation must stay silent because another CLI is running it."""
432
+ if host == "claude":
433
+ return not genuine_claude(payload, env, home)
434
+ if host == "gemini":
435
+ return env.get("ANTIGRAVITY_AGENT") == "1" # agy has its own hooks.json entry
436
+ return False
437
+
438
+
439
+ def normalise(host, raw, payload):
440
+ kind = EVENTS.get(raw, "other")
441
+ tool = token(first(payload, "tool_name", "toolName", "tool"), 64)
442
+ tool_input = payload.get("tool_input") if isinstance(payload.get("tool_input"), dict) else {}
443
+ event = {
444
+ "host": host, "event": kind, "raw": token(raw, 40),
445
+ "session": token(first(payload, "session_id", "sessionId", "conversation_id", "conversationId", "thread_id"), 128),
446
+ "cwd": plain_path(first(payload, "cwd", "workspace_root", "workspaceRoot")),
447
+ "model": token(first(payload, "model", "model_name", "modelName"), 80) if not isinstance(payload.get("model"), dict)
448
+ else token(payload["model"].get("id") or payload["model"].get("display_name"), 80),
449
+ "permissionMode": token(first(payload, "permission_mode", "permissionMode", "approval_policy", "approvalMode"), 40),
450
+ "tool": tool if kind in ("tool_start", "tool_end", "tool_error", "permission_request") else None,
451
+ "agent": token(first(payload, "agent_type", "subagent_type", "agent_name"), 64) if kind.startswith("subagent") else None,
452
+ }
453
+ if kind == "tool_start" and tool_input.get("run_in_background") is True:
454
+ event["background"] = True
455
+ if kind == "notification":
456
+ kind_name = str(first(payload, "notification_type", "type") or "")
457
+ event["blocked"] = kind_name in ("permission_prompt", "permission", "elicitation_dialog", "ToolPermission")
458
+ event["notification"] = token(kind_name, 40)
459
+ if kind == "permission_request":
460
+ event["blocked"] = True
461
+ return {k: v for k, v in event.items() if v is not None}
462
+
463
+
464
+ def process_cwd():
465
+ try:
466
+ return plain_path(os.getcwd())
467
+ except OSError:
468
+ return None
469
+
470
+
471
+ def prompt_text(payload):
472
+ value = first(payload, "prompt", "user_prompt", "lastUserInput", "last_user_input")
473
+ return value if isinstance(value, str) else None
474
+
475
+
476
+ def output_for(host, raw, context=None, system=None, block=None):
477
+ """The JSON each host reads from a hook's stdout, or None for no output."""
478
+ if block is not None:
479
+ if host == "agy":
480
+ return {"injectSteps": [{"ephemeralMessage": block + " Responda só confirmando isso em uma linha."}]}
481
+ return {"decision": "block", "reason": block}
482
+ if host == "agy":
483
+ steps = []
484
+ if context:
485
+ steps.append({"ephemeralMessage": context})
486
+ out = {"injectSteps": steps} if steps else {}
487
+ if system:
488
+ out["systemMessage"] = system
489
+ return out or None
490
+ out = {}
491
+ if context:
492
+ out["hookSpecificOutput"] = {"hookEventName": raw, "additionalContext": context}
493
+ if system:
494
+ out["systemMessage"] = system
495
+ return out or None
496
+
497
+
498
+ def handle(host, raw, payload, home, env=None):
499
+ """Process one hook call; returns the JSON to print (or None). Never raises to the caller."""
500
+ env = os.environ if env is None else env
501
+ if host not in HOSTS or not isinstance(payload, dict) or foreign(host, payload, env, home):
502
+ return None
503
+ event = normalise(host, raw, payload)
504
+ current = read_mode(home)
505
+ if raw in PROMPT_EVENTS:
506
+ text = prompt_text(payload)
507
+ match = COMMAND.match(text) if text and len(text) < 64 else None
508
+ if match and host in ("codex", "devin", "agy"):
509
+ # CLIs without a shell slash command toggle here and block the prompt with the result.
510
+ wanted = match.group(1).lower()
511
+ if wanted != "status":
512
+ current = write_mode(home, wanted, host)
513
+ record(home, dict(event, event="mode_command", mode=current["mode"]), snapshot=True, fallback_cwd=process_cwd())
514
+ return output_for(host, raw, block=indicator(current["mode"]) + " (crow-skills; vale para todos os CLIs a partir do próximo prompt)")
515
+ stamped, snap = record(home, dict(event, mode=current["mode"]), snapshot=True, fallback_cwd=process_cwd())
516
+ if raw not in PROMPT_EVENTS and raw != "SessionStart":
517
+ return None
518
+ system = None
519
+ seen = (snap or {}).get("modeSeen")
520
+ stamp = current["mode"] + "@" + str(current["updatedAt"])
521
+ if host in ("codex", "gemini", "devin") and (raw == "SessionStart" or (seen and seen != stamp)):
522
+ system = indicator(current["mode"]) # visible line only at start or when the mode changed
523
+ if snap is not None and seen != stamp:
524
+ update_session(home, dict(stamped, event="other"), {"modeSeen": stamp, "mode": current["mode"]})
525
+ if raw == "SessionStart":
526
+ return output_for(host, raw, system=system) if system else None
527
+ context = context_line(current["mode"])
528
+ memory = resource_hint(home)
529
+ if memory:
530
+ context += "\n" + memory
531
+ return output_for(host, raw, context=context, system=system)
532
+
533
+
534
+ def update_metrics(home, host, payload):
535
+ """Status line payloads carry model, cost and usage: keep them in the session snapshot.
536
+
537
+ Only numbers and ids are kept; writes happen only when a value changed.
538
+ """
539
+ if not isinstance(payload, dict):
540
+ return
541
+ session = token(first(payload, "session_id", "sessionId", "conversation_id", "conversationId"), 128)
542
+ cwd = plain_path(((payload.get("workspace") or {}) if isinstance(payload.get("workspace"), dict) else {}).get("current_dir")
543
+ or payload.get("cwd"))
544
+ metrics = {}
545
+ model = payload.get("model")
546
+ if isinstance(model, dict):
547
+ metrics["model"] = token(model.get("id") or model.get("display_name"), 80)
548
+ elif model:
549
+ metrics["model"] = token(model, 80)
550
+ cost = payload.get("cost")
551
+ if isinstance(cost, dict):
552
+ value = cost.get("total_cost_usd", cost.get("totalCostUsd"))
553
+ if isinstance(value, (int, float)):
554
+ metrics["costUsd"] = round(float(value), 6)
555
+ elif isinstance(cost, (int, float)):
556
+ metrics["costUsd"] = round(float(cost), 6)
557
+ usage = payload.get("usage") if isinstance(payload.get("usage"), dict) else (payload.get("context_window") or {})
558
+ if isinstance(usage, dict):
559
+ numbers = {k: int(v) for k, v in usage.items() if isinstance(v, (int, float)) and not isinstance(v, bool) and len(k) < 40}
560
+ if numbers:
561
+ metrics["usage"] = numbers
562
+ metrics = {k: v for k, v in metrics.items() if v is not None}
563
+ if not metrics or not (session or cwd):
564
+ return
565
+ path = events_dir(home) / "sessions" / (session_key(host, session, cwd) + ".json")
566
+ snap = read_json(path) or {}
567
+ if all(snap.get(k) == v for k, v in metrics.items()):
568
+ return
569
+ update_session(home, {"host": host, "session": session, "cwd": cwd, "event": "other", "ts": now_iso()}, metrics)
570
+
571
+
572
+ # --- entry point --------------------------------------------------------------------------
573
+
574
+ def main(argv=None):
575
+ import argparse
576
+ p = argparse.ArgumentParser(prog="crow_hook", description="Hook e modo do crow-skills.")
577
+ sub = p.add_subparsers(dest="command", required=True)
578
+ hook = sub.add_parser("hook")
579
+ hook.add_argument("--host", required=True)
580
+ hook.add_argument("--event", required=True)
581
+ hook.add_argument("--home")
582
+ mode = sub.add_parser("mode")
583
+ mode.add_argument("action", nargs="?", default="status", choices=("on", "off", "status", "toggle"))
584
+ mode.add_argument("--home")
585
+ mode.add_argument("--by")
586
+ mode.add_argument("--json", action="store_true")
587
+ args = p.parse_args(argv)
588
+ home = Path(args.home).expanduser() if args.home else Path.home()
589
+ if args.command == "mode":
590
+ current = read_mode(home)
591
+ try:
592
+ if args.action in MODES:
593
+ current = write_mode(home, args.action, args.by)
594
+ elif args.action == "toggle":
595
+ current = write_mode(home, "off" if current["mode"] == "on" else "on", args.by)
596
+ except OSError:
597
+ print("crow: não foi possível gravar " + str(mode_path(home)) + "; modo mantido: " + current["mode"])
598
+ return 1
599
+ print(json.dumps(current, ensure_ascii=False) if args.json else indicator(current["mode"]) +
600
+ " (vale para todos os CLIs a partir do próximo prompt)")
601
+ return 0
602
+ try:
603
+ raw = sys.stdin.buffer.read(MAX_STDIN + 1) if not sys.stdin.isatty() else b""
604
+ payload = json.loads(raw.decode("utf-8")) if raw.strip() and len(raw) <= MAX_STDIN else {}
605
+ result = handle(args.host, args.event, payload, home)
606
+ if args.event == "SessionStart":
607
+ prune_sessions(home)
608
+ if result:
609
+ sys.stdout.write(json.dumps(result, ensure_ascii=False) + "\n")
610
+ except Exception: # noqa: BLE001 - a hook must never break the agent
611
+ return 0
612
+ return 0
613
+
614
+
615
+ if __name__ == "__main__":
616
+ raise SystemExit(main())