@andespindola/crow-skills 0.11.0 → 0.12.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.
@@ -187,17 +187,39 @@ def managed_skills(agent, home, store):
187
187
  target = destinations(agent, home)[0]
188
188
  rows = store.status([target], ())
189
189
  store_ok = store_managed(home, store) if any(r["status"] == "uniflow_link" for r in rows) else set()
190
- return sorted(r["name"] for r in rows if r["status"] in ("installed", "modified")
191
- or (r["status"] == "uniflow_link" and r["name"] in store_ok))
190
+ names = {r["name"] for r in rows if r["status"] in ("installed", "modified")
191
+ or (r["status"] == "uniflow_link" and r["name"] in store_ok)}
192
+ from .projection import projected
193
+ return sorted(names | projected(agent, home, store)) # codex: Uniflow's links in ~/.codex/skills
192
194
 
193
195
 
194
- def store_managed(home, store):
195
- """Names whose Uniflow store copy is a crow-skills install (a record, files installed or edited).
196
- A link to a store entry the user wrote by hand is not a crow-skills install for the CLI."""
196
+ def store_statuses(home, store):
197
+ """{name: status} of the Uniflow store (crow-skills view); {} when it cannot be read."""
197
198
  try:
198
- return {r["name"] for r in store.status(destinations("uniflow", home), ()) if r["status"] in ("installed", "modified")}
199
+ return {r["name"]: r["status"] for r in store.status(destinations("uniflow", home), ()) if r["name"]}
199
200
  except (CrowError, OSError):
200
- return set()
201
+ return {}
202
+
203
+
204
+ def store_managed(home, store):
205
+ """Names whose Uniflow store copy is an intact crow-skills install. A link to a store entry the user wrote
206
+ by hand, or to a managed entry that was edited or lost files (a CLI may fail to load it), does not count
207
+ as an install for the CLI."""
208
+ return {n for n, s in store_statuses(home, store).items() if s == "installed"}
209
+
210
+
211
+ def store_broken(home, store):
212
+ """Managed store entries that were edited or lost files (status modified/missing)."""
213
+ return {n for n, s in store_statuses(home, store).items() if s in ("modified", "missing")}
214
+
215
+
216
+ BROKEN_REASON = ("a entrada do store do Uniflow foi editada ou perdeu arquivos; o CLI pode não carregá-la, então não conta "
217
+ "como instalada")
218
+
219
+
220
+ def broken_fix(name):
221
+ return ("revise o store e, para restaurá-lo com backup: crow-skills update " + name +
222
+ " --agents uniflow --global --overwrite-modified --yes")
201
223
 
202
224
 
203
225
  def present_skills(agent, home, store, catalog):
@@ -27,6 +27,12 @@ live sessions in the same repository, only when there are any; ``--check``
27
27
  live session on the same file or clone without writing anything.
28
28
 
29
29
  crow_hook.py hook --host HOST --event PreToolUse --check [--home DIR]
30
+
31
+ On Stop and SessionEnd the hook also records the live descendants of the
32
+ session's CLI (pid, start time, executable plus package/script name; never
33
+ arguments) in the crow-broadcast state, and at most every 10 minutes refreshes
34
+ the orphan count (crow-skills resources --orphans) that the memory line cites
35
+ under pressure: one ps call on those events, none on prompts.
30
36
  """
31
37
  from datetime import datetime, timezone
32
38
  import hashlib
@@ -236,11 +242,48 @@ def resource_hint(home):
236
242
  if module is None:
237
243
  return None
238
244
  try:
239
- return module.hint(module.probe(home, processes=False, use_cache=True))
245
+ text = module.hint(module.probe(home, processes=False, use_cache=True))
246
+ if text and hasattr(module, "orphan_hint"):
247
+ text += module.orphan_hint(home) # last orphan scan, a file read: no ps call per prompt
248
+ return text
240
249
  except Exception: # noqa: BLE001
241
250
  return None
242
251
 
243
252
 
253
+ STOP_EVENTS = ("Stop", "AfterAgent", "PostInvocation", "SessionEnd")
254
+ ORPHAN_REFRESH = 600 # seconds: a Stop event rescans orphans at most this often (it already has the table)
255
+
256
+
257
+ def record_processes(home, host, event, ppid=None):
258
+ """Stop/SessionEnd: record the CLI's live descendants in crow-broadcast state (pid, start,
259
+ redacted command), so a later orphan check can attribute leftovers to a dead session; and
260
+ refresh the orphan count the pressure hint reads. One ps call; never raises."""
261
+ module, probe = broadcast_module(), resources_module()
262
+ if module is None or probe is None or not hasattr(probe, "read_table") or not hasattr(module, "record_processes"):
263
+ return None
264
+ try:
265
+ cfg = module.config(home)
266
+ if not cfg["enabled"]:
267
+ return None
268
+ rows = probe.read_table()
269
+ if not rows:
270
+ return None
271
+ now = time.time()
272
+ cli = probe.hook_cli_pid(rows, os.getppid() if ppid is None else ppid)
273
+ row = next((r for r in rows if r["pid"] == cli), None)
274
+ if row is None or cli <= 1:
275
+ return None
276
+ key = session_key(host, event.get("session"), event.get("cwd"))
277
+ module.record_processes(home, key, host, cli, probe.start_of(row, now),
278
+ probe.session_descendants(rows, cli, now=now), now)
279
+ if probe.read_orphan_cache(home, max_age=ORPHAN_REFRESH) is None:
280
+ doc = probe.orphan_scan(home, rows=rows, context=module.process_context(home, cfg, now, event.get("cwd")), now=now)
281
+ probe.write_orphan_cache(home, doc)
282
+ return cli
283
+ except Exception: # noqa: BLE001 - housekeeping never breaks the event
284
+ return None
285
+
286
+
244
287
  # --- event bus ----------------------------------------------------------------------------
245
288
 
246
289
  def append_event(home, event):
@@ -373,9 +416,12 @@ def update_session(home, event, extra=None, fallback_cwd=None, presence=None):
373
416
  if extra:
374
417
  snap.update(extra)
375
418
  if presence:
376
- module, touched, ppid = presence
419
+ module, touched, ppid = presence[:3]
377
420
  try:
378
- module.fold(snap, event, touched, ppid=ppid)
421
+ if len(presence) > 3: # (…, cli pid resolved from the process table on Stop/SessionEnd)
422
+ module.fold(snap, event, touched, ppid=ppid, cli_pid=presence[3])
423
+ else:
424
+ module.fold(snap, event, touched, ppid=ppid)
379
425
  except Exception: # noqa: BLE001 - presence is best effort
380
426
  pass
381
427
  write_json(path, snap)
@@ -696,6 +742,15 @@ def handle(host, raw, payload, home, env=None, check=False):
696
742
  except Exception: # noqa: BLE001 - the broadcast never breaks the event
697
743
  module, presence = None, None
698
744
  stamped, snap = record(home, dict(event, mode=current["mode"]), snapshot=True, fallback_cwd=process_cwd(), presence=presence)
745
+ if raw in STOP_EVENTS:
746
+ cli = record_processes(home, host, event)
747
+ if cli and snap is not None:
748
+ bmod = broadcast_module()
749
+ if bmod is not None:
750
+ try: # the resolved CLI pid becomes the presence pid (a hook's sh -c parent is transient)
751
+ update_session(home, dict(stamped, event="other"), presence=(bmod, [], None, cli))
752
+ except Exception: # noqa: BLE001
753
+ pass
699
754
  if module is not None and host in SYNC_TOOL_HOSTS:
700
755
  if raw in TOOL_START_EVENTS and (touched or tree or checked):
701
756
  return broadcast_check(host, raw, home, module, snap, checked or touched, tree)
@@ -848,6 +903,8 @@ def run_main(args):
848
903
  if module is not None:
849
904
  try:
850
905
  module.prune_claims(home)
906
+ if hasattr(module, "prune_processes"):
907
+ module.prune_processes(home)
851
908
  except Exception: # noqa: BLE001 - pruning is housekeeping; the output already went out
852
909
  pass
853
910
  except Exception: # noqa: BLE001 - a hook must never break the agent
@@ -173,6 +173,7 @@ def classify(store, target, names, agent, replace, overwrite, store_names=()):
173
173
  pending = set(store_names) if projected else set()
174
174
  store_ok = guide.store_managed(store.home, store) if projected else set()
175
175
  disabled = store_disabled(store.home, store) if projected else set()
176
+ broken = guide.store_broken(store.home, store) if projected else set()
176
177
  for name in names:
177
178
  row = rows.get(name) or {"status": "absent"}
178
179
  status = row["status"]
@@ -181,6 +182,9 @@ def classify(store, target, names, agent, replace, overwrite, store_names=()):
181
182
  elif status == "uniflow_link" and name in disabled:
182
183
  kept.append({"name": name, "status": "uniflow_link_disabled", "reason": DISABLED_REASON,
183
184
  "fix": "reabilite a skill no Uniflow; o crow-skills mantém a desabilitação"})
185
+ elif status == "uniflow_link" and name in broken:
186
+ kept.append({"name": name, "status": "uniflow_link_modified", "reason": guide.BROKEN_REASON,
187
+ "fix": guide.broken_fix(name)})
184
188
  elif status == "uniflow_link":
185
189
  kept.append({"name": name, "status": "uniflow_link_unmanaged",
186
190
  "reason": "link do Uniflow para um pacote do store que o crow-skills não gerencia; não conta como instalada",
@@ -240,6 +244,8 @@ def configure_agent(args, home, store, agent, names, dry_run, store_names=()):
240
244
  try:
241
245
  install, entry["kept"], entry["via_uniflow"], waiting = classify(store, target, names, agent, replace, overwrite,
242
246
  store_names if agent != "uniflow" else ())
247
+ if agent == "codex":
248
+ install = codex_projection_step(args, home, store, entry, names, install, waiting, store_names, dry_run)
243
249
  if waiting:
244
250
  entry["via_uniflow_pending"] = waiting
245
251
  if not install:
@@ -272,6 +278,60 @@ def configure_agent(args, home, store, agent, names, dry_run, store_names=()):
272
278
  return entry
273
279
 
274
280
 
281
+ DUPLICATE = "removida: duplicava a projeção do Uniflow"
282
+
283
+
284
+ def codex_projection_step(args, home, store, entry, names, install, waiting, store_names, dry_run):
285
+ """Codex sees ~/.codex/skills (Uniflow's links to its store) and ~/.agents/skills (our copies) and lists a
286
+ same-name skill twice. A name Codex sees via a managed projection gets no native copy, and an intact
287
+ managed native copy of it is removed through uninstall (backup, transaction, rollback). Returns the
288
+ names still to install natively."""
289
+ from . import projection
290
+ for name, kind in sorted(projection.links(home, store).items()):
291
+ if kind == "modified" and name in names:
292
+ entry["kept"].append({"name": name, "status": "uniflow_link_modified",
293
+ "reason": guide.BROKEN_REASON + "; a cópia nativa em ~/.agents/skills continua valendo",
294
+ "fix": guide.broken_fix(name)})
295
+ projected = projection.projected("codex", home, store)
296
+ if not projected:
297
+ return install
298
+ selected = set(names)
299
+ # Names on their way into the store reach Codex by the same projection on its next start.
300
+ pending = (set(store_names) - projected - {"crow-behaviour"}) & set(install)
301
+ pending = {n for n in pending if n not in entry["unchanged"]}
302
+ native = {r["name"]: r["status"] for r in store.status([destinations("codex", home)[0]], sorted(pending)) if r["name"]}
303
+ pending = {n for n in pending if native.get(n, "absent") == "absent"}
304
+ entry["via_uniflow"] = sorted(set(entry["via_uniflow"]) | (projected & selected) | pending)
305
+ waiting.extend(sorted(pending))
306
+ dupes = projection.duplicates(home, store)
307
+ overwrite = bool(getattr(args, "overwrite_modified", False))
308
+ remove = [n for n in dupes["remove"] + (dupes["modified"] if overwrite else []) if n in selected]
309
+ entry["kept"] = [k for k in entry["kept"] if k["name"] not in projected]
310
+ for name in dupes["modified"]:
311
+ if name in selected and not overwrite:
312
+ entry["kept"].append({"name": name, "status": "duplicate_modified",
313
+ "reason": "cópia nativa editada que duplica a projeção do Uniflow no Codex",
314
+ "fix": "revise e, para remover com backup: crow-skills setup --agents codex --overwrite-modified --yes"})
315
+ for name in dupes["shared"]:
316
+ if name in selected:
317
+ entry["kept"].append({"name": name, "status": "duplicate_shared",
318
+ "reason": "cópia nativa também registrada para shared (diretório lido por outros hosts); "
319
+ "mantida, e o Codex a vê duas vezes",
320
+ "fix": "se nenhum outro host precisa dela: crow-skills uninstall " + name +
321
+ " --agents codex,shared --global --yes"})
322
+ if remove:
323
+ cli = _cli()
324
+ argv = ["uninstall", *remove, "--agents", "codex", "--global", "--json", "--home", str(home),
325
+ "--dry-run" if dry_run else "--yes"]
326
+ if overwrite:
327
+ argv.append("--overwrite-modified")
328
+ argv.append("--no-integrations") # hooks/trust follow in the install/refresh step, with the run's --no-trust
329
+ data = cli.run(cli.parser().parse_args(argv))
330
+ entry["removed"] = [{"name": c["name"], "reason": DUPLICATE} for c in data["changes"] if c["action"] != "unchanged"]
331
+ entry["removal_transaction"] = data.get("transaction")
332
+ return [n for n in install if n not in projected and n not in pending]
333
+
334
+
275
335
  def machine_steps(args, home, store, dry_run):
276
336
  steps = {}
277
337
  # Crow mode: written as on only when the user never chose (no mode file).
@@ -409,6 +469,8 @@ def summarize(detected, entries, store, home, probe, dry_run, trust=True):
409
469
  parts.append(str(unchanged) + " já em dia")
410
470
  if entry.get("via_uniflow"):
411
471
  parts.append(str(len(entry["via_uniflow"])) + " via uniflow")
472
+ if entry.get("removed"):
473
+ parts.append(str(len(entry["removed"])) + (" a remover" if dry_run else " removidas") + " (duplicavam o Uniflow)")
412
474
  if kept:
413
475
  parts.append(str(len(kept)) + " mantidas")
414
476
  item["skills"] = ", ".join(parts) or "—"
@@ -466,7 +528,7 @@ def summarize(detected, entries, store, home, probe, dry_run, trust=True):
466
528
  elif kept or state in ("warn", "absent"):
467
529
  item["state"] = "warn"
468
530
  else:
469
- item["state"] = "plan" if dry_run and (entry["changed"] or real_changes(entry.get("integrations"))) else "ok"
531
+ item["state"] = "plan" if dry_run and (entry["changed"] or entry.get("removed") or real_changes(entry.get("integrations"))) else "ok"
470
532
  summary.append(item)
471
533
  return summary
472
534
 
@@ -497,7 +559,7 @@ def setup(args, home, store, interactive):
497
559
  entries, steps = build(True)
498
560
  result = {"version": __version__, "revision": source.revision, "skills": names, "detected": detected, "agents": agents,
499
561
  "absent": [r["id"] for r in detected if not r["detected"]], "trust": not getattr(args, "no_trust", False)}
500
- pending = any(e["changed"] or real_changes(e.get("integrations")) or real_changes(e.get("guides"))
562
+ pending = any(e["changed"] or e.get("removed") or real_changes(e.get("integrations")) or real_changes(e.get("guides"))
501
563
  for e in entries) or any(s["state"] == "plan" for s in steps.values())
502
564
  if dry_run or not agents:
503
565
  summary = summarize(detected, entries, store, home, False, True)
@@ -564,11 +626,21 @@ def upgrade_summary(skills, dry_run):
564
626
  agent = s["agent"]
565
627
  item = {"agent": agent, "label": AGENT_LABELS.get(agent, agent), "changed": s.get("updated") or [],
566
628
  "unchanged": s.get("unchanged", 0), "kept": s.get("kept") or [], "via_uniflow": len(s.get("via_uniflow") or []),
629
+ "removed": [r["name"] for r in s.get("removed") or []],
567
630
  "needs_user": []}
568
631
  for k in item["kept"]:
569
632
  if k["status"] in ("modified", "missing"):
570
633
  item["needs_user"].append(k["name"] + " editada localmente; para substituir com backup: crow-skills update " +
571
634
  k["name"] + " --agents " + agent + " --global --overwrite-modified --yes")
635
+ elif k["status"] == "duplicate_modified":
636
+ item["needs_user"].append(k["name"] + " (cópia nativa editada) duplica a projeção do Uniflow no Codex; para remover "
637
+ "com backup: crow-skills setup --agents codex --overwrite-modified --yes")
638
+ elif k["status"] == "uniflow_link_modified":
639
+ item["needs_user"].append(k["name"] + ": a entrada do store do Uniflow foi editada ou perdeu arquivos; para "
640
+ "restaurá-la com backup: " + k.get("fix", "crow-skills update " + k["name"] +
641
+ " --agents uniflow --global --overwrite-modified --yes").split(": ", 1)[-1])
642
+ elif k["status"] == "duplicate_shared":
643
+ item["needs_user"].append(k["name"] + " duplica a projeção do Uniflow no Codex, mas também é de shared; mantida")
572
644
  elif k["status"] == "other_origin":
573
645
  item["needs_user"].append(k["name"] + " instalada de outra origem; para trocar: crow-skills install " +
574
646
  k["name"] + " --agents " + agent + " --global --yes")
@@ -576,7 +648,7 @@ def upgrade_summary(skills, dry_run):
576
648
  item["needs_user"].append(k["name"] + ": " + k["status"])
577
649
  if s.get("error"):
578
650
  item["needs_user"].insert(0, s["error"]["message"])
579
- item["state"] = "fail" if s.get("error") else ("warn" if item["needs_user"] else ("plan" if dry_run and item["changed"] else "ok"))
651
+ item["state"] = "fail" if s.get("error") else ("warn" if item["needs_user"] else ("plan" if dry_run and (item["changed"] or item["removed"]) else "ok"))
580
652
  rows.append(item)
581
653
  return rows
582
654
 
@@ -698,8 +770,11 @@ def render_upgrade(data, stream=None):
698
770
  rows = []
699
771
  for item in data.get("summary", []):
700
772
  changed = ", ".join(item["changed"]) if len(item["changed"]) <= 3 else str(len(item["changed"])) + " skills"
773
+ removed = ((" · " if changed else "") + str(len(item["removed"])) + (" a remover" if dry else " removidas") +
774
+ " (duplicavam o Uniflow)") if item.get("removed") else ""
701
775
  rows.append([ui.mark(STATE_MARK[item["state"]], stream), item["label"],
702
- (("a atualizar: " if dry else "atualizadas: ") + changed) if changed else "sem mudanças",
776
+ ((("a atualizar: " if dry else "atualizadas: ") + changed) if changed else ("" if removed else "sem mudanças"))
777
+ + removed,
703
778
  str(item["unchanged"]), str(item.get("via_uniflow", 0)), str(len(item["kept"]))])
704
779
  if rows:
705
780
  lines.append("")
@@ -0,0 +1,128 @@
1
+ """Uniflow's projection of its store into Codex's own skills dir ($CODEX_HOME/skills, default ~/.codex/skills).
2
+
3
+ Codex scans both $CODEX_HOME/skills and ~/.agents/skills (where crow-skills installs for codex), follows
4
+ symlinks and lists a same-name skill twice when the two entries resolve to different directories. Uniflow
5
+ links ~/.codex/skills/crow-x -> <store>/crow-x, so a native copy in ~/.agents/skills duplicates it.
6
+
7
+ A link counts as the codex install of a skill ("via uniflow") only when it resolves to <store>/<name> and
8
+ that store entry is crow-skills-managed (guide.store_managed); a store entry Uniflow disabled is reported as
9
+ disabled; a link to anything else is ignored. A link ~/.codex/skills/crow-x -> ~/.agents/skills/crow-x is an
10
+ alias Codex deduplicates by canonical path: harmless, never touched.
11
+ """
12
+ import os
13
+ from pathlib import Path
14
+
15
+ from .agents import destinations, real_home, uniflow_store
16
+ from .source import CrowError
17
+
18
+
19
+ def codex_home(home):
20
+ """$CODEX_HOME for the real profile (as Codex resolves it); an isolated --home uses <home>/.codex."""
21
+ home = Path(home)
22
+ if real_home(home) and os.environ.get("CODEX_HOME"):
23
+ return Path(os.path.abspath(Path(os.environ["CODEX_HOME"]).expanduser()))
24
+ return Path(os.path.abspath(home / ".codex"))
25
+
26
+
27
+ def codex_skills_dir(home):
28
+ return codex_home(home) / "skills"
29
+
30
+
31
+ def links(home, store):
32
+ """{name: "managed" | "disabled" | "modified" | "unmanaged" | "alias" | "other"} for crow-* symlinks in Codex's own
33
+ skills dir: to an intact managed / Uniflow-disabled / edited-or-incomplete managed / hand-written store entry, to our
34
+ own copy, or elsewhere. Only "managed" counts as the codex install and allows removing a native duplicate."""
35
+ directory = codex_skills_dir(home)
36
+ try:
37
+ if directory.is_symlink() or not directory.is_dir():
38
+ return {}
39
+ entries = [p for p in directory.iterdir() if p.name.startswith("crow-") and p.is_symlink()]
40
+ except OSError:
41
+ return {}
42
+ if not entries:
43
+ return {}
44
+ store_dir = uniflow_store(home)
45
+ native = Path(destinations("codex", home)[0]["path"])
46
+ from .guide import store_statuses
47
+ statuses = store_statuses(home, store) # only an intact ("installed") store entry is a working codex install
48
+ result = {}
49
+ for link in entries:
50
+ name = link.name
51
+ resolved = os.path.realpath(link)
52
+ if resolved == os.path.realpath(native / name):
53
+ result[name] = "alias"
54
+ elif (resolved == os.path.realpath(store_dir / name) and (store_dir / name).is_dir()
55
+ and not (store_dir / name).is_symlink()):
56
+ state = statuses.get(name)
57
+ result[name] = {"installed": "managed", "disabled": "disabled", "modified": "modified",
58
+ "missing": "modified"}.get(state, "unmanaged")
59
+ else:
60
+ result[name] = "other"
61
+ return result
62
+
63
+
64
+ def status_rows(home, store, rows):
65
+ """Rows for `status`/`doctor`: Codex's view through Uniflow's links, as a destination of its own."""
66
+ directory = str(codex_skills_dir(home))
67
+ extra = []
68
+ for name, kind in sorted(links(home, store).items()):
69
+ if kind == "managed":
70
+ extra.append({"name": name, "target": directory, "agents": ["codex"], "status": "uniflow_link", "revision": None,
71
+ "activation_verified": False, "projection": "codex_home",
72
+ "note": "Link do Uniflow em " + directory + " para o store; é a instalação do codex (via uniflow)."})
73
+ elif kind == "disabled":
74
+ extra.append({"name": name, "target": directory, "agents": ["codex"], "status": "disabled", "revision": None,
75
+ "activation_verified": False, "projection": "uniflow_link_disabled",
76
+ "note": "Desabilitada no Uniflow (SKILL.md.disabled no store); reabilite no Uniflow para usar."})
77
+ elif kind == "modified":
78
+ from .guide import BROKEN_REASON, broken_fix
79
+ extra.append({"name": name, "target": directory, "agents": ["codex"], "status": "modified", "revision": None,
80
+ "activation_verified": False, "projection": "uniflow_link_modified",
81
+ "note": BROKEN_REASON[0].upper() + BROKEN_REASON[1:] + ". Para corrigir: " + broken_fix(name)})
82
+ elif kind == "unmanaged":
83
+ extra.append({"name": name, "target": directory, "agents": ["codex"], "status": "unmanaged", "revision": None,
84
+ "activation_verified": False, "projection": "uniflow_link_unmanaged",
85
+ "note": "Link do Uniflow para um pacote do store que o crow-skills não gerencia; não conta como instalada. "
86
+ "Revise o store e, para gerenciá-lo com backup: crow-skills setup --agents uniflow --replace --yes"})
87
+ return extra
88
+
89
+
90
+ def projected(agent, home, store):
91
+ """Names Codex already sees through a managed Uniflow projection (empty for other agents)."""
92
+ if agent != "codex":
93
+ return set()
94
+ return {n for n, kind in links(home, store).items() if kind == "managed"}
95
+
96
+
97
+ def native_target(agent, target, home):
98
+ """target is the user-scope codex install dir (not --project/--dest)."""
99
+ return agent == "codex" and os.path.abspath(target["path"]) == os.path.abspath(destinations("codex", home)[0]["path"])
100
+
101
+
102
+ def duplicates(home, store):
103
+ """Native copies in ~/.agents/skills that duplicate a managed projection, by what can be done with them.
104
+
105
+ Returns {"remove": [...], "modified": [...], "shared": [...]}: "remove" are crow-skills-managed and intact;
106
+ "modified" were edited locally (kept unless --overwrite-modified); "shared" are also recorded for another
107
+ agent of that dir (shared), which may rely on them."""
108
+ names = projected("codex", home, store)
109
+ result = {"remove": [], "modified": [], "shared": []}
110
+ if not names:
111
+ return result
112
+ target = destinations("codex", home)[0]
113
+ try:
114
+ rows = {r["name"]: r for r in store.status([target], sorted(names)) if r["name"]}
115
+ records = store.state(target["path"])["installed"]
116
+ except (CrowError, OSError):
117
+ return result
118
+ for name in sorted(names):
119
+ row = rows.get(name)
120
+ if not row or row["status"] not in ("installed", "modified", "missing"):
121
+ continue # absent, unmanaged or unreadable: nothing of ours to remove
122
+ if set(records.get(name, {}).get("agents") or ["codex"]) - {"codex"}:
123
+ result["shared"].append(name)
124
+ elif row["status"] == "installed":
125
+ result["remove"].append(name)
126
+ else:
127
+ result["modified"].append(name)
128
+ return result