@andespindola/crow-skills 0.9.0 → 0.10.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.
@@ -18,6 +18,15 @@ On a prompt event the hook also appends one memory line to the context, only
18
18
  when the machine is under pressure or the recommended parallelism is not
19
19
  "parallel" (crow_resources.py, copied beside this file; cached for a few
20
20
  seconds, so most prompts cost one small file read).
21
+
22
+ crow-broadcast (crow_broadcast.py, copied beside this file): every event also
23
+ keeps the session's presence (git repository, CLI pid, PATHS of files its edit
24
+ tools target) in the snapshot; a prompt event appends a short digest of other
25
+ live sessions in the same repository, only when there are any; ``--check``
26
+ (a synchronous PreToolUse entry for edit and shell tools) warns about another
27
+ live session on the same file or clone without writing anything.
28
+
29
+ crow_hook.py hook --host HOST --event PreToolUse --check [--home DIR]
21
30
  """
22
31
  from datetime import datetime, timezone
23
32
  import hashlib
@@ -193,6 +202,34 @@ def resources_module():
193
202
  return _RESOURCES or None
194
203
 
195
204
 
205
+ _BROADCAST = None
206
+
207
+
208
+ def broadcast_module():
209
+ """crow-broadcast: the package module, or crow_broadcast.py beside this file; None when missing."""
210
+ global _BROADCAST
211
+ if _BROADCAST is None:
212
+ _BROADCAST = False
213
+ try:
214
+ if __package__:
215
+ from . import broadcast as module # type: ignore[attr-defined]
216
+ _BROADCAST = module
217
+ else:
218
+ import importlib.util
219
+ here = Path(os.path.abspath(__file__)).parent
220
+ for name in ("crow_broadcast.py", "broadcast.py"):
221
+ path = here / name
222
+ if path.is_file() and not path.is_symlink():
223
+ spec = importlib.util.spec_from_file_location("crow_broadcast", path)
224
+ module = importlib.util.module_from_spec(spec)
225
+ spec.loader.exec_module(module)
226
+ _BROADCAST = module
227
+ break
228
+ except Exception: # noqa: BLE001 - a broken module never breaks the hook
229
+ _BROADCAST = False
230
+ return _BROADCAST or None
231
+
232
+
196
233
  def resource_hint(home):
197
234
  """The one-line memory hint, or None (no pressure, no probe, or any failure)."""
198
235
  module = resources_module()
@@ -277,8 +314,11 @@ def git_branch(cwd):
277
314
  return None
278
315
 
279
316
 
280
- def update_session(home, event, extra=None, fallback_cwd=None):
281
- """Fold one event into the per-session snapshot (state, counters, last action)."""
317
+ def update_session(home, event, extra=None, fallback_cwd=None, presence=None):
318
+ """Fold one event into the per-session snapshot (state, counters, last action).
319
+
320
+ presence: (broadcast module, touched paths, parent pid) to fold the crow-broadcast
321
+ presence in the same locked write; a failure there never loses the event."""
282
322
  directory = no_follow_dir(events_dir(home) / "sessions")
283
323
  key = session_key(event.get("host"), event.get("session"), event.get("cwd"))
284
324
  path = directory / (key + ".json")
@@ -332,6 +372,12 @@ def update_session(home, event, extra=None, fallback_cwd=None):
332
372
  snap["updatedAt"] = event.get("ts") or now_iso()
333
373
  if extra:
334
374
  snap.update(extra)
375
+ if presence:
376
+ module, touched, ppid = presence
377
+ try:
378
+ module.fold(snap, event, touched, ppid=ppid)
379
+ except Exception: # noqa: BLE001 - presence is best effort
380
+ pass
335
381
  write_json(path, snap)
336
382
  return snap
337
383
  finally:
@@ -352,13 +398,13 @@ def prune_sessions(home, now=None):
352
398
  pass
353
399
 
354
400
 
355
- def record(home, fields, snapshot=False, fallback_cwd=None):
401
+ def record(home, fields, snapshot=False, fallback_cwd=None, presence=None):
356
402
  """Stamp and append an event; optionally fold it into the session snapshot."""
357
403
  event = {"v": 1, "schema": EVENT_SCHEMA, "ts": now_iso()}
358
404
  event.update({k: v for k, v in fields.items() if v is not None})
359
405
  append_event(home, event)
360
406
  if snapshot:
361
- return event, update_session(home, event, fallback_cwd=fallback_cwd)
407
+ return event, update_session(home, event, fallback_cwd=fallback_cwd, presence=presence)
362
408
  return event, None
363
409
 
364
410
 
@@ -380,7 +426,7 @@ def sessions(home, live_only=True, now=None):
380
426
  continue
381
427
  snap["ageSeconds"] = int(age)
382
428
  result.append(snap)
383
- return sorted(result, key=lambda s: s.get("updatedAt") or "", reverse=True)
429
+ return sorted(result, key=lambda s: str(s.get("updatedAt") or ""), reverse=True)
384
430
 
385
431
 
386
432
  def tail(home, limit=50):
@@ -436,14 +482,26 @@ def foreign(host, payload, env, home):
436
482
  return False
437
483
 
438
484
 
485
+ def tool_call(payload):
486
+ """Antigravity sends the tool as {"toolCall": {"name", "args"}}."""
487
+ call = payload.get("toolCall")
488
+ return call if isinstance(call, dict) else {}
489
+
490
+
491
+ def workspace(payload):
492
+ """Antigravity runs hooks from the hooks.json directory and sends the workspace as workspacePaths."""
493
+ paths = payload.get("workspacePaths")
494
+ return paths[0] if isinstance(paths, list) and paths and isinstance(paths[0], str) else None
495
+
496
+
439
497
  def normalise(host, raw, payload):
440
498
  kind = EVENTS.get(raw, "other")
441
- tool = token(first(payload, "tool_name", "toolName", "tool"), 64)
499
+ tool = token(first(payload, "tool_name", "toolName", "tool") or tool_call(payload).get("name"), 64)
442
500
  tool_input = payload.get("tool_input") if isinstance(payload.get("tool_input"), dict) else {}
443
501
  event = {
444
502
  "host": host, "event": kind, "raw": token(raw, 40),
445
503
  "session": token(first(payload, "session_id", "sessionId", "conversation_id", "conversationId", "thread_id"), 128),
446
- "cwd": plain_path(first(payload, "cwd", "workspace_root", "workspaceRoot")),
504
+ "cwd": plain_path(first(payload, "cwd", "workspace_root", "workspaceRoot") or workspace(payload)),
447
505
  "model": token(first(payload, "model", "model_name", "modelName"), 80) if not isinstance(payload.get("model"), dict)
448
506
  else token(payload["model"].get("id") or payload["model"].get("display_name"), 80),
449
507
  "permissionMode": token(first(payload, "permission_mode", "permissionMode", "approval_policy", "approvalMode"), 40),
@@ -495,11 +553,107 @@ def output_for(host, raw, context=None, system=None, block=None):
495
553
  return out or None
496
554
 
497
555
 
498
- def handle(host, raw, payload, home, env=None):
556
+ TOOL_START_EVENTS = ("PreToolUse", "BeforeTool")
557
+ SHELL_NAMES = frozenset(("Bash", "shell", "exec_command", "local_shell", "run_shell_command", "run_command", "exec", "bash"))
558
+ # Edit and shell tool names of every host (crow_broadcast.EDIT_TOOLS and SHELL_TOOLS; a test keeps
559
+ # them equal): only these tool events load the broadcast module.
560
+ BROADCAST_TOOLS = frozenset((
561
+ "Edit", "Write", "MultiEdit", "NotebookEdit", "apply_patch", "write_file", "replace", "code_action", "file_change",
562
+ "propose_code", "edit_notebook", "write_blob", "write_to_file", "replace_file_content", "multi_replace_file_content",
563
+ "edit", "write", "notebook_edit",
564
+ "Bash", "shell", "exec_command", "local_shell", "run_shell_command", "run_command", "exec", "bash"))
565
+ # Hosts whose tool-start hook is synchronous in our install (its output is read): the warning goes
566
+ # out from the same call. Claude Code and Codex record asynchronously and get a separate --check entry.
567
+ SYNC_TOOL_HOSTS = ("gemini", "agy", "devin")
568
+
569
+
570
+ def tool_input_of(payload):
571
+ args = tool_call(payload).get("args")
572
+ if isinstance(args, dict):
573
+ return args
574
+ for key in ("tool_input", "toolInput", "input", "tool_args", "arguments", "args"):
575
+ value = payload.get(key)
576
+ if isinstance(value, dict):
577
+ return value
578
+ if isinstance(value, str) and key in ("arguments", "tool_input") and value[:1] == "{" and len(value) < MAX_STDIN:
579
+ try:
580
+ parsed = json.loads(value)
581
+ except ValueError:
582
+ continue
583
+ if isinstance(parsed, dict):
584
+ return parsed
585
+ return {}
586
+
587
+
588
+ # Where a warning can reach the model before/after a tool, per host (checked on the installed
589
+ # versions, see docs/broadcast.md): Claude Code and Codex read additionalContext on PreToolUse;
590
+ # Gemini CLI and Devin only on AfterTool/PostToolUse (their pre-tool hook can only block);
591
+ # Antigravity's PreToolUse reads only a decision (the next PreInvocation digest carries the warning).
592
+ PRE_CONTEXT_HOSTS = ("claude", "codex")
593
+ POST_CONTEXT_HOSTS = ("gemini", "devin")
594
+ POST_TOOL_EVENTS = ("PostToolUse", "AfterTool")
595
+
596
+
597
+ def tool_output(host, raw, text, block=False):
598
+ """Tool-hook warning (model-visible where the host reads it) or, with block, a refusal."""
599
+ if block:
600
+ if host == "claude":
601
+ return {"hookSpecificOutput": {"hookEventName": raw, "permissionDecision": "deny", "permissionDecisionReason": text,
602
+ "additionalContext": text}}
603
+ if host in ("agy", "gemini"):
604
+ return {"decision": "deny", "reason": text}
605
+ return {"decision": "block", "reason": text}
606
+ if raw in TOOL_START_EVENTS and host not in PRE_CONTEXT_HOSTS:
607
+ return None
608
+ return {"hookSpecificOutput": {"hookEventName": raw, "additionalContext": text}, "systemMessage": text.split("\n", 1)[0]}
609
+
610
+
611
+ def broadcast_check(host, raw, home, module, me, touched, tree):
612
+ """Run the conflict check; returns the host output or None. Never raises."""
613
+ try:
614
+ cfg = module.config(home)
615
+ pre = raw in TOOL_START_EVENTS
616
+ block = pre and bool(cfg["block_on_conflict"])
617
+ if pre and not block and host not in PRE_CONTEXT_HOSTS:
618
+ return None # nothing this host would show before the tool; the post-tool or prompt hook warns
619
+ text, conflict = module.check(home, (me or {}).get("key"), me, touched, tree, cfg=cfg)
620
+ if not text:
621
+ return None
622
+ return tool_output(host, raw, text, block=block and conflict)
623
+ except Exception: # noqa: BLE001
624
+ return None
625
+
626
+
627
+ def check_only(host, raw, payload, home):
628
+ """--check: read-only conflict check before an edit/shell tool (no event, no snapshot write)."""
629
+ tool = first(payload, "tool_name", "toolName", "tool") or tool_call(payload).get("name")
630
+ shell_input = tool_input_of(payload)
631
+ command = shell_input.get("command") or shell_input.get("cmd") or shell_input.get("CommandLine") or shell_input.get("commandLine")
632
+ if tool in SHELL_NAMES and "git" not in json.dumps(command or ""):
633
+ return None # a shell command without git cannot switch the clone's branch: nothing to load
634
+ module = broadcast_module()
635
+ if module is None or raw not in TOOL_START_EVENTS:
636
+ return None
637
+ event = normalise(host, raw, payload)
638
+ cwd = event.get("cwd") or process_cwd()
639
+ tool_input = tool_input_of(payload)
640
+ touched = module.edit_paths(host, event.get("tool"), tool_input, cwd)
641
+ tree = module.tree_command(host, event.get("tool"), tool_input, cwd)
642
+ if not touched and not tree:
643
+ return None
644
+ key = session_key(host, event.get("session"), event.get("cwd"))
645
+ me = read_json(events_dir(home) / "sessions" / (key + ".json")) or {}
646
+ me = dict(me, key=key, cwd=me.get("cwd") or cwd)
647
+ return broadcast_check(host, raw, home, module, me, touched, tree)
648
+
649
+
650
+ def handle(host, raw, payload, home, env=None, check=False):
499
651
  """Process one hook call; returns the JSON to print (or None). Never raises to the caller."""
500
652
  env = os.environ if env is None else env
501
653
  if host not in HOSTS or not isinstance(payload, dict) or foreign(host, payload, env, home):
502
654
  return None
655
+ if check:
656
+ return check_only(host, raw, payload, home)
503
657
  event = normalise(host, raw, payload)
504
658
  current = read_mode(home)
505
659
  if raw in PROMPT_EVENTS:
@@ -512,7 +666,49 @@ def handle(host, raw, payload, home, env=None):
512
666
  current = write_mode(home, wanted, host)
513
667
  record(home, dict(event, event="mode_command", mode=current["mode"]), snapshot=True, fallback_cwd=process_cwd())
514
668
  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())
669
+ # The broadcast module is loaded only where it has work: prompts and session start (repo,
670
+ # pid, digest) and tool events of edit/shell tools (paths, checks). Other events keep the
671
+ # 0.9.0 cost; the snapshot's updatedAt is the heartbeat.
672
+ wanted = raw in PROMPT_EVENTS or raw == "SessionStart" or \
673
+ (event["event"] in ("tool_start", "tool_end") and event.get("tool") in BROADCAST_TOOLS)
674
+ module, presence, touched, tree, checked = broadcast_module() if wanted else None, None, [], [], []
675
+ if module is not None:
676
+ try:
677
+ cfg = module.config(home)
678
+ if cfg["enabled"]:
679
+ if event["event"] == "tool_start":
680
+ tool_input = tool_input_of(payload)
681
+ cwd = event.get("cwd") or process_cwd()
682
+ touched = module.edit_paths(host, event.get("tool"), tool_input, cwd)
683
+ checked = list(touched)
684
+ tree = module.tree_command(host, event.get("tool"), tool_input, cwd)
685
+ if touched and cfg["block_on_conflict"]:
686
+ # An edit the conflict check is about to refuse is not published as this
687
+ # session's: otherwise the session that edited first would be locked out too.
688
+ key = session_key(host, event.get("session"), event.get("cwd"))
689
+ mine = read_json(events_dir(home) / "sessions" / (key + ".json")) or {}
690
+ mine = dict(mine, key=key, cwd=mine.get("cwd") or cwd)
691
+ if module.check(home, key, mine, touched, (), cfg=cfg)[1]:
692
+ touched = []
693
+ presence = (module, touched, os.getppid())
694
+ else:
695
+ module = None
696
+ except Exception: # noqa: BLE001 - the broadcast never breaks the event
697
+ module, presence = None, None
698
+ stamped, snap = record(home, dict(event, mode=current["mode"]), snapshot=True, fallback_cwd=process_cwd(), presence=presence)
699
+ if module is not None and host in SYNC_TOOL_HOSTS:
700
+ if raw in TOOL_START_EVENTS and (touched or tree or checked):
701
+ return broadcast_check(host, raw, home, module, snap, checked or touched, tree)
702
+ if raw in POST_TOOL_EVENTS and host in POST_CONTEXT_HOSTS:
703
+ try:
704
+ tool_input = tool_input_of(payload)
705
+ cwd = event.get("cwd") or process_cwd()
706
+ after = module.edit_paths(host, event.get("tool"), tool_input, cwd)
707
+ tree_after = module.tree_command(host, event.get("tool"), tool_input, cwd)
708
+ except Exception: # noqa: BLE001
709
+ after, tree_after = [], []
710
+ if after or tree_after:
711
+ return broadcast_check(host, raw, home, module, snap, after, tree_after)
516
712
  if raw not in PROMPT_EVENTS and raw != "SessionStart":
517
713
  return None
518
714
  system = None
@@ -528,6 +724,13 @@ def handle(host, raw, payload, home, env=None):
528
724
  memory = resource_hint(home)
529
725
  if memory:
530
726
  context += "\n" + memory
727
+ if module is not None and snap is not None:
728
+ try:
729
+ others = module.digest(home, snap.get("key"), me=snap)
730
+ except Exception: # noqa: BLE001
731
+ others = None
732
+ if others:
733
+ context += "\n" + others
531
734
  return output_for(host, raw, context=context, system=system)
532
735
 
533
736
 
@@ -571,7 +774,36 @@ def update_metrics(home, host, payload):
571
774
 
572
775
  # --- entry point --------------------------------------------------------------------------
573
776
 
777
+ def fast_hook_args(argv):
778
+ """The hook's own argv parsed without argparse (~5 ms per call); None when it is anything else."""
779
+ if not argv or argv[0] != "hook":
780
+ return None
781
+ values, flags, i = {}, set(), 1
782
+ while i < len(argv):
783
+ name = argv[i]
784
+ if name in ("--host", "--event", "--home") and i + 1 < len(argv):
785
+ values[name[2:]] = argv[i + 1]
786
+ i += 2
787
+ elif name == "--check":
788
+ flags.add("check")
789
+ i += 1
790
+ else:
791
+ return None
792
+ if "host" not in values or "event" not in values:
793
+ return None
794
+ import types
795
+ return types.SimpleNamespace(command="hook", host=values["host"], event=values["event"], home=values.get("home"),
796
+ check="check" in flags)
797
+
798
+
574
799
  def main(argv=None):
800
+ args = fast_hook_args(list(sys.argv[1:] if argv is None else argv))
801
+ if args is None:
802
+ args = parse_args(argv)
803
+ return run_main(args)
804
+
805
+
806
+ def parse_args(argv):
575
807
  import argparse
576
808
  p = argparse.ArgumentParser(prog="crow_hook", description="Hook e modo do crow-skills.")
577
809
  sub = p.add_subparsers(dest="command", required=True)
@@ -579,12 +811,16 @@ def main(argv=None):
579
811
  hook.add_argument("--host", required=True)
580
812
  hook.add_argument("--event", required=True)
581
813
  hook.add_argument("--home")
814
+ hook.add_argument("--check", action="store_true")
582
815
  mode = sub.add_parser("mode")
583
816
  mode.add_argument("action", nargs="?", default="status", choices=("on", "off", "status", "toggle"))
584
817
  mode.add_argument("--home")
585
818
  mode.add_argument("--by")
586
819
  mode.add_argument("--json", action="store_true")
587
- args = p.parse_args(argv)
820
+ return p.parse_args(argv)
821
+
822
+
823
+ def run_main(args):
588
824
  home = Path(args.home).expanduser() if args.home else Path.home()
589
825
  if args.command == "mode":
590
826
  current = read_mode(home)
@@ -602,11 +838,18 @@ def main(argv=None):
602
838
  try:
603
839
  raw = sys.stdin.buffer.read(MAX_STDIN + 1) if not sys.stdin.isatty() else b""
604
840
  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)
841
+ result = handle(args.host, args.event, payload, home, check=args.check)
608
842
  if result:
609
843
  sys.stdout.write(json.dumps(result, ensure_ascii=False) + "\n")
844
+ sys.stdout.flush()
845
+ if args.event == "SessionStart" and not args.check:
846
+ prune_sessions(home)
847
+ module = broadcast_module()
848
+ if module is not None:
849
+ try:
850
+ module.prune_claims(home)
851
+ except Exception: # noqa: BLE001 - pruning is housekeeping; the output already went out
852
+ pass
610
853
  except Exception: # noqa: BLE001 - a hook must never break the agent
611
854
  return 0
612
855
  return 0