@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.
- package/.agent-src/commands/image/analyse.md +51 -0
- package/.agent-src/commands/image/create.md +53 -0
- package/.agent-src/commands/image/verify.md +48 -0
- package/.agent-src/commands/image.md +69 -0
- package/.agent-src/commands/knowledge/cross-repo.md +71 -0
- package/.agent-src/commands/knowledge.md +2 -0
- package/.agent-src/commands/skill/preview.md +67 -0
- package/.agent-src/commands/skill.md +48 -0
- package/.agent-src/commands/skills/discover.md +76 -0
- package/.agent-src/commands/skills.md +56 -0
- package/.agent-src/commands/video/from-song.md +351 -0
- package/.agent-src/commands/video.md +19 -9
- package/.agent-src/contexts/authority/commit-mechanics.md +8 -0
- package/.agent-src/rules/commit-policy.md +3 -8
- package/.agent-src/rules/linked-projects-onboarding-gate.md +1 -1
- package/.agent-src/rules/media-sync-ground-truth.md +58 -0
- package/.agent-src/skills/image-analyser/SKILL.md +121 -0
- package/.agent-src/skills/image-analyser/canon-spec.md +109 -0
- package/.agent-src/skills/image-analyser/evals/triggers.json +16 -0
- package/.agent-src/skills/image-creator/SKILL.md +117 -0
- package/.agent-src/skills/image-creator/evals/triggers.json +16 -0
- package/.agent-src/skills/song-to-script/SKILL.md +216 -0
- package/.claude-plugin/marketplace.json +15 -2
- package/CHANGELOG.md +84 -0
- package/CONTRIBUTING.md +6 -0
- package/README.md +3 -3
- package/config/agent-settings.template.yml +18 -0
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +327 -20
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +4 -4
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +24 -10
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +20 -6
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/router.json +1 -1
- package/dist/server/schemas/settings.js +4 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/architecture.md +3 -3
- package/docs/catalog.md +20 -6
- package/docs/contracts/benchmark-report-schema.md +12 -10
- package/docs/contracts/command-clusters.md +5 -1
- package/docs/contracts/cross-repo-retrieval.md +64 -0
- package/docs/contracts/rule-router.md +39 -0
- package/docs/contracts/skill-discovery.md +80 -0
- package/docs/contracts/skill-dry-run.md +47 -0
- package/docs/contracts/value-dashboard-spec.md +7 -3
- package/docs/contracts/value-report-schema.md +6 -1
- package/docs/decisions/ADR-032-linked-projects-scope.md +7 -3
- package/docs/getting-started.md +2 -2
- package/docs/guides/cross-repo-linked-projects.md +7 -0
- package/docs/guides/cross-repo-retrieval.md +61 -0
- package/docs/guides/skill-discovery.md +71 -0
- package/docs/guides/skill-preview.md +71 -0
- package/docs/value.md +17 -17
- package/package.json +1 -1
- package/scripts/__pycache__/validate_frontmatter.cpython-312.pyc +0 -0
- package/scripts/_dispatch.bash +10 -0
- package/scripts/_lib/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/_lib/__pycache__/agent_src.cpython-312.pyc +0 -0
- package/scripts/_lib/bench_report.py +13 -14
- package/scripts/_lib/bench_telegraph_report.py +1 -2
- package/scripts/_lib/token_count.py +95 -0
- package/scripts/_lib/value_report.py +3 -3
- package/scripts/ai-video/adapters/higgsfield.sh +163 -6
- package/scripts/ai-video/adapters/openai-images.sh +92 -6
- package/scripts/ai-video/lib/probe-audio.sh +181 -0
- package/scripts/audit_auto_rules.py +22 -6
- package/scripts/audit_command_surface.py +6 -1
- package/scripts/audit_initial_context.py +210 -0
- package/scripts/bench_ab_diff.py +4 -11
- package/scripts/bench_run.py +2 -3
- package/scripts/bench_runner.py +2 -2
- package/scripts/condense.py +44 -3
- package/scripts/cross_repo_retrieve.py +172 -0
- package/scripts/inventory_meta_layers.py +288 -0
- package/scripts/iron_law_sha.py +14 -5
- package/scripts/linked_projects_list.py +91 -0
- package/scripts/measure_rule_budget.py +15 -0
- package/scripts/memory_lookup.py +53 -2
- package/scripts/project_thin_rules.py +168 -0
- package/scripts/render_value_md.py +14 -23
- package/scripts/schemas/command.schema.json +1 -1
- package/scripts/schemas/rule.schema.json +1 -1
- package/scripts/schemas/skill.schema.json +2 -2
- package/scripts/skill_discovery.py +254 -0
- package/scripts/skill_linter.py +8 -4
- package/scripts/skill_preview.py +179 -0
- package/scripts/trigger_coverage.py +129 -0
package/scripts/memory_lookup.py
CHANGED
|
@@ -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 (
|
|
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*, *
|
|
107
|
-
|
|
108
|
-
"
|
|
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 |
|
|
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)} |
|
|
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
|
-
"-
|
|
254
|
-
"
|
|
255
|
-
"
|
|
256
|
-
"
|
|
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
|
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
"description": {
|
|
16
16
|
"type": "string",
|
|
17
17
|
"minLength": 1,
|
|
18
|
-
"maxLength":
|
|
19
|
-
"description": "Trigger phrase; ≤ 200 chars recommended,
|
|
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())
|
package/scripts/skill_linter.py
CHANGED
|
@@ -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
|
-
#
|
|
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:
|