@andespindola/crow-skills 0.1.0 → 0.3.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.
package/README.md CHANGED
@@ -25,7 +25,7 @@ Exemplos:
25
25
 
26
26
  ## Instalação e portabilidade
27
27
 
28
- O pacote de cada skill é somente sua pasta em `skills/`. O [CLI crow-skills](docs/cli.md) instala cópias completas e versionadas em agentes/escopos explícitos, com preview, saída JSON, detecção de conflitos e recuperação. Requer Python 3.10+; Git para fontes em clone, Node 18+ para o launcher npm; mutações em macOS/Linux. A distribuição npm privada `@andespindola/crow-skills` inclui um snapshot completo das skills, sem clone ou hooks que as instalem automaticamente.
28
+ O pacote de cada skill é somente sua pasta em `skills/`. O [CLI crow-skills](docs/cli.md) instala cópias completas e versionadas em agentes/escopos explícitos, com preview, saída JSON, detecção de conflitos e recuperação. Requer Python 3.10+; Git para fontes em clone, Node 18+ para o launcher npm; mutações em macOS/Linux. A distribuição npm privada `@andespindola/crow-skills` inclui um snapshot completo das skills, sem clone ou hooks que as instalem automaticamente. Em `--global`, instalar a crow-behaviour também grava o guia aditivo do agente (claude, codex, gemini, devin) para usar as skills; `--no-guide` desliga.
29
29
 
30
30
  ```sh
31
31
  ./bin/crow-skills list
package/catalog.json CHANGED
@@ -12,7 +12,7 @@
12
12
  {
13
13
  "name": "crow-behaviour",
14
14
  "title": "Atuação coordenada",
15
- "description": "Aplique um modo de atuação orientado a orquestração, profundidade e verificação em tarefas substantivas. Decomponha, delegue com as capacidades reais do agente e sintetize evidências. Em conversa ou ajustes triviais, responda diretamente; não simule agentes nem altere permissões.",
15
+ "description": "Use PROATIVAMENTE, sem o usuário pedir, sempre que a tarefa for substantiva — multi-etapa, com exploração/decisões, mexendo em vários arquivos ou componentes, ou exigindo verificação antes de entregar (implementar uma feature, refatorar, depurar, auditar, revisar, migrar, pesquisar a fundo). Ao ativar: decomponha o trabalho, delegue com as capacidades reais do runtime, verifique de forma adversarial e sintetize evidências. NÃO ative em conversa, explicação curta ou ajuste trivial/local — nesses casos responda direto. Não simule agentes nem altere permissões.",
16
16
  "role": "coordinator",
17
17
  "owns": "execution-ledger",
18
18
  "next": []
@@ -1,3 +1,3 @@
1
1
  """Portable, local-first installer for the crow-skills catalogue."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.3.0"
@@ -1,16 +1,18 @@
1
1
  """Human and agent CLI. Noninteractive mutations require explicit --yes."""
2
2
  import argparse
3
3
  import json
4
+ import os
4
5
  from pathlib import Path
5
6
  import sys
6
7
 
7
8
  from . import __version__
9
+ from . import guide
8
10
  from .agents import AGENTS, destinations, inventory
9
11
  from .source import CrowError, Source, valid_name
10
12
  from .storage import Store, checked
11
13
 
12
14
  DEFAULT_SOURCE = Path(__file__).resolve().parents[1]
13
- MUTATIONS = {"install", "update", "uninstall", "rollback", "recover"}
15
+ MUTATIONS = {"install", "update", "uninstall", "rollback", "recover", "guide"}
14
16
 
15
17
 
16
18
  class Parser(argparse.ArgumentParser):
@@ -51,6 +53,11 @@ def parser():
51
53
  child.add_argument("--replace", action="store_true", help="Autorizar substituição com backup de pacote não gerenciado")
52
54
  child.add_argument("--with-handoffs", action="store_true", help="Incluir apenas handoffs diretos opcionais do catálogo")
53
55
  child.add_argument("--overwrite-modified", action="store_true", help="Autorizar backup e substituição/remoção de edições locais")
56
+ child.add_argument("--no-guide", action="store_true", help="Não aplicar/atualizar/remover o guia do agente (--global)")
57
+ child = sub.add_parser("guide", parents=[common], help="Instruções gerenciadas para o agente usar as skills instaladas")
58
+ child.add_argument("action", choices=("install", "status", "uninstall"))
59
+ child.add_argument("--replace", action="store_true", help="Autorizar backup e substituição de conteúdo não gerenciado no caminho do guia")
60
+ child.add_argument("--overwrite-modified", action="store_true", help="Autorizar backup e substituição/remoção de guia editado")
54
61
  for name in ("rollback", "recover"):
55
62
  child = sub.add_parser(name, parents=[common])
56
63
  child.add_argument("--transaction", required=name == "rollback")
@@ -113,6 +120,119 @@ def relationship_view(source, name, store, targets):
113
120
  "meaning": "Handoffs e overlays são complementos opcionais, não dependências nem ordem automática de execução."}
114
121
 
115
122
 
123
+ def guide_command(args, home, store, interactive):
124
+ if getattr(args, "project", None) or getattr(args, "dest", None):
125
+ raise CrowError("unsupported_guide", "Guias só têm mecanismo nativo verificado no escopo do usuário; use --global. "
126
+ "Escopo de projeto e --dest não são suportados.")
127
+ agents = getattr(args, "agents", None)
128
+ if args.action == "status":
129
+ rows = guide.status(store, home, guide.select(agents) if agents else list(guide.MECHANISMS))
130
+ return {"guides": rows, "pending_transactions": [t["id"] for t in store.pending()], "activation_verified": False}
131
+ if not getattr(args, "global_scope", False):
132
+ raise CrowError("usage", "guide " + args.action + " exige --global explícito (único escopo verificado).")
133
+ if not agents and interactive:
134
+ agents = prompt("Agentes separados por vírgula (" + ",".join(guide.MECHANISMS) + "):")
135
+ selected = guide.select(agents)
136
+ source = None
137
+ if args.action == "install":
138
+ source = Source(getattr(args, "source", DEFAULT_SOURCE), getattr(args, "ref", "HEAD"), getattr(args, "fetch", False))
139
+ plan = guide.plan(store, args.action, selected, home, source, args.replace, args.overwrite_modified)
140
+ summary = {"changes": plan["changes"], "revision": source.revision if source else None,
141
+ "additive": True, "activation_verified": False}
142
+ if getattr(args, "dry_run", False):
143
+ summary["preview"] = plan["previews"]
144
+ if plan["items"]:
145
+ authorize(args, interactive, summary)
146
+ txid = None if getattr(args, "dry_run", False) else guide.apply(store, plan, home)
147
+ return {**summary, "transaction": txid, "dry_run": getattr(args, "dry_run", False),
148
+ "applied": not getattr(args, "dry_run", False),
149
+ "reload_hint": ("Somente preview; nada gravado." if getattr(args, "dry_run", False) else
150
+ "Abra uma sessão nova do CLI para carregar as instruções; o carregamento não é verificado por este comando.")}
151
+
152
+
153
+ GUIDE_HINT = (" O guia do agente é aplicado junto com as skills em --global; revise e use --replace ou "
154
+ "--overwrite-modified conforme o caso, ou --no-guide para não alterar o guia. Nada foi alterado.")
155
+
156
+
157
+ def guide_sync(args, home, store, command, targets, plan, source):
158
+ """Plan the guide changes that follow a skills plan, before anything is written.
159
+
160
+ Returns (report, guide plan or None). A refused guide raises, so the command
161
+ fails as a whole with no partial state.
162
+ """
163
+ report = {"changes": [], "skipped": [], "transaction": None}
164
+ selected = [a for t in targets for a in t["agents"]]
165
+ if getattr(args, "no_guide", False):
166
+ report["skipped"] = [{"agent": a, "reason": "--no-guide: guia não alterado."} for a in selected]
167
+ return report, None
168
+ if getattr(args, "project", None) or getattr(args, "dest", None):
169
+ report["skipped"] = [{"agent": a, "reason": "Sem mecanismo de guia verificado fora do escopo do usuário; "
170
+ "guia aplicado só com --global."} for a in selected]
171
+ return report, None
172
+ for agent in selected:
173
+ if agent not in guide.MECHANISMS:
174
+ report["skipped"].append({"agent": agent, "reason": guide.UNVERIFIED.get(agent, "Sem mecanismo de guia verificado.")})
175
+ state = guide.load_state(store)
176
+ touched = {}
177
+ for item in plan["items"]:
178
+ added, removed = touched.setdefault(os.path.abspath(item["target"]), (set(), set()))
179
+ (removed if item["new"] is None else added).add(item["name"])
180
+ actions, skills_after = {}, {}
181
+ for agent, mech in guide.MECHANISMS.items():
182
+ path = os.path.abspath(destinations(agent, home)[0]["path"])
183
+ record = state["guides"].get(agent)
184
+ if agent not in selected and not (record and path in touched):
185
+ continue
186
+ added, removed = touched.get(path, (set(), set()))
187
+ after = sorted((set(guide.managed_skills(agent, home, store)) | added) - removed)
188
+ if mech["kind"] == "toml" and guide.tomllib is None:
189
+ report["skipped"].append({"agent": agent, "reason": "Python 3.11+ (tomllib) é necessário para editar config.toml; guia não alterado."})
190
+ elif "crow-behaviour" in after and (record or command != "uninstall"):
191
+ actions[agent], skills_after[agent] = "install", after
192
+ elif record and "crow-behaviour" in removed:
193
+ actions[agent] = "uninstall"
194
+ elif record and "crow-behaviour" not in after:
195
+ report["skipped"].append({"agent": agent, "reason": "crow-behaviour ausente; guia mantido. Confira crow-skills guide status."})
196
+ elif not record and command != "uninstall":
197
+ report["skipped"].append({"agent": agent, "reason": "crow-behaviour não ficará instalada para " + agent + "; guia não aplicado."})
198
+ if not actions:
199
+ return report, None
200
+ if source is None and "install" in actions.values():
201
+ source = Source(getattr(args, "source", DEFAULT_SOURCE), getattr(args, "ref", "HEAD"))
202
+ try:
203
+ gplan = guide.plan(store, actions, list(actions), home, source, getattr(args, "replace", False),
204
+ args.overwrite_modified, skills_after)
205
+ except CrowError as exc:
206
+ raise CrowError(exc.code, str(exc) + GUIDE_HINT) from exc
207
+ report["changes"] = gplan["changes"]
208
+ report["revision"] = source.revision if source else None
209
+ if getattr(args, "dry_run", False):
210
+ report["preview"] = gplan["previews"]
211
+ return report, gplan
212
+
213
+
214
+ def apply_chain(store, plan, gplan, home):
215
+ """Skills first, then the guide; a failed guide rolls the skills back."""
216
+ txid = store.apply(plan)
217
+ if gplan is None:
218
+ return txid, None
219
+ try:
220
+ return txid, guide.apply(store, gplan, home, linked=txid)
221
+ except (Exception, KeyboardInterrupt) as exc:
222
+ if txid:
223
+ try:
224
+ store.rollback(txid)
225
+ except CrowError as inner:
226
+ raise CrowError("partial_state", "Guia falhou e as skills da transação " + txid + " não puderam ser desfeitas (" +
227
+ str(inner) + "). Execute crow-skills recover; ele desfaz o guia e as skills ligadas.") from exc
228
+ raise
229
+
230
+
231
+ def linked_guides(store, txid):
232
+ """Committed guide transactions that completed the given skills transaction."""
233
+ return [t["id"] for t in store.transactions() if t.get("kind") == "guide" and t.get("linked") == txid and t["phase"] == "committed"]
234
+
235
+
116
236
  def run(args):
117
237
  if args.version:
118
238
  return {"version": __version__}
@@ -121,18 +241,24 @@ def run(args):
121
241
  home = checked(getattr(args, "home", Path.home()))
122
242
  store = Store(home)
123
243
  interactive = sys.stdin.isatty() and not getattr(args, "non_interactive", False) and not getattr(args, "json", False)
124
- if getattr(args, "fetch", False) and (getattr(args, "dry_run", False) or args.command not in ("install", "update", "list", "info", "related")):
244
+ if getattr(args, "fetch", False) and (getattr(args, "dry_run", False) or args.command not in ("install", "update", "list", "info", "related", "guide")):
125
245
  raise CrowError("usage", "--fetch requer um comando com fonte e não pode ser combinado com --dry-run.")
126
246
  if args.command == "agents":
127
247
  return {"agents": inventory(home), "note": "Detecção por PATH/diretórios não comprova carregamento da skill."}
248
+ if args.command == "guide":
249
+ return guide_command(args, home, store, interactive)
128
250
  if args.command in ("status", "doctor"):
129
251
  targets = scope(args, home, read=True)
130
252
  rows = store.status(targets)
131
253
  pending = [t["id"] for t in store.pending()]
132
- result = {"installations": rows, "pending_transactions": pending,
254
+ global_view = not (getattr(args, "project", None) or getattr(args, "dest", None))
255
+ names = [n.strip() for n in getattr(args, "agents", "").split(",")] if getattr(args, "agents", None) else list(guide.MECHANISMS)
256
+ guides = guide.status(store, home, names) if global_view else []
257
+ result = {"installations": rows, "guides": guides, "pending_transactions": pending,
133
258
  "activation_verified": False, "reload_hint": "Peça ao host para recarregar/listar skills; reinicie a sessão se necessário."}
134
259
  if args.command == "doctor":
135
- result.update(healthy=not pending and all(r["status"] not in ("modified", "missing", "unsafe") for r in rows), agents=inventory(home))
260
+ result.update(healthy=not pending and all(r["status"] not in ("modified", "missing", "unsafe") for r in rows + guides),
261
+ agents=inventory(home))
136
262
  return result
137
263
  if args.command in ("rollback", "recover"):
138
264
  if any(getattr(args, k, None) for k in ("agents", "dest", "project", "global_scope")):
@@ -142,12 +268,28 @@ def run(args):
142
268
  expected = ("prepared", "restoring") if args.command == "recover" else ("committed",)
143
269
  if any(t["phase"] not in expected for t in transactions):
144
270
  raise CrowError("invalid_transaction", "Transação incompatível com a operação.")
145
- summary = {"transactions": [{"id": t["id"], "targets": list(t["before"])} for t in transactions]}
271
+ # install/update/uninstall chain a skills transaction and a guide transaction
272
+ # (journal field "linked"). Undo both together: the guide first on rollback;
273
+ # on recover, an interrupted guide also undoes the skills it completed.
274
+ follow = []
275
+ if args.command == "rollback":
276
+ before_ids = [g for txid in ids for g in linked_guides(store, txid)]
277
+ ids, transactions = before_ids + ids, [store.journal(t) for t in before_ids] + transactions
278
+ else:
279
+ follow = [t["linked"] for t in transactions if t.get("linked")
280
+ and store.journal(t["linked"])["phase"] == "committed"]
281
+ view = lambda t: {"id": t["id"], "kind": t.get("kind", "skills"),
282
+ "targets": [i["path"] for i in t["items"]] if t.get("kind") == "guide" else list(t["before"])}
283
+ summary = {"transactions": [view(t) for t in transactions]}
284
+ if follow:
285
+ summary["then_rollback"] = [view(store.journal(t)) for t in follow]
146
286
  if transactions:
147
287
  authorize(args, interactive, summary)
148
288
  if not getattr(args, "dry_run", False):
149
289
  for txid in ids:
150
290
  store.rollback(txid, recovery=args.command == "recover")
291
+ for txid in follow:
292
+ store.rollback(txid)
151
293
  return {**summary, "dry_run": getattr(args, "dry_run", False), "applied": not getattr(args, "dry_run", False)}
152
294
  source = None
153
295
  if args.command != "uninstall":
@@ -185,12 +327,15 @@ def run(args):
185
327
  source.require(name)
186
328
  names.extend(n for n in source.catalog[name]["next"] if n not in names)
187
329
  plan = store.plan(args.command, names, targets, source, getattr(args, "replace", False), args.overwrite_modified)
330
+ guides, gplan = guide_sync(args, home, store, args.command, targets, plan, source)
188
331
  summary = {"changes": plan["changes"], "revision": source.revision if source else None,
189
332
  "relationships": {n: source.related(n) for n in names} if source else {},
190
- "handoffs_are_optional": True, "activation_verified": False}
191
- if plan["items"]:
333
+ "guides": guides, "handoffs_are_optional": True, "activation_verified": False}
334
+ if plan["items"] or (gplan and gplan["items"]):
192
335
  authorize(args, interactive, summary)
193
- txid = None if getattr(args, "dry_run", False) else store.apply(plan)
336
+ txid = None
337
+ if not getattr(args, "dry_run", False):
338
+ txid, guides["transaction"] = apply_chain(store, plan, gplan, home)
194
339
  return {**summary, "transaction": txid, "dry_run": getattr(args, "dry_run", False),
195
340
  "applied": not getattr(args, "dry_run", False),
196
341
  "reload_hint": ("Somente preview; nada instalado." if getattr(args, "dry_run", False) else
@@ -0,0 +1,585 @@
1
+ """Managed, additive agent guidance in each CLI's native always-loaded location.
2
+
3
+ Only mechanisms proven with a sentinel in a fresh interactive session of the
4
+ CLI are supported, all of them at user scope and all additive: the CLI keeps
5
+ loading its existing base/shared prompt next to this guide. Shared prompts
6
+ (for example a ~/.claude/CLAUDE.md symlink) and their targets are never
7
+ written, and mechanisms that replace a base prompt are not used.
8
+
9
+ Ownership: a whole file that this CLI creates (claude, gemini, devin) or a
10
+ single marked block holding one top-level key in a config file (codex). The
11
+ rest of a shared config file is preserved byte for byte.
12
+ """
13
+ from copy import deepcopy
14
+ from datetime import datetime, timezone
15
+ import hashlib
16
+ import os
17
+ from pathlib import Path
18
+ import re
19
+ import stat
20
+ import uuid
21
+
22
+ try: # Python 3.11+; required only to verify config.toml edits.
23
+ import tomllib
24
+ except ImportError: # pragma: no cover - CI and supported hosts have 3.11+
25
+ tomllib = None
26
+
27
+ from .agents import destinations
28
+ from .source import CrowError, SHA
29
+ from .storage import atomic_json, checked, private_dir, read_json
30
+
31
+ HEADER = "<!-- Gerenciado por crow-skills guide. Não edite; use crow-skills guide install/uninstall. -->"
32
+ BEGIN = "# >>> crow-skills guide (gerenciado; use crow-skills guide uninstall) >>>"
33
+ END = "# <<< crow-skills guide <<<"
34
+ TOML_KEY = "developer_instructions"
35
+ MAX_CONFIG = 4 * 1024 * 1024
36
+ HEX = re.compile(r"[a-f0-9]{64}\Z")
37
+ PRESERVED = re.compile(r"[a-f0-9]{32}/[a-z]+\.bak\Z")
38
+
39
+ # path is relative to the user's home. All entries were verified on 2026-10-21
40
+ # with a sentinel loaded in a fresh session alongside the existing base prompt.
41
+ MECHANISMS = {
42
+ "claude": {"kind": "file", "path": ".claude/rules/crow-skills.md", "frontmatter": False,
43
+ "label": "Claude Code", "invoke": "/{name}",
44
+ "load": "use a ferramenta Skill com o nome da skill ou digite /nome"},
45
+ "codex": {"kind": "toml", "path": ".codex/config.toml", "label": "Codex CLI", "invoke": "${name}",
46
+ "load": "mencione $nome ou abra o SKILL.md dela pela lista de skills disponíveis"},
47
+ "gemini": {"kind": "file", "path": ".gemini/config/rules/crow-skills.md", "frontmatter": True,
48
+ "label": "Antigravity CLI, agy", "invoke": "/{name}",
49
+ "load": "use a ferramenta de skills do agy ou digite /nome"},
50
+ "devin": {"kind": "file", "path": ".devin/rules/crow-skills.md", "frontmatter": True,
51
+ "label": "Devin CLI", "invoke": "/devin:{name}",
52
+ "load": "use a ferramenta de skills do Devin ou digite /devin:nome"},
53
+ }
54
+ UNVERIFIED = {
55
+ "shared": "shared é um diretório de skills, não um CLI; não há onde gravar instruções.",
56
+ }
57
+ # Shared prompt files (often symlinks into ~/.config/uniflow) and their targets.
58
+ PROTECTED = (".claude/CLAUDE.md", ".codex/AGENTS.md", ".codex/AGENTS.override.md", ".gemini/GEMINI.md",
59
+ ".config/devin/CLAUDE.md", ".config/devin/AGENTS.md", ".config/uniflow")
60
+
61
+
62
+ def sha(data):
63
+ if data is None:
64
+ return None
65
+ if isinstance(data, str):
66
+ data = data.encode("utf-8")
67
+ return hashlib.sha256(data).hexdigest()
68
+
69
+
70
+ def now():
71
+ return datetime.now(timezone.utc).isoformat()
72
+
73
+
74
+ def select(agents):
75
+ if not agents:
76
+ raise CrowError("usage", "Selecione --agents; não há instalação implícita em todos os agentes.")
77
+ names = agents.split(",") if isinstance(agents, str) else list(agents)
78
+ result = []
79
+ for name in (n.strip() for n in names):
80
+ if name in UNVERIFIED:
81
+ raise CrowError("unsupported_guide", "Sem mecanismo verificado para " + name + ": " + UNVERIFIED[name])
82
+ if name not in MECHANISMS:
83
+ raise CrowError("unsupported_guide", "Sem mecanismo nativo verificado para '" + name +
84
+ "'. Suportados: " + ",".join(MECHANISMS) + ".")
85
+ if name not in result:
86
+ result.append(name)
87
+ return result
88
+
89
+
90
+ def protect(path, home):
91
+ """Refuse symlinked components, shared prompts and anything inside their targets."""
92
+ path = checked(path)
93
+ for rel in PROTECTED:
94
+ guard = Path(os.path.abspath(home / rel))
95
+ guards = {guard}
96
+ if os.path.lexists(guard):
97
+ guards.add(Path(os.path.realpath(guard)))
98
+ for item in guards:
99
+ if path == item or path.is_relative_to(item):
100
+ raise CrowError("protected_path", "Arquivo de prompt compartilhado (ou alvo dele) não é gravado: " + str(path))
101
+ return path
102
+
103
+
104
+ def read_regular(path):
105
+ if not os.path.lexists(path):
106
+ return None
107
+ info = path.lstat()
108
+ if not stat.S_ISREG(info.st_mode):
109
+ raise CrowError("unsafe_destination", "Destino do guia não é arquivo regular: " + str(path))
110
+ if info.st_nlink > 1:
111
+ raise CrowError("unsafe_destination", "Destino do guia tem hard links; recusado: " + str(path))
112
+ if info.st_size > MAX_CONFIG:
113
+ raise CrowError("unsafe_destination", "Destino do guia excede o limite de leitura segura.")
114
+ return path.read_bytes()
115
+
116
+
117
+ def write_atomic(path, data, mode):
118
+ temp = path.parent / ("." + path.name + ".crow-" + uuid.uuid4().hex)
119
+ try:
120
+ fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
121
+ with os.fdopen(fd, "wb") as f:
122
+ f.write(data)
123
+ f.flush()
124
+ os.fsync(f.fileno())
125
+ os.chmod(temp, mode)
126
+ os.replace(temp, path)
127
+ fd = os.open(path.parent, os.O_RDONLY)
128
+ try:
129
+ os.fsync(fd)
130
+ finally:
131
+ os.close(fd)
132
+ finally:
133
+ if os.path.lexists(temp):
134
+ temp.unlink()
135
+
136
+
137
+ # --- content -----------------------------------------------------------------
138
+
139
+ def clean(text):
140
+ return "".join(c for c in str(text) if c.isprintable()).replace("'''", "'").replace("-->", "->")
141
+
142
+
143
+ def managed_skills(agent, home, store):
144
+ """crow-* packages this CLI manages for the agent (edited copies still count)."""
145
+ target = destinations(agent, home)[0]
146
+ return sorted(r["name"] for r in store.status([target], ()) if r["status"] in ("installed", "modified"))
147
+
148
+
149
+ def present_skills(agent, home, store, catalog):
150
+ return [n for n in managed_skills(agent, home, store) if n in catalog]
151
+
152
+
153
+ def render_body(agent, catalog, revision, skills):
154
+ mech = MECHANISMS[agent]
155
+ invoke = mech["invoke"].format
156
+ specialists = [n for n in skills if n != "crow-behaviour"]
157
+ example = " (por exemplo, " + invoke(name=specialists[0]) + ")" if specialists else ""
158
+ lines = [
159
+ "# Crow skills neste agente (" + mech["label"] + ")",
160
+ "",
161
+ "Gerado por crow-skills guide a partir do catálogo " + revision[:12] + ". Complementa as instruções do usuário e do projeto; havendo conflito, elas prevalecem.",
162
+ "",
163
+ "## crow-behaviour: quando aplicar",
164
+ "- Tarefa substantiva (várias etapas ou partes, exploração e decisões, mudanças em vários arquivos ou componentes, ou entrega que exige verificação): carregue a skill crow-behaviour ANTES de começar, por iniciativa própria, sem esperar pedido. Para carregar, " + mech["load"] + "; por exemplo, " + invoke(name="crow-behaviour") + ". Siga-a até a entrega.",
165
+ "- Vale também quando a entrega é só um plano, um diagnóstico ou uma revisão, ou quando não há código no diretório: depurar, auditar, investigar, migrar ou planejar uma mudança com várias etapas continua sendo tarefa substantiva.",
166
+ "- Conversa, pergunta factual curta ou ajuste trivial e local: responda direto, sem carregá-la.",
167
+ "",
168
+ "## Skills especialistas",
169
+ ]
170
+ lines += [
171
+ "- Antes de executar, consulte a lista de skills disponíveis neste agente e carregue pelo nome a especialista cujo domínio corresponde à tarefa" + example + ". Se a tarefa cruzar domínios, combine as pertinentes; a crow-behaviour coordena, a especialista executa.",
172
+ "- Handoffs entre skills são sugestões opcionais, não dependências; não carregue a biblioteca inteira.",
173
+ "- Se uma skill não estiver disponível no host, diga isso e siga sem ela; nunca simule o conteúdo de uma skill que não carregou.",
174
+ "",
175
+ "## Crow skills gerenciadas neste agente",
176
+ ]
177
+ for name in skills:
178
+ lines.append("- " + invoke(name=name) + " — " + clean(catalog[name].get("title") or catalog[name]["owns"]))
179
+ return "\n".join(lines) + "\n"
180
+
181
+
182
+ def managed_content(agent, body):
183
+ """Exact bytes owned by this CLI: a whole file, or one marked TOML block."""
184
+ mech = MECHANISMS[agent]
185
+ if mech["kind"] == "toml":
186
+ if "'''" in body or any(not (c.isprintable() or c in "\n\t") for c in body):
187
+ raise CrowError("invalid_guide", "Conteúdo do guia não cabe numa string TOML literal.")
188
+ return BEGIN + "\n" + TOML_KEY + " = '''\n" + body + "'''\n" + END + "\n"
189
+ front = "---\ntrigger: always_on\n---\n\n" if mech["frontmatter"] else ""
190
+ return front + HEADER + "\n\n" + body
191
+
192
+
193
+ # --- codex config.toml block ------------------------------------------------------
194
+
195
+ def parse_toml(text):
196
+ if tomllib is None:
197
+ raise CrowError("unsupported_platform", "Python 3.11+ (tomllib) é necessário para editar config.toml com verificação.")
198
+ try:
199
+ return tomllib.loads(text)
200
+ except tomllib.TOMLDecodeError as exc:
201
+ raise CrowError("invalid_config", "config.toml não é TOML válido; nada alterado.") from exc
202
+
203
+
204
+ def find_block(text):
205
+ """Return (start, end) offsets of the managed block, or None."""
206
+ begins = [m.start() for m in re.finditer(r"(?m)^" + re.escape(BEGIN) + r"\n", text)]
207
+ ends = [m.end() for m in re.finditer(r"(?m)^" + re.escape(END) + r"\n", text)]
208
+ if not begins and not ends:
209
+ return None
210
+ if len(begins) != 1 or len(ends) != 1 or ends[0] <= begins[0]:
211
+ raise CrowError("guide_corrupted", "Marcadores do bloco crow-skills em config.toml estão incompletos ou duplicados; revise manualmente.")
212
+ return begins[0], ends[0]
213
+
214
+
215
+ def decode(data):
216
+ try:
217
+ return data.decode("utf-8")
218
+ except UnicodeDecodeError as exc:
219
+ raise CrowError("invalid_config", "config.toml não é UTF-8; nada alterado.") from exc
220
+
221
+
222
+ def toml_block(data):
223
+ if data is None:
224
+ return None
225
+ text = decode(data)
226
+ span = find_block(text)
227
+ return text[span[0]:span[1]] if span else None
228
+
229
+
230
+ def toml_compose(old_bytes, old_block, new_block, body=None):
231
+ """Remove the existing managed block and prepend the new one; verify parse."""
232
+ text = decode(old_bytes) if old_bytes is not None else ""
233
+ if old_block is not None:
234
+ start, end = find_block(text)
235
+ text = text[:start] + text[end:]
236
+ base = parse_toml(text)
237
+ if TOML_KEY in base:
238
+ raise CrowError("unmanaged_conflict", "config.toml já define " + TOML_KEY + " fora do bloco gerenciado; remova ou mova antes. Nada alterado.")
239
+ result = (new_block or "") + text
240
+ parsed = parse_toml(result)
241
+ expected = dict(base)
242
+ if new_block is not None:
243
+ expected[TOML_KEY] = body if body is not None else parse_toml(new_block)[TOML_KEY]
244
+ if parsed != expected:
245
+ raise CrowError("invalid_config", "Edição do config.toml não preservou o restante do arquivo; nada alterado.")
246
+ return result.encode("utf-8")
247
+
248
+
249
+ # --- state ----------------------------------------------------------------------
250
+
251
+ def state_path(store):
252
+ return store.root / "guides" / "state.json"
253
+
254
+
255
+ def validate_state(data):
256
+ try:
257
+ if data["schema_version"] != 1 or not isinstance(data["guides"], dict):
258
+ raise ValueError("schema")
259
+ for agent, record in data["guides"].items():
260
+ if agent not in MECHANISMS or record["mechanism"] != MECHANISMS[agent]["kind"]:
261
+ raise ValueError("agent")
262
+ if (not isinstance(record["path"], str) or not HEX.fullmatch(record["sha256"]) or
263
+ not SHA.fullmatch(record["revision"]) or not isinstance(record["skills"], list) or
264
+ not all(isinstance(d, str) for d in record.get("created_dirs", [])) or
265
+ not isinstance(record.get("created_file", False), bool)):
266
+ raise ValueError("record")
267
+ preserved = record.get("preserved")
268
+ if preserved is not None and (not HEX.fullmatch(preserved["sha256"]) or
269
+ not PRESERVED.fullmatch(preserved.get("backup") or "")):
270
+ raise ValueError("preserved")
271
+ except (KeyError, TypeError, ValueError) as exc:
272
+ raise CrowError("invalid_state", "Estado de guias inválido; nenhuma alteração aplicada.") from exc
273
+ return data
274
+
275
+
276
+ def load_state(store):
277
+ path = checked(state_path(store))
278
+ if not path.exists():
279
+ return {"schema_version": 1, "guides": {}}
280
+ return validate_state(read_json(path))
281
+
282
+
283
+ def validate_journal(data, txid):
284
+ try:
285
+ if data["id"] != txid or data["phase"] not in ("prepared", "restoring", "committed", "rolled_back"):
286
+ raise ValueError("phase")
287
+ validate_state(data["before"])
288
+ validate_state(data["after"])
289
+ if "linked" in data and not re.fullmatch(r"[a-f0-9]{32}", data["linked"] or ""):
290
+ raise ValueError("linked")
291
+ if not isinstance(data["items"], list) or not data["items"]:
292
+ raise ValueError("items")
293
+ for item in data["items"]:
294
+ if item["agent"] not in MECHANISMS or item["kind"] != MECHANISMS[item["agent"]]["kind"]:
295
+ raise ValueError("agent")
296
+ if str(checked(item["path"])) != item["path"]:
297
+ raise ValueError("path")
298
+ for key in ("old_sha", "new_sha"):
299
+ if item[key] is not None and not HEX.fullmatch(item[key]):
300
+ raise ValueError("sha")
301
+ for key in ("old_block", "new_block"):
302
+ if item[key] is not None and not isinstance(item[key], str):
303
+ raise ValueError("block")
304
+ if item["backup"] is not None and not re.fullmatch(r"[a-z]+\.bak", item["backup"]):
305
+ raise ValueError("backup")
306
+ item.setdefault("removed_dirs", [])
307
+ for key in ("created_dirs", "removed_dirs"):
308
+ if not isinstance(item[key], list) or not all(isinstance(d, str) and str(checked(d)) == d for d in item[key]):
309
+ raise ValueError("dirs")
310
+ except (KeyError, TypeError, ValueError) as exc:
311
+ raise CrowError("invalid_transaction", "Registro transacional de guia inconsistente; recuperação recusada.") from exc
312
+ return data
313
+
314
+
315
+ # --- plan / apply / restore -----------------------------------------------------------
316
+
317
+ def current(agent, home):
318
+ """(path, file bytes, managed bytes-or-text) for an agent; never follows links."""
319
+ mech = MECHANISMS[agent]
320
+ path = protect(home / mech["path"], home)
321
+ data = read_regular(path)
322
+ if mech["kind"] == "toml":
323
+ return path, data, toml_block(data)
324
+ return path, data, data.decode("utf-8", "replace") if data is not None else None
325
+
326
+
327
+ def plan(store, action, agents, home, source=None, replace=False, overwrite=False, skills_after=None):
328
+ """Plan guide changes. action is "install"/"uninstall" or a {agent: action} map.
329
+
330
+ skills_after maps an agent to the managed skills it will have once a pending
331
+ skills transaction applies; without it the current installation is used.
332
+ """
333
+ if store.pending():
334
+ raise CrowError("recovery_required", "Há uma transação interrompida. Execute recover antes de alterar instalações.")
335
+ actions = action if isinstance(action, dict) else {agent: action for agent in agents}
336
+ before = load_state(store)
337
+ after = deepcopy(before)
338
+ items, changes, payload, previews = [], [], {}, {}
339
+ for agent in agents:
340
+ action = actions[agent]
341
+ mech = MECHANISMS[agent]
342
+ path, data, managed = current(agent, home)
343
+ record = before["guides"].get(agent)
344
+ if record and record["path"] != str(path):
345
+ raise CrowError("invalid_state", "Guia registrado em outro caminho para " + agent + "; revise o estado.")
346
+ managed_sha = sha(managed)
347
+ if action == "uninstall":
348
+ if not record:
349
+ raise CrowError("not_managed", "Guia não gerenciado para " + agent + "; nada removido.")
350
+ if managed_sha != record["sha256"] and not overwrite:
351
+ raise CrowError("local_changes", "Guia de " + agent + " alterado/ausente. Revise; --overwrite-modified autoriza backup e remoção.")
352
+ new_managed, body, skills = None, None, None
353
+ original = preserved_original(store, record)
354
+ else:
355
+ if "crow-behaviour" not in source.catalog:
356
+ raise CrowError("invalid_catalog", "Catálogo sem crow-behaviour.")
357
+ if skills_after is not None and agent in skills_after:
358
+ skills = [n for n in skills_after[agent] if n in source.catalog]
359
+ else:
360
+ skills = present_skills(agent, home, store, source.catalog)
361
+ if "crow-behaviour" not in skills:
362
+ raise CrowError("guide_requires_skill", "crow-behaviour não está instalada para " + agent +
363
+ "; instale antes: crow-skills install crow-behaviour --agents " + agent + " --global --yes")
364
+ body = render_body(agent, source.catalog, source.revision, skills)
365
+ new_managed = managed_content(agent, body)
366
+ previews[agent] = new_managed
367
+ if record and managed_sha == sha(new_managed):
368
+ after["guides"][agent].update(revision=source.revision, skills=skills)
369
+ changes.append({"agent": agent, "path": str(path), "mechanism": mech["kind"], "action": "unchanged"})
370
+ continue
371
+ if record and managed_sha != record["sha256"] and not overwrite:
372
+ raise CrowError("local_changes", "Guia de " + agent + " alterado/ausente desde a instalação. Revise; --overwrite-modified autoriza backup e substituição.")
373
+ if not record and managed is not None and not replace:
374
+ raise CrowError("unmanaged_conflict", "Já existe conteúdo não gerenciado em " + str(path) + ". Revise; --replace autoriza backup e substituição.")
375
+ if mech["kind"] == "toml":
376
+ new_data = toml_compose(data, managed, new_managed, body)
377
+ if new_data == b"" and new_managed is None and record.get("created_file", False):
378
+ new_data = None # This CLI created the file and only our block is left: remove it.
379
+ elif new_managed is not None:
380
+ new_data = new_managed.encode("utf-8")
381
+ else:
382
+ new_data = original # Uninstall: put back what --replace displaced, or remove our file.
383
+ created = []
384
+ if new_data is not None:
385
+ parent = path.parent
386
+ while not parent.exists() and parent != home and home in parent.parents:
387
+ created.append(str(parent))
388
+ parent = parent.parent
389
+ mode = stat.S_IMODE(path.lstat().st_mode) if data is not None else (0o600 if mech["kind"] == "toml" else 0o644)
390
+ items.append({"agent": agent, "kind": mech["kind"], "path": str(path), "old_sha": sha(data), "new_sha": sha(new_data),
391
+ "old_block": managed if mech["kind"] == "toml" else None,
392
+ "new_block": new_managed if mech["kind"] == "toml" else None,
393
+ "mode": mode, "backup": None, "created_dirs": created,
394
+ "removed_dirs": record.get("created_dirs", []) if action == "uninstall" else []})
395
+ payload[agent] = (data, new_data)
396
+ if action == "uninstall":
397
+ after["guides"].pop(agent)
398
+ act = "remove"
399
+ else:
400
+ entry = {"path": str(path), "mechanism": mech["kind"], "sha256": sha(new_managed),
401
+ "revision": source.revision, "skills": skills, "installed_at": now(),
402
+ "created_dirs": created + (record.get("created_dirs", []) if record else [])}
403
+ if mech["kind"] == "toml":
404
+ entry["created_file"] = record.get("created_file", False) if record else data is None
405
+ if record and record.get("preserved"):
406
+ entry["preserved"] = record["preserved"]
407
+ elif not record and data is not None and mech["kind"] == "file":
408
+ entry["preserved"] = {"sha256": sha(data)} # backup id is set by apply()
409
+ after["guides"][agent] = entry
410
+ act = "replace" if managed is not None else "install"
411
+ changes.append({"agent": agent, "path": str(path), "mechanism": mech["kind"], "action": act,
412
+ "edits": "bloco " + TOML_KEY + " marcado" if mech["kind"] == "toml" else "arquivo próprio"})
413
+ return {"before": before, "after": after, "items": items, "payload": payload, "changes": changes, "previews": previews}
414
+
415
+
416
+ def apply(store, plan_data, home, linked=None):
417
+ """Apply a guide plan; linked is the skills transaction this one completes."""
418
+ if not plan_data["items"]:
419
+ if plan_data["after"] != plan_data["before"]:
420
+ with store.lock():
421
+ if load_state(store) != plan_data["before"]:
422
+ raise CrowError("changed_during_plan", "O estado mudou após o preview; execute novamente.")
423
+ atomic_json(state_path(store), plan_data["after"])
424
+ return None
425
+ with store.lock():
426
+ if store.pending():
427
+ raise CrowError("recovery_required", "Recuperação pendente; nenhuma alteração aplicada.")
428
+ if load_state(store) != plan_data["before"]:
429
+ raise CrowError("changed_during_plan", "O estado mudou após o preview; execute novamente.")
430
+ for item in plan_data["items"]:
431
+ if sha(read_regular(protect(Path(item["path"]), home))) != item["old_sha"]:
432
+ raise CrowError("changed_during_plan", "Um destino mudou após o preview; execute novamente.")
433
+ for item in plan_data["items"]:
434
+ # A skills transaction applied after planning may have created parents
435
+ # (for example ~/.claude); this transaction does not own those.
436
+ existing = [d for d in item["created_dirs"] if os.path.lexists(d)]
437
+ if existing:
438
+ item["created_dirs"] = [d for d in item["created_dirs"] if d not in existing]
439
+ entry = plan_data["after"]["guides"].get(item["agent"])
440
+ if entry is not None:
441
+ entry["created_dirs"] = [d for d in entry["created_dirs"] if d not in existing]
442
+ txid = uuid.uuid4().hex
443
+ backups = private_dir(store.root / "guide-backups") / txid
444
+ backups = private_dir(backups)
445
+ for item in plan_data["items"]:
446
+ old, _ = plan_data["payload"][item["agent"]]
447
+ if old is not None:
448
+ item["backup"] = item["agent"] + ".bak"
449
+ write_atomic(backups / item["backup"], old, 0o600)
450
+ kept = plan_data["after"]["guides"].get(item["agent"], {}).get("preserved")
451
+ if kept is not None and "backup" not in kept:
452
+ kept["backup"] = txid + "/" + item["backup"]
453
+ data = {"id": txid, "kind": "guide", "phase": "prepared", "created_at": now(),
454
+ "before": plan_data["before"], "after": plan_data["after"], "items": plan_data["items"]}
455
+ if linked:
456
+ data["linked"] = linked
457
+ journal = store.root / "transactions" / (txid + ".json")
458
+ atomic_json(journal, data)
459
+ try:
460
+ for item in plan_data["items"]:
461
+ path = protect(Path(item["path"]), home)
462
+ if sha(read_regular(path)) != item["old_sha"]:
463
+ raise CrowError("changed_during_install", "Destino do guia alterado durante a instalação.")
464
+ _, new = plan_data["payload"][item["agent"]]
465
+ if new is None:
466
+ if os.path.lexists(path):
467
+ path.unlink()
468
+ remove_empty(item["removed_dirs"])
469
+ else:
470
+ for directory in reversed(item["created_dirs"]):
471
+ os.mkdir(directory, 0o755)
472
+ write_atomic(path, new, item["mode"])
473
+ atomic_json(state_path(store), plan_data["after"])
474
+ data["phase"] = "committed"
475
+ atomic_json(journal, data)
476
+ except (Exception, KeyboardInterrupt):
477
+ restore(store, data, home)
478
+ raise
479
+ return txid
480
+
481
+
482
+ def restore(store, data, home):
483
+ """Return every guide to its pre-transaction content, refusing over later edits."""
484
+ state = load_state(store)
485
+ if state != data["before"] and state != data["after"]:
486
+ raise CrowError("recovery_conflict", "Estado de guias mudou após a transação; recuperação recusada.")
487
+ backups = store.root / "guide-backups" / data["id"]
488
+ operations = []
489
+ for item in reversed(data["items"]):
490
+ path = protect(Path(item["path"]), home)
491
+ now_data = read_regular(path)
492
+ now_sha = sha(now_data)
493
+ if now_sha == item["old_sha"]:
494
+ continue
495
+ old = None
496
+ if item["old_sha"] is not None:
497
+ old = read_regular(checked(backups / item["backup"]))
498
+ if sha(old) != item["old_sha"]:
499
+ raise CrowError("recovery_conflict", "Backup do guia ausente ou alterado; recuperação recusada.")
500
+ if now_sha == item["new_sha"]:
501
+ operations.append((path, old, item))
502
+ elif item["kind"] == "toml" and now_data is not None and toml_block(now_data) == item["new_block"]:
503
+ # Codex rewrote other parts of config.toml: undo only our block.
504
+ operations.append((path, toml_compose(now_data, item["new_block"], item["old_block"]), item))
505
+ else:
506
+ raise CrowError("recovery_conflict", "Guia de " + item["agent"] + " mudou após a transação; recuperação não sobrescreverá alterações.")
507
+ data["phase"] = "restoring"
508
+ atomic_json(store.root / "transactions" / (data["id"] + ".json"), data)
509
+ for path, content, item in operations:
510
+ if content is None:
511
+ if os.path.lexists(path):
512
+ path.unlink()
513
+ else:
514
+ for directory in reversed(item["removed_dirs"]):
515
+ if not os.path.lexists(directory):
516
+ os.mkdir(directory, 0o755)
517
+ write_atomic(path, content, item["mode"])
518
+ for item in data["items"]:
519
+ remove_empty(item["created_dirs"])
520
+ atomic_json(state_path(store), data["before"])
521
+ data["phase"] = "rolled_back"
522
+ atomic_json(store.root / "transactions" / (data["id"] + ".json"), data)
523
+
524
+
525
+ def preserved_original(store, record):
526
+ """Bytes that --replace displaced for this guide, verified against the state; None if nothing was displaced."""
527
+ kept = record.get("preserved")
528
+ if not kept:
529
+ return None
530
+ data = read_regular(checked(store.root / "guide-backups" / kept["backup"]))
531
+ if sha(data) != kept["sha256"]:
532
+ raise CrowError("recovery_conflict", "Backup do conteúdo original substituído por --replace está ausente ou alterado; nada removido.")
533
+ return data
534
+
535
+
536
+ def remove_empty(directories):
537
+ """Remove directories this CLI created, deepest first, only while empty."""
538
+ for directory in directories:
539
+ try:
540
+ os.rmdir(checked(directory))
541
+ except OSError:
542
+ pass # Not empty or already gone: never remove foreign content.
543
+
544
+
545
+ def rollback(store, data, home, recovery=False):
546
+ if not recovery and load_state(store) != data["after"]:
547
+ raise CrowError("rollback_conflict", "Há mudanças posteriores nos guias; rollback antigo recusado.")
548
+ restore(store, data, home)
549
+
550
+
551
+ # --- status ---------------------------------------------------------------------
552
+
553
+ def status(store, home, agents):
554
+ state = load_state(store)
555
+ rows = []
556
+ for agent in agents:
557
+ if agent not in MECHANISMS:
558
+ continue
559
+ mech = MECHANISMS[agent]
560
+ record = state["guides"].get(agent)
561
+ row = {"agent": agent, "path": str(home / mech["path"]), "mechanism": mech["kind"],
562
+ "revision": record["revision"] if record else None, "activation_verified": False}
563
+ try:
564
+ path, data, managed = current(agent, home)
565
+ parsed = parse_toml(decode(data)) if mech["kind"] == "toml" and data is not None else {}
566
+ if record is None:
567
+ row["status"] = "unmanaged" if managed is not None or TOML_KEY in parsed else "absent"
568
+ elif managed is None:
569
+ row["status"] = "missing"
570
+ elif sha(managed) != record["sha256"]:
571
+ row["status"] = "modified"
572
+ elif mech["kind"] == "toml" and parsed.get(TOML_KEY) != parse_toml(managed).get(TOML_KEY):
573
+ row["status"] = "modified"
574
+ row["note"] = "Bloco gerenciado não está mais no nível superior do config.toml."
575
+ elif managed_skills(agent, home, store) != record["skills"]:
576
+ row["status"] = "outdated"
577
+ row["note"] = "Skills instaladas mudaram; execute crow-skills guide install para regenerar."
578
+ else:
579
+ row["status"] = "installed"
580
+ except CrowError as exc:
581
+ row["status"] = "unsafe"
582
+ row["note"] = str(exc)
583
+ rows.append(row)
584
+ return rows
585
+
@@ -154,6 +154,9 @@ class Store:
154
154
  if not re.fullmatch(r"[a-f0-9]{32}", txid):
155
155
  raise CrowError("invalid_transaction", "ID de transação inválido.")
156
156
  data = read_json(self.root / "transactions" / (txid + ".json"))
157
+ if isinstance(data, dict) and data.get("kind") == "guide":
158
+ from .guide import validate_journal
159
+ return validate_journal(data, txid)
157
160
  try:
158
161
  if data["id"] != txid or data["phase"] not in ("prepared", "restoring", "committed", "rolled_back"):
159
162
  raise ValueError("phase")
@@ -356,6 +359,9 @@ class Store:
356
359
  raise CrowError("invalid_transaction", "Estado da transação incompatível com a operação.")
357
360
  if not recovery and self.pending():
358
361
  raise CrowError("recovery_required", "Resolva transações interrompidas primeiro.")
362
+ if data.get("kind") == "guide":
363
+ from .guide import rollback
364
+ return rollback(self, data, self.home, recovery)
359
365
  if not recovery:
360
366
  for target, state in data["after"].items():
361
367
  if self.state(target) != state:
@@ -22,6 +22,8 @@ Checklist aplicável: autorização por escopo e flags (sim); validação de nom
22
22
  | Rollback antigo apagando versão nova | Comparação de estado posterior e inventário antes de restaurar; teste de transação subsequente |
23
23
  | Dois instaladores concorrentes | Lock POSIX por perfil; segundo processo real deve retornar busy |
24
24
  | Preview ou list instalando silenciosamente | Sem gravação/lock/fetch no dry-run, flags explícitas em mutações; testes de diretório vazio e fetch proibido |
25
+ | Guia de agente sobrescrevendo prompt compartilhado ou config alheia | Só mecanismos aditivos verificados; recusa de symlinks, hard links, prompts compartilhados e seus alvos; bloco TOML marcado validado por parse, chave alheia nunca editada; testes de symlink, hard link, chave estrangeira e remoção byte a byte |
26
+ | Install/update aplicando o guia de forma parcial ou sobre conteúdo alheio | Guia planejado e conferido antes de gravar skills; recusa falha o comando inteiro sem estado parcial; falha do guia desfaz as skills; transações encadeadas (`linked`) com rollback/recover conjuntos; `--no-guide` explícito; mesmas proteções de Uniflow/symlink/hard link; testes de recusa, falha, interrupção e rollback |
25
27
  | Metadados de complementaridade virando instalação em massa | Relacionamentos opcionais, --with-handoffs direto e explícito, sem expansão de overlays; teste de conjunto exato |
26
28
 
27
29
  Esta tabela define ameaça/controle, não é um certificado de segurança. Resultados executados e revisão independente são registrados separadamente na entrega. Persistem limites de processos hostis do mesmo usuário, filesystem/hardware, confiança no clone/configuração Git escolhido e efeitos que o host venha a permitir ao executar uma skill após instalada.
package/docs/cli.md CHANGED
@@ -1,6 +1,6 @@
1
- # crow-skills CLI 0.1.0
1
+ # crow-skills CLI 0.3.0
2
2
 
3
- CLI local-first para humanos e agentes. Python 3.10+, sem bibliotecas Python externas; Git é necessário para fontes em clone, não para o snapshot npm. O launcher npm requer Node 18+. Mutações suportadas em macOS/Linux (locks POSIX). Não instala nem configura um agente, não executa scripts das skills e não muda modelos, prompts ou permissões.
3
+ CLI local-first para humanos e agentes. Python 3.10+, sem bibliotecas Python externas; Git é necessário para fontes em clone, não para o snapshot npm. O launcher npm requer Node 18+. Mutações suportadas em macOS/Linux (locks POSIX). Não instala o agente, não executa scripts das skills e não muda modelos, prompts base ou permissões. Instruções de uso das skills (o guia do agente, abaixo) são gravadas pelo comando `guide` e, por padrão, por `install`/`update`/`uninstall` em `--global` quando a crow-behaviour fica instalada para um agente com mecanismo verificado; `--no-guide` desliga isso.
4
4
 
5
5
  ## Executar
6
6
 
@@ -17,6 +17,8 @@ O launcher do checkout pode ser referenciado por um symlink em um diretório já
17
17
 
18
18
  Nome: `@andespindola/crow-skills`; `publishConfig.access` é `restricted`. Isso não equivale a `private:true`, que impediria qualquer publicação. A publicação exige autorização/entitlement da conta npm; nunca faça fallback para public.
19
19
 
20
+ A publicação é feita pelo workflow `.github/workflows/publish.yml` ao empurrar uma tag `vX.Y.Z` igual à `version` do `package.json` (ou por `workflow_dispatch` em `main`). Ele autentica por Trusted Publishing (OIDC), sem token npm em secret, e roda check, testes e o teste de instalação isolada antes de `npm publish`. O trusted publisher do pacote no npmjs.com deve apontar para `andersonflima/crow-skills` e `publish.yml`.
21
+
20
22
  Depois de a versão estar publicada e a conta estar autenticada com acesso:
21
23
 
22
24
  ```sh
@@ -29,7 +31,7 @@ Para testar/distribuir o arquivo local sem registry:
29
31
 
30
32
  ```sh
31
33
  npm pack
32
- npm install -g ./andespindola-crow-skills-0.1.0.tgz --ignore-scripts
34
+ npm install -g ./andespindola-crow-skills-0.3.0.tgz --ignore-scripts
33
35
  ```
34
36
 
35
37
  O pacote contém o CLI Python e todas as skills do commit, com `release.json` de hashes. Não requer acesso ao GitHub ou clone para list/install. O launcher Node encaminha argumentos sem shell para Python em modo isolado, evitando imports pelo diretório do chamador/PYTHONPATH. `CROW_SKILLS_PYTHON` pode apontar para outro executável Python 3.10+. Não há dependências npm nem hooks de instalação que instalem skills automaticamente.
@@ -49,6 +51,8 @@ crow-skills install crow-debug --dest /caminho/do/host/skills --yes
49
51
 
50
52
  `--dest` indica uma raiz de skills explícita para outro host; não promete compatibilidade/ativação nesse host. Escolha somente um escopo: `--global`, `--project PATH` ou `--dest PATH`. Não combine `--dest` com `--agents`.
51
53
 
54
+ Em `--global`, quando o resultado deixa a crow-behaviour instalada para claude, codex, gemini ou devin, o mesmo comando aplica ou regenera o guia desse agente (veja [Instruções para o agente](#instruções-para-o-agente-guide)); `--no-guide` desliga esse passo.
55
+
52
56
  `--all` seleciona explicitamente todas as skills do catálogo em install. Sem `--all`, só os nomes pedidos são instalados. Cada pacote inteiro é copiado: referências, scripts, assets e metadata, preservando hashes e executabilidade. Dependências externas não são baixadas nem executadas pelo instalador.
53
57
 
54
58
  ## Complementaridade
@@ -96,6 +100,7 @@ crow-skills recover --yes
96
100
  - Staging e backups ficam no diretório reservado `.crow-skills/` ao lado da raiz de skills, na mesma filesystem para permitir renames, nunca dentro dela. Backups são privados e retidos; não há expiração automática nesta versão.
97
101
  - Todos os destinos passam por preflight antes de aplicar. Renames são atômicos por pasta; uma operação com múltiplos destinos não é um único rename atômico global. Erros comuns provocam restauração; interrupções deixam journal recuperável, bloqueando novas mutações até `recover`.
98
102
  - `recover` restaura o estado anterior, não tenta terminar silenciosamente a instalação. A própria restauração tem um estado pendente `restoring`, permitindo retomada se também for interrompida. Confere arquivos/backup antes de substituir. Se houver edições após a interrupção, recusa e conserva os dados para resolução manual.
103
+ - `install`/`update`/`uninstall` que também alteram o guia gravam duas transações encadeadas: skills e depois guia, com o journal do guia apontando a de skills (`linked`). As duas IDs saem no resultado (`transaction` e `guides.transaction`). Se o guia falhar, as skills dessa execução são desfeitas antes do erro. `rollback` da transação de skills desfaz primeiro o guia ligado; `rollback` da transação de guia desfaz só o guia. `recover` de um guia interrompido também desfaz as skills ligadas (`then_rollback` no preview). Uma interrupção exatamente entre as duas deixa as skills aplicadas e o guia `outdated`/`absent`, sem journal pendente: repita o comando ou faça rollback da transação de skills.
99
104
  - `rollback` funciona sobre uma transação concluída sem alterações posteriores no estado/artefatos afetados. Uma transação antiga não sobrescreve uma nova.
100
105
  - Um destino não gerenciado exige `--replace` em install. Edições locais, arquivos extras ou ausência de pacote gerenciado bloqueiam update/uninstall. `--overwrite-modified` é autorização explícita para preservar backup e substituir/remover o pacote afetado.
101
106
  - Uninstall só remove nomes gerenciados. Sem nomes exige `--all` explícito. Não elimina skills de terceiros nem diretórios alheios.
@@ -133,6 +138,43 @@ Erros retornam `ok:false` e `error.code/message`. Saídas reais incluem alteraç
133
138
 
134
139
  Codex/shared são deduplicados quando apontam para o mesmo diretório. O diretório compartilhado também pode ser lido por outros hosts; não é isolamento entre agentes. `.codex/skills` é uma localização legada encontrada em ambientes existentes; o adaptador usa a localização moderna documentada. Para um destino legado específico, use `--dest` explicitamente.
135
140
 
136
- `agents` só verifica PATH e caminhos, sem iniciar agente/LLM. `status`/`doctor` conferem arquivos e estado de instalação, não provam carregamento, ativação, independência dos workers ou execução correta da skill. Gemini oferece `/skills reload`; para outros hosts, confira sua lista/reload ou reinicie a sessão conforme a versão. Nenhum hook ou system prompt é alterado para forçar uso.
141
+ `agents` só verifica PATH e caminhos, sem iniciar agente/LLM. `status`/`doctor` conferem arquivos e estado de instalação, não provam carregamento, ativação, independência dos workers ou execução correta da skill. Gemini oferece `/skills reload`; para outros hosts, confira sua lista/reload ou reinicie a sessão conforme a versão. Nenhum hook ou system prompt base é alterado para forçar uso; o guia abaixo, aplicado por padrão em `--global` e desligável com `--no-guide`, é aditivo.
137
142
 
138
143
  Fontes primárias consultadas em 2026-10-20: [Codex](https://learn.chatgpt.com/docs/build-skills#where-codex-loads-local-skills), [Claude](https://code.claude.com/docs/en/skills), [Gemini](https://geminicli.com/docs/cli/using-agent-skills/), [Devin](https://docs.devin.ai/cli/extensibility/skills/overview).
144
+
145
+ ## Instruções para o agente (guide)
146
+
147
+ ```sh
148
+ crow-skills install crow-behaviour --agents claude,codex,gemini,devin --global --yes # aplica o guia junto
149
+ crow-skills install crow-behaviour --agents claude --global --no-guide --yes # só a skill
150
+ crow-skills guide install --agents claude,codex,gemini,devin --global --dry-run
151
+ crow-skills guide install --agents claude,codex,gemini,devin --global --yes
152
+ crow-skills guide status
153
+ crow-skills guide uninstall --agents codex --global --yes
154
+ ```
155
+
156
+ `guide` grava, no mecanismo nativo e sempre carregado de cada CLI, instruções curtas em pt-BR para o agente usar as skills instaladas: carregar a crow-behaviour por iniciativa própria antes de tarefas substantivas (várias etapas ou partes, decisões, vários arquivos, verificação), responder direto em conversa ou ajuste trivial, consultar a lista de skills disponíveis no agente (crow ou não) e carregar a especialista pertinente pelo nome. O conteúdo é gerado do catálogo e das skills gerenciadas pelo CLI naquele agente, com a forma de invocação dele. Exige a crow-behaviour instalada para o agente.
157
+
158
+ Aplicação automática (padrão em `install`, `update` e `uninstall` com `--global`, desligada por `--no-guide`):
159
+
160
+ - O guia é planejado junto com as skills, a partir do conjunto de skills que o agente terá depois do comando, e conferido antes de gravar qualquer coisa. Install/update que deixam a crow-behaviour instalada aplicam o guia ou o regeneram; mudança de outra skill gerenciada regenera um guia já instalado (também quando a mudança chega pelo diretório compartilhado `~/.agents/skills`, que é o do codex). Uninstall da crow-behaviour remove o guia, devolvendo o arquivo que `--replace` havia deslocado; uninstall de outra skill regenera o guia instalado e nunca cria um novo.
161
+ - Recusa do guia (arquivo não gerenciado sem `--replace`, guia editado/ausente sem `--overwrite-modified`, chave `developer_instructions` alheia, symlink, hard link, prompt compartilhado ou Uniflow) falha o comando inteiro, com código do guia e sugestão de `--replace`, `--overwrite-modified` ou `--no-guide`, sem gravar skills nem guia. Em install/update, `--replace` e `--overwrite-modified` valem para skills e guia.
162
+ - `shared`, `--project` e `--dest` não têm mecanismo: o comando segue e informa em `guides.skipped`. Sem Python 3.11+ (tomllib), o guia do codex também é pulado e informado.
163
+ - `--dry-run` inclui `guides.changes` e `guides.preview` (conteúdo que seria gravado). A saída JSON traz `guides` com `changes`, `skipped`, `revision` e `transaction`.
164
+
165
+ | Agente | Mecanismo (usuário) | Propriedade |
166
+ |---|---|---|
167
+ | claude | `~/.claude/rules/crow-skills.md` | arquivo próprio |
168
+ | codex | chave `developer_instructions` no topo de `~/.codex/config.toml` | bloco marcado; o restante do arquivo fica intacto |
169
+ | gemini (Antigravity `agy`) | `~/.gemini/config/rules/crow-skills.md`, `trigger: always_on` | arquivo próprio |
170
+ | devin | `~/.devin/rules/crow-skills.md`, `trigger: always_on` | arquivo próprio |
171
+
172
+ Cada mecanismo foi verificado em 2026-10-21 (Claude Code 2.1.304, Codex CLI 0.163.0, agy 1.4.3, Devin CLI 3000.11.92) com uma sentinela carregada numa sessão nova junto do prompt base já existente: os mecanismos são aditivos. Versões futuras dos CLIs podem mudar isso; confira abrindo uma sessão nova. O comando não inicia agentes e retorna `activation_verified:false`.
173
+
174
+ - Só `--global` é suportado. No comando `guide`, `--project`, `--dest` e `shared` são recusados com `unsupported_guide`, porque não há mecanismo verificado.
175
+ - Nunca grava em symlinks, em arquivos com hard links, em prompts compartilhados (`~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, `~/.gemini/GEMINI.md`, `~/.config/devin/CLAUDE.md`), em `~/.config/uniflow/` ou nos alvos desses links. Não usa mecanismos que substituem o prompt base (como `model_instructions_file` ou `AGENTS.override.md`).
176
+ - No Codex, uma chave `developer_instructions` definida fora do bloco é recusada sempre, mesmo com `--replace`. A edição é validada por parse TOML (Python 3.11+): o restante do arquivo precisa continuar idêntico. Uninstall devolve o arquivo byte a byte.
177
+ - Arquivo existente não gerenciado exige `--replace`; guia editado ou ausente exige `--overwrite-modified`. Ambos guardam backup privado em `~/.local/state/crow-skills/guide-backups/`. O uninstall devolve o arquivo que `--replace` substituiu (conferido pelo SHA-256 do backup), em vez de apagá-lo, e mantém um `config.toml` que já existia, mesmo vazio.
178
+ - Transações usam o mesmo journal, lock, `rollback` e `recover` das skills. Se o Codex reescrever outras partes do `config.toml` depois, o rollback desfaz só o bloco.
179
+ - `status`/`doctor` mostram `guides`: `installed`, `outdated` (as skills gerenciadas mudaram com `--no-guide` ou fora do CLI; rode `guide install` ou `update` de novo), `modified`, `missing`, `unmanaged`, `absent` ou `unsafe`. Doctor fica `healthy:false` para `modified`, `missing` e `unsafe`.
180
+ - Diretórios de regras criados pelo comando são removidos no uninstall se estiverem vazios.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/crow-skills",
3
- "version": "0.1.0",
3
+ "version": "0.3.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
@@ -3,7 +3,7 @@
3
3
  "README.md": {
4
4
  "executable": false,
5
5
  "kind": "file",
6
- "sha256": "50387f058109b5451ba40c07230d73ecbdf63a5f71c320150b0c83705ce2ed3a"
6
+ "sha256": "b354e9907359550f40b94ab52fb3a38162a7cdf950d33de419f51e82e8b1423e"
7
7
  },
8
8
  "bin/npm-crow-skills.cjs": {
9
9
  "executable": true,
@@ -13,12 +13,12 @@
13
13
  "catalog.json": {
14
14
  "executable": false,
15
15
  "kind": "file",
16
- "sha256": "96eb202af8bc22443c11947e56332d3fd04d1e1a72eef0348521dbefee32f101"
16
+ "sha256": "07c01ffc61cab5c6c7f3c2374110705dc8746c8267bb5a0c25f88c8b601f2242"
17
17
  },
18
18
  "crow_skills/__init__.py": {
19
19
  "executable": false,
20
20
  "kind": "file",
21
- "sha256": "aad4c7d8944057606a7770888c318f903003eef953b00c8bba24a808acf5a36a"
21
+ "sha256": "f81792d3fad0ea3fdeb96616d1df41e0f9cf811e1aee8e48946ffc7077a8469b"
22
22
  },
23
23
  "crow_skills/__main__.py": {
24
24
  "executable": false,
@@ -33,7 +33,12 @@
33
33
  "crow_skills/cli.py": {
34
34
  "executable": false,
35
35
  "kind": "file",
36
- "sha256": "5f62c76d4b35d5101da97d4c90af2a2dbf580b81b3eb545b1fd25917adbe36ae"
36
+ "sha256": "9aa14347630210decb6a4e4fc58f1910dbeb53ec6907d1cf819939b7419e4df4"
37
+ },
38
+ "crow_skills/guide.py": {
39
+ "executable": false,
40
+ "kind": "file",
41
+ "sha256": "65b93fff61c496f7d3e1b3b32eadb008ba3a9cfd543a4db53f73becdb4ceec6f"
37
42
  },
38
43
  "crow_skills/npm_entry.py": {
39
44
  "executable": false,
@@ -48,22 +53,22 @@
48
53
  "crow_skills/storage.py": {
49
54
  "executable": false,
50
55
  "kind": "file",
51
- "sha256": "4ddc2ee88d3fab2cd008bf588627ef12ec0ae36744ccabb5e5235a209c7253b4"
56
+ "sha256": "2c3823f783c1da2f971860e6207a5146275e105fab74a7aa54e57033cd3993f0"
52
57
  },
53
58
  "docs/cli-security.md": {
54
59
  "executable": false,
55
60
  "kind": "file",
56
- "sha256": "639cfe05dea16b4bcee8d8f790b2de915ff69333aac3b128d76ee3a049c04428"
61
+ "sha256": "33eebc401bf86443569f011b9987d7e954a9f5b5ade6bb2e18e26055b9ed74f2"
57
62
  },
58
63
  "docs/cli.md": {
59
64
  "executable": false,
60
65
  "kind": "file",
61
- "sha256": "92653a908c3f9dea3ab9f03a0f2806315ad533ed20859ca3459c0d05472a0890"
66
+ "sha256": "a0af861933004eac9581215fd5a27312e0127cf1e21f9e8bac12b957200c5fdb"
62
67
  },
63
68
  "package.json": {
64
69
  "executable": false,
65
70
  "kind": "file",
66
- "sha256": "ab5e5fef04a9577dc5fcc82ba2676b842687d7b4e9c302fffa1dc2658bbe78e3"
71
+ "sha256": "4985632e9d45f238022af574b5533c0df90e509276decac44f90a3e37c7973ec"
67
72
  },
68
73
  "skills/crow-accessibility/SKILL.md": {
69
74
  "executable": false,
@@ -93,7 +98,7 @@
93
98
  "skills/crow-behaviour/SKILL.md": {
94
99
  "executable": false,
95
100
  "kind": "file",
96
- "sha256": "0e146a38ad24bd59d1552a80d27dae9f40f3734570df4935132a68e8d7f8e15c"
101
+ "sha256": "d58086484a9e914f4e1b4dbded62834fd9105a5df10076888852b8ed5ed5d303"
97
102
  },
98
103
  "skills/crow-behaviour/agents/openai.yaml": {
99
104
  "executable": false,
@@ -601,7 +606,7 @@
601
606
  "sha256": "a04946ef7637e0d3fbaedc650b4ddcba9c8f7c71708e837c7e44779d7d6263ca"
602
607
  }
603
608
  },
604
- "revision": "51620c2ae9d08b1288dfa7d569087b455d46ed5c",
609
+ "revision": "d5b45e0d80d58a203ff86a9f4211fbd63a57d655",
605
610
  "schema_version": 1,
606
- "version": "0.1.0"
611
+ "version": "0.3.0"
607
612
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: crow-behaviour
3
- description: "Aplique um modo de atuação orientado a orquestração, profundidade e verificação em tarefas substantivas. Decomponha, delegue com as capacidades reais do agente e sintetize evidências. Em conversa ou ajustes triviais, responda diretamente; não simule agentes nem altere permissões."
3
+ description: "Use PROATIVAMENTE, sem o usuário pedir, sempre que a tarefa for substantiva — multi-etapa, com exploração/decisões, mexendo em vários arquivos ou componentes, ou exigindo verificação antes de entregar (implementar uma feature, refatorar, depurar, auditar, revisar, migrar, pesquisar a fundo). Ao ativar: decomponha o trabalho, delegue com as capacidades reais do runtime, verifique de forma adversarial e sintetize evidências. NÃO ative em conversa, explicação curta ou ajuste trivial/local — nesses casos responda direto. Não simule agentes nem altere permissões."
4
4
  ---
5
5
 
6
6
  # Atuação coordenada