@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
@@ -31,6 +31,20 @@ MAX_LEDGER = 2 * 1024 * 1024
31
31
  MAX_LOG_LINE = 8 * 1024 * 1024
32
32
  RECENT_DONE = 300 # seconds a finished run stays visible in compact views
33
33
  STALE = 2 * 3600 # a "running" ledger untouched for this long is treated as abandoned
34
+ MAX_AGENT_ROWS = 6 # process rows shown in a status line before "+N"
35
+
36
+
37
+ def hook_module():
38
+ """The mode/event helpers: the package module, or crow_hook.py copied beside this script."""
39
+ try:
40
+ from . import hook # type: ignore[attr-defined]
41
+ return hook
42
+ except ImportError:
43
+ try:
44
+ import crow_hook as hook # sibling copy in the integrations directory
45
+ return hook
46
+ except ImportError:
47
+ return None
34
48
 
35
49
 
36
50
  # --- ledger -------------------------------------------------------------------------
@@ -69,7 +83,7 @@ def validate(data):
69
83
  "status": data.get("status") if data.get("status") in ("running",) + FINISHED else "running",
70
84
  "startedAt": data.get("startedAt"), "updatedAt": data.get("updatedAt"), "endedAt": data.get("endedAt"),
71
85
  "session": data.get("session") if isinstance(data.get("session"), dict) else {},
72
- "phases": [], "units": []}
86
+ "phases": [], "units": [], "resources": resources_decision(data.get("resources"))}
73
87
  for phase in phases:
74
88
  if isinstance(phase, dict) and isinstance(phase.get("id"), str):
75
89
  run["phases"].append({"id": phase["id"], "title": safe(phase.get("title") or phase["id"]),
@@ -93,6 +107,42 @@ def validate(data):
93
107
  return run
94
108
 
95
109
 
110
+ RESOURCE_MODES = ("parallel", "reduced", "sequential")
111
+ RESOURCE_LEVELS = ("normal", "warn", "critical", "unknown")
112
+ RESOURCE_SOURCES = ("probe", "os", "assumed")
113
+
114
+
115
+ def resources_decision(value):
116
+ """The optional run-level ``resources`` field (0.9.0): the parallelism decision recorded
117
+ before a fan-out. Ledgers without it, or with an invalid one, read as None."""
118
+ if not isinstance(value, dict) or value.get("mode") not in RESOURCE_MODES:
119
+ return None
120
+
121
+ def count(key):
122
+ number = value.get(key)
123
+ return number if isinstance(number, int) and not isinstance(number, bool) and 0 <= number <= 256 else None
124
+
125
+ return {"mode": value["mode"], "pressure": value.get("pressure") if value.get("pressure") in RESOURCE_LEVELS else None,
126
+ "source": value.get("source") if value.get("source") in RESOURCE_SOURCES else None,
127
+ "maxParallelAgents": count("maxParallelAgents"), "maxParallelHeavyJobs": count("maxParallelHeavyJobs"),
128
+ "maxWorkersPerJob": count("maxWorkersPerJob"),
129
+ "checkedAt": value.get("checkedAt") if isinstance(value.get("checkedAt"), str) else None,
130
+ "note": safe(value.get("note") or "", 160)}
131
+
132
+
133
+ def resources_line(decision, unicode=True):
134
+ if not decision:
135
+ return ""
136
+ parts = [decision["mode"]]
137
+ if decision["maxParallelAgents"] is not None:
138
+ parts.append(("≤" if unicode else "<=") + str(decision["maxParallelAgents"]) + " agente(s)")
139
+ if decision["maxParallelHeavyJobs"] is not None:
140
+ parts.append(("≤" if unicode else "<=") + str(decision["maxParallelHeavyJobs"]) + " job(s) pesado(s)")
141
+ if decision["pressure"]:
142
+ parts.append("pressão " + decision["pressure"])
143
+ return "recursos: " + (" · " if unicode else " - ").join(parts) + (" (" + decision["note"] + ")" if decision["note"] else "")
144
+
145
+
96
146
  def load(path):
97
147
  path = Path(path)
98
148
  if path.is_symlink() or not path.is_file() or path.stat().st_size > MAX_LEDGER:
@@ -501,6 +551,9 @@ def render(run, usage=None, now=None, color=False, unicode=True, width=20):
501
551
  bar(info["progress"], width, unicode) + " " + str(int(info["progress"] * 100)).rjust(3) + "% " +
502
552
  str(info["done"]) + "/" + str(info["total"]) + " unidades" +
503
553
  (" - " + str(info["failed"]) + " falha(s)" if info["failed"] else "") + " " + token_text(usage, unicode)]
554
+ if run.get("resources"):
555
+ lines.append(paint("dim", resources_line(run["resources"], unicode)))
556
+ lines += ["", *agent_rows(run, unicode, counts=False), ""]
504
557
  phase_ids = [p["id"] for p in run["phases"]]
505
558
  groups = [(p, [u for u in run["units"] if u["phase"] == p["id"]]) for p in run["phases"]]
506
559
  loose = [u for u in run["units"] if u["phase"] not in phase_ids]
@@ -527,6 +580,50 @@ def render(run, usage=None, now=None, color=False, unicode=True, width=20):
527
580
  return "\n".join(lines)
528
581
 
529
582
 
583
+ def owner_label(owner):
584
+ """subagent:leitor -> leitor; workflow:x and sequential stay as written."""
585
+ owner = owner or "main"
586
+ return owner.split(":", 1)[1] if owner.startswith("subagent:") and owner[9:] else owner
587
+
588
+
589
+ def agent_rows(run, unicode=True, limit=None, counts=True):
590
+ """Live process list, like the host's own agent list:
591
+
592
+ ● main <what the coordinator is doing>
593
+ ○ <owner> <running unit>
594
+ ✓ 3 concluídas · ✗ 1 falhou · 2 pendentes
595
+
596
+ Rows come only from the ledger (units the coordinator recorded); nothing is invented.
597
+ """
598
+ dot, ring = ("●", "○") if unicode else ("*", "o")
599
+ sep = " · " if unicode else " - "
600
+ running = [u for u in run["units"] if u["status"] == "running"]
601
+ mine = [u for u in running if owner_label(u["owner"]) in ("main", "sequential")]
602
+ others = [u for u in running if u not in mine]
603
+ head = dot + " main"
604
+ if mine:
605
+ head += " " + safe(mine[0]["title"], 60) + (" (+" + str(len(mine) - 1) + ")" if len(mine) > 1 else "")
606
+ elif run["status"] == "running":
607
+ head += " " + safe(run["title"], 60)
608
+ lines = [head]
609
+ shown = others if limit is None else others[:limit]
610
+ for unit in shown:
611
+ lines.append(ring + " " + safe(owner_label(unit["owner"]), 32) + " " + safe(unit["title"], 60))
612
+ if limit is not None and len(others) > limit:
613
+ lines.append(ring + " +" + str(len(others) - limit))
614
+ if counts:
615
+ done = sum(u["status"] == "done" for u in run["units"])
616
+ failed = sum(u["status"] == "failed" for u in run["units"])
617
+ pending = sum(u["status"] == "pending" for u in run["units"])
618
+ parts = [("✓ " if unicode else "ok ") + str(done) + " concluída(s)"]
619
+ if failed:
620
+ parts.append(("✗ " if unicode else "x ") + str(failed) + " falha(s)")
621
+ if pending:
622
+ parts.append(str(pending) + " pendente(s)")
623
+ lines.append(sep.join(parts))
624
+ return lines
625
+
626
+
530
627
  def compact(run, usage=None, unicode=True, width=10):
531
628
  info = summary(run)
532
629
  mark = "✓" if run["status"] == "done" else ("✗" if run["status"] in ("failed", "cancelled") else "▸")
@@ -580,19 +677,53 @@ def watch(cwd, home, run_id=None, interval=1.0, stream=None, linger=5.0, max_fra
580
677
  return 0
581
678
 
582
679
 
583
- def statusline(home, stdin_text, chain=None):
584
- """Claude Code statusLine: one line; the previous status line (if chained) comes first."""
680
+ def mode_text(home, unicode=True):
681
+ hook = hook_module()
682
+ if hook is None:
683
+ return ""
684
+ try:
685
+ return hook.indicator(hook.read_mode(home)["mode"], unicode)
686
+ except (OSError, ValueError, KeyError):
687
+ return ""
688
+
689
+
690
+ def memory_text(home, unicode=True):
691
+ """'mem ▲ 85% ×2' when the machine is under pressure or parallelism is reduced; else ''."""
692
+ hook = hook_module()
693
+ module = hook.resources_module() if hook is not None and hasattr(hook, "resources_module") else None
694
+ if module is None:
695
+ return ""
696
+ try:
697
+ return module.segment(module.probe(home, processes=False, use_cache=True), unicode)
698
+ except Exception: # noqa: BLE001 - the probe never breaks a status line
699
+ return ""
700
+
701
+
702
+ def statusline(home, stdin_text, chain=None, host="claude"):
703
+ """Status line for Claude Code and Antigravity (agy).
704
+
705
+ First line: the previous (chained) status line, the mode indicator and the compact
706
+ run; then one row per running unit of the ledger, like the host's agent list. agy
707
+ stacks it under its default line (stack_with_default), so no chain is needed there.
708
+ """
585
709
  try:
586
710
  payload = json.loads(stdin_text) if stdin_text.strip() else {}
587
711
  except ValueError:
588
712
  payload = {}
589
713
  if not isinstance(payload, dict):
590
714
  payload = {}
591
- cwd = (payload.get("workspace") or {}).get("current_dir") or payload.get("cwd") or os.getcwd()
715
+ workspace = payload.get("workspace") if isinstance(payload.get("workspace"), dict) else {}
716
+ cwd = workspace.get("current_dir") or payload.get("cwd") or os.getcwd()
592
717
  transcript = payload.get("transcript_path")
593
- session = payload.get("session_id")
718
+ session = payload.get("session_id") or payload.get("conversation_id")
594
719
  if not (isinstance(session, str) and session):
595
720
  session = Path(transcript).stem if isinstance(transcript, str) and transcript.endswith(".jsonl") else None
721
+ hook = hook_module()
722
+ if hook is not None:
723
+ try:
724
+ hook.update_metrics(home, host, payload)
725
+ except Exception: # noqa: BLE001 - metrics never break the status line
726
+ pass
596
727
  previous = ""
597
728
  if chain:
598
729
  try:
@@ -603,9 +734,10 @@ def statusline(home, stdin_text, chain=None):
603
734
  except (OSError, subprocess.SubprocessError):
604
735
  previous = ""
605
736
  run = select(discover(cwd, home), cwd, strict=True, session=session)
606
- ours = ""
737
+ parts = [p for p in (mode_text(home), memory_text(home)) if p]
738
+ rows = []
607
739
  if active(run):
608
- if run["host"] == "claude" and not run["session"].get("id") and session:
740
+ if run["host"] == "claude" and host == "claude" and not run["session"].get("id") and session:
609
741
  # The status line knows its own session: exact usage instead of every session in the directory.
610
742
  try:
611
743
  usage = claude_tokens(dict(run, session={"id": session}), Path(home), *window(run))
@@ -613,19 +745,28 @@ def statusline(home, stdin_text, chain=None):
613
745
  usage = None
614
746
  else:
615
747
  usage = tokens(run, home)
616
- ours = compact(run, usage)
748
+ parts.append(compact(run, usage))
749
+ if run["status"] == "running":
750
+ rows = agent_rows(run, limit=MAX_AGENT_ROWS, counts=False)
751
+ if len(rows) == 1:
752
+ rows = [] # only "● main": the compact segment already says it
753
+ ours = " │ ".join(parts)
617
754
  if not previous.strip():
618
- return ours
619
- if not ours:
620
- return previous
621
- return previous + " │ " + ours
755
+ first_line = ours
756
+ elif not ours:
757
+ first_line = previous
758
+ else:
759
+ first_line = previous + " │ " + ours
760
+ return "\n".join([first_line, *rows]) if rows else first_line
622
761
 
623
762
 
624
763
  def tmux_segment(home, cwd):
764
+ head = " · ".join(p for p in (mode_text(home), memory_text(home)) if p)
625
765
  run = select(discover(cwd, home), cwd, strict=True)
626
766
  if not active(run):
627
- return ""
628
- return compact(run, tokens(run, home), unicode=True, width=8).replace("#", "##")
767
+ return head.replace("#", "##")
768
+ text = compact(run, tokens(run, home), unicode=True, width=8)
769
+ return ((head + " · " if head else "") + text).replace("#", "##")
629
770
 
630
771
 
631
772
  def main(argv=None):
@@ -636,6 +777,7 @@ def main(argv=None):
636
777
  p.add_argument("--cwd", default=None)
637
778
  p.add_argument("--home", default=None)
638
779
  p.add_argument("--chain-file", default=None, help="JSON com o comando de status line anterior, encadeado antes do crow")
780
+ p.add_argument("--host", default="claude", choices=("claude", "agy"), help="CLI que chama a status line")
639
781
  args = p.parse_args(argv)
640
782
  home = Path(args.home).expanduser() if args.home else Path.home()
641
783
  cwd = args.cwd or os.getcwd()
@@ -646,7 +788,7 @@ def main(argv=None):
646
788
  chain = json.loads(Path(args.chain_file).read_text(encoding="utf-8")).get("command")
647
789
  except (OSError, ValueError, AttributeError):
648
790
  chain = None
649
- print(statusline(home, sys.stdin.read() if not sys.stdin.isatty() else "", chain))
791
+ print(statusline(home, sys.stdin.read() if not sys.stdin.isatty() else "", chain, args.host))
650
792
  return 0
651
793
  if args.mode == "tmux":
652
794
  print(tmux_segment(home, cwd))