@andespindola/crow-skills 0.18.2 → 0.19.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.
@@ -1,3 +1,3 @@
1
1
  """Portable, local-first installer for the crow-skills catalogue."""
2
2
 
3
- __version__ = "0.18.2"
3
+ __version__ = "0.19.1"
@@ -352,6 +352,14 @@ def edit_paths(host, tool, tool_input, cwd):
352
352
  GIT_WORD = re.compile(r"^(?:\S*/)?git$")
353
353
  DIR_WORD = re.compile(r"(?:^|[\s;&|()])(?:cd|[^\s;&|()]*/?git)(?:\s|$)")
354
354
  TREE_SUBCOMMANDS = ("checkout", "switch", "rebase", "stash", "merge", "pull", "cherry-pick", "bisect", "restore", "clean")
355
+ # A git call is not by itself a sign of working in a clone: `git log`, `git status` and `git diff`
356
+ # are reading. Measured right after shipping occupancy(): a peer session that only INSPECTED two
357
+ # clones was still published as occupying them, because any git verb marked the clone as driven.
358
+ # Everything outside this list keeps marking occupancy, so an unknown verb errs toward warning.
359
+ READ_SUBCOMMANDS = ("log", "status", "diff", "show", "rev-parse", "rev-list", "ls-tree", "ls-files",
360
+ "ls-remote", "cat-file", "describe", "blame", "shortlog", "for-each-ref",
361
+ "name-rev", "merge-base", "symbolic-ref", "whatchanged", "grep", "count-objects",
362
+ "check-ignore", "verify-commit", "verify-tag", "var", "help", "version")
355
363
 
356
364
 
357
365
  def tree_command(host, tool, tool_input, cwd=None):
@@ -423,7 +431,7 @@ def _note(visited, directory, source="cd"):
423
431
  if visited is None or not directory or not directory.startswith("/"):
424
432
  return
425
433
  if directory in visited:
426
- if source == "git":
434
+ if source == "git": # working outranks reading and the incidental cd
427
435
  visited[directory] = "git"
428
436
  return
429
437
  if len(visited) < MAX_PATTERNS:
@@ -488,7 +496,8 @@ def _segment(words, here, targets, depth=0, visited=None):
488
496
  if i >= len(words):
489
497
  return here
490
498
  sub, rest = words[i], words[i + 1:]
491
- _note(visited, directory, "git") # any git call names the clone the session is working in
499
+ # "git" means the session WORKS in that clone; a read-only verb means it only looked.
500
+ _note(visited, directory, "read" if sub in READ_SUBCOMMANDS else "git")
492
501
  if sub in TREE_SUBCOMMANDS or (sub == "reset" and "--hard" in rest):
493
502
  if directory and directory not in targets:
494
503
  targets.append(directory)
@@ -703,14 +712,21 @@ def fold(snap, event, touched=(), now=None, ppid=None, cli_pid=None, tty_runner=
703
712
  repos = [r for r in repos if r["root"] != found["root"]]
704
713
  entry = {"id": found["id"], "root": found["root"], "ts": now_iso(now),
705
714
  "hits": int((previous or {}).get("hits") or 0) + 1}
706
- if (previous or {}).get("driven") or (isinstance(dirs, dict) and dirs.get(directory) == "git"):
715
+ source = dirs.get(directory) if isinstance(dirs, dict) else None
716
+ # TWO different questions about the same clone. `driven`: a git call named it, which is a
717
+ # stronger signal than an incidental `cd` and wins the eviction contest — a `git log` counts.
718
+ # `working`: the verb CHANGES something there, which is what occupancy() may warn about.
719
+ if (previous or {}).get("driven") or source in ("git", "read"):
707
720
  entry["driven"] = True
721
+ if (previous or {}).get("working") or source == "git":
722
+ entry["working"] = True
708
723
  repos.append(entry)
709
724
  if repos or presence.get("repos") is not None:
710
725
  # Eviction is not FIFO: a clone a git command actually drove, and one visited many times,
711
726
  # outrank the incidental `cd` that would otherwise push it out of the eight slots.
712
- keep = sorted(repos, key=lambda r: (bool(r.get("driven")), int(r.get("hits") or 0),
713
- parse_time(r.get("ts")) or 0), reverse=True)[:MAX_REPOS]
727
+ keep = sorted(repos, key=lambda r: (bool(r.get("working")), bool(r.get("driven")),
728
+ int(r.get("hits") or 0), parse_time(r.get("ts")) or 0),
729
+ reverse=True)[:MAX_REPOS]
714
730
  presence["repos"] = [r for r in repos if r in keep]
715
731
  snap["presence"] = presence
716
732
  return snap
@@ -905,6 +921,49 @@ def repo_scope(snap, now=None, window=None):
905
921
  return ids, roots
906
922
 
907
923
 
924
+
925
+ def occupancy(snap, now=None, window=None):
926
+ """The clones a session OCCUPIES, as opposed to the ones it merely reached.
927
+
928
+ Occupied: the cwd clone, a clone where it edited a file, and a clone a git command actually
929
+ drove (``driven``). Reached-only is a clone a shell call read or cd'ed into — and conflating the
930
+ two has a measured cost: a read-only ``cd`` plus a ``sed`` of one doc marked a session as using
931
+ a clone, which made a peer session stop a running test suite and apologise for damage to work
932
+ that did not exist there. Reach stays visible as information; only occupancy warns.
933
+ """
934
+ now = now or time.time()
935
+ window = FILE_TTL if window is None else window
936
+ ids, roots = set(), set()
937
+ presence = presence_of(snap)
938
+ repo = snap.get("repo") if isinstance(snap.get("repo"), dict) else None
939
+ if repo is None:
940
+ repo = presence.get("repo") if isinstance(presence.get("repo"), dict) else None
941
+ if repo is None and snap.get("cwd"):
942
+ repo = repo_info(snap["cwd"])
943
+ if isinstance(repo, dict): # where the session lives
944
+ if repo.get("id"):
945
+ ids.add(repo["id"])
946
+ if repo.get("root"):
947
+ roots.add(repo["root"])
948
+ for entry in presence.get("repos") or []: # only the clones a WRITING git command drove
949
+ if not isinstance(entry, dict) or not entry.get("working"):
950
+ continue
951
+ if now - (parse_time(entry.get("ts")) or 0) > window:
952
+ continue
953
+ if isinstance(entry.get("id"), str):
954
+ ids.add(entry["id"])
955
+ if isinstance(entry.get("root"), str):
956
+ roots.add(entry["root"])
957
+ for item in recent_files(snap, now, window): # and the clones whose files it edited
958
+ if isinstance(item.get("repo"), str):
959
+ ids.add(item["repo"])
960
+ rel, path = item.get("rel"), item.get("path")
961
+ if isinstance(rel, str) and rel and isinstance(path, str) and path.endswith(rel):
962
+ root = path[: len(path) - len(rel)].rstrip("/")
963
+ if root:
964
+ roots.add(root)
965
+ return ids, roots
966
+
908
967
  def same_file(a, b):
909
968
  """Two touched-file entries name the same file: same path, or same repo id and repo-relative path."""
910
969
  if a.get("path") == b.get("path"):
@@ -1492,8 +1551,14 @@ def relations(me, other, now, cfg):
1492
1551
  my_ids, my_roots = repo_scope(me, now)
1493
1552
  their_ids, their_roots = repo_scope(other, now)
1494
1553
  shared_roots = sorted(my_roots & their_roots)
1554
+ # Reach and occupancy are different claims about the same clone: `sharedClones` is "both sides
1555
+ # touched it", `occupiedClones` is "both sides are WORKING in it". Only the second warns.
1556
+ my_live, their_live = occupancy(me, now), occupancy(other, now)
1557
+ occupied_roots = sorted(my_live[1] & their_live[1])
1558
+ occupied_ids = sorted(my_live[0] & their_live[0])
1495
1559
  rel = {"sameRepo": bool(my_ids & their_ids), "sameClone": bool(shared_roots),
1496
1560
  "sharedRepos": sorted(my_ids & their_ids), "sharedClones": shared_roots,
1561
+ "occupiedClones": occupied_roots, "occupiedRepos": occupied_ids,
1497
1562
  "sameBranch": False, "overlap": []}
1498
1563
  rel["sameBranch"] = bool(mine.get("id") and mine.get("id") == theirs.get("id") and me.get("branch")
1499
1564
  and me.get("branch") == other.get("branch"))
@@ -1569,7 +1634,11 @@ def session_lines(s, home, now, with_files=True):
1569
1634
  state = "bloqueada" if s.get("blocked") else {"working": "trabalhando", "idle": "ociosa", "blocked": "bloqueada",
1570
1635
  "error": "com erro"}.get(s.get("state"), clean(s.get("state") or "?", 12))
1571
1636
  root = (s.get("repo") or {}).get("root") or s.get("cwd") or "?"
1572
- where = ("MESMO clone " if root in (rel.get("sharedClones") or []) else "clone ") + short_path(root)
1637
+ occupied = rel.get("occupiedClones")
1638
+ occupied = occupied if isinstance(occupied, list) else (rel.get("sharedClones") or [])
1639
+ where = ("MESMO clone " if root in occupied
1640
+ else "leu/rodou comando em " if root in (rel.get("sharedClones") or [])
1641
+ else "clone ") + short_path(root)
1573
1642
  head = "- " + clean(s.get("host"), 12) + " " + state + " · branch " + clean(s.get("branch") or "?", 60) + \
1574
1643
  (" (a mesma)" if rel.get("sameBranch") else "") + " · " + where + " · " + age_text(s.get("ageSeconds"))
1575
1644
  lines = [head[:LINE_CHARS]]
@@ -1584,10 +1653,18 @@ def session_lines(s, home, now, with_files=True):
1584
1653
  names = [clean(f["rel"], 80) if f.get("rel") else short_path(f["path"], 60) for f in reversed(files)]
1585
1654
  shown = ", ".join(names[:4]) + (" (+" + str(len(names) - 4) + ")" if len(names) > 4 else "")
1586
1655
  lines.append((" arquivos editados: " + shown)[:LINE_CHARS])
1587
- shared = [r for r in (rel.get("sharedRepos") or []) if r != (s.get("repo") or {}).get("id")]
1588
- if shared:
1656
+ mine_id = (s.get("repo") or {}).get("id")
1657
+ shared = [r for r in (rel.get("sharedRepos") or []) if r != mine_id]
1658
+ # "trabalha em" was claiming too much: a read-only visit landed here too, and a peer session
1659
+ # stopped a running suite over it. The two claims are now separate lines.
1660
+ busy = [r for r in shared if r in (rel.get("occupiedRepos") or [])]
1661
+ read = [r for r in shared if r not in (rel.get("occupiedRepos") or [])]
1662
+ if busy:
1589
1663
  lines.append((" TAMBÉM trabalha em repositório(s) deste trabalho: " +
1590
- ", ".join(clean(r, 60) for r in shared[:3]))[:LINE_CHARS])
1664
+ ", ".join(clean(r, 60) for r in busy[:3]))[:LINE_CHARS])
1665
+ if read:
1666
+ lines.append((" leu/rodou comando em repositório(s) deste trabalho (sem editar): " +
1667
+ ", ".join(clean(r, 60) for r in read[:3]))[:LINE_CHARS])
1591
1668
  if rel.get("overlap"):
1592
1669
  lines.append((" SOBREPOSIÇÃO com arquivos desta sessão: " + ", ".join(clean(x, 80) for x in rel["overlap"][:4]))[:LINE_CHARS])
1593
1670
  return lines
@@ -1660,7 +1737,7 @@ def digest(home, me_key, me=None, now=None, cfg=None, deliver=True):
1660
1737
  lines.append(claim_line(claim, live_by_key, now))
1661
1738
  my_root = (view["me"].get("repo") or {}).get("root")
1662
1739
  tail = []
1663
- if my_root and any(my_root in ((s.get("relation") or {}).get("sharedClones") or []) for s in others):
1740
+ if my_root and any(my_root in ((s.get("relation") or {}).get("occupiedClones") or []) for s in others):
1664
1741
  tail.append("ATENÇÃO: outra sessão viva trabalha neste mesmo clone (cwd, edição ou comando); "
1665
1742
  "não faça checkout/switch/rebase/stash aqui.")
1666
1743
  tail.append(FOOTER)
@@ -1710,7 +1787,7 @@ def check(home, me_key, me, paths=(), tree_change=(), now=None, cfg=None):
1710
1787
  warnings.append("- " + clean(", ".join(hits[:4]), 160) + ": editado " + age_text(s.get("ageSeconds")) + " por " +
1711
1788
  clean(s.get("host"), 12) + " (branch " + clean(s.get("branch") or "?", 60) + ", " +
1712
1789
  short_path((s.get("repo") or {}).get("root") or s.get("cwd") or "?") + ")")
1713
- theirs_root = next((r for r in sorted(repo_scope(s, now)[1]) if r in tree_roots), None)
1790
+ theirs_root = next((r for r in sorted(occupancy(s, now)[1]) if r in tree_roots), None)
1714
1791
  if theirs_root:
1715
1792
  warnings.append("- o clone " + short_path(theirs_root) + " está em uso por " + clean(s.get("host"), 12) +
1716
1793
  " na branch " + clean(s.get("branch") or "?", 60) + "; trocar de branch, rebase, stash ou reset aqui "
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/crow-skills",
3
- "version": "0.18.2",
3
+ "version": "0.19.1",
4
4
  "description": "Versioned, complementary agent skills with a protected multi-agent installer",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
package/release.json CHANGED
@@ -18,7 +18,7 @@
18
18
  "crow_skills/__init__.py": {
19
19
  "executable": false,
20
20
  "kind": "file",
21
- "sha256": "985f4a517618b99f51bf9a9d98121075293b712b2a0a91b5666375d5853546ea"
21
+ "sha256": "0fac3e997aef5911a1a83e61ec8abc3b92abc0e02cdff187a430b3277e40b80f"
22
22
  },
23
23
  "crow_skills/__main__.py": {
24
24
  "executable": false,
@@ -38,7 +38,7 @@
38
38
  "crow_skills/broadcast.py": {
39
39
  "executable": false,
40
40
  "kind": "file",
41
- "sha256": "354f013d999adec87ccfd69e8ae2fce749593b9c2043b41116d069e57e213ccc"
41
+ "sha256": "135fb80925dea8e6c0790ee830c6b7e440cc5c0f116a4c9dcc4cf36bab1c22e3"
42
42
  },
43
43
  "crow_skills/cli.py": {
44
44
  "executable": false,
@@ -128,7 +128,7 @@
128
128
  "package.json": {
129
129
  "executable": false,
130
130
  "kind": "file",
131
- "sha256": "07b5468a007188905565bf5636d3d37919b93aae840b54a879a90625ba668d2a"
131
+ "sha256": "25d9d44e380c13b64264f33b18d0b722a1b465c693fd92d819581b78df7f6fb8"
132
132
  },
133
133
  "skills/crow-accessibility/SKILL.md": {
134
134
  "executable": false,
@@ -173,7 +173,7 @@
173
173
  "skills/crow-behaviour/references/broadcast.md": {
174
174
  "executable": false,
175
175
  "kind": "file",
176
- "sha256": "5ec341809cf357d303df81a69822e7cff5fe979f2187ee623961ff197fe6cd2c"
176
+ "sha256": "6653d4495b49856e2e8299dcb048f8b7e5b785a741367e6d25350e1f9aeb1cb3"
177
177
  },
178
178
  "skills/crow-behaviour/references/context-economy.md": {
179
179
  "executable": false,
@@ -721,7 +721,7 @@
721
721
  "sha256": "e37132ef8b060548191fbf14a5a18a9dd3acd84ea9a35e802174455b9e374d3d"
722
722
  }
723
723
  },
724
- "revision": "92f37cb4b5af7c738adbe218b3911a41f5cab202",
724
+ "revision": "2a8fb8a28c5b1073fd5faea2da0e2b4fe631fab7",
725
725
  "schema_version": 1,
726
- "version": "0.18.2"
726
+ "version": "0.19.1"
727
727
  }
@@ -37,6 +37,15 @@ crow-skills broadcast release --all # ao concluir ou cancelar (só as des
37
37
  - Libere ao terminar cada fase ou a execução. Reservas expiram pelo TTL (máximo 24 h) e somem quando a sessão dona deixa de estar viva.
38
38
  - Sem outras sessões vivas no repositório, não reserve: é ruído.
39
39
 
40
+ ## Alcance não é ocupação
41
+
42
+ O bloco distingue duas alegações sobre o mesmo clone, e só uma é motivo para parar:
43
+
44
+ - **`MESMO clone` / "TAMBÉM trabalha em"** — a outra sessão **ocupa** o clone: é o cwd dela, ela editou arquivo ali ou rodou git ali. Aqui vale a regra: não faça checkout, switch, rebase ou stash, e combine antes de editar.
45
+ - **"leu/rodou comando em"** — ela só **alcançou** o clone: leu um arquivo, entrou nele num comando. É informação, não conflito. Não pare o seu trabalho por isso e não peça para ela parar o dela.
46
+
47
+ Isso existe porque confundir os dois custou caro uma vez: um `cd` com um `sed` de leitura marcou uma sessão como estando no clone, e a outra interrompeu uma suíte em execução e pediu desculpas por um dano que não existia.
48
+
40
49
  ## Falar com outra sessão
41
50
 
42
51
  O bloco diz quem está vivo; o canal serve para dizer algo a quem está vivo. Vale entre CLIs diferentes — uma sessão do Codex alcança uma do Claude, porque o transporte é o hook, que todas têm.