@event4u/agent-config 2.17.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.
@@ -39,7 +39,7 @@ schema_version: 1
39
39
  # CI guard: a release bump of `package.json` must update this value
40
40
  # in lockstep — see scripts/check_template_pin_drift.py (road-to-
41
41
  # portable-runtime-and-update-check P3.3).
42
- agent_config_version: "2.16.0"
42
+ agent_config_version: "2.17.0"
43
43
 
44
44
  # --- Project identity ---
45
45
  project:
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Shared agent configuration \u2014 skills for AI coding tools (Claude Code, Augment, Cursor, Cline, Windsurf, Gemini CLI).",
9
- "version": "2.17.0",
9
+ "version": "2.18.0",
10
10
  "keywords": [
11
11
  "agent-config",
12
12
  "skills",
package/CHANGELOG.md CHANGED
@@ -702,6 +702,35 @@ our recommendation order, not its support status.
702
702
  > that forces a new era split (`# Era: 2.16.x`, etc.) — see
703
703
  > [`docs/contracts/CHANGELOG-conventions.md § Era splits`](docs/contracts/CHANGELOG-conventions.md).
704
704
 
705
+ ## [2.18.0](https://github.com/event4u-app/agent-config/compare/2.17.0...2.18.0) (2026-05-16)
706
+
707
+ ### Features
708
+
709
+ * **lint:** user-type axis frontmatter audit + task wiring ([322bf1d](https://github.com/event4u-app/agent-config/commit/322bf1dc805550cb8c234808bde4235aaf0ba39e))
710
+ * **mcp:** filter skill prompts by personal.user_type ([0b09911](https://github.com/event4u-app/agent-config/commit/0b09911ea6ed2870b12b52ee67a922c3df1f919c))
711
+ * **install:** wire --user-type flag across install entrypoints ([6589c6b](https://github.com/event4u-app/agent-config/commit/6589c6bce4682d521c12727adc4903e7333a421d))
712
+ * **install:** add user_type schema + template placeholder + ADR ([3ede84d](https://github.com/event4u-app/agent-config/commit/3ede84d79ce0d3612ae6d2a862ae239cb01f8762))
713
+
714
+ ### Documentation
715
+
716
+ * **roadmaps:** close step-9 user-types axis + flip parent step-12 ([f1926dc](https://github.com/event4u-app/agent-config/commit/f1926dc6905128bfcb909dc03e47a37c7754e4e3))
717
+
718
+ ### Tests
719
+
720
+ * **install:** cover --user-type + sync user_type preservation ([349478f](https://github.com/event4u-app/agent-config/commit/349478fccc608a2a9818585f7c2a43368ee076c0))
721
+
722
+ ### Chores
723
+
724
+ * **docs:** drop broken step-12 link from getting-started-by-role ([351505a](https://github.com/event4u-app/agent-config/commit/351505a8fbc6626db5dd26cea494d2a4dbaead89))
725
+ * **contracts:** inline council verdict + pragma roadmap source-trails ([4442030](https://github.com/event4u-app/agent-config/commit/4442030543b02511ced3643d9c6062e0722448d9))
726
+ * **contracts:** drop transient roadmap refs from stable artifacts ([ecad21e](https://github.com/event4u-app/agent-config/commit/ecad21e16b7269257ba95d104b2dce06a9f140a5))
727
+ * **contracts:** align keep-beta-until with 90-day window cap ([5c87588](https://github.com/event4u-app/agent-config/commit/5c8758854d19f5790b21bba41c4631e8460caa5e))
728
+ * **roadmaps:** retag step-13/14 complexity to lightweight ([e87cd35](https://github.com/event4u-app/agent-config/commit/e87cd35483cb16eb39574a10318c2f9cb720e9a7))
729
+ * **template:** bump agent_config_version pin to 2.17.0 ([f6bb24e](https://github.com/event4u-app/agent-config/commit/f6bb24e1267623bd49dffd1deb0c9ae071ea4906))
730
+ * **index:** regenerate index after upstream privacy-review desc edit ([fd7fe24](https://github.com/event4u-app/agent-config/commit/fd7fe248b2297021912ec41aff5dd5b1596c9989))
731
+
732
+ Tests: 4476 (+17 since 2.17.0)
733
+
705
734
  ## [2.17.0](https://github.com/event4u-app/agent-config/compare/2.16.0...2.17.0) (2026-05-15)
706
735
 
707
736
  ### Features
@@ -73,6 +73,13 @@ personal:
73
73
  # See rules/autonomous-execution.md for the full definition.
74
74
  autonomy: auto
75
75
 
76
+ # Primary user-type for skill filtering (step-9 axis).
77
+ # Empty string ("") = no filter (legacy behavior — every skill surfaces).
78
+ # Set this via `bash scripts/install --user-type=<id>` or hand-edit later.
79
+ # Valid ids: consultant | creator | developer | finance | founder | gtm | ops
80
+ # See user-types/README.md for the per-id contract.
81
+ user_type: "__USER_TYPE__"
82
+
76
83
  # --- Project / team preferences ---
77
84
  project:
78
85
  # Path to the PR template file (relative to project root)
package/docs/catalog.md CHANGED
@@ -148,7 +148,7 @@ are excluded.
148
148
  | skill | [`playwright-testing`](../.agent-src/skills/playwright-testing/SKILL.md) | | Use when writing Playwright E2E tests — browser automation, visual regression testing, Page Objects, fixtures, and reliable test patterns. |
149
149
  | skill | [`po-discovery`](../.agent-src/skills/po-discovery/SKILL.md) | | Use when shaping a fuzzy product ask into a refined backlog item — problem framing, user-story rewrite, AC tightening — even if the user just says 'help me write this ticket'. |
150
150
  | skill | [`positioning-strategy`](../.agent-src/skills/positioning-strategy/SKILL.md) | | Use when locking the market frame — category, segment, alternative, point-of-view — before messaging, launch, or pricing rides on it. Triggers on 'who are we for', 'opposable audit'. |
151
- | skill | [`privacy-review`](../.agent-src/skills/privacy-review/SKILL.md) | | Use when reviewing data flows for GDPR / CCPA / HIPAA fit — regulatory-regime delta, consent shape, breach-impact triage. Triggers on 'is this GDPR-safe', 'do we need a DPA'. |
151
+ | skill | [`privacy-review`](../.agent-src/skills/privacy-review/SKILL.md) | | Use when reviewing data flows, support macros, refund templates for GDPR/CCPA/HIPAA fit — regime, consent, PII redaction (email, order-id), breach triage. Triggers 'is this GDPR-safe', 'PII redact'. |
152
152
  | skill | [`project-analysis-core`](../.agent-src/skills/project-analysis-core/SKILL.md) | | Raw discovery primitives — project discovery, version resolution, docs loading, architecture mapping, execution flow. Called by `universal-project-analysis`. Single-pass scan → `project-analyzer`. |
153
153
  | skill | [`project-analysis-hypothesis-driven`](../.agent-src/skills/project-analysis-hypothesis-driven/SKILL.md) | | Use when a bug has multiple plausible causes across layers — competing hypotheses, validation loops, evidence-based conclusions — even when the user just says 'why is this happening?'. |
154
154
  | skill | [`project-analysis-laravel`](../.agent-src/skills/project-analysis-laravel/SKILL.md) | | Use for deep Laravel project analysis: boot flow, request lifecycle, container usage, Eloquent/data flow, async systems, and Laravel-specific failure patterns. |
@@ -0,0 +1,107 @@
1
+ ---
2
+ stability: beta
3
+ keep-beta-until: 2026-08-13
4
+ ---
5
+
6
+ # ADR — Install-time user-type axis
7
+
8
+ > **Status:** Decided · 2026-05-15
9
+ > **Source:** AI Council session 2026-05-15-step12-closure-run2 (Reviewer-A, Reviewer-B, Reviewer-C) D1 verdict — ACCEPT with amendments: seed all 7 user-types, cap roadmap at ≤5 phases, cap deliverables per phase.
10
+ > **Sibling axis (distinct layer):** the runtime `personas/` ladder. The
11
+ > install-time `user_type` axis filters *which skills load*; personas filter
12
+ > *which voice reviews*. The two compose orthogonally.
13
+
14
+ ## Decision
15
+
16
+ Install-time skill filtering uses a dedicated axis seeded under
17
+ `user-types/` (package root, not `.agent-src.uncompressed/`). The
18
+ selection lands in the consumer's `.agent-settings.yml` under
19
+ `personal.user_type: <id>` via `agent-config install --user-type=<id>`.
20
+ Runtime skill discovery intersects each skill's
21
+ `recommended_for_user_types` frontmatter against
22
+ `personal.user_type`; unset → "show all skills" (legacy behavior).
23
+
24
+ The `user-types/` directory holds **seven** YAML configs — `consultant`,
25
+ `creator`, `developer`, `finance`, `founder`, `gtm`, `ops` — matching
26
+ every value already in active use across 32 skills' frontmatter. Adding
27
+ an eighth value requires a new YAML plus a frontmatter audit (Phase 4).
28
+ Schema: [`scripts/schemas/user-type-axis.schema.json`](../../scripts/schemas/user-type-axis.schema.json).
29
+
30
+ ## Why this is distinct from the review-lens axis
31
+
32
+ Same vocabulary, different layer:
33
+
34
+ | Axis | Layer | Owner roadmap | Where it bites |
35
+ |---|---|---|---|
36
+ | **Install filter** (this ADR) | onboarding / `.agent-settings.yml` | `step-9-user-types-axis.md` | Which skills surface first at discovery |
37
+ | **Review lens** | runtime / `refine-ticket` | `step-6-user-types-axis.md` | Whose viewpoint a review adopts |
38
+
39
+ The two never collide because they live in different files and
40
+ different config keys. Install filter narrows *which skills load by
41
+ default*; review lens narrows *whose voice a refine-ticket review
42
+ adopts*. Authors can use both, neither, or one — independently.
43
+
44
+ The naming overlap is preserved deliberately. Consumers think in terms
45
+ of "I am a consultant" once; both axes consume that fact in their own
46
+ layer. Renaming one would force consumers to learn a second vocabulary
47
+ for the same self-identification.
48
+
49
+ ## Why install-time and not runtime-only
50
+
51
+ Three options were considered:
52
+
53
+ 1. **Runtime-only filter** — read `personal.user_type` on every
54
+ session start, no install-time wiring. Rejected: needs the value
55
+ to exist before any skill loads; bootstrapping into an empty
56
+ `.agent-settings.yml` is fragile and undocumented.
57
+ 2. **Install-time flag, no runtime filter** — `--user-type` writes the
58
+ key, but skill discovery ignores it. Rejected: the value is dead
59
+ metadata without the discovery hook (Phase 3). Already the state of
60
+ the codebase before this roadmap; the whole point of step-9 is to
61
+ wire it.
62
+ 3. **Install-time flag + runtime filter** *(chosen)* — `--user-type`
63
+ writes the key; the discovery hook intersects frontmatter against
64
+ it. Legacy "no flag" path is preserved by treating an unset value
65
+ as "match all". Additive, opt-in, no breaking change.
66
+
67
+ ## Consequences
68
+
69
+ - `scripts/install.sh`, `scripts/install.py`, `scripts/install` each
70
+ accept `--user-type=<id>`. Validation against `user-types/*.yml`
71
+ stems happens in one place (`install.py`) and the bash entry-point
72
+ delegates. Invalid values fail fast with a non-zero exit.
73
+ - `config/agent-settings.template.yml` keeps a commented
74
+ `personal.user_type:` stub documenting the seven valid values.
75
+ - The existing `--interactive` flag (legacy `.agent-config.local.json`
76
+ stub) is preserved for backward compat; the new flag is the
77
+ first-class path going forward. A future ADR can deprecate the JSON
78
+ stub once consumers migrate.
79
+ - Frontmatter audit (Phase 4) catches drift: every value used in
80
+ `recommended_for_user_types` MUST have a corresponding YAML, and
81
+ every YAML SHOULD be consumed by at least one skill. The audit ships
82
+ as a CI gate (`task lint-user-type-axis` →
83
+ [`scripts/audit_user_type_axis.py`](../../scripts/audit_user_type_axis.py)).
84
+ Initial sweep at Phase 4 close: 7 declared / 7 used / 0 orphans / 0
85
+ unused — coverage is clean, no rename or backfill needed. Report:
86
+ [`agents/reports/user-type-axis-audit.md`](../../agents/reports/user-type-axis-audit.md).
87
+ - Adding an 8th value is non-trivial by design — it requires a YAML, a
88
+ schema-compatible frontmatter rollout, and a roadmap entry. The
89
+ friction is the feature: a sprawling axis defeats the purpose of
90
+ filtering.
91
+
92
+ ## Open questions deferred
93
+
94
+ - Skill-loader hook location is left to Phase 3 — likely
95
+ `scripts/skill_linter.py`'s discovery pass plus a runtime helper for
96
+ agent hosts that read the axis directly.
97
+ - Whether to surface a "show outside `<id>` filter" affordance in
98
+ agent hosts is left to host-side UX, not enforced by this ADR.
99
+ - The `default_skill_priority` field is informational in v1 (no
100
+ loader consumes it yet). Phase 3 may promote it to a sort key.
101
+
102
+ ## See also
103
+
104
+ - [`user-types/README.md`](../../user-types/README.md) — schema + seed table
105
+ - [`docs/contracts/universal-skills.md`](universal-skills.md) — always-loaded floor
106
+ - [`docs/contracts/rule-router.md`](rule-router.md) — kernel + router
107
+ architecture (sibling but orthogonal axis)
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  stability: beta
3
- keep-beta-until: 2026-08-15
3
+ keep-beta-until: 2026-08-13
4
4
  ---
5
5
 
6
6
  # Init Telemetry v1
7
7
 
8
- > **Status:** beta · **Owner:** step-12 Phase 7 (`agents/roadmaps/step-12-universal-os-reframe.md`) · **Depends on:** [`step-9-user-types-axis`](../../agents/roadmaps/step-99-north-star-restructure.md) telemetry wire-up · **Stability:** additive only
8
+ > **Status:** beta · **Depends on:** install-time `user_type` axis telemetry wire-up · **Stability:** additive only
9
9
 
10
10
  ## Purpose
11
11
 
@@ -123,7 +123,6 @@ When `telemetry.init: false`, the producer:
123
123
 
124
124
  ## Cross-references
125
125
 
126
- - [`step-12-universal-os-reframe.md`](../../agents/roadmaps/step-12-universal-os-reframe.md) Phase 7 L127 — the box this contract pre-authors.
127
126
  - [`universal-skills.md`](universal-skills.md) — sibling contract for the allowlist this telemetry validates against.
128
127
  - [`router-blending.md`](router-blending.md) — the user-type → skill blend that telemetry confirms is being used.
129
128
  - [`STABILITY.md`](STABILITY.md) — schema_version bump rules.
@@ -4,7 +4,7 @@
4
4
 
5
5
  `agent-config` ships ~210 skills, ~67 rules, and ~124 commands. You do not need all of them. Each role below filters to the slice that pays back in week one; the rest stays available and shows up on demand when a task references it.
6
6
 
7
- > **Eval-gated messaging note.** Until `task bench --corpus non-dev` reports `selection_accuracy >= 0.60` (step-12 Phase 1 exit), this page is documentation, not marketing. The skills listed below are the candidates the corpus tests against; their description quality is what the eval validates. See [`agents/roadmaps/step-12-universal-os-reframe.md`](../agents/roadmaps/step-12-universal-os-reframe.md).
7
+ > **Eval-gated messaging note.** Until `task bench --corpus non-dev` reports `selection_accuracy >= 0.60` (step-12 Phase 1 exit), this page is documentation, not marketing. The skills listed below are the candidates the corpus tests against; their description quality is what the eval validates.
8
8
 
9
9
  ---
10
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@event4u/agent-config",
3
- "version": "2.17.0",
3
+ "version": "2.18.0",
4
4
  "description": "Shared agent configuration \u2014 skills, rules, commands, guidelines, and templates for AI coding tools",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env python3
2
+ """Audit the user-type axis frontmatter coverage (step-9 Phase 4).
3
+
4
+ Two checks across `.agent-src.uncompressed/skills/`:
5
+
6
+ 1. **Orphan values** — every `recommended_for_user_types` value must have
7
+ a corresponding `user-types/<value>.yml` config. Orphans are FATAL
8
+ (exit 1) — they imply the runtime filter would tag prompts against a
9
+ user-type with no documented identity.
10
+ 2. **Unused configs** — every `user-types/*.yml` should be consumed by
11
+ at least one skill. Unused configs are WARN-only (exit 0): seeding
12
+ future identities ahead of consumption is allowed.
13
+
14
+ Writes a markdown report to `agents/reports/user-type-axis-audit.md` and
15
+ emits a one-line summary to stdout. Stdlib-only — no PyYAML dependency.
16
+
17
+ Usage:
18
+ python3 scripts/audit_user_type_axis.py # human report + exit code
19
+ python3 scripts/audit_user_type_axis.py --quiet # exit code only
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ import sys
26
+ from collections import defaultdict
27
+ from pathlib import Path
28
+
29
+ REPO_ROOT = Path(__file__).resolve().parent.parent
30
+ SKILLS_ROOT = REPO_ROOT / ".agent-src.uncompressed" / "skills"
31
+ USER_TYPES_ROOT = REPO_ROOT / "user-types"
32
+ REPORT_PATH = REPO_ROOT / "agents" / "reports" / "user-type-axis-audit.md"
33
+
34
+ _FRONTMATTER_LINE = re.compile(
35
+ r"^recommended_for_user_types:\s*\[([^\]]*)\]\s*$",
36
+ re.MULTILINE,
37
+ )
38
+
39
+
40
+ def _declared_user_types() -> set[str]:
41
+ """Read `user-types/*.yml` stems (one identity per YAML file)."""
42
+ if not USER_TYPES_ROOT.is_dir():
43
+ return set()
44
+ return {p.stem for p in USER_TYPES_ROOT.glob("*.yml")}
45
+
46
+
47
+ def _scan_skill_values() -> dict[str, list[Path]]:
48
+ """Map every frontmatter user-type value → list of declaring SKILL.md paths."""
49
+ by_value: dict[str, list[Path]] = defaultdict(list)
50
+ if not SKILLS_ROOT.is_dir():
51
+ return by_value
52
+ for skill_md in sorted(SKILLS_ROOT.rglob("SKILL.md")):
53
+ text = skill_md.read_text(encoding="utf-8", errors="replace")
54
+ # Frontmatter is the leading `---` block — strip everything after.
55
+ if text.startswith("---\n"):
56
+ end = text.find("\n---", 4)
57
+ fm = text[4:end] if end >= 0 else text
58
+ else:
59
+ fm = text[:4096]
60
+ match = _FRONTMATTER_LINE.search(fm)
61
+ if not match:
62
+ continue
63
+ for raw in match.group(1).split(","):
64
+ value = raw.strip().strip('"').strip("'")
65
+ if value:
66
+ by_value[value].append(skill_md.relative_to(REPO_ROOT))
67
+ return by_value
68
+
69
+
70
+ def _render_report(
71
+ declared: set[str],
72
+ by_value: dict[str, list[Path]],
73
+ orphans: set[str],
74
+ unused: set[str],
75
+ ) -> str:
76
+ lines: list[str] = [
77
+ "# User-type axis — frontmatter coverage audit",
78
+ "",
79
+ "Generated by `scripts/audit_user_type_axis.py` (step-9 Phase 4).",
80
+ "",
81
+ f"- Declared user-types (`user-types/*.yml`): **{len(declared)}**",
82
+ f"- Distinct frontmatter values across skills: **{len(by_value)}**",
83
+ f"- Orphans (FATAL): **{len(orphans)}**",
84
+ f"- Unused configs (WARN): **{len(unused)}**",
85
+ "",
86
+ "## Coverage matrix",
87
+ "",
88
+ "| user-type | declared | consuming skills |",
89
+ "| --- | --- | --- |",
90
+ ]
91
+ for ut in sorted(declared | set(by_value)):
92
+ flag_declared = "yes" if ut in declared else "**no (orphan)**"
93
+ count = len(by_value.get(ut, []))
94
+ lines.append(f"| `{ut}` | {flag_declared} | {count} |")
95
+ if orphans:
96
+ lines.extend(["", "## Orphans", ""])
97
+ for orphan in sorted(orphans):
98
+ lines.append(f"- `{orphan}` — referenced by:")
99
+ for path in by_value[orphan]:
100
+ lines.append(f" - `{path}`")
101
+ if unused:
102
+ lines.extend(["", "## Unused configs (WARN)", ""])
103
+ for stem in sorted(unused):
104
+ lines.append(f"- `user-types/{stem}.yml` has no consuming skill yet.")
105
+ lines.append("")
106
+ return "\n".join(lines)
107
+
108
+
109
+ def main(argv: list[str]) -> int:
110
+ quiet = "--quiet" in argv
111
+ declared = _declared_user_types()
112
+ by_value = _scan_skill_values()
113
+ used = set(by_value)
114
+ orphans = used - declared
115
+ unused = declared - used
116
+
117
+ report = _render_report(declared, by_value, orphans, unused)
118
+ REPORT_PATH.parent.mkdir(parents=True, exist_ok=True)
119
+ REPORT_PATH.write_text(report, encoding="utf-8")
120
+
121
+ if not quiet:
122
+ sys.stdout.write(
123
+ f"user-type-axis audit — declared={len(declared)} "
124
+ f"used={len(used)} orphans={len(orphans)} unused={len(unused)}\n"
125
+ )
126
+ if orphans:
127
+ sys.stdout.write(
128
+ " FAIL orphans: " + ", ".join(sorted(orphans)) + "\n"
129
+ )
130
+ if unused:
131
+ sys.stdout.write(
132
+ " warn unused: " + ", ".join(sorted(unused)) + "\n"
133
+ )
134
+ sys.stdout.write(f" report: {REPORT_PATH.relative_to(REPO_ROOT)}\n")
135
+
136
+ return 1 if orphans else 0
137
+
138
+
139
+ if __name__ == "__main__":
140
+ sys.exit(main(sys.argv[1:]))
package/scripts/install CHANGED
@@ -15,6 +15,10 @@
15
15
  # --source <dir> Package source directory (default: auto-detect)
16
16
  # --target <dir> Target project root (default: cwd)
17
17
  # --profile <name> Cost profile for bridges (minimal|balanced|full)
18
+ # --user-type <id> Primary user-type for skill filtering (step-9 axis).
19
+ # Valid ids: consultant | creator | developer | finance
20
+ # | founder | gtm | ops. Default: empty (no filter).
21
+ # Written to personal.user_type in .agent-settings.yml.
18
22
  # --tools <list> Comma-separated tool IDs to install (default: all).
19
23
  # Valid: claude-code,claude-desktop,cursor,windsurf,
20
24
  # cline,gemini-cli,copilot,augment,aider,codex,
@@ -82,12 +86,13 @@ SCOPE=""
82
86
  CUSTOM_PATH=""
83
87
  OFFLINE=false
84
88
  MINIMAL=false
89
+ USER_TYPE=""
85
90
 
86
91
  # Single source of truth for valid tool IDs (also referenced by install.sh / install.py).
87
92
  VALID_TOOLS="claude-code claude-desktop cursor windsurf cline gemini-cli copilot augment aider codex roocode continue kilocode zed jetbrains kiro all"
88
93
 
89
94
  show_help() {
90
- sed -n '3,54p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
95
+ sed -n '3,58p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
91
96
  }
92
97
 
93
98
  list_tools() {
@@ -146,6 +151,8 @@ while [[ $# -gt 0 ]]; do
146
151
  --target=*) TARGET_DIR="${1#*=}"; shift ;;
147
152
  --profile) PROFILE="$2"; shift 2 ;;
148
153
  --profile=*) PROFILE="${1#*=}"; shift ;;
154
+ --user-type) USER_TYPE="$2"; shift 2 ;;
155
+ --user-type=*) USER_TYPE="${1#*=}"; shift ;;
149
156
  --tools) TOOLS="${TOOLS:+$TOOLS,}$2"; TOOLS_EXPLICIT=true; shift 2 ;;
150
157
  --tools=*) TOOLS="${TOOLS:+$TOOLS,}${1#*=}"; TOOLS_EXPLICIT=true; shift ;;
151
158
  --ai) TOOLS="${TOOLS:+$TOOLS,}$2"; TOOLS_EXPLICIT=true; shift 2 ;;
@@ -309,6 +316,7 @@ run_bridges() {
309
316
 
310
317
  local args=(--project "$TARGET_DIR" --package "$SOURCE_DIR")
311
318
  [[ -n "$PROFILE" ]] && args+=(--profile="$PROFILE")
319
+ [[ -n "$USER_TYPE" ]] && args+=(--user-type="$USER_TYPE")
312
320
  $FORCE && args+=(--force)
313
321
  $QUIET && args+=(--quiet)
314
322
  $GLOBAL && args+=(--global)
@@ -45,6 +45,8 @@ except ImportError: # pragma: no cover — alt sys.path layout
45
45
  DEFAULT_PROFILE = "balanced"
46
46
  SUPPORTED_PROFILES = ("minimal", "balanced", "full")
47
47
  COST_PROFILE_PLACEHOLDER = "__COST_PROFILE__"
48
+ USER_TYPE_PLACEHOLDER = "__USER_TYPE__"
49
+ USER_TYPES_DIR = "user-types"
48
50
 
49
51
  # Env-var equivalent of --force for CI / scripted installs (P3.4).
50
52
  # When set to "1" the install run treats every conflict as
@@ -781,7 +783,44 @@ def _render_template(template: str, profile_values: "dict[str, str]") -> str:
781
783
  return body
782
784
 
783
785
 
784
- def ensure_agent_settings(project_root: Path, package_root: Path, profile: str, force: bool) -> None:
786
+ def _load_valid_user_types(package_root: Path) -> list[str]:
787
+ """Return the sorted user-type slugs shipped under ``user-types/``.
788
+
789
+ Maps `user-types/<id>.yml` → `<id>`. The ``README.md`` is skipped.
790
+ Empty list when the directory is absent (older package payloads).
791
+ """
792
+ directory = package_root / USER_TYPES_DIR
793
+ if not directory.is_dir():
794
+ return []
795
+ return sorted(p.stem for p in directory.glob("*.yml"))
796
+
797
+
798
+ def _validate_user_type(package_root: Path, value: str) -> str:
799
+ """Return the validated user-type slug (empty string allowed → no filter)."""
800
+ cleaned = (value or "").strip()
801
+ if not cleaned:
802
+ return ""
803
+ valid = _load_valid_user_types(package_root)
804
+ if not valid:
805
+ fail(
806
+ f"--user-type={cleaned} requested but no user-types/*.yml present "
807
+ f"under {package_root}"
808
+ )
809
+ if cleaned not in valid:
810
+ fail(
811
+ f"Unknown --user-type={cleaned}. Valid: {', '.join(valid)} "
812
+ "(empty string disables the filter)."
813
+ )
814
+ return cleaned
815
+
816
+
817
+ def ensure_agent_settings(
818
+ project_root: Path,
819
+ package_root: Path,
820
+ profile: str,
821
+ force: bool,
822
+ user_type: str = "",
823
+ ) -> None:
785
824
  target = project_root / SETTINGS_FILE
786
825
  profile_source = package_root / "config" / "profiles" / f"{profile}.ini"
787
826
  template_source = package_root / "config" / "agent-settings.template.yml"
@@ -794,12 +833,16 @@ def ensure_agent_settings(project_root: Path, package_root: Path, profile: str,
794
833
  template = template_source.read_text(encoding="utf-8")
795
834
  if COST_PROFILE_PLACEHOLDER not in template:
796
835
  fail(f"Template is missing placeholder {COST_PROFILE_PLACEHOLDER}")
836
+ if USER_TYPE_PLACEHOLDER not in template:
837
+ fail(f"Template is missing placeholder {USER_TYPE_PLACEHOLDER}")
797
838
  profile_values = _parse_profile_ini(profile_source)
798
839
  if profile_values.get("cost_profile") != profile:
799
840
  fail(
800
841
  f"Profile preset {profile_source.name} has cost_profile="
801
842
  f"{profile_values.get('cost_profile')!r} but --profile={profile}"
802
843
  )
844
+ # Inject runtime-only values (not part of the .ini profile presets).
845
+ profile_values["user_type"] = _validate_user_type(package_root, user_type)
803
846
  template_body = _render_template(template, profile_values)
804
847
 
805
848
  legacy_target = project_root / LEGACY_SETTINGS_FILE
@@ -822,7 +865,9 @@ def ensure_agent_settings(project_root: Path, package_root: Path, profile: str,
822
865
  return
823
866
 
824
867
  write_file(target, template_body)
825
- success(f"{SETTINGS_FILE} created (cost_profile={profile})")
868
+ user_type_value = profile_values.get("user_type", "")
869
+ suffix = f", user_type={user_type_value}" if user_type_value else ""
870
+ success(f"{SETTINGS_FILE} created (cost_profile={profile}{suffix})")
826
871
 
827
872
 
828
873
  def ensure_vscode_bridge(project_root: Path, package_type: str, force: bool) -> None:
@@ -3130,6 +3175,17 @@ def parse_options(argv: list[str]) -> argparse.Namespace:
3130
3175
  default=DEFAULT_PROFILE,
3131
3176
  help=f"cost_profile value ({'|'.join(SUPPORTED_PROFILES)}, default: {DEFAULT_PROFILE})",
3132
3177
  )
3178
+ parser.add_argument(
3179
+ "--user-type",
3180
+ dest="user_type",
3181
+ default="",
3182
+ help=(
3183
+ "primary user-type for skill filtering (step-9 axis). "
3184
+ "Valid ids: consultant | creator | developer | finance | "
3185
+ "founder | gtm | ops. Default: empty (no filter, every skill "
3186
+ "surfaces). Written to personal.user_type in .agent-settings.yml."
3187
+ ),
3188
+ )
3133
3189
  parser.add_argument("--force", action="store_true", help="overwrite existing files")
3134
3190
  parser.add_argument("--skip-bridges", action="store_true", help="only create .agent-settings.yml")
3135
3191
  parser.add_argument(
@@ -3354,7 +3410,7 @@ def _write_install_mode_marker(project_root: Path, mode: str) -> None:
3354
3410
  pass
3355
3411
 
3356
3412
 
3357
- def install_minimal(target_root: Path, force: bool) -> int:
3413
+ def install_minimal(target_root: Path, force: bool, user_type: str = "") -> int:
3358
3414
  """Bootstrap the project-local override layer only (D2-compliant).
3359
3415
 
3360
3416
  Writes:
@@ -3421,8 +3477,16 @@ def install_minimal(target_root: Path, force: bool) -> int:
3421
3477
  if settings_dst.exists() and not force:
3422
3478
  skip(f"{SETTINGS_FILE} already exists (use --force to overwrite)")
3423
3479
  else:
3424
- settings_dst.write_text(settings_src.read_text(encoding="utf-8"), encoding="utf-8")
3425
- success(f"Wrote {SETTINGS_FILE}")
3480
+ body = settings_src.read_text(encoding="utf-8")
3481
+ if user_type:
3482
+ body = body.rstrip() + (
3483
+ "\n\n# --- Personal (step-9 user-type axis) ---\n"
3484
+ "personal:\n"
3485
+ f" user_type: {user_type}\n"
3486
+ )
3487
+ settings_dst.write_text(body, encoding="utf-8")
3488
+ suffix = f" (user_type={user_type})" if user_type else ""
3489
+ success(f"Wrote {SETTINGS_FILE}{suffix}")
3426
3490
 
3427
3491
  # 3. install-mode marker (Step 8 A5) — authoritative state for
3428
3492
  # doctor --context and future install-aware tooling. Written even
@@ -3592,7 +3656,13 @@ def main(argv: list[str]) -> int:
3592
3656
  target_root = Path(
3593
3657
  opts.custom_path or opts.project or os.environ.get("PROJECT_ROOT") or os.getcwd()
3594
3658
  ).resolve()
3595
- return install_minimal(target_root, opts.force)
3659
+ # Validate --user-type early so the minimal short-circuit fails
3660
+ # fast on a bogus slug instead of writing a half-formed stub.
3661
+ # _minimal_templates_root() returns <package_root>/templates/minimal;
3662
+ # walk two parents up to reach the package root where user-types/ lives.
3663
+ minimal_package_root = _minimal_templates_root().parent.parent
3664
+ validated_user_type = _validate_user_type(minimal_package_root, opts.user_type)
3665
+ return install_minimal(target_root, opts.force, validated_user_type)
3596
3666
 
3597
3667
  # Multi-signal scope detection (Phase 1.3) + scope resolution
3598
3668
  # (Phase 1.4). Order of precedence (highest first):
@@ -3670,9 +3740,13 @@ def _main_project_install(
3670
3740
  info(f"Package: {package_root}")
3671
3741
  info(f"Type: {package_type}")
3672
3742
  info(f"Profile: {opts.profile}")
3743
+ if opts.user_type:
3744
+ info(f"UserType: {opts.user_type}")
3673
3745
  print()
3674
3746
 
3675
- ensure_agent_settings(project_root, package_root, opts.profile, opts.force)
3747
+ ensure_agent_settings(
3748
+ project_root, package_root, opts.profile, opts.force, opts.user_type
3749
+ )
3676
3750
 
3677
3751
  # Install-mode marker (Step 8 A5) — full path flips any prior
3678
3752
  # minimal marker to "full" so doctor --context reflects the
@@ -79,6 +79,13 @@ parse_args() {
79
79
  --skip-gitignore) SKIP_GITIGNORE=true; shift ;;
80
80
  --tools) TOOLS="$2"; shift 2 ;;
81
81
  --tools=*) TOOLS="${1#*=}"; shift ;;
82
+ # --user-type is consumed by install.py (settings persistence).
83
+ # Accepted here so direct `bash scripts/install.sh --user-type=...`
84
+ # invocations from the `install` wrapper / standalone users do not
85
+ # trip the "Unknown argument" guard. Value is intentionally unused
86
+ # by the payload-sync stage.
87
+ --user-type) shift 2 ;;
88
+ --user-type=*) shift ;;
82
89
  --minimal|--settings-only) MINIMAL=true; shift ;;
83
90
  --help|-h) show_help; exit 0 ;;
84
91
  *) log_error "Unknown argument: $1"; show_help; exit 1 ;;
@@ -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,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)