@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.
- package/.agent-src/commands/refine-ticket.md +3 -0
- package/.agent-src/personas/README.md +8 -0
- package/.agent-src/skills/refine-ticket/SKILL.md +3 -0
- package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/.agent-src/user-types/README.md +124 -0
- package/.agent-src/user-types/_template/user-type.md +95 -0
- package/.agent-src/user-types/galabau-field-crew.md +100 -0
- package/.agent-src/user-types/metalworking-shop.md +105 -0
- package/.agent-src/user-types/truck-driver.md +113 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +68 -0
- package/config/agent-settings.template.yml +7 -0
- package/docs/catalog.md +1 -1
- package/docs/contracts/adr-install-user-type-axis.md +107 -0
- package/docs/contracts/adr-mcp-runtime.md +128 -0
- package/docs/contracts/adr-user-types-axis.md +127 -0
- package/docs/contracts/init-telemetry.md +2 -3
- package/docs/contracts/user-type-schema.md +146 -0
- package/docs/getting-started-by-role.md +1 -1
- package/docs/recruits/_template.md +81 -0
- package/package.json +1 -1
- package/scripts/audit_user_type_axis.py +140 -0
- package/scripts/compress.py +48 -2
- package/scripts/install +9 -1
- package/scripts/install.py +81 -7
- package/scripts/install.sh +7 -0
- package/scripts/mcp_server/prompts.py +134 -2
- package/scripts/schemas/user-type-axis.schema.json +56 -0
- package/scripts/schemas/user-type.schema.json +35 -0
- package/scripts/skill_linter.py +139 -4
- package/scripts/skill_tools/audit_user_type_coverage.py +148 -0
- 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:]))
|
package/scripts/compress.py
CHANGED
|
@@ -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,
|
|
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)
|
package/scripts/install.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
3425
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
package/scripts/install.sh
CHANGED
|
@@ -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
|
-
|
|
21
|
+
import dataclasses
|
|
22
|
+
from dataclasses import dataclass, field
|
|
22
23
|
from pathlib import Path
|
|
23
24
|
from typing import Any, Literal
|
|
24
25
|
|
|
@@ -36,6 +37,7 @@ PHASE_1_SKILLS: tuple[str, ...] = (
|
|
|
36
37
|
)
|
|
37
38
|
|
|
38
39
|
PromptKind = Literal["skill", "command"]
|
|
40
|
+
UserTypeMatch = Literal["", "match", "universal", "outside"]
|
|
39
41
|
|
|
40
42
|
|
|
41
43
|
@dataclass(frozen=True)
|
|
@@ -46,6 +48,12 @@ class SkillPrompt:
|
|
|
46
48
|
field is the frontmatter `name:` value verbatim (e.g.
|
|
47
49
|
`test-driven-development` or `research:report`); MCP wire names
|
|
48
50
|
are derived in `to_mcp_prompt_meta` with `kind`-aware prefixing.
|
|
51
|
+
|
|
52
|
+
`recommended_for_user_types` mirrors the SKILL.md frontmatter
|
|
53
|
+
array (step-9 user-type axis). Empty tuple = universal (no
|
|
54
|
+
user-type constraint declared). `user_type_match` is the
|
|
55
|
+
cache-computed match label against the active `personal.user_type`
|
|
56
|
+
in `.agent-settings.yml`; empty string means filtering is disabled.
|
|
49
57
|
"""
|
|
50
58
|
|
|
51
59
|
name: str
|
|
@@ -53,6 +61,8 @@ class SkillPrompt:
|
|
|
53
61
|
body: str
|
|
54
62
|
source: str
|
|
55
63
|
kind: PromptKind = "skill"
|
|
64
|
+
recommended_for_user_types: tuple[str, ...] = ()
|
|
65
|
+
user_type_match: UserTypeMatch = ""
|
|
56
66
|
|
|
57
67
|
|
|
58
68
|
def _project_root() -> Path:
|
|
@@ -84,6 +94,69 @@ def _strip_frontmatter(text: str) -> tuple[dict[str, str], str]:
|
|
|
84
94
|
return meta, body.lstrip("\n")
|
|
85
95
|
|
|
86
96
|
|
|
97
|
+
def _parse_inline_array(value: str) -> tuple[str, ...]:
|
|
98
|
+
"""Parse `[a, b, c]` inline-array frontmatter value into a tuple.
|
|
99
|
+
|
|
100
|
+
Returns `()` for any malformed or empty value. Quotes around items
|
|
101
|
+
are stripped. This is intentionally a tiny parser — the canonical
|
|
102
|
+
schema for skill frontmatter is enforced upstream by
|
|
103
|
+
`task lint-skills` / `scripts/validate_frontmatter.py`.
|
|
104
|
+
"""
|
|
105
|
+
v = value.strip()
|
|
106
|
+
if not (v.startswith("[") and v.endswith("]")):
|
|
107
|
+
return ()
|
|
108
|
+
inner = v[1:-1].strip()
|
|
109
|
+
if not inner:
|
|
110
|
+
return ()
|
|
111
|
+
items: list[str] = []
|
|
112
|
+
for raw in inner.split(","):
|
|
113
|
+
item = raw.strip().strip('"').strip("'")
|
|
114
|
+
if item:
|
|
115
|
+
items.append(item)
|
|
116
|
+
return tuple(items)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _load_active_user_type(root: Path) -> str:
|
|
120
|
+
"""Read `personal.user_type` from `.agent-settings.yml`.
|
|
121
|
+
|
|
122
|
+
Returns `""` when the file is missing, the key is unset, or the
|
|
123
|
+
value is still the install-time placeholder (`__USER_TYPE__`).
|
|
124
|
+
Empty string disables the runtime filter (legacy behavior — every
|
|
125
|
+
skill surfaces with its native sort order).
|
|
126
|
+
|
|
127
|
+
Tiny line-based parser to avoid a `pyyaml` runtime dependency for
|
|
128
|
+
the loader (consistent with `_strip_frontmatter`). Only matches
|
|
129
|
+
`user_type:` directly under the top-level `personal:` block.
|
|
130
|
+
"""
|
|
131
|
+
settings = root / ".agent-settings.yml"
|
|
132
|
+
if not settings.is_file():
|
|
133
|
+
return ""
|
|
134
|
+
try:
|
|
135
|
+
text = settings.read_text(encoding="utf-8")
|
|
136
|
+
except OSError:
|
|
137
|
+
return ""
|
|
138
|
+
in_personal = False
|
|
139
|
+
for raw in text.splitlines():
|
|
140
|
+
if not raw or raw.lstrip().startswith("#"):
|
|
141
|
+
continue
|
|
142
|
+
if not raw[0].isspace():
|
|
143
|
+
# Top-level key — flip in_personal based on whether it's `personal:`.
|
|
144
|
+
head = raw.split("#", 1)[0].strip().rstrip(":")
|
|
145
|
+
in_personal = head == "personal"
|
|
146
|
+
continue
|
|
147
|
+
if not in_personal:
|
|
148
|
+
continue
|
|
149
|
+
stripped = raw.strip()
|
|
150
|
+
if not stripped.startswith("user_type:"):
|
|
151
|
+
continue
|
|
152
|
+
_, _, value = stripped.partition(":")
|
|
153
|
+
value = value.split("#", 1)[0].strip().strip('"').strip("'")
|
|
154
|
+
if value.startswith("__") and value.endswith("__"):
|
|
155
|
+
return ""
|
|
156
|
+
return value
|
|
157
|
+
return ""
|
|
158
|
+
|
|
159
|
+
|
|
87
160
|
def load_skill(name: str, root: Path | None = None) -> SkillPrompt:
|
|
88
161
|
"""Load a single skill by name. Raises FileNotFoundError if missing."""
|
|
89
162
|
base = root or _project_root()
|
|
@@ -107,6 +180,9 @@ def _load_file(
|
|
|
107
180
|
body=body.rstrip() + "\n",
|
|
108
181
|
source=meta.get("source", "package"),
|
|
109
182
|
kind=kind,
|
|
183
|
+
recommended_for_user_types=_parse_inline_array(
|
|
184
|
+
meta.get("recommended_for_user_types", "")
|
|
185
|
+
),
|
|
110
186
|
)
|
|
111
187
|
|
|
112
188
|
|
|
@@ -231,20 +307,48 @@ def to_mcp_prompt_meta(prompt: SkillPrompt) -> dict[str, Any]:
|
|
|
231
307
|
Colons in command names (e.g. `research:report`) become `.` so
|
|
232
308
|
the wire identifier is a single-segment dotted path that survives
|
|
233
309
|
every MCP client we have tested.
|
|
310
|
+
|
|
311
|
+
When the user-type axis is active (`PromptCache` resolves a
|
|
312
|
+
non-empty `personal.user_type`), each prompt carries a
|
|
313
|
+
`user_type_match` label and the projected `_meta` surfaces it so
|
|
314
|
+
MCP clients can render the "outside <id> filter" collapse group.
|
|
315
|
+
Absent / empty label means filtering is off — meta is unchanged
|
|
316
|
+
from the legacy shape, preserving back-compat.
|
|
234
317
|
"""
|
|
235
318
|
if prompt.kind == "command":
|
|
236
319
|
wire = f"command.{prompt.name.replace(':', '.')}"
|
|
237
320
|
else:
|
|
238
321
|
wire = f"skill.{prompt.name}"
|
|
322
|
+
meta: dict[str, Any] = {"source": prompt.source, "kind": prompt.kind}
|
|
323
|
+
if prompt.user_type_match:
|
|
324
|
+
meta["user_type_match"] = prompt.user_type_match
|
|
239
325
|
return {
|
|
240
326
|
"name": wire,
|
|
241
327
|
"title": prompt.name,
|
|
242
328
|
"description": prompt.description,
|
|
243
329
|
"arguments": [],
|
|
244
|
-
"_meta":
|
|
330
|
+
"_meta": meta,
|
|
245
331
|
}
|
|
246
332
|
|
|
247
333
|
|
|
334
|
+
def _user_type_rank(prompt: SkillPrompt, user_type: str) -> tuple[int, UserTypeMatch]:
|
|
335
|
+
"""Return `(sort_rank, match_label)` for the step-9 axis.
|
|
336
|
+
|
|
337
|
+
Ranks (lower sorts first):
|
|
338
|
+
0 = match — user_type is in `recommended_for_user_types`
|
|
339
|
+
1 = universal — prompt declares no recommended_for_user_types
|
|
340
|
+
2 = outside — declared, but user_type is not in the list
|
|
341
|
+
|
|
342
|
+
Caller must guarantee `user_type` is non-empty (filter is on).
|
|
343
|
+
"""
|
|
344
|
+
declared = prompt.recommended_for_user_types
|
|
345
|
+
if not declared:
|
|
346
|
+
return (1, "universal")
|
|
347
|
+
if user_type in declared:
|
|
348
|
+
return (0, "match")
|
|
349
|
+
return (2, "outside")
|
|
350
|
+
|
|
351
|
+
|
|
248
352
|
class PromptCache:
|
|
249
353
|
"""In-memory cache with mtime-based invalidation (B5 hot-reload).
|
|
250
354
|
|
|
@@ -264,6 +368,7 @@ class PromptCache:
|
|
|
264
368
|
self._errors: list[str] = []
|
|
265
369
|
self._signature: tuple[tuple[str, float], ...] = ()
|
|
266
370
|
self._index: dict[str, SkillPrompt] = {}
|
|
371
|
+
self._active_user_type: str = ""
|
|
267
372
|
|
|
268
373
|
def _current_signature(self) -> tuple[tuple[str, float], ...]:
|
|
269
374
|
entries: list[tuple[str, float]] = []
|
|
@@ -278,10 +383,32 @@ class PromptCache:
|
|
|
278
383
|
for path in sorted(cmd_root.rglob("*.md")):
|
|
279
384
|
if path.is_file():
|
|
280
385
|
entries.append((str(path), path.stat().st_mtime))
|
|
386
|
+
# `.agent-settings.yml` participates in the signature so a
|
|
387
|
+
# user_type flip (re-run install with a different --user-type)
|
|
388
|
+
# invalidates the cache without needing a SKILL.md touch.
|
|
389
|
+
settings = self._root / ".agent-settings.yml"
|
|
390
|
+
if settings.is_file():
|
|
391
|
+
entries.append((str(settings), settings.stat().st_mtime))
|
|
281
392
|
return tuple(entries)
|
|
282
393
|
|
|
283
394
|
def _refresh(self) -> None:
|
|
284
395
|
prompts, errors = load_all_prompts(self._root)
|
|
396
|
+
user_type = _load_active_user_type(self._root)
|
|
397
|
+
self._active_user_type = user_type
|
|
398
|
+
if user_type:
|
|
399
|
+
# Tag every prompt with its match label and resort:
|
|
400
|
+
# match (0) → universal (1) → outside (2), then wire name.
|
|
401
|
+
tagged: list[SkillPrompt] = []
|
|
402
|
+
for prompt in prompts:
|
|
403
|
+
_rank, label = _user_type_rank(prompt, user_type)
|
|
404
|
+
tagged.append(dataclasses.replace(prompt, user_type_match=label))
|
|
405
|
+
prompts = sorted(
|
|
406
|
+
tagged,
|
|
407
|
+
key=lambda p: (
|
|
408
|
+
_user_type_rank(p, user_type)[0],
|
|
409
|
+
to_mcp_prompt_meta(p)["name"],
|
|
410
|
+
),
|
|
411
|
+
)
|
|
285
412
|
self._prompts = prompts
|
|
286
413
|
self._errors = errors
|
|
287
414
|
self._index = {to_mcp_prompt_meta(p)["name"]: p for p in prompts}
|
|
@@ -299,6 +426,11 @@ class PromptCache:
|
|
|
299
426
|
"""Cached `(path, mtime)` tuples (Phase-6 F1 input). Call `get()` first."""
|
|
300
427
|
return self._signature
|
|
301
428
|
|
|
429
|
+
@property
|
|
430
|
+
def active_user_type(self) -> str:
|
|
431
|
+
"""Currently resolved `personal.user_type` (or `""` if no filter)."""
|
|
432
|
+
return self._active_user_type
|
|
433
|
+
|
|
302
434
|
def lookup(self, wire_name: str) -> SkillPrompt | None:
|
|
303
435
|
"""Resolve an MCP wire name to its SkillPrompt, refreshing first."""
|
|
304
436
|
self.get()
|