@andespindola/crow-skills 0.13.1 → 0.15.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 +2 -2
- package/crow_skills/__init__.py +1 -1
- package/crow_skills/agents.py +35 -2
- package/crow_skills/autoupdate.py +17 -4
- package/crow_skills/broadcast.py +207 -28
- package/crow_skills/cli.py +54 -22
- package/crow_skills/guide.py +0 -1
- package/crow_skills/hook.py +54 -23
- package/crow_skills/machine.py +143 -11
- package/crow_skills/projection.py +69 -27
- package/crow_skills/resources.py +5 -3
- package/crow_skills/storage.py +9 -1
- package/crow_skills/ui.py +1 -1
- package/docs/cli.md +23 -11
- package/package.json +1 -1
- package/release.json +19 -19
- package/skills/crow-behaviour/SKILL.md +1 -1
- package/skills/crow-behaviour/references/broadcast.md +8 -7
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Skills com escopo explícito, conhecimento especializado preservado e fluxos comuns reutilizáveis.
|
|
4
4
|
|
|
5
|
-
**Versão 0.12.2** ([mudanças](CHANGELOG.md)): o snapshot de cada sessão (`crow-skills events snapshot`) passa a trazer o pid do CLI de agente e o terminal dele (`pid`, `tty`), para o crow-term achar o painel de cada sessão. A 0.12.1 corrigiu a proteção de processos registrados e um teste instável; a 0.12.0 fez a crow-behaviour passar a encerrar a árvore inteira de todo processo que a execução inicia (com timeout, registro no ledger e verificação no fim) e `crow-skills resources --orphans` encontra e, com `--kill`, encerra só o que ficou órfão de ferramentas de agente. A 0.11.1 fez o Codex deixar de mostrar em dobro as skills que o Uniflow já projeta em `~/.codex/skills`. A 0.11.0 trouxe `crow-skills setup --yes`, que configura de uma vez todos os CLIs de agente da máquina, e `crow-skills upgrade --yes`, que atualiza tudo numa só passada; os hooks que o crow-skills grava já saem habilitados (no Codex, aprovados pelo próprio `codex app-server`, só os do crow-skills; `--no-trust` desliga) e `status`/`doctor` mostram se cada CLI vai rodá-los; a ajuda ficou curta e organizada por tarefa. A 0.10.1 passou a tratar swap e CPU como eixos de pressão no controle de recursos (swap quase cheio e carga alta reduzem agentes, jobs e workers; nenhuma recomendação cresce com a pressão), mostrou a CPU% dos CLIs de agente, trouxe `crow-skills resources --exec-prefix` para rodar jobs pesados em prioridade baixa e as regras da crow-behaviour sob pressão (reduzir já, um job pesado por vez, não repetir verificação, validação ampla no CI); os hooks do Codex vão para o `config.toml` quando o `hooks.json` é symlink. A 0.10.0 trouxe o crow-broadcast, que compartilha, de forma passiva, o que cada sessão e cada CLI (Claude Code, Codex, Gemini CLI, Antigravity, Devin, inclusive dentro do Uniflow) está fazendo nesta máquina: repositório, clone, branch, execução crow em andamento e arquivos em edição; o prompt recebe um
|
|
5
|
+
**Versão 0.12.2** ([mudanças](CHANGELOG.md)): o snapshot de cada sessão (`crow-skills events snapshot`) passa a trazer o pid do CLI de agente e o terminal dele (`pid`, `tty`), para o crow-term achar o painel de cada sessão. A 0.12.1 corrigiu a proteção de processos registrados e um teste instável; a 0.12.0 fez a crow-behaviour passar a encerrar a árvore inteira de todo processo que a execução inicia (com timeout, registro no ledger e verificação no fim) e `crow-skills resources --orphans` encontra e, com `--kill`, encerra só o que ficou órfão de ferramentas de agente. A 0.11.1 fez o Codex deixar de mostrar em dobro as skills que o Uniflow já projeta em `~/.codex/skills`. A 0.11.0 trouxe `crow-skills setup --yes`, que configura de uma vez todos os CLIs de agente da máquina, e `crow-skills upgrade --yes`, que atualiza tudo numa só passada; os hooks que o crow-skills grava já saem habilitados (no Codex, aprovados pelo próprio `codex app-server`, só os do crow-skills; `--no-trust` desliga) e `status`/`doctor` mostram se cada CLI vai rodá-los; a ajuda ficou curta e organizada por tarefa. A 0.10.1 passou a tratar swap e CPU como eixos de pressão no controle de recursos (swap quase cheio e carga alta reduzem agentes, jobs e workers; nenhuma recomendação cresce com a pressão), mostrou a CPU% dos CLIs de agente, trouxe `crow-skills resources --exec-prefix` para rodar jobs pesados em prioridade baixa e as regras da crow-behaviour sob pressão (reduzir já, um job pesado por vez, não repetir verificação, validação ampla no CI); os hooks do Codex vão para o `config.toml` quando o `hooks.json` é symlink. A 0.10.0 trouxe o crow-broadcast, que compartilha, de forma passiva, o que cada sessão e cada CLI (Claude Code, Codex, Gemini CLI, Antigravity, Devin, inclusive dentro do Uniflow) está fazendo nesta máquina: repositório, clone, branch, execução crow em andamento e arquivos em edição; o prompt recebe em toda mensagem um bloco com as sessões vivas que afetam este trabalho (ou uma linha dizendo que não há nenhuma), editar um arquivo que outra sessão tocou ou trocar de branch num clone em uso gera aviso (ou bloqueio, se configurado), e `crow-skills broadcast claim` reserva áreas; a crow-behaviour consulta isso antes de planejar e reserva as áreas que delega ([crow-broadcast](docs/broadcast.md)). A 0.9.0 trouxe o controle de memória da máquina: `crow-skills resources` mede memória, pressão e os CLIs de agente em execução e recomenda quantos agentes, jobs pesados e workers rodar em paralelo; a crow-behaviour consulta essa recomendação antes de cada fan-out (sob `warn` reduz, sob `critical` segue sequencial), limita workers de builds e testes, reutiliza um dev server por projeto e encerra o que abriu, e 15 especialistas que sobem processos pesados ganharam a regra correspondente; o hook de prompt e as status lines só avisam quando a máquina está sob pressão. A 0.8.0 trouxe `crow-skills mode on|off`, que liga e desliga a crow-behaviour em todas as sessões e CLIs (Claude Code, Codex, Gemini CLI, Antigravity, Devin) a partir do próximo prompt, com `/crow on|off` dentro da sessão e o indicador `▸▸ crow on` na status line; a crow-behaviour mostra o progresso como a lista de agentes do host (`● main`, `○ dono atividade`); `crow-skills events` publica um barramento de eventos dos hooks para terminais; `crow-skills autoupdate on` atualiza o CLI e as skills gerenciadas todo dia e no login. A 0.7.1 trouxe as correções da revalidação no Codex às skills especialistas; a 0.7.0 tornou o Uniflow agente de instalação (`--agents uniflow`); a 0.6.1 passou a mostrar o progresso da crow-behaviour na própria conversa; a 0.6.0 trouxe as regras de entrega das 32 skills especialistas, a partir da [validação no Codex](docs/evaluations/2026-10-26-codex.md).
|
|
6
6
|
|
|
7
7
|
**Reconstrução em andamento.** A primeira versão com 32 contratos genéricos não atende à preservação de capacidades solicitada. Não use esse número nem o mapeamento por categoria como prova de substituição das originais. A revisão passa a abranger cada definição e seus recursos, modos e dependências, sem número fixo de skills ou limite artificial de linhas. Veja [critérios e progresso](docs/reconstruction.md).
|
|
8
8
|
|
|
@@ -26,7 +26,7 @@ npm install -g @andespindola/crow-skills && crow-skills setup --yes
|
|
|
26
26
|
4. Entregue o artefato com alvo/revisão, evidência e pendências. Desconhecido não significa aprovado.
|
|
27
27
|
5. Carregue outra skill só quando houver decisão distinta a tomar. Handoffs são opcionais, não dependências ocultas.
|
|
28
28
|
|
|
29
|
-
Para adotar uma atuação mais coordenada em qualquer domínio, use `crow-behaviour`: todo pedido que não seja conversa, pergunta factual curta ou ajuste trivial e local recebe plano, delegação suportada, verificação e síntese, mantidos nos prompts seguintes da sessão; só essas exceções continuam diretas. A skill adapta o comportamento às ferramentas reais, inclusive execução sequencial, sem depender do Workflow do Claude. Fora dessas exceções ela mantém um ledger de progresso (`.crow/runs/`) e mostra na conversa o bloco de processos no formato da lista de agentes do host (`● main …` e `○ dono atividade` por unidade em andamento); `crow-skills progress --watch` e as status lines mostram o mesmo. `crow-skills mode on|off` (ou `/crow on|off`) a liga ou desliga em todos os CLIs. Com várias sessões ou CLIs
|
|
29
|
+
Para adotar uma atuação mais coordenada em qualquer domínio, use `crow-behaviour`: todo pedido que não seja conversa, pergunta factual curta ou ajuste trivial e local recebe plano, delegação suportada, verificação e síntese, mantidos nos prompts seguintes da sessão; só essas exceções continuam diretas. A skill adapta o comportamento às ferramentas reais, inclusive execução sequencial, sem depender do Workflow do Claude. Fora dessas exceções ela mantém um ledger de progresso (`.crow/runs/`) e mostra na conversa o bloco de processos no formato da lista de agentes do host (`● main …` e `○ dono atividade` por unidade em andamento); `crow-skills progress --watch` e as status lines mostram o mesmo. `crow-skills mode on|off` (ou `/crow on|off`) a liga ou desliga em todos os CLIs. Com várias sessões ou CLIs nos mesmos repositórios, ela lê o bloco do crow-broadcast que chega em todo prompt (`crow-skills broadcast` detalha) para não duplicar trabalho nem trocar a branch de um clone em uso, e reserva as áreas que delega. Ela não muda o modelo/effort. [Escopo e evidência](analysis/crow-behaviour.md).
|
|
30
30
|
|
|
31
31
|
Exemplos:
|
|
32
32
|
- Plataforma nova: `crow-platform` coordena; `crow-discovery` e `crow-spec` definem o que falta; as especialistas implementam/verificam por fatia.
|
package/crow_skills/__init__.py
CHANGED
package/crow_skills/agents.py
CHANGED
|
@@ -4,18 +4,47 @@ import os
|
|
|
4
4
|
import shutil
|
|
5
5
|
from .source import CrowError
|
|
6
6
|
|
|
7
|
+
# Every destination is the CLI's OWN skills directory. There is no shared folder: the
|
|
8
|
+
# ~/.agents/skills store that codex used until 0.14.0 made crow-skills act on a directory
|
|
9
|
+
# instead of on an agent, which showed up as a phantom "codex,shared" agent and let Codex
|
|
10
|
+
# list the same skill twice (its own dir plus the shared one). LEGACY_SHARED stays named
|
|
11
|
+
# only so an install made there can be found and migrated away.
|
|
12
|
+
LEGACY_SHARED = ".agents/skills"
|
|
7
13
|
AGENTS = {
|
|
8
|
-
"codex": (".
|
|
14
|
+
"codex": (".codex/skills", ".codex/skills", "codex"),
|
|
9
15
|
"claude": (".claude/skills", ".claude/skills", "claude"),
|
|
10
16
|
"gemini": (".gemini/skills", ".gemini/skills", "gemini"),
|
|
11
17
|
"devin": (".config/devin/skills", ".devin/skills", "devin"),
|
|
12
|
-
"shared": (".agents/skills", ".agents/skills", None),
|
|
13
18
|
# Uniflow keeps one store and links it into each CLI's user skills dir; it has
|
|
14
19
|
# no project scope. The store sits beside config.json (see uniflow_store).
|
|
15
20
|
"uniflow": (".config/uniflow/skills", None, "uniflow"),
|
|
16
21
|
}
|
|
17
22
|
|
|
18
23
|
|
|
24
|
+
def codex_home(home):
|
|
25
|
+
"""$CODEX_HOME for the real profile (as Codex resolves it); an isolated --home uses <home>/.codex.
|
|
26
|
+
|
|
27
|
+
A RELATIVE $CODEX_HOME is ignored: os.path.abspath would resolve it against this process's cwd,
|
|
28
|
+
which is not Codex's, so the destination would depend on where the command was run and could
|
|
29
|
+
land inside the user's repository. Only an absolute (or ~-anchored) value is honoured."""
|
|
30
|
+
home = Path(home)
|
|
31
|
+
raw = os.environ.get("CODEX_HOME") if real_home(home) else None
|
|
32
|
+
if raw:
|
|
33
|
+
expanded = Path(raw).expanduser()
|
|
34
|
+
if expanded.is_absolute():
|
|
35
|
+
return Path(os.path.abspath(expanded))
|
|
36
|
+
return Path(os.path.abspath(home / ".codex"))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def codex_skills_dir(home):
|
|
40
|
+
return codex_home(home) / "skills"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def legacy_shared_dir(home):
|
|
44
|
+
"""Where codex installs landed until 0.14.0; read only, to migrate what is still there."""
|
|
45
|
+
return Path(os.path.abspath(Path(home) / LEGACY_SHARED))
|
|
46
|
+
|
|
47
|
+
|
|
19
48
|
def real_home(home):
|
|
20
49
|
"""Environment overrides describe the real profile; an isolated --home ignores them."""
|
|
21
50
|
return home == Path.home().resolve()
|
|
@@ -74,6 +103,10 @@ def destinations(names, home, project=None, custom=None):
|
|
|
74
103
|
# Respect explicit XDG config, but isolate it when --home selects another profile.
|
|
75
104
|
if name == "devin" and not project and real_home(home) and os.getenv("XDG_CONFIG_HOME"):
|
|
76
105
|
path = Path(os.environ["XDG_CONFIG_HOME"]).expanduser() / "devin/skills"
|
|
106
|
+
# Codex resolves its own home through $CODEX_HOME; the destination must follow it, or an
|
|
107
|
+
# install would write beside a profile Codex never reads.
|
|
108
|
+
if name == "codex" and not project:
|
|
109
|
+
path = codex_skills_dir(home)
|
|
77
110
|
if name == "uniflow":
|
|
78
111
|
path = uniflow_store(home)
|
|
79
112
|
key = str(path.absolute())
|
|
@@ -29,7 +29,7 @@ import subprocess
|
|
|
29
29
|
import sys
|
|
30
30
|
|
|
31
31
|
from . import __version__
|
|
32
|
-
from .agents import destinations
|
|
32
|
+
from .agents import destinations, legacy_shared_dir
|
|
33
33
|
from .guide import protect, read_regular, sha, write_atomic
|
|
34
34
|
from .source import CrowError, Source, same_source
|
|
35
35
|
from .storage import atomic_json, checked, private_dir, read_json
|
|
@@ -334,8 +334,9 @@ def reinstall_behaviour(home, entry, dry_run, trust):
|
|
|
334
334
|
|
|
335
335
|
|
|
336
336
|
def codex_duplicates(home, store, entry, names, dry_run):
|
|
337
|
-
"""Codex sees Uniflow's links in
|
|
338
|
-
|
|
337
|
+
"""Codex sees Uniflow's links in its own dir: those names are not updated there, and intact managed copies
|
|
338
|
+
left in the legacy shared folder are removed through uninstall against THAT folder (backup, transaction).
|
|
339
|
+
Returns the names to update."""
|
|
339
340
|
from . import projection
|
|
340
341
|
from .cli import parser, run as cli_run
|
|
341
342
|
seen = projection.projected("codex", home, store)
|
|
@@ -347,7 +348,8 @@ def codex_duplicates(home, store, entry, names, dry_run):
|
|
|
347
348
|
entry["kept"] += [{"name": n, "status": "duplicate_modified"} for n in dupes["modified"]]
|
|
348
349
|
entry["kept"] += [{"name": n, "status": "duplicate_shared"} for n in dupes["shared"]]
|
|
349
350
|
if dupes["remove"]:
|
|
350
|
-
|
|
351
|
+
# --dest na pasta legada: em ~/.codex/skills esses nomes são links do Uniflow e o uninstall recusaria.
|
|
352
|
+
argv = ["uninstall", *dupes["remove"], "--dest", str(legacy_shared_dir(home)), "--json", "--home", str(home),
|
|
351
353
|
"--dry-run" if dry_run else "--yes", "--no-integrations"]
|
|
352
354
|
try:
|
|
353
355
|
data = cli_run(parser().parse_args(argv))
|
|
@@ -411,6 +413,11 @@ def skills_update(home, trust=True, dry_run=False):
|
|
|
411
413
|
entry["error"] = {"code": exc.code, "message": str(exc)}
|
|
412
414
|
except (OSError, ValueError, KeyError, TypeError):
|
|
413
415
|
entry["error"] = {"code": "io_error", "message": "Falha de leitura/gravação; nada concluído para este agente."}
|
|
416
|
+
if agent == "codex" and not entry.get("error"):
|
|
417
|
+
# Ramo mais provável de quem vinha da 0.14.0: no diretório do Codex só há links do
|
|
418
|
+
# Uniflow, e TODAS as cópias do codex estão na pasta compartilhada. Sem migrar aqui,
|
|
419
|
+
# uma máquina que só roda autoupdate nunca sai daquela pasta.
|
|
420
|
+
machine.migrate_legacy(None, home, store, entry, dry_run, planned=projected)
|
|
414
421
|
results.append(entry)
|
|
415
422
|
continue
|
|
416
423
|
records = store.state(target["path"])["installed"]
|
|
@@ -455,6 +462,12 @@ def skills_update(home, trust=True, dry_run=False):
|
|
|
455
462
|
entry["error"] = {"code": exc.code, "message": str(exc)}
|
|
456
463
|
except (OSError, ValueError, KeyError, TypeError):
|
|
457
464
|
entry["error"] = {"code": "io_error", "message": "Falha de leitura/gravação; nada concluído para este agente."}
|
|
465
|
+
if agent == "codex" and not entry.get("error"):
|
|
466
|
+
# Mesma migração do setup, e pelo mesmo motivo: sem isto, uma máquina que só roda
|
|
467
|
+
# autoupdate/upgrade mantém a pasta compartilhada antiga para sempre. Depois da
|
|
468
|
+
# atualização, nunca antes.
|
|
469
|
+
from . import machine
|
|
470
|
+
machine.migrate_legacy(None, home, store, entry, dry_run)
|
|
458
471
|
results.append(entry)
|
|
459
472
|
return results
|
|
460
473
|
|
package/crow_skills/broadcast.py
CHANGED
|
@@ -49,6 +49,7 @@ SESSION_SCHEMA = "crow-session/1"
|
|
|
49
49
|
DEFAULTS = {"enabled": True, "block_on_conflict": False, "conflict_window_minutes": 30, "stale_minutes": 30,
|
|
50
50
|
"pid_alive_hours": 12, "digest_max_sessions": 4}
|
|
51
51
|
MAX_FILES = 40 # paths kept per session
|
|
52
|
+
MAX_REPOS = 8 # clones kept per session (cwd apart): the ones its edits and shell calls reached
|
|
52
53
|
FILE_TTL = 6 * 3600 # a touched path older than this is forgotten
|
|
53
54
|
MAX_CLAIMS = 200
|
|
54
55
|
MAX_CLAIM_TTL = 24 * 3600
|
|
@@ -343,6 +344,7 @@ def edit_paths(host, tool, tool_input, cwd):
|
|
|
343
344
|
|
|
344
345
|
|
|
345
346
|
GIT_WORD = re.compile(r"^(?:\S*/)?git$")
|
|
347
|
+
DIR_WORD = re.compile(r"(?:^|[\s;&|()])(?:cd|[^\s;&|()]*/?git)(?:\s|$)")
|
|
346
348
|
TREE_SUBCOMMANDS = ("checkout", "switch", "rebase", "stash", "merge", "pull", "cherry-pick", "bisect", "restore", "clean")
|
|
347
349
|
|
|
348
350
|
|
|
@@ -359,10 +361,32 @@ def tree_command(host, tool, tool_input, cwd=None):
|
|
|
359
361
|
return []
|
|
360
362
|
here = cwd if isinstance(cwd, str) and cwd.startswith("/") else None
|
|
361
363
|
targets = []
|
|
362
|
-
_scan(command[:64 * 1024], here, targets, 0)
|
|
364
|
+
_scan(strip_heredocs(command[:64 * 1024]), here, targets, 0)
|
|
363
365
|
return targets
|
|
364
366
|
|
|
365
367
|
|
|
368
|
+
HEREDOC = re.compile(r"<<-?\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\1")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def strip_heredocs(command):
|
|
372
|
+
"""Drop heredoc bodies before the command is scanned: their lines are data being written to a
|
|
373
|
+
file, not commands this session runs. Without this a `cd <clone>` inside a script being written
|
|
374
|
+
would publish that clone as presence and tell the sessions there not to change branch."""
|
|
375
|
+
out, rest = [], command
|
|
376
|
+
while True:
|
|
377
|
+
match = HEREDOC.search(rest)
|
|
378
|
+
if not match:
|
|
379
|
+
out.append(rest)
|
|
380
|
+
break
|
|
381
|
+
out.append(rest[: match.end()])
|
|
382
|
+
body = rest[match.end():]
|
|
383
|
+
end = re.search(r"^[ \t]*" + re.escape(match.group(2)) + r"[ \t]*$", body, re.MULTILINE)
|
|
384
|
+
if end is None: # unterminated: everything after the marker is body
|
|
385
|
+
break
|
|
386
|
+
rest = body[end.end():]
|
|
387
|
+
return "".join(out)
|
|
388
|
+
|
|
389
|
+
|
|
366
390
|
def _tokens(command):
|
|
367
391
|
"""Shell words and separators; a newline separates commands like ';'. Unbalanced quotes
|
|
368
392
|
(an apostrophe in a message) fall back to a plain split instead of giving up."""
|
|
@@ -376,22 +400,35 @@ def _tokens(command):
|
|
|
376
400
|
return [t for t in re.split(r"(\s+|[;&|()]+)", text) if t and not t.isspace()]
|
|
377
401
|
|
|
378
402
|
|
|
379
|
-
def _scan(command, here, targets, depth):
|
|
403
|
+
def _scan(command, here, targets, depth, visited=None):
|
|
380
404
|
segment = []
|
|
381
405
|
for token in _tokens(command) + [";"]:
|
|
382
406
|
if token and set(token) <= set(";&|()"):
|
|
383
|
-
here = _segment(segment, here, targets, depth)
|
|
407
|
+
here = _segment(segment, here, targets, depth, visited)
|
|
384
408
|
segment = []
|
|
385
409
|
else:
|
|
386
410
|
segment.append(token)
|
|
387
411
|
return here
|
|
388
412
|
|
|
389
413
|
|
|
414
|
+
def _note(visited, directory, source="cd"):
|
|
415
|
+
"""Collect a directory a command works in, with how it got there (never the command itself).
|
|
416
|
+
`git` outranks `cd` when the session has more clones than it can publish."""
|
|
417
|
+
if visited is None or not directory or not directory.startswith("/"):
|
|
418
|
+
return
|
|
419
|
+
if directory in visited:
|
|
420
|
+
if source == "git":
|
|
421
|
+
visited[directory] = "git"
|
|
422
|
+
return
|
|
423
|
+
if len(visited) < MAX_PATTERNS:
|
|
424
|
+
visited[directory] = source
|
|
425
|
+
|
|
426
|
+
|
|
390
427
|
WRAPPER_ARGS = {"sudo": ("-u", "-g", "-h", "-p", "-C", "-D", "-r", "-t"), "env": ("-u", "-C", "-S"), "nice": ("-n",),
|
|
391
428
|
"timeout": ("-s", "-k"), "time": (), "nohup": (), "command": (), "exec": ("-a",), "xargs": ()}
|
|
392
429
|
|
|
393
430
|
|
|
394
|
-
def _segment(words, here, targets, depth=0):
|
|
431
|
+
def _segment(words, here, targets, depth=0, visited=None):
|
|
395
432
|
"""One simple command: returns the directory after it (cd) and appends git work-tree targets."""
|
|
396
433
|
words = list(words)
|
|
397
434
|
while words:
|
|
@@ -412,12 +449,14 @@ def _segment(words, here, targets, depth=0):
|
|
|
412
449
|
if re.match(r"^(?:\S*/)?(?:ba|z|da|k)?sh$", words[0]) and "-c" in words[1:] and depth < 2:
|
|
413
450
|
index = words.index("-c")
|
|
414
451
|
if index + 1 < len(words):
|
|
415
|
-
_scan(words[index + 1], here, targets, depth + 1)
|
|
452
|
+
_scan(words[index + 1], here, targets, depth + 1, visited)
|
|
416
453
|
return here
|
|
417
454
|
if words[0] == "cd":
|
|
418
455
|
target = words[1] if len(words) > 1 else None
|
|
419
456
|
if target and not target.startswith("-"):
|
|
420
|
-
|
|
457
|
+
moved = os.path.normpath(os.path.join(here or "/", os.path.expanduser(target))) if (here or target.startswith("/")) else None
|
|
458
|
+
_note(visited, moved)
|
|
459
|
+
return moved
|
|
421
460
|
return here
|
|
422
461
|
if not GIT_WORD.match(words[0]):
|
|
423
462
|
return here
|
|
@@ -443,12 +482,56 @@ def _segment(words, here, targets, depth=0):
|
|
|
443
482
|
if i >= len(words):
|
|
444
483
|
return here
|
|
445
484
|
sub, rest = words[i], words[i + 1:]
|
|
485
|
+
_note(visited, directory, "git") # any git call names the clone the session is working in
|
|
446
486
|
if sub in TREE_SUBCOMMANDS or (sub == "reset" and "--hard" in rest):
|
|
447
487
|
if directory and directory not in targets:
|
|
448
488
|
targets.append(directory)
|
|
449
489
|
return here
|
|
450
490
|
|
|
451
491
|
|
|
492
|
+
def shell_dirs(host, tool, tool_input, cwd=None, with_source=False):
|
|
493
|
+
"""Directories other than its own cwd where a shell call works (`cd DIR`, `git -C DIR`,
|
|
494
|
+
`git --work-tree DIR`); [] for anything else. Only directories leave this function: the
|
|
495
|
+
command is read here and never stored. They become the session's published clones, so a
|
|
496
|
+
session driving a repository it did not start in stops being invisible to the others.
|
|
497
|
+
with_source: return {directory: "git"|"cd"} instead of a list, so the presence can keep the
|
|
498
|
+
clone a git command drove rather than the last `cd` the session happened to run."""
|
|
499
|
+
if tool not in SHELL_TOOLS or not isinstance(tool_input, dict):
|
|
500
|
+
return []
|
|
501
|
+
command = tool_input.get("command") or tool_input.get("cmd") or tool_input.get("CommandLine") or tool_input.get("commandLine")
|
|
502
|
+
if isinstance(command, list):
|
|
503
|
+
command = " ".join(str(x) for x in command if isinstance(x, str))
|
|
504
|
+
if not isinstance(command, str) or not DIR_WORD.search(command[:64 * 1024]):
|
|
505
|
+
return {} if with_source else [] # same short circuit tree_command has: no `cd`/`git` word
|
|
506
|
+
here = cwd if isinstance(cwd, str) and cwd.startswith("/") else None
|
|
507
|
+
visited = {}
|
|
508
|
+
_scan(strip_heredocs(command[:64 * 1024]), here, [], 0, visited)
|
|
509
|
+
found = [d for d in visited if d != here][:MAX_REPOS]
|
|
510
|
+
return {d: visited[d] for d in found} if with_source else found
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def shell_scan(host, tool, tool_input, cwd=None):
|
|
514
|
+
"""tree_command and shell_dirs in ONE parse: (work-tree targets, {directory: "git"|"cd"}).
|
|
515
|
+
|
|
516
|
+
The hook needs both on every shell event and tokenising is the whole cost, so a big command
|
|
517
|
+
line is never walked twice. Equivalent to calling the two functions separately."""
|
|
518
|
+
if tool not in SHELL_TOOLS or not isinstance(tool_input, dict):
|
|
519
|
+
return [], {}
|
|
520
|
+
command = tool_input.get("command") or tool_input.get("cmd") or tool_input.get("CommandLine") or tool_input.get("commandLine")
|
|
521
|
+
if isinstance(command, list):
|
|
522
|
+
command = " ".join(str(x) for x in command if isinstance(x, str))
|
|
523
|
+
if not isinstance(command, str):
|
|
524
|
+
return [], {}
|
|
525
|
+
command = command[:64 * 1024]
|
|
526
|
+
if not DIR_WORD.search(command):
|
|
527
|
+
return [], {}
|
|
528
|
+
here = cwd if isinstance(cwd, str) and cwd.startswith("/") else None
|
|
529
|
+
targets, visited = [], {}
|
|
530
|
+
_scan(strip_heredocs(command), here, targets, 0, visited)
|
|
531
|
+
found = [d for d in visited if d != here][:MAX_REPOS]
|
|
532
|
+
return targets, {d: visited[d] for d in found}
|
|
533
|
+
|
|
534
|
+
|
|
452
535
|
def pid_alive(pid):
|
|
453
536
|
if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 1:
|
|
454
537
|
return None
|
|
@@ -496,11 +579,13 @@ def tty_of(pid, runner=None):
|
|
|
496
579
|
return tty_lookup(pid, runner)[0]
|
|
497
580
|
|
|
498
581
|
|
|
499
|
-
def fold(snap, event, touched=(), now=None, ppid=None, cli_pid=None, tty_runner=None):
|
|
582
|
+
def fold(snap, event, touched=(), now=None, ppid=None, cli_pid=None, tty_runner=None, dirs=()):
|
|
500
583
|
"""Update the session snapshot's presence object (in place) for one event.
|
|
501
584
|
|
|
502
585
|
cli_pid: the agent CLI resolved from a process table (Stop/SessionEnd); once known and alive it
|
|
503
|
-
is the presence pid, so a hook started through a transient ``sh -c`` does not replace it.
|
|
586
|
+
is the presence pid, so a hook started through a transient ``sh -c`` does not replace it.
|
|
587
|
+
dirs: directories a shell call worked in (crow_broadcast.shell_dirs); their clones are published
|
|
588
|
+
beside the cwd clone, so a session driving another repository is visible to the sessions there."""
|
|
504
589
|
now = now or time.time()
|
|
505
590
|
presence = snap.get("presence") if isinstance(snap.get("presence"), dict) else {}
|
|
506
591
|
if cli_pid:
|
|
@@ -554,6 +639,29 @@ def fold(snap, event, touched=(), now=None, ppid=None, cli_pid=None, tty_runner=
|
|
|
554
639
|
entry["rel"] = relative(path, target["root"])
|
|
555
640
|
files.append(entry)
|
|
556
641
|
presence["files"] = files[-MAX_FILES:]
|
|
642
|
+
# Clones this session reaches outside its cwd: shell directories, and the clone of every file it
|
|
643
|
+
# edited. Same TTL as the paths: an old visit stops counting as presence.
|
|
644
|
+
repos = [r for r in presence.get("repos") or [] if isinstance(r, dict) and isinstance(r.get("root"), str)
|
|
645
|
+
and now - (parse_time(r.get("ts")) or 0) <= FILE_TTL]
|
|
646
|
+
# Only the shell directories of THIS event: the clones of edited files are already derived from
|
|
647
|
+
# the paths themselves (repo_scope), so no event pays a filesystem walk per remembered file.
|
|
648
|
+
for directory in [d for d in (dirs or []) if isinstance(d, str)]:
|
|
649
|
+
found = repo_info(directory)
|
|
650
|
+
if not found or (repo and found["root"] == repo["root"]):
|
|
651
|
+
continue
|
|
652
|
+
previous = next((r for r in repos if r["root"] == found["root"]), None)
|
|
653
|
+
repos = [r for r in repos if r["root"] != found["root"]]
|
|
654
|
+
entry = {"id": found["id"], "root": found["root"], "ts": now_iso(now),
|
|
655
|
+
"hits": int((previous or {}).get("hits") or 0) + 1}
|
|
656
|
+
if (previous or {}).get("driven") or (isinstance(dirs, dict) and dirs.get(directory) == "git"):
|
|
657
|
+
entry["driven"] = True
|
|
658
|
+
repos.append(entry)
|
|
659
|
+
if repos or presence.get("repos") is not None:
|
|
660
|
+
# Eviction is not FIFO: a clone a git command actually drove, and one visited many times,
|
|
661
|
+
# outrank the incidental `cd` that would otherwise push it out of the eight slots.
|
|
662
|
+
keep = sorted(repos, key=lambda r: (bool(r.get("driven")), int(r.get("hits") or 0),
|
|
663
|
+
parse_time(r.get("ts")) or 0), reverse=True)[:MAX_REPOS]
|
|
664
|
+
presence["repos"] = [r for r in repos if r in keep]
|
|
557
665
|
snap["presence"] = presence
|
|
558
666
|
return snap
|
|
559
667
|
|
|
@@ -630,6 +738,45 @@ def recent_files(snap, now, window=None):
|
|
|
630
738
|
return out
|
|
631
739
|
|
|
632
740
|
|
|
741
|
+
def repo_scope(snap, now=None, window=None):
|
|
742
|
+
"""Every repository a session works in, not only the clone it was started from: its cwd clone,
|
|
743
|
+
the clones its shell calls reached and the clones of the files it touched. Returns
|
|
744
|
+
(repository ids, clone roots). This is what makes a session that edits or drives another
|
|
745
|
+
repository visible to the sessions working there."""
|
|
746
|
+
now = now or time.time()
|
|
747
|
+
window = FILE_TTL if window is None else window
|
|
748
|
+
ids, roots = set(), set()
|
|
749
|
+
presence = presence_of(snap)
|
|
750
|
+
if not isinstance(presence.get("repos"), list):
|
|
751
|
+
presence = dict(presence, repos=[])
|
|
752
|
+
repo = snap.get("repo") if isinstance(snap.get("repo"), dict) else None
|
|
753
|
+
if repo is None:
|
|
754
|
+
repo = presence.get("repo") if isinstance(presence.get("repo"), dict) else None
|
|
755
|
+
if repo is None and snap.get("cwd"):
|
|
756
|
+
repo = repo_info(snap["cwd"])
|
|
757
|
+
if isinstance(repo, dict):
|
|
758
|
+
if repo.get("id"):
|
|
759
|
+
ids.add(repo["id"])
|
|
760
|
+
if repo.get("root"):
|
|
761
|
+
roots.add(repo["root"])
|
|
762
|
+
for entry in presence.get("repos") or []:
|
|
763
|
+
if not isinstance(entry, dict) or now - (parse_time(entry.get("ts")) or 0) > window:
|
|
764
|
+
continue
|
|
765
|
+
if isinstance(entry.get("id"), str):
|
|
766
|
+
ids.add(entry["id"])
|
|
767
|
+
if isinstance(entry.get("root"), str):
|
|
768
|
+
roots.add(entry["root"])
|
|
769
|
+
for item in recent_files(snap, now, window):
|
|
770
|
+
if isinstance(item.get("repo"), str):
|
|
771
|
+
ids.add(item["repo"])
|
|
772
|
+
rel, path = item.get("rel"), item.get("path")
|
|
773
|
+
if isinstance(rel, str) and rel and isinstance(path, str) and path.endswith(rel):
|
|
774
|
+
root = path[: len(path) - len(rel)].rstrip("/")
|
|
775
|
+
if root:
|
|
776
|
+
roots.add(root)
|
|
777
|
+
return ids, roots
|
|
778
|
+
|
|
779
|
+
|
|
633
780
|
def same_file(a, b):
|
|
634
781
|
"""Two touched-file entries name the same file: same path, or same repo id and repo-relative path."""
|
|
635
782
|
if a.get("path") == b.get("path"):
|
|
@@ -1080,12 +1227,19 @@ def git_branch(root):
|
|
|
1080
1227
|
# --- relevance ------------------------------------------------------------------------------
|
|
1081
1228
|
|
|
1082
1229
|
def relations(me, other, now, cfg):
|
|
1083
|
-
"""Why another session matters to me:
|
|
1230
|
+
"""Why another session matters to me: shared clone, shared repository, same branch, overlapping
|
|
1231
|
+
files. Shared is computed over everything each side works in (repo_scope), not only the clone it
|
|
1232
|
+
was started from: a session whose cwd is elsewhere but that edits or drives files in my
|
|
1233
|
+
repository is relevant, which is the case the cwd-only comparison used to miss."""
|
|
1084
1234
|
mine, theirs = me.get("repo") or {}, other.get("repo") or {}
|
|
1085
|
-
|
|
1086
|
-
|
|
1235
|
+
my_ids, my_roots = repo_scope(me, now)
|
|
1236
|
+
their_ids, their_roots = repo_scope(other, now)
|
|
1237
|
+
shared_roots = sorted(my_roots & their_roots)
|
|
1238
|
+
rel = {"sameRepo": bool(my_ids & their_ids), "sameClone": bool(shared_roots),
|
|
1239
|
+
"sharedRepos": sorted(my_ids & their_ids), "sharedClones": shared_roots,
|
|
1087
1240
|
"sameBranch": False, "overlap": []}
|
|
1088
|
-
rel["sameBranch"] =
|
|
1241
|
+
rel["sameBranch"] = bool(mine.get("id") and mine.get("id") == theirs.get("id") and me.get("branch")
|
|
1242
|
+
and me.get("branch") == other.get("branch"))
|
|
1089
1243
|
window = cfg["conflict_window_minutes"] * 60
|
|
1090
1244
|
my_files = recent_files(me, now, FILE_TTL)
|
|
1091
1245
|
for theirs_file in recent_files(other, now, window):
|
|
@@ -1123,10 +1277,12 @@ def overview(home, me_key=None, me=None, cwd=None, now=None, cfg=None):
|
|
|
1123
1277
|
item["pid"] = presence.get("pid") if presence.get("pidStable") else None
|
|
1124
1278
|
item["files"] = [{"path": f["path"], "rel": f.get("rel"), "repo": f.get("repo"), "ts": f.get("ts"), "tool": f.get("tool")}
|
|
1125
1279
|
for f in recent_files(s, now, FILE_TTL)][-10:]
|
|
1280
|
+
item["repos"] = sorted(repo_scope(s, now)[0])
|
|
1126
1281
|
item["relation"] = relations(me, s, now, cfg) if me else None
|
|
1127
1282
|
sessions.append(item)
|
|
1128
1283
|
return {"schema": BROADCAST_SCHEMA, "generatedAt": now_iso(now), "config": {k: cfg[k] for k in DEFAULTS},
|
|
1129
|
-
"me": {"key": me_key, "cwd": (me or {}).get("cwd"), "repo": (me or {}).get("repo"), "branch": (me or {}).get("branch")
|
|
1284
|
+
"me": {"key": me_key, "cwd": (me or {}).get("cwd"), "repo": (me or {}).get("repo"), "branch": (me or {}).get("branch"),
|
|
1285
|
+
"repos": sorted(repo_scope(me, now)[0])}
|
|
1130
1286
|
if me else None,
|
|
1131
1287
|
"sessions": sessions, "claims": claims, "_live": live}
|
|
1132
1288
|
|
|
@@ -1155,7 +1311,8 @@ def session_lines(s, home, now, with_files=True):
|
|
|
1155
1311
|
rel = s.get("relation") or {}
|
|
1156
1312
|
state = "bloqueada" if s.get("blocked") else {"working": "trabalhando", "idle": "ociosa", "blocked": "bloqueada",
|
|
1157
1313
|
"error": "com erro"}.get(s.get("state"), clean(s.get("state") or "?", 12))
|
|
1158
|
-
|
|
1314
|
+
root = (s.get("repo") or {}).get("root") or s.get("cwd") or "?"
|
|
1315
|
+
where = ("MESMO clone " if root in (rel.get("sharedClones") or []) else "clone ") + short_path(root)
|
|
1159
1316
|
head = "- " + clean(s.get("host"), 12) + " " + state + " · branch " + clean(s.get("branch") or "?", 60) + \
|
|
1160
1317
|
(" (a mesma)" if rel.get("sameBranch") else "") + " · " + where + " · " + age_text(s.get("ageSeconds"))
|
|
1161
1318
|
lines = [head[:LINE_CHARS]]
|
|
@@ -1170,6 +1327,10 @@ def session_lines(s, home, now, with_files=True):
|
|
|
1170
1327
|
names = [clean(f["rel"], 80) if f.get("rel") else short_path(f["path"], 60) for f in reversed(files)]
|
|
1171
1328
|
shown = ", ".join(names[:4]) + (" (+" + str(len(names) - 4) + ")" if len(names) > 4 else "")
|
|
1172
1329
|
lines.append((" arquivos editados: " + shown)[:LINE_CHARS])
|
|
1330
|
+
shared = [r for r in (rel.get("sharedRepos") or []) if r != (s.get("repo") or {}).get("id")]
|
|
1331
|
+
if shared:
|
|
1332
|
+
lines.append((" TAMBÉM trabalha em repositório(s) deste trabalho: " +
|
|
1333
|
+
", ".join(clean(r, 60) for r in shared[:3]))[:LINE_CHARS])
|
|
1173
1334
|
if rel.get("overlap"):
|
|
1174
1335
|
lines.append((" SOBREPOSIÇÃO com arquivos desta sessão: " + ", ".join(clean(x, 80) for x in rel["overlap"][:4]))[:LINE_CHARS])
|
|
1175
1336
|
return lines
|
|
@@ -1193,22 +1354,34 @@ FOOTER = ("Não edite arquivos em uso por outra sessão nem troque de branch num
|
|
|
1193
1354
|
"usuário; reserve a área antes de delegar edições (crow-skills broadcast claim). Detalhe: crow-skills broadcast status --json.")
|
|
1194
1355
|
|
|
1195
1356
|
|
|
1357
|
+
def quiet_line(total, scope):
|
|
1358
|
+
"""The one-line form: the check ran and found nothing that touches this work. It is always
|
|
1359
|
+
emitted so a session never has to remember to ask; the absence of the block is now a fact,
|
|
1360
|
+
not a doubt."""
|
|
1361
|
+
if not total:
|
|
1362
|
+
return ("[crow-broadcast] Nenhuma outra sessão de agente viva nesta máquina agora. "
|
|
1363
|
+
"Detalhe a qualquer momento: crow-skills broadcast status --json.")
|
|
1364
|
+
where = "nenhuma nos repositórios deste trabalho" if scope else "e esta sessão não está num repositório conhecido"
|
|
1365
|
+
return ("[crow-broadcast] " + str(total) + " outra(s) sessão(ões) de agente viva(s) nesta máquina, " + where +
|
|
1366
|
+
" · antes de editar em outro clone: crow-skills broadcast status --json.")[:LINE_CHARS]
|
|
1367
|
+
|
|
1368
|
+
|
|
1196
1369
|
def digest(home, me_key, me=None, now=None, cfg=None):
|
|
1197
|
-
"""The prompt-hook block
|
|
1370
|
+
"""The prompt-hook block. Always present while the broadcast is enabled: the full block when
|
|
1371
|
+
another live session touches this work, otherwise one line saying the check ran."""
|
|
1198
1372
|
cfg = cfg or config(home)
|
|
1199
1373
|
if not cfg["enabled"]:
|
|
1200
1374
|
return None
|
|
1201
1375
|
now = now or time.time()
|
|
1202
1376
|
view = overview(home, me_key, me=me, now=now, cfg=cfg)
|
|
1203
|
-
if view["me"] is None:
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
my_repo = (view["me"].get("repo") or {}).get("id")
|
|
1377
|
+
scope = set() if view["me"] is None else repo_scope(me or {"cwd": view["me"].get("cwd"), "repo": view["me"].get("repo")}, now)[0]
|
|
1378
|
+
others = relevant(view) if view["me"] is not None else []
|
|
1379
|
+
my_repos = scope
|
|
1207
1380
|
live_by_key = {s.get("key"): s for s in view["_live"]}
|
|
1208
|
-
claims = [c for c in view["claims"] if (c.get("repo") or {}).get("id")
|
|
1381
|
+
claims = [c for c in view["claims"] if (c.get("repo") or {}).get("id") in my_repos and my_repos
|
|
1209
1382
|
and (c.get("owner") or {}).get("key") != me_key]
|
|
1210
1383
|
if not others and not claims:
|
|
1211
|
-
return
|
|
1384
|
+
return quiet_line(len(view["sessions"]), scope)
|
|
1212
1385
|
lines = [HEADER]
|
|
1213
1386
|
limit = int(cfg["digest_max_sessions"])
|
|
1214
1387
|
for s in others[:limit]:
|
|
@@ -1217,12 +1390,18 @@ def digest(home, me_key, me=None, now=None, cfg=None):
|
|
|
1217
1390
|
lines.append("- +" + str(len(others) - limit) + " outra(s) sessão(ões); veja crow-skills broadcast status")
|
|
1218
1391
|
for claim in claims[:4]:
|
|
1219
1392
|
lines.append(claim_line(claim, live_by_key, now))
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1393
|
+
my_root = (view["me"].get("repo") or {}).get("root")
|
|
1394
|
+
tail = []
|
|
1395
|
+
if my_root and any(my_root in ((s.get("relation") or {}).get("sharedClones") or []) for s in others):
|
|
1396
|
+
tail.append("ATENÇÃO: outra sessão viva trabalha neste mesmo clone (cwd, edição ou comando); "
|
|
1397
|
+
"não faça checkout/switch/rebase/stash aqui.")
|
|
1398
|
+
tail.append(FOOTER)
|
|
1399
|
+
# The warning and the footer are never what gets cut: truncation eats the session list.
|
|
1400
|
+
text = "\n".join(lines + tail)
|
|
1224
1401
|
if len(text) > DIGEST_CHARS:
|
|
1225
|
-
|
|
1402
|
+
room = DIGEST_CHARS - len("\n".join(tail)) - 3
|
|
1403
|
+
kept = "\n".join(lines)[:max(0, room)].rsplit("\n", 1)[0] if room > 0 else lines[0][:max(0, room)]
|
|
1404
|
+
text = kept + "\n…\n" + "\n".join(tail)
|
|
1226
1405
|
return text
|
|
1227
1406
|
|
|
1228
1407
|
|
|
@@ -1263,8 +1442,8 @@ def check(home, me_key, me, paths=(), tree_change=(), now=None, cfg=None):
|
|
|
1263
1442
|
warnings.append("- " + clean(", ".join(hits[:4]), 160) + ": editado " + age_text(s.get("ageSeconds")) + " por " +
|
|
1264
1443
|
clean(s.get("host"), 12) + " (branch " + clean(s.get("branch") or "?", 60) + ", " +
|
|
1265
1444
|
short_path((s.get("repo") or {}).get("root") or s.get("cwd") or "?") + ")")
|
|
1266
|
-
theirs_root = (s
|
|
1267
|
-
if theirs_root
|
|
1445
|
+
theirs_root = next((r for r in sorted(repo_scope(s, now)[1]) if r in tree_roots), None)
|
|
1446
|
+
if theirs_root:
|
|
1268
1447
|
warnings.append("- o clone " + short_path(theirs_root) + " está em uso por " + clean(s.get("host"), 12) +
|
|
1269
1448
|
" na branch " + clean(s.get("branch") or "?", 60) + "; trocar de branch, rebase, stash ou reset aqui "
|
|
1270
1449
|
"muda os arquivos que ela está usando")
|