@andespindola/crow-skills 0.38.2 → 0.39.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.38.2"
3
+ __version__ = "0.39.1"
@@ -8,6 +8,7 @@ import sys
8
8
  from . import __version__
9
9
  from . import autoupdate
10
10
  from . import bots
11
+ from . import demands as demands_mod
11
12
  from . import broadcast
12
13
  from . import events
13
14
  from . import groups as groups_mod
@@ -75,6 +76,10 @@ COMMANDS = {
75
76
  ["crow-skills mode", "crow-skills mode off", "crow-skills mode on"]),
76
77
  "events": ("Barramento de eventos dos hooks (crow-event/1) para terminais.",
77
78
  ["crow-skills events", "crow-skills events tail --limit 20", "crow-skills events tail --follow"]),
79
+ "demand": ("Demandas do dono que ficam registradas até alguém assumir — sem validade, porque demanda sem resposta não é velha, é pendente.",
80
+ ["crow-skills demand list", "crow-skills demand list --pending",
81
+ "crow-skills demand add \"arrumar o scroll do painel\"",
82
+ "crow-skills demand take 9f2a1c", "crow-skills demand done 9f2a1c \"publicado em 0.2.26\""]),
78
83
  "bot": ("Bots especialistas independentes: criar sem bloquear, reaproveitar o ocioso, emprestar entre sessões.",
79
84
  ["crow-skills bot list", "crow-skills bot spawn --role revisor-sql --brief \"revise o diff de migrations\"",
80
85
  "crow-skills bot assign 9f2a1c \"agora valide o rollback\"", "crow-skills bot release 9f2a1c",
@@ -209,6 +214,13 @@ def parser():
209
214
  child.add_argument("action", nargs="?", default="snapshot", choices=("snapshot", "tail"))
210
215
  child.add_argument("--limit", type=int, default=50, help="tail: quantos eventos (padrão 50; 0 = todos os retidos)")
211
216
  child.add_argument("--follow", action="store_true", help="tail: transmitir eventos novos em JSONL até Ctrl-C")
217
+ child = command_parser(sub, "demand", [common])
218
+ child.add_argument("action", nargs="?", default="list",
219
+ choices=("list", "add", "take", "done", "fail", "release"))
220
+ child.add_argument("target", nargs="?", help="take/done/fail/release: id da demanda")
221
+ child.add_argument("text", nargs="*", help="add: o pedido; done/fail: a nota de desfecho")
222
+ child.add_argument("--pending", action="store_true", help="list: só o que ninguém assumiu")
223
+ child.add_argument("--thread", help="add: id da conversa do broadcast que originou o pedido")
212
224
  child = command_parser(sub, "bot", [common])
213
225
  child.add_argument("action", nargs="?", default="list",
214
226
  choices=("list", "spawn", "assign", "release", "stop", "prune", "learned", "knowledge"))
@@ -718,6 +730,45 @@ def orphans_command(args, home, interactive):
718
730
  return data
719
731
 
720
732
 
733
+ def demand_command(args, home):
734
+ """The owner's queue. `list` is the default action because the first thing
735
+ an agent should do is look at what is already waiting."""
736
+ action = args.action
737
+ cwd = os.path.abspath(getattr(args, "cwd", None) or os.getcwd())
738
+ if action == "list":
739
+ live = {s.get("key") for s in broadcast.live_sessions(home)}
740
+ rows = demands_mod.listing(home, pending_only=getattr(args, "pending", False), live_keys=live)
741
+ return {"demands": rows, "text": demands_mod.render(rows)}
742
+ if action == "add":
743
+ # `target` is positional, so it swallows the first word of the demand;
744
+ # for add there is no id, and the whole tail is the request.
745
+ parts = ([getattr(args, "target", None)] if getattr(args, "target", None) else []) + list(getattr(args, "text", []) or [])
746
+ text = " ".join(p for p in parts if p).strip()
747
+ if not text:
748
+ raise CrowError("usage", "add precisa do pedido: crow-skills demand add \"o que fazer\".")
749
+ try:
750
+ record = demands_mod.add(home, text, thread=getattr(args, "thread", None))
751
+ except ValueError as exc:
752
+ raise CrowError("usage", str(exc))
753
+ return {"demand": record,
754
+ "text": "demanda " + record["id"] + " registrada e pendente; ela não expira."}
755
+ if not getattr(args, "target", None):
756
+ raise CrowError("usage", action + " precisa do id da demanda.")
757
+ note = " ".join(getattr(args, "text", []) or []).strip()
758
+ try:
759
+ if action == "take":
760
+ owner = broadcast_owner(args, home, cwd)
761
+ record = demands_mod.take(home, args.target, by=(owner or {}).get("key"))
762
+ return {"demand": record, "text": "demanda " + record["id"] + " é sua; devolva com release se não puder terminar."}
763
+ if action in ("done", "fail"):
764
+ record = demands_mod.close(home, args.target, "done" if action == "done" else "failed", note)
765
+ return {"demand": record, "text": "demanda " + record["id"] + " encerrada como " + record["state"] + "."}
766
+ record = demands_mod.release(home, args.target)
767
+ return {"demand": record, "text": "demanda " + record["id"] + " devolvida à fila."}
768
+ except (ValueError, RuntimeError) as exc:
769
+ raise CrowError("usage", str(exc))
770
+
771
+
721
772
  def bot_command(args, home):
722
773
  """Independent specialists. `list` comes first on purpose: the rule is to
723
774
  look for an idle bot before creating another one."""
@@ -1364,6 +1415,8 @@ def run(args):
1364
1415
  return mcp_command(args, home)
1365
1416
  if args.command == "events":
1366
1417
  return events_command(args, home, store)
1418
+ if args.command == "demand":
1419
+ return demand_command(args, home)
1367
1420
  if args.command == "bot":
1368
1421
  return bot_command(args, home)
1369
1422
  if args.command == "broadcast":
@@ -0,0 +1,195 @@
1
+ """The owner's demands, kept until somebody takes them.
2
+
3
+ A demand typed in the Crow Cockpit used to exist only as a broadcast message,
4
+ and broadcast messages expire (`stale_minutes`, 30 by default). So a demand
5
+ sent while no agent session was alive to receive it simply evaporated: nothing
6
+ to resume, because nothing had been written down. Closing the app looked like
7
+ the cause; the real cause was that the request was never recorded anywhere.
8
+
9
+ This is deliberately NOT a second memory. The vault remains the record: an
10
+ agent that takes a demand writes it there. This store exists for one narrow
11
+ reason — the cockpit is not an agent, carries no contract and has no
12
+ authenticated path to the vault, so it needs somewhere durable to leave a
13
+ request for the window in which nobody is listening. It holds the handoff, not
14
+ the history, and it should disappear the day the cockpit can record directly.
15
+
16
+ No TTL, on purpose: an unanswered demand is not stale, it is unanswered.
17
+ """
18
+
19
+ import json
20
+ import os
21
+ import uuid
22
+
23
+ from . import broadcast
24
+ from .storage import atomic_json, ensure_dir
25
+
26
+ SCHEMA = "crow-demands/1"
27
+
28
+ #: pending — written, nobody took it
29
+ #: taken — an agent is on it (with who and when)
30
+ #: done — finished, with a short note of the outcome
31
+ #: failed — attempted and abandoned, with the reason
32
+ STATES = ("pending", "taken", "done", "failed")
33
+
34
+ #: Keep finished demands around this long so the owner can still see what was
35
+ #: answered; they are history, not queue, and are pruned on write.
36
+ KEEP_DONE_HOURS = 72
37
+
38
+
39
+ def path(home):
40
+ return broadcast.state_dir(home) / "demands.json"
41
+
42
+
43
+ def _load(home):
44
+ data = broadcast.read_json(path(home)) or {}
45
+ if data.get("schema") != SCHEMA:
46
+ return {"schema": SCHEMA, "demands": []}
47
+ data.setdefault("demands", [])
48
+ return data
49
+
50
+
51
+ def _save(home, data):
52
+ target = path(home)
53
+ ensure_dir(target.parent)
54
+ atomic_json(target, data)
55
+
56
+
57
+ def _age_hours(stamp):
58
+ import time
59
+ try:
60
+ return (time.time() - time.mktime(time.strptime((stamp or "")[:19], "%Y-%m-%dT%H:%M:%S"))) / 3600
61
+ except (ValueError, TypeError):
62
+ return None
63
+
64
+
65
+ def add(home, text, thread=None, source=None):
66
+ """Record a demand. Called by whoever received it, before acting."""
67
+ text = (text or "").strip()
68
+ if not text:
69
+ raise ValueError("uma demanda vazia não é uma demanda")
70
+ record = {
71
+ "id": uuid.uuid4().hex[:10],
72
+ "text": text,
73
+ "state": "pending",
74
+ "createdAt": broadcast.now_iso(),
75
+ "thread": thread or None,
76
+ "source": source or "cockpit",
77
+ "takenBy": None,
78
+ "takenAt": None,
79
+ "closedAt": None,
80
+ "note": None,
81
+ "attempts": 0,
82
+ }
83
+ data = _load(home)
84
+ data["demands"].append(record)
85
+ _prune(data)
86
+ _save(home, data)
87
+ return record
88
+
89
+
90
+ def _find(data, demand_id):
91
+ return next((d for d in data["demands"] if d.get("id") == demand_id), None)
92
+
93
+
94
+ def take(home, demand_id, by=None):
95
+ """Claim a demand. Refuses one already in hand, so two agents do not both
96
+ start the same work — the duplication this queue exists to prevent."""
97
+ data = _load(home)
98
+ record = _find(data, demand_id)
99
+ if not record:
100
+ raise ValueError("demanda desconhecida: " + str(demand_id))
101
+ if record["state"] == "taken" and record.get("takenBy") not in (None, by):
102
+ raise RuntimeError("a demanda " + demand_id + " já está com " + str(record.get("takenBy")))
103
+ if record["state"] in ("done", "failed"):
104
+ raise RuntimeError("a demanda " + demand_id + " já foi encerrada como " + record["state"])
105
+ record.update(state="taken", takenBy=by, takenAt=broadcast.now_iso(),
106
+ attempts=int(record.get("attempts") or 0) + 1)
107
+ _save(home, data)
108
+ return record
109
+
110
+
111
+ def close(home, demand_id, state, note=None):
112
+ """Finish a demand. `note` is what the owner needs to read, not a status."""
113
+ if state not in ("done", "failed"):
114
+ raise ValueError("estado final inválido: " + str(state))
115
+ if state == "failed" and not (note or "").strip():
116
+ raise ValueError("uma demanda que falhou precisa dizer por quê")
117
+ data = _load(home)
118
+ record = _find(data, demand_id)
119
+ if not record:
120
+ raise ValueError("demanda desconhecida: " + str(demand_id))
121
+ record.update(state=state, closedAt=broadcast.now_iso(), note=(note or "").strip() or None)
122
+ _prune(data)
123
+ _save(home, data)
124
+ return record
125
+
126
+
127
+ def release(home, demand_id):
128
+ """Put a taken demand back. An agent that cannot finish must hand it back
129
+ rather than leave it held by a session that is about to die."""
130
+ data = _load(home)
131
+ record = _find(data, demand_id)
132
+ if not record:
133
+ raise ValueError("demanda desconhecida: " + str(demand_id))
134
+ if record["state"] != "taken":
135
+ raise RuntimeError("a demanda " + demand_id + " não está tomada")
136
+ record.update(state="pending", takenBy=None, takenAt=None)
137
+ _save(home, data)
138
+ return record
139
+
140
+
141
+ def _prune(data):
142
+ kept = []
143
+ for record in data["demands"]:
144
+ if record.get("state") in ("done", "failed"):
145
+ age = _age_hours(record.get("closedAt"))
146
+ if age is not None and age > KEEP_DONE_HOURS:
147
+ continue
148
+ kept.append(record)
149
+ data["demands"] = kept
150
+
151
+
152
+ def listing(home, pending_only=False, live_keys=None):
153
+ """The queue, with abandonment made visible.
154
+
155
+ A demand held by a session that no longer exists is reported as `orphaned`
156
+ rather than `taken`: it is the one state that looks like progress and is
157
+ not, and it is exactly what happens when the owner's agent dies mid-task.
158
+ """
159
+ data = _load(home)
160
+ rows = []
161
+ changed = False
162
+ for record in data["demands"]:
163
+ row = dict(record)
164
+ if record.get("state") == "taken" and live_keys is not None:
165
+ if record.get("takenBy") and record["takenBy"] not in live_keys:
166
+ row["orphaned"] = True
167
+ # Hand it back so somebody can pick it up again.
168
+ record.update(state="pending", takenBy=None, takenAt=None)
169
+ row["state"] = "pending"
170
+ changed = True
171
+ if pending_only and row.get("state") != "pending":
172
+ continue
173
+ rows.append(row)
174
+ if changed:
175
+ _save(home, data)
176
+ return rows
177
+
178
+
179
+ def render(rows):
180
+ if not rows:
181
+ return "(nenhuma demanda registrada)"
182
+ mark = {"pending": "○", "taken": "●", "done": "✓", "failed": "✗"}
183
+ lines = []
184
+ for d in rows:
185
+ text = broadcast.clean(d.get("text"), 90) if hasattr(broadcast, "clean") else str(d.get("text"))[:90]
186
+ extra = []
187
+ if d.get("orphaned"):
188
+ extra.append("estava com uma sessão que morreu, devolvida à fila")
189
+ if d.get("takenBy"):
190
+ extra.append("com " + str(d["takenBy"])[:10])
191
+ if d.get("note"):
192
+ extra.append(str(d["note"])[:60])
193
+ lines.append(" " + mark.get(d.get("state"), "·") + " [" + str(d.get("id")) + "] " + text +
194
+ (" · " + "; ".join(extra) if extra else ""))
195
+ return "\n".join(lines)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/crow-skills",
3
- "version": "0.38.2",
3
+ "version": "0.39.1",
4
4
  "description": "Versioned, complementary agent skills with a protected multi-agent installer",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "repository": {
package/release.json CHANGED
@@ -28,7 +28,7 @@
28
28
  "crow_skills/__init__.py": {
29
29
  "executable": false,
30
30
  "kind": "file",
31
- "sha256": "7765d89d0c3b7dd8b7d765a61e6a507429a1b2f7710fae47b1d014183fb87e40"
31
+ "sha256": "cc31d6c68bd4608a765d04e1159af015aa8cba0aa61d2da53c027fc033be366f"
32
32
  },
33
33
  "crow_skills/__main__.py": {
34
34
  "executable": false,
@@ -58,7 +58,12 @@
58
58
  "crow_skills/cli.py": {
59
59
  "executable": false,
60
60
  "kind": "file",
61
- "sha256": "7fdd9af628596e8b7abc111b9277cc6498f5a68e6bdab9421e9aec35c0374ab3"
61
+ "sha256": "506a45896915a0effa409a6763c95ddabf46953aa68e16f958af1d2088e7ad18"
62
+ },
63
+ "crow_skills/demands.py": {
64
+ "executable": false,
65
+ "kind": "file",
66
+ "sha256": "ce2436f20878b92eb175143d6b36f03ed62d490315bbb01958aa1646ae9d8225"
62
67
  },
63
68
  "crow_skills/events.py": {
64
69
  "executable": false,
@@ -148,7 +153,7 @@
148
153
  "package.json": {
149
154
  "executable": false,
150
155
  "kind": "file",
151
- "sha256": "9c066a2aa80bb4a853faf9ec8c9d5e0887884e4541539f5e61c62dd369c715b3"
156
+ "sha256": "5b9beb57525b19906aa91a49dce1505bb0b230a4b402e5c9bd35686f489965f7"
152
157
  },
153
158
  "skills/crow-accessibility/SKILL.md": {
154
159
  "executable": false,
@@ -213,7 +218,7 @@
213
218
  "skills/crow-behaviour/SKILL.md": {
214
219
  "executable": false,
215
220
  "kind": "file",
216
- "sha256": "e7f2cdb1acf214e78be8f8b7c27c013e591478df7ac99addd3e0891cbc0d13b9"
221
+ "sha256": "7c8ac7b0f41d07a5815b3beb600848894e661c6318526e62ca564026f3e10c31"
217
222
  },
218
223
  "skills/crow-behaviour/agents/openai.yaml": {
219
224
  "executable": false,
@@ -971,7 +976,7 @@
971
976
  "sha256": "7dd3660097c3afeadb58357939b51da518a4da7cf2d7e684c2618b22f6c4a87b"
972
977
  }
973
978
  },
974
- "revision": "11982e7c39ba1b449a442f8deb6468d5a27d26f4",
979
+ "revision": "0f9a4474c68714d028c27163a483cc3381d84e64",
975
980
  "schema_version": 1,
976
- "version": "0.38.2"
981
+ "version": "0.39.1"
977
982
  }
@@ -62,6 +62,11 @@ Quando `crow-model-level` estiver instalada, o nível de cada unidade delegada
62
62
  - **O braçal é delegado por padrão, não por escolha sua.** Uma unidade é braçal quando você consegue especificar o resultado e aceitá-lo pela evidência, sem ver o caminho: a edição que você já decidiu, a varredura com critério fechado, a reescrita mecânica, a coleta de evidência, rodar um comando e relatar o que saiu, levantar como uma coisa funciona hoje. Se é preciso decidir no meio do caminho, não é braçal — é desenho, e é seu. Executar você mesmo uma unidade braçal porque "o contexto já está carregado" é a falha característica desta skill: troca o seu contexto, que é o recurso escasso e não se recupera, por trabalho que qualquer executor faria, e o briefing que você economizou custa menos do que o contexto que você gastou.
63
63
  - Ao delegar execução, descubra quem é o micro-agent em vez de presumir: `crow_micro.py probe` — com `codex` disponível e autenticado, os micro-agents são do codex; sem ele, são os do próprio CLI. Ao micro-agent vai só o braçal já especificado; mapear, planejar, desenhar e verificar no fim continuam seus. Leia [micro-agents](references/micro-agents.md).
64
64
  - **Micro-agent ou bot autônomo: decida pela natureza da demanda, não por hábito.** Resultado que você especifica e aceita pela evidência, dentro do turno, é **micro-agent** — efêmero, você verifica e descarta. Demanda que persiste além do turno, dispara por evento e precisa de estado próprio entre execuções é **bot autônomo** — um processo que sobrevive à sessão, com memória externa própria e gatilho; ele não lembra, relê o que gravou e reconstrói o contexto a cada disparo. Micro-agent é o default; bot só quando a persistência ou o gatilho de evento é a essência da demanda, não um "seria bom ter". Bot e micro-agent usam igualmente o crow-broadcast e a mesma fronteira de autonomia desta skill; o criador continua responsável por ambos, verifica o resultado e age quando qualquer um fica ocioso.
65
+ - **Pendência que você encontra é registro, e quando outro pode resolvê-la, é oferta.** Anotar o que deu certo e esconder o que ficou pela metade produz um vault que parece completo e não é: sem um registro próprio, a pendência vira prosa dentro do desfecho e ninguém consegue perguntar "o que está pendente". Então registre-a **assim que a identificar**, não no fim, com o que a torna acionável: o que falta, o que ela destrava e como se sabe que fechou. Em seguida decida se é **sua** ou **de qualquer um**: pendência que depende de decisão do dono, de credencial ou de acesso que só você tem fica registrada e **não** é anunciada — pedir ajuda que ninguém pode dar é ruído no canal que o dono observa. Pendência que outro agente conseguiria resolver vira **handoff compartilhado** na memória do dono (`scope: "shared"`, com `summary`, `nextSteps` e `sources` apontando caminho e revisão exata), e só então o broadcast anuncia que existe — a mensagem é a campainha, nunca o registro, porque mensagem expira e pendência não. Quem assume reivindica o handoff (`claim`, que carrega lease e cerca de versão, de modo que dois não pegam o mesmo e quem morrer segurando solta sozinho) e fecha com `complete` e o desfecho. **Não crie fila própria para isso:** o mecanismo existe, é testado, e duplicá-lo produz dois registros divergindo.
66
+ - **Ocioso não é desocupado: é quem pode ajudar.** Ao ficar sem trabalho, leia os handoffs compartilhados em aberto antes de encerrar ou de inventar tarefa. Se couber no seu escopo autorizado, **assuma e resolva**. Se não couber — porque o conserto é do dono daquele código, ou exige acesso que você não tem — ainda assim ajude de outra forma: **levante a informação que falta** para quem vai atuar (reprodução mínima, revisão exata, trecho relevante, o que já foi descartado) e devolva isso no handoff. Informação boa entregue a quem pode agir vale mais que um conserto apressado feito por quem não deveria tocar ali, e evita dois agentes brigando pelo mesmo arquivo.
67
+
68
+ - **Antes de pegar trabalho novo, veja o que o dono já pediu e ninguém assumiu.** Demanda digitada no cockpit fica registrada numa fila local sem validade (`crow-skills demand list --pending`), porque mensagem de broadcast expira e pedido sem resposta não é velho, é pendente. Ao iniciar, e sempre que ficar ocioso, leia a fila: se houver demanda compatível com o seu escopo autorizado, **assuma** (`demand take <id>`), execute, e encerre dizendo o desfecho (`demand done <id> "<o que saiu>"` ou `demand fail <id> "<por quê>"`). Se não puder terminar, **devolva** (`demand release <id>`) em vez de morrer segurando — a fila marca como órfã a demanda presa a uma sessão que sumiu, mas devolver na hora é honesto e evita a espera. A fila é entrega, não memória: ao assumir, registre o pedido na memória do dono, que é onde ele passa a existir de verdade.
69
+
65
70
  - **O conhecimento do bot é seu para guardar, não dele.** A memória do bot é apartada e perecível: ela morre com a sessão e envelhece quando ele fica ocioso. O que ele sabe veio de você, no briefing — ele é consumidor do conhecimento, nunca o guardião. Então, antes de criar um bot de um papel que já existiu, **leia o que já foi acumulado** (`crow-skills bot knowledge --role-of <papel>`) e inclua no briefing o que valer, em vez de ensinar de novo o que já foi pago. Quando o bot reporta, **registre o que ele devolveu** (`crow-skills bot learned <id> "<o que apurou>"`): o retorno não registrado é conhecimento que ninguém mais tem. Esse registro é local e mecânico; **promover ao durável é seu**, pela memória do dono, porque o bot não toca o vault — só você toca. Destile, não acumule: nota que cresce a cada briefing recria, em outro lugar, o custo de contexto que a delegação existe para cortar. E trate o retorno como alegação a julgar antes de virar registro, não como verdade.
66
71
  - Leia [adaptação ao runtime](references/orchestration.md) ao escolher workflow, subagentes ou execução sequencial. Não presuma nomes de ferramentas, paralelismo ou suporte a isolamento. Se o host exigir opt-in explícito do usuário para orquestração programável, esta skill não é esse opt-in: peça o opt-in da ferramenta, nunca permissão para aplicar esta skill, e sem ele siga com subagentes ou sequencialmente.
67
72