@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,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
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://github.com/event4u-app/agent-config/scripts/schemas/user-type.schema.json",
|
|
4
|
+
"title": "User-type frontmatter",
|
|
5
|
+
"$comment": "Source: docs/contracts/user-type-schema.md. The runtime review-lens axis seeded under .agent-src.uncompressed/user-types/. Distinct from the install-time user-type-axis YAMLs in user-types/ (root) which carry their own user-type-axis.schema.json.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["id", "kind", "description", "version", "source"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"id": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"pattern": "^[a-z][a-z0-9-]*$",
|
|
13
|
+
"description": "Must match the user-type filename stem."
|
|
14
|
+
},
|
|
15
|
+
"kind": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"const": "user-type",
|
|
18
|
+
"description": "Discriminator — locks this file as a review-lens user-type, distinct from the install-time user-type-axis YAMLs."
|
|
19
|
+
},
|
|
20
|
+
"description": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"minLength": 1,
|
|
23
|
+
"maxLength": 240
|
|
24
|
+
},
|
|
25
|
+
"version": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"pattern": "^[0-9]+(\\.[0-9]+){0,2}$"
|
|
28
|
+
},
|
|
29
|
+
"source": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"enum": ["package", "project"],
|
|
32
|
+
"description": "Project-specific the typical case — most end-user simulations are consumer-domain, not package-defaults."
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
package/scripts/skill_linter.py
CHANGED
|
@@ -39,7 +39,7 @@ from validate_frontmatter import ( # noqa: E402
|
|
|
39
39
|
)
|
|
40
40
|
|
|
41
41
|
Severity = Literal["error", "warning", "info"]
|
|
42
|
-
ArtifactType = Literal["skill", "rule", "command", "guideline", "persona", "unknown"]
|
|
42
|
+
ArtifactType = Literal["skill", "rule", "command", "guideline", "persona", "user-type", "unknown"]
|
|
43
43
|
|
|
44
44
|
REQUIRED_PERSONA_SECTIONS_CORE = [
|
|
45
45
|
"Focus",
|
|
@@ -57,6 +57,20 @@ REQUIRED_PERSONA_SECTIONS = REQUIRED_PERSONA_SECTIONS_CORE
|
|
|
57
57
|
VALID_PERSONA_TIERS = {"core", "specialist"}
|
|
58
58
|
# Locked in docs/contracts/persona-schema.md § 4: core ≤ 120, specialist ≤ 100.
|
|
59
59
|
PERSONA_LINE_BUDGETS = {"core": 120, "specialist": 100}
|
|
60
|
+
|
|
61
|
+
# User-type spine — locked in docs/contracts/user-type-schema.md § 3.
|
|
62
|
+
# Runtime end-user simulation lens (sister axis to personas — methodology vs
|
|
63
|
+
# end-user). Single tier in v1 (no core/specialist split).
|
|
64
|
+
REQUIRED_USERTYPE_SECTIONS = [
|
|
65
|
+
"Focus",
|
|
66
|
+
"Daily Workflow",
|
|
67
|
+
"Vocabulary",
|
|
68
|
+
"Operational Constraints",
|
|
69
|
+
"Unique Questions",
|
|
70
|
+
"Ticket Red Flags",
|
|
71
|
+
"Anti-Patterns",
|
|
72
|
+
]
|
|
73
|
+
USERTYPE_LINE_BUDGET = 120
|
|
60
74
|
# Wing-scoped overrides — Wing-3 (GTM) and Wing-4 (Money/Strategy/Ops) carry
|
|
61
75
|
# denser cognition (funnel × channel × lifecycle, or finance × org × strategy)
|
|
62
76
|
# than Wing-1/2 specialists, so the line cap rises to keep the seven-section
|
|
@@ -514,6 +528,8 @@ def detect_artifact_type(path: Path, text: str) -> ArtifactType:
|
|
|
514
528
|
return "guideline"
|
|
515
529
|
if "/personas/" in path_str:
|
|
516
530
|
return "persona"
|
|
531
|
+
if "/user-types/" in path_str:
|
|
532
|
+
return "user-type"
|
|
517
533
|
if has_skill_heading:
|
|
518
534
|
return "skill"
|
|
519
535
|
return "unknown"
|
|
@@ -1849,6 +1865,106 @@ def lint_persona(path: Path, text: str) -> LintResult:
|
|
|
1849
1865
|
)
|
|
1850
1866
|
|
|
1851
1867
|
|
|
1868
|
+
def lint_usertype(path: Path, text: str) -> LintResult:
|
|
1869
|
+
"""Lint a user-type .md file (frontmatter schema + required sections + size).
|
|
1870
|
+
|
|
1871
|
+
User-types are the runtime end-user simulation lens (sister axis to
|
|
1872
|
+
personas — methodology vs end-user). Contract:
|
|
1873
|
+
docs/contracts/user-type-schema.md.
|
|
1874
|
+
"""
|
|
1875
|
+
issues: List[Issue] = []
|
|
1876
|
+
|
|
1877
|
+
frontmatter = extract_frontmatter(text)
|
|
1878
|
+
if not frontmatter:
|
|
1879
|
+
issues.append(Issue("error", "missing_frontmatter", "User-type requires YAML frontmatter"))
|
|
1880
|
+
return LintResult(
|
|
1881
|
+
file=str(path),
|
|
1882
|
+
artifact_type="user-type",
|
|
1883
|
+
status="fail",
|
|
1884
|
+
issues=issues,
|
|
1885
|
+
suggestions=[".agent-src.uncompressed/user-types/_template/user-type.md"],
|
|
1886
|
+
)
|
|
1887
|
+
|
|
1888
|
+
# Required keys per docs/contracts/user-type-schema.md § 1.
|
|
1889
|
+
required = {
|
|
1890
|
+
"id": re.compile(r'^id:\s*"?([\w-]+)"?\s*$', re.MULTILINE),
|
|
1891
|
+
"kind": re.compile(r'^kind:\s*"?([\w-]+)"?\s*$', re.MULTILINE),
|
|
1892
|
+
"description": re.compile(r'^description:\s*"?([^"\n]+?)"?\s*$', re.MULTILINE),
|
|
1893
|
+
"version": re.compile(r'^version:\s*"?([\d.]+)"?\s*$', re.MULTILINE),
|
|
1894
|
+
"source": re.compile(r'^source:\s*"?(package|project)"?\s*$', re.MULTILINE),
|
|
1895
|
+
}
|
|
1896
|
+
parsed: dict = {}
|
|
1897
|
+
for field, pattern in required.items():
|
|
1898
|
+
value = extract_frontmatter_field(frontmatter, pattern)
|
|
1899
|
+
if not value:
|
|
1900
|
+
issues.append(Issue("error", f"missing_{field}", f"User-type frontmatter must declare `{field}`"))
|
|
1901
|
+
else:
|
|
1902
|
+
parsed[field] = value
|
|
1903
|
+
|
|
1904
|
+
if "id" in parsed and parsed["id"] != path.stem:
|
|
1905
|
+
issues.append(Issue(
|
|
1906
|
+
"error",
|
|
1907
|
+
"id_filename_mismatch",
|
|
1908
|
+
f"User-type id `{parsed['id']}` must match filename stem `{path.stem}`",
|
|
1909
|
+
))
|
|
1910
|
+
|
|
1911
|
+
if "kind" in parsed and parsed["kind"] != "user-type":
|
|
1912
|
+
issues.append(Issue(
|
|
1913
|
+
"error",
|
|
1914
|
+
"invalid_kind",
|
|
1915
|
+
f"User-type kind must be `user-type` (got `{parsed['kind']}`)",
|
|
1916
|
+
))
|
|
1917
|
+
|
|
1918
|
+
if "description" in parsed and len(parsed["description"]) > 160:
|
|
1919
|
+
issues.append(Issue(
|
|
1920
|
+
"warning",
|
|
1921
|
+
"long_description",
|
|
1922
|
+
f"User-type description is {len(parsed['description'])} chars (target ≤ 160)",
|
|
1923
|
+
))
|
|
1924
|
+
|
|
1925
|
+
sections = extract_sections(text)
|
|
1926
|
+
for required_section in REQUIRED_USERTYPE_SECTIONS:
|
|
1927
|
+
if required_section not in sections:
|
|
1928
|
+
issues.append(Issue(
|
|
1929
|
+
"error",
|
|
1930
|
+
"missing_section",
|
|
1931
|
+
f"User-type is missing required section `## {required_section}`",
|
|
1932
|
+
))
|
|
1933
|
+
|
|
1934
|
+
# Anti-Generic Quality Bar: ≥ 3 Unique Questions
|
|
1935
|
+
uq_block = extract_section_block(text, "Unique Questions")
|
|
1936
|
+
if uq_block:
|
|
1937
|
+
bullet_count = len(re.findall(r"^\s*[-*]\s+", uq_block, re.MULTILINE))
|
|
1938
|
+
if bullet_count < 3:
|
|
1939
|
+
issues.append(Issue(
|
|
1940
|
+
"warning",
|
|
1941
|
+
"too_few_unique_questions",
|
|
1942
|
+
f"User-type has {bullet_count} unique questions (target ≥ 3)",
|
|
1943
|
+
))
|
|
1944
|
+
|
|
1945
|
+
line_count = len(text.splitlines())
|
|
1946
|
+
if line_count > USERTYPE_LINE_BUDGET:
|
|
1947
|
+
issues.append(Issue(
|
|
1948
|
+
"warning",
|
|
1949
|
+
"size_budget",
|
|
1950
|
+
f"User-type has {line_count} lines (budget ≤ {USERTYPE_LINE_BUDGET})",
|
|
1951
|
+
))
|
|
1952
|
+
|
|
1953
|
+
if not H1_PATTERN.search(text):
|
|
1954
|
+
issues.append(Issue("warning", "missing_h1", "User-type is missing an H1 heading"))
|
|
1955
|
+
|
|
1956
|
+
if not text.endswith("\n"):
|
|
1957
|
+
issues.append(Issue("warning", "no_trailing_newline", "File must end with exactly one newline"))
|
|
1958
|
+
|
|
1959
|
+
return LintResult(
|
|
1960
|
+
file=str(path),
|
|
1961
|
+
artifact_type="user-type",
|
|
1962
|
+
status=classify_status(issues),
|
|
1963
|
+
issues=issues,
|
|
1964
|
+
suggestions=[],
|
|
1965
|
+
)
|
|
1966
|
+
|
|
1967
|
+
|
|
1852
1968
|
def gather_all_candidate_files(root: Path) -> list[Path]:
|
|
1853
1969
|
"""Gather all lintable files. Prefers .agent-src.uncompressed/ (source of truth).
|
|
1854
1970
|
Falls back to .agent-src/ only if uncompressed doesn't exist.
|
|
@@ -1906,6 +2022,18 @@ def gather_all_candidate_files(root: Path) -> list[Path]:
|
|
|
1906
2022
|
if not f.is_symlink():
|
|
1907
2023
|
candidates.append(f)
|
|
1908
2024
|
|
|
2025
|
+
# User-types (sister axis to personas — methodology vs end-user).
|
|
2026
|
+
# Top-level .md only; README and _template/ subtree excluded.
|
|
2027
|
+
uncompressed_usertypes = root / ".agent-src.uncompressed" / "user-types"
|
|
2028
|
+
augment_usertypes = root / ".agent-src" / "user-types"
|
|
2029
|
+
usertypes_base = uncompressed_usertypes if uncompressed_usertypes.exists() else augment_usertypes
|
|
2030
|
+
if usertypes_base.exists():
|
|
2031
|
+
for f in usertypes_base.glob("*.md"):
|
|
2032
|
+
if f.name.lower() == "readme.md":
|
|
2033
|
+
continue
|
|
2034
|
+
if not f.is_symlink():
|
|
2035
|
+
candidates.append(f)
|
|
2036
|
+
|
|
1909
2037
|
# Frugality charter (Phase 0.4 Layer 2). Lives in contexts/, not
|
|
1910
2038
|
# walked by the artifact-type loops above, but still needs the
|
|
1911
2039
|
# index-integrity check.
|
|
@@ -2093,8 +2221,13 @@ def _is_execution_artifact(path: Path, text: str) -> bool:
|
|
|
2093
2221
|
path_lower = str(path).lower()
|
|
2094
2222
|
text_lower = text.lower()
|
|
2095
2223
|
|
|
2096
|
-
# Exclude commands, guidelines,
|
|
2097
|
-
if
|
|
2224
|
+
# Exclude commands, guidelines, personas, user-types — not execution-oriented
|
|
2225
|
+
if (
|
|
2226
|
+
"/commands/" in path_lower
|
|
2227
|
+
or "/guidelines/" in path_lower
|
|
2228
|
+
or "/personas/" in path_lower
|
|
2229
|
+
or "/user-types/" in path_lower
|
|
2230
|
+
):
|
|
2098
2231
|
return False
|
|
2099
2232
|
|
|
2100
2233
|
# File name match — strong signal
|
|
@@ -2779,7 +2912,7 @@ def lint_output_schema(path: Path, text: str) -> List[Issue]:
|
|
|
2779
2912
|
|
|
2780
2913
|
|
|
2781
2914
|
# Artefact types that carry a JSON-Schema contract for their frontmatter.
|
|
2782
|
-
_SCHEMA_ARTEFACT_TYPES = {"skill", "rule", "command", "persona"}
|
|
2915
|
+
_SCHEMA_ARTEFACT_TYPES = {"skill", "rule", "command", "persona", "user-type"}
|
|
2783
2916
|
|
|
2784
2917
|
|
|
2785
2918
|
def lint_frontmatter_schema(path: Path, text: str, artifact_type: str) -> List[Issue]:
|
|
@@ -2840,6 +2973,8 @@ def lint_file(path: Path, repo_root: Path | None = None) -> LintResult:
|
|
|
2840
2973
|
result = lint_guideline(display_path, text)
|
|
2841
2974
|
elif artifact_type == "persona":
|
|
2842
2975
|
result = lint_persona(display_path, text)
|
|
2976
|
+
elif artifact_type == "user-type":
|
|
2977
|
+
result = lint_usertype(display_path, text)
|
|
2843
2978
|
else:
|
|
2844
2979
|
# Frugality charter lives in contexts/ (artifact_type == unknown)
|
|
2845
2980
|
# but still needs Layer 2 index-integrity validation.
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Block D · D3 — audit_user_type_coverage.
|
|
3
|
+
|
|
4
|
+
Coverage audit for the user-type axis. User-types are **CLI-only** in v1
|
|
5
|
+
(see `docs/contracts/adr-user-types-axis.md` and Phase 4 step 3 of
|
|
6
|
+
`agents/roadmaps/step-6-user-types-axis.md`) — skills do NOT declare a
|
|
7
|
+
`user-types:` frontmatter key, so persona-style citation counting does
|
|
8
|
+
not apply. Instead this script:
|
|
9
|
+
|
|
10
|
+
- Inventories every user-type file in the source directory.
|
|
11
|
+
- Scans skills, commands, and `docs/` for `--user-type=<id>` mentions.
|
|
12
|
+
- Flags **orphan references** (CLI mention to a non-existent id) and
|
|
13
|
+
**never-referenced** user-types (file exists but nobody cites it).
|
|
14
|
+
|
|
15
|
+
Inputs:
|
|
16
|
+
--user-types-dir DIR — directory holding user-type Markdown files
|
|
17
|
+
--search-root DIR — root to recurse for `--user-type=<id>` mentions
|
|
18
|
+
--json — machine-readable output
|
|
19
|
+
|
|
20
|
+
Output: per-user-type reference count + status (ok / never-referenced /
|
|
21
|
+
orphan). Exit code: 0 always (advisory, not a CI gate).
|
|
22
|
+
|
|
23
|
+
Stdlib-only. ≤ 130 LOC. Sibling of `audit_persona_coverage.py`.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import json
|
|
29
|
+
import re
|
|
30
|
+
import sys
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Dict, List, Set
|
|
33
|
+
|
|
34
|
+
ROOT = Path(__file__).resolve().parents[2]
|
|
35
|
+
DEFAULT_USER_TYPES = ROOT / ".agent-src.uncompressed" / "user-types"
|
|
36
|
+
DEFAULT_SEARCH_ROOT = ROOT / ".agent-src.uncompressed"
|
|
37
|
+
REFERENCE_THRESHOLD = 1 # user-type with 0 references → flagged.
|
|
38
|
+
|
|
39
|
+
# Matches `--user-type=<id>` in command markdown, skill prose, docs.
|
|
40
|
+
_REFERENCE_RE = re.compile(r"--user-type=([\w-]+)")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _read_block(path: Path) -> str:
|
|
44
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
45
|
+
if not text.startswith("---"):
|
|
46
|
+
return ""
|
|
47
|
+
end = text.find("\n---", 3)
|
|
48
|
+
return text[3:end] if end != -1 else ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _frontmatter_value(block: str, key: str) -> str | None:
|
|
52
|
+
m = re.search(rf"^{re.escape(key)}\s*:\s*(.+)$", block, re.MULTILINE)
|
|
53
|
+
if not m:
|
|
54
|
+
return None
|
|
55
|
+
val = m.group(1).strip()
|
|
56
|
+
if val.startswith('"') and val.endswith('"'):
|
|
57
|
+
val = val[1:-1]
|
|
58
|
+
return val
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _load_user_types(user_types_dir: Path) -> Set[str]:
|
|
62
|
+
ids: Set[str] = set()
|
|
63
|
+
if not user_types_dir.is_dir():
|
|
64
|
+
return ids
|
|
65
|
+
for md in sorted(user_types_dir.glob("*.md")):
|
|
66
|
+
if md.name.lower() == "readme.md":
|
|
67
|
+
continue
|
|
68
|
+
block = _read_block(md)
|
|
69
|
+
slug = _frontmatter_value(block, "id") or md.stem
|
|
70
|
+
ids.add(slug)
|
|
71
|
+
# Walk one level deeper to skip `_template/` etc.
|
|
72
|
+
for md in sorted(user_types_dir.glob("*/*.md")):
|
|
73
|
+
if "_template" in md.parts:
|
|
74
|
+
continue
|
|
75
|
+
block = _read_block(md)
|
|
76
|
+
slug = _frontmatter_value(block, "id") or md.parent.name
|
|
77
|
+
ids.add(slug)
|
|
78
|
+
return ids
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _count_references(search_root: Path, skip_dir: Path) -> Dict[str, int]:
|
|
82
|
+
counts: Dict[str, int] = {}
|
|
83
|
+
if not search_root.is_dir():
|
|
84
|
+
return counts
|
|
85
|
+
skip_resolved = skip_dir.resolve() if skip_dir.is_dir() else None
|
|
86
|
+
for md in search_root.rglob("*.md"):
|
|
87
|
+
# Don't count references inside the user-types dir itself
|
|
88
|
+
# (the README documents the flag in example form).
|
|
89
|
+
if skip_resolved and skip_resolved in md.resolve().parents:
|
|
90
|
+
continue
|
|
91
|
+
text = md.read_text(encoding="utf-8", errors="replace")
|
|
92
|
+
for slug in _REFERENCE_RE.findall(text):
|
|
93
|
+
counts[slug] = counts.get(slug, 0) + 1
|
|
94
|
+
return counts
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def audit(user_types_dir: Path, search_root: Path) -> List[Dict[str, object]]:
|
|
98
|
+
ids = _load_user_types(user_types_dir)
|
|
99
|
+
references = _count_references(search_root, user_types_dir)
|
|
100
|
+
rows: List[Dict[str, object]] = []
|
|
101
|
+
for slug in sorted(ids):
|
|
102
|
+
count = references.get(slug, 0)
|
|
103
|
+
status = "ok" if count >= REFERENCE_THRESHOLD else "never-referenced"
|
|
104
|
+
rows.append({"user_type": slug, "references": count,
|
|
105
|
+
"threshold": REFERENCE_THRESHOLD, "status": status})
|
|
106
|
+
for slug in sorted(references.keys()):
|
|
107
|
+
if slug not in ids:
|
|
108
|
+
rows.append({"user_type": slug, "references": references[slug],
|
|
109
|
+
"threshold": REFERENCE_THRESHOLD, "status": "orphan"})
|
|
110
|
+
return rows
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _print_human(rows: List[Dict[str, object]]) -> None:
|
|
114
|
+
if not rows:
|
|
115
|
+
print("(no user-types found)")
|
|
116
|
+
return
|
|
117
|
+
width = max(len(str(r["user_type"])) for r in rows)
|
|
118
|
+
print(f" {'user-type':<{width}} refs status")
|
|
119
|
+
print(f" {'-' * width} ----- ----------------")
|
|
120
|
+
for r in rows:
|
|
121
|
+
print(f" {str(r['user_type']):<{width}} "
|
|
122
|
+
f"{int(r['references']):>5} {r['status']}")
|
|
123
|
+
flagged = [r for r in rows if r["status"] != "ok"]
|
|
124
|
+
if flagged:
|
|
125
|
+
print(f"\n {len(flagged)} user-type(s) flagged "
|
|
126
|
+
f"(never-referenced or orphan).")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def main(argv: List[str] | None = None) -> int:
|
|
130
|
+
parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
|
|
131
|
+
parser.add_argument("--user-types-dir", default=str(DEFAULT_USER_TYPES))
|
|
132
|
+
parser.add_argument("--search-root", default=str(DEFAULT_SEARCH_ROOT))
|
|
133
|
+
parser.add_argument("--json", action="store_true",
|
|
134
|
+
help="emit JSON instead of text")
|
|
135
|
+
args = parser.parse_args(argv)
|
|
136
|
+
rows = audit(Path(args.user_types_dir), Path(args.search_root))
|
|
137
|
+
if args.json:
|
|
138
|
+
json.dump({"rows": rows}, sys.stdout, indent=2)
|
|
139
|
+
sys.stdout.write("\n")
|
|
140
|
+
else:
|
|
141
|
+
_print_human(rows)
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
_SAMPLE = {"threshold": REFERENCE_THRESHOLD}
|
|
146
|
+
|
|
147
|
+
if __name__ == "__main__":
|
|
148
|
+
raise SystemExit(main())
|
|
@@ -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)
|