@andespindola/crow-skills 0.16.0 → 0.16.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.16.0"
3
+ __version__ = "0.16.1"
@@ -212,9 +212,12 @@ def refuse_store_as_cli_dir(targets, home):
212
212
  for target in targets:
213
213
  others = [a for a in target.get("agents") or [] if a != "uniflow"]
214
214
  if others and physical(target["path"]) == store:
215
- raise CrowError("protected_path", "O destino de " + ",".join(others) + " resolve para o store do "
216
- "Uniflow (" + store + "), que vale para todos os CLIs que ele projeta, não só para "
217
- + ",".join(others) + ". Use --agents uniflow para gravar no store.")
215
+ # "custom" é o rótulo interno de --dest: dizer "o destino de custom" confunde quem não
216
+ # passou agente nenhum. A mensagem nomeia o que o usuário realmente escreveu.
217
+ quem = "--dest" if others == ["custom"] else ",".join(others)
218
+ raise CrowError("protected_path", "O destino de " + quem + " resolve para o store do Uniflow ("
219
+ + store + "), que vale para todos os CLIs que ele projeta. Use --agents uniflow "
220
+ "para gravar no store.")
218
221
 
219
222
 
220
223
  def inventory(home):
@@ -793,8 +793,12 @@ def liveness(snap, age, cfg, now=None, table=None):
793
793
  if exists is False or is_zombie:
794
794
  return None # o processo sumiu, ou é zumbi: a sessão acabou, sem esperar a janela
795
795
  recorded = presence.get("cliStart")
796
- if exists and isinstance(start, (int, float)) and isinstance(recorded, (int, float)) \
797
- and abs(start - recorded) > CLI_REUSE_SECONDS:
796
+ # Um heartbeat FRESCO vence a comparação de start: `cliStart` é gravado como
797
+ # (agora_do_hook - etime) e a tabela recalcula (agora_do_leitor - etime), então um salto de
798
+ # relógio (NTP, suspensão do Mac) maior que a tolerância faria os dois divergirem e mataria
799
+ # uma sessão VIVA. Se ela deu sinal agora, está viva, qualquer que seja a aritmética.
800
+ if exists and age > CLI_REUSE_SECONDS and isinstance(start, (int, float)) \
801
+ and isinstance(recorded, (int, float)) and abs(start - recorded) > CLI_REUSE_SECONDS:
798
802
  return None # o número do pid foi reciclado por outro processo; esta sessão morreu
799
803
  if age <= cfg["stale_minutes"] * 60:
800
804
  return "live"
@@ -140,7 +140,9 @@ def protect_skill(path, home):
140
140
  config (config.json, prompts, backups) stay protected. The package entry itself may
141
141
  be a link Uniflow made; the plan decides about it, never following it."""
142
142
  path = Path(os.path.abspath(path))
143
- if path.parent == uniflow_store(home) and NAME.fullmatch(path.name):
143
+ from .agents import physical
144
+ # Mesmo motivo: o store em dotfiles resolve, e a comparação crua nunca batia.
145
+ if physical(path.parent) == physical(uniflow_store(home)) and NAME.fullmatch(path.name):
144
146
  checked(path.parent)
145
147
  return path
146
148
  path = checked(path)
@@ -216,6 +216,22 @@ class Store:
216
216
  # existe para evitar. Sem resposta única, nenhuma resposta.
217
217
  return found[0] if len(found) == 1 else None
218
218
 
219
+ def retire_alias(self, target):
220
+ """Retira o estado gravado sob um APELIDO deste destino, depois de gravar o canônico.
221
+
222
+ Sem isto o registro antigo ficava para trás divergente e voltava a mandar se o diretório
223
+ deixasse de ser link: uma skill removida ressuscitava como `missing`, com a revisão errada,
224
+ deixando o doctor insalubre e o update em source_mismatch. Renomeia para `.superseded`
225
+ em vez de apagar, para que nada se perca se o diagnóstico precisar do histórico.
226
+ """
227
+ alias = self.aliased_state_path(target)
228
+ if alias is None:
229
+ return
230
+ try:
231
+ alias.replace(alias.with_suffix(".superseded.json"))
232
+ except OSError:
233
+ pass # nunca derruba a mutação por causa da aposentadoria do apelido
234
+
219
235
  def state(self, target):
220
236
  target = resolved(target)
221
237
  path = checked(self.state_path(target))
@@ -354,7 +370,13 @@ class Store:
354
370
  "crow-skills uninstall " + name + " --dest " + str(target) + " --yes e, se for para o "
355
371
  "Uniflow, reinstale com --agents uniflow --global.") from exc
356
372
  # Only the uniflow agent replaces Uniflow's import links; --dest keeps refusing links.
357
- store_target = "uniflow" in target_info["agents"] and target == uniflow_store(self.home)
373
+ # physical() e não resolved(): plan() tem um local chamado `resolved` mais abaixo, que
374
+ # torna o nome local em toda a função e faria esta linha estourar UnboundLocalError.
375
+ # O store do Uniflow em dotfiles tem o caminho resolvido, e comparar com o não resolvido
376
+ # deixava o store inteiro não gerenciável (lacuna anterior à 0.16.0).
377
+ from .agents import physical as _physical
378
+ store_target = "uniflow" in target_info["agents"] and \
379
+ str(target) == _physical(uniflow_store(self.home))
358
380
  before[key] = state
359
381
  after[key] = json.loads(json.dumps(state))
360
382
  selected = list(names) if names else list(state["installed"])
@@ -468,6 +490,7 @@ class Store:
468
490
  os.replace(work / "new" / item["name"], dest)
469
491
  for target, state in data["after"].items():
470
492
  atomic_json(self.state_path(target), retargeted(state, target))
493
+ self.retire_alias(target)
471
494
  data["phase"] = "committed"
472
495
  atomic_json(journal, data)
473
496
  except (Exception, KeyboardInterrupt):
@@ -537,6 +560,7 @@ class Store:
537
560
  os.replace(backup, dest)
538
561
  for target, state in data["before"].items():
539
562
  atomic_json(self.state_path(target), retargeted(state, target))
563
+ self.retire_alias(target)
540
564
  data["phase"] = "rolled_back"
541
565
  atomic_json(self.root / "transactions" / (data["id"] + ".json"), data)
542
566
 
@@ -583,7 +607,11 @@ class Store:
583
607
  continue
584
608
  record = state["installed"].get(name)
585
609
  link = target / name
586
- store = uniflow_store(self.home)
610
+ from .agents import physical as _physical # local `resolved` nesta função sombreia o helper
611
+ # Path(), não a string crua: `physical()` devolve str, e aqui o valor é comparado
612
+ # com um Path (`target == store`, que seria SEMPRE falso, matando em silêncio o
613
+ # ramo de status external_link) e usado como caminho (`store / name`, TypeError).
614
+ store = Path(_physical(uniflow_store(self.home)))
587
615
  if link.is_symlink() and target == store:
588
616
  resolved = os.path.realpath(link)
589
617
  managed = self.managed_target(resolved, name)
package/docs/cli.md CHANGED
@@ -228,6 +228,12 @@ O Uniflow guarda as skills num store próprio e cria, ao subir cada CLI, links d
228
228
  - Cópias nativas duplicadas: se claude, gemini ou devin já têm um pacote crow de mesmo nome, o Uniflow não cria o link para ele e a cópia nativa continua valendo ali, possivelmente em outra revisão (`effect: skipped`). O codex se comporta igual desde a 0.15.0, por instalar no diretório dele (`effect: skipped`); antes ele instalava na pasta compartilhada e o Codex via a mesma skill duas vezes. `install`/`update` com `uniflow` listam essas cópias em `uniflow.native_duplicates` (diretórios que não puderam ser lidos, como um `~/.claude` symlink de dotfiles, vão para `uniflow.uninspected` sem derrubar o comando) e sugerem em `uniflow.suggested_command` o `uninstall` explícito; nada é removido sem esse comando. Remover a cópia nativa da crow-behaviour remove também o guia daquele agente, que deixa de valer fora do Uniflow.
229
229
  - Links projetados: em `status`/`doctor`, um link do Uniflow para o store num diretório de agente aparece como `uniflow_link`, e não deixa o doctor `healthy:false`.
230
230
 
231
+ #### Estado gravado sob um apelido do destino
232
+
233
+ A chave do estado é o caminho FÍSICO. Um estado escrito quando `~/.claude` ainda era diretório comum fica sob outra chave, e `Store.state()` o encontra varrendo os destinos conhecidos (`aliased_state_path`) para não perder a instalação quando você move tudo para dotfiles. Depois de gravar o estado canônico, a mutação APOSENTA o apelido, renomeando-o para `.superseded.json` em vez de apagar. Sem essa aposentadoria o registro antigo ficava divergente e voltava a mandar caso o diretório deixasse de ser link: uma skill removida ressuscitava como `missing`, com a revisão errada, e o `update` passava a recusar com `source_mismatch`. Dois apelidos para o mesmo caminho físico são ambíguos e nenhum é usado.
234
+
235
+ Consequência a conhecer, se você DESFIZER o link depois de ter mexido pelo link: o estado canônico ficou sob o caminho físico de então (o dos dotfiles) e o apelido antigo foi aposentado, então o novo local não tem registro e as skills aparecem como `unmanaged`, com o `doctor` saudável. É a resposta honesta — não existe registro para aquele caminho —, e `install --replace` ou `setup` readotam o que está lá. O comportamento anterior era pior: o registro antigo ressuscitava e reportava uma skill já removida como `missing`, com a revisão errada, deixando o `doctor` insalubre e o `update` em `source_mismatch`.
236
+
231
237
  #### Exercitar um hook sem efeito colateral (`--probe`)
232
238
 
233
239
  `crow_hook.py hook --host <host> --event <evento> --probe` calcula exatamente o que o hook entregaria naquele evento — modo crow, aviso de memória e o bloco do crow-broadcast — e **não grava nada**: nenhum evento no barramento, nenhum snapshot de sessão, nenhuma presença, nenhuma escrita de modo. É o que usar para conferir se o hook de um CLI está cabeado e funcionando.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/crow-skills",
3
- "version": "0.16.0",
3
+ "version": "0.16.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": "6a988b314e5b6bb3f2db799d74aec9717c6c3dfa59b4b1ebdeb1e3697fad498b"
21
+ "sha256": "fb5a2f995f5007c08a1d21d9853763865628ac218330da800ef7b3461282a626"
22
22
  },
23
23
  "crow_skills/__main__.py": {
24
24
  "executable": false,
@@ -28,7 +28,7 @@
28
28
  "crow_skills/agents.py": {
29
29
  "executable": false,
30
30
  "kind": "file",
31
- "sha256": "1c2f61e6729c88f0ddc218fba0c5159869e5226d4f7bc0fd2181dd688d4272fe"
31
+ "sha256": "903750fa57f9aa3cba686bcbd3775881493275b1e5aff86c5e57035d2144c7f1"
32
32
  },
33
33
  "crow_skills/autoupdate.py": {
34
34
  "executable": false,
@@ -38,7 +38,7 @@
38
38
  "crow_skills/broadcast.py": {
39
39
  "executable": false,
40
40
  "kind": "file",
41
- "sha256": "3d25bd5fa2bdbe7e6ec78ce9c44a3b96f13d48b2ceea076c792ffbecd5bbbf1f"
41
+ "sha256": "d886806e138953dfe47bd19c8f12b4267f5137e510346081c2e3fb15e4b7a8fb"
42
42
  },
43
43
  "crow_skills/cli.py": {
44
44
  "executable": false,
@@ -58,7 +58,7 @@
58
58
  "crow_skills/guide.py": {
59
59
  "executable": false,
60
60
  "kind": "file",
61
- "sha256": "ab75772bc70758b3c3b16339dcf0ec572af358dd0378f7fc3844dbf039590563"
61
+ "sha256": "0b5ca9d1629bcdc88e30f42b14b1189f41e9417b46c16771c339fe3ffc09dece"
62
62
  },
63
63
  "crow_skills/hook.py": {
64
64
  "executable": false,
@@ -103,7 +103,7 @@
103
103
  "crow_skills/storage.py": {
104
104
  "executable": false,
105
105
  "kind": "file",
106
- "sha256": "7cbc25b39ada200e516079b81a77770e641b267cd5e5868686eb0d87a6604ea8"
106
+ "sha256": "2e276f27c25256508670afcd1d6da38811819351dee45e42f4098090a00d8832"
107
107
  },
108
108
  "crow_skills/trust.py": {
109
109
  "executable": false,
@@ -123,12 +123,12 @@
123
123
  "docs/cli.md": {
124
124
  "executable": false,
125
125
  "kind": "file",
126
- "sha256": "0f425d8d694e1103cd9e56b8b6f6332756bcb8415ca04ec55e0db2f201481807"
126
+ "sha256": "84bc06ac93083a18d81d202409dfe26202feb20d4d6cab7a46520badd2b13a13"
127
127
  },
128
128
  "package.json": {
129
129
  "executable": false,
130
130
  "kind": "file",
131
- "sha256": "64613f54bedd2d62023be224e30de2dd87bbc0ac7664ed0c49ee8e20a6454ade"
131
+ "sha256": "774b17857c2e1701e444296d522e1d36d3a1b4915b63bb86d3bc45d405a51f73"
132
132
  },
133
133
  "skills/crow-accessibility/SKILL.md": {
134
134
  "executable": false,
@@ -716,7 +716,7 @@
716
716
  "sha256": "e37132ef8b060548191fbf14a5a18a9dd3acd84ea9a35e802174455b9e374d3d"
717
717
  }
718
718
  },
719
- "revision": "23cca75e8117dd6110db7e2565b0ce0b740b8efb",
719
+ "revision": "a0d9e9a088c074f4422b2181ac0629631dbea321",
720
720
  "schema_version": 1,
721
- "version": "0.16.0"
721
+ "version": "0.16.1"
722
722
  }