@andespindola/crow-skills 0.19.0 → 0.20.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.
@@ -1,3 +1,3 @@
1
1
  """Portable, local-first installer for the crow-skills catalogue."""
2
2
 
3
- __version__ = "0.19.0"
3
+ __version__ = "0.20.0"
@@ -319,6 +319,69 @@ def broken_projections(agent, home, store, rows):
319
319
  return [{"name": n, "status": "uniflow_link_modified", "fix": broken_fix(n)} for n in sorted(names)]
320
320
 
321
321
 
322
+
323
+ def catalog_gap(agent, home, source, rows, projected, broken):
324
+ """What this agent is missing from the catalog, and whether upgrade may install it.
325
+
326
+ WHY THIS EXISTS. `upgrade` only ever touched skills already RECORDED for the agent, so a skill
327
+ that is new in the catalog was published and never arrived: 0.17.0 shipped crow-be-human, every
328
+ CLI reported "atualizadas" and `doctor` stayed healthy while the skill existed nowhere on disk.
329
+ A release that the command the user runs cannot deliver is not delivered.
330
+
331
+ Returns {"missing": [names], "install": [names]}. The decision is per skill and read from the
332
+ GROUPS, because "has everything else" is vacuous — the set of missing names is by definition
333
+ everything it does not have. A missing skill is installed when some group of it is otherwise
334
+ COMPLETE here: the user took that group wholesale, and a new member belongs to it. A skill whose
335
+ every group is partial was curated by hand, so it is only reported, with the command.
336
+ """
337
+ if source is None:
338
+ return {"missing": [], "install": []}
339
+ present = {r.get("name") for r in rows if r.get("name")}
340
+ present |= set(projected or [])
341
+ present |= {k.get("name") for k in (broken or []) if isinstance(k, dict) and k.get("name")}
342
+ missing = sorted(set(source.catalog) - present)
343
+ if not missing:
344
+ return {"missing": [], "install": []}
345
+ try:
346
+ from . import groups as groups_mod
347
+ available = [g for g in groups_mod.load(source) if g["name"] != groups_mod.ALL]
348
+ except (CrowError, OSError, ValueError, KeyError):
349
+ available = []
350
+ install = []
351
+ for name in missing:
352
+ for group in available:
353
+ members = [s for s in group["skills"] if s != name]
354
+ if name in group["skills"] and members and all(m in present for m in members):
355
+ install.append(name)
356
+ break
357
+ return {"missing": missing, "install": install}
358
+
359
+
360
+ def install_new(home, entry, names, agent, dry_run, trust):
361
+ """Install catalog skills the agent is missing — only when it already has every other one.
362
+
363
+ The decision is read from the machine, not assumed: an agent holding the whole catalog minus the
364
+ new names has said "all of them", so a new skill belongs there. An agent with a SUBSET chose that
365
+ subset, and adding to it would be deciding for the user, so those names are only reported.
366
+ """
367
+ if not names:
368
+ return
369
+ argv = ["install", *names, "--agents", agent, "--global", "--json", "--home", str(home),
370
+ "--dry-run" if dry_run else "--yes"]
371
+ if not trust:
372
+ argv.append("--no-trust")
373
+ from .cli import parser, run as cli_run
374
+ try:
375
+ data = cli_run(parser().parse_args(argv))
376
+ except CrowError as exc:
377
+ entry["newInCatalogError"] = {"code": exc.code, "message": str(exc)}
378
+ return
379
+ done = [c["name"] for c in data.get("changes") or [] if c.get("action") != "unchanged"]
380
+ entry["installedNew"] = sorted(done)
381
+ entry["updated"] = sorted({*entry.get("updated", []), *done})
382
+ if data.get("transaction"):
383
+ entry["newTransaction"] = data["transaction"]
384
+
322
385
  def reinstall_behaviour(home, entry, dry_run, trust):
323
386
  """Codex without a working crow-behaviour (its projection is broken, no native copy): install it natively,
324
387
  as setup does, so the guide and hooks keep a coordinator to point to."""
@@ -402,6 +465,9 @@ def skills_update(home, trust=True, dry_run=False):
402
465
  from . import machine
403
466
  entry = {"agent": agent, "target": target["path"], "updated": [], "unchanged": 0, "kept": list(broken), "error": None,
404
467
  "via_uniflow": projected}
468
+ gap = catalog_gap(agent, home, current, rows, projected, broken)
469
+ if gap["missing"]:
470
+ entry["newInCatalog"] = gap["missing"]
405
471
  if dry_run:
406
472
  entry["dry_run"] = True
407
473
  try:
@@ -413,6 +479,8 @@ def skills_update(home, trust=True, dry_run=False):
413
479
  entry["error"] = {"code": exc.code, "message": str(exc)}
414
480
  except (OSError, ValueError, KeyError, TypeError):
415
481
  entry["error"] = {"code": "io_error", "message": "Falha de leitura/gravação; nada concluído para este agente."}
482
+ if gap["install"] and not entry.get("error"):
483
+ install_new(home, entry, gap["install"], agent, dry_run, trust)
416
484
  if agent == "codex" and not entry.get("error"):
417
485
  # Ramo mais provável de quem vinha da 0.14.0: no diretório do Codex só há links do
418
486
  # Uniflow, e TODAS as cópias do codex estão na pasta compartilhada. Sem migrar aqui,
@@ -431,6 +499,9 @@ def skills_update(home, trust=True, dry_run=False):
431
499
  names.append(r["name"])
432
500
  entry = {"agent": agent, "target": target["path"], "updated": [], "unchanged": 0, "kept": kept + list(broken),
433
501
  "error": None, "via_uniflow": projected}
502
+ gap = catalog_gap(agent, home, current, rows, projected, broken)
503
+ if gap["missing"]:
504
+ entry["newInCatalog"] = gap["missing"]
434
505
  if dry_run:
435
506
  entry["dry_run"] = True
436
507
  if agent == "codex":
@@ -462,6 +533,8 @@ def skills_update(home, trust=True, dry_run=False):
462
533
  entry["error"] = {"code": exc.code, "message": str(exc)}
463
534
  except (OSError, ValueError, KeyError, TypeError):
464
535
  entry["error"] = {"code": "io_error", "message": "Falha de leitura/gravação; nada concluído para este agente."}
536
+ if gap["install"] and not entry.get("error"):
537
+ install_new(home, entry, gap["install"], agent, dry_run, trust)
465
538
  if agent == "codex" and not entry.get("error"):
466
539
  # Mesma migração do setup, e pelo mesmo motivo: sem isto, uma máquina que só roda
467
540
  # autoupdate/upgrade mantém a pasta compartilhada antiga para sempre. Depois da
@@ -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
@@ -929,8 +945,8 @@ def occupancy(snap, now=None, window=None):
929
945
  ids.add(repo["id"])
930
946
  if repo.get("root"):
931
947
  roots.add(repo["root"])
932
- for entry in presence.get("repos") or []: # only the clones a git command drove
933
- if not isinstance(entry, dict) or not entry.get("driven"):
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"):
934
950
  continue
935
951
  if now - (parse_time(entry.get("ts")) or 0) > window:
936
952
  continue
@@ -778,6 +778,17 @@ def upgrade_summary(skills, dry_run):
778
778
  k["name"] + " --agents " + agent + " --global --yes")
779
779
  else:
780
780
  item["needs_user"].append(k["name"] + ": " + k["status"])
781
+ # Skill nova no catálogo: instalada quando o agente já tinha todas as outras, e NOMEADA com o
782
+ # comando quando ele tem um subconjunto escolhido. Antes disto o upgrade dizia "atualizadas" e
783
+ # a skill não existia em disco em lugar nenhum.
784
+ item["installed_new"] = s.get("installedNew") or []
785
+ pending = [n for n in (s.get("newInCatalog") or []) if n not in item["installed_new"]]
786
+ if pending:
787
+ item["needs_user"].append(str(len(pending)) + " skill(s) nova(s) no catálogo não instalada(s) aqui (" +
788
+ ", ".join(pending[:3]) + "); este agente tem um subconjunto escolhido. Para instalar: "
789
+ "crow-skills install " + " ".join(pending[:3]) + " --agents " + agent + " --global --yes")
790
+ if s.get("newInCatalogError"):
791
+ item["needs_user"].insert(0, "skill nova do catálogo não instalada: " + s["newInCatalogError"]["message"])
781
792
  if s.get("error"):
782
793
  item["needs_user"].insert(0, s["error"]["message"])
783
794
  item["state"] = "fail" if s.get("error") else ("warn" if item["needs_user"] else ("plan" if dry_run and (item["changed"] or item["removed"]) else "ok"))
@@ -904,9 +915,14 @@ def render_upgrade(data, stream=None):
904
915
  changed = ", ".join(item["changed"]) if len(item["changed"]) <= 3 else str(len(item["changed"])) + " skills"
905
916
  removed = ((" · " if changed else "") + str(len(item["removed"])) + (" a remover" if dry else " removidas") +
906
917
  " (duplicavam o Uniflow)") if item.get("removed") else ""
918
+ fresh = item.get("installed_new") or []
919
+ # Dito na própria linha do CLI: uma skill nova do catálogo não é "atualizada", é instalada
920
+ # agora — e foi exatamente isso que faltou quando a 0.17.0 publicou a crow-be-human.
921
+ new_text = ((" · " if changed or removed else "") + (("instalaria " if dry else "instaladas ") +
922
+ ("novas: " + ", ".join(fresh[:3]) if len(fresh) <= 3 else str(len(fresh)) + " novas"))) if fresh else ""
907
923
  rows.append([ui.mark(STATE_MARK[item["state"]], stream), item["label"],
908
- ((("a atualizar: " if dry else "atualizadas: ") + changed) if changed else ("" if removed else "sem mudanças"))
909
- + removed,
924
+ ((("a atualizar: " if dry else "atualizadas: ") + changed) if changed else ("" if removed or new_text else "sem mudanças"))
925
+ + removed + new_text,
910
926
  str(item["unchanged"]), str(item.get("via_uniflow", 0)), str(len(item["kept"]))])
911
927
  if rows:
912
928
  lines.append("")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/crow-skills",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
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": "7720abacfffca6315ecfee7b1dfced20c3c6e7387f00113ce2c437ed63f8e0bc"
21
+ "sha256": "430c470bc3c5bc90aecb240c2756f9d1d7465a1e8828c36e01451eb15b211dbd"
22
22
  },
23
23
  "crow_skills/__main__.py": {
24
24
  "executable": false,
@@ -33,12 +33,12 @@
33
33
  "crow_skills/autoupdate.py": {
34
34
  "executable": false,
35
35
  "kind": "file",
36
- "sha256": "8de4a6f3f7ce773dafb4c4f9b217d399becbd98b0b0638a80e27e13450e3a0f6"
36
+ "sha256": "f5a994f8eb47ed77861cf2e512d7660654ba3e8cf9381229add678180ae14e0d"
37
37
  },
38
38
  "crow_skills/broadcast.py": {
39
39
  "executable": false,
40
40
  "kind": "file",
41
- "sha256": "998cdd9bc67d52e63dfcde5528c5caaa04c509ec6e1e7d6afb298c8bcf4a442f"
41
+ "sha256": "135fb80925dea8e6c0790ee830c6b7e440cc5c0f116a4c9dcc4cf36bab1c22e3"
42
42
  },
43
43
  "crow_skills/cli.py": {
44
44
  "executable": false,
@@ -73,7 +73,7 @@
73
73
  "crow_skills/machine.py": {
74
74
  "executable": false,
75
75
  "kind": "file",
76
- "sha256": "8b7187afae9ad85369fdc8a918e19b32bab9d95776246cda6670a963dff19df3"
76
+ "sha256": "83d3f6fa0e296b4de87d90ff90ff50784241cf346bc1947ab78dd415409ebc0d"
77
77
  },
78
78
  "crow_skills/npm_entry.py": {
79
79
  "executable": false,
@@ -128,7 +128,7 @@
128
128
  "package.json": {
129
129
  "executable": false,
130
130
  "kind": "file",
131
- "sha256": "b70074096ec8ed3994a6cff3eeb2b72885ec366d01269f9006d0e492fccf649a"
131
+ "sha256": "84ebc44ccadb19d2e6b1640994476d016c6ebef62bb0e2d79fdd14e6643f5141"
132
132
  },
133
133
  "skills/crow-accessibility/SKILL.md": {
134
134
  "executable": false,
@@ -721,7 +721,7 @@
721
721
  "sha256": "e37132ef8b060548191fbf14a5a18a9dd3acd84ea9a35e802174455b9e374d3d"
722
722
  }
723
723
  },
724
- "revision": "9c7d5fe418c5c993eef7df75790263b3c77d400d",
724
+ "revision": "4c059d58b0bff2c5c3cfcf019667bb1ee93cd08d",
725
725
  "schema_version": 1,
726
- "version": "0.19.0"
726
+ "version": "0.20.0"
727
727
  }