@event4u/agent-config 2.17.0 → 2.19.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 (32) hide show
  1. package/.agent-src/commands/refine-ticket.md +3 -0
  2. package/.agent-src/personas/README.md +8 -0
  3. package/.agent-src/skills/refine-ticket/SKILL.md +3 -0
  4. package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  5. package/.agent-src/user-types/README.md +124 -0
  6. package/.agent-src/user-types/_template/user-type.md +95 -0
  7. package/.agent-src/user-types/galabau-field-crew.md +100 -0
  8. package/.agent-src/user-types/metalworking-shop.md +105 -0
  9. package/.agent-src/user-types/truck-driver.md +113 -0
  10. package/.claude-plugin/marketplace.json +1 -1
  11. package/CHANGELOG.md +68 -0
  12. package/config/agent-settings.template.yml +7 -0
  13. package/docs/catalog.md +1 -1
  14. package/docs/contracts/adr-install-user-type-axis.md +107 -0
  15. package/docs/contracts/adr-mcp-runtime.md +128 -0
  16. package/docs/contracts/adr-user-types-axis.md +127 -0
  17. package/docs/contracts/init-telemetry.md +2 -3
  18. package/docs/contracts/user-type-schema.md +146 -0
  19. package/docs/getting-started-by-role.md +1 -1
  20. package/docs/recruits/_template.md +81 -0
  21. package/package.json +1 -1
  22. package/scripts/audit_user_type_axis.py +140 -0
  23. package/scripts/compress.py +48 -2
  24. package/scripts/install +9 -1
  25. package/scripts/install.py +81 -7
  26. package/scripts/install.sh +7 -0
  27. package/scripts/mcp_server/prompts.py +134 -2
  28. package/scripts/schemas/user-type-axis.schema.json +56 -0
  29. package/scripts/schemas/user-type.schema.json +35 -0
  30. package/scripts/skill_linter.py +139 -4
  31. package/scripts/skill_tools/audit_user_type_coverage.py +148 -0
  32. package/scripts/sync_agent_settings.py +6 -0
@@ -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:]))
@@ -346,6 +346,7 @@ TOOL_DIRS = {
346
346
  SKILLS_SOURCE = PROJECT_ROOT / ".agent-src" / "skills"
347
347
  COMMANDS_SOURCE = PROJECT_ROOT / ".agent-src" / "commands"
348
348
  PERSONAS_SOURCE = PROJECT_ROOT / ".agent-src" / "personas"
349
+ USER_TYPES_SOURCE = PROJECT_ROOT / ".agent-src" / "user-types"
349
350
  CLAUDE_SKILLS_DIR = PROJECT_ROOT / ".claude" / "skills"
350
351
 
351
352
  PERSONA_TOOL_DIRS = {
@@ -353,6 +354,11 @@ PERSONA_TOOL_DIRS = {
353
354
  ".cursor/personas": "../../.agent-src/personas",
354
355
  }
355
356
 
357
+ USER_TYPE_TOOL_DIRS = {
358
+ ".claude/user-types": "../../.agent-src/user-types",
359
+ ".cursor/user-types": "../../.agent-src/user-types",
360
+ }
361
+
356
362
  # Map tool-projection directories to the canonical tool ID used by
357
363
  # `.agent-tools.yml`. Directories not in this map are always emitted.
358
364
  _DIR_TOOL_ID = {
@@ -361,6 +367,8 @@ _DIR_TOOL_ID = {
361
367
  ".clinerules": "cline",
362
368
  ".claude/personas": "claude-code",
363
369
  ".cursor/personas": "cursor",
370
+ ".claude/user-types": "claude-code",
371
+ ".cursor/user-types": "cursor",
364
372
  }
365
373
 
366
374
 
@@ -901,6 +909,43 @@ def generate_persona_symlinks() -> int:
901
909
  return total
902
910
 
903
911
 
912
+ def generate_user_type_symlinks() -> int:
913
+ """Create symlink directories for user-types (.claude/user-types/, .cursor/user-types/).
914
+
915
+ Symlinks each user-type .md file from .agent-src/user-types/ into tool-specific
916
+ directories. Excludes README.md and _template/ — those are authoring scaffolding,
917
+ not user-type lenses.
918
+ """
919
+ if not USER_TYPES_SOURCE.exists():
920
+ print(" ⚠️ .agent-src/user-types/ not found — skipping user-types")
921
+ return 0
922
+
923
+ user_types = sorted([
924
+ f.name for f in USER_TYPES_SOURCE.glob("*.md") if f.stem != "README"
925
+ ])
926
+ tool_dirs = _filter_tool_dirs(USER_TYPE_TOOL_DIRS)
927
+ total = 0
928
+ for tool_dir, rel_prefix in tool_dirs.items():
929
+ target_dir = PROJECT_ROOT / tool_dir
930
+ target_dir.mkdir(parents=True, exist_ok=True)
931
+
932
+ # Clean stale symlinks
933
+ for item in target_dir.iterdir():
934
+ if item.is_symlink() and item.name not in user_types and item.name != "README.md":
935
+ item.unlink()
936
+
937
+ for user_type in user_types:
938
+ link = target_dir / user_type
939
+ target = Path(rel_prefix) / user_type
940
+ if link.exists() or link.is_symlink():
941
+ link.unlink()
942
+ link.symlink_to(target)
943
+ total += 1
944
+
945
+ info(f" ✅ Created {total} user-type symlinks across {len(tool_dirs)} tool directories ({len(user_types)} user-types each)")
946
+ return total
947
+
948
+
904
949
  def generate_tools() -> None:
905
950
  """Generate all tool-specific directories and files.
906
951
 
@@ -916,13 +961,14 @@ def generate_tools() -> None:
916
961
  skills = generate_claude_skills() if _tool_active("claude-code") else 0
917
962
  commands = generate_claude_commands() if _tool_active("claude-code") else 0
918
963
  personas = generate_persona_symlinks()
964
+ user_types = generate_user_type_symlinks()
919
965
  cursor_mdc = generate_cursor_mdc_rules() if _tool_active("cursor") else 0
920
966
  windsurf_modern = generate_windsurf_modern_rules() if _tool_active("windsurf") else 0
921
967
  cursor_cmds = generate_cursor_commands() if _tool_active("cursor") else 0
922
968
  windsurf_wf = generate_windsurf_workflows() if _tool_active("windsurf") else 0
923
969
  summary = (
924
970
  f"✅ generate-tools — rules={rules} skills={skills} "
925
- f"commands={commands} personas={personas} "
971
+ f"commands={commands} personas={personas} user_types={user_types} "
926
972
  f"cursor_mdc={cursor_mdc} windsurf_rules={windsurf_modern} "
927
973
  f"cursor_commands={cursor_cmds} windsurf_workflows={windsurf_wf} "
928
974
  f"windsurfrules={windsurfrules}"
@@ -943,7 +989,7 @@ def generate_tools() -> None:
943
989
  # them to symlinks (everything else is always symlinked).
944
990
 
945
991
  # Subdirectories of .agent-src/ that map into .augment/ as symlinks.
946
- AUGMENT_SYMLINK_DIRS = ("skills", "commands", "guidelines", "personas", "templates", "contexts", "scripts")
992
+ AUGMENT_SYMLINK_DIRS = ("skills", "commands", "guidelines", "personas", "user-types", "templates", "contexts", "scripts")
947
993
  # Top-level files to symlink into .augment/ (README, etc.)
948
994
  AUGMENT_SYMLINK_FILES = ("README.md",)
949
995
 
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()