@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.
@@ -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())