@event4u/agent-config 5.4.1 → 5.6.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 (92) hide show
  1. package/.agent-src/commands/image/analyse.md +51 -0
  2. package/.agent-src/commands/image/create.md +53 -0
  3. package/.agent-src/commands/image/verify.md +48 -0
  4. package/.agent-src/commands/image.md +69 -0
  5. package/.agent-src/commands/knowledge/cross-repo.md +71 -0
  6. package/.agent-src/commands/knowledge.md +2 -0
  7. package/.agent-src/commands/skill/preview.md +67 -0
  8. package/.agent-src/commands/skill.md +48 -0
  9. package/.agent-src/commands/skills/discover.md +76 -0
  10. package/.agent-src/commands/skills.md +56 -0
  11. package/.agent-src/commands/video/from-song.md +351 -0
  12. package/.agent-src/commands/video.md +19 -9
  13. package/.agent-src/contexts/authority/commit-mechanics.md +8 -0
  14. package/.agent-src/rules/commit-policy.md +3 -8
  15. package/.agent-src/rules/linked-projects-onboarding-gate.md +1 -1
  16. package/.agent-src/rules/media-sync-ground-truth.md +58 -0
  17. package/.agent-src/skills/image-analyser/SKILL.md +121 -0
  18. package/.agent-src/skills/image-analyser/canon-spec.md +109 -0
  19. package/.agent-src/skills/image-analyser/evals/triggers.json +16 -0
  20. package/.agent-src/skills/image-creator/SKILL.md +117 -0
  21. package/.agent-src/skills/image-creator/evals/triggers.json +16 -0
  22. package/.agent-src/skills/song-to-script/SKILL.md +216 -0
  23. package/.claude-plugin/marketplace.json +15 -2
  24. package/CHANGELOG.md +84 -0
  25. package/CONTRIBUTING.md +6 -0
  26. package/README.md +3 -3
  27. package/config/agent-settings.template.yml +18 -0
  28. package/dist/cli/registry.js +1 -0
  29. package/dist/cli/registry.js.map +1 -1
  30. package/dist/discovery/deprecation-report.md +1 -1
  31. package/dist/discovery/discovery-manifest.json +327 -20
  32. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  33. package/dist/discovery/discovery-manifest.summary.md +4 -4
  34. package/dist/discovery/orphan-report.md +1 -1
  35. package/dist/discovery/packs.json +24 -10
  36. package/dist/discovery/trust-report.md +3 -3
  37. package/dist/discovery/workspaces.json +20 -6
  38. package/dist/mcp/registry-manifest.json +3 -3
  39. package/dist/router.json +1 -1
  40. package/dist/server/schemas/settings.js +4 -0
  41. package/dist/server/schemas/settings.js.map +1 -1
  42. package/docs/architecture.md +3 -3
  43. package/docs/catalog.md +20 -6
  44. package/docs/contracts/benchmark-report-schema.md +12 -10
  45. package/docs/contracts/command-clusters.md +5 -1
  46. package/docs/contracts/cross-repo-retrieval.md +64 -0
  47. package/docs/contracts/rule-router.md +39 -0
  48. package/docs/contracts/skill-discovery.md +80 -0
  49. package/docs/contracts/skill-dry-run.md +47 -0
  50. package/docs/contracts/value-dashboard-spec.md +7 -3
  51. package/docs/contracts/value-report-schema.md +6 -1
  52. package/docs/decisions/ADR-032-linked-projects-scope.md +7 -3
  53. package/docs/getting-started.md +2 -2
  54. package/docs/guides/cross-repo-linked-projects.md +7 -0
  55. package/docs/guides/cross-repo-retrieval.md +61 -0
  56. package/docs/guides/skill-discovery.md +71 -0
  57. package/docs/guides/skill-preview.md +71 -0
  58. package/docs/value.md +17 -17
  59. package/package.json +1 -1
  60. package/scripts/__pycache__/validate_frontmatter.cpython-312.pyc +0 -0
  61. package/scripts/_dispatch.bash +10 -0
  62. package/scripts/_lib/__pycache__/__init__.cpython-312.pyc +0 -0
  63. package/scripts/_lib/__pycache__/agent_src.cpython-312.pyc +0 -0
  64. package/scripts/_lib/bench_report.py +13 -14
  65. package/scripts/_lib/bench_telegraph_report.py +1 -2
  66. package/scripts/_lib/token_count.py +95 -0
  67. package/scripts/_lib/value_report.py +3 -3
  68. package/scripts/ai-video/adapters/higgsfield.sh +163 -6
  69. package/scripts/ai-video/adapters/openai-images.sh +92 -6
  70. package/scripts/ai-video/lib/probe-audio.sh +181 -0
  71. package/scripts/audit_auto_rules.py +22 -6
  72. package/scripts/audit_command_surface.py +6 -1
  73. package/scripts/audit_initial_context.py +210 -0
  74. package/scripts/bench_ab_diff.py +4 -11
  75. package/scripts/bench_run.py +2 -3
  76. package/scripts/bench_runner.py +2 -2
  77. package/scripts/condense.py +44 -3
  78. package/scripts/cross_repo_retrieve.py +172 -0
  79. package/scripts/inventory_meta_layers.py +288 -0
  80. package/scripts/iron_law_sha.py +14 -5
  81. package/scripts/linked_projects_list.py +91 -0
  82. package/scripts/measure_rule_budget.py +15 -0
  83. package/scripts/memory_lookup.py +53 -2
  84. package/scripts/project_thin_rules.py +168 -0
  85. package/scripts/render_value_md.py +14 -23
  86. package/scripts/schemas/command.schema.json +1 -1
  87. package/scripts/schemas/rule.schema.json +1 -1
  88. package/scripts/schemas/skill.schema.json +2 -2
  89. package/scripts/skill_discovery.py +254 -0
  90. package/scripts/skill_linter.py +8 -4
  91. package/scripts/skill_preview.py +179 -0
  92. package/scripts/trigger_coverage.py +129 -0
@@ -56,12 +56,21 @@ CURATED_TYPES = {
56
56
  # conflict rule still treats them as repo entries against operational.
57
57
  KNOWLEDGE_TYPE = "knowledge"
58
58
 
59
+ # Cross-repo retrieval (road-to-leaner-core-and-discovery Phase 4). When this
60
+ # type is requested AND opted-in linked-project siblings exist, matches from
61
+ # scripts/cross_repo_retrieve.py are projected as `source="cross-repo"` Hits,
62
+ # scored below curated/knowledge so cross-repo context never outranks the
63
+ # project's own truth (mirrors the 0.85× knowledge discount, then floored
64
+ # further). Opt-in by caller (type must be requested) + lazy import → existing
65
+ # call sites and consumers without the script are unaffected.
66
+ CROSS_REPO_TYPE = "cross-repo"
67
+
59
68
 
60
69
  @dataclass
61
70
  class Hit:
62
71
  id: str
63
72
  type: str
64
- source: str # "curated" | "intake" | "operational"
73
+ source: str # "curated" | "intake" | "operational" | "knowledge" | "cross-repo"
65
74
  path: str # file (or logical locator) that produced the hit
66
75
  score: float # naive, content-match based [0..1]
67
76
  entry: dict = field(default_factory=dict)
@@ -416,6 +425,45 @@ def package_operational_provider() -> Optional[OperationalProvider]:
416
425
  return _cli_operational_provider
417
426
 
418
427
 
428
+ def _cross_repo_hits(keys: list[str], limit: int) -> list[Hit]:
429
+ """Project cross-repo matches into discounted, tagged Hits.
430
+
431
+ Lazy + guarded: imports `cross_repo_retrieve` on demand and swallows any
432
+ failure (script absent in a consumer install, no opted-in siblings) so the
433
+ cross-repo type degrades to zero hits rather than breaking retrieval. Scores
434
+ sit below curated/knowledge (0.85× floor, then a small per-rank decrement)
435
+ so cross-repo context never outranks the project's own truth.
436
+ """
437
+ query = " ".join(k for k in keys if k).strip()
438
+ if not query:
439
+ return []
440
+ try:
441
+ import os
442
+ import sys as _sys
443
+ from pathlib import Path as _Path
444
+
445
+ here = _Path(__file__).resolve().parent
446
+ if str(here) not in _sys.path:
447
+ _sys.path.insert(0, str(here))
448
+ import cross_repo_retrieve # type: ignore
449
+
450
+ result = cross_repo_retrieve.retrieve(_Path(os.getcwd()), query, None, limit)
451
+ except Exception: # noqa: BLE001 — optional surface; never break retrieval
452
+ return []
453
+
454
+ hits: list[Hit] = []
455
+ for i, m in enumerate(result.get("matches", [])):
456
+ hits.append(Hit(
457
+ id=f"cross-repo:{m.get('source_repo', '')}:{m.get('path', '')}",
458
+ type=CROSS_REPO_TYPE,
459
+ source="cross-repo",
460
+ path=f"{m.get('source_repo', '')}/{m.get('path', '')}",
461
+ score=round(0.7 * 0.85 - i * 0.01, 4),
462
+ entry=m,
463
+ ))
464
+ return hits
465
+
466
+
419
467
  def retrieve(
420
468
  types: list[str],
421
469
  keys: list[str],
@@ -455,6 +503,9 @@ def retrieve(
455
503
  entry=entry,
456
504
  ))
457
505
  continue
506
+ if mtype == CROSS_REPO_TYPE:
507
+ repo_hits.extend(_cross_repo_hits(keys, limit))
508
+ continue
458
509
  if mtype not in CURATED_TYPES:
459
510
  continue
460
511
  for path, entry in _iter_curated_entries(mtype):
@@ -503,7 +554,7 @@ CONTRACT_VERSION = 1
503
554
 
504
555
  # Memory types this file-backed backend can answer. Types outside this
505
556
  # set map to `unknown_type` per the retrieval contract.
506
- _KNOWN_TYPES = CURATED_TYPES | {KNOWLEDGE_TYPE}
557
+ _KNOWN_TYPES = CURATED_TYPES | {KNOWLEDGE_TYPE, CROSS_REPO_TYPE}
507
558
 
508
559
 
509
560
  def retrieve_v1(
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env python3
2
+ """Thin-projection of the rule layer (lean-initial-context build-out, Phase 3.1).
3
+
4
+ The dominant always-on cost is rule BODIES (~58k GPT tok; kernel only ~6.5k).
5
+ 0B.6 verdict: demote every non-kernel rule body to a progressive-disclosure
6
+ pointer the agent resolves on trigger-match (the one mechanism 0B.5 confirmed
7
+ works for the primary tool — like skills). The kernel stays full-bodied.
8
+
9
+ A **thin** rule entry keeps the matching signal (frontmatter `description` +
10
+ `triggers`) so the router still selects it, and replaces the body with a
11
+ one-line pointer to the full text. The agent loads the body on match.
12
+
13
+ This module is the mechanism + a measurement harness. It writes to a target
14
+ dir of your choosing — it never overwrites the live `.claude/` / `.augment/`
15
+ projections. condense.py reads `lean_projection.mode` (default `eager-all`)
16
+ to decide whether the real generate-tools path calls in here; until that flag
17
+ is flipped + live-A/B-validated, the default projection is unchanged.
18
+
19
+ Usage:
20
+ python3 scripts/project_thin_rules.py --measure # measure delta, no write
21
+ python3 scripts/project_thin_rules.py --out <dir> # write thin rules to <dir>
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import json
28
+ import re
29
+ import sys
30
+ from pathlib import Path
31
+
32
+ REPO_ROOT = Path(__file__).resolve().parent.parent
33
+ sys.path.insert(0, str(REPO_ROOT / "scripts"))
34
+ from _lib import token_count # noqa: E402
35
+
36
+ RULES_SOURCE = REPO_ROOT / ".agent-src" / "rules"
37
+ ROUTER = REPO_ROOT / "dist" / "router.json"
38
+
39
+
40
+ def kernel_ids() -> set[str]:
41
+ """The always-full-bodied set — authoritative kernel list from the router."""
42
+ return set(json.loads(ROUTER.read_text(encoding="utf-8")).get("kernel", []))
43
+
44
+
45
+ def split_frontmatter(text: str) -> tuple[str, str]:
46
+ """Return (frontmatter_including_fences, body). Empty fm if none."""
47
+ if text.startswith("---\n"):
48
+ end = text.find("\n---\n", 4)
49
+ if end != -1:
50
+ return text[: end + 5], text[end + 5 :]
51
+ return "", text
52
+
53
+
54
+ def _description(fm: str) -> str:
55
+ m = re.search(r'^description:\s*"?(.+?)"?\s*$', fm, re.MULTILINE)
56
+ return m.group(1).strip() if m else ""
57
+
58
+
59
+ # How many trigger keywords/phrases to surface as the always-on match hint.
60
+ # The full trigger set lives in dist/router.json (compiled from source) — the
61
+ # projected entry only needs enough signal for the agent to recognise a match
62
+ # and load the body. The router, not this list, drives actual selection.
63
+ _TRIGGER_HINT_LIMIT = 6
64
+
65
+
66
+ def _trigger_hint(fm: str) -> str:
67
+ """A short, comma-joined sample of the rule's trigger keywords/phrases."""
68
+ hits: list[str] = []
69
+ for m in re.finditer(r'^\s*-\s*(?:keyword|phrase|intent):\s*"?(.+?)"?\s*$', fm, re.MULTILINE):
70
+ hits.append(m.group(1).strip())
71
+ if len(hits) >= _TRIGGER_HINT_LIMIT:
72
+ break
73
+ return ", ".join(hits)
74
+
75
+
76
+ def thin_entry(rule_id: str, text: str) -> str:
77
+ """Build the minimal progressive-disclosure pointer for a non-kernel rule.
78
+
79
+ The always-on layer keeps only the match signal (description + a short
80
+ trigger hint) and a pointer to the full body — NOT the full frontmatter.
81
+ The router (dist/router.json, compiled from source) holds the complete
82
+ `triggers:` / `routes_to:`; selection is unchanged. Dropping the inlined
83
+ frontmatter is where the bulk of the token saving comes from.
84
+ """
85
+ fm, _body = split_frontmatter(text)
86
+ desc = _description(fm)
87
+ hint = _trigger_hint(fm)
88
+ title = rule_id.replace("-", " ").title()
89
+ fires = f" Fires on: {hint}." if hint else ""
90
+ return (
91
+ f"## {title}\n"
92
+ f"> Routed rule — load the body on trigger-match.{fires} {desc} "
93
+ f"Body: [`{rule_id}`](../../.agent-src.uncondensed/rules/{rule_id}.md)\n"
94
+ )
95
+
96
+
97
+ def build_thin(rules_dir: Path = RULES_SOURCE) -> dict[str, str]:
98
+ """Map {filename: thin_or_full_text} for every rule. Kernel stays full."""
99
+ kernel = kernel_ids()
100
+ out: dict[str, str] = {}
101
+ for p in sorted(rules_dir.glob("*.md")):
102
+ text = p.read_text(encoding="utf-8")
103
+ out[p.name] = text if p.stem in kernel else thin_entry(p.stem, text)
104
+ return out
105
+
106
+
107
+ def measure(rules_dir: Path = RULES_SOURCE) -> dict:
108
+ """Eager vs thin token footprint for the rule layer."""
109
+ kernel = kernel_ids()
110
+ eager_blob = "".join(
111
+ p.read_text(encoding="utf-8") for p in sorted(rules_dir.glob("*.md"))
112
+ )
113
+ thin_blob = "".join(build_thin(rules_dir).values())
114
+ eager = token_count.measure(eager_blob)
115
+ thin = token_count.measure(thin_blob)
116
+ n = len(list(rules_dir.glob("*.md")))
117
+ return {
118
+ "rules_total": n,
119
+ "kernel_full": len(kernel & {p.stem for p in rules_dir.glob("*.md")}),
120
+ "non_kernel_thinned": n - len(kernel & {p.stem for p in rules_dir.glob("*.md")}),
121
+ "eager_gpt": eager["tokens_gpt"],
122
+ "thin_gpt": thin["tokens_gpt"],
123
+ "saved_gpt": eager["tokens_gpt"] - thin["tokens_gpt"],
124
+ "saved_pct": round(
125
+ 100 * (eager["tokens_gpt"] - thin["tokens_gpt"]) / eager["tokens_gpt"], 1
126
+ )
127
+ if eager["tokens_gpt"]
128
+ else 0.0,
129
+ "eager_chars": eager["chars"],
130
+ "thin_chars": thin["chars"],
131
+ "token_method": token_count.method_note(),
132
+ }
133
+
134
+
135
+ def write_thin(out_dir: Path, rules_dir: Path = RULES_SOURCE) -> int:
136
+ out_dir.mkdir(parents=True, exist_ok=True)
137
+ files = build_thin(rules_dir)
138
+ for name, text in files.items():
139
+ (out_dir / name).write_text(text, encoding="utf-8")
140
+ return len(files)
141
+
142
+
143
+ def main(argv: list[str] | None = None) -> int:
144
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
145
+ ap.add_argument("--measure", action="store_true", help="print the eager-vs-thin token delta")
146
+ ap.add_argument("--out", type=Path, help="write thin rule files to this dir")
147
+ ap.add_argument("--json", action="store_true")
148
+ args = ap.parse_args(argv)
149
+
150
+ if args.out:
151
+ n = write_thin(args.out)
152
+ print(f"wrote {n} thin rule files → {args.out}")
153
+ return 0
154
+
155
+ m = measure()
156
+ if args.json:
157
+ print(json.dumps(m, indent=2, sort_keys=True))
158
+ else:
159
+ print(f"Rule-layer thin projection (kernel full-bodied + {m['non_kernel_thinned']} non-kernel pointers):")
160
+ print(f" eager: {m['eager_gpt']:>6} GPT tok ({m['eager_chars']:,} chars)")
161
+ print(f" thin: {m['thin_gpt']:>6} GPT tok ({m['thin_chars']:,} chars)")
162
+ print(f" saved: {m['saved_gpt']:>6} GPT tok ({m['saved_pct']}% of the rule layer)")
163
+ print(f" method: {m['token_method']}")
164
+ return 0
165
+
166
+
167
+ if __name__ == "__main__":
168
+ sys.exit(main())
@@ -57,10 +57,6 @@ def fmt_signed_int(value: int) -> str:
57
57
  return f"{value:+,}".replace(",", " ")
58
58
 
59
59
 
60
- def fmt_eur(value: float) -> str:
61
- return f"{value:+.2f} €"
62
-
63
-
64
60
  def fmt_pct(value: float) -> str:
65
61
  return f"{value:+.2f}%"
66
62
 
@@ -89,7 +85,6 @@ def render_intro(report: Dict[str, Any]) -> str:
89
85
  avg_in = ref.get("avg_input_tokens", 8000)
90
86
  avg_out = ref.get("avg_output_tokens", 600)
91
87
  tier = ref.get("model_tier", "sonnet")
92
- sourced = ref.get("pricing_sourced_on", "—")
93
88
  return (
94
89
  f"# Value Dashboard — was kostet das Paket, was bringt es?\n"
95
90
  "\n"
@@ -101,11 +96,12 @@ def render_intro(report: Dict[str, Any]) -> str:
101
96
  "\n"
102
97
  "## Wie diese Seite zu lesen ist\n"
103
98
  "\n"
104
- "**Panel A (Kostenleiter)** — von oben nach unten lesen. Jede "
99
+ "**Panel A (Token-Leiter)** — von oben nach unten lesen. Jede "
105
100
  "Stufe sagt: *was sie macht*, *wie viele Input-Tokens sie pro "
106
- "Request hinzufügt oder spart*, *was das in auf "
107
- f"{requests:,} Requests kostet*, und *wo wir kumulativ stehen*. "
108
- "Die fett gedruckte **NETTO**-Zeile am Ende ist die Antwort.\n"
101
+ "Request hinzufügt oder spart*, und *wo wir kumulativ stehen*. "
102
+ "Die fett gedruckte **NETTO**-Zeile am Ende ist die Antwort. "
103
+ "Bewusst rein in Tokens kein €-Vergleich, da Abo-Nutzer keine "
104
+ "Per-Request-API-Preise zahlen.\n"
109
105
  "\n"
110
106
  "**Panel B (Verhalten)** — vier reale Vergleiche, *mit* vs. "
111
107
  "*ohne* Paket. Hier liegt der nicht-Token-Wert: passende Skill-"
@@ -122,8 +118,7 @@ def render_intro(report: Dict[str, Any]) -> str:
122
118
  f"- **{requests:,}** Requests, durchschnittlich "
123
119
  f"**{avg_in:,}** Input-Tokens und **{avg_out:,}** Output-Tokens "
124
120
  "pro Request\n"
125
- f"- Modell-Tier: `{tier}` · "
126
- f"Preisstand `{sourced}` (Quelle: `internal/bench/pricing.yaml`)\n"
121
+ f"- Modell-Tier (Workload-Annahme): `{tier}`\n"
127
122
  "- Wer einen anderen Workload fährt, rechnet selbst nach — die "
128
123
  "Methodik ist offengelegt; nichts ist hardcodiert versteckt.\n"
129
124
  )
@@ -135,8 +130,8 @@ def render_panel_a(report: Dict[str, Any]) -> str:
135
130
  "Liest sich von oben nach unten. Positive Δ-Werte = das Paket "
136
131
  "*kostet* Tokens (Regel-Load ist die ehrliche Up-Front-Steuer); "
137
132
  "negative Δ-Werte = das Paket *spart* Tokens.\n",
138
- "| Stufe | Was sie tut | Δ Tokens | Δ € (1k Req) | Kumulativ | Quelle |",
139
- "|---|---|---:|---:|---:|---|",
133
+ "| Stufe | Was sie tut | Δ Tokens | Kumulativ | Quelle |",
134
+ "|---|---|---:|---:|---|",
140
135
  ]
141
136
  for rung in report.get("cost_ladder", []):
142
137
  if rung["id"] == "baseline":
@@ -145,7 +140,6 @@ def render_panel_a(report: Dict[str, Any]) -> str:
145
140
  label_cell = rung["label"]
146
141
  what = rung.get("what_it_does", "")
147
142
  token_delta = int(rung.get("token_delta", 0))
148
- eur_delta = float(rung.get("eur_delta", 0.0))
149
143
  cum = float(rung.get("cumulative_pct", 0.0))
150
144
  conf = confidence_badge(rung.get("confidence", "pending"))
151
145
  source = rung.get("source_report", "")
@@ -154,17 +148,16 @@ def render_panel_a(report: Dict[str, Any]) -> str:
154
148
  what = f"{what} ⚠️ erst teurer"
155
149
  lines.append(
156
150
  f"| {label_cell} | {what} | "
157
- f"{fmt_signed_int(token_delta)} | {fmt_eur(eur_delta)} | "
151
+ f"{fmt_signed_int(token_delta)} | "
158
152
  f"{fmt_pct(cum)} | `{source}` · {conf} |"
159
153
  )
160
154
  if rung.get("footnote"):
161
155
  lines.append(
162
- f"| | _Fußnote:_ {rung['footnote']} | | | | |"
156
+ f"| | _Fußnote:_ {rung['footnote']} | | | |"
163
157
  )
164
158
 
165
159
  totals = report.get("totals", {})
166
160
  cum_tokens = int(totals.get("cumulative_token_delta", 0))
167
- cum_eur = float(totals.get("cumulative_eur_delta", 0.0))
168
161
  cum_pct = float(totals.get("cumulative_pct", 0.0))
169
162
  verdict = totals.get("net_verdict", "—")
170
163
  verdict_label = {
@@ -177,8 +170,6 @@ def render_panel_a(report: Dict[str, Any]) -> str:
177
170
  "",
178
171
  f"{verdict_label} — "
179
172
  f"**{fmt_signed_int(cum_tokens)} Tokens / Request**, "
180
- f"**{fmt_eur(cum_eur)}** auf "
181
- f"{report.get('reference_scale', {}).get('requests', 1000):,} Requests, "
182
173
  f"kumulativ **{fmt_pct(cum_pct)}** vs. Baseline.\n",
183
174
  ]
184
175
  )
@@ -250,10 +241,10 @@ def render_glossary() -> str:
250
241
  "nutzt. Spart Output-Tokens — wenn der Korpus es belohnt.\n"
251
242
  "- **Ohne Paket / Mit Paket** — *without the package* / *with "
252
243
  "the package* — die zwei Arme des A/B-Vergleichs.\n"
253
- "- **€-per-1k-requests** — Token-Kosten auf der "
254
- "Referenz-Skala (1.000 Requests durchschnittlicher Größe, "
255
- "gepreist mit den aktuellen Sonnet-Raten aus "
256
- "`internal/bench/pricing.yaml`).\n"
244
+ "- **Δ Tokens** — Input-Token-Differenz pro Request gegenüber der "
245
+ "Baseline. Bewusst die einzige Kosten-Einheit: ein €-Vergleich "
246
+ "würde Per-Request-API-Preise unterstellen, die Abo-Nutzer nicht "
247
+ "zahlen.\n"
257
248
  )
258
249
 
259
250
 
@@ -20,7 +20,7 @@
20
20
  "description": {
21
21
  "type": "string",
22
22
  "minLength": 1,
23
- "maxLength": 500
23
+ "maxLength": 200
24
24
  },
25
25
  "disable-model-invocation": {
26
26
  "type": "boolean",
@@ -20,7 +20,7 @@
20
20
  "description": {
21
21
  "type": "string",
22
22
  "minLength": 1,
23
- "maxLength": 500
23
+ "maxLength": 190
24
24
  },
25
25
  "alwaysApply": {
26
26
  "type": "boolean",
@@ -15,8 +15,8 @@
15
15
  "description": {
16
16
  "type": "string",
17
17
  "minLength": 1,
18
- "maxLength": 300,
19
- "description": "Trigger phrase; ≤ 200 chars recommended, 300 is the hard ceiling."
18
+ "maxLength": 220,
19
+ "description": "Trigger phrase; ≤ 200 chars recommended, 220 is the ceiling (lean-initial-context: descriptions load eagerly via progressive disclosure). Over-cap is a soft warning, not a hard fail — a warning window so authors adapt."
20
20
  },
21
21
  "source": {
22
22
  "type": "string",
@@ -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: