@event4u/agent-config 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/.agent-src/commands/install-via-agent.md +129 -0
  2. package/.agent-src/commands/video/from-script.md +1 -1
  3. package/.agent-src/commands/video.md +1 -1
  4. package/.agent-src/contexts/execution/cheap-question-mechanics.md +81 -0
  5. package/.agent-src/rules/caveman-speak.md +2 -2
  6. package/.agent-src/rules/context-hygiene.md +36 -0
  7. package/.agent-src/rules/engineering-safety-floor.md +102 -0
  8. package/.agent-src/rules/finance-safety-floor.md +114 -0
  9. package/.agent-src/rules/git-history-discipline.md +1 -1
  10. package/.agent-src/rules/no-cheap-questions.md +34 -32
  11. package/.agent-src/rules/provider-lifecycle-discipline.md +4 -4
  12. package/.agent-src/rules/strategy-safety-floor.md +114 -0
  13. package/.agent-src/skills/agents-md-thin-root/SKILL.md +15 -9
  14. package/.agent-src/skills/async-python-patterns/SKILL.md +1 -1
  15. package/.agent-src/skills/project-analysis-node-express/SKILL.md +1 -1
  16. package/.agent-src/skills/readme-reviewer/SKILL.md +52 -3
  17. package/.agent-src/skills/readme-writing/SKILL.md +52 -4
  18. package/.agent-src/skills/readme-writing-package/SKILL.md +48 -5
  19. package/.agent-src/skills/systematic-debugging/SKILL.md +41 -0
  20. package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  21. package/.agent-src/templates/hooks/pre-commit-frontmatter +66 -0
  22. package/.agent-src/templates/hooks/pre-commit-roadmap-progress +78 -39
  23. package/.agent-src/templates/scripts/work_engine/_lib/agent_settings.py +4 -1
  24. package/.agent-src/templates/scripts/work_engine/orchestration.py +25 -11
  25. package/.claude-plugin/marketplace.json +2 -1
  26. package/AGENTS.md +10 -8
  27. package/CHANGELOG.md +223 -125
  28. package/README.md +165 -553
  29. package/config/agent-settings.template.yml +0 -7
  30. package/config/discovery/packs.yml +20 -0
  31. package/config/discovery/unassigned-artefacts.yml +2 -0
  32. package/config/gitignore-block.txt +19 -3
  33. package/dist/cli/commands/uiServe.js +13 -4
  34. package/dist/cli/commands/uiServe.js.map +1 -1
  35. package/dist/cli/registry.js +2 -0
  36. package/dist/cli/registry.js.map +1 -1
  37. package/dist/discovery/deprecation-report.md +7 -0
  38. package/dist/discovery/discovery-manifest.json +2107 -1409
  39. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  40. package/dist/discovery/discovery-manifest.summary.md +9 -9
  41. package/dist/discovery/orphan-report.md +10 -0
  42. package/dist/discovery/packs.json +1002 -0
  43. package/dist/discovery/trust-report.md +26 -0
  44. package/dist/discovery/workspaces.json +705 -0
  45. package/dist/mcp/registry-manifest.json +4 -4
  46. package/dist/router.json +1623 -0
  47. package/dist/server/app.js +11 -3
  48. package/dist/server/app.js.map +1 -1
  49. package/dist/server/io/atomicMultiWrite.js +3 -1
  50. package/dist/server/io/atomicMultiWrite.js.map +1 -1
  51. package/dist/server/io/yamlIO.js +22 -0
  52. package/dist/server/io/yamlIO.js.map +1 -1
  53. package/dist/server/routes/ping.js +8 -0
  54. package/dist/server/routes/ping.js.map +1 -1
  55. package/dist/server/routes/schema.js +2 -2
  56. package/dist/server/routes/schema.js.map +1 -1
  57. package/dist/server/routes/settings.js +104 -23
  58. package/dist/server/routes/settings.js.map +1 -1
  59. package/dist/server/routes/userMd.js +37 -27
  60. package/dist/server/routes/userMd.js.map +1 -1
  61. package/dist/server/routes/wizard.js +256 -20
  62. package/dist/server/routes/wizard.js.map +1 -1
  63. package/dist/server/schemas/settings.js +0 -1
  64. package/dist/server/schemas/settings.js.map +1 -1
  65. package/dist/server/token.js +10 -3
  66. package/dist/server/token.js.map +1 -1
  67. package/dist/server/writeRoot.js +28 -11
  68. package/dist/server/writeRoot.js.map +1 -1
  69. package/dist/server/writeRoot.test.js +22 -4
  70. package/dist/server/writeRoot.test.js.map +1 -1
  71. package/dist/shared/userMd/formAdapter.js +29 -51
  72. package/dist/shared/userMd/formAdapter.js.map +1 -1
  73. package/dist/shared/userMd/schema.js +32 -104
  74. package/dist/shared/userMd/schema.js.map +1 -1
  75. package/dist/shared/userMd/utils.js +64 -50
  76. package/dist/shared/userMd/utils.js.map +1 -1
  77. package/dist/ui/assets/index-D-DY1ywI.js +35 -0
  78. package/dist/ui/assets/index-D-DY1ywI.js.map +1 -0
  79. package/dist/ui/index.html +1 -1
  80. package/docs/adrs/router/0001-three-tier-routing.md +5 -5
  81. package/docs/adrs/smoke/0001-per-tier-smoke-scripts.md +1 -1
  82. package/docs/architecture.md +3 -3
  83. package/docs/archive/CHANGELOG-pre-3.1.0.md +167 -0
  84. package/docs/catalog.md +30 -26
  85. package/docs/contracts/CHANGELOG-conventions.md +1 -1
  86. package/docs/contracts/agent-user-schema.md +6 -9
  87. package/docs/contracts/consumer-bridge.md +79 -0
  88. package/docs/contracts/discovery-manifest.md +209 -0
  89. package/docs/contracts/discovery-manifest.schema.json +77 -4
  90. package/docs/contracts/explain-trace.schema.json +1 -1
  91. package/docs/contracts/file-ownership-matrix.json +197 -13
  92. package/docs/contracts/frontmatter-contract.md +140 -0
  93. package/docs/contracts/gui-wizard.md +223 -0
  94. package/docs/contracts/installer-agent-mode.md +137 -0
  95. package/docs/contracts/kernel-membership.md +1 -1
  96. package/docs/contracts/mcp-tool-inventory.md +9 -9
  97. package/docs/contracts/namespace.md +6 -6
  98. package/docs/contracts/provider-lifecycle.md +5 -5
  99. package/docs/contracts/rule-router.md +4 -4
  100. package/docs/contracts/settings-api.md +53 -6
  101. package/docs/contracts/smoke-contracts.md +3 -3
  102. package/docs/contracts/trust-and-safety.md +144 -0
  103. package/docs/customization.md +2 -2
  104. package/docs/decisions/ADR-007-agent-discovery-scopes.md +12 -0
  105. package/docs/decisions/ADR-013-discovery-frontmatter-contract.md +24 -0
  106. package/docs/decisions/ADR-015-discovery-manifest-contract.md +146 -0
  107. package/docs/decisions/ADR-016-installer-architecture.md +189 -0
  108. package/docs/decisions/ADR-017-monorepo-physical-layout.md +261 -0
  109. package/docs/decisions/ADR-018-trust-and-safety-layer.md +159 -0
  110. package/docs/decisions/ADR-019-router-json-dist-location.md +124 -0
  111. package/docs/decisions/ADR-020-global-only-consumer-scope.md +123 -0
  112. package/docs/decisions/ADR-021-deployment-shape.md +153 -0
  113. package/docs/decisions/INDEX.md +7 -0
  114. package/docs/deploy/connector-setup.md +129 -0
  115. package/docs/deploy/env-vars.md +70 -0
  116. package/docs/deploy/policy-cookbook.md +130 -0
  117. package/docs/deploy/quickstart.md +112 -0
  118. package/docs/distribution/public-install-smoke.md +68 -0
  119. package/docs/distribution/registries.md +55 -0
  120. package/docs/distribution/telemetry-privacy.md +128 -0
  121. package/docs/distribution/telemetry-schema.md +174 -0
  122. package/docs/featured-skills.md +95 -0
  123. package/docs/getting-started-by-role.md +19 -1
  124. package/docs/getting-started.md +2 -2
  125. package/docs/guidelines/agent-infra/installed-tools-manifest.md +11 -8
  126. package/docs/guidelines/docs/readme-size-and-splitting.md +53 -1
  127. package/docs/installation.md +27 -14
  128. package/docs/maintainers/dev-mode.md +105 -0
  129. package/docs/setup/per-ide/claude-desktop.md +3 -2
  130. package/docs/wizard.md +39 -4
  131. package/package.json +18 -1
  132. package/scripts/__pycache__/validate_frontmatter.cpython-312.pyc +0 -0
  133. package/scripts/_cli/cmd_doctor.py +150 -2
  134. package/scripts/_cli/cmd_explain.py +2 -1
  135. package/scripts/_cli/cmd_migrate_to_global.py +415 -0
  136. package/scripts/_cli/cmd_settings_migrate.py +146 -0
  137. package/scripts/_cli/explain_last/route.py +2 -1
  138. package/scripts/_dispatch.bash +36 -3
  139. package/scripts/_lib/__pycache__/__init__.cpython-312.pyc +0 -0
  140. package/scripts/_lib/__pycache__/agent_src.cpython-312.pyc +0 -0
  141. package/scripts/_lib/agent_settings.py +4 -1
  142. package/scripts/_lib/agent_src.py +157 -0
  143. package/scripts/agent-config +17 -6
  144. package/scripts/audit_skill_descriptions.py +18 -6
  145. package/scripts/build_discovery_manifest.py +373 -17
  146. package/scripts/check_artefact_checksums.py +104 -0
  147. package/scripts/check_cluster_patterns.py +20 -4
  148. package/scripts/check_command_count_messaging.py +33 -14
  149. package/scripts/check_council_references.py +43 -4
  150. package/scripts/check_overlay_cascade_subdirs.py +7 -3
  151. package/scripts/check_references.py +5 -2
  152. package/scripts/check_reply_consistency.py +32 -9
  153. package/scripts/check_template_pin_drift.py +24 -7
  154. package/scripts/check_token_optimizer_freshness.py +18 -3
  155. package/scripts/compile_router.py +34 -2
  156. package/scripts/compress.py +162 -44
  157. package/scripts/config/presets.py +19 -1
  158. package/scripts/config/profiles.py +16 -1
  159. package/scripts/discovery_stats.py +70 -0
  160. package/scripts/expected_perms.json +47 -0
  161. package/scripts/generate_index.py +78 -46
  162. package/scripts/generate_ownership_matrix.py +98 -43
  163. package/scripts/generate_pack_manifests.py +183 -0
  164. package/scripts/install +18 -1
  165. package/scripts/install.py +934 -59
  166. package/scripts/install.sh +27 -9
  167. package/scripts/lint_agents_layout.py +93 -13
  168. package/scripts/lint_agents_md.py +1 -1
  169. package/scripts/lint_archived_skills.py +32 -16
  170. package/scripts/lint_bench_corpus.py +14 -2
  171. package/scripts/lint_command_tiers.py +15 -2
  172. package/scripts/lint_featured_skills.py +139 -0
  173. package/scripts/lint_framework_leakage.py +33 -6
  174. package/scripts/lint_global_paths.py +147 -0
  175. package/scripts/lint_orchestration_dsl.py +6 -3
  176. package/scripts/lint_pack_boundaries.py +147 -0
  177. package/scripts/lint_pack_first_win.py +103 -0
  178. package/scripts/lint_readme_jargon.py +131 -0
  179. package/scripts/lint_readme_size.py +33 -0
  180. package/scripts/lint_rule_interactions.py +23 -5
  181. package/scripts/lint_rule_tiers.py +12 -3
  182. package/scripts/lint_trust_coherence.py +212 -0
  183. package/scripts/measure_rule_budget.py +22 -4
  184. package/scripts/move_artefact.py +143 -0
  185. package/scripts/new_skill.py +148 -0
  186. package/scripts/plan_physical_move.py +353 -0
  187. package/scripts/refine_ticket_detect.py +30 -7
  188. package/scripts/schemas/command.schema.json +4 -0
  189. package/scripts/skill_linter.py +248 -118
  190. package/scripts/skill_trigger_eval.py +28 -8
  191. package/scripts/smoke/kernel.sh +1 -1
  192. package/scripts/smoke/router.sh +24 -5
  193. package/scripts/smoke/skills.sh +15 -7
  194. package/scripts/smoke_quickstart.py +11 -2
  195. package/scripts/snapshot_agent_outputs.py +144 -0
  196. package/scripts/update_counts.py +45 -17
  197. package/scripts/validate_decision_engine.py +9 -1
  198. package/scripts/validate_discovery_manifest.py +94 -0
  199. package/scripts/validate_frontmatter.py +39 -20
  200. package/scripts/verify_physical_move.py +185 -0
  201. package/templates/agent-user.md +0 -1
  202. package/templates/agent-user.yml +21 -0
  203. package/templates/minimal/agents-overrides-readme.md +46 -0
  204. package/templates/minimal/overrides-gitkeep +2 -0
  205. package/dist/ui/assets/index-BTRcKDlB.js +0 -39
  206. package/dist/ui/assets/index-BTRcKDlB.js.map +0 -1
  207. package/templates/minimal/agents-gitkeep +0 -2
@@ -37,6 +37,7 @@ from validate_frontmatter import ( # noqa: E402
37
37
  load_schema,
38
38
  validate as validate_against_schema,
39
39
  )
40
+ from _lib.agent_src import artefact_roots, resolve_logical # noqa: E402
40
41
 
41
42
  Severity = Literal["error", "warning", "info"]
42
43
  ArtifactType = Literal["skill", "rule", "command", "guideline", "persona", "user-type", "unknown"]
@@ -974,6 +975,30 @@ def extract_frontmatter(text: str) -> Optional[str]:
974
975
  return match.group(1) if match else None
975
976
 
976
977
 
978
+ def _parse_trust_level(frontmatter: str) -> Optional[str]:
979
+ """Parse `trust.level:` from the nested `trust:` mapping in frontmatter.
980
+
981
+ Returns the level string (e.g. ``"core"``, ``"advisory"``) or ``None``
982
+ if absent. Stdlib-only — mirrors the line-walking approach of
983
+ ``_parse_yaml_list`` so the linter stays pyyaml-free.
984
+ """
985
+ lines = frontmatter.splitlines()
986
+ in_block = False
987
+ for line in lines:
988
+ if not in_block:
989
+ if line.startswith("trust:"):
990
+ rhs = line[len("trust:"):].strip()
991
+ if rhs == "":
992
+ in_block = True
993
+ continue
994
+ if line.startswith(" level:"):
995
+ return line[len(" level:"):].strip().strip('"').strip("'")
996
+ if line.startswith(" "):
997
+ continue
998
+ break
999
+ return None
1000
+
1001
+
977
1002
  def _parse_yaml_list(frontmatter: str, key: str) -> Optional[list]:
978
1003
  """Parse a simple top-level YAML list `key:` from frontmatter.
979
1004
 
@@ -1028,6 +1053,10 @@ def lint_router_frontmatter(rule_id: str, frontmatter: str,
1028
1053
  Lenient checks (info-level until Phase 4 migrations land): non-kernel
1029
1054
  rules without `triggers:` / `routes_to:` get an informational note,
1030
1055
  not an error — the existing description-matching path still works.
1056
+
1057
+ Trust-tier carve-out: rules with ``trust.level: core`` are exempt from
1058
+ the ``router_routes_to_missing`` migration hint — they are authoritative
1059
+ by design and their body legitimately lives inline.
1031
1060
  """
1032
1061
  issues: List[Issue] = []
1033
1062
  triggers = _parse_yaml_list(frontmatter, "triggers")
@@ -1068,9 +1097,15 @@ def lint_router_frontmatter(rule_id: str, frontmatter: str,
1068
1097
  f"triggers[{idx}] key '{k}' not in allowed set ({allowed})"))
1069
1098
 
1070
1099
  if routes_to is None:
1071
- issues.append(Issue("info", "router_routes_to_missing",
1072
- "Non-kernel rule has no routes_to: — body should migrate to skill / "
1073
- "guideline in Phase 4"))
1100
+ # Trust-tier carve-out: rules pinned at trust.level: core are
1101
+ # authoritative their body IS the behavior and may legitimately
1102
+ # live inline without a routes_to: delegation. The Phase 4
1103
+ # migration hint applies only to lower-trust rules.
1104
+ trust_level = _parse_trust_level(frontmatter)
1105
+ if trust_level != "core":
1106
+ issues.append(Issue("info", "router_routes_to_missing",
1107
+ "Non-kernel rule has no routes_to: — body should migrate to skill / "
1108
+ "guideline in Phase 4"))
1074
1109
  else:
1075
1110
  repo_root = Path(__file__).resolve().parent.parent
1076
1111
  for idx, item in enumerate(routes_to):
@@ -1079,29 +1114,34 @@ def lint_router_frontmatter(rule_id: str, frontmatter: str,
1079
1114
  f"routes_to[{idx}] must be 'kind:id'"))
1080
1115
  continue
1081
1116
  kind, _, target_id = item.partition(":")
1117
+ # Multi-root aware (ADR-017): resolve logical paths via every
1118
+ # source root so kernel rules keep routing to skills/commands
1119
+ # that moved into packages/*/.
1120
+ target: Optional[Path] = None
1082
1121
  if kind == "skill":
1083
- target = repo_root / ".agent-src.uncompressed" / "skills" / target_id / "SKILL.md"
1122
+ target = resolve_logical(f"skills/{target_id}/SKILL.md")
1084
1123
  elif kind == "guideline":
1085
- target = repo_root / "docs" / "guidelines" / f"{target_id}.md"
1124
+ gpath = repo_root / "docs" / "guidelines" / f"{target_id}.md"
1125
+ target = gpath if gpath.exists() else None
1086
1126
  elif kind == "command":
1087
- target = repo_root / ".agent-src.uncompressed" / "commands" / f"{target_id}.md"
1127
+ target = resolve_logical(f"commands/{target_id}.md")
1088
1128
  elif kind == "contract":
1089
1129
  # Contracts live in two places: stable host docs in
1090
- # docs/contracts/ and load-bearing flows in
1091
- # .agent-src.uncompressed/contexts/contracts/ (road-to-path-fixes
1130
+ # docs/contracts/ and load-bearing flows under
1131
+ # contexts/contracts/ inside any source root (road-to-path-fixes
1092
1132
  # P4 / Council R2). Try both before failing.
1093
- target = repo_root / "docs" / "contracts" / f"{target_id}.md"
1094
- if not target.exists():
1095
- alt = repo_root / ".agent-src.uncompressed" / "contexts" / "contracts" / f"{target_id}.md"
1096
- if alt.exists():
1097
- target = alt
1133
+ cpath = repo_root / "docs" / "contracts" / f"{target_id}.md"
1134
+ if cpath.exists():
1135
+ target = cpath
1136
+ else:
1137
+ target = resolve_logical(f"contexts/contracts/{target_id}.md")
1098
1138
  else:
1099
1139
  issues.append(Issue("error", "route_kind_unknown",
1100
1140
  f"routes_to[{idx}] kind '{kind}' must be 'skill', 'guideline', 'command', or 'contract'"))
1101
1141
  continue
1102
- if not target.exists():
1142
+ if target is None or not target.exists():
1103
1143
  issues.append(Issue("error", "route_target_missing",
1104
- f"routes_to[{idx}] target '{item}' not found at {target}"))
1144
+ f"routes_to[{idx}] target '{item}' not found under any artefact root"))
1105
1145
  return issues
1106
1146
 
1107
1147
 
@@ -2019,86 +2059,126 @@ def lint_usertype(path: Path, text: str) -> LintResult:
2019
2059
 
2020
2060
 
2021
2061
  def gather_all_candidate_files(root: Path) -> list[Path]:
2022
- """Gather all lintable files. Prefers .agent-src.uncompressed/ (source of truth).
2023
- Falls back to .agent-src/ only if uncompressed doesn't exist.
2024
- Skips symlinks to avoid double-counting."""
2062
+ """Gather all lintable files across every source root (ADR-017 multi-root).
2063
+
2064
+ Walks ``artefact_roots()`` (legacy ``.agent-src.uncompressed/`` plus every
2065
+ ``packages/*/.agent-src.uncompressed/``). Falls back to ``.agent-src/``
2066
+ only when no source root exists. Skips symlinks to avoid double-counting.
2067
+ Deduplicates on logical relpath \u2014 first root wins per the agent_src
2068
+ contract.
2069
+ """
2025
2070
  candidates: list[Path] = []
2071
+ seen_logical: set[str] = set()
2026
2072
 
2027
- # Source of truth directories
2028
- uncompressed_skills = root / ".agent-src.uncompressed" / "skills"
2029
- uncompressed_rules = root / ".agent-src.uncompressed" / "rules"
2030
- uncompressed_commands = root / ".agent-src.uncompressed" / "commands"
2031
- uncompressed_guidelines = root / ".agent-src.uncompressed" / "guidelines"
2032
-
2033
- # Fallback directories (only if uncompressed doesn't exist)
2034
- augment_skills = root / ".agent-src" / "skills"
2035
- augment_rules = root / ".agent-src" / "rules"
2036
- augment_commands = root / ".agent-src" / "commands"
2037
- augment_guidelines = root / ".agent-src" / "guidelines"
2038
-
2039
- # Skills
2040
- skills_base = uncompressed_skills if uncompressed_skills.exists() else augment_skills
2041
- if skills_base.exists():
2042
- for f in skills_base.rglob("SKILL.md"):
2043
- if not f.is_symlink():
2044
- candidates.append(f)
2045
-
2046
- # Rules
2047
- rules_base = uncompressed_rules if uncompressed_rules.exists() else augment_rules
2048
- if rules_base.exists():
2049
- for f in rules_base.rglob("*.md"):
2050
- if not f.is_symlink():
2051
- candidates.append(f)
2052
-
2053
- # Commands
2054
- commands_base = uncompressed_commands if uncompressed_commands.exists() else augment_commands
2055
- if commands_base.exists():
2056
- for f in commands_base.rglob("*.md"):
2057
- if not f.is_symlink():
2058
- candidates.append(f)
2059
-
2060
- # Guidelines
2061
- guidelines_base = uncompressed_guidelines if uncompressed_guidelines.exists() else augment_guidelines
2062
- if guidelines_base.exists():
2063
- for f in guidelines_base.rglob("*.md"):
2064
- if not f.is_symlink():
2065
- candidates.append(f)
2066
-
2067
- # Personas
2068
- uncompressed_personas = root / ".agent-src.uncompressed" / "personas"
2069
- augment_personas = root / ".agent-src" / "personas"
2070
- personas_base = uncompressed_personas if uncompressed_personas.exists() else augment_personas
2071
- if personas_base.exists():
2072
- for f in personas_base.glob("*.md"):
2073
- if f.name.lower() == "readme.md":
2074
- continue
2075
- if not f.is_symlink():
2076
- candidates.append(f)
2077
-
2078
- # User-types (sister axis to personas — methodology vs end-user).
2079
- # Top-level .md only; README and _template/ subtree excluded.
2080
- uncompressed_usertypes = root / ".agent-src.uncompressed" / "user-types"
2081
- augment_usertypes = root / ".agent-src" / "user-types"
2082
- usertypes_base = uncompressed_usertypes if uncompressed_usertypes.exists() else augment_usertypes
2083
- if usertypes_base.exists():
2084
- for f in usertypes_base.glob("*.md"):
2085
- if f.name.lower() == "readme.md":
2086
- continue
2087
- if not f.is_symlink():
2088
- candidates.append(f)
2089
-
2090
- # Frugality charter (Phase 0.4 Layer 2). Lives in contexts/, not
2091
- # walked by the artifact-type loops above, but still needs the
2092
- # index-integrity check.
2093
- for base in (root / ".agent-src.uncompressed", root / ".agent-src"):
2094
- charter = base / FRUGALITY_CHARTER_RELPATH
2095
- if charter.exists() and not charter.is_symlink():
2096
- candidates.append(charter)
2097
- break
2073
+ def _add(file: Path, source_root: Path) -> None:
2074
+ if file.is_symlink() or not file.is_file():
2075
+ return
2076
+ try:
2077
+ logical = file.relative_to(source_root).as_posix()
2078
+ except ValueError:
2079
+ logical = file.name
2080
+ # Namespace by artefact-kind subdir so the same skill name across
2081
+ # packs would still dedupe (but the agent_src layout guarantees
2082
+ # each logical path lives in exactly one root post-move).
2083
+ if logical in seen_logical:
2084
+ return
2085
+ seen_logical.add(logical)
2086
+ candidates.append(file)
2087
+
2088
+ sources = artefact_roots()
2089
+ if sources:
2090
+ for src_root in sources:
2091
+ for f in (src_root / "skills").rglob("SKILL.md") if (src_root / "skills").exists() else []:
2092
+ _add(f, src_root)
2093
+ for sub in ("rules", "commands", "guidelines"):
2094
+ base = src_root / sub
2095
+ if base.exists():
2096
+ for f in base.rglob("*.md"):
2097
+ _add(f, src_root)
2098
+ for sub in ("personas", "user-types"):
2099
+ base = src_root / sub
2100
+ if base.exists():
2101
+ for f in base.glob("*.md"):
2102
+ if f.name.lower() == "readme.md":
2103
+ continue
2104
+ _add(f, src_root)
2105
+ charter = src_root / FRUGALITY_CHARTER_RELPATH
2106
+ if charter.exists() and not charter.is_symlink():
2107
+ _add(charter, src_root)
2108
+ else:
2109
+ # Pure-compressed fallback (.agent-src/ only). Used by consumer
2110
+ # projects that vendor the compressed tree without sources.
2111
+ augment_root = root / ".agent-src"
2112
+ if augment_root.exists():
2113
+ for sub_pattern in (
2114
+ ("skills", "SKILL.md"),
2115
+ ("rules", "*.md"),
2116
+ ("commands", "*.md"),
2117
+ ("guidelines", "*.md"),
2118
+ ):
2119
+ base = augment_root / sub_pattern[0]
2120
+ if base.exists():
2121
+ for f in base.rglob(sub_pattern[1]):
2122
+ _add(f, augment_root)
2123
+ for sub in ("personas", "user-types"):
2124
+ base = augment_root / sub
2125
+ if base.exists():
2126
+ for f in base.glob("*.md"):
2127
+ if f.name.lower() == "readme.md":
2128
+ continue
2129
+ _add(f, augment_root)
2130
+ charter = augment_root / FRUGALITY_CHARTER_RELPATH
2131
+ if charter.exists() and not charter.is_symlink():
2132
+ _add(charter, augment_root)
2098
2133
 
2099
2134
  return sorted(set(candidates))
2100
2135
 
2101
2136
 
2137
+ def gather_candidate_files_under(src_root: Path) -> list[Path]:
2138
+ """Gather lintable files under an arbitrary source root.
2139
+
2140
+ Mirrors the per-root walk used by ``gather_all_candidate_files`` but
2141
+ scoped to a single directory \u2014 e.g. ``packages/pack-laravel/.agent-src.uncompressed/``
2142
+ so CI can lint a single pack in parallel (ADR-017 Phase 4.4).
2143
+ Skips symlinks and ``README.md`` siblings under ``personas/`` /
2144
+ ``user-types/``.
2145
+ """
2146
+ out: list[Path] = []
2147
+ if not src_root.is_dir():
2148
+ return out
2149
+ seen: set[Path] = set()
2150
+
2151
+ def _push(file: Path) -> None:
2152
+ if file.is_symlink() or not file.is_file():
2153
+ return
2154
+ resolved = file.resolve()
2155
+ if resolved in seen:
2156
+ return
2157
+ seen.add(resolved)
2158
+ out.append(file)
2159
+
2160
+ skills_dir = src_root / "skills"
2161
+ if skills_dir.exists():
2162
+ for f in skills_dir.rglob("SKILL.md"):
2163
+ _push(f)
2164
+ for sub in ("rules", "commands", "guidelines"):
2165
+ base = src_root / sub
2166
+ if base.exists():
2167
+ for f in base.rglob("*.md"):
2168
+ _push(f)
2169
+ for sub in ("personas", "user-types"):
2170
+ base = src_root / sub
2171
+ if base.exists():
2172
+ for f in base.glob("*.md"):
2173
+ if f.name.lower() == "readme.md":
2174
+ continue
2175
+ _push(f)
2176
+ charter = src_root / FRUGALITY_CHARTER_RELPATH
2177
+ if charter.exists() and not charter.is_symlink():
2178
+ _push(charter)
2179
+ return sorted(set(out))
2180
+
2181
+
2102
2182
  def gather_changed_candidate_files(root: Path) -> list[Path]:
2103
2183
  """Find changed skill/rule files using git diff.
2104
2184
 
@@ -2138,10 +2218,15 @@ def gather_changed_candidate_files(root: Path) -> list[Path]:
2138
2218
  # dirs (.windsurf/, .cursor/, .clinerules/, .claude/) use
2139
2219
  # tool-native frontmatter (e.g. Windsurf's trigger/globs) that
2140
2220
  # the linter does not validate — they regenerate from source.
2141
- if not (
2221
+ # ADR-017: accept legacy flat layout AND
2222
+ # packages/*/.agent-src.uncompressed/ paths.
2223
+ in_source = (
2142
2224
  norm.startswith(".agent-src.uncompressed/")
2143
2225
  or norm.startswith(".agent-src/")
2144
- ):
2226
+ or "/.agent-src.uncompressed/" in norm
2227
+ or "/.agent-src/" in norm
2228
+ )
2229
+ if not in_source:
2145
2230
  continue
2146
2231
  if path.name == "SKILL.md" or "/rules/" in norm or "/commands/" in norm:
2147
2232
  files.append(path)
@@ -2770,20 +2855,37 @@ def lint_governance(path: Path, text: str, artifact_type: str, repo_root: Path |
2770
2855
  return issues
2771
2856
 
2772
2857
  # --- Check: compressed/uncompressed pair exists ---
2858
+ # ADR-017: sources live under packages/*/.agent-src.uncompressed/ but
2859
+ # all packs project into the single repo-root .agent-src/ tree. The
2860
+ # pair-check now resolves via logical relpath, not a path-swap.
2861
+ from _lib.agent_src import strip_source_prefix as _strip
2862
+ norm = path_str.replace("\\", "/")
2773
2863
  if is_uncompressed:
2774
- # Find expected compressed path
2775
- compressed_path = Path(path_str.replace("/.agent-src.uncompressed/", "/.agent-src/"))
2776
- if not compressed_path.exists():
2777
- issues.append(Issue("warning", "compressed_variant_missing",
2778
- f"Uncompressed file exists but compressed variant missing: "
2779
- f"{compressed_path.name}"))
2864
+ # Compute logical path then map to .agent-src/ at repo root.
2865
+ # Try direct strip first; fall back to substring split for absolute paths.
2866
+ logical = _strip(norm)
2867
+ if logical is None:
2868
+ marker = "/.agent-src.uncompressed/"
2869
+ idx = norm.rfind(marker)
2870
+ logical = norm[idx + len(marker):] if idx != -1 else None
2871
+ if logical:
2872
+ compressed_path = repo_root / ".agent-src" / logical
2873
+ if not compressed_path.exists():
2874
+ issues.append(Issue("warning", "compressed_variant_missing",
2875
+ f"Uncompressed file exists but compressed variant missing: "
2876
+ f"{compressed_path.name}"))
2780
2877
  elif is_compressed:
2781
- # Find expected uncompressed path
2782
- uncompressed_path = Path(path_str.replace("/.agent-src/", "/.agent-src.uncompressed/"))
2783
- if not uncompressed_path.exists():
2784
- issues.append(Issue("warning", "uncompressed_variant_missing",
2785
- f"Compressed file exists but uncompressed source missing: "
2786
- f"{uncompressed_path.name}"))
2878
+ # Compressed lives at repo-root .agent-src/<logical>. Source could
2879
+ # be at any source root \u2014 resolve via artefact_roots.
2880
+ marker = "/.agent-src/"
2881
+ idx = norm.rfind(marker)
2882
+ logical = norm[idx + len(marker):] if idx != -1 else None
2883
+ if logical:
2884
+ uncompressed_path = resolve_logical(logical)
2885
+ if uncompressed_path is None or not uncompressed_path.exists():
2886
+ issues.append(Issue("warning", "uncompressed_variant_missing",
2887
+ f"Compressed file exists but uncompressed source missing: "
2888
+ f"{Path(logical).name}"))
2787
2889
 
2788
2890
  # --- Check: file in correct location for type ---
2789
2891
  location_map = {
@@ -3186,18 +3288,23 @@ def check_compression_pairs(root: Path) -> list[LintResult]:
3186
3288
  ]
3187
3289
 
3188
3290
  for subdir, pattern, is_nested in pairs:
3189
- uncompressed_dir = root / ".agent-src.uncompressed" / subdir
3291
+ # ADR-017: union across every source root.
3190
3292
  compressed_dir = root / ".agent-src" / subdir
3293
+ uncompressed_names: set[str] = set()
3294
+ any_source = False
3295
+ for src_root in artefact_roots():
3296
+ uncompressed_dir = src_root / subdir
3297
+ if not uncompressed_dir.exists():
3298
+ continue
3299
+ any_source = True
3300
+ if is_nested:
3301
+ uncompressed_names |= {d.name for d in uncompressed_dir.iterdir() if d.is_dir() and (d / pattern).exists()}
3302
+ else:
3303
+ uncompressed_names |= {f.name for f in uncompressed_dir.glob(pattern) if f.is_file()}
3191
3304
 
3192
- if not uncompressed_dir.exists():
3305
+ if not any_source:
3193
3306
  continue
3194
3307
 
3195
- # Collect names from uncompressed
3196
- if is_nested:
3197
- uncompressed_names = {d.name for d in uncompressed_dir.iterdir() if d.is_dir() and (d / pattern).exists()}
3198
- else:
3199
- uncompressed_names = {f.name for f in uncompressed_dir.glob(pattern) if f.is_file()}
3200
-
3201
3308
  # Collect names from compressed
3202
3309
  if compressed_dir.exists():
3203
3310
  if is_nested:
@@ -3235,16 +3342,23 @@ def check_compression_pairs(root: Path) -> list[LintResult]:
3235
3342
  def check_compression_quality(root: Path) -> list[LintResult]:
3236
3343
  """Check that compressed skills preserve key content from their uncompressed source."""
3237
3344
  results: list[LintResult] = []
3238
- uncompressed_dir = root / ".agent-src.uncompressed" / "skills"
3239
3345
  compressed_dir = root / ".agent-src" / "skills"
3346
+ if not compressed_dir.exists():
3347
+ return results
3240
3348
 
3241
- if not uncompressed_dir.exists() or not compressed_dir.exists():
3349
+ # ADR-017: collect skill dirs from every source root.
3350
+ skill_sources: list[Path] = []
3351
+ for src_root in artefact_roots():
3352
+ uncompressed_dir = src_root / "skills"
3353
+ if uncompressed_dir.exists():
3354
+ skill_sources.extend(sorted(uncompressed_dir.iterdir()))
3355
+ if not skill_sources:
3242
3356
  return results
3243
3357
 
3244
3358
  # Sections that MUST exist in compressed if they exist in uncompressed
3245
3359
  preserved_sections = ["When to use", "Procedure", "Gotcha", "Gotchas", "Do NOT", "Output format", "Output"]
3246
3360
 
3247
- for skill_dir in sorted(uncompressed_dir.iterdir()):
3361
+ for skill_dir in skill_sources:
3248
3362
  src = skill_dir / "SKILL.md"
3249
3363
  dst = compressed_dir / skill_dir.name / "SKILL.md"
3250
3364
  if not src.exists() or not dst.exists():
@@ -3306,13 +3420,23 @@ def check_compression_quality(root: Path) -> list[LintResult]:
3306
3420
  def check_duplication(root: Path) -> list[LintResult]:
3307
3421
  """Detect skills with highly similar names or descriptions."""
3308
3422
  results: list[LintResult] = []
3309
- skills_dir = root / ".agent-src.uncompressed" / "skills"
3310
- if not skills_dir.exists():
3423
+ # ADR-017: collect skill dirs across every source root, dedup by name.
3424
+ skill_dirs: list[Path] = []
3425
+ seen: set[str] = set()
3426
+ for src_root in artefact_roots():
3427
+ sd = src_root / "skills"
3428
+ if not sd.exists():
3429
+ continue
3430
+ for d in sorted(sd.iterdir()):
3431
+ if d.is_dir() and d.name not in seen:
3432
+ seen.add(d.name)
3433
+ skill_dirs.append(d)
3434
+ if not skill_dirs:
3311
3435
  return results
3312
3436
 
3313
3437
  # Collect all skill names and descriptions
3314
3438
  skill_data: list[tuple[str, str, Path]] = []
3315
- for skill_dir in sorted(skills_dir.iterdir()):
3439
+ for skill_dir in skill_dirs:
3316
3440
  skill_file = skill_dir / "SKILL.md"
3317
3441
  if not skill_file.exists():
3318
3442
  continue
@@ -3497,7 +3621,13 @@ def main() -> int:
3497
3621
  paths.extend(gather_changed_candidate_files(root))
3498
3622
  for raw in args.paths:
3499
3623
  path = (root / raw).resolve() if not Path(raw).is_absolute() else Path(raw)
3500
- if path.exists():
3624
+ if not path.exists():
3625
+ continue
3626
+ if path.is_dir():
3627
+ # Walk the directory like a source root so callers can pass
3628
+ # `packages/pack-laravel/.agent-src.uncompressed/` (ADR-017 Phase 4.4).
3629
+ paths.extend(gather_candidate_files_under(path))
3630
+ else:
3501
3631
  paths.append(path)
3502
3632
 
3503
3633
  paths = sorted(set(paths))
@@ -149,16 +149,32 @@ class MockRouter:
149
149
  return loaded, len(query) // 4 + len(skills) * 20, 16
150
150
 
151
151
 
152
- def load_skill_metas(root: Path = SKILLS_SOURCE) -> list[SkillMeta]:
153
- """Parse name + description from every SKILL.md frontmatter under root."""
152
+ def load_skill_metas(root: Path | None = None) -> list[SkillMeta]:
153
+ """Parse name + description from every SKILL.md frontmatter under root.
154
+
155
+ If ``root`` is None, the package's ``artefact_roots()`` are scanned so
156
+ discovery works across the monorepo's per-pack ``.agent-src.uncompressed/``
157
+ trees. A single explicit ``root`` keeps tests that mock a sub-tree working
158
+ unchanged.
159
+ """
160
+ if root is not None:
161
+ roots = [root]
162
+ else:
163
+ from _lib.agent_src import artefact_roots
164
+ roots = [r / "skills" for r in artefact_roots()]
154
165
  metas: list[SkillMeta] = []
155
- for skill_dir in sorted(p for p in root.iterdir() if p.is_dir()):
156
- skill_md = skill_dir / "SKILL.md"
157
- if not skill_md.exists():
166
+ seen: set[str] = set()
167
+ for skills_dir in roots:
168
+ if not skills_dir.is_dir():
158
169
  continue
159
- meta = _parse_frontmatter(skill_md)
160
- if meta is not None:
161
- metas.append(meta)
170
+ for skill_dir in sorted(p for p in skills_dir.iterdir() if p.is_dir()):
171
+ skill_md = skill_dir / "SKILL.md"
172
+ if not skill_md.exists():
173
+ continue
174
+ meta = _parse_frontmatter(skill_md)
175
+ if meta is not None and meta.name not in seen:
176
+ metas.append(meta)
177
+ seen.add(meta.name)
162
178
  return metas
163
179
 
164
180
 
@@ -587,6 +603,10 @@ def build_arg_parser() -> argparse.ArgumentParser:
587
603
 
588
604
 
589
605
  def _default_triggers_path(skill: str) -> Path:
606
+ from _lib.agent_src import resolve_logical
607
+ resolved = resolve_logical(f"skills/{skill}/evals/triggers.json")
608
+ if resolved is not None:
609
+ return resolved
590
610
  return SKILLS_SOURCE / skill / "evals" / "triggers.json"
591
611
 
592
612
 
@@ -29,7 +29,7 @@ log() { [ "$quiet" = "1" ] || printf '%s\n' "$*"; }
29
29
  # 1. kernel ids from router.json
30
30
  kernel_ids=$(python3 -c '
31
31
  import json
32
- d = json.load(open("router.json"))
32
+ d = json.load(open("dist/router.json"))
33
33
  print("\n".join(d.get("kernel", [])))
34
34
  ')
35
35
  kernel_count=$(printf '%s\n' "$kernel_ids" | grep -c .)
@@ -24,7 +24,12 @@ log() { [ "$quiet" = "1" ] || printf '%s\n' "$*"; }
24
24
  result=$(python3 <<'PY'
25
25
  import json, os, sys, pathlib
26
26
 
27
- d = json.load(open("router.json"))
27
+ # ADR-017: routes_to resolution walks artefact_roots() across the
28
+ # monorepo. Skills/commands/guidelines may live under any source root.
29
+ sys.path.insert(0, "scripts")
30
+ from _lib.agent_src import resolve_logical
31
+
32
+ d = json.load(open("dist/router.json"))
28
33
  kernel = d.get("kernel", [])
29
34
  tier1 = d.get("tier_1", [])
30
35
  tier2 = d.get("tier_2", [])
@@ -34,20 +39,34 @@ total = len(ids)
34
39
  # Rule-file resolution
35
40
  missing_rules = [i for i in ids if not os.path.exists(f".agent-src/rules/{i}.md")]
36
41
 
37
- # routes_to resolution
42
+ # routes_to resolution — multi-root aware via resolve_logical.
38
43
  def resolve(ref):
39
44
  if ":" not in ref:
40
- return f".agent-src.uncompressed/skills/{ref}/SKILL.md", "skill"
41
- kind, rest = ref.split(":", 1)
45
+ kind, rest = "skill", ref
46
+ else:
47
+ kind, rest = ref.split(":", 1)
42
48
  if kind == "skill":
43
- return f".agent-src.uncompressed/skills/{rest}/SKILL.md", "skill"
49
+ # Legacy projected path first (fast path), then multi-root source.
50
+ for p in (
51
+ f".agent-src/skills/{rest}/SKILL.md",
52
+ f".agent-src.uncompressed/skills/{rest}/SKILL.md",
53
+ ):
54
+ if os.path.exists(p):
55
+ return p, "skill"
56
+ hit = resolve_logical(f"skills/{rest}/SKILL.md")
57
+ return (str(hit) if hit else f".agent-src.uncompressed/skills/{rest}/SKILL.md"), "skill"
44
58
  if kind == "command":
45
59
  for p in (
60
+ f".agent-src/commands/{rest}.md",
46
61
  f".agent-src.uncompressed/commands/{rest}.md",
47
62
  f".agent-src.uncompressed/commands/{rest}/INDEX.md",
48
63
  ):
49
64
  if os.path.exists(p):
50
65
  return p, "command"
66
+ for logical in (f"commands/{rest}.md", f"commands/{rest}/INDEX.md"):
67
+ hit = resolve_logical(logical)
68
+ if hit:
69
+ return str(hit), "command"
51
70
  return f".agent-src.uncompressed/commands/{rest}.md", "command"
52
71
  if kind == "guideline":
53
72
  return f"docs/guidelines/{rest}.md", "guideline"
@@ -26,13 +26,21 @@ result=$(python3 <<'PY'
26
26
  import os, sys, time, hashlib, pathlib, glob
27
27
  sys.path.insert(0, "scripts")
28
28
  from validate_frontmatter import parse_frontmatter, load_schema, validate
29
+ from _lib.agent_src import artefact_roots
29
30
 
30
- root = ".agent-src.uncompressed/skills"
31
- skills = sorted(
32
- d for d in os.listdir(root)
33
- if os.path.isdir(os.path.join(root, d))
34
- and os.path.exists(os.path.join(root, d, "SKILL.md"))
35
- )
31
+ # ADR-017: walk every source root, collect skill dirs by logical name.
32
+ # First root wins on collision (legacy > core > packs per agent_src).
33
+ skills_by_name: dict[str, str] = {}
34
+ for src_root in artefact_roots():
35
+ sd = src_root / "skills"
36
+ if not sd.exists():
37
+ continue
38
+ for d in sorted(sd.iterdir()):
39
+ if not d.is_dir():
40
+ continue
41
+ if (d / "SKILL.md").exists() and d.name not in skills_by_name:
42
+ skills_by_name[d.name] = str(d / "SKILL.md")
43
+ skills = sorted(skills_by_name.keys())
36
44
  total = len(skills)
37
45
  print(f"TOTAL_SKILLS={total}")
38
46
 
@@ -46,7 +54,7 @@ schema = load_schema("skill")
46
54
 
47
55
  failures = []
48
56
  for name in sample:
49
- path = os.path.join(root, name, "SKILL.md")
57
+ path = skills_by_name[name]
50
58
  text = open(path, encoding="utf-8").read()
51
59
  fm, _ = parse_frontmatter(text)
52
60
  if fm is None: