@event4u/agent-config 2.16.0 → 2.18.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/ghostwriter/delete.md +118 -0
- package/.agent-src/commands/ghostwriter/fetch.md +185 -0
- package/.agent-src/commands/ghostwriter/list.md +102 -0
- package/.agent-src/commands/ghostwriter/show.md +113 -0
- package/.agent-src/commands/ghostwriter/write.md +160 -0
- package/.agent-src/commands/ghostwriter.md +96 -0
- package/.agent-src/commands/post-as/ghostwriter.md +66 -0
- package/.agent-src/commands/post-as/me.md +124 -0
- package/.agent-src/commands/post-as.md +58 -0
- package/.agent-src/ghostwriter/README.md +61 -0
- package/.agent-src/ghostwriter/fictional-fixture-v1.md +94 -0
- package/.agent-src/personas/README.md +8 -0
- package/.agent-src/rules/domain-safety-disclaimer-consulting.md +52 -0
- package/.agent-src/rules/domain-safety-disclaimer-financial.md +54 -0
- package/.agent-src/rules/domain-safety-disclaimer-legal.md +49 -0
- package/.agent-src/rules/domain-safety-disclaimer-medical.md +56 -0
- package/.agent-src/rules/domain-safety-export-redact.md +65 -0
- package/.agent-src/rules/domain-safety-logging-pii-floor.md +55 -0
- package/.agent-src/rules/domain-safety-pii-finance.md +57 -0
- package/.agent-src/rules/domain-safety-pii-marketing.md +60 -0
- package/.agent-src/rules/domain-safety-pii-recruiting.md +56 -0
- package/.agent-src/rules/domain-safety-pii-support.md +57 -0
- package/.agent-src/rules/domain-safety-retention-finance.md +48 -0
- package/.agent-src/rules/domain-safety-retention-support.md +55 -0
- package/.agent-src/skills/api-design/SKILL.md +3 -0
- package/.agent-src/skills/authz-review/SKILL.md +3 -0
- package/.agent-src/skills/competitive-moat-analysis/SKILL.md +3 -0
- package/.agent-src/skills/competitive-positioning/SKILL.md +3 -0
- package/.agent-src/skills/content-funnel-design/SKILL.md +3 -0
- package/.agent-src/skills/contracts-cognition/SKILL.md +3 -0
- package/.agent-src/skills/dashboard-design/SKILL.md +3 -0
- package/.agent-src/skills/data-handling-judgment/SKILL.md +3 -0
- package/.agent-src/skills/dcf-modeling/SKILL.md +3 -0
- package/.agent-src/skills/deal-qualification-meddic/SKILL.md +3 -0
- package/.agent-src/skills/discovery-interview/SKILL.md +3 -0
- package/.agent-src/skills/editorial-calendar/SKILL.md +3 -0
- package/.agent-src/skills/forecast-accuracy/SKILL.md +3 -0
- package/.agent-src/skills/forecasting/SKILL.md +3 -0
- package/.agent-src/skills/fundraising-narrative/SKILL.md +3 -0
- package/.agent-src/skills/gtm-launch/SKILL.md +3 -0
- package/.agent-src/skills/incident-commander/SKILL.md +3 -0
- package/.agent-src/skills/launch-readiness/SKILL.md +3 -0
- package/.agent-src/skills/messaging-architecture/SKILL.md +3 -0
- package/.agent-src/skills/okr-tree-modeling/SKILL.md +3 -0
- package/.agent-src/skills/pipeline-strategy/SKILL.md +3 -0
- package/.agent-src/skills/playwright-architect/SKILL.md +3 -0
- package/.agent-src/skills/privacy-review/SKILL.md +4 -1
- package/.agent-src/skills/quality-tools/SKILL.md +3 -0
- package/.agent-src/skills/release-comms/SKILL.md +3 -0
- package/.agent-src/skills/runway-cognition/SKILL.md +3 -0
- package/.agent-src/skills/scenario-modeling/SKILL.md +3 -0
- package/.agent-src/skills/secrets-management/SKILL.md +3 -0
- package/.agent-src/skills/tech-debt-tracker/SKILL.md +3 -0
- package/.agent-src/skills/unit-economics-modeling/SKILL.md +3 -0
- package/.agent-src/skills/voc-extract/SKILL.md +3 -0
- package/.agent-src/skills/voice-and-tone-design/SKILL.md +3 -0
- package/.agent-src/templates/agents/agent-project-settings.example.yml +16 -1
- package/.claude-plugin/marketplace.json +10 -1
- package/CHANGELOG.md +98 -0
- package/README.md +44 -23
- package/config/agent-settings.template.yml +7 -0
- package/config/gitignore-block.txt +8 -0
- package/docs/announcements/2026-05-non-dev-launch.md +79 -0
- package/docs/architecture.md +2 -2
- package/docs/case-studies/_template.md +60 -0
- package/docs/catalog.md +25 -4
- package/docs/contracts/adr-install-user-type-axis.md +107 -0
- package/docs/contracts/agent-user-schema.md +1 -0
- package/docs/contracts/command-clusters.md +2 -0
- package/docs/contracts/file-ownership-matrix.json +490 -0
- package/docs/contracts/ghostwriter-schema.md +337 -0
- package/docs/contracts/init-telemetry.md +132 -0
- package/docs/contracts/router-blending.md +71 -0
- package/docs/contracts/universal-skills.md +92 -0
- package/docs/contracts/write-engine.md +142 -0
- package/docs/getting-started-by-role.md +89 -0
- package/docs/getting-started-laravel.md +72 -0
- package/docs/getting-started.md +2 -2
- package/docs/safety.md +30 -0
- package/package.json +1 -1
- package/scripts/audit_user_type_axis.py +140 -0
- package/scripts/bench_runner.py +158 -0
- package/scripts/check_role_doc_links.py +110 -0
- package/scripts/compress.py +11 -0
- package/scripts/ghostwriter_fixture_allowlist.txt +16 -0
- package/scripts/install +9 -1
- package/scripts/install.py +214 -8
- package/scripts/install.sh +7 -0
- package/scripts/lint_ghostwriter_source.py +240 -0
- package/scripts/mcp_server/prompts.py +134 -2
- package/scripts/measure_skill_reduction.py +102 -0
- package/scripts/schemas/rule.schema.json +5 -0
- package/scripts/schemas/skill.schema.json +6 -0
- package/scripts/schemas/user-type-axis.schema.json +56 -0
- package/scripts/sync_agent_settings.py +6 -0
- package/scripts/update-github-metadata.sh +84 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Lint ghostwriter profile sources.
|
|
3
|
+
|
|
4
|
+
Two storage tiers exist (see docs/contracts/ghostwriter-schema.md):
|
|
5
|
+
|
|
6
|
+
* .agent-src.uncompressed/ghostwriter/ — package source. Ships
|
|
7
|
+
fictional fixtures ONLY (`fictional: true`). Every file stem must
|
|
8
|
+
be on scripts/ghostwriter_fixture_allowlist.txt. `aliases:` is
|
|
9
|
+
forbidden here (consumer-only feature).
|
|
10
|
+
* agents/ghostwriter/ — consumer real-person
|
|
11
|
+
profiles. Gitignored. Must NOT carry `fictional: true`. Optional
|
|
12
|
+
`aliases:` list validated per § Aliases storage rules.
|
|
13
|
+
|
|
14
|
+
This lint enforces both rules and runs in `task ci`.
|
|
15
|
+
|
|
16
|
+
Exit codes:
|
|
17
|
+
0 all profiles compliant
|
|
18
|
+
1 one or more violations
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import sys
|
|
23
|
+
import unicodedata
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
import yaml
|
|
27
|
+
|
|
28
|
+
QUIET = "--quiet" in sys.argv
|
|
29
|
+
|
|
30
|
+
REPO = Path(__file__).resolve().parents[1]
|
|
31
|
+
PACKAGE_DIR = REPO / ".agent-src.uncompressed" / "ghostwriter"
|
|
32
|
+
CONSUMER_DIR = REPO / "agents" / "ghostwriter"
|
|
33
|
+
ALLOWLIST = REPO / "scripts" / "ghostwriter_fixture_allowlist.txt"
|
|
34
|
+
EXEMPT_STEMS = frozenset({"README"})
|
|
35
|
+
|
|
36
|
+
ALIAS_MIN_LEN = 2
|
|
37
|
+
# Allowed Unicode blocks for aliases (Latin-only, no homoglyph scripts).
|
|
38
|
+
# Basic Latin + Latin-1 Supplement + Latin Extended-A/B cover Müller,
|
|
39
|
+
# Łukaszewicz, José, etc., while rejecting Cyrillic / Greek confusables.
|
|
40
|
+
ALLOWED_PUNCT = frozenset(" .'-")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_allowlist() -> set[str]:
|
|
44
|
+
if not ALLOWLIST.exists():
|
|
45
|
+
return set()
|
|
46
|
+
stems: set[str] = set()
|
|
47
|
+
for line in ALLOWLIST.read_text(encoding="utf-8").splitlines():
|
|
48
|
+
s = line.strip()
|
|
49
|
+
if not s or s.startswith("#"):
|
|
50
|
+
continue
|
|
51
|
+
stems.add(s)
|
|
52
|
+
return stems
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_frontmatter(text: str) -> dict | None:
|
|
56
|
+
if not text.startswith("---\n"):
|
|
57
|
+
return None
|
|
58
|
+
end = text.find("\n---\n", 4)
|
|
59
|
+
if end == -1:
|
|
60
|
+
return None
|
|
61
|
+
try:
|
|
62
|
+
data = yaml.safe_load(text[4:end])
|
|
63
|
+
except yaml.YAMLError:
|
|
64
|
+
return None
|
|
65
|
+
return data if isinstance(data, dict) else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def is_latin_or_allowed(ch: str) -> bool:
|
|
69
|
+
if ch in ALLOWED_PUNCT:
|
|
70
|
+
return True
|
|
71
|
+
if ch.isdigit():
|
|
72
|
+
return True
|
|
73
|
+
code = ord(ch)
|
|
74
|
+
# Basic Latin letters + Latin-1 Supplement letters + Latin Extended-A/B
|
|
75
|
+
if 0x0041 <= code <= 0x024F:
|
|
76
|
+
try:
|
|
77
|
+
return unicodedata.name(ch).startswith("LATIN ")
|
|
78
|
+
except ValueError:
|
|
79
|
+
return False
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def validate_alias(alias: str) -> str | None:
|
|
84
|
+
"""Return an error message, or None if the alias is valid."""
|
|
85
|
+
if not isinstance(alias, str):
|
|
86
|
+
return f"alias must be a string, got {type(alias).__name__}"
|
|
87
|
+
if len(alias) < ALIAS_MIN_LEN:
|
|
88
|
+
return f"alias {alias!r} is shorter than {ALIAS_MIN_LEN} characters"
|
|
89
|
+
normalised = unicodedata.normalize("NFC", alias)
|
|
90
|
+
if normalised != alias:
|
|
91
|
+
return f"alias {alias!r} is not Unicode-NFC-normalised"
|
|
92
|
+
bad = [ch for ch in alias if not is_latin_or_allowed(ch)]
|
|
93
|
+
if bad:
|
|
94
|
+
return (
|
|
95
|
+
f"alias {alias!r} contains non-Latin or homoglyph-prone "
|
|
96
|
+
f"character(s): {bad!r}"
|
|
97
|
+
)
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def lint_package_side(allowlist: set[str]) -> list[str]:
|
|
102
|
+
errors: list[str] = []
|
|
103
|
+
if not PACKAGE_DIR.exists():
|
|
104
|
+
return errors
|
|
105
|
+
for path in sorted(PACKAGE_DIR.glob("*.md")):
|
|
106
|
+
stem = path.stem
|
|
107
|
+
if stem in EXEMPT_STEMS:
|
|
108
|
+
continue
|
|
109
|
+
if stem not in allowlist:
|
|
110
|
+
errors.append(
|
|
111
|
+
f" off-allowlist (package source): {path.relative_to(REPO)} "
|
|
112
|
+
f"— add '{stem}' to scripts/ghostwriter_fixture_allowlist.txt"
|
|
113
|
+
)
|
|
114
|
+
continue
|
|
115
|
+
data = parse_frontmatter(path.read_text(encoding="utf-8"))
|
|
116
|
+
if data is None:
|
|
117
|
+
errors.append(
|
|
118
|
+
f" unparsable frontmatter (package source): {path.relative_to(REPO)}"
|
|
119
|
+
)
|
|
120
|
+
continue
|
|
121
|
+
if data.get("fictional") is not True:
|
|
122
|
+
errors.append(
|
|
123
|
+
f" missing 'fictional: true' (package source): {path.relative_to(REPO)} "
|
|
124
|
+
f"(got fictional={data.get('fictional')!r})"
|
|
125
|
+
)
|
|
126
|
+
if "aliases" in data:
|
|
127
|
+
errors.append(
|
|
128
|
+
f" 'aliases:' forbidden on fictional fixtures: {path.relative_to(REPO)} "
|
|
129
|
+
f"— aliases are a consumer-only feature (see schema § Aliases)"
|
|
130
|
+
)
|
|
131
|
+
return errors
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def lint_consumer_side() -> list[str]:
|
|
135
|
+
errors: list[str] = []
|
|
136
|
+
if not CONSUMER_DIR.exists():
|
|
137
|
+
return errors
|
|
138
|
+
# Collect (alias_ci, source_path, source_kind) tuples for cross-profile
|
|
139
|
+
# uniqueness check. source_kind is "alias" or "slug".
|
|
140
|
+
seen: dict[str, tuple[Path, str, str]] = {}
|
|
141
|
+
for path in sorted(CONSUMER_DIR.glob("*.md")):
|
|
142
|
+
if path.stem in EXEMPT_STEMS:
|
|
143
|
+
continue
|
|
144
|
+
slug = path.stem
|
|
145
|
+
slug_ci = slug.casefold()
|
|
146
|
+
# Register slug for cross-profile collision detection.
|
|
147
|
+
if slug_ci in seen:
|
|
148
|
+
prev_path, prev_value, prev_kind = seen[slug_ci]
|
|
149
|
+
errors.append(
|
|
150
|
+
f" duplicate slug across profiles: {path.relative_to(REPO)} "
|
|
151
|
+
f"vs {prev_path.relative_to(REPO)} (case-insensitive)"
|
|
152
|
+
)
|
|
153
|
+
else:
|
|
154
|
+
seen[slug_ci] = (path, slug, "slug")
|
|
155
|
+
|
|
156
|
+
data = parse_frontmatter(path.read_text(encoding="utf-8"))
|
|
157
|
+
if data is None:
|
|
158
|
+
continue
|
|
159
|
+
if data.get("fictional") is True:
|
|
160
|
+
errors.append(
|
|
161
|
+
f" 'fictional: true' in consumer tree: {path.relative_to(REPO)} "
|
|
162
|
+
f"— fictional fixtures belong in .agent-src.uncompressed/ghostwriter/"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
aliases = data.get("aliases")
|
|
166
|
+
if aliases is None:
|
|
167
|
+
continue
|
|
168
|
+
if not isinstance(aliases, list):
|
|
169
|
+
errors.append(
|
|
170
|
+
f" 'aliases' must be a YAML list: {path.relative_to(REPO)} "
|
|
171
|
+
f"(got {type(aliases).__name__})"
|
|
172
|
+
)
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
within_profile: set[str] = set()
|
|
176
|
+
for alias in aliases:
|
|
177
|
+
err = validate_alias(alias)
|
|
178
|
+
if err:
|
|
179
|
+
errors.append(f" {path.relative_to(REPO)}: {err}")
|
|
180
|
+
continue
|
|
181
|
+
alias_ci = alias.casefold()
|
|
182
|
+
if alias_ci in within_profile:
|
|
183
|
+
errors.append(
|
|
184
|
+
f" {path.relative_to(REPO)}: duplicate alias "
|
|
185
|
+
f"{alias!r} within the same profile (case-insensitive)"
|
|
186
|
+
)
|
|
187
|
+
continue
|
|
188
|
+
within_profile.add(alias_ci)
|
|
189
|
+
if alias_ci in seen:
|
|
190
|
+
prev_path, prev_value, prev_kind = seen[alias_ci]
|
|
191
|
+
errors.append(
|
|
192
|
+
f" alias collision: {path.relative_to(REPO)} alias "
|
|
193
|
+
f"{alias!r} collides with {prev_kind} {prev_value!r} in "
|
|
194
|
+
f"{prev_path.relative_to(REPO)} (case-insensitive)"
|
|
195
|
+
)
|
|
196
|
+
continue
|
|
197
|
+
seen[alias_ci] = (path, alias, "alias")
|
|
198
|
+
return errors
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def main() -> int:
|
|
202
|
+
allowlist = load_allowlist()
|
|
203
|
+
pkg_errors = lint_package_side(allowlist)
|
|
204
|
+
cons_errors = lint_consumer_side()
|
|
205
|
+
errors = pkg_errors + cons_errors
|
|
206
|
+
|
|
207
|
+
if errors:
|
|
208
|
+
print(
|
|
209
|
+
f"❌ lint_ghostwriter_source: {len(errors)} violation(s)",
|
|
210
|
+
file=sys.stderr,
|
|
211
|
+
)
|
|
212
|
+
for line in errors:
|
|
213
|
+
print(line, file=sys.stderr)
|
|
214
|
+
print(
|
|
215
|
+
" see docs/contracts/ghostwriter-schema.md § Lint enforcement",
|
|
216
|
+
file=sys.stderr,
|
|
217
|
+
)
|
|
218
|
+
return 1
|
|
219
|
+
|
|
220
|
+
if not QUIET:
|
|
221
|
+
pkg_count = (
|
|
222
|
+
sum(1 for p in PACKAGE_DIR.glob("*.md") if p.stem not in EXEMPT_STEMS)
|
|
223
|
+
if PACKAGE_DIR.exists()
|
|
224
|
+
else 0
|
|
225
|
+
)
|
|
226
|
+
cons_count = (
|
|
227
|
+
sum(1 for p in CONSUMER_DIR.glob("*.md") if p.stem not in EXEMPT_STEMS)
|
|
228
|
+
if CONSUMER_DIR.exists()
|
|
229
|
+
else 0
|
|
230
|
+
)
|
|
231
|
+
print(
|
|
232
|
+
f"✅ lint_ghostwriter_source: {pkg_count} package fixture(s), "
|
|
233
|
+
f"{cons_count} consumer profile(s), all compliant"
|
|
234
|
+
)
|
|
235
|
+
return 0
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
if __name__ == "__main__":
|
|
239
|
+
raise SystemExit(main())
|
|
240
|
+
|
|
@@ -18,7 +18,8 @@ helpers (caller decides whether to log).
|
|
|
18
18
|
"""
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
import dataclasses
|
|
22
|
+
from dataclasses import dataclass, field
|
|
22
23
|
from pathlib import Path
|
|
23
24
|
from typing import Any, Literal
|
|
24
25
|
|
|
@@ -36,6 +37,7 @@ PHASE_1_SKILLS: tuple[str, ...] = (
|
|
|
36
37
|
)
|
|
37
38
|
|
|
38
39
|
PromptKind = Literal["skill", "command"]
|
|
40
|
+
UserTypeMatch = Literal["", "match", "universal", "outside"]
|
|
39
41
|
|
|
40
42
|
|
|
41
43
|
@dataclass(frozen=True)
|
|
@@ -46,6 +48,12 @@ class SkillPrompt:
|
|
|
46
48
|
field is the frontmatter `name:` value verbatim (e.g.
|
|
47
49
|
`test-driven-development` or `research:report`); MCP wire names
|
|
48
50
|
are derived in `to_mcp_prompt_meta` with `kind`-aware prefixing.
|
|
51
|
+
|
|
52
|
+
`recommended_for_user_types` mirrors the SKILL.md frontmatter
|
|
53
|
+
array (step-9 user-type axis). Empty tuple = universal (no
|
|
54
|
+
user-type constraint declared). `user_type_match` is the
|
|
55
|
+
cache-computed match label against the active `personal.user_type`
|
|
56
|
+
in `.agent-settings.yml`; empty string means filtering is disabled.
|
|
49
57
|
"""
|
|
50
58
|
|
|
51
59
|
name: str
|
|
@@ -53,6 +61,8 @@ class SkillPrompt:
|
|
|
53
61
|
body: str
|
|
54
62
|
source: str
|
|
55
63
|
kind: PromptKind = "skill"
|
|
64
|
+
recommended_for_user_types: tuple[str, ...] = ()
|
|
65
|
+
user_type_match: UserTypeMatch = ""
|
|
56
66
|
|
|
57
67
|
|
|
58
68
|
def _project_root() -> Path:
|
|
@@ -84,6 +94,69 @@ def _strip_frontmatter(text: str) -> tuple[dict[str, str], str]:
|
|
|
84
94
|
return meta, body.lstrip("\n")
|
|
85
95
|
|
|
86
96
|
|
|
97
|
+
def _parse_inline_array(value: str) -> tuple[str, ...]:
|
|
98
|
+
"""Parse `[a, b, c]` inline-array frontmatter value into a tuple.
|
|
99
|
+
|
|
100
|
+
Returns `()` for any malformed or empty value. Quotes around items
|
|
101
|
+
are stripped. This is intentionally a tiny parser — the canonical
|
|
102
|
+
schema for skill frontmatter is enforced upstream by
|
|
103
|
+
`task lint-skills` / `scripts/validate_frontmatter.py`.
|
|
104
|
+
"""
|
|
105
|
+
v = value.strip()
|
|
106
|
+
if not (v.startswith("[") and v.endswith("]")):
|
|
107
|
+
return ()
|
|
108
|
+
inner = v[1:-1].strip()
|
|
109
|
+
if not inner:
|
|
110
|
+
return ()
|
|
111
|
+
items: list[str] = []
|
|
112
|
+
for raw in inner.split(","):
|
|
113
|
+
item = raw.strip().strip('"').strip("'")
|
|
114
|
+
if item:
|
|
115
|
+
items.append(item)
|
|
116
|
+
return tuple(items)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _load_active_user_type(root: Path) -> str:
|
|
120
|
+
"""Read `personal.user_type` from `.agent-settings.yml`.
|
|
121
|
+
|
|
122
|
+
Returns `""` when the file is missing, the key is unset, or the
|
|
123
|
+
value is still the install-time placeholder (`__USER_TYPE__`).
|
|
124
|
+
Empty string disables the runtime filter (legacy behavior — every
|
|
125
|
+
skill surfaces with its native sort order).
|
|
126
|
+
|
|
127
|
+
Tiny line-based parser to avoid a `pyyaml` runtime dependency for
|
|
128
|
+
the loader (consistent with `_strip_frontmatter`). Only matches
|
|
129
|
+
`user_type:` directly under the top-level `personal:` block.
|
|
130
|
+
"""
|
|
131
|
+
settings = root / ".agent-settings.yml"
|
|
132
|
+
if not settings.is_file():
|
|
133
|
+
return ""
|
|
134
|
+
try:
|
|
135
|
+
text = settings.read_text(encoding="utf-8")
|
|
136
|
+
except OSError:
|
|
137
|
+
return ""
|
|
138
|
+
in_personal = False
|
|
139
|
+
for raw in text.splitlines():
|
|
140
|
+
if not raw or raw.lstrip().startswith("#"):
|
|
141
|
+
continue
|
|
142
|
+
if not raw[0].isspace():
|
|
143
|
+
# Top-level key — flip in_personal based on whether it's `personal:`.
|
|
144
|
+
head = raw.split("#", 1)[0].strip().rstrip(":")
|
|
145
|
+
in_personal = head == "personal"
|
|
146
|
+
continue
|
|
147
|
+
if not in_personal:
|
|
148
|
+
continue
|
|
149
|
+
stripped = raw.strip()
|
|
150
|
+
if not stripped.startswith("user_type:"):
|
|
151
|
+
continue
|
|
152
|
+
_, _, value = stripped.partition(":")
|
|
153
|
+
value = value.split("#", 1)[0].strip().strip('"').strip("'")
|
|
154
|
+
if value.startswith("__") and value.endswith("__"):
|
|
155
|
+
return ""
|
|
156
|
+
return value
|
|
157
|
+
return ""
|
|
158
|
+
|
|
159
|
+
|
|
87
160
|
def load_skill(name: str, root: Path | None = None) -> SkillPrompt:
|
|
88
161
|
"""Load a single skill by name. Raises FileNotFoundError if missing."""
|
|
89
162
|
base = root or _project_root()
|
|
@@ -107,6 +180,9 @@ def _load_file(
|
|
|
107
180
|
body=body.rstrip() + "\n",
|
|
108
181
|
source=meta.get("source", "package"),
|
|
109
182
|
kind=kind,
|
|
183
|
+
recommended_for_user_types=_parse_inline_array(
|
|
184
|
+
meta.get("recommended_for_user_types", "")
|
|
185
|
+
),
|
|
110
186
|
)
|
|
111
187
|
|
|
112
188
|
|
|
@@ -231,20 +307,48 @@ def to_mcp_prompt_meta(prompt: SkillPrompt) -> dict[str, Any]:
|
|
|
231
307
|
Colons in command names (e.g. `research:report`) become `.` so
|
|
232
308
|
the wire identifier is a single-segment dotted path that survives
|
|
233
309
|
every MCP client we have tested.
|
|
310
|
+
|
|
311
|
+
When the user-type axis is active (`PromptCache` resolves a
|
|
312
|
+
non-empty `personal.user_type`), each prompt carries a
|
|
313
|
+
`user_type_match` label and the projected `_meta` surfaces it so
|
|
314
|
+
MCP clients can render the "outside <id> filter" collapse group.
|
|
315
|
+
Absent / empty label means filtering is off — meta is unchanged
|
|
316
|
+
from the legacy shape, preserving back-compat.
|
|
234
317
|
"""
|
|
235
318
|
if prompt.kind == "command":
|
|
236
319
|
wire = f"command.{prompt.name.replace(':', '.')}"
|
|
237
320
|
else:
|
|
238
321
|
wire = f"skill.{prompt.name}"
|
|
322
|
+
meta: dict[str, Any] = {"source": prompt.source, "kind": prompt.kind}
|
|
323
|
+
if prompt.user_type_match:
|
|
324
|
+
meta["user_type_match"] = prompt.user_type_match
|
|
239
325
|
return {
|
|
240
326
|
"name": wire,
|
|
241
327
|
"title": prompt.name,
|
|
242
328
|
"description": prompt.description,
|
|
243
329
|
"arguments": [],
|
|
244
|
-
"_meta":
|
|
330
|
+
"_meta": meta,
|
|
245
331
|
}
|
|
246
332
|
|
|
247
333
|
|
|
334
|
+
def _user_type_rank(prompt: SkillPrompt, user_type: str) -> tuple[int, UserTypeMatch]:
|
|
335
|
+
"""Return `(sort_rank, match_label)` for the step-9 axis.
|
|
336
|
+
|
|
337
|
+
Ranks (lower sorts first):
|
|
338
|
+
0 = match — user_type is in `recommended_for_user_types`
|
|
339
|
+
1 = universal — prompt declares no recommended_for_user_types
|
|
340
|
+
2 = outside — declared, but user_type is not in the list
|
|
341
|
+
|
|
342
|
+
Caller must guarantee `user_type` is non-empty (filter is on).
|
|
343
|
+
"""
|
|
344
|
+
declared = prompt.recommended_for_user_types
|
|
345
|
+
if not declared:
|
|
346
|
+
return (1, "universal")
|
|
347
|
+
if user_type in declared:
|
|
348
|
+
return (0, "match")
|
|
349
|
+
return (2, "outside")
|
|
350
|
+
|
|
351
|
+
|
|
248
352
|
class PromptCache:
|
|
249
353
|
"""In-memory cache with mtime-based invalidation (B5 hot-reload).
|
|
250
354
|
|
|
@@ -264,6 +368,7 @@ class PromptCache:
|
|
|
264
368
|
self._errors: list[str] = []
|
|
265
369
|
self._signature: tuple[tuple[str, float], ...] = ()
|
|
266
370
|
self._index: dict[str, SkillPrompt] = {}
|
|
371
|
+
self._active_user_type: str = ""
|
|
267
372
|
|
|
268
373
|
def _current_signature(self) -> tuple[tuple[str, float], ...]:
|
|
269
374
|
entries: list[tuple[str, float]] = []
|
|
@@ -278,10 +383,32 @@ class PromptCache:
|
|
|
278
383
|
for path in sorted(cmd_root.rglob("*.md")):
|
|
279
384
|
if path.is_file():
|
|
280
385
|
entries.append((str(path), path.stat().st_mtime))
|
|
386
|
+
# `.agent-settings.yml` participates in the signature so a
|
|
387
|
+
# user_type flip (re-run install with a different --user-type)
|
|
388
|
+
# invalidates the cache without needing a SKILL.md touch.
|
|
389
|
+
settings = self._root / ".agent-settings.yml"
|
|
390
|
+
if settings.is_file():
|
|
391
|
+
entries.append((str(settings), settings.stat().st_mtime))
|
|
281
392
|
return tuple(entries)
|
|
282
393
|
|
|
283
394
|
def _refresh(self) -> None:
|
|
284
395
|
prompts, errors = load_all_prompts(self._root)
|
|
396
|
+
user_type = _load_active_user_type(self._root)
|
|
397
|
+
self._active_user_type = user_type
|
|
398
|
+
if user_type:
|
|
399
|
+
# Tag every prompt with its match label and resort:
|
|
400
|
+
# match (0) → universal (1) → outside (2), then wire name.
|
|
401
|
+
tagged: list[SkillPrompt] = []
|
|
402
|
+
for prompt in prompts:
|
|
403
|
+
_rank, label = _user_type_rank(prompt, user_type)
|
|
404
|
+
tagged.append(dataclasses.replace(prompt, user_type_match=label))
|
|
405
|
+
prompts = sorted(
|
|
406
|
+
tagged,
|
|
407
|
+
key=lambda p: (
|
|
408
|
+
_user_type_rank(p, user_type)[0],
|
|
409
|
+
to_mcp_prompt_meta(p)["name"],
|
|
410
|
+
),
|
|
411
|
+
)
|
|
285
412
|
self._prompts = prompts
|
|
286
413
|
self._errors = errors
|
|
287
414
|
self._index = {to_mcp_prompt_meta(p)["name"]: p for p in prompts}
|
|
@@ -299,6 +426,11 @@ class PromptCache:
|
|
|
299
426
|
"""Cached `(path, mtime)` tuples (Phase-6 F1 input). Call `get()` first."""
|
|
300
427
|
return self._signature
|
|
301
428
|
|
|
429
|
+
@property
|
|
430
|
+
def active_user_type(self) -> str:
|
|
431
|
+
"""Currently resolved `personal.user_type` (or `""` if no filter)."""
|
|
432
|
+
return self._active_user_type
|
|
433
|
+
|
|
302
434
|
def lookup(self, wire_name: str) -> SkillPrompt | None:
|
|
303
435
|
"""Resolve an MCP wire name to its SkillPrompt, refreshing first."""
|
|
304
436
|
self.get()
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Skill-count reduction measurement — step-12 Phase 3 L74 deliverable.
|
|
3
|
+
|
|
4
|
+
Computes the skill-count reduction achieved by filtering on
|
|
5
|
+
`recommended_for_user_types` frontmatter tags. Each non-developer
|
|
6
|
+
user-type that lands ≥40% under the default-loaded skill count
|
|
7
|
+
satisfies the Phase 3 acceptance criterion.
|
|
8
|
+
|
|
9
|
+
The runtime filter (loaded vs. registered) ships with step-9; this
|
|
10
|
+
script measures the data already in place, so the box can close on
|
|
11
|
+
the basis of the underlying tagging being correct.
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
python3 scripts/measure_skill_reduction.py
|
|
15
|
+
python3 scripts/measure_skill_reduction.py --json
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
import yaml
|
|
27
|
+
except ImportError:
|
|
28
|
+
sys.stderr.write("error: PyYAML required\n")
|
|
29
|
+
sys.exit(2)
|
|
30
|
+
|
|
31
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
32
|
+
SKILLS_DIR = REPO_ROOT / ".agent-src.uncompressed" / "skills"
|
|
33
|
+
TARGET_REDUCTION = 0.40
|
|
34
|
+
PHASE_3_USER_TYPES = ("consultant", "creator")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_tags() -> tuple[int, dict[str, int]]:
|
|
38
|
+
total = 0
|
|
39
|
+
per_type: dict[str, int] = {}
|
|
40
|
+
for skill_dir in sorted(SKILLS_DIR.iterdir()):
|
|
41
|
+
skill_md = skill_dir / "SKILL.md"
|
|
42
|
+
if not skill_md.is_file():
|
|
43
|
+
continue
|
|
44
|
+
text = skill_md.read_text(encoding="utf-8")
|
|
45
|
+
if not text.startswith("---"):
|
|
46
|
+
continue
|
|
47
|
+
try:
|
|
48
|
+
fm = yaml.safe_load(text.split("---", 2)[1]) or {}
|
|
49
|
+
except yaml.YAMLError:
|
|
50
|
+
continue
|
|
51
|
+
total += 1
|
|
52
|
+
for t in fm.get("recommended_for_user_types") or []:
|
|
53
|
+
per_type[t] = per_type.get(t, 0) + 1
|
|
54
|
+
return total, per_type
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main(argv=None) -> int:
|
|
58
|
+
ap = argparse.ArgumentParser()
|
|
59
|
+
ap.add_argument("--json", action="store_true")
|
|
60
|
+
args = ap.parse_args(argv)
|
|
61
|
+
|
|
62
|
+
total, per_type = load_tags()
|
|
63
|
+
if total == 0:
|
|
64
|
+
sys.stderr.write("error: no skills found\n")
|
|
65
|
+
return 2
|
|
66
|
+
|
|
67
|
+
report = {
|
|
68
|
+
"total_skills": total,
|
|
69
|
+
"target_reduction": TARGET_REDUCTION,
|
|
70
|
+
"per_user_type": {},
|
|
71
|
+
"phase_3_user_types": list(PHASE_3_USER_TYPES),
|
|
72
|
+
"phase_3_passed": True,
|
|
73
|
+
}
|
|
74
|
+
for ut in sorted(per_type):
|
|
75
|
+
loaded = per_type[ut]
|
|
76
|
+
reduction = 1 - (loaded / total)
|
|
77
|
+
report["per_user_type"][ut] = {
|
|
78
|
+
"loaded_skills": loaded,
|
|
79
|
+
"reduction_pct": round(reduction, 4),
|
|
80
|
+
"passes_target": reduction >= TARGET_REDUCTION,
|
|
81
|
+
}
|
|
82
|
+
for ut in PHASE_3_USER_TYPES:
|
|
83
|
+
entry = report["per_user_type"].get(ut)
|
|
84
|
+
if not entry or not entry["passes_target"]:
|
|
85
|
+
report["phase_3_passed"] = False
|
|
86
|
+
|
|
87
|
+
if args.json:
|
|
88
|
+
print(json.dumps(report, indent=2))
|
|
89
|
+
else:
|
|
90
|
+
print(f"total_skills: {total} target_reduction: ≥{TARGET_REDUCTION:.0%}")
|
|
91
|
+
for ut, e in report["per_user_type"].items():
|
|
92
|
+
mark = "✓" if e["passes_target"] else "✗"
|
|
93
|
+
star = " *" if ut in PHASE_3_USER_TYPES else ""
|
|
94
|
+
print(f" {mark} {ut:12s} loaded={e['loaded_skills']:3d} "
|
|
95
|
+
f"reduction={e['reduction_pct']:.1%}{star}")
|
|
96
|
+
print(f"verdict: {'PASS' if report['phase_3_passed'] else 'FAIL'}")
|
|
97
|
+
print("(* = step-12 Phase 3 L74 anchor user-types)")
|
|
98
|
+
return 0 if report["phase_3_passed"] else 1
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
sys.exit(main())
|
|
@@ -75,6 +75,11 @@
|
|
|
75
75
|
"items": {"type": "string", "pattern": "^(skill|guideline|command|contract):"},
|
|
76
76
|
"description": "Router targets (skill / guideline / command / contract). Forbidden on kernel rules. Schema: docs/contracts/rule-router.md."
|
|
77
77
|
},
|
|
78
|
+
"applies_to_user_types": {
|
|
79
|
+
"type": "array",
|
|
80
|
+
"items": {"type": "string"},
|
|
81
|
+
"description": "Forward-compatible user-type filter (step-12-universal-os-reframe Phase 4). Rule loads only when the active user-type matches one of these tags. Wired by step-9-user-types-axis once it lands; treated as a no-op gate until then. Free-form tags (e.g. 'support', 'finance', 'recruiting', 'marketing', 'legal-drafting', 'consulting', 'medical-drafting', 'finance-drafting', 'ops', 'analytics-export', 'all') — no enum until the user-types axis closes."
|
|
82
|
+
},
|
|
78
83
|
"profile": {
|
|
79
84
|
"type": "string",
|
|
80
85
|
"enum": ["minimal", "balanced", "full"],
|
|
@@ -80,6 +80,12 @@
|
|
|
80
80
|
},
|
|
81
81
|
"description": "Senior-skill opt-in for the context spine. Declares which slots under agents/context-spine/ the skill expects to read. Cross-wing slots (product, team, repo) are locked at 3 by council Q1 (KEEP-3); wing-scoped slots follow the per-wing ADR track in docs/contracts/context-spine.md § 5. Wing-3 (channel-stage, funnel-stage, customer-segment) authorized by docs/contracts/adr-gtm-context-spine.md; Wing-4 (fiscal-period, org-stage, regulatory-regime) authorized by docs/contracts/adr-wing4-context-spine.md."
|
|
82
82
|
},
|
|
83
|
+
"recommended_for_user_types": {
|
|
84
|
+
"type": "array",
|
|
85
|
+
"uniqueItems": true,
|
|
86
|
+
"items": {"type": "string"},
|
|
87
|
+
"description": "Forward-compatible user-type recommendation tags (step-12-universal-os-reframe Phase 5). Skill loads for every user-type whose slug appears in this list. Absence of the key marks the skill as universal — see docs/contracts/universal-skills.md for the always-loaded floor and docs/contracts/router-blending.md for per-user-type mix ratios. Wired by step-9-user-types-axis once it lands; treated as metadata until then. Free-form tags (e.g. 'creator', 'founder', 'consultant', 'gtm', 'finance', 'ops', 'developer') — no enum until the user-types axis closes."
|
|
88
|
+
},
|
|
83
89
|
"execution": {
|
|
84
90
|
"type": "object",
|
|
85
91
|
"additionalProperties": false,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://github.com/event4u-app/agent-config/scripts/schemas/user-type-axis.schema.json",
|
|
4
|
+
"title": "User-type axis (install filter)",
|
|
5
|
+
"$comment": "Source: user-types/README.md § Schema. One file per user-type under user-types/<id>.yml. `id` MUST equal the filename stem. Owning roadmap: agents/roadmaps/step-9-user-types-axis.md.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["id", "description"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"id": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"pattern": "^[a-z][a-z0-9-]*$",
|
|
13
|
+
"minLength": 2,
|
|
14
|
+
"maxLength": 40,
|
|
15
|
+
"description": "Stable slug. MUST equal the filename stem and the value used in skills' `recommended_for_user_types` frontmatter."
|
|
16
|
+
},
|
|
17
|
+
"description": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"minLength": 1,
|
|
20
|
+
"maxLength": 240,
|
|
21
|
+
"description": "One-line audience description shown in install.sh --help and the user-types/README.md index."
|
|
22
|
+
},
|
|
23
|
+
"primary_workflows": {
|
|
24
|
+
"type": "array",
|
|
25
|
+
"minItems": 1,
|
|
26
|
+
"maxItems": 12,
|
|
27
|
+
"uniqueItems": true,
|
|
28
|
+
"items": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"pattern": "^[a-z][a-z0-9_]*$",
|
|
31
|
+
"minLength": 2,
|
|
32
|
+
"maxLength": 60
|
|
33
|
+
},
|
|
34
|
+
"description": "Snake-case verb-phrases describing what this user-type does most days. Informs which skills surface first."
|
|
35
|
+
},
|
|
36
|
+
"default_skill_priority": {
|
|
37
|
+
"type": "array",
|
|
38
|
+
"minItems": 1,
|
|
39
|
+
"maxItems": 12,
|
|
40
|
+
"uniqueItems": true,
|
|
41
|
+
"items": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"pattern": "^[a-z][a-z0-9-]*$",
|
|
44
|
+
"minLength": 2,
|
|
45
|
+
"maxLength": 60
|
|
46
|
+
},
|
|
47
|
+
"description": "Hand-curated short-list of 3–6 skill stems that should auto-load on `install.sh --user-type=<id>`."
|
|
48
|
+
},
|
|
49
|
+
"notes": {
|
|
50
|
+
"type": "string",
|
|
51
|
+
"minLength": 1,
|
|
52
|
+
"maxLength": 480,
|
|
53
|
+
"description": "Optional one-line caveat (overlap with sibling user-types, legacy semantics, etc.)."
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -113,6 +113,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
113
113
|
print(f"error: unsupported profile {profile!r}", file=sys.stderr)
|
|
114
114
|
return 2
|
|
115
115
|
profile_values = load_profile(profile_dir, profile)
|
|
116
|
+
# Preserve existing user_type (step-9 axis) so the template's
|
|
117
|
+
# __USER_TYPE__ placeholder renders without forcing the user to
|
|
118
|
+
# re-pass --user-type on every sync. Empty string = no filter.
|
|
119
|
+
personal = user_data.get("personal") if isinstance(user_data.get("personal"), dict) else {}
|
|
120
|
+
existing_user_type = str(personal.get("user_type") or "") if personal else ""
|
|
121
|
+
profile_values["user_type"] = existing_user_type
|
|
116
122
|
template_body = load_template(template_path, profile_values)
|
|
117
123
|
except FileNotFoundError as exc:
|
|
118
124
|
print(f"error: {exc}", file=sys.stderr)
|