@andespindola/crow-skills 0.38.1 → 0.39.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/crow_skills/__init__.py +1 -1
- package/crow_skills/cli.py +53 -0
- package/crow_skills/demands.py +195 -0
- package/crow_skills/mcp.py +50 -1
- package/package.json +1 -1
- package/release.json +13 -8
- package/skills/crow-behaviour/SKILL.md +2 -0
- package/skills/crow-uny-mcp/scripts/crow_mcp.py +69 -0
package/crow_skills/__init__.py
CHANGED
package/crow_skills/cli.py
CHANGED
|
@@ -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/crow_skills/mcp.py
CHANGED
|
@@ -534,15 +534,46 @@ def _identity(pid):
|
|
|
534
534
|
return None
|
|
535
535
|
|
|
536
536
|
|
|
537
|
+
_NAMES_READER = None
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _served_names():
|
|
541
|
+
"""pid -> server name, borrowed from the runtime module.
|
|
542
|
+
|
|
543
|
+
This module deliberately spawns nothing; it reaches the process table the
|
|
544
|
+
same way it reaches process identity, through crow_mcp.py.
|
|
545
|
+
"""
|
|
546
|
+
global _NAMES_READER
|
|
547
|
+
try:
|
|
548
|
+
if _NAMES_READER is None:
|
|
549
|
+
spec = importlib.util.spec_from_file_location("crow_mcp_status_names", _runtime())
|
|
550
|
+
module = importlib.util.module_from_spec(spec)
|
|
551
|
+
spec.loader.exec_module(module)
|
|
552
|
+
_NAMES_READER = module.serving_names
|
|
553
|
+
return _NAMES_READER() or {}
|
|
554
|
+
except (OSError, ValueError, ImportError, AttributeError):
|
|
555
|
+
return {}
|
|
556
|
+
|
|
557
|
+
|
|
537
558
|
def status(home):
|
|
538
559
|
home = _home(home)
|
|
539
560
|
result = plan(home)
|
|
540
561
|
result.update(action="status", configuredManaged=sum(r["managed"] for r in result["servers"]))
|
|
541
562
|
snapshots = []
|
|
542
563
|
directory = _state(home) / "instances"
|
|
564
|
+
served = _served_names()
|
|
565
|
+
stale = 0
|
|
543
566
|
if directory.exists():
|
|
544
567
|
_safe(directory, home)
|
|
545
|
-
|
|
568
|
+
# Newest first. This used to be sorted(glob)[:200], i.e. by FILENAME —
|
|
569
|
+
# and filenames start with a pid, so the window was arbitrary. Measured
|
|
570
|
+
# on a real machine: 1402 records on disk, the 200 read held 142
|
|
571
|
+
# "stopped" and exactly 2 live pids, while `ps` showed 25 live servers.
|
|
572
|
+
# The observations described mostly dead processes and missed the ones
|
|
573
|
+
# actually running, which is worse than reporting nothing.
|
|
574
|
+
files = sorted(directory.glob("runtime-*.json"), key=lambda f: f.stat().st_mtime, reverse=True)
|
|
575
|
+
stale = max(0, len(files) - 200)
|
|
576
|
+
for path in files[:200]:
|
|
546
577
|
try:
|
|
547
578
|
data = json.loads(_read(path, home))
|
|
548
579
|
# Registry entries are observations, never authority to signal PIDs.
|
|
@@ -561,10 +592,28 @@ def status(home):
|
|
|
561
592
|
row[live_key] = current is not None
|
|
562
593
|
row[verified_key] = current is not None and isinstance(recorded, (tuple, list)) and tuple(recorded) == tuple(current)
|
|
563
594
|
row["source"] = "runtime-reported"
|
|
595
|
+
# Which server this is, when its process is still around.
|
|
596
|
+
name = served.get(data.get("pid"))
|
|
597
|
+
if name:
|
|
598
|
+
row["name"] = name
|
|
564
599
|
snapshots.append(row)
|
|
565
600
|
except (OSError, ValueError):
|
|
566
601
|
continue
|
|
567
602
|
result["runtimeObservations"] = snapshots
|
|
603
|
+
# Aggregates, so a caller can say "N of M alive" instead of counting
|
|
604
|
+
# configured entries and calling it a measurement.
|
|
605
|
+
alive = sorted({r["name"] for r in snapshots if r.get("name") and r.get("pidAlive")})
|
|
606
|
+
result["aliveManaged"] = len(alive)
|
|
607
|
+
result["aliveNames"] = alive
|
|
608
|
+
result["staleRuntimeRecords"] = stale
|
|
609
|
+
# Warm-up caches are reported, never deleted here: dropping one forces a
|
|
610
|
+
# cold start, which is a behaviour change and the owner's decision.
|
|
611
|
+
try:
|
|
612
|
+
caches = list(directory.glob("metadata-*.json")) if directory.exists() else []
|
|
613
|
+
result["warmupCaches"] = len(caches)
|
|
614
|
+
result["warmupCacheBytes"] = sum(c.stat().st_size for c in caches)
|
|
615
|
+
except OSError:
|
|
616
|
+
pass
|
|
568
617
|
result["liveSessionsReloaded"] = False
|
|
569
618
|
return result
|
|
570
619
|
|
package/package.json
CHANGED
package/release.json
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"crow_skills/__init__.py": {
|
|
29
29
|
"executable": false,
|
|
30
30
|
"kind": "file",
|
|
31
|
-
"sha256": "
|
|
31
|
+
"sha256": "3b0211c6914097b573cea26e30b34fef698b83056d512dc3f2f1de2e0c51ade5"
|
|
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": "
|
|
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,
|
|
@@ -93,7 +98,7 @@
|
|
|
93
98
|
"crow_skills/mcp.py": {
|
|
94
99
|
"executable": false,
|
|
95
100
|
"kind": "file",
|
|
96
|
-
"sha256": "
|
|
101
|
+
"sha256": "cf88b7c1fa188eda74c43e6da17198b7b285cc97706d2003ddf7fbe6d8dd5c99"
|
|
97
102
|
},
|
|
98
103
|
"crow_skills/npm_entry.py": {
|
|
99
104
|
"executable": false,
|
|
@@ -148,7 +153,7 @@
|
|
|
148
153
|
"package.json": {
|
|
149
154
|
"executable": false,
|
|
150
155
|
"kind": "file",
|
|
151
|
-
"sha256": "
|
|
156
|
+
"sha256": "de3052ab0111c37ac40038dc78a1372e3c5696e87007a36fcc8241880bba9b14"
|
|
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": "
|
|
221
|
+
"sha256": "0d943e32841e808c9f3e60f2faa97bac08b1ce143ec56099354f2bb4939d82f4"
|
|
217
222
|
},
|
|
218
223
|
"skills/crow-behaviour/agents/openai.yaml": {
|
|
219
224
|
"executable": false,
|
|
@@ -968,10 +973,10 @@
|
|
|
968
973
|
"skills/crow-uny-mcp/scripts/crow_mcp.py": {
|
|
969
974
|
"executable": false,
|
|
970
975
|
"kind": "file",
|
|
971
|
-
"sha256": "
|
|
976
|
+
"sha256": "7dd3660097c3afeadb58357939b51da518a4da7cf2d7e684c2618b22f6c4a87b"
|
|
972
977
|
}
|
|
973
978
|
},
|
|
974
|
-
"revision": "
|
|
979
|
+
"revision": "1eef9e88688b6baa3a085c2d803127c983573b98",
|
|
975
980
|
"schema_version": 1,
|
|
976
|
-
"version": "0.
|
|
981
|
+
"version": "0.39.0"
|
|
977
982
|
}
|
|
@@ -62,6 +62,8 @@ 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
|
+
- **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.
|
|
66
|
+
|
|
65
67
|
- **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
68
|
- 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
69
|
|
|
@@ -133,6 +133,72 @@ if sys.platform == "darwin":
|
|
|
133
133
|
pass
|
|
134
134
|
|
|
135
135
|
|
|
136
|
+
def prune_runtime_records(state_dir, keep_hours=24):
|
|
137
|
+
"""Drop runtime records whose process is gone and that are no longer fresh.
|
|
138
|
+
|
|
139
|
+
Nothing ever removed these. Measured on a real machine: 1402 records, 1148
|
|
140
|
+
of them older than a day. They are tiny (0.4 MB), but the status reader
|
|
141
|
+
takes a bounded window over them, so the dead ones crowded out every live
|
|
142
|
+
server — it reported 2 live pids while 25 servers were running. Pruned by
|
|
143
|
+
whoever creates them; a reader must stay read-only. Metadata caches are
|
|
144
|
+
deliberately NOT touched: discarding a warm-up cache changes behaviour by
|
|
145
|
+
forcing cold starts, which is the owner's call, not hygiene.
|
|
146
|
+
"""
|
|
147
|
+
removed = 0
|
|
148
|
+
try:
|
|
149
|
+
directory = Path(state_dir)
|
|
150
|
+
cutoff = time.time() - keep_hours * 3600
|
|
151
|
+
for path in directory.glob("runtime-*.json"):
|
|
152
|
+
try:
|
|
153
|
+
if path.stat().st_mtime > cutoff:
|
|
154
|
+
continue
|
|
155
|
+
data = json.loads(path.read_text())
|
|
156
|
+
pid = data.get("pid")
|
|
157
|
+
if isinstance(pid, int) and not isinstance(pid, bool) and process_identity(pid) is not None:
|
|
158
|
+
continue # still alive: not ours to remove
|
|
159
|
+
path.unlink()
|
|
160
|
+
removed += 1
|
|
161
|
+
except (OSError, ValueError):
|
|
162
|
+
continue
|
|
163
|
+
except OSError:
|
|
164
|
+
return removed
|
|
165
|
+
return removed
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def serving_names():
|
|
169
|
+
"""pid -> public name of every live `crow_mcp.py serve` on this machine.
|
|
170
|
+
|
|
171
|
+
The runtime registry records state and pids but not the server's NAME, so a
|
|
172
|
+
reader could only report anonymous liveness. The name is on the command
|
|
173
|
+
line; this is the one place allowed to look, because the status module
|
|
174
|
+
deliberately spawns nothing and borrows helpers from here.
|
|
175
|
+
"""
|
|
176
|
+
names = {}
|
|
177
|
+
try:
|
|
178
|
+
out = subprocess.run(["/bin/ps", "-axo", "pid=,command="],
|
|
179
|
+
capture_output=True, text=True, timeout=10)
|
|
180
|
+
except (OSError, subprocess.SubprocessError):
|
|
181
|
+
return names
|
|
182
|
+
if out.returncode != 0:
|
|
183
|
+
return names
|
|
184
|
+
for line in out.stdout.splitlines():
|
|
185
|
+
text = line.strip()
|
|
186
|
+
if "crow_mcp.py" not in text or " serve" not in text:
|
|
187
|
+
continue
|
|
188
|
+
head, _, rest = text.partition(" ")
|
|
189
|
+
try:
|
|
190
|
+
pid = int(head)
|
|
191
|
+
except ValueError:
|
|
192
|
+
continue
|
|
193
|
+
parts = rest.split()
|
|
194
|
+
if "--name" in parts:
|
|
195
|
+
try:
|
|
196
|
+
names[pid] = parts[parts.index("--name") + 1]
|
|
197
|
+
except IndexError:
|
|
198
|
+
continue
|
|
199
|
+
return names
|
|
200
|
+
|
|
201
|
+
|
|
136
202
|
def process_identity(pid):
|
|
137
203
|
"""Kernel start identity, not PID alone or second-granularity ps time."""
|
|
138
204
|
try:
|
|
@@ -173,6 +239,9 @@ class Proxy:
|
|
|
173
239
|
self.input_fd, self.output_fd = input_fd, output_fd
|
|
174
240
|
self.store = PrivateStore(options.state_dir)
|
|
175
241
|
self.registry = "runtime-" + str(os.getpid()) + "-" + uuid.uuid4().hex + ".json"
|
|
242
|
+
# Clean up after the ones that came before: this is the layer that
|
|
243
|
+
# creates the records, so it is the layer that retires them.
|
|
244
|
+
prune_runtime_records(options.state_dir)
|
|
176
245
|
self.sel = selectors.DefaultSelector()
|
|
177
246
|
self.sel.register(input_fd, selectors.EVENT_READ, "host")
|
|
178
247
|
os.set_blocking(output_fd, False)
|