@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.
Files changed (96) hide show
  1. package/.agent-src/commands/ghostwriter/delete.md +118 -0
  2. package/.agent-src/commands/ghostwriter/fetch.md +185 -0
  3. package/.agent-src/commands/ghostwriter/list.md +102 -0
  4. package/.agent-src/commands/ghostwriter/show.md +113 -0
  5. package/.agent-src/commands/ghostwriter/write.md +160 -0
  6. package/.agent-src/commands/ghostwriter.md +96 -0
  7. package/.agent-src/commands/post-as/ghostwriter.md +66 -0
  8. package/.agent-src/commands/post-as/me.md +124 -0
  9. package/.agent-src/commands/post-as.md +58 -0
  10. package/.agent-src/ghostwriter/README.md +61 -0
  11. package/.agent-src/ghostwriter/fictional-fixture-v1.md +94 -0
  12. package/.agent-src/personas/README.md +8 -0
  13. package/.agent-src/rules/domain-safety-disclaimer-consulting.md +52 -0
  14. package/.agent-src/rules/domain-safety-disclaimer-financial.md +54 -0
  15. package/.agent-src/rules/domain-safety-disclaimer-legal.md +49 -0
  16. package/.agent-src/rules/domain-safety-disclaimer-medical.md +56 -0
  17. package/.agent-src/rules/domain-safety-export-redact.md +65 -0
  18. package/.agent-src/rules/domain-safety-logging-pii-floor.md +55 -0
  19. package/.agent-src/rules/domain-safety-pii-finance.md +57 -0
  20. package/.agent-src/rules/domain-safety-pii-marketing.md +60 -0
  21. package/.agent-src/rules/domain-safety-pii-recruiting.md +56 -0
  22. package/.agent-src/rules/domain-safety-pii-support.md +57 -0
  23. package/.agent-src/rules/domain-safety-retention-finance.md +48 -0
  24. package/.agent-src/rules/domain-safety-retention-support.md +55 -0
  25. package/.agent-src/skills/api-design/SKILL.md +3 -0
  26. package/.agent-src/skills/authz-review/SKILL.md +3 -0
  27. package/.agent-src/skills/competitive-moat-analysis/SKILL.md +3 -0
  28. package/.agent-src/skills/competitive-positioning/SKILL.md +3 -0
  29. package/.agent-src/skills/content-funnel-design/SKILL.md +3 -0
  30. package/.agent-src/skills/contracts-cognition/SKILL.md +3 -0
  31. package/.agent-src/skills/dashboard-design/SKILL.md +3 -0
  32. package/.agent-src/skills/data-handling-judgment/SKILL.md +3 -0
  33. package/.agent-src/skills/dcf-modeling/SKILL.md +3 -0
  34. package/.agent-src/skills/deal-qualification-meddic/SKILL.md +3 -0
  35. package/.agent-src/skills/discovery-interview/SKILL.md +3 -0
  36. package/.agent-src/skills/editorial-calendar/SKILL.md +3 -0
  37. package/.agent-src/skills/forecast-accuracy/SKILL.md +3 -0
  38. package/.agent-src/skills/forecasting/SKILL.md +3 -0
  39. package/.agent-src/skills/fundraising-narrative/SKILL.md +3 -0
  40. package/.agent-src/skills/gtm-launch/SKILL.md +3 -0
  41. package/.agent-src/skills/incident-commander/SKILL.md +3 -0
  42. package/.agent-src/skills/launch-readiness/SKILL.md +3 -0
  43. package/.agent-src/skills/messaging-architecture/SKILL.md +3 -0
  44. package/.agent-src/skills/okr-tree-modeling/SKILL.md +3 -0
  45. package/.agent-src/skills/pipeline-strategy/SKILL.md +3 -0
  46. package/.agent-src/skills/playwright-architect/SKILL.md +3 -0
  47. package/.agent-src/skills/privacy-review/SKILL.md +4 -1
  48. package/.agent-src/skills/quality-tools/SKILL.md +3 -0
  49. package/.agent-src/skills/release-comms/SKILL.md +3 -0
  50. package/.agent-src/skills/runway-cognition/SKILL.md +3 -0
  51. package/.agent-src/skills/scenario-modeling/SKILL.md +3 -0
  52. package/.agent-src/skills/secrets-management/SKILL.md +3 -0
  53. package/.agent-src/skills/tech-debt-tracker/SKILL.md +3 -0
  54. package/.agent-src/skills/unit-economics-modeling/SKILL.md +3 -0
  55. package/.agent-src/skills/voc-extract/SKILL.md +3 -0
  56. package/.agent-src/skills/voice-and-tone-design/SKILL.md +3 -0
  57. package/.agent-src/templates/agents/agent-project-settings.example.yml +16 -1
  58. package/.claude-plugin/marketplace.json +10 -1
  59. package/CHANGELOG.md +98 -0
  60. package/README.md +44 -23
  61. package/config/agent-settings.template.yml +7 -0
  62. package/config/gitignore-block.txt +8 -0
  63. package/docs/announcements/2026-05-non-dev-launch.md +79 -0
  64. package/docs/architecture.md +2 -2
  65. package/docs/case-studies/_template.md +60 -0
  66. package/docs/catalog.md +25 -4
  67. package/docs/contracts/adr-install-user-type-axis.md +107 -0
  68. package/docs/contracts/agent-user-schema.md +1 -0
  69. package/docs/contracts/command-clusters.md +2 -0
  70. package/docs/contracts/file-ownership-matrix.json +490 -0
  71. package/docs/contracts/ghostwriter-schema.md +337 -0
  72. package/docs/contracts/init-telemetry.md +132 -0
  73. package/docs/contracts/router-blending.md +71 -0
  74. package/docs/contracts/universal-skills.md +92 -0
  75. package/docs/contracts/write-engine.md +142 -0
  76. package/docs/getting-started-by-role.md +89 -0
  77. package/docs/getting-started-laravel.md +72 -0
  78. package/docs/getting-started.md +2 -2
  79. package/docs/safety.md +30 -0
  80. package/package.json +1 -1
  81. package/scripts/audit_user_type_axis.py +140 -0
  82. package/scripts/bench_runner.py +158 -0
  83. package/scripts/check_role_doc_links.py +110 -0
  84. package/scripts/compress.py +11 -0
  85. package/scripts/ghostwriter_fixture_allowlist.txt +16 -0
  86. package/scripts/install +9 -1
  87. package/scripts/install.py +214 -8
  88. package/scripts/install.sh +7 -0
  89. package/scripts/lint_ghostwriter_source.py +240 -0
  90. package/scripts/mcp_server/prompts.py +134 -2
  91. package/scripts/measure_skill_reduction.py +102 -0
  92. package/scripts/schemas/rule.schema.json +5 -0
  93. package/scripts/schemas/skill.schema.json +6 -0
  94. package/scripts/schemas/user-type-axis.schema.json +56 -0
  95. package/scripts/sync_agent_settings.py +6 -0
  96. 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
- from dataclasses import dataclass
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": {"source": prompt.source, "kind": prompt.kind},
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)