@event4u/agent-config 5.4.0 → 5.5.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.
Files changed (49) hide show
  1. package/.agent-src/commands/knowledge/cross-repo.md +71 -0
  2. package/.agent-src/commands/knowledge.md +2 -0
  3. package/.agent-src/commands/skill/preview.md +67 -0
  4. package/.agent-src/commands/skill.md +48 -0
  5. package/.agent-src/commands/skills/discover.md +76 -0
  6. package/.agent-src/commands/skills.md +56 -0
  7. package/.agent-src/commands/video/from-song.md +317 -0
  8. package/.agent-src/commands/video.md +19 -9
  9. package/.agent-src/rules/linked-projects-onboarding-gate.md +1 -1
  10. package/.agent-src/skills/song-to-script/SKILL.md +193 -0
  11. package/.claude-plugin/marketplace.json +9 -2
  12. package/CHANGELOG.md +49 -0
  13. package/CONTRIBUTING.md +6 -0
  14. package/README.md +3 -3
  15. package/dist/cli/registry.js +1 -0
  16. package/dist/cli/registry.js.map +1 -1
  17. package/dist/discovery/deprecation-report.md +1 -1
  18. package/dist/discovery/discovery-manifest.json +171 -17
  19. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  20. package/dist/discovery/discovery-manifest.summary.md +4 -4
  21. package/dist/discovery/orphan-report.md +1 -1
  22. package/dist/discovery/packs.json +17 -10
  23. package/dist/discovery/trust-report.md +3 -3
  24. package/dist/discovery/workspaces.json +13 -6
  25. package/dist/mcp/registry-manifest.json +2 -2
  26. package/docs/architecture.md +2 -2
  27. package/docs/contracts/command-clusters.md +4 -1
  28. package/docs/contracts/cross-repo-retrieval.md +64 -0
  29. package/docs/contracts/skill-discovery.md +80 -0
  30. package/docs/contracts/skill-dry-run.md +47 -0
  31. package/docs/decisions/ADR-032-linked-projects-scope.md +7 -3
  32. package/docs/getting-started.md +1 -1
  33. package/docs/guides/cross-repo-linked-projects.md +7 -0
  34. package/docs/guides/cross-repo-retrieval.md +61 -0
  35. package/docs/guides/skill-discovery.md +71 -0
  36. package/docs/guides/skill-preview.md +71 -0
  37. package/package.json +1 -1
  38. package/scripts/__pycache__/validate_frontmatter.cpython-312.pyc +0 -0
  39. package/scripts/_dispatch.bash +10 -0
  40. package/scripts/_lib/__pycache__/__init__.cpython-312.pyc +0 -0
  41. package/scripts/_lib/__pycache__/agent_src.cpython-312.pyc +0 -0
  42. package/scripts/ai-video/lib/probe-audio.sh +181 -0
  43. package/scripts/cross_repo_retrieve.py +172 -0
  44. package/scripts/inventory_meta_layers.py +288 -0
  45. package/scripts/linked_projects_list.py +91 -0
  46. package/scripts/memory_lookup.py +53 -2
  47. package/scripts/skill_discovery.py +254 -0
  48. package/scripts/skill_linter.py +8 -4
  49. package/scripts/skill_preview.py +179 -0
@@ -0,0 +1,254 @@
1
+ """Skill discovery recommender — local-only, explained, no network.
2
+
3
+ Phase 3 of `road-to-leaner-core-and-discovery`. Turns existing local signals
4
+ (skill catalog frontmatter, role shortlists, optional local-analytics JSONL)
5
+ into a short, *explained* skill shortlist. Every recommendation carries a
6
+ non-empty `why` (contract: docs/contracts/skill-discovery.md). Adds no
7
+ always-loaded layer; reads local files only.
8
+
9
+ Four classes:
10
+ most-useful-for-role — role skills.yml priority order
11
+ related-to-current-task— skills sharing the role's core domains
12
+ recently-adopted — analytics events (last 14d) with a skill id
13
+ popular-in-role — analytics skill-events filtered by role, by frequency
14
+
15
+ Analytics is optional; missing / empty / opted-out degrades gracefully to
16
+ the role shortlist with an honest `why`. Honours the same opt-out as
17
+ local-analytics.md (AGENT_CONFIG_NO_LOCAL_ANALYTICS env + analytics.local config).
18
+
19
+ Usage:
20
+ python3 scripts/skill_discovery.py [--role ROLE] [--format text|json] [--limit N]
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import os
27
+ import sys
28
+ from collections import Counter, defaultdict
29
+ from dataclasses import dataclass, field
30
+ from datetime import datetime, timezone
31
+ from pathlib import Path
32
+
33
+ import yaml
34
+
35
+ REPO_ROOT = Path(__file__).resolve().parent.parent
36
+ SKILLS_DIR = REPO_ROOT / ".agent-src" / "skills"
37
+ ROLES_DIR = REPO_ROOT / "agents" / "roles"
38
+ COMMANDS_DIR = REPO_ROOT / ".agent-src" / "commands"
39
+ RECENT_DAYS = 14
40
+
41
+ sys.path.insert(0, str(REPO_ROOT / "scripts"))
42
+ try:
43
+ from _lib.user_global_paths import event4u_root # type: ignore
44
+ except Exception: # pragma: no cover - fallback when run outside repo
45
+ def event4u_root(env=None): # type: ignore
46
+ return Path.home() / ".event4u" / "agent-config"
47
+
48
+ CLASSES = ("most-useful-for-role", "related-to-current-task", "recently-adopted", "popular-in-role")
49
+
50
+
51
+ @dataclass
52
+ class Skill:
53
+ name: str
54
+ description: str
55
+ domain: str
56
+
57
+
58
+ @dataclass
59
+ class Rec:
60
+ skill: str
61
+ cls: str
62
+ why: str
63
+ first_command: str = ""
64
+
65
+
66
+ def _frontmatter(text: str) -> dict:
67
+ if not text.startswith("---"):
68
+ return {}
69
+ end = text.find("\n---", 3)
70
+ if end == -1:
71
+ return {}
72
+ try:
73
+ return yaml.safe_load(text[3:end]) or {}
74
+ except yaml.YAMLError:
75
+ return {}
76
+
77
+
78
+ def load_catalog() -> dict[str, Skill]:
79
+ out: dict[str, Skill] = {}
80
+ if not SKILLS_DIR.exists():
81
+ return out
82
+ for d in sorted(SKILLS_DIR.iterdir()):
83
+ sk = d / "SKILL.md"
84
+ if not sk.is_file():
85
+ continue
86
+ fm = _frontmatter(sk.read_text(encoding="utf-8", errors="replace"))
87
+ name = str(fm.get("name") or d.name).strip().strip('"')
88
+ out[name] = Skill(name, str(fm.get("description", "")).strip(), str(fm.get("domain", "")).strip())
89
+ return out
90
+
91
+
92
+ def load_role_shortlist(role: str) -> list[dict]:
93
+ f = ROLES_DIR / role / "skills.yml"
94
+ if not f.is_file():
95
+ return []
96
+ data = yaml.safe_load(f.read_text(encoding="utf-8", errors="replace")) or {}
97
+ return [s for s in (data.get("skills") or []) if isinstance(s, dict) and s.get("id")]
98
+
99
+
100
+ def available_roles() -> list[str]:
101
+ if not ROLES_DIR.exists():
102
+ return []
103
+ return sorted(d.name for d in ROLES_DIR.iterdir() if (d / "skills.yml").is_file())
104
+
105
+
106
+ def analytics_enabled(settings: dict) -> bool:
107
+ if os.environ.get("AGENT_CONFIG_NO_LOCAL_ANALYTICS", "").strip():
108
+ return False
109
+ val = ((settings.get("analytics") or {}).get("local"))
110
+ return str(val).strip().lower() not in ("off", "false", "0", "no")
111
+
112
+
113
+ def load_settings() -> dict:
114
+ try:
115
+ from _lib.agent_settings import load_agent_settings # type: ignore
116
+ return load_agent_settings(cwd=Path.cwd()) or {}
117
+ except Exception:
118
+ return {}
119
+
120
+
121
+ def load_analytics_events() -> list[dict]:
122
+ path = event4u_root() / "workspace" / "analytics" / "events.jsonl"
123
+ if not path.is_file():
124
+ return []
125
+ events: list[dict] = []
126
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
127
+ line = line.strip()
128
+ if not line:
129
+ continue
130
+ try:
131
+ events.append(json.loads(line))
132
+ except json.JSONDecodeError:
133
+ continue
134
+ return events
135
+
136
+
137
+ def _days_ago(ts: str, now: datetime) -> int | None:
138
+ try:
139
+ dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
140
+ if dt.tzinfo is None:
141
+ dt = dt.replace(tzinfo=timezone.utc)
142
+ return (now - dt).days
143
+ except (ValueError, AttributeError):
144
+ return None
145
+
146
+
147
+ def first_command(name: str) -> str:
148
+ for cand in (COMMANDS_DIR / f"{name}.md", *COMMANDS_DIR.glob(f"*/{name}.md")):
149
+ if cand.is_file():
150
+ return f"/{name}"
151
+ return f"Skill › {name}"
152
+
153
+
154
+ def recommend(role: str, catalog: dict[str, Skill], shortlist: list[dict],
155
+ events: list[dict], use_analytics: bool, now: datetime, limit: int) -> list[Rec]:
156
+ recs: list[Rec] = []
157
+ claimed: set[str] = set()
158
+
159
+ def add(name: str, cls: str, why: str) -> None:
160
+ if name in claimed or name not in catalog or not why:
161
+ return
162
+ claimed.add(name)
163
+ recs.append(Rec(name, cls, why, first_command(name)))
164
+
165
+ # 1. most-useful-for-role — role shortlist priority order.
166
+ short_ids = [s["id"] for s in shortlist]
167
+ for s in shortlist[:limit]:
168
+ why = (s.get("why") or "").strip() or f"on the {role} role's priority shortlist"
169
+ add(s["id"], "most-useful-for-role", why)
170
+
171
+ # 2. related-to-current-task — same domain as the role's core skills, not yet shortlisted.
172
+ role_domains = {catalog[i].domain for i in short_ids if i in catalog and catalog[i].domain}
173
+ related = [sk for n, sk in sorted(catalog.items())
174
+ if sk.domain in role_domains and n not in short_ids and sk.domain]
175
+ for sk in related[:limit]:
176
+ add(sk.name, "related-to-current-task", f"same domain ({sk.domain}) as your {role} core skills")
177
+
178
+ # 3 + 4. analytics-backed, or graceful role-shortlist fallback.
179
+ skill_events = [e for e in events if isinstance(e.get("data"), dict) and e["data"].get("skill")]
180
+ if use_analytics and skill_events:
181
+ recent = sorted(
182
+ ((e["data"]["skill"], _days_ago(e.get("ts", ""), now)) for e in skill_events),
183
+ key=lambda kv: (kv[1] is None, kv[1] if kv[1] is not None else 1e9),
184
+ )
185
+ for name, days in recent:
186
+ if days is not None and days <= RECENT_DAYS:
187
+ add(name, "recently-adopted", f"used {days}d ago in this workspace")
188
+ role_counts = Counter(
189
+ e["data"]["skill"] for e in skill_events if e["data"].get("role") == role
190
+ )
191
+ for name, n in role_counts.most_common(limit):
192
+ add(name, "popular-in-role", f"launched {n}× by the {role} role locally")
193
+ else:
194
+ reason = "from your role shortlist — no local usage signal yet"
195
+ for s in shortlist[limit: limit * 2]:
196
+ add(s["id"], "recently-adopted", reason)
197
+ for s in shortlist:
198
+ add(s["id"], "popular-in-role", reason)
199
+ return recs
200
+
201
+
202
+ def render_text(role: str, recs: list[Rec], analytics_on: bool) -> str:
203
+ lines = [f"# Suggested skills for the `{role}` role", ""]
204
+ note = "local analytics: on" if analytics_on else "local analytics: off (role shortlist only)"
205
+ lines.append(f"_{note}_\n")
206
+ lines += ["| skill | class | why | first command |", "|---|---|---|---|"]
207
+ for r in recs:
208
+ lines.append(f"| `{r.skill}` | {r.cls} | {r.why} | `{r.first_command}` |")
209
+ lines.append("")
210
+ return "\n".join(lines)
211
+
212
+
213
+ def main(argv: list[str] | None = None) -> int:
214
+ ap = argparse.ArgumentParser(description="Local skill-discovery recommender (read-only, explained).")
215
+ ap.add_argument("--role", default=None, help="Role id (defaults to active role experience, else prompts).")
216
+ ap.add_argument("--format", choices=("text", "json"), default="text")
217
+ ap.add_argument("--limit", type=int, default=5)
218
+ ap.add_argument("--now", default=None, help="ISO timestamp override for tests.")
219
+ args = ap.parse_args(argv)
220
+
221
+ settings = load_settings()
222
+ role = args.role or ((settings.get("roles") or {}).get("active_role") or "").strip()
223
+ roles = available_roles()
224
+ if not role:
225
+ print(f"No role given and no active role set. Available roles: {', '.join(roles) or '(none)'}", file=sys.stderr)
226
+ print("Re-run with --role <role>.", file=sys.stderr)
227
+ return 2
228
+ if role not in roles:
229
+ print(f"Unknown role {role!r}. Available: {', '.join(roles) or '(none)'}", file=sys.stderr)
230
+ return 2
231
+
232
+ catalog = load_catalog()
233
+ shortlist = load_role_shortlist(role)
234
+ use_analytics = analytics_enabled(settings)
235
+ events = load_analytics_events() if use_analytics else []
236
+ now = datetime.fromisoformat(args.now.replace("Z", "+00:00")) if args.now else datetime.now(timezone.utc)
237
+ if now.tzinfo is None:
238
+ now = now.replace(tzinfo=timezone.utc)
239
+
240
+ recs = recommend(role, catalog, shortlist, events, use_analytics, now, args.limit)
241
+
242
+ if args.format == "json":
243
+ print(json.dumps({
244
+ "role": role,
245
+ "analytics": use_analytics,
246
+ "recommendations": [r.__dict__ for r in recs],
247
+ }, indent=2))
248
+ else:
249
+ print(render_text(role, recs, use_analytics))
250
+ return 0
251
+
252
+
253
+ if __name__ == "__main__":
254
+ raise SystemExit(main())
@@ -533,12 +533,16 @@ def detect_artifact_type(path: Path, text: str) -> ArtifactType:
533
533
  path_str = str(path).lower()
534
534
  has_skill_heading = "## When to use" in text and "## Procedure" in text
535
535
 
536
- # Skills take priority /skills/commands/SKILL.md is a skill, not a command
536
+ # A file inside a /commands/ tree is a command the commands tree wins,
537
+ # even for a cluster head literally named `skill.md` or a sub-command under
538
+ # a `skills/` cluster dir (e.g. /commands/skills/discover.md). The only
539
+ # /commands/ file that is NOT a command is a nested skill body, which is
540
+ # always `SKILL.md` (case-sensitive — command files are lowercase).
541
+ if "/commands/" in path_str and path.name != "SKILL.md":
542
+ return "command"
543
+ # Skills: a SKILL.md body, or anything under a /skills/ tree.
537
544
  if path.name.lower() == "skill.md" or "/skills/" in path_str:
538
545
  return "skill"
539
- # Commands are flat .md files in /commands/ directories (not SKILL.md)
540
- if "/commands/" in path_str and path.name.lower() != "skill.md":
541
- return "command"
542
546
  if "/rules/" in path_str:
543
547
  return "rule"
544
548
  if "/guidelines/" in path_str:
@@ -0,0 +1,179 @@
1
+ """Skill preview — non-destructive "what will this skill do?" summary.
2
+
3
+ Phase 5 of `road-to-leaner-core-and-discovery`. Reads a skill's declared intent
4
+ (frontmatter + `## Steps` body) and renders a plain-language summary BEFORE the
5
+ skill runs. Read-only, no network, no execution.
6
+
7
+ NOT a sandbox: it surfaces declared intent, it does not run the skill or prove
8
+ side-effect-freeness (contract: docs/contracts/skill-dry-run.md). For
9
+ `execution: manual` skills (the default) it states "instructional only".
10
+
11
+ Usage:
12
+ python3 scripts/skill_preview.py <name> [--technical] [--format text|json]
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import re
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ import yaml
23
+
24
+ REPO_ROOT = Path(__file__).resolve().parent.parent
25
+ SKILLS_DIR = REPO_ROOT / ".agent-src" / "skills"
26
+
27
+ _CMD_RE = re.compile(r"`(python3?|bash|node|php|npm|task|pytest)\s+[^`]+`")
28
+ _PATH_RE = re.compile(r"`([\w./-]+\.(?:py|sh|md|json|yml|yaml|ts|js|php))`")
29
+
30
+
31
+ class PreviewError(Exception):
32
+ """Raised for a missing or malformed SKILL.md — rendered, never crashed on."""
33
+
34
+
35
+ def _split_frontmatter(text: str) -> tuple[dict, str]:
36
+ if not text.startswith("---"):
37
+ raise PreviewError("SKILL.md has no YAML frontmatter (missing leading `---`).")
38
+ end = text.find("\n---", 3)
39
+ if end == -1:
40
+ raise PreviewError("SKILL.md frontmatter is not closed (missing terminating `---`).")
41
+ try:
42
+ fm = yaml.safe_load(text[3:end]) or {}
43
+ except yaml.YAMLError as exc:
44
+ raise PreviewError(f"SKILL.md frontmatter is not valid YAML: {exc}")
45
+ if not isinstance(fm, dict):
46
+ raise PreviewError("SKILL.md frontmatter did not parse to a mapping.")
47
+ return fm, text[end + 4:]
48
+
49
+
50
+ def _steps(body: str) -> list[str]:
51
+ out: list[str] = []
52
+ in_steps = False
53
+ for line in body.splitlines():
54
+ if re.match(r"^##\s+Steps\b", line, re.IGNORECASE):
55
+ in_steps = True
56
+ continue
57
+ if in_steps and re.match(r"^##\s+\S", line): # next top-level section
58
+ break
59
+ if in_steps:
60
+ m = re.match(r"^###\s+(.*)", line)
61
+ if m:
62
+ out.append(m.group(1).strip())
63
+ return out
64
+
65
+
66
+ def _targets(body: str) -> tuple[list[str], list[str]]:
67
+ cmds = sorted({m.group(0).strip("`") for m in _CMD_RE.finditer(body)})
68
+ paths = sorted({m.group(1) for m in _PATH_RE.finditer(body)})
69
+ return cmds, paths
70
+
71
+
72
+ def load_preview(name: str) -> dict:
73
+ skill_dir = SKILLS_DIR / name
74
+ sk = skill_dir / "SKILL.md"
75
+ if not sk.is_file():
76
+ try:
77
+ shown = sk.relative_to(REPO_ROOT)
78
+ except ValueError:
79
+ shown = sk
80
+ raise PreviewError(f"no skill named {name!r} (looked for {shown}).")
81
+ fm, body = _split_frontmatter(sk.read_text(encoding="utf-8", errors="replace"))
82
+ execution = fm.get("execution") or {}
83
+ if not isinstance(execution, dict):
84
+ execution = {}
85
+ cmds, paths = _targets(body)
86
+ return {
87
+ "name": fm.get("name") or name,
88
+ "description": (fm.get("description") or "").strip(),
89
+ "domain": fm.get("domain") or "",
90
+ "execution_type": (execution.get("type") or "manual"),
91
+ "handler": (execution.get("handler") or "none"),
92
+ "allowed_tools": execution.get("allowed_tools") or [],
93
+ "command": execution.get("command") or [],
94
+ "steps": _steps(body),
95
+ "commands_named": cmds,
96
+ "paths_named": paths,
97
+ }
98
+
99
+
100
+ def render_plain(p: dict) -> str:
101
+ lines = [f"# Preview — `{p['name']}`", ""]
102
+ if p["description"]:
103
+ lines += [p["description"], ""]
104
+ etype = p["execution_type"]
105
+ if etype == "manual":
106
+ lines.append("**Execution: instructional only.** This skill does not run anything "
107
+ "automatically — it guides the agent step by step.")
108
+ elif etype == "assisted":
109
+ lines.append(f"**Execution: assisted** (handler `{p['handler']}`). It will *propose* actions "
110
+ "for you to approve — it never executes silently.")
111
+ else:
112
+ lines.append(f"**Execution: {etype}** (handler `{p['handler']}`). It can run actions; review the "
113
+ "declared tools and commands below before allowing it.")
114
+ lines.append("")
115
+ if p["steps"]:
116
+ lines.append("This skill will walk these steps:")
117
+ lines += [f"- {s}" for s in p["steps"]]
118
+ lines.append("")
119
+ if p["allowed_tools"]:
120
+ lines.append(f"Declared tools: {', '.join(p['allowed_tools'])}")
121
+ if p["command"]:
122
+ lines.append(f"Declared command: `{' '.join(str(c) for c in p['command'])}`")
123
+ if p["commands_named"]:
124
+ lines.append("Commands it may run:")
125
+ lines += [f"- `{c}`" for c in p["commands_named"]]
126
+ if p["paths_named"]:
127
+ lines.append("Files / scripts it references:")
128
+ lines += [f"- `{f}`" for f in p["paths_named"]]
129
+ if not (p["allowed_tools"] or p["command"] or p["commands_named"] or p["paths_named"]):
130
+ lines.append("_No tools, commands, or file targets declared — pure guidance._")
131
+ lines.append("")
132
+ lines.append("> Preview shows declared intent only — it does not run the skill or guarantee "
133
+ "side-effect-freeness. Contract: docs/contracts/skill-dry-run.md")
134
+ return "\n".join(lines)
135
+
136
+
137
+ def render_technical(p: dict) -> str:
138
+ lines = [f"# Preview (technical) — {p['name']}", "", "## Frontmatter (execution)", "```yaml"]
139
+ lines.append(f"execution_type: {p['execution_type']}")
140
+ lines.append(f"handler: {p['handler']}")
141
+ lines.append(f"allowed_tools: {p['allowed_tools']}")
142
+ if p["command"]:
143
+ lines.append(f"command: {p['command']}")
144
+ lines += ["```", "", "## Declared steps"]
145
+ lines += [f"{i+1}. {s}" for i, s in enumerate(p["steps"])] or ["(none)"]
146
+ if p["commands_named"]:
147
+ lines += ["", "## Commands named in body"] + [f"- `{c}`" for c in p["commands_named"]]
148
+ if p["paths_named"]:
149
+ lines += ["", "## Paths named in body"] + [f"- `{f}`" for f in p["paths_named"]]
150
+ return "\n".join(lines)
151
+
152
+
153
+ def main(argv: list[str] | None = None) -> int:
154
+ ap = argparse.ArgumentParser(description="Preview a skill's declared intent (read-only, no execution).")
155
+ ap.add_argument("name", help="Skill name (directory under .agent-src/skills/).")
156
+ ap.add_argument("--technical", action="store_true", help="Show raw frontmatter + step list.")
157
+ ap.add_argument("--format", choices=("text", "json"), default="text")
158
+ args = ap.parse_args(argv)
159
+
160
+ try:
161
+ preview = load_preview(args.name)
162
+ except PreviewError as exc:
163
+ if args.format == "json":
164
+ print(json.dumps({"error": str(exc), "name": args.name}, indent=2))
165
+ else:
166
+ print(f"❌ Cannot preview {args.name!r}: {exc}", file=sys.stderr)
167
+ return 2
168
+
169
+ if args.format == "json":
170
+ print(json.dumps(preview, indent=2))
171
+ elif args.technical:
172
+ print(render_technical(preview))
173
+ else:
174
+ print(render_plain(preview))
175
+ return 0
176
+
177
+
178
+ if __name__ == "__main__":
179
+ raise SystemExit(main())