@andespindola/crow-skills 0.3.0 → 0.4.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 +7 -3
- package/crow_skills/__init__.py +1 -1
- package/crow_skills/cli.py +112 -4
- package/crow_skills/integrations.py +520 -0
- package/crow_skills/progress.py +667 -0
- package/docs/cli.md +61 -3
- package/package.json +1 -1
- package/release.json +31 -11
- package/skills/crow-behaviour/SKILL.md +23 -18
- package/skills/crow-behaviour/agents/openai.yaml +1 -1
- package/skills/crow-behaviour/references/orchestration.md +19 -9
- package/skills/crow-behaviour/references/progress-ledger.md +83 -0
- package/skills/crow-behaviour/references/quality-patterns.md +22 -2
- package/skills/crow-behaviour/scripts/crow_ledger.py +279 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Run ledger for crow-behaviour: record units and print the chat summary.
|
|
3
|
+
|
|
4
|
+
Standard library only; runs on any host that can execute python3. The
|
|
5
|
+
coordinator is the single writer of a ledger; workers report back and the
|
|
6
|
+
coordinator records the transition.
|
|
7
|
+
|
|
8
|
+
crow_ledger.py start --title "..." --host claude [--phase id=Título ...] [--session ID] [--global]
|
|
9
|
+
crow_ledger.py phase ID --run RUN [--title T] [--status running|done|...]
|
|
10
|
+
crow_ledger.py unit ID --run RUN [--title T] [--phase P] [--owner O] [--status S] [--progress 0..1] [--summary TXT]
|
|
11
|
+
crow_ledger.py finish --run RUN [--status done|failed|cancelled]
|
|
12
|
+
crow_ledger.py show [--run RUN] # tree + ASCII bars for the chat
|
|
13
|
+
crow_ledger.py path [--run RUN] # where the ledger lives
|
|
14
|
+
|
|
15
|
+
start prints "<run-id> <path>"; pass that run id as --run (or $CROW_RUN) on
|
|
16
|
+
every later call. Without it the helper only proceeds when exactly one
|
|
17
|
+
running ledger matches the working directory, so parallel sessions in the
|
|
18
|
+
same clone never write to each other's run.
|
|
19
|
+
|
|
20
|
+
Location: $CROW_LEDGER (a file) when set; with --global, or when the working
|
|
21
|
+
directory is not writable, ~/.crow/runs/<run-id>.json; else
|
|
22
|
+
<cwd>/.crow/runs/<run-id>.json, with .crow/.gitignore = "*" so the directory
|
|
23
|
+
never shows up in Git. Lookups walk from the working directory up to the
|
|
24
|
+
filesystem root, so a run started at the repository root is still found from
|
|
25
|
+
a subdirectory.
|
|
26
|
+
"""
|
|
27
|
+
import argparse
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
import re
|
|
33
|
+
import sys
|
|
34
|
+
import tempfile
|
|
35
|
+
|
|
36
|
+
SCHEMA = "crow-run/1"
|
|
37
|
+
UNIT_STATUSES = ("pending", "running", "done", "failed", "skipped")
|
|
38
|
+
RUN_STATUSES = ("running", "done", "failed", "cancelled")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def now():
|
|
42
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def slug(text):
|
|
46
|
+
value = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
|
47
|
+
return value[:40] or "run"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def run_dirs():
|
|
51
|
+
"""<dir>/.crow/runs from the working directory up to the root, then ~/.crow/runs."""
|
|
52
|
+
cwd = Path.cwd()
|
|
53
|
+
dirs = [d / ".crow" / "runs" for d in (cwd, *cwd.parents)]
|
|
54
|
+
dirs.append(Path.home() / ".crow" / "runs")
|
|
55
|
+
result = []
|
|
56
|
+
for d in dirs:
|
|
57
|
+
if d not in result:
|
|
58
|
+
result.append(d)
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def under(cwd, base):
|
|
63
|
+
"""True when cwd is base or one of its subdirectories."""
|
|
64
|
+
base = str(base).rstrip("/") or "/"
|
|
65
|
+
return cwd == base or cwd.startswith(base.rstrip("/") + "/")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def writable_dir(global_only=False):
|
|
69
|
+
home_runs = Path.home() / ".crow" / "runs"
|
|
70
|
+
choices = [home_runs] if global_only else [Path.cwd() / ".crow" / "runs", home_runs]
|
|
71
|
+
for index, directory in enumerate(choices):
|
|
72
|
+
try:
|
|
73
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
if index == 0 and directory != home_runs:
|
|
75
|
+
ignore = directory.parent / ".gitignore"
|
|
76
|
+
if not ignore.exists():
|
|
77
|
+
ignore.write_text("*\n", encoding="utf-8")
|
|
78
|
+
probe = tempfile.NamedTemporaryFile(dir=directory, delete=True)
|
|
79
|
+
probe.close()
|
|
80
|
+
return directory
|
|
81
|
+
except OSError:
|
|
82
|
+
continue
|
|
83
|
+
raise SystemExit("crow_ledger: nenhum diretório gravável para o ledger.")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def save(path, data):
|
|
87
|
+
data["updatedAt"] = now()
|
|
88
|
+
path = Path(path)
|
|
89
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
fd, temp = tempfile.mkstemp(dir=path.parent, prefix="." + path.name + ".")
|
|
91
|
+
try:
|
|
92
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
93
|
+
json.dump(data, handle, ensure_ascii=False, indent=2)
|
|
94
|
+
handle.write("\n")
|
|
95
|
+
os.replace(temp, path)
|
|
96
|
+
finally:
|
|
97
|
+
if os.path.exists(temp):
|
|
98
|
+
os.unlink(temp)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def candidates():
|
|
102
|
+
if os.environ.get("CROW_LEDGER"):
|
|
103
|
+
return [Path(os.environ["CROW_LEDGER"]).expanduser()]
|
|
104
|
+
files = []
|
|
105
|
+
for directory in run_dirs():
|
|
106
|
+
if directory.is_dir():
|
|
107
|
+
files += directory.glob("*.json")
|
|
108
|
+
return files
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def locate(run_id=None, write=False):
|
|
112
|
+
"""Find the ledger to read or update.
|
|
113
|
+
|
|
114
|
+
With a run id (--run or $CROW_RUN) the match is exact. Without one, only
|
|
115
|
+
ledgers whose cwd is the working directory or one of its ancestors are
|
|
116
|
+
considered, and a write is refused when more than one of them is running:
|
|
117
|
+
guessing would put this session's units into another session's run.
|
|
118
|
+
"""
|
|
119
|
+
run_id = run_id or os.environ.get("CROW_RUN") or None
|
|
120
|
+
pinned = bool(os.environ.get("CROW_LEDGER"))
|
|
121
|
+
cwd = str(Path.cwd())
|
|
122
|
+
found = []
|
|
123
|
+
for path in candidates():
|
|
124
|
+
try:
|
|
125
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
126
|
+
mtime = path.stat().st_mtime
|
|
127
|
+
except (OSError, ValueError):
|
|
128
|
+
continue
|
|
129
|
+
if not isinstance(data, dict) or data.get("schema") != SCHEMA:
|
|
130
|
+
continue
|
|
131
|
+
if run_id and data.get("id") != run_id:
|
|
132
|
+
continue
|
|
133
|
+
if not run_id and not pinned and isinstance(data.get("cwd"), str) and not under(cwd, data["cwd"]):
|
|
134
|
+
continue
|
|
135
|
+
found.append(((data.get("status") == "running", mtime), path, data))
|
|
136
|
+
if not found:
|
|
137
|
+
raise SystemExit("crow_ledger: nenhuma execução encontrada" + (" com id " + run_id if run_id else "") +
|
|
138
|
+
"; use start primeiro.")
|
|
139
|
+
running = [item for item in found if item[2].get("status") == "running"]
|
|
140
|
+
if not run_id and not pinned and len(running) > 1 and write:
|
|
141
|
+
ids = ", ".join(sorted(str(item[2].get("id")) for item in running))
|
|
142
|
+
raise SystemExit("crow_ledger: mais de uma execução em andamento neste diretório (" + ids +
|
|
143
|
+
"); passe --run <id> (o id impresso por start).")
|
|
144
|
+
best = max(found, key=lambda item: item[0])
|
|
145
|
+
return best[1], best[2]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def bar(fraction, width=20):
|
|
149
|
+
filled = int(round(max(0.0, min(1.0, fraction)) * width))
|
|
150
|
+
return "[" + "#" * filled + "-" * (width - filled) + "]"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def clean(text, limit=120):
|
|
154
|
+
text = " ".join("".join(c if c.isprintable() else " " for c in str(text or "")).split())
|
|
155
|
+
return text if len(text) <= limit else text[: limit - 3] + "..."
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def render(data):
|
|
159
|
+
units = data.get("units", [])
|
|
160
|
+
counted = [u for u in units if u.get("status") != "skipped"]
|
|
161
|
+
done = sum(u.get("status") == "done" for u in counted)
|
|
162
|
+
overall = sum(1.0 if u.get("status") in ("done", "skipped") else float(u.get("progress") or 0) for u in units) / len(units) if units else 0.0
|
|
163
|
+
icons = {"done": "x", "running": ">", "failed": "!", "pending": " ", "skipped": "-"}
|
|
164
|
+
lines = ["crow > " + clean(data.get("title")) + " [" + data.get("status", "running") + "]",
|
|
165
|
+
bar(overall) + " " + str(int(overall * 100)) + "% " + str(done) + "/" + str(len(counted)) + " unidades"]
|
|
166
|
+
phases = data.get("phases", []) or []
|
|
167
|
+
known = [p.get("id") for p in phases]
|
|
168
|
+
groups = [(p.get("title") or p.get("id"), [u for u in units if u.get("phase") == p.get("id")]) for p in phases]
|
|
169
|
+
loose = [u for u in units if u.get("phase") not in known]
|
|
170
|
+
if loose:
|
|
171
|
+
groups.append(("(sem fase)", loose))
|
|
172
|
+
for title, members in groups:
|
|
173
|
+
finished = sum(u.get("status") in ("done", "skipped") for u in members)
|
|
174
|
+
lines.append("- " + clean(title) + " " + str(finished) + "/" + str(len(members)))
|
|
175
|
+
for unit in members:
|
|
176
|
+
progress = 1.0 if unit.get("status") in ("done", "skipped") else float(unit.get("progress") or 0)
|
|
177
|
+
owner = " @" + clean(unit.get("owner"), 30) if unit.get("owner") else ""
|
|
178
|
+
lines.append(" [" + icons.get(unit.get("status"), "?") + "] " + clean(unit.get("title") or unit.get("id"), 70) +
|
|
179
|
+
" " + bar(progress, 10) + " " + str(int(progress * 100)) + "%" + owner)
|
|
180
|
+
if unit.get("summary") and unit.get("status") in ("done", "failed"):
|
|
181
|
+
lines.append(" " + clean(unit["summary"]))
|
|
182
|
+
return "\n".join(lines)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def main(argv=None):
|
|
186
|
+
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
187
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
188
|
+
start = sub.add_parser("start")
|
|
189
|
+
start.add_argument("--title", required=True)
|
|
190
|
+
start.add_argument("--host", default="other",
|
|
191
|
+
help="claude, codex, gemini (Gemini CLI), agy (Antigravity; nunca gemini), devin ou other")
|
|
192
|
+
start.add_argument("--phase", action="append", default=[], help="id=Título; repetir na ordem")
|
|
193
|
+
start.add_argument("--session", help="ID da sessão do host, se conhecido (tokens exatos e status line da sessão certa)")
|
|
194
|
+
start.add_argument("--global", dest="global_only", action="store_true",
|
|
195
|
+
help="grava em ~/.crow/runs (tarefa somente leitura no diretório ou clone compartilhado)")
|
|
196
|
+
phase = sub.add_parser("phase")
|
|
197
|
+
phase.add_argument("id")
|
|
198
|
+
phase.add_argument("--title")
|
|
199
|
+
phase.add_argument("--status", choices=UNIT_STATUSES)
|
|
200
|
+
unit = sub.add_parser("unit")
|
|
201
|
+
unit.add_argument("id")
|
|
202
|
+
unit.add_argument("--title")
|
|
203
|
+
unit.add_argument("--phase")
|
|
204
|
+
unit.add_argument("--owner")
|
|
205
|
+
unit.add_argument("--status", choices=UNIT_STATUSES)
|
|
206
|
+
unit.add_argument("--progress", type=float)
|
|
207
|
+
unit.add_argument("--summary")
|
|
208
|
+
finish = sub.add_parser("finish")
|
|
209
|
+
finish.add_argument("--status", choices=RUN_STATUSES[1:], default="done")
|
|
210
|
+
for name in ("show", "path"):
|
|
211
|
+
child = sub.add_parser(name)
|
|
212
|
+
child.add_argument("--run")
|
|
213
|
+
for child in (phase, unit, finish):
|
|
214
|
+
child.add_argument("--run")
|
|
215
|
+
args = p.parse_args(argv)
|
|
216
|
+
|
|
217
|
+
if args.command == "start":
|
|
218
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
219
|
+
run_id = stamp + "-" + slug(args.title)
|
|
220
|
+
path = Path(os.environ["CROW_LEDGER"]).expanduser() if os.environ.get("CROW_LEDGER") else writable_dir(args.global_only) / (run_id + ".json")
|
|
221
|
+
phases = []
|
|
222
|
+
for item in args.phase:
|
|
223
|
+
pid, _, title = item.partition("=")
|
|
224
|
+
phases.append({"id": pid.strip(), "title": (title or pid).strip(), "status": "pending"})
|
|
225
|
+
data = {"schema": SCHEMA, "id": run_id, "title": args.title, "host": args.host.lower(), "cwd": str(Path.cwd()),
|
|
226
|
+
"status": "running", "startedAt": now(), "endedAt": None, "phases": phases, "units": []}
|
|
227
|
+
if args.session:
|
|
228
|
+
data["session"] = {"host": args.host.lower(), "id": args.session}
|
|
229
|
+
save(path, data)
|
|
230
|
+
print(run_id + " " + str(path))
|
|
231
|
+
return 0
|
|
232
|
+
path, data = locate(getattr(args, "run", None), write=args.command in ("phase", "unit", "finish"))
|
|
233
|
+
if args.command == "path":
|
|
234
|
+
print(path)
|
|
235
|
+
return 0
|
|
236
|
+
if args.command == "show":
|
|
237
|
+
print(render(data))
|
|
238
|
+
return 0
|
|
239
|
+
if args.command == "phase":
|
|
240
|
+
phases = data.setdefault("phases", [])
|
|
241
|
+
entry = next((x for x in phases if x.get("id") == args.id), None)
|
|
242
|
+
if entry is None:
|
|
243
|
+
entry = {"id": args.id, "title": args.title or args.id, "status": "pending"}
|
|
244
|
+
phases.append(entry)
|
|
245
|
+
if args.title:
|
|
246
|
+
entry["title"] = args.title
|
|
247
|
+
if args.status:
|
|
248
|
+
entry["status"] = args.status
|
|
249
|
+
elif args.command == "unit":
|
|
250
|
+
units = data.setdefault("units", [])
|
|
251
|
+
entry = next((x for x in units if x.get("id") == args.id), None)
|
|
252
|
+
if entry is None:
|
|
253
|
+
entry = {"id": args.id, "title": args.title or args.id, "owner": "", "status": "pending", "progress": 0.0,
|
|
254
|
+
"startedAt": None, "endedAt": None, "summary": ""}
|
|
255
|
+
units.append(entry)
|
|
256
|
+
for field in ("title", "phase", "owner", "summary"):
|
|
257
|
+
if getattr(args, field) is not None:
|
|
258
|
+
entry[field] = getattr(args, field)
|
|
259
|
+
if args.progress is not None:
|
|
260
|
+
entry["progress"] = max(0.0, min(1.0, args.progress))
|
|
261
|
+
if args.status:
|
|
262
|
+
entry["status"] = args.status
|
|
263
|
+
if args.status == "running" and not entry.get("startedAt"):
|
|
264
|
+
entry["startedAt"] = now()
|
|
265
|
+
if args.status in ("done", "failed", "skipped"):
|
|
266
|
+
entry["endedAt"] = now()
|
|
267
|
+
if not entry.get("startedAt"):
|
|
268
|
+
entry["startedAt"] = entry["endedAt"]
|
|
269
|
+
if args.status in ("done", "skipped"):
|
|
270
|
+
entry["progress"] = 1.0
|
|
271
|
+
elif args.command == "finish":
|
|
272
|
+
data["status"] = args.status
|
|
273
|
+
data["endedAt"] = now()
|
|
274
|
+
save(path, data)
|
|
275
|
+
return 0
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
if __name__ == "__main__":
|
|
279
|
+
sys.exit(main())
|