@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.
- package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +29 -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/init-telemetry.md +2 -3
- package/docs/getting-started-by-role.md +1 -1
- package/package.json +1 -1
- package/scripts/audit_user_type_axis.py +140 -0
- 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/sync_agent_settings.py +6 -0
|
@@ -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.
|
|
42
|
+
agent_config_version: "2.17.0"
|
|
43
43
|
|
|
44
44
|
# --- Project identity ---
|
|
45
45
|
project:
|
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
|
|
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-
|
|
3
|
+
keep-beta-until: 2026-08-13
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Init Telemetry v1
|
|
7
7
|
|
|
8
|
-
> **Status:** beta · **
|
|
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.
|
|
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
|
@@ -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,
|
|
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()
|
|
@@ -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)
|