@andespindola/crow-skills 0.1.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/catalog.json +1 -1
- package/crow_skills/__init__.py +1 -1
- package/crow_skills/cli.py +261 -8
- package/crow_skills/guide.py +585 -0
- package/crow_skills/integrations.py +520 -0
- package/crow_skills/progress.py +667 -0
- package/crow_skills/storage.py +6 -0
- package/docs/cli-security.md +2 -0
- package/docs/cli.md +104 -4
- package/package.json +1 -1
- package/release.json +39 -14
- package/skills/crow-behaviour/SKILL.md +24 -19
- 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
|
@@ -17,12 +17,24 @@ Quando a solução ainda for desconhecida, separe perguntas falsificáveis e res
|
|
|
17
17
|
|
|
18
18
|
Não imponha consenso: dois agentes podem compartilhar a mesma suposição errada. Sem independência real, rotule como perspectivas da mesma execução.
|
|
19
19
|
|
|
20
|
+
## Painel com juízes
|
|
21
|
+
|
|
22
|
+
Para decisões de design com várias saídas plausíveis: N propostas independentes (mesmo problema, sem ver umas às outras); avaliadores paralelos pontuam cada uma pelos critérios do usuário, com justificativa ligada a evidência; sintetize a vencedora incorporando o melhor das demais. Os juízes recebem as propostas e os critérios, não a preferência do coordenador. Empate ou divergência forte entre juízes é sinal para buscar a evidência que decide, não para votar de novo.
|
|
23
|
+
|
|
20
24
|
## Pesquisa com triangulação
|
|
21
25
|
|
|
22
|
-
Separe eixos complementares, não só variações de palavras-chave. Exija fontes rastreáveis, data/versão, observações e inferências separadas. Na síntese, identifique fontes derivadas entre si e conflitos de versão; não conte cópias da mesma origem como confirmações independentes.
|
|
26
|
+
Separe eixos complementares, não só variações de palavras-chave, e execute-os em paralelo. Exija fontes rastreáveis, data/versão, observações e inferências separadas. Na síntese, identifique fontes derivadas entre si e conflitos de versão; não conte cópias da mesma origem como confirmações independentes.
|
|
23
27
|
|
|
24
28
|
Verifique afirmações centrais contra a fonte primária quando disponível. Falta de acesso é desconhecido, não refutação. Não preencha o relatório com fontes irrelevantes para aparentar cobertura.
|
|
25
29
|
|
|
30
|
+
## Varredura por vários eixos
|
|
31
|
+
|
|
32
|
+
Em revisão e auditoria, como em pesquisa, divida a busca por dimensões (por exemplo, corretude, segurança, desempenho, compatibilidade, dados, operação) e execute cada eixo em paralelo, com seu próprio executor. Cada achado segue para a verificação adversária; achados duplicados entre eixos são deduplicados numa barreira antes da verificação.
|
|
33
|
+
|
|
34
|
+
## Busca até secar
|
|
35
|
+
|
|
36
|
+
Em descoberta, auditoria ou revisão, repita rodadas com ângulos novos até K rodadas consecutivas sem item novo (K = 2 por padrão; maior quando o custo de um item perdido é alto). Registre no ledger K, o número de rodadas e o motivo de parada (secou, limite explícito ou capacidade ausente). Isto é diferente de "Corrigir até passar": lá o loop termina quando um check passa; aqui termina quando a descoberta para de render itens.
|
|
37
|
+
|
|
26
38
|
## Fan-out por item: auditoria ou migração
|
|
27
39
|
|
|
28
40
|
Faça inventário do conjunto-alvo e estabeleça uma unidade verificável por item/lote. Preserve identidade de cada item e resultado inclusive em falha, cancelamento ou retorno vazio. Não filtre falhas silenciosamente para produzir uma taxa de sucesso melhor.
|
|
@@ -39,8 +51,14 @@ Depois de uma falha, formule causa e correção, aplique o menor ajuste autoriza
|
|
|
39
51
|
|
|
40
52
|
O executor entrega o artefato e sua evidência; o revisor recebe requisitos e artefato e tenta refutar o aceite. Revise fronteiras: entradas inválidas, permissões, concorrência, compatibilidade, recuperação ou fidelidade de conteúdo, conforme o domínio real.
|
|
41
53
|
|
|
54
|
+
Por padrão, salvo trabalho trivial ou já verificado nesta revisão, submeta cada achado ou alegação a vários verificadores independentes instruídos a refutar, cada um com uma lente distinta (corretude, segurança, desempenho, reprodução). O achado cai se a maioria refutar, salvo evidência reproduzível (teste, comando, trecho de fonte primária) que resista às refutações; nesse caso ele fica, e a divergência é registrada com o motivo. Verificadores que leram a mesma fonte derivada contam como uma só confirmação.
|
|
55
|
+
|
|
42
56
|
O principal valida achados contestados e reconcilia revisão com testes. Não peça ao revisor apenas que confirme que o executor está certo. Quando não houver segundo agente, faça revisão sequencial sem afirmar independência, e prefira testes/medidas reproduzíveis para reduzir viés.
|
|
43
57
|
|
|
58
|
+
## Crítico de completude
|
|
59
|
+
|
|
60
|
+
Antes de encerrar, uma unidade separada (outro executor, ou uma passada sem o contexto da produção quando não houver outro) recebe o pedido original, os critérios e a entrega, e lista apenas o que falta ou não está demonstrado. Itens novos voltam para o plano; uma resposta vazia conta para a parada. Não peça ao crítico que aprove a entrega.
|
|
61
|
+
|
|
44
62
|
## Critério de conclusão
|
|
45
63
|
|
|
46
64
|
Antes de encerrar, confronte pedido e entrega:
|
|
@@ -51,6 +69,8 @@ Antes de encerrar, confronte pedido e entrega:
|
|
|
51
69
|
| O que foi realmente executado? | Saída observada, alvo/revisão e condição do teste. |
|
|
52
70
|
| As partes funcionam juntas? | Verificação de interfaces, não só aprovação isolada. |
|
|
53
71
|
| Há alegação sem base ou conflito escondido? | Fonte, teste ou rótulo de desconhecido; motivo da resolução. |
|
|
54
|
-
|
|
|
72
|
+
| A descoberta secou? | Rodadas sem item novo (K), crítico de completude sem lacuna nova, ou limite explícito registrado. |
|
|
73
|
+
| O que ficou de fora? | Itens cortados ou não verificados, com motivo; nenhum corte silencioso. |
|
|
74
|
+
| O trabalho pode continuar sem repetir tudo? | Ledger com estado remanescente, artefatos válidos e próxima ação. |
|
|
55
75
|
|
|
56
76
|
A resposta final prioriza o resultado e limitações que mudam a decisão do usuário. A coordenação fica em segundo plano; só detalhe a metodologia quando solicitado ou necessário para interpretar a confiança.
|
|
@@ -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())
|