@event4u/agent-config 1.9.1

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 (446) hide show
  1. package/.agent-src/README.md +64 -0
  2. package/.agent-src/commands/agent-handoff.md +64 -0
  3. package/.agent-src/commands/agent-status.md +83 -0
  4. package/.agent-src/commands/agents-audit.md +243 -0
  5. package/.agent-src/commands/agents-cleanup.md +169 -0
  6. package/.agent-src/commands/agents-prepare.md +137 -0
  7. package/.agent-src/commands/analyze-reference-repo.md +191 -0
  8. package/.agent-src/commands/bug-fix.md +181 -0
  9. package/.agent-src/commands/bug-investigate.md +175 -0
  10. package/.agent-src/commands/commit.md +121 -0
  11. package/.agent-src/commands/compress.md +177 -0
  12. package/.agent-src/commands/config-agent-settings.md +126 -0
  13. package/.agent-src/commands/context-create.md +167 -0
  14. package/.agent-src/commands/context-refactor.md +170 -0
  15. package/.agent-src/commands/copilot-agents-init.md +150 -0
  16. package/.agent-src/commands/copilot-agents-optimize.md +251 -0
  17. package/.agent-src/commands/create-pr-description.md +112 -0
  18. package/.agent-src/commands/create-pr.md +76 -0
  19. package/.agent-src/commands/do-and-judge.md +114 -0
  20. package/.agent-src/commands/do-in-steps.md +84 -0
  21. package/.agent-src/commands/e2e-heal.md +98 -0
  22. package/.agent-src/commands/e2e-plan.md +85 -0
  23. package/.agent-src/commands/estimate-ticket.md +80 -0
  24. package/.agent-src/commands/feature-dev.md +111 -0
  25. package/.agent-src/commands/feature-explore.md +180 -0
  26. package/.agent-src/commands/feature-plan.md +288 -0
  27. package/.agent-src/commands/feature-refactor.md +181 -0
  28. package/.agent-src/commands/feature-roadmap.md +184 -0
  29. package/.agent-src/commands/fix-ci.md +48 -0
  30. package/.agent-src/commands/fix-portability.md +97 -0
  31. package/.agent-src/commands/fix-pr-bot-comments.md +146 -0
  32. package/.agent-src/commands/fix-pr-comments.md +58 -0
  33. package/.agent-src/commands/fix-pr-developer-comments.md +152 -0
  34. package/.agent-src/commands/fix-references.md +94 -0
  35. package/.agent-src/commands/fix-seeder.md +146 -0
  36. package/.agent-src/commands/implement-ticket.md +133 -0
  37. package/.agent-src/commands/jira-ticket.md +71 -0
  38. package/.agent-src/commands/judge.md +86 -0
  39. package/.agent-src/commands/memory-add.md +130 -0
  40. package/.agent-src/commands/memory-full.md +97 -0
  41. package/.agent-src/commands/memory-promote.md +144 -0
  42. package/.agent-src/commands/mode.md +121 -0
  43. package/.agent-src/commands/module-create.md +132 -0
  44. package/.agent-src/commands/module-explore.md +157 -0
  45. package/.agent-src/commands/optimize-agents.md +139 -0
  46. package/.agent-src/commands/optimize-augmentignore.md +262 -0
  47. package/.agent-src/commands/optimize-rtk-filters.md +120 -0
  48. package/.agent-src/commands/optimize-skills.md +121 -0
  49. package/.agent-src/commands/override-create.md +97 -0
  50. package/.agent-src/commands/override-manage.md +96 -0
  51. package/.agent-src/commands/package-reset.md +154 -0
  52. package/.agent-src/commands/package-test.md +154 -0
  53. package/.agent-src/commands/prepare-for-review.md +91 -0
  54. package/.agent-src/commands/project-analyze.md +300 -0
  55. package/.agent-src/commands/project-health.md +95 -0
  56. package/.agent-src/commands/propose-memory.md +108 -0
  57. package/.agent-src/commands/quality-fix.md +106 -0
  58. package/.agent-src/commands/refine-ticket.md +81 -0
  59. package/.agent-src/commands/review-changes.md +130 -0
  60. package/.agent-src/commands/review-routing.md +111 -0
  61. package/.agent-src/commands/roadmap-create.md +110 -0
  62. package/.agent-src/commands/roadmap-execute.md +68 -0
  63. package/.agent-src/commands/rule-compliance-audit.md +139 -0
  64. package/.agent-src/commands/tests-create.md +73 -0
  65. package/.agent-src/commands/tests-execute.md +58 -0
  66. package/.agent-src/commands/threat-model.md +115 -0
  67. package/.agent-src/commands/update-form-request-messages.md +189 -0
  68. package/.agent-src/commands/upstream-contribute.md +171 -0
  69. package/.agent-src/contexts/augment-infrastructure.md +181 -0
  70. package/.agent-src/contexts/documentation-hierarchy.md +142 -0
  71. package/.agent-src/contexts/model-recommendations.md +142 -0
  72. package/.agent-src/contexts/override-system.md +187 -0
  73. package/.agent-src/contexts/skills-and-commands.md +154 -0
  74. package/.agent-src/contexts/subagent-configuration.md +62 -0
  75. package/.agent-src/guidelines/agent-infra/agent-interaction-and-decision-quality.md +110 -0
  76. package/.agent-src/guidelines/agent-infra/break-glass-usage.md +113 -0
  77. package/.agent-src/guidelines/agent-infra/developer-judgment.md +82 -0
  78. package/.agent-src/guidelines/agent-infra/engineering-memory-data-format.md +117 -0
  79. package/.agent-src/guidelines/agent-infra/layered-settings.md +158 -0
  80. package/.agent-src/guidelines/agent-infra/memory-access.md +121 -0
  81. package/.agent-src/guidelines/agent-infra/naming.md +69 -0
  82. package/.agent-src/guidelines/agent-infra/output-patterns.md +117 -0
  83. package/.agent-src/guidelines/agent-infra/review-routing-data-format.md +144 -0
  84. package/.agent-src/guidelines/agent-infra/role-contracts.md +211 -0
  85. package/.agent-src/guidelines/agent-infra/role-mode-router.md +89 -0
  86. package/.agent-src/guidelines/agent-infra/runtime-layer.md +89 -0
  87. package/.agent-src/guidelines/agent-infra/self-improvement-pipeline.md +135 -0
  88. package/.agent-src/guidelines/agent-infra/size-and-scope.md +189 -0
  89. package/.agent-src/guidelines/agent-infra/tool-integration.md +73 -0
  90. package/.agent-src/guidelines/docs/readme-size-and-splitting.md +153 -0
  91. package/.agent-src/guidelines/e2e/playwright.md +363 -0
  92. package/.agent-src/guidelines/php/api-design.md +115 -0
  93. package/.agent-src/guidelines/php/artisan-commands.md +81 -0
  94. package/.agent-src/guidelines/php/blade-ui.md +78 -0
  95. package/.agent-src/guidelines/php/controllers.md +90 -0
  96. package/.agent-src/guidelines/php/database.md +111 -0
  97. package/.agent-src/guidelines/php/eloquent.md +208 -0
  98. package/.agent-src/guidelines/php/flux.md +80 -0
  99. package/.agent-src/guidelines/php/general.md +191 -0
  100. package/.agent-src/guidelines/php/git.md +96 -0
  101. package/.agent-src/guidelines/php/jobs.md +111 -0
  102. package/.agent-src/guidelines/php/livewire.md +71 -0
  103. package/.agent-src/guidelines/php/logging.md +79 -0
  104. package/.agent-src/guidelines/php/naming.md +89 -0
  105. package/.agent-src/guidelines/php/patterns/dependency-injection.md +57 -0
  106. package/.agent-src/guidelines/php/patterns/dtos.md +199 -0
  107. package/.agent-src/guidelines/php/patterns/events.md +67 -0
  108. package/.agent-src/guidelines/php/patterns/factory.md +53 -0
  109. package/.agent-src/guidelines/php/patterns/pipelines.md +66 -0
  110. package/.agent-src/guidelines/php/patterns/policies.md +66 -0
  111. package/.agent-src/guidelines/php/patterns/repositories.md +122 -0
  112. package/.agent-src/guidelines/php/patterns/service-layer.md +64 -0
  113. package/.agent-src/guidelines/php/patterns/strategy.md +69 -0
  114. package/.agent-src/guidelines/php/patterns.md +28 -0
  115. package/.agent-src/guidelines/php/performance.md +92 -0
  116. package/.agent-src/guidelines/php/resources.md +100 -0
  117. package/.agent-src/guidelines/php/security.md +110 -0
  118. package/.agent-src/guidelines/php/sql.md +97 -0
  119. package/.agent-src/guidelines/php/validations.md +119 -0
  120. package/.agent-src/guidelines/php/websocket.md +100 -0
  121. package/.agent-src/personas/README.md +104 -0
  122. package/.agent-src/personas/ai-agent.md +77 -0
  123. package/.agent-src/personas/critical-challenger.md +73 -0
  124. package/.agent-src/personas/developer.md +73 -0
  125. package/.agent-src/personas/product-owner.md +78 -0
  126. package/.agent-src/personas/qa.md +67 -0
  127. package/.agent-src/personas/senior-engineer.md +77 -0
  128. package/.agent-src/personas/stakeholder.md +78 -0
  129. package/.agent-src/rules/agent-docs.md +61 -0
  130. package/.agent-src/rules/analysis-skill-routing.md +48 -0
  131. package/.agent-src/rules/architecture.md +62 -0
  132. package/.agent-src/rules/artifact-drafting-protocol.md +73 -0
  133. package/.agent-src/rules/ask-when-uncertain.md +52 -0
  134. package/.agent-src/rules/augment-portability.md +38 -0
  135. package/.agent-src/rules/augment-source-of-truth.md +128 -0
  136. package/.agent-src/rules/capture-learnings.md +89 -0
  137. package/.agent-src/rules/cli-output-handling.md +94 -0
  138. package/.agent-src/rules/commit-conventions.md +64 -0
  139. package/.agent-src/rules/context-hygiene.md +90 -0
  140. package/.agent-src/rules/docker-commands.md +55 -0
  141. package/.agent-src/rules/docs-sync.md +79 -0
  142. package/.agent-src/rules/downstream-changes.md +70 -0
  143. package/.agent-src/rules/e2e-testing.md +53 -0
  144. package/.agent-src/rules/guidelines.md +90 -0
  145. package/.agent-src/rules/improve-before-implement.md +94 -0
  146. package/.agent-src/rules/language-and-tone.md +104 -0
  147. package/.agent-src/rules/laravel-translations.md +48 -0
  148. package/.agent-src/rules/markdown-safe-codeblocks.md +18 -0
  149. package/.agent-src/rules/minimal-safe-diff.md +87 -0
  150. package/.agent-src/rules/missing-tool-handling.md +62 -0
  151. package/.agent-src/rules/model-recommendation.md +70 -0
  152. package/.agent-src/rules/package-ci-checks.md +80 -0
  153. package/.agent-src/rules/php-coding.md +63 -0
  154. package/.agent-src/rules/preservation-guard.md +29 -0
  155. package/.agent-src/rules/review-routing-awareness.md +125 -0
  156. package/.agent-src/rules/reviewer-awareness.md +92 -0
  157. package/.agent-src/rules/roadmap-progress-sync.md +56 -0
  158. package/.agent-src/rules/role-mode-adherence.md +54 -0
  159. package/.agent-src/rules/rule-type-governance.md +46 -0
  160. package/.agent-src/rules/runtime-safety.md +42 -0
  161. package/.agent-src/rules/scope-control.md +40 -0
  162. package/.agent-src/rules/security-sensitive-stop.md +77 -0
  163. package/.agent-src/rules/size-enforcement.md +29 -0
  164. package/.agent-src/rules/skill-improvement-trigger.md +58 -0
  165. package/.agent-src/rules/skill-quality.md +110 -0
  166. package/.agent-src/rules/slash-commands.md +30 -0
  167. package/.agent-src/rules/think-before-action.md +91 -0
  168. package/.agent-src/rules/token-efficiency.md +99 -0
  169. package/.agent-src/rules/tool-safety.md +36 -0
  170. package/.agent-src/rules/upstream-proposal.md +76 -0
  171. package/.agent-src/rules/user-interaction.md +79 -0
  172. package/.agent-src/rules/verify-before-complete.md +120 -0
  173. package/.agent-src/scripts/scan-seeder-violations.php +145 -0
  174. package/.agent-src/scripts/update_roadmap_progress.py +244 -0
  175. package/.agent-src/skills/adversarial-review/SKILL.md +149 -0
  176. package/.agent-src/skills/agent-docs-writing/SKILL.md +234 -0
  177. package/.agent-src/skills/analysis-autonomous-mode/SKILL.md +197 -0
  178. package/.agent-src/skills/analysis-skill-router/SKILL.md +134 -0
  179. package/.agent-src/skills/api-design/SKILL.md +104 -0
  180. package/.agent-src/skills/api-endpoint/SKILL.md +185 -0
  181. package/.agent-src/skills/api-testing/SKILL.md +206 -0
  182. package/.agent-src/skills/artisan-commands/SKILL.md +78 -0
  183. package/.agent-src/skills/authz-review/SKILL.md +171 -0
  184. package/.agent-src/skills/aws-infrastructure/SKILL.md +152 -0
  185. package/.agent-src/skills/blade-ui/SKILL.md +75 -0
  186. package/.agent-src/skills/blast-radius-analyzer/SKILL.md +185 -0
  187. package/.agent-src/skills/bug-analyzer/SKILL.md +256 -0
  188. package/.agent-src/skills/check-refs/SKILL.md +72 -0
  189. package/.agent-src/skills/code-refactoring/SKILL.md +200 -0
  190. package/.agent-src/skills/code-review/SKILL.md +214 -0
  191. package/.agent-src/skills/command-routing/SKILL.md +96 -0
  192. package/.agent-src/skills/command-writing/SKILL.md +143 -0
  193. package/.agent-src/skills/composer-packages/SKILL.md +172 -0
  194. package/.agent-src/skills/context-authoring/SKILL.md +157 -0
  195. package/.agent-src/skills/context-document/SKILL.md +153 -0
  196. package/.agent-src/skills/conventional-commits-writing/SKILL.md +70 -0
  197. package/.agent-src/skills/copilot-agents-optimization/SKILL.md +220 -0
  198. package/.agent-src/skills/copilot-config/SKILL.md +203 -0
  199. package/.agent-src/skills/dashboard-design/SKILL.md +116 -0
  200. package/.agent-src/skills/data-flow-mapper/SKILL.md +160 -0
  201. package/.agent-src/skills/database/SKILL.md +91 -0
  202. package/.agent-src/skills/dependency-upgrade/SKILL.md +204 -0
  203. package/.agent-src/skills/description-assist/SKILL.md +169 -0
  204. package/.agent-src/skills/design-review/SKILL.md +228 -0
  205. package/.agent-src/skills/devcontainer/SKILL.md +121 -0
  206. package/.agent-src/skills/developer-like-execution/SKILL.md +276 -0
  207. package/.agent-src/skills/docker/SKILL.md +245 -0
  208. package/.agent-src/skills/dto-creator/SKILL.md +117 -0
  209. package/.agent-src/skills/eloquent/SKILL.md +92 -0
  210. package/.agent-src/skills/eloquent/evals/last-run.json +99 -0
  211. package/.agent-src/skills/eloquent/evals/triggers.json +16 -0
  212. package/.agent-src/skills/estimate-ticket/SKILL.md +186 -0
  213. package/.agent-src/skills/estimate-ticket/evals/output-schema.yml +20 -0
  214. package/.agent-src/skills/estimate-ticket/evals/triggers.json +18 -0
  215. package/.agent-src/skills/fe-design/SKILL.md +223 -0
  216. package/.agent-src/skills/feature-planning/SKILL.md +226 -0
  217. package/.agent-src/skills/file-editor/SKILL.md +129 -0
  218. package/.agent-src/skills/finishing-a-development-branch/SKILL.md +200 -0
  219. package/.agent-src/skills/flux/SKILL.md +64 -0
  220. package/.agent-src/skills/git-workflow/SKILL.md +102 -0
  221. package/.agent-src/skills/github-ci/SKILL.md +122 -0
  222. package/.agent-src/skills/grafana/SKILL.md +168 -0
  223. package/.agent-src/skills/guideline-writing/SKILL.md +147 -0
  224. package/.agent-src/skills/jira-integration/SKILL.md +182 -0
  225. package/.agent-src/skills/jobs-events/SKILL.md +87 -0
  226. package/.agent-src/skills/judge-bug-hunter/SKILL.md +157 -0
  227. package/.agent-src/skills/judge-code-quality/SKILL.md +158 -0
  228. package/.agent-src/skills/judge-security-auditor/SKILL.md +167 -0
  229. package/.agent-src/skills/judge-test-coverage/SKILL.md +154 -0
  230. package/.agent-src/skills/laravel/SKILL.md +195 -0
  231. package/.agent-src/skills/laravel-horizon/SKILL.md +169 -0
  232. package/.agent-src/skills/laravel-mail/SKILL.md +193 -0
  233. package/.agent-src/skills/laravel-middleware/SKILL.md +185 -0
  234. package/.agent-src/skills/laravel-notifications/SKILL.md +168 -0
  235. package/.agent-src/skills/laravel-pennant/SKILL.md +188 -0
  236. package/.agent-src/skills/laravel-pulse/SKILL.md +160 -0
  237. package/.agent-src/skills/laravel-reverb/SKILL.md +205 -0
  238. package/.agent-src/skills/laravel-scheduling/SKILL.md +167 -0
  239. package/.agent-src/skills/laravel-validation/SKILL.md +71 -0
  240. package/.agent-src/skills/learning-to-rule-or-skill/SKILL.md +249 -0
  241. package/.agent-src/skills/lint-skills/SKILL.md +72 -0
  242. package/.agent-src/skills/livewire/SKILL.md +79 -0
  243. package/.agent-src/skills/logging-monitoring/SKILL.md +100 -0
  244. package/.agent-src/skills/mcp/SKILL.md +193 -0
  245. package/.agent-src/skills/merge-conflicts/SKILL.md +158 -0
  246. package/.agent-src/skills/migration-creator/SKILL.md +160 -0
  247. package/.agent-src/skills/module-management/SKILL.md +154 -0
  248. package/.agent-src/skills/multi-tenancy/SKILL.md +129 -0
  249. package/.agent-src/skills/openapi/SKILL.md +154 -0
  250. package/.agent-src/skills/override-management/SKILL.md +186 -0
  251. package/.agent-src/skills/performance/SKILL.md +69 -0
  252. package/.agent-src/skills/performance-analysis/SKILL.md +118 -0
  253. package/.agent-src/skills/pest-testing/SKILL.md +321 -0
  254. package/.agent-src/skills/php-coder/SKILL.md +78 -0
  255. package/.agent-src/skills/php-coder/evals/triggers.json +16 -0
  256. package/.agent-src/skills/php-debugging/SKILL.md +184 -0
  257. package/.agent-src/skills/php-service/SKILL.md +96 -0
  258. package/.agent-src/skills/playwright-testing/SKILL.md +244 -0
  259. package/.agent-src/skills/project-analysis-core/SKILL.md +138 -0
  260. package/.agent-src/skills/project-analysis-hypothesis-driven/SKILL.md +130 -0
  261. package/.agent-src/skills/project-analysis-laravel/SKILL.md +119 -0
  262. package/.agent-src/skills/project-analysis-nextjs/SKILL.md +123 -0
  263. package/.agent-src/skills/project-analysis-node-express/SKILL.md +111 -0
  264. package/.agent-src/skills/project-analysis-react/SKILL.md +119 -0
  265. package/.agent-src/skills/project-analysis-symfony/SKILL.md +111 -0
  266. package/.agent-src/skills/project-analysis-zend-laminas/SKILL.md +108 -0
  267. package/.agent-src/skills/project-analyzer/SKILL.md +341 -0
  268. package/.agent-src/skills/project-docs/SKILL.md +137 -0
  269. package/.agent-src/skills/quality-tools/SKILL.md +411 -0
  270. package/.agent-src/skills/readme-reviewer/SKILL.md +187 -0
  271. package/.agent-src/skills/readme-writing/SKILL.md +142 -0
  272. package/.agent-src/skills/readme-writing-package/SKILL.md +185 -0
  273. package/.agent-src/skills/receiving-code-review/SKILL.md +190 -0
  274. package/.agent-src/skills/refine-ticket/SKILL.md +310 -0
  275. package/.agent-src/skills/refine-ticket/detection-map.yml +124 -0
  276. package/.agent-src/skills/refine-ticket/evals/output-schema.yml +16 -0
  277. package/.agent-src/skills/refine-ticket/evals/triggers.json +16 -0
  278. package/.agent-src/skills/requesting-code-review/SKILL.md +199 -0
  279. package/.agent-src/skills/review-routing/SKILL.md +195 -0
  280. package/.agent-src/skills/roadmap-management/SKILL.md +303 -0
  281. package/.agent-src/skills/rtk-output-filtering/SKILL.md +184 -0
  282. package/.agent-src/skills/rule-writing/SKILL.md +148 -0
  283. package/.agent-src/skills/security/SKILL.md +79 -0
  284. package/.agent-src/skills/security-audit/SKILL.md +123 -0
  285. package/.agent-src/skills/sentry-integration/SKILL.md +170 -0
  286. package/.agent-src/skills/sequential-thinking/SKILL.md +158 -0
  287. package/.agent-src/skills/skill-improvement-pipeline/SKILL.md +155 -0
  288. package/.agent-src/skills/skill-management/SKILL.md +121 -0
  289. package/.agent-src/skills/skill-reviewer/SKILL.md +218 -0
  290. package/.agent-src/skills/skill-writing/SKILL.md +291 -0
  291. package/.agent-src/skills/skill-writing/evals/triggers.json +16 -0
  292. package/.agent-src/skills/sql-writing/SKILL.md +74 -0
  293. package/.agent-src/skills/subagent-orchestration/SKILL.md +190 -0
  294. package/.agent-src/skills/systematic-debugging/SKILL.md +244 -0
  295. package/.agent-src/skills/technical-specification/SKILL.md +185 -0
  296. package/.agent-src/skills/terraform/SKILL.md +137 -0
  297. package/.agent-src/skills/terragrunt/SKILL.md +217 -0
  298. package/.agent-src/skills/test-driven-development/SKILL.md +252 -0
  299. package/.agent-src/skills/test-performance/SKILL.md +172 -0
  300. package/.agent-src/skills/threat-modeling/SKILL.md +189 -0
  301. package/.agent-src/skills/traefik/SKILL.md +319 -0
  302. package/.agent-src/skills/universal-project-analysis/SKILL.md +179 -0
  303. package/.agent-src/skills/upstream-contribute/SKILL.md +255 -0
  304. package/.agent-src/skills/using-git-worktrees/SKILL.md +148 -0
  305. package/.agent-src/skills/validate-feature-fit/SKILL.md +113 -0
  306. package/.agent-src/skills/verify-before-complete/SKILL.md +188 -0
  307. package/.agent-src/skills/websocket/SKILL.md +75 -0
  308. package/.agent-src/templates/AGENTS.md +146 -0
  309. package/.agent-src/templates/agent-settings.md +256 -0
  310. package/.agent-src/templates/agents/.gitattributes.fragment +16 -0
  311. package/.agent-src/templates/agents/agent-project-settings.example.yml +138 -0
  312. package/.agent-src/templates/agents/memory/architecture-decisions.example.yml +95 -0
  313. package/.agent-src/templates/agents/memory/domain-invariants.example.yml +80 -0
  314. package/.agent-src/templates/agents/memory/historical-patterns.example.yml +82 -0
  315. package/.agent-src/templates/agents/memory/incident-learnings.example.yml +113 -0
  316. package/.agent-src/templates/agents/memory/ownership.example.yml +75 -0
  317. package/.agent-src/templates/agents/memory/product-rules.example.yml +87 -0
  318. package/.agent-src/templates/agents/proposal.example.md +143 -0
  319. package/.agent-src/templates/command.md +84 -0
  320. package/.agent-src/templates/contexts/auth-model.md +59 -0
  321. package/.agent-src/templates/contexts/data-sensitivity.md +60 -0
  322. package/.agent-src/templates/contexts/deployment-order.md +72 -0
  323. package/.agent-src/templates/contexts/observability.md +64 -0
  324. package/.agent-src/templates/contexts/tenant-boundaries.md +68 -0
  325. package/.agent-src/templates/contexts.md +116 -0
  326. package/.agent-src/templates/copilot-instructions.md +115 -0
  327. package/.agent-src/templates/features.md +125 -0
  328. package/.agent-src/templates/github-workflows/memory-hygiene.yml +133 -0
  329. package/.agent-src/templates/github-workflows/pr-risk-review.yml +123 -0
  330. package/.agent-src/templates/github-workflows/proposal-drift.yml +118 -0
  331. package/.agent-src/templates/overrides/command.md +24 -0
  332. package/.agent-src/templates/overrides/guideline.md +21 -0
  333. package/.agent-src/templates/overrides/rule.md +19 -0
  334. package/.agent-src/templates/overrides/skill.md +24 -0
  335. package/.agent-src/templates/overrides/template.md +21 -0
  336. package/.agent-src/templates/persona.md +99 -0
  337. package/.agent-src/templates/roadmaps.md +109 -0
  338. package/.agent-src/templates/scripts/README.md +195 -0
  339. package/.agent-src/templates/scripts/check_memory.py +283 -0
  340. package/.agent-src/templates/scripts/check_memory_proposal.py +180 -0
  341. package/.agent-src/templates/scripts/historical-bug-patterns.example.yml +84 -0
  342. package/.agent-src/templates/scripts/implement_ticket/__init__.py +57 -0
  343. package/.agent-src/templates/scripts/implement_ticket/__main__.py +9 -0
  344. package/.agent-src/templates/scripts/implement_ticket/cli.py +171 -0
  345. package/.agent-src/templates/scripts/implement_ticket/delivery_state.py +130 -0
  346. package/.agent-src/templates/scripts/implement_ticket/dispatcher.py +134 -0
  347. package/.agent-src/templates/scripts/implement_ticket/persona_policy.py +85 -0
  348. package/.agent-src/templates/scripts/implement_ticket/steps/__init__.py +49 -0
  349. package/.agent-src/templates/scripts/implement_ticket/steps/analyze.py +98 -0
  350. package/.agent-src/templates/scripts/implement_ticket/steps/implement.py +145 -0
  351. package/.agent-src/templates/scripts/implement_ticket/steps/memory.py +136 -0
  352. package/.agent-src/templates/scripts/implement_ticket/steps/plan.py +175 -0
  353. package/.agent-src/templates/scripts/implement_ticket/steps/refine.py +140 -0
  354. package/.agent-src/templates/scripts/implement_ticket/steps/report.py +195 -0
  355. package/.agent-src/templates/scripts/implement_ticket/steps/test.py +180 -0
  356. package/.agent-src/templates/scripts/implement_ticket/steps/verify.py +170 -0
  357. package/.agent-src/templates/scripts/memory_hash.py +75 -0
  358. package/.agent-src/templates/scripts/memory_lookup.py +216 -0
  359. package/.agent-src/templates/scripts/memory_report.py +184 -0
  360. package/.agent-src/templates/scripts/memory_signal.py +167 -0
  361. package/.agent-src/templates/scripts/memory_status.py +156 -0
  362. package/.agent-src/templates/scripts/ownership-map.example.yml +87 -0
  363. package/.agent-src/templates/scripts/pr-risk-config.example.yml +76 -0
  364. package/.agent-src/templates/scripts/pr_review_routing.py +340 -0
  365. package/.agent-src/templates/scripts/pr_risk_review.py +211 -0
  366. package/.agent-src/templates/skill.md +136 -0
  367. package/.augment-plugin/marketplace.json +32 -0
  368. package/.augment-plugin/plugin.json +21 -0
  369. package/.claude-plugin/marketplace.json +119 -0
  370. package/AGENTS.md +121 -0
  371. package/CHANGELOG.md +279 -0
  372. package/CONTRIBUTING.md +176 -0
  373. package/LICENSE +21 -0
  374. package/README.md +357 -0
  375. package/bin/install.php +38 -0
  376. package/composer.json +29 -0
  377. package/config/agent-settings.template.yml +96 -0
  378. package/config/profiles/balanced.ini +10 -0
  379. package/config/profiles/full.ini +10 -0
  380. package/config/profiles/minimal.ini +10 -0
  381. package/docs/architecture.md +144 -0
  382. package/docs/customization.md +88 -0
  383. package/docs/development.md +171 -0
  384. package/docs/getting-started.md +130 -0
  385. package/docs/github-topics.md +84 -0
  386. package/docs/installation.md +376 -0
  387. package/docs/mcp.md +133 -0
  388. package/docs/quality.md +98 -0
  389. package/docs/skills-catalog.md +136 -0
  390. package/docs/troubleshooting.md +167 -0
  391. package/llms.txt +130 -0
  392. package/package.json +31 -0
  393. package/scripts/audit_skill_descriptions.py +168 -0
  394. package/scripts/check_compression.py +221 -0
  395. package/scripts/check_memory.py +341 -0
  396. package/scripts/check_memory_proposal.py +180 -0
  397. package/scripts/check_portability.py +320 -0
  398. package/scripts/check_proposal.py +269 -0
  399. package/scripts/check_references.py +400 -0
  400. package/scripts/ci_summary.py +131 -0
  401. package/scripts/compress.py +671 -0
  402. package/scripts/compress.sh +18 -0
  403. package/scripts/first-run.sh +109 -0
  404. package/scripts/generate_catalog.py +116 -0
  405. package/scripts/install +151 -0
  406. package/scripts/install-hooks.sh +29 -0
  407. package/scripts/install.py +487 -0
  408. package/scripts/install.sh +637 -0
  409. package/scripts/install_anthropic_key.sh +101 -0
  410. package/scripts/inventory_frontmatter.py +164 -0
  411. package/scripts/lint_marketplace.py +142 -0
  412. package/scripts/lint_regression.py +232 -0
  413. package/scripts/mcp_render.py +159 -0
  414. package/scripts/measure_patterns.py +376 -0
  415. package/scripts/memory_hash.py +75 -0
  416. package/scripts/memory_lookup.py +441 -0
  417. package/scripts/memory_report.py +336 -0
  418. package/scripts/memory_signal.py +210 -0
  419. package/scripts/memory_status.py +195 -0
  420. package/scripts/postinstall.sh +60 -0
  421. package/scripts/readme_linter.py +580 -0
  422. package/scripts/refine_ticket_detect.py +623 -0
  423. package/scripts/requirements-evals.txt +7 -0
  424. package/scripts/runtime_dispatcher.py +265 -0
  425. package/scripts/runtime_handler.py +148 -0
  426. package/scripts/runtime_registry.py +166 -0
  427. package/scripts/schemas/command.schema.json +32 -0
  428. package/scripts/schemas/persona.schema.json +42 -0
  429. package/scripts/schemas/rule.schema.json +28 -0
  430. package/scripts/schemas/skill.schema.json +73 -0
  431. package/scripts/setup.sh +230 -0
  432. package/scripts/setup_eval_venv.sh +58 -0
  433. package/scripts/skill_linter.py +2175 -0
  434. package/scripts/skill_trigger_eval.py +651 -0
  435. package/scripts/tool_registry.py +146 -0
  436. package/scripts/tools/__init__.py +1 -0
  437. package/scripts/tools/adapter_errors.py +63 -0
  438. package/scripts/tools/base_adapter.py +91 -0
  439. package/scripts/tools/github_adapter.py +128 -0
  440. package/scripts/tools/jira_adapter.py +115 -0
  441. package/scripts/update_counts.py +147 -0
  442. package/scripts/validate_frontmatter.py +424 -0
  443. package/templates/consumer-settings/README.md +46 -0
  444. package/templates/consumer-settings/augment-settings.json +12 -0
  445. package/templates/consumer-settings/claude-settings.json +9 -0
  446. package/templates/consumer-settings/copilot-settings.json +14 -0
@@ -0,0 +1,2175 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Minimal skill/rule linter for agent-config repositories.
4
+
5
+ MVP checks:
6
+ - Detect skill vs rule
7
+ - Required skill sections
8
+ - Basic rule validation
9
+ - Vague validation detection
10
+ - Output format presence
11
+ - Gotchas / Do NOT presence
12
+ - Single file, --all, --changed
13
+ - Text and JSON output
14
+
15
+ Exit codes:
16
+ 0 = pass
17
+ 1 = warnings only
18
+ 2 = errors
19
+ 3 = internal error
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import re
27
+ import subprocess
28
+ import sys
29
+ from dataclasses import dataclass, asdict
30
+ from pathlib import Path
31
+ from typing import Iterable, List, Literal, Optional
32
+
33
+ # Sibling module — stdlib-only frontmatter schema validator.
34
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
35
+ from validate_frontmatter import ( # noqa: E402
36
+ parse_frontmatter as parse_frontmatter_for_schema,
37
+ load_schema,
38
+ validate as validate_against_schema,
39
+ )
40
+
41
+ Severity = Literal["error", "warning", "info"]
42
+ ArtifactType = Literal["skill", "rule", "command", "guideline", "persona", "unknown"]
43
+
44
+ REQUIRED_PERSONA_SECTIONS = [
45
+ "Focus",
46
+ "Mindset",
47
+ "Unique Questions",
48
+ "Output Expectations",
49
+ "Anti-Patterns",
50
+ ]
51
+ VALID_PERSONA_TIERS = {"core", "specialist"}
52
+ PERSONA_LINE_BUDGETS = {"core": 120, "specialist": 80}
53
+
54
+
55
+ REQUIRED_SKILL_SECTIONS = [
56
+ "When to use",
57
+ "Gotcha",
58
+ "Procedure",
59
+ "Output format",
60
+ "Do NOT",
61
+ ]
62
+
63
+ # Aliases: linter accepts any of these as matching the required section
64
+ SECTION_ALIASES = {
65
+ "Gotcha": {"Gotcha", "Gotchas"},
66
+ "Procedure": set(), # prefix-matched separately
67
+ "Do NOT": {"Do NOT", "Do not", "Anti-patterns"},
68
+ "Output format": {"Output format", "Output"},
69
+ }
70
+
71
+ RECOMMENDED_SKILL_SECTIONS: list[str] = []
72
+
73
+ RULE_BAD_SIGNS = [
74
+ "## Procedure",
75
+ "## Output format",
76
+ "## Gotchas",
77
+ ]
78
+
79
+ VAGUE_VALIDATION_PATTERNS = [
80
+ r"\bcheck if it works\b",
81
+ r"\bverify it works\b",
82
+ r"\btest manually\b",
83
+ r"\bcheck manually\b",
84
+ r"\bmake sure it works\b",
85
+ ]
86
+
87
+ TRIGGER_WARNING_PATTERNS = [
88
+ r"\bgeneral helper\b",
89
+ r"\blaravel skill\b",
90
+ r"\bgeneral coding\b",
91
+ r"\beverything about\b",
92
+ ]
93
+
94
+ ORDERED_STEP_PATTERN = re.compile(r"^(?:\s*|\#{1,4}\s*)(\d+)\.\s+", re.MULTILINE)
95
+ SECTION_PATTERN = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE)
96
+ FRONTMATTER_PATTERN = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL)
97
+ DESCRIPTION_PATTERN = re.compile(r'^description:\s*"?(.*?)"?\s*$', re.MULTILINE)
98
+ TYPE_PATTERN = re.compile(r'^type:\s*"?(always|auto)"?\s*$', re.MULTILINE)
99
+ SOURCE_PATTERN = re.compile(r'^source:\s*"?(package|project)"?\s*$', re.MULTILINE)
100
+ STATUS_PATTERN = re.compile(r'^status:\s*"?(active|deprecated|superseded)"?\s*$', re.MULTILINE)
101
+ REPLACED_BY_PATTERN = re.compile(r'^replaced_by:\s*"?([\w-]+)"?\s*$', re.MULTILINE)
102
+ H1_PATTERN = re.compile(r"^# .+", re.MULTILINE)
103
+ DOUBLE_BLANK_PATTERN = re.compile(r"\n{3,}")
104
+
105
+ VALID_RULE_TYPES = {"always", "auto"}
106
+ VALID_RULE_SOURCES = {"package", "project"}
107
+ VALID_STATUSES = {"active", "deprecated", "superseded"}
108
+
109
+ # --- Runtime execution metadata constants ---
110
+ VALID_EXECUTION_TYPES = {"manual", "assisted", "automated"}
111
+ VALID_EXECUTION_HANDLERS = {"none", "shell", "php", "node", "internal"}
112
+ VALID_EXECUTION_SAFETY_MODES = {"strict"}
113
+ VALID_EXECUTION_FIELDS = {"type", "handler", "timeout_seconds", "safety_mode", "allowed_tools", "command"}
114
+
115
+
116
+ @dataclass
117
+ class Issue:
118
+ severity: Severity
119
+ code: str
120
+ message: str
121
+
122
+
123
+ @dataclass
124
+ class LintResult:
125
+ file: str
126
+ artifact_type: ArtifactType
127
+ status: Literal["pass", "pass_with_warnings", "fail"]
128
+ issues: List[Issue]
129
+ suggestions: List[str]
130
+
131
+
132
+ def read_text(path: Path) -> str:
133
+ return path.read_text(encoding="utf-8")
134
+
135
+
136
+ # --- Role-contract anchor cache (see road-to-role-modes Phase 1) ---
137
+ # Populated lazily so the linter stays fast when the guideline is absent.
138
+ _ROLE_CONTRACT_CANDIDATES = (
139
+ Path(".agent-src.uncompressed/guidelines/agent-infra/role-contracts.md"),
140
+ Path(".agent-src/guidelines/agent-infra/role-contracts.md"),
141
+ Path(".augment/guidelines/agent-infra/role-contracts.md"),
142
+ )
143
+ _ROLE_CONTRACT_SLUGS_CACHE: Optional[set[str]] = None
144
+
145
+
146
+ def _load_role_contract_slugs() -> set[str]:
147
+ """Return the set of H3 mode slugs defined in role-contracts.md.
148
+
149
+ Empty set if the guideline cannot be found — callers MUST treat an
150
+ empty cache as "no data" and skip the check rather than flagging
151
+ every reference as broken.
152
+ """
153
+ global _ROLE_CONTRACT_SLUGS_CACHE
154
+ if _ROLE_CONTRACT_SLUGS_CACHE is not None:
155
+ return _ROLE_CONTRACT_SLUGS_CACHE
156
+ slugs: set[str] = set()
157
+ for candidate in _ROLE_CONTRACT_CANDIDATES:
158
+ if not candidate.exists():
159
+ continue
160
+ try:
161
+ text = candidate.read_text(encoding="utf-8")
162
+ except OSError:
163
+ continue
164
+ in_skeletons = False
165
+ for line in text.splitlines():
166
+ if line.startswith("## "):
167
+ in_skeletons = line.strip().lower().startswith(
168
+ "## contract skeletons"
169
+ )
170
+ continue
171
+ if in_skeletons and line.startswith("### "):
172
+ name = line[4:].strip().lower()
173
+ slugs.add(re.sub(r"[^a-z0-9]+", "-", name).strip("-"))
174
+ if slugs:
175
+ break
176
+ _ROLE_CONTRACT_SLUGS_CACHE = slugs
177
+ return slugs
178
+
179
+
180
+ _ROLE_CONTRACT_REF_PATTERN = re.compile(
181
+ r"role-contracts\.md#([a-z0-9][a-z0-9-]*)", re.IGNORECASE
182
+ )
183
+
184
+
185
+ def lint_role_contract_refs(text: str) -> List[Issue]:
186
+ """Warn if a file references `role-contracts.md#<slug>` for a mode
187
+ that does not exist as an H3 heading in the guideline. No-op when
188
+ the guideline is missing or declares no modes (bootstrap safety).
189
+ """
190
+ slugs = _load_role_contract_slugs()
191
+ if not slugs:
192
+ return []
193
+ issues: List[Issue] = []
194
+ seen: set[str] = set()
195
+ for match in _ROLE_CONTRACT_REF_PATTERN.finditer(text):
196
+ slug = match.group(1).lower()
197
+ if slug in seen:
198
+ continue
199
+ seen.add(slug)
200
+ if slug not in slugs:
201
+ issues.append(Issue(
202
+ "warning", "unknown_role_contract",
203
+ f"References role-contracts.md#{slug} but no such "
204
+ f"mode is defined in the guideline (known: "
205
+ f"{', '.join(sorted(slugs))})",
206
+ ))
207
+ return issues
208
+
209
+
210
+ def extract_sections(text: str) -> set[str]:
211
+ return {match.group(1).strip() for match in SECTION_PATTERN.finditer(text)}
212
+
213
+
214
+ def extract_description(text: str) -> Optional[str]:
215
+ frontmatter = FRONTMATTER_PATTERN.search(text)
216
+ if not frontmatter:
217
+ return None
218
+ description = DESCRIPTION_PATTERN.search(frontmatter.group(1))
219
+ return description.group(1).strip() if description else None
220
+
221
+
222
+ NAME_PATTERN = re.compile(r'^name:\s*"?(.*?)"?\s*$', re.MULTILINE)
223
+ DISABLE_MODEL_PATTERN = re.compile(r'^disable-model-invocation:\s*"?(true|false)"?\s*$', re.MULTILINE)
224
+
225
+
226
+ def detect_artifact_type(path: Path, text: str) -> ArtifactType:
227
+ path_str = str(path).lower()
228
+ has_skill_heading = "## When to use" in text and "## Procedure" in text
229
+
230
+ # Skills take priority — /skills/commands/SKILL.md is a skill, not a command
231
+ if path.name.lower() == "skill.md" or "/skills/" in path_str:
232
+ return "skill"
233
+ # Commands are flat .md files in /commands/ directories (not SKILL.md)
234
+ if "/commands/" in path_str and path.name.lower() != "skill.md":
235
+ return "command"
236
+ if "/rules/" in path_str:
237
+ return "rule"
238
+ if "/guidelines/" in path_str:
239
+ return "guideline"
240
+ if "/personas/" in path_str:
241
+ return "persona"
242
+ if has_skill_heading:
243
+ return "skill"
244
+ return "unknown"
245
+
246
+
247
+ def classify_status(issues: List[Issue]) -> Literal["pass", "pass_with_warnings", "fail"]:
248
+ severities = {issue.severity for issue in issues}
249
+ if "error" in severities:
250
+ return "fail"
251
+ if "warning" in severities:
252
+ return "pass_with_warnings"
253
+ return "pass"
254
+
255
+
256
+
257
+ def extract_section_block(text: str, section_name: str) -> str:
258
+ pattern = re.compile(
259
+ rf"^##\s+{re.escape(section_name)}\s*$" r"(.*?)(?=^##\s+|\Z)",
260
+ re.MULTILINE | re.DOTALL,
261
+ )
262
+ match = pattern.search(text)
263
+ return match.group(1).strip() if match else ""
264
+
265
+
266
+ def parse_ordered_list_items(text: str) -> list[str]:
267
+ return [line.strip() for line in text.splitlines() if re.match(r"^\s*\d+\.\s+", line)]
268
+
269
+
270
+ def count_bullets(text: str) -> int:
271
+ return sum(1 for line in text.splitlines() if re.match(r"^\s*[*-]\s+", line))
272
+
273
+
274
+ def has_validation_step(procedure_block: str) -> bool:
275
+ lowered = procedure_block.lower()
276
+ if "validate" in lowered or "validation" in lowered:
277
+ return True
278
+ good_signals = [
279
+ "expected", "status code", "no errors", "appears in", "exact check", "concrete checks",
280
+ "verify", "confirm", "must pass", "must fail", "assert", "check that", "ensure",
281
+ "run test", "run phpstan", "run ecs", "run rector", "lint", "passes",
282
+ "exit code", "should return", "should contain", "must contain", "must return",
283
+ ]
284
+ return any(signal in lowered for signal in good_signals)
285
+
286
+
287
+ def has_inspect_step(procedure_block: str) -> bool:
288
+ lowered = procedure_block.lower()
289
+ inspect_signals = ["inspect", "check current", "review existing", "identify", "analyze"]
290
+ return any(signal in lowered for signal in inspect_signals)
291
+
292
+
293
+ def find_vague_validation(text: str) -> list[str]:
294
+ hits: list[str] = []
295
+ for pattern in VAGUE_VALIDATION_PATTERNS:
296
+ for match in re.finditer(pattern, text, re.IGNORECASE):
297
+ hits.append(match.group(0))
298
+ return hits
299
+
300
+
301
+ def is_probably_too_broad(text: str, description: Optional[str]) -> bool:
302
+ # Only check description and "When to use" for broad signals — not the entire text
303
+ haystacks: list[str] = []
304
+ if description:
305
+ haystacks.append(description.lower())
306
+ when_block = extract_section_block(text, "When to use")
307
+ if when_block:
308
+ haystacks.append(when_block.lower())
309
+ if not haystacks:
310
+ return False
311
+ combined = "\n".join(haystacks)
312
+ broad_signals = ["everything about", "general purpose", "general-purpose", "all markdown", "helper for everything"]
313
+ return any(signal in combined for signal in broad_signals)
314
+
315
+
316
+ def dedupe_preserve_order(items: Iterable[str]) -> list[str]:
317
+ seen: set[str] = set()
318
+ result: list[str] = []
319
+ for item in items:
320
+ if item not in seen:
321
+ seen.add(item)
322
+ result.append(item)
323
+ return result
324
+
325
+
326
+ def section_matches(required: str, sections: set[str]) -> bool:
327
+ """Check if a required section name matches any extracted section, supporting aliases and prefix matching."""
328
+ # Direct match
329
+ if required in sections:
330
+ return True
331
+ # Alias match (e.g. "Gotcha" matches "Gotchas")
332
+ aliases = SECTION_ALIASES.get(required, set())
333
+ if aliases & sections:
334
+ return True
335
+ # Prefix match (e.g. "Procedure" matches "Procedure: Create X")
336
+ for s in sections:
337
+ if s.startswith(required + ":") or s.startswith(required + " "):
338
+ return True
339
+ return False
340
+
341
+
342
+ def find_procedure_block(text: str) -> Optional[str]:
343
+ """Find the procedure section block, supporting prefix-named variants."""
344
+ block = extract_section_block(text, "Procedure")
345
+ if block:
346
+ return block
347
+ # Try prefix match: find "## Procedure: ..." or "## Procedure " headings
348
+ match = re.search(r"^##\s+Procedure[:\s]", text, re.MULTILINE)
349
+ if match:
350
+ # Extract from this heading to the next ## heading
351
+ start = match.end()
352
+ next_heading = re.search(r"^##\s+", text[start:], re.MULTILINE)
353
+ if next_heading:
354
+ return text[start:start + next_heading.start()].strip()
355
+ return text[start:].strip()
356
+ return None
357
+
358
+
359
+ def lint_skill(path: Path, text: str) -> LintResult:
360
+ issues: List[Issue] = []
361
+ suggestions: List[str] = []
362
+
363
+ sections = extract_sections(text)
364
+ description = extract_description(text)
365
+
366
+ for section in REQUIRED_SKILL_SECTIONS:
367
+ if not section_matches(section, sections):
368
+ issues.append(Issue("error", "missing_section", f"Missing required section: {section}"))
369
+
370
+ for section in RECOMMENDED_SKILL_SECTIONS:
371
+ if not section_matches(section, sections):
372
+ issues.append(Issue("warning", "missing_recommended_section", f"Missing recommended section: {section}"))
373
+
374
+ if description:
375
+ if len(description) > 200:
376
+ issues.append(Issue("warning", "description_too_long", "Description is longer than 200 characters"))
377
+ for pattern in TRIGGER_WARNING_PATTERNS:
378
+ if re.search(pattern, description, re.IGNORECASE):
379
+ issues.append(Issue("warning", "weak_trigger", f"Description looks too generic: {description}"))
380
+ break
381
+ else:
382
+ issues.append(Issue("warning", "missing_description", "Frontmatter description is missing or unreadable"))
383
+
384
+ # --- Bare-noun name check ---
385
+ skill_name = path.parent.name if path.name == "SKILL.md" else path.stem
386
+ if skill_name and "-" not in skill_name and len(skill_name) >= 3:
387
+ # Single word without qualifier — likely too generic
388
+ ALLOWED_BARE_NOUNS = {"database", "devcontainer", "docker", "eloquent", "flux", "grafana",
389
+ "laravel", "livewire", "mcp", "openapi", "performance", "security",
390
+ "terraform", "terragrunt", "traefik", "websocket"}
391
+ if skill_name.lower() not in ALLOWED_BARE_NOUNS:
392
+ issues.append(Issue("warning", "bare_noun_name",
393
+ f"Bare-noun skill name `{skill_name}` — consider adding a qualifier (e.g., `{skill_name}-management`)"))
394
+
395
+ # --- Status lifecycle check ---
396
+ frontmatter = extract_frontmatter(text)
397
+ if frontmatter:
398
+ status_match = STATUS_PATTERN.search(frontmatter)
399
+ if status_match:
400
+ status = status_match.group(1)
401
+ if status == "deprecated":
402
+ replaced_by = extract_frontmatter_field(frontmatter, REPLACED_BY_PATTERN)
403
+ msg = f"Skill is deprecated"
404
+ if replaced_by:
405
+ msg += f" (replaced by: {replaced_by})"
406
+ issues.append(Issue("warning", "deprecated_skill", msg))
407
+ elif status == "superseded":
408
+ replaced_by = extract_frontmatter_field(frontmatter, REPLACED_BY_PATTERN)
409
+ msg = f"Skill is superseded — should be removed"
410
+ if replaced_by:
411
+ msg += f" (replaced by: {replaced_by})"
412
+ issues.append(Issue("warning", "superseded_skill", msg))
413
+
414
+ # --- Execution metadata check ---
415
+ execution = parse_execution_block(frontmatter)
416
+ if execution is not None:
417
+ issues.extend(lint_execution_metadata(execution))
418
+
419
+ procedure_block = find_procedure_block(text)
420
+ if procedure_block is not None:
421
+ if not procedure_block:
422
+ issues.append(Issue("error", "empty_procedure", "Procedure section is empty"))
423
+ else:
424
+ # Check for ordered steps OR sub-headings as structural indicators
425
+ has_ordered = ORDERED_STEP_PATTERN.search(procedure_block)
426
+ has_subheadings = bool(re.search(r"^###\s+", procedure_block, re.MULTILINE))
427
+ if not has_ordered and not has_subheadings:
428
+ issues.append(Issue("error", "unordered_procedure", "Procedure has no ordered steps or sub-headings"))
429
+ meaningful_steps = len(ORDERED_STEP_PATTERN.findall(procedure_block))
430
+ if meaningful_steps < 3:
431
+ issues.append(Issue("warning", "short_procedure", "Procedure has fewer than 3 ordered steps"))
432
+ # Check validation in procedure block OR in the full skill text
433
+ # (some skills have ### Validate under a sibling ## section)
434
+ if not has_validation_step(procedure_block) and not has_validation_step(text):
435
+ issues.append(Issue("error", "missing_validation", "Skill lacks a concrete validation step"))
436
+ vague_hits = find_vague_validation(procedure_block)
437
+ for hit in vague_hits:
438
+ issues.append(Issue("error", "vague_validation", f"Vague validation detected: {hit}"))
439
+ if not has_inspect_step(procedure_block):
440
+ issues.append(Issue("warning", "missing_inspect_step", "Procedure has no explicit inspect/check step"))
441
+
442
+ if "## Output format" in text:
443
+ output_block = extract_section_block(text, "Output format")
444
+ if not output_block or len(parse_ordered_list_items(output_block)) < 2:
445
+ issues.append(Issue("warning", "weak_output_format", "Output format should contain at least 2 ordered requirements"))
446
+ suggestions.append("Add 2-4 ordered output requirements")
447
+ else:
448
+ suggestions.append("Add an Output format section with ordered response constraints")
449
+
450
+ # Check Gotcha/Gotchas section (alias support)
451
+ gotcha_block = extract_section_block(text, "Gotchas") or extract_section_block(text, "Gotcha")
452
+ if gotcha_block:
453
+ if count_bullets(gotcha_block) < 1:
454
+ issues.append(Issue("warning", "weak_gotchas", "Gotchas should contain at least one realistic failure mode"))
455
+ else:
456
+ suggestions.append("Add at least one realistic failure pattern to Gotchas")
457
+
458
+ if "## Do NOT" in text:
459
+ do_not_block = extract_section_block(text, "Do NOT")
460
+ if count_bullets(do_not_block) < 1:
461
+ issues.append(Issue("warning", "weak_do_not", "Do NOT should contain at least one enforceable constraint"))
462
+ else:
463
+ suggestions.append("Add at least one enforceable Do NOT constraint")
464
+
465
+ if is_probably_too_broad(text, description):
466
+ issues.append(Issue("warning", "broad_scope", "Skill scope appears broad and may need splitting"))
467
+ suggestions.append("Narrow the trigger or split unrelated workflows")
468
+
469
+ # --- Developer judgment check for assisted skills ---
470
+ fm = extract_frontmatter(text)
471
+ exec_block = parse_execution_block(fm) if fm else None
472
+ exec_type = exec_block.get("type", "") if exec_block else ""
473
+ if exec_type == "assisted" and procedure_block:
474
+ validation_terms = ["validat", "check", "verify", "confirm", "challenge",
475
+ "existing", "duplicate", "contradict", "fit", "misfit"]
476
+ has_validation = any(term in procedure_block.lower() for term in validation_terms)
477
+ if not has_validation:
478
+ issues.append(Issue("warning", "missing_validation_step",
479
+ "Assisted skill has no validation/challenge step in procedure"))
480
+ suggestions.append("Add a requirement-checking or validation step before implementation")
481
+
482
+ # --- Size check (see guidelines/agent-infra/size-and-scope.md) ---
483
+ total_lines = len(text.splitlines())
484
+ if total_lines > 300:
485
+ issues.append(Issue("warning", "skill_too_large", f"Skill has {total_lines} lines; review for split (see size-and-scope guideline)"))
486
+
487
+ # --- Pointer-only / guideline-dependent skill detection ---
488
+ if procedure_block:
489
+ proc_lines = [line.strip() for line in procedure_block.splitlines() if line.strip()]
490
+
491
+ # Delegation patterns: references to external docs instead of own workflow
492
+ delegation_patterns = re.findall(
493
+ r"(?:see|read|check|follow|refer\s+to|consult|per|apply\s+.*from)\s+.*"
494
+ r"(?:guideline|skill|rule|doc|documentation)",
495
+ procedure_block, re.IGNORECASE)
496
+ delegation_count = len(delegation_patterns)
497
+
498
+ # Action verbs that indicate the skill has its own operational workflow
499
+ action_verbs = re.findall(
500
+ r"\b(?:run|execute|create|write|validate|verify|inspect|check|ensure|test|build|"
501
+ r"generate|compare|extract|parse|detect|fix|update|add|remove|install|configure|"
502
+ r"deploy|trace|review|map|resolve|measure|confirm)\b",
503
+ procedure_block, re.IGNORECASE)
504
+ action_count = len(set(v.lower() for v in action_verbs))
505
+
506
+ # Count actual ordered steps
507
+ meaningful_steps = len(ORDERED_STEP_PATTERN.findall(procedure_block))
508
+
509
+ # Thin procedure: few steps AND few lines
510
+ has_thin_procedure = meaningful_steps < 3 and len(proc_lines) < 8
511
+
512
+ # Error: effectively a pointer, not a real skill
513
+ if delegation_count >= 3 and action_count <= 1 and has_thin_procedure:
514
+ issues.append(Issue("error", "guideline_dependent_skill",
515
+ f"Skill is effectively a pointer to guidelines/docs "
516
+ f"({delegation_count} delegations, {action_count} action verbs, "
517
+ f"{meaningful_steps} steps) — not an executable workflow"))
518
+ suggestions.append("Add concrete steps, decision points, and validation directly into the skill")
519
+ # Warning: likely too dependent on external guidance
520
+ elif delegation_count >= 2 and action_count <= 2 and has_thin_procedure:
521
+ issues.append(Issue("warning", "pointer_only_skill",
522
+ f"Skill appears too guideline-dependent "
523
+ f"({delegation_count} delegations, {action_count} action verbs, "
524
+ f"{meaningful_steps} steps) — may lack its own executable workflow"))
525
+ suggestions.append("Expand the skill so it remains executable without opening a guideline")
526
+
527
+ return LintResult(
528
+ file=str(path),
529
+ artifact_type="skill",
530
+ status=classify_status(issues),
531
+ issues=issues,
532
+ suggestions=dedupe_preserve_order(suggestions),
533
+ )
534
+
535
+
536
+ def extract_frontmatter(text: str) -> Optional[str]:
537
+ match = FRONTMATTER_PATTERN.search(text)
538
+ return match.group(1) if match else None
539
+
540
+
541
+ def extract_frontmatter_field(frontmatter: str, pattern: re.Pattern[str]) -> Optional[str]:
542
+ match = pattern.search(frontmatter)
543
+ return match.group(1).strip() if match else None
544
+
545
+
546
+ def parse_execution_block(frontmatter: str) -> Optional[dict]:
547
+ """Parse the execution block from YAML frontmatter.
548
+
549
+ Uses simple line-based parsing to avoid requiring PyYAML.
550
+ Returns None if no execution block is present.
551
+ """
552
+ lines = frontmatter.splitlines()
553
+ exec_start = None
554
+ for i, line in enumerate(lines):
555
+ if re.match(r'^execution:\s*$', line):
556
+ exec_start = i
557
+ break
558
+ if exec_start is None:
559
+ return None
560
+
561
+ result: dict = {}
562
+ for line in lines[exec_start + 1:]:
563
+ # Stop at next top-level key (no indentation)
564
+ if line and not line[0].isspace():
565
+ break
566
+ stripped = line.strip()
567
+ if not stripped or stripped.startswith('#'):
568
+ continue
569
+ # Handle list items (for allowed_tools)
570
+ if stripped.startswith('- '):
571
+ if '_current_list' in result:
572
+ result[result['_current_list']].append(stripped[2:].strip().strip('"').strip("'"))
573
+ continue
574
+ # Handle key: value pairs
575
+ match = re.match(r'^(\w+):\s*(.*?)\s*$', stripped)
576
+ if match:
577
+ key = match.group(1)
578
+ value = match.group(2).strip('"').strip("'")
579
+ if value == '[]':
580
+ result[key] = []
581
+ result['_current_list'] = key
582
+ elif re.match(r'^\[.*\]$', value):
583
+ # Inline YAML/JSON array like [github] or ["github", "jira"]
584
+ inner = value[1:-1].strip()
585
+ if inner:
586
+ items = [item.strip().strip('"').strip("'") for item in inner.split(',')]
587
+ result[key] = items
588
+ else:
589
+ result[key] = []
590
+ result['_current_list'] = key
591
+ elif value == '':
592
+ # Could be a list starting on next line
593
+ result[key] = []
594
+ result['_current_list'] = key
595
+ else:
596
+ # Try to parse as int
597
+ try:
598
+ result[key] = int(value)
599
+ except ValueError:
600
+ result[key] = value
601
+ result.pop('_current_list', None)
602
+
603
+ result.pop('_current_list', None)
604
+ return result
605
+
606
+
607
+ def lint_execution_metadata(execution: dict) -> List[Issue]:
608
+ """Validate the execution block of a skill."""
609
+ issues: List[Issue] = []
610
+
611
+ # Validate type
612
+ exec_type = execution.get("type")
613
+ if exec_type is not None:
614
+ if exec_type not in VALID_EXECUTION_TYPES:
615
+ issues.append(Issue("error", "invalid_execution_type",
616
+ f"Invalid execution.type '{exec_type}'; "
617
+ f"must be one of: {', '.join(sorted(VALID_EXECUTION_TYPES))}"))
618
+ else:
619
+ issues.append(Issue("error", "missing_execution_type",
620
+ "Execution block present but missing 'type' field"))
621
+
622
+ # Validate handler
623
+ handler = execution.get("handler")
624
+ if handler is not None:
625
+ if handler not in VALID_EXECUTION_HANDLERS:
626
+ issues.append(Issue("error", "invalid_execution_handler",
627
+ f"Invalid execution.handler '{handler}'; "
628
+ f"must be one of: {', '.join(sorted(VALID_EXECUTION_HANDLERS))}"))
629
+
630
+ # Automated-specific checks
631
+ if exec_type == "automated":
632
+ if handler is None or handler == "none":
633
+ issues.append(Issue("error", "automated_missing_handler",
634
+ "Automated execution requires a handler other than 'none'"))
635
+ safety_mode = execution.get("safety_mode")
636
+ if safety_mode is None:
637
+ issues.append(Issue("error", "automated_missing_safety_mode",
638
+ "Automated execution requires 'safety_mode: strict'"))
639
+ elif safety_mode not in VALID_EXECUTION_SAFETY_MODES:
640
+ issues.append(Issue("error", "invalid_safety_mode",
641
+ f"Invalid safety_mode '{safety_mode}'; must be 'strict'"))
642
+ if "allowed_tools" not in execution:
643
+ issues.append(Issue("warning", "automated_missing_allowed_tools",
644
+ "Automated execution should declare 'allowed_tools' (use [] for none)"))
645
+
646
+ # Validate safety_mode if present (even for non-automated)
647
+ safety_mode = execution.get("safety_mode")
648
+ if safety_mode is not None and safety_mode not in VALID_EXECUTION_SAFETY_MODES:
649
+ issues.append(Issue("error", "invalid_safety_mode",
650
+ f"Invalid safety_mode '{safety_mode}'; must be 'strict'"))
651
+
652
+ # Validate timeout_seconds
653
+ timeout = execution.get("timeout_seconds")
654
+ if timeout is not None:
655
+ if not isinstance(timeout, int) or timeout <= 0:
656
+ issues.append(Issue("warning", "invalid_timeout",
657
+ f"timeout_seconds should be a positive integer, got '{timeout}'"))
658
+
659
+ # Validate allowed_tools is a list of strings
660
+ allowed_tools = execution.get("allowed_tools")
661
+ if allowed_tools is not None:
662
+ if not isinstance(allowed_tools, list):
663
+ issues.append(Issue("error", "invalid_allowed_tools",
664
+ "allowed_tools must be a list"))
665
+ elif not all(isinstance(t, str) for t in allowed_tools):
666
+ issues.append(Issue("error", "invalid_allowed_tools_entries",
667
+ "All entries in allowed_tools must be strings"))
668
+
669
+ # Validate command shape if present. Skills that declare `command` are
670
+ # runtime-executable; skills without it stay in proposal-only mode.
671
+ command = execution.get("command")
672
+ if command is not None:
673
+ if not isinstance(command, list) or not all(isinstance(c, str) for c in command):
674
+ issues.append(Issue("error", "invalid_command",
675
+ "command must be a list of strings (argv form)"))
676
+ elif len(command) == 0:
677
+ issues.append(Issue("error", "empty_command",
678
+ "command must not be empty"))
679
+
680
+ # Check for unknown fields
681
+ known_fields = VALID_EXECUTION_FIELDS
682
+ unknown = set(execution.keys()) - known_fields
683
+ for field in sorted(unknown):
684
+ issues.append(Issue("warning", "unknown_execution_field",
685
+ f"Unknown field in execution block: '{field}'"))
686
+
687
+ return issues
688
+
689
+
690
+ def lint_rule(path: Path, text: str) -> LintResult:
691
+ issues: List[Issue] = []
692
+ suggestions: List[str] = []
693
+
694
+ # --- Frontmatter checks ---
695
+ frontmatter = extract_frontmatter(text)
696
+ if frontmatter is None:
697
+ issues.append(Issue("error", "missing_frontmatter", "Rule is missing YAML frontmatter (--- block)"))
698
+ else:
699
+ # type field
700
+ rule_type = extract_frontmatter_field(frontmatter, TYPE_PATTERN)
701
+ if rule_type is None:
702
+ issues.append(Issue("error", "missing_type", "Frontmatter missing 'type' field (must be 'always' or 'auto')"))
703
+ elif rule_type not in VALID_RULE_TYPES:
704
+ issues.append(Issue("error", "invalid_type", f"Invalid type '{rule_type}'; must be 'always' or 'auto'"))
705
+
706
+ # source field
707
+ rule_source = extract_frontmatter_field(frontmatter, SOURCE_PATTERN)
708
+ if rule_source is None:
709
+ issues.append(Issue("error", "missing_source", "Frontmatter missing 'source' field (must be 'package' or 'project')"))
710
+ elif rule_source not in VALID_RULE_SOURCES:
711
+ issues.append(Issue("error", "invalid_source", f"Invalid source '{rule_source}'; must be 'package' or 'project'"))
712
+
713
+ # description required for auto rules
714
+ if rule_type == "auto":
715
+ description = extract_description(text)
716
+ if not description:
717
+ issues.append(Issue("error", "auto_missing_description", "Auto rules require a 'description' field for matching"))
718
+
719
+ # always-rules that look like auto candidates (rule-type-governance check)
720
+ if rule_type == "always":
721
+ description = extract_description(text) or ""
722
+ # If description contains topic-specific keywords, it might be an auto candidate
723
+ topic_keywords = re.findall(
724
+ r"\b(?:PHP|Laravel|Docker|Git|E2E|Playwright|SQL|Blade|Livewire|"
725
+ r"Terraform|Jira|Sentry|translations|i18n)\b",
726
+ description, re.IGNORECASE)
727
+ if len(topic_keywords) >= 2:
728
+ issues.append(Issue("info", "always_auto_candidate",
729
+ f"Always-rule with topic-specific description ({', '.join(topic_keywords)}) — "
730
+ f"consider auto type per rule-type-governance"))
731
+
732
+ # --- Structure checks ---
733
+ # H1 heading
734
+ if not H1_PATTERN.search(text):
735
+ issues.append(Issue("error", "missing_h1", "Rule is missing an H1 heading (# Title)"))
736
+
737
+ # File must end with exactly one newline
738
+ if not text.endswith("\n"):
739
+ issues.append(Issue("error", "no_trailing_newline", "File must end with exactly one newline"))
740
+ elif text.endswith("\n\n"):
741
+ issues.append(Issue("warning", "extra_trailing_newlines", "File ends with multiple newlines; should be exactly one"))
742
+
743
+ # No double/triple blank lines in content
744
+ if DOUBLE_BLANK_PATTERN.search(text):
745
+ issues.append(Issue("warning", "double_blank_lines", "File contains double or triple blank lines"))
746
+
747
+ # --- Content checks (see guidelines/agent-infra/size-and-scope.md) ---
748
+ line_count = len([line for line in text.splitlines() if line.strip()])
749
+ total_lines = len(text.splitlines())
750
+ if total_lines > 200:
751
+ issues.append(Issue("error", "rule_too_large", f"Rule has {total_lines} lines (hard limit: 200); must split or move to guideline"))
752
+ elif line_count > 60:
753
+ issues.append(Issue("warning", "long_rule", f"Rule has {line_count} non-empty lines; prefer < 60 (see size-and-scope guideline)"))
754
+ elif line_count > 40:
755
+ issues.append(Issue("warning", "long_rule", f"Rule has {line_count} non-empty lines; rules should be concise"))
756
+
757
+ for bad_sign in RULE_BAD_SIGNS:
758
+ if bad_sign in text:
759
+ issues.append(Issue("error", "rule_looks_like_skill", f"Rule contains skill-like section: {bad_sign}"))
760
+
761
+ # Exclude frontmatter from procedural check (frontmatter may contain "type")
762
+ body = text.split("---", 2)[-1] if frontmatter else text
763
+ if re.search(r"\b(procedure|workflow)\b", body, re.IGNORECASE):
764
+ issues.append(Issue("warning", "procedural_rule", "Rule looks procedural; consider a skill instead"))
765
+
766
+ return LintResult(
767
+ file=str(path),
768
+ artifact_type="rule",
769
+ status=classify_status(issues),
770
+ issues=issues,
771
+ suggestions=dedupe_preserve_order(suggestions),
772
+ )
773
+
774
+
775
+ def lint_command(path: Path, text: str) -> LintResult:
776
+ issues: List[Issue] = []
777
+ suggestions: List[str] = []
778
+
779
+ # --- Frontmatter checks ---
780
+ frontmatter = extract_frontmatter(text)
781
+ if frontmatter is None:
782
+ issues.append(Issue("error", "missing_frontmatter", "Command is missing YAML frontmatter (--- block)"))
783
+ else:
784
+ # name field
785
+ name_match = NAME_PATTERN.search(frontmatter)
786
+ if not name_match or not name_match.group(1).strip():
787
+ issues.append(Issue("error", "missing_name", "Frontmatter missing 'name' field"))
788
+
789
+ # disable-model-invocation field
790
+ dmi_match = DISABLE_MODEL_PATTERN.search(frontmatter)
791
+ if not dmi_match:
792
+ issues.append(Issue("error", "missing_disable_model_invocation",
793
+ "Frontmatter missing 'disable-model-invocation: true' (required for Claude Code)"))
794
+ elif dmi_match.group(1) != "true":
795
+ issues.append(Issue("warning", "disable_model_invocation_false",
796
+ "disable-model-invocation should be 'true' for commands"))
797
+
798
+ # description field
799
+ description = extract_description(text)
800
+ if not description:
801
+ issues.append(Issue("warning", "missing_description", "Frontmatter description is missing"))
802
+
803
+ # --- Structure checks ---
804
+ if not H1_PATTERN.search(text):
805
+ issues.append(Issue("error", "missing_h1", "Command is missing an H1 heading (# Title)"))
806
+
807
+ # Must have at least one ## section with steps
808
+ sections = extract_sections(text)
809
+ has_steps = any(s.lower().startswith("step") for s in sections)
810
+ has_numbered = bool(re.search(r"^###?\s+\d+\.\s+", text, re.MULTILINE))
811
+ if not has_steps and not has_numbered:
812
+ issues.append(Issue("warning", "no_steps", "Command has no Steps section or numbered sub-headings"))
813
+
814
+ # --- Size check (see guidelines/agent-infra/size-and-scope.md) ---
815
+ word_count = len(text.split())
816
+ if word_count > 1000:
817
+ issues.append(Issue("warning", "large_command", f"Command has {word_count} words (target: 200-600, max ~1000)"))
818
+
819
+ # File must end with exactly one newline
820
+ if not text.endswith("\n"):
821
+ issues.append(Issue("error", "no_trailing_newline", "File must end with exactly one newline"))
822
+ elif text.endswith("\n\n"):
823
+ issues.append(Issue("warning", "extra_trailing_newlines", "File ends with multiple newlines; should be exactly one"))
824
+
825
+ # Role-contract anchor validity (road-to-role-modes Phase 1).
826
+ issues.extend(lint_role_contract_refs(text))
827
+
828
+ return LintResult(
829
+ file=str(path),
830
+ artifact_type="command",
831
+ status=classify_status(issues),
832
+ issues=issues,
833
+ suggestions=dedupe_preserve_order(suggestions),
834
+ )
835
+
836
+
837
+ def lint_unknown(path: Path, text: str) -> LintResult:
838
+ issues = [Issue("error", "unknown_artifact", "Could not detect whether file is a skill, rule, or command")]
839
+ return LintResult(
840
+ file=str(path),
841
+ artifact_type="unknown",
842
+ status="fail",
843
+ issues=issues,
844
+ suggestions=["Move the file into a recognized skills/, rules/, or commands/ path"],
845
+ )
846
+
847
+
848
+ def lint_guideline(path: Path, text: str) -> LintResult:
849
+ """Lint a guideline .md file (size + structure checks)."""
850
+ issues: List[Issue] = []
851
+
852
+ # H1 heading
853
+ if not H1_PATTERN.search(text):
854
+ issues.append(Issue("warning", "missing_h1", "Guideline is missing an H1 heading"))
855
+
856
+ # Size check (guidelines/agent-infra/size-and-scope.md: target 400-1500 words)
857
+ word_count = len(text.split())
858
+ if word_count > 1500:
859
+ issues.append(Issue("info", "large_guideline", f"Guideline has {word_count} words (target: 400-1500)"))
860
+
861
+ # Trailing newline
862
+ if not text.endswith("\n"):
863
+ issues.append(Issue("warning", "no_trailing_newline", "File must end with exactly one newline"))
864
+
865
+ return LintResult(
866
+ file=str(path),
867
+ artifact_type="guideline",
868
+ status=classify_status(issues),
869
+ issues=issues,
870
+ suggestions=[],
871
+ )
872
+
873
+
874
+ def lint_persona(path: Path, text: str) -> LintResult:
875
+ """Lint a persona .md file (frontmatter schema + required sections + size)."""
876
+ issues: List[Issue] = []
877
+
878
+ # Frontmatter required
879
+ frontmatter = extract_frontmatter(text)
880
+ if not frontmatter:
881
+ issues.append(Issue("error", "missing_frontmatter", "Persona requires YAML frontmatter"))
882
+ return LintResult(
883
+ file=str(path),
884
+ artifact_type="persona",
885
+ status="fail",
886
+ issues=issues,
887
+ suggestions=["See .agent-src.uncompressed/templates/persona.md for the schema"],
888
+ )
889
+
890
+ # Required frontmatter fields
891
+ required = {
892
+ "id": re.compile(r'^id:\s*"?([\w-]+)"?\s*$', re.MULTILINE),
893
+ "role": re.compile(r'^role:\s*"?(.+?)"?\s*$', re.MULTILINE),
894
+ "description": re.compile(r'^description:\s*"?(.+?)"?\s*$', re.MULTILINE),
895
+ "tier": re.compile(r'^tier:\s*"?(\w+)"?\s*$', re.MULTILINE),
896
+ "version": re.compile(r'^version:\s*"?(.+?)"?\s*$', re.MULTILINE),
897
+ "source": re.compile(r'^source:\s*"?(package|project)"?\s*$', re.MULTILINE),
898
+ }
899
+ parsed: dict = {}
900
+ for field, pattern in required.items():
901
+ value = extract_frontmatter_field(frontmatter, pattern)
902
+ if not value:
903
+ issues.append(Issue("error", f"missing_{field}", f"Persona frontmatter must declare `{field}`"))
904
+ else:
905
+ parsed[field] = value
906
+
907
+ # id matches filename stem
908
+ if "id" in parsed and parsed["id"] != path.stem:
909
+ issues.append(Issue(
910
+ "error",
911
+ "id_filename_mismatch",
912
+ f"Persona id `{parsed['id']}` must match filename stem `{path.stem}`",
913
+ ))
914
+
915
+ # tier in valid set
916
+ if "tier" in parsed and parsed["tier"] not in VALID_PERSONA_TIERS:
917
+ issues.append(Issue(
918
+ "error",
919
+ "invalid_tier",
920
+ f"Persona tier `{parsed['tier']}` must be one of {sorted(VALID_PERSONA_TIERS)}",
921
+ ))
922
+
923
+ # description length
924
+ if "description" in parsed and len(parsed["description"]) > 160:
925
+ issues.append(Issue(
926
+ "warning",
927
+ "long_description",
928
+ f"Persona description is {len(parsed['description'])} chars (target ≤ 160)",
929
+ ))
930
+
931
+ # Required sections
932
+ sections = extract_sections(text)
933
+ for required_section in REQUIRED_PERSONA_SECTIONS:
934
+ if required_section not in sections:
935
+ issues.append(Issue(
936
+ "error",
937
+ "missing_section",
938
+ f"Persona is missing required section `## {required_section}`",
939
+ ))
940
+
941
+ # Unique Questions must have ≥ 3 bullet items
942
+ uq_block = extract_section_block(text, "Unique Questions")
943
+ if uq_block:
944
+ bullet_count = len(re.findall(r"^\s*[-*]\s+", uq_block, re.MULTILINE))
945
+ if bullet_count < 3:
946
+ issues.append(Issue(
947
+ "warning",
948
+ "too_few_unique_questions",
949
+ f"Persona has {bullet_count} unique questions (target ≥ 3)",
950
+ ))
951
+
952
+ # Size budget by tier
953
+ if "tier" in parsed and parsed["tier"] in PERSONA_LINE_BUDGETS:
954
+ budget = PERSONA_LINE_BUDGETS[parsed["tier"]]
955
+ line_count = len(text.splitlines())
956
+ if line_count > budget:
957
+ issues.append(Issue(
958
+ "warning",
959
+ "size_budget",
960
+ f"Persona has {line_count} lines ({parsed['tier']} budget ≤ {budget})",
961
+ ))
962
+
963
+ # H1 heading
964
+ if not H1_PATTERN.search(text):
965
+ issues.append(Issue("warning", "missing_h1", "Persona is missing an H1 heading"))
966
+
967
+ # Trailing newline
968
+ if not text.endswith("\n"):
969
+ issues.append(Issue("warning", "no_trailing_newline", "File must end with exactly one newline"))
970
+
971
+ return LintResult(
972
+ file=str(path),
973
+ artifact_type="persona",
974
+ status=classify_status(issues),
975
+ issues=issues,
976
+ suggestions=[],
977
+ )
978
+
979
+
980
+ def gather_all_candidate_files(root: Path) -> list[Path]:
981
+ """Gather all lintable files. Prefers .agent-src.uncompressed/ (source of truth).
982
+ Falls back to .agent-src/ only if uncompressed doesn't exist.
983
+ Skips symlinks to avoid double-counting."""
984
+ candidates: list[Path] = []
985
+
986
+ # Source of truth directories
987
+ uncompressed_skills = root / ".agent-src.uncompressed" / "skills"
988
+ uncompressed_rules = root / ".agent-src.uncompressed" / "rules"
989
+ uncompressed_commands = root / ".agent-src.uncompressed" / "commands"
990
+ uncompressed_guidelines = root / ".agent-src.uncompressed" / "guidelines"
991
+
992
+ # Fallback directories (only if uncompressed doesn't exist)
993
+ augment_skills = root / ".agent-src" / "skills"
994
+ augment_rules = root / ".agent-src" / "rules"
995
+ augment_commands = root / ".agent-src" / "commands"
996
+ augment_guidelines = root / ".agent-src" / "guidelines"
997
+
998
+ # Skills
999
+ skills_base = uncompressed_skills if uncompressed_skills.exists() else augment_skills
1000
+ if skills_base.exists():
1001
+ for f in skills_base.rglob("SKILL.md"):
1002
+ if not f.is_symlink():
1003
+ candidates.append(f)
1004
+
1005
+ # Rules
1006
+ rules_base = uncompressed_rules if uncompressed_rules.exists() else augment_rules
1007
+ if rules_base.exists():
1008
+ for f in rules_base.rglob("*.md"):
1009
+ if not f.is_symlink():
1010
+ candidates.append(f)
1011
+
1012
+ # Commands
1013
+ commands_base = uncompressed_commands if uncompressed_commands.exists() else augment_commands
1014
+ if commands_base.exists():
1015
+ for f in commands_base.rglob("*.md"):
1016
+ if not f.is_symlink():
1017
+ candidates.append(f)
1018
+
1019
+ # Guidelines
1020
+ guidelines_base = uncompressed_guidelines if uncompressed_guidelines.exists() else augment_guidelines
1021
+ if guidelines_base.exists():
1022
+ for f in guidelines_base.rglob("*.md"):
1023
+ if not f.is_symlink():
1024
+ candidates.append(f)
1025
+
1026
+ # Personas
1027
+ uncompressed_personas = root / ".agent-src.uncompressed" / "personas"
1028
+ augment_personas = root / ".agent-src" / "personas"
1029
+ personas_base = uncompressed_personas if uncompressed_personas.exists() else augment_personas
1030
+ if personas_base.exists():
1031
+ for f in personas_base.glob("*.md"):
1032
+ if f.name.lower() == "readme.md":
1033
+ continue
1034
+ if not f.is_symlink():
1035
+ candidates.append(f)
1036
+
1037
+ return sorted(set(candidates))
1038
+
1039
+
1040
+ def gather_changed_candidate_files(root: Path) -> list[Path]:
1041
+ """Find changed skill/rule files using git diff.
1042
+
1043
+ Tries multiple strategies:
1044
+ 1. CI: diff against origin/main (PR changes)
1045
+ 2. Local: staged changes (git diff --cached)
1046
+ 3. Fallback: unstaged changes (git diff HEAD)
1047
+ """
1048
+ diff_commands = [
1049
+ ["git", "diff", "--name-only", "origin/main...HEAD"],
1050
+ ["git", "diff", "--name-only", "--cached", "HEAD"],
1051
+ ["git", "diff", "--name-only", "HEAD"],
1052
+ ]
1053
+ try:
1054
+ raw_lines: list[str] = []
1055
+ for cmd in diff_commands:
1056
+ result = subprocess.run(
1057
+ cmd, cwd=root, text=True, capture_output=True, check=False,
1058
+ )
1059
+ if result.returncode == 0 and result.stdout.strip():
1060
+ raw_lines = result.stdout.splitlines()
1061
+ break
1062
+
1063
+ files = []
1064
+ for raw in raw_lines:
1065
+ raw = raw.strip()
1066
+ if not raw:
1067
+ continue
1068
+ path = root / raw
1069
+ if not path.exists():
1070
+ continue
1071
+ # Skip symlinks to avoid double-counting (e.g. .claude/skills/ → .agent-src/commands/)
1072
+ if path.is_symlink():
1073
+ continue
1074
+ norm = raw.replace("\\", "/")
1075
+ if path.name == "SKILL.md" or "/rules/" in norm or "/commands/" in norm:
1076
+ files.append(path)
1077
+ return sorted(set(files))
1078
+ except Exception:
1079
+ return []
1080
+
1081
+
1082
+ # --- Interaction quality checks (keyword-based, for meta/interaction artifacts only) ---
1083
+
1084
+ # File name patterns that indicate an interaction/meta artifact (strict — avoids false positives)
1085
+ _INTERACTION_NAME_PATTERNS = re.compile(
1086
+ r"skill-router|handoff|analysis-skill|skill-writing|skill-reviewer|"
1087
+ r"model-recommendation|developer-like-execution|universal-project-analysis|"
1088
+ r"interaction|autonomous-mode|feature-planning",
1089
+ re.IGNORECASE,
1090
+ )
1091
+ _INTERACTION_CONTENT_KEYWORDS = {"handoff", "model switch", "clarification", "ask the user", "framework choice", "requirements are unclear"}
1092
+
1093
+
1094
+ def _is_interaction_artifact(path: Path, text: str) -> bool:
1095
+ """Check if file is an interaction/meta artifact that should get question-quality checks."""
1096
+ name = str(path).lower()
1097
+ # Strict name match — only truly interaction-focused artifacts
1098
+ if _INTERACTION_NAME_PATTERNS.search(name):
1099
+ return True
1100
+ # Content match needs 3+ keywords to avoid false positives on analysis/coding skills
1101
+ text_lower = text.lower()
1102
+ matches = sum(1 for kw in _INTERACTION_CONTENT_KEYWORDS if kw in text_lower)
1103
+ return matches >= 3
1104
+
1105
+
1106
+ def lint_interaction_quality(path: Path, text: str) -> List[Issue]:
1107
+ """Check interaction/meta artifacts for question strategy, handoff order, etc."""
1108
+ if not _is_interaction_artifact(path, text):
1109
+ return []
1110
+
1111
+ issues: List[Issue] = []
1112
+ text_lower = text.lower()
1113
+
1114
+ # Only check files that explicitly discuss user questioning strategy
1115
+ has_question_context = any(kw in text_lower for kw in (
1116
+ "ask the user", "ask clarification", "numbered options", "present options",
1117
+ "question strategy", "ask before",
1118
+ ))
1119
+
1120
+ # Check 1: Question strategy — distinguishes simple grouped vs complex sequential
1121
+ if has_question_context:
1122
+ has_simple = any(kw in text_lower for kw in ("simple", "binary", "independent"))
1123
+ has_complex = any(kw in text_lower for kw in ("complex", "one at a time", "one question"))
1124
+ if not (has_simple and has_complex):
1125
+ issues.append(Issue("warning", "question_strategy_missing",
1126
+ "Interaction guidance does not distinguish simple grouped questions "
1127
+ "from complex sequential questions"))
1128
+
1129
+ # Check 2: Handoff ordering — handoff/model-switch questions should come last
1130
+ has_handoff = any(kw in text_lower for kw in ("handoff", "model switch", "model-switch"))
1131
+ if has_handoff:
1132
+ has_ordering = any(kw in text_lower for kw in (
1133
+ "last", "after context", "after clarification", "after all",
1134
+ ))
1135
+ if not has_ordering:
1136
+ issues.append(Issue("warning", "handoff_order_missing",
1137
+ "Handoff/model-switch guidance does not specify asking handoff "
1138
+ "questions AFTER context/domain questions"))
1139
+
1140
+ # Check 3: Framework choice guard — only when file explicitly discusses choosing between systems
1141
+ has_impl = any(kw in text_lower for kw in ("implement", "component", "ui component", "ui framework"))
1142
+ has_multi = any(kw in text_lower for kw in ("multiple frameworks", "multiple systems", "competing", "which framework"))
1143
+ if has_impl and has_multi:
1144
+ has_guard = any(kw in text_lower for kw in (
1145
+ "ask which", "ask before", "do not implement blindly", "analyze what exists",
1146
+ "do not pick", "clarif",
1147
+ ))
1148
+ if not has_guard:
1149
+ issues.append(Issue("warning", "framework_choice_guard_missing",
1150
+ "Discusses implementation choices but does not require clarification "
1151
+ "when multiple frameworks/patterns exist"))
1152
+
1153
+ # Check 4: Clarification guard — only for files with explicit interaction/execution guidance
1154
+ has_execution_guidance = any(kw in text_lower for kw in ("procedure", "workflow", "step 1", "### 1."))
1155
+ if has_execution_guidance:
1156
+ has_clarification = any(kw in text_lower for kw in (
1157
+ "requirements are unclear", "ask clarification", "do not assume",
1158
+ "clarification question", "missing instructions", "incomplete",
1159
+ ))
1160
+ if not has_clarification:
1161
+ issues.append(Issue("info", "clarification_guard_missing",
1162
+ "Contains action guidance but no explicit clarification behavior "
1163
+ "for incomplete requirements"))
1164
+
1165
+ # Check 5: Feedback learning — meta/reviewer artifacts should support learning
1166
+ is_meta = any(kw in str(path).lower() for kw in ("review", "improve", "learn", "audit", "optim"))
1167
+ if is_meta:
1168
+ has_learning = any(kw in text_lower for kw in (
1169
+ "learning", "feedback", "frustration", "capture", "improve the system",
1170
+ "rule / skill", "rule/skill",
1171
+ ))
1172
+ if not has_learning:
1173
+ issues.append(Issue("info", "feedback_learning_missing",
1174
+ "Meta/reviewer artifact does not mention learning from negative "
1175
+ "feedback or converting failures into system improvements"))
1176
+
1177
+ return issues
1178
+
1179
+
1180
+ # --- Execution quality checks ---
1181
+
1182
+ # File name signals for execution-oriented artifacts
1183
+ _EXEC_FILE_SIGNALS = (
1184
+ "execution", "debug", "implement", "developer", "action",
1185
+ "validation", "testing", "coder", "bug", "fix",
1186
+ )
1187
+
1188
+ # Content signals that indicate execution-oriented artifact
1189
+ _EXEC_CONTENT_SIGNALS = (
1190
+ "implement", "debug", "refactor", "modify", "fix",
1191
+ "verify", "validate", "runtime", "test", "coding",
1192
+ "before acting", "before coding", "before changing",
1193
+ )
1194
+
1195
+
1196
+ def _is_execution_artifact(path: Path, text: str) -> bool:
1197
+ """Detect if artifact is execution/implementation oriented.
1198
+
1199
+ Only skills and rules qualify — commands and guidelines are excluded
1200
+ because commands are workflows (not execution guidance) and guidelines
1201
+ are coding patterns (not developer workflow enforcement).
1202
+ """
1203
+ path_lower = str(path).lower()
1204
+ text_lower = text.lower()
1205
+
1206
+ # Exclude commands, guidelines, and personas — they are not execution-oriented
1207
+ if "/commands/" in path_lower or "/guidelines/" in path_lower or "/personas/" in path_lower:
1208
+ return False
1209
+
1210
+ # File name match — strong signal
1211
+ if any(sig in path_lower for sig in _EXEC_FILE_SIGNALS):
1212
+ return True
1213
+
1214
+ # Content match — need at least 5 signals to avoid false positives
1215
+ # (many artifacts mention "implement" or "fix" without being execution-focused)
1216
+ matches = sum(1 for sig in _EXEC_CONTENT_SIGNALS if sig in text_lower)
1217
+ return matches >= 5
1218
+
1219
+
1220
+ def lint_execution_quality(path: Path, text: str) -> List[Issue]:
1221
+ """Check execution-oriented artifacts for developer workflow quality."""
1222
+ if not _is_execution_artifact(path, text):
1223
+ return []
1224
+
1225
+ issues: List[Issue] = []
1226
+ text_lower = text.lower()
1227
+ path_lower = str(path).lower()
1228
+
1229
+ # Strong match = file name signal; weak match = content-only signal
1230
+ is_strong_match = any(sig in path_lower for sig in _EXEC_FILE_SIGNALS)
1231
+
1232
+ # --- Signal groups ---
1233
+ # Each group uses broad synonyms to reduce false negatives.
1234
+ # Skills often express analysis/verification concepts without using
1235
+ # the exact words "analyze" or "verify".
1236
+ analysis_signals = (
1237
+ "analyze", "inspect", "understand", "read relevant",
1238
+ "review existing", "trace flow", "read affected",
1239
+ "check current", "before acting", "before coding",
1240
+ # Synonyms added in Phase 2b
1241
+ "examine", "study", "investigate", "check existing",
1242
+ "gather context", "read project", "read the changelog",
1243
+ "identify break", "assess", "before upgrading",
1244
+ "before changing", "before creating", "before modifying",
1245
+ "read docs", "read module", "read agents",
1246
+ )
1247
+
1248
+ verification_signals = (
1249
+ "verify", "validate", "test", "real execution",
1250
+ "run endpoint", "playwright", "curl", "postman",
1251
+ "debugger", "run tests", "hit the endpoint",
1252
+ # Synonyms added in Phase 2b
1253
+ "confirm", "assert", "check result", "observe",
1254
+ "run phpstan", "run rector", "build and verify",
1255
+ "must pass", "response shape",
1256
+ )
1257
+
1258
+ verification_tool_signals = (
1259
+ "playwright", "curl", "postman", "xdebug",
1260
+ "browser", "http::fake",
1261
+ # Synonyms added in Phase 2b
1262
+ "phpstan", "rector", "phpunit", "pest",
1263
+ "devcontainer build",
1264
+ )
1265
+
1266
+ debug_runtime_signals = (
1267
+ "debugger", "xdebug", "mcp debugger", "runtime inspection",
1268
+ "trace execution", "breakpoint", "step through",
1269
+ # Synonyms added in Phase 2b
1270
+ "runtime", "stack trace", "dump", "dd(",
1271
+ )
1272
+
1273
+ efficient_tooling_signals = (
1274
+ "jq", " rg ", "grep", "filter", "selective",
1275
+ "extract", "targeted", "--json", "--filter",
1276
+ # Synonyms added in Phase 2b
1277
+ "narrow", "scoped", "specific field", "only relevant",
1278
+ )
1279
+
1280
+ anti_bruteforce_signals = (
1281
+ "avoid retr", "do not brute", "do not guess",
1282
+ "do not retry blind", "analyze before retry",
1283
+ "blind retr", "trial-and-error", "trial and error",
1284
+ "max 2 retries", "stop and rethink",
1285
+ # Synonyms added in Phase 2b
1286
+ "diagnose", "root cause", "targeted fix",
1287
+ "do not blindly", "never guess",
1288
+ )
1289
+
1290
+ clarification_signals = (
1291
+ "ask", "clarif", "unclear", "missing information",
1292
+ "do not assume", "don't assume", "instead of assuming",
1293
+ # Synonyms added in Phase 2b
1294
+ "confirm with user", "verify requirement", "ambiguous",
1295
+ "if unsure", "when in doubt",
1296
+ )
1297
+
1298
+ # Helper
1299
+ def has_any(signals: tuple[str, ...]) -> bool:
1300
+ return any(s in text_lower for s in signals)
1301
+
1302
+ # --- Section-based detection (complement to keyword matching) ---
1303
+ # Detects structural signals: sections whose names imply analysis or verification.
1304
+ import re
1305
+ section_headers = re.findall(r'^#{1,4}\s+(.+)$', text, re.MULTILINE)
1306
+ section_headers_lower = [h.lower() for h in section_headers]
1307
+
1308
+ # Section names that imply analysis-before-action
1309
+ has_analysis_section = any(
1310
+ any(kw in h for kw in ("understand", "analyze", "assess", "context", "review",
1311
+ "current setup", "current state", "before"))
1312
+ for h in section_headers_lower
1313
+ )
1314
+
1315
+ # Section names that imply verification
1316
+ has_verification_section = any(
1317
+ any(kw in h for kw in ("verify", "validat", "test", "acceptance", "quality gate"))
1318
+ for h in section_headers_lower
1319
+ )
1320
+
1321
+ # Section names that imply anti-patterns / gotchas
1322
+ has_antipattern_section = any(
1323
+ any(kw in h for kw in ("do not", "don't", "gotcha", "anti-pattern", "avoid"))
1324
+ for h in section_headers_lower
1325
+ )
1326
+
1327
+ # Detect implementation/change language
1328
+ change_signals = ("implement", "modify", "fix", "refactor", "change", "update", "code")
1329
+ has_change_language = any(s in text_lower for s in change_signals)
1330
+
1331
+ # Combine keyword + section signals
1332
+ has_analysis = has_any(analysis_signals) or has_analysis_section
1333
+ has_verification = has_any(verification_signals) or has_verification_section
1334
+
1335
+ # --- Check 1: Missing analysis-before-action (ERROR, skills only) ---
1336
+ # Rules describe constraints, not workflows — they don't need analysis sections
1337
+ is_skill = "/skills/" in str(path).lower()
1338
+ if is_skill and has_change_language and not has_analysis:
1339
+ issues.append(Issue("error", "missing_analysis_before_action",
1340
+ "Execution-oriented skill encourages implementation "
1341
+ "without requiring prior analysis of existing system"))
1342
+
1343
+ # --- Check 2: Missing real verification (ERROR, skills with strong match) ---
1344
+ if is_skill and is_strong_match and has_change_language and not has_verification:
1345
+ issues.append(Issue("error", "missing_real_verification",
1346
+ "Implementation/debugging skill does not require "
1347
+ "real verification after changes"))
1348
+
1349
+ # Checks 3-7 only apply to strong matches (file name signal) to avoid noise
1350
+ # on generic skills that happen to mention "implement" or "fix"
1351
+ if is_strong_match:
1352
+ # --- Check 3: Missing verification tool mapping (WARNING) ---
1353
+ if has_any(verification_signals) and not has_any(verification_tool_signals):
1354
+ issues.append(Issue("warning", "missing_verification_tool_mapping",
1355
+ "Verification is generic — does not reference concrete "
1356
+ "tools (Playwright, curl, Postman, Xdebug)"))
1357
+
1358
+ # --- Check 4: Missing runtime debug guidance (WARNING) ---
1359
+ debug_context = any(s in text_lower for s in ("debug", "execution flow", "trace", "unexpected behavior"))
1360
+ if debug_context and not has_any(debug_runtime_signals):
1361
+ issues.append(Issue("warning", "missing_runtime_debug_guidance",
1362
+ "Debugging/execution artifact does not mention "
1363
+ "runtime debug tools (Xdebug, debugger, breakpoints)"))
1364
+
1365
+ # --- Check 5: Missing efficient tooling guidance (WARNING) ---
1366
+ data_context = any(s in text_lower for s in ("api", "log", "json", "response", "output", "data"))
1367
+ if data_context and not has_any(efficient_tooling_signals):
1368
+ issues.append(Issue("warning", "missing_efficient_tooling_guidance",
1369
+ "Artifact does not encourage targeted filtering tools "
1370
+ "(jq, rg, grep) for reducing output"))
1371
+
1372
+ # --- Check 6: Missing anti-bruteforce guidance (WARNING, skills only) ---
1373
+ if is_skill and has_change_language and not has_any(anti_bruteforce_signals):
1374
+ issues.append(Issue("warning", "missing_anti_bruteforce_guidance",
1375
+ "Execution guidance lacks explicit anti-retry / "
1376
+ "anti-bruteforce behavior"))
1377
+
1378
+ # --- Check 7: Missing clarification guard (WARNING, skills only) ---
1379
+ if is_skill and has_change_language and not has_any(clarification_signals):
1380
+ issues.append(Issue("warning", "missing_clarification_guard",
1381
+ "Implementation guidance does not require clarification "
1382
+ "when requirements are incomplete"))
1383
+
1384
+ return issues
1385
+
1386
+
1387
+ # --- Type boundary checks ---
1388
+
1389
+
1390
+ def lint_type_boundaries(path: Path, text: str, artifact_type: str) -> List[Issue]:
1391
+ """Check that artifacts respect their type boundaries.
1392
+
1393
+ - Guidelines should not contain executable procedures
1394
+ - Commands should reference skills
1395
+ - Skills should have concrete validation (not vague)
1396
+ """
1397
+ issues: List[Issue] = []
1398
+ text_lower = text.lower()
1399
+ import re
1400
+
1401
+ # --- Guideline: should not have executable procedures ---
1402
+ if artifact_type == "guideline":
1403
+ # Count numbered steps (1. 2. 3. etc.) — guidelines shouldn't have >5
1404
+ numbered_steps = re.findall(r'^\d+\.\s+\*?\*?(?:Step|Run|Create|Execute|Implement)',
1405
+ text, re.MULTILINE | re.IGNORECASE)
1406
+ if len(numbered_steps) >= 5:
1407
+ issues.append(Issue("warning", "guideline_contains_executable_procedure",
1408
+ f"Guideline has {len(numbered_steps)} executable numbered steps — "
1409
+ "consider extracting into a skill or command"))
1410
+
1411
+ # --- Command: should reference skills ---
1412
+ if artifact_type == "command":
1413
+ # Check frontmatter skills field
1414
+ frontmatter = extract_frontmatter(text)
1415
+ has_skills_field = False
1416
+ if frontmatter:
1417
+ skills_match = re.search(r'skills:\s*\[(.+)\]', frontmatter)
1418
+ has_skills_field = bool(skills_match and skills_match.group(1).strip())
1419
+
1420
+ # Also check body for skill references
1421
+ has_skill_ref = bool(re.search(r'skill|SKILL\.md', text))
1422
+
1423
+ if not has_skills_field and not has_skill_ref:
1424
+ issues.append(Issue("warning", "command_missing_skill_references",
1425
+ "Command does not reference any skills — "
1426
+ "commands should orchestrate skills, not contain domain logic"))
1427
+
1428
+ # --- Skill: validation should be concrete, not vague ---
1429
+ if artifact_type == "skill":
1430
+ # Find validation/verify sections
1431
+ validation_section = re.search(
1432
+ r'(?:^#{1,4}\s+(?:Validat|Verif|Quality|Accept).+?\n)((?:.*\n)*?)(?=^#{1,4}\s|\Z)',
1433
+ text, re.MULTILINE | re.IGNORECASE
1434
+ )
1435
+ if validation_section:
1436
+ validation_text = validation_section.group(1).lower()
1437
+ vague_patterns = ("check if it works", "make sure it's correct",
1438
+ "verify it works", "should work", "looks correct")
1439
+ concrete_patterns = ("run ", "curl ", "phpstan", "rector", "pest",
1440
+ "playwright", "assert", "exit code", "must pass",
1441
+ "0 fail", "0 error")
1442
+ has_vague = any(p in validation_text for p in vague_patterns)
1443
+ has_concrete = any(p in validation_text for p in concrete_patterns)
1444
+ if has_vague and not has_concrete:
1445
+ issues.append(Issue("warning", "skill_validation_too_generic",
1446
+ "Validation section uses vague language — "
1447
+ "add concrete checks (commands, expected output, conditions)"))
1448
+
1449
+ return issues
1450
+
1451
+
1452
+ # --- Verification maturity checks ---
1453
+
1454
+ # Task type detection signals
1455
+ _TASK_TYPE_SIGNALS = {
1456
+ "backend": ("api", "endpoint", "controller", "route", "service", "repository",
1457
+ "eloquent", "migration", "artisan", "middleware", "job", "queue"),
1458
+ "frontend": ("blade", "livewire", "component", "view", "ui", "frontend",
1459
+ "tailwind", "flux", "css", "template"),
1460
+ "cli": ("artisan command", "cli", "console", "schedule", "cron"),
1461
+ "database": ("migration", "database", "schema", "index", "query", "sql",
1462
+ "mariadb", "mysql", "seeder"),
1463
+ "debugging": ("debug", "xdebug", "error", "exception", "sentry", "trace",
1464
+ "breakpoint", "log"),
1465
+ }
1466
+
1467
+ # Expected verification tools per task type
1468
+ _VERIFICATION_TOOLS = {
1469
+ "backend": ("curl", "postman", "http::fake", "actingas", "api/"),
1470
+ "frontend": ("playwright", "browser", "screenshot", "snapshot", "livewire test"),
1471
+ "cli": ("exit code", "command output", "artisan test", "expectsoutput"),
1472
+ "database": ("query", "assertdatabase", "migration", "seedandassert", "table"),
1473
+ "debugging": ("xdebug", "breakpoint", "dump", "dd(", "stack trace", "log"),
1474
+ }
1475
+
1476
+
1477
+ def lint_verification_maturity(path: Path, text: str, artifact_type: str) -> List[Issue]:
1478
+ """Check that verification matches the skill's task type."""
1479
+ if artifact_type != "skill":
1480
+ return []
1481
+
1482
+ # Only check skills with strong execution signals
1483
+ path_lower = str(path).lower()
1484
+ if not any(sig in path_lower for sig in _EXEC_FILE_SIGNALS):
1485
+ return []
1486
+
1487
+ issues: List[Issue] = []
1488
+ text_lower = text.lower()
1489
+
1490
+ # Detect task types present in the skill
1491
+ detected_types: list[str] = []
1492
+ for task_type, signals in _TASK_TYPE_SIGNALS.items():
1493
+ matches = sum(1 for s in signals if s in text_lower)
1494
+ if matches >= 2: # Need at least 2 signals to classify
1495
+ detected_types.append(task_type)
1496
+
1497
+ if not detected_types:
1498
+ return []
1499
+
1500
+ # Check if appropriate verification tools are mentioned
1501
+ for task_type in detected_types:
1502
+ tools = _VERIFICATION_TOOLS.get(task_type, ())
1503
+ has_tool = any(t in text_lower for t in tools)
1504
+ if not has_tool:
1505
+ issues.append(Issue("warning", f"missing_{task_type}_verification_example",
1506
+ f"Skill covers {task_type} tasks but does not mention "
1507
+ f"verification tools for that context "
1508
+ f"(e.g. {', '.join(tools[:3])})"))
1509
+
1510
+ return issues
1511
+
1512
+
1513
+ # --- Governance & packaging checks ---
1514
+
1515
+
1516
+ def lint_governance(path: Path, text: str, artifact_type: str, repo_root: Path | None = None) -> List[Issue]:
1517
+ """Check governance and packaging consistency.
1518
+
1519
+ - Compressed/uncompressed pairs must exist
1520
+ - No duplicate skill names
1521
+ - Files must be in correct location for their type
1522
+ """
1523
+ issues: List[Issue] = []
1524
+ if repo_root is None:
1525
+ return issues
1526
+
1527
+ path_str = str(path)
1528
+ path_relative = path_str
1529
+
1530
+ # Determine if this is a compressed or uncompressed artifact
1531
+ is_compressed = "/.agent-src/" in path_str and "/.agent-src.uncompressed/" not in path_str
1532
+ is_uncompressed = "/.agent-src.uncompressed/" in path_str
1533
+
1534
+ if not is_compressed and not is_uncompressed:
1535
+ return issues
1536
+
1537
+ # --- Check: compressed/uncompressed pair exists ---
1538
+ if is_uncompressed:
1539
+ # Find expected compressed path
1540
+ compressed_path = Path(path_str.replace("/.agent-src.uncompressed/", "/.agent-src/"))
1541
+ if not compressed_path.exists():
1542
+ issues.append(Issue("warning", "compressed_variant_missing",
1543
+ f"Uncompressed file exists but compressed variant missing: "
1544
+ f"{compressed_path.name}"))
1545
+ elif is_compressed:
1546
+ # Find expected uncompressed path
1547
+ uncompressed_path = Path(path_str.replace("/.agent-src/", "/.agent-src.uncompressed/"))
1548
+ if not uncompressed_path.exists():
1549
+ issues.append(Issue("warning", "uncompressed_variant_missing",
1550
+ f"Compressed file exists but uncompressed source missing: "
1551
+ f"{uncompressed_path.name}"))
1552
+
1553
+ # --- Check: file in correct location for type ---
1554
+ location_map = {
1555
+ "skill": "/skills/",
1556
+ "rule": "/rules/",
1557
+ "command": "/commands/",
1558
+ "guideline": "/guidelines/",
1559
+ }
1560
+ expected_loc = location_map.get(artifact_type)
1561
+ if expected_loc and expected_loc not in path_str:
1562
+ issues.append(Issue("warning", "invalid_location_for_type",
1563
+ f"Artifact detected as '{artifact_type}' but not in "
1564
+ f"expected location ({expected_loc})"))
1565
+
1566
+ return issues
1567
+
1568
+
1569
+ # --- Output-schema check (see road-to-trigger-evals Phase 3.5) ---
1570
+ #
1571
+ # Skills that freeze an output shape (`refine-ticket`, `estimate-ticket`)
1572
+ # ship an optional `evals/output-schema.yml` listing the `##`-headers
1573
+ # their output template MUST carry. The linter fails if a header drifts.
1574
+
1575
+ _OUTPUT_SCHEMA_KEY_PATTERN = re.compile(r'^(\w+):\s*(.*?)\s*$')
1576
+
1577
+
1578
+ def parse_output_schema(text: str) -> dict:
1579
+ """Tiny YAML-like parser for ``evals/output-schema.yml`` — no PyYAML dep.
1580
+
1581
+ Supported shape::
1582
+
1583
+ version: 1
1584
+ required_headers:
1585
+ - "Refined ticket"
1586
+ - "Top-5 risks"
1587
+
1588
+ Unknown keys are preserved but ignored by :func:`lint_output_schema`.
1589
+ """
1590
+ result: dict = {}
1591
+ current_list: Optional[str] = None
1592
+ for raw in text.splitlines():
1593
+ stripped = raw.strip()
1594
+ if not stripped or stripped.startswith("#"):
1595
+ continue
1596
+ if stripped.startswith("- "):
1597
+ if current_list is None:
1598
+ continue
1599
+ value = stripped[2:].strip().strip('"').strip("'")
1600
+ result[current_list].append(value)
1601
+ continue
1602
+ match = _OUTPUT_SCHEMA_KEY_PATTERN.match(stripped)
1603
+ if not match:
1604
+ continue
1605
+ key, value = match.group(1), match.group(2).strip('"').strip("'")
1606
+ if value == "":
1607
+ result[key] = []
1608
+ current_list = key
1609
+ else:
1610
+ current_list = None
1611
+ try:
1612
+ result[key] = int(value)
1613
+ except ValueError:
1614
+ result[key] = value
1615
+ return result
1616
+
1617
+
1618
+ def load_output_schema(skill_path: Path) -> Optional[dict]:
1619
+ """Return the parsed schema sibling to ``skill_path`` or ``None``.
1620
+
1621
+ Lookup: ``<skill-dir>/evals/output-schema.yml``. Callers MUST use the
1622
+ real path (not the repo-relative display path) so the sibling lookup
1623
+ hits the actual directory.
1624
+ """
1625
+ if skill_path.name != "SKILL.md":
1626
+ return None
1627
+ schema_path = skill_path.parent / "evals" / "output-schema.yml"
1628
+ if not schema_path.exists():
1629
+ return None
1630
+ try:
1631
+ return parse_output_schema(schema_path.read_text(encoding="utf-8"))
1632
+ except OSError:
1633
+ return None
1634
+
1635
+
1636
+ def lint_output_schema(path: Path, text: str) -> List[Issue]:
1637
+ """Fail if any required header declared in the sibling schema is
1638
+ missing from the skill's output template.
1639
+
1640
+ No-op when the schema file does not exist or declares no
1641
+ ``required_headers`` — keeps the check opt-in per skill.
1642
+ """
1643
+ schema = load_output_schema(path)
1644
+ if schema is None:
1645
+ return []
1646
+ required = schema.get("required_headers") or []
1647
+ if not isinstance(required, list) or not required:
1648
+ return []
1649
+ issues: List[Issue] = []
1650
+ # Scan the whole skill text. Template headers live inside a fenced
1651
+ # code block, but the `^## <header>$` line still matches — a drift
1652
+ # (rename/removal) makes the line disappear from the file entirely.
1653
+ for header in required:
1654
+ if not isinstance(header, str) or not header.strip():
1655
+ continue
1656
+ pattern = re.compile(
1657
+ rf"^##\s+{re.escape(header.strip())}\s*$", re.MULTILINE,
1658
+ )
1659
+ if not pattern.search(text):
1660
+ issues.append(Issue(
1661
+ "error", "output_schema_drift",
1662
+ f"Output template is missing required header "
1663
+ f"`## {header}` (declared in evals/output-schema.yml)",
1664
+ ))
1665
+ return issues
1666
+
1667
+
1668
+ # Artefact types that carry a JSON-Schema contract for their frontmatter.
1669
+ _SCHEMA_ARTEFACT_TYPES = {"skill", "rule", "command", "persona"}
1670
+
1671
+
1672
+ def lint_frontmatter_schema(path: Path, text: str, artifact_type: str) -> List[Issue]:
1673
+ """Validate the frontmatter of an artefact against its JSON-Schema.
1674
+
1675
+ Schemas live in ``scripts/schemas/``. One schema per artefact type;
1676
+ see ``agents/docs/frontmatter-contract.md`` for the human-readable
1677
+ contract the schemas encode. Guidelines have no frontmatter and are
1678
+ skipped.
1679
+ """
1680
+ if artifact_type not in _SCHEMA_ARTEFACT_TYPES:
1681
+ return []
1682
+ try:
1683
+ schema = load_schema(artifact_type)
1684
+ except FileNotFoundError:
1685
+ return []
1686
+
1687
+ data, _ = parse_frontmatter_for_schema(text)
1688
+ if data is None:
1689
+ # Other linter checks already emit a missing-frontmatter error for
1690
+ # rules/commands/personas; avoid double-reporting here.
1691
+ return []
1692
+
1693
+ issues: List[Issue] = []
1694
+ for error in validate_against_schema(data, schema):
1695
+ code = f"schema_{error.rule}"
1696
+ message = f"{error.path} – {error.message}"
1697
+ issues.append(Issue("error", code, message))
1698
+ return issues
1699
+
1700
+
1701
+ def lint_file(path: Path, repo_root: Path | None = None) -> LintResult:
1702
+ # Skip README files — they are not lintable artifacts
1703
+ if path.name.lower() == "readme.md":
1704
+ return LintResult(
1705
+ file=str(path),
1706
+ artifact_type="unknown",
1707
+ status="pass",
1708
+ issues=[],
1709
+ suggestions=[],
1710
+ )
1711
+ text = read_text(path)
1712
+ artifact_type = detect_artifact_type(path, text)
1713
+ # Use relative path for output if repo_root is provided
1714
+ display_path = path
1715
+ if repo_root:
1716
+ try:
1717
+ display_path = path.relative_to(repo_root)
1718
+ except ValueError:
1719
+ pass
1720
+ if artifact_type == "skill":
1721
+ result = lint_skill(display_path, text)
1722
+ elif artifact_type == "rule":
1723
+ result = lint_rule(display_path, text)
1724
+ elif artifact_type == "command":
1725
+ result = lint_command(display_path, text)
1726
+ elif artifact_type == "guideline":
1727
+ result = lint_guideline(display_path, text)
1728
+ elif artifact_type == "persona":
1729
+ result = lint_persona(display_path, text)
1730
+ else:
1731
+ return lint_unknown(display_path, text)
1732
+
1733
+ # Post-processing: frontmatter schema validation (errors). Runs first
1734
+ # so schema failures surface before the softer quality checks below.
1735
+ schema_issues = lint_frontmatter_schema(display_path, text, artifact_type)
1736
+ if schema_issues:
1737
+ result.issues.extend(schema_issues)
1738
+ result.status = classify_status(result.issues)
1739
+
1740
+ # Post-processing: interaction quality checks (warnings/info only)
1741
+ interaction_issues = lint_interaction_quality(display_path, text)
1742
+ if interaction_issues:
1743
+ result.issues.extend(interaction_issues)
1744
+ result.status = classify_status(result.issues)
1745
+
1746
+ # Post-processing: execution quality checks (errors/warnings)
1747
+ execution_issues = lint_execution_quality(display_path, text)
1748
+ if execution_issues:
1749
+ result.issues.extend(execution_issues)
1750
+ result.status = classify_status(result.issues)
1751
+
1752
+ # Post-processing: type boundary checks (warnings)
1753
+ boundary_issues = lint_type_boundaries(display_path, text, artifact_type)
1754
+ if boundary_issues:
1755
+ result.issues.extend(boundary_issues)
1756
+ result.status = classify_status(result.issues)
1757
+
1758
+ # Post-processing: verification maturity checks (warnings)
1759
+ maturity_issues = lint_verification_maturity(display_path, text, artifact_type)
1760
+ if maturity_issues:
1761
+ result.issues.extend(maturity_issues)
1762
+ result.status = classify_status(result.issues)
1763
+
1764
+ # Post-processing: governance and packaging checks (warnings)
1765
+ governance_issues = lint_governance(path, text, artifact_type, repo_root)
1766
+ if governance_issues:
1767
+ result.issues.extend(governance_issues)
1768
+ result.status = classify_status(result.issues)
1769
+
1770
+ # Post-processing: output-schema drift (errors). Skills only — schema
1771
+ # lookup walks a sibling `evals/` directory off the real SKILL.md.
1772
+ if artifact_type == "skill":
1773
+ schema_issues = lint_output_schema(path, text)
1774
+ if schema_issues:
1775
+ result.issues.extend(schema_issues)
1776
+ result.status = classify_status(result.issues)
1777
+
1778
+ return result
1779
+
1780
+
1781
+ def format_text(results: list[LintResult]) -> str:
1782
+ lines: list[str] = []
1783
+ for result in results:
1784
+ badge = {"pass": "[PASS]", "pass_with_warnings": "[WARN]", "fail": "[FAIL]"}[result.status]
1785
+ lines.append(f"{badge} {result.file} ({result.artifact_type})")
1786
+ if result.issues:
1787
+ for issue in result.issues:
1788
+ lines.append(f" - {issue.severity.upper()} {issue.code}: {issue.message}")
1789
+ else:
1790
+ lines.append(" - No issues found")
1791
+ if result.suggestions:
1792
+ lines.append(" Suggested fixes:")
1793
+ for suggestion in result.suggestions:
1794
+ lines.append(f" - {suggestion}")
1795
+ lines.append("")
1796
+
1797
+ total = len(results)
1798
+ fails = sum(1 for r in results if r.status == "fail")
1799
+ warns = sum(1 for r in results if r.status == "pass_with_warnings")
1800
+ passes = sum(1 for r in results if r.status == "pass")
1801
+ lines.append(f"Summary: {passes} pass, {warns} warn, {fails} fail, {total} total")
1802
+ return "\n".join(lines)
1803
+
1804
+
1805
+ def format_json(results: list[LintResult]) -> str:
1806
+ payload = {
1807
+ "summary": {
1808
+ "pass": sum(1 for r in results if r.status == "pass"),
1809
+ "pass_with_warnings": sum(1 for r in results if r.status == "pass_with_warnings"),
1810
+ "fail": sum(1 for r in results if r.status == "fail"),
1811
+ "total": len(results),
1812
+ },
1813
+ "results": [
1814
+ {
1815
+ "file": r.file,
1816
+ "artifact_type": r.artifact_type,
1817
+ "status": r.status,
1818
+ "issues": [asdict(issue) for issue in r.issues],
1819
+ "suggestions": r.suggestions,
1820
+ }
1821
+ for r in results
1822
+ ],
1823
+ }
1824
+ return json.dumps(payload, indent=2, ensure_ascii=False)
1825
+
1826
+
1827
+ def check_compression_pairs(root: Path) -> list[LintResult]:
1828
+ """Check that every uncompressed skill/rule/command has a compressed counterpart and vice versa."""
1829
+ results: list[LintResult] = []
1830
+
1831
+ pairs = [
1832
+ ("skills", "SKILL.md", True), # (subdir, filename, is_nested)
1833
+ ("rules", "*.md", False),
1834
+ ("commands", "*.md", False),
1835
+ ]
1836
+
1837
+ for subdir, pattern, is_nested in pairs:
1838
+ uncompressed_dir = root / ".agent-src.uncompressed" / subdir
1839
+ compressed_dir = root / ".agent-src" / subdir
1840
+
1841
+ if not uncompressed_dir.exists():
1842
+ continue
1843
+
1844
+ # Collect names from uncompressed
1845
+ if is_nested:
1846
+ uncompressed_names = {d.name for d in uncompressed_dir.iterdir() if d.is_dir() and (d / pattern).exists()}
1847
+ else:
1848
+ uncompressed_names = {f.name for f in uncompressed_dir.glob(pattern) if f.is_file()}
1849
+
1850
+ # Collect names from compressed
1851
+ if compressed_dir.exists():
1852
+ if is_nested:
1853
+ compressed_names = {d.name for d in compressed_dir.iterdir() if d.is_dir() and (d / pattern).exists()}
1854
+ else:
1855
+ compressed_names = {f.name for f in compressed_dir.glob(pattern) if f.is_file()}
1856
+ else:
1857
+ compressed_names = set()
1858
+
1859
+ # Missing compressed
1860
+ for name in sorted(uncompressed_names - compressed_names):
1861
+ path_str = f".agent-src/{subdir}/{name}/{pattern}" if is_nested else f".agent-src/{subdir}/{name}"
1862
+ results.append(LintResult(
1863
+ file=path_str,
1864
+ artifact_type=subdir.rstrip("s"),
1865
+ status="fail",
1866
+ issues=[Issue("error", "missing_compressed", f"Uncompressed exists but compressed version is missing")],
1867
+ suggestions=[f"Run /compress to generate .agent-src/{subdir}/{name}"],
1868
+ ))
1869
+
1870
+ # Orphaned compressed (no source)
1871
+ for name in sorted(compressed_names - uncompressed_names):
1872
+ path_str = f".agent-src/{subdir}/{name}/{pattern}" if is_nested else f".agent-src/{subdir}/{name}"
1873
+ results.append(LintResult(
1874
+ file=path_str,
1875
+ artifact_type=subdir.rstrip("s"),
1876
+ status="fail",
1877
+ issues=[Issue("error", "orphaned_compressed", f"Compressed exists but uncompressed source is missing")],
1878
+ suggestions=[f"Delete orphaned file or restore uncompressed source"],
1879
+ ))
1880
+
1881
+ return results
1882
+
1883
+
1884
+ def check_compression_quality(root: Path) -> list[LintResult]:
1885
+ """Check that compressed skills preserve key content from their uncompressed source."""
1886
+ results: list[LintResult] = []
1887
+ uncompressed_dir = root / ".agent-src.uncompressed" / "skills"
1888
+ compressed_dir = root / ".agent-src" / "skills"
1889
+
1890
+ if not uncompressed_dir.exists() or not compressed_dir.exists():
1891
+ return results
1892
+
1893
+ # Sections that MUST exist in compressed if they exist in uncompressed
1894
+ preserved_sections = ["When to use", "Procedure", "Gotcha", "Gotchas", "Do NOT", "Output format", "Output"]
1895
+
1896
+ for skill_dir in sorted(uncompressed_dir.iterdir()):
1897
+ src = skill_dir / "SKILL.md"
1898
+ dst = compressed_dir / skill_dir.name / "SKILL.md"
1899
+ if not src.exists() or not dst.exists():
1900
+ continue
1901
+
1902
+ src_text = read_text(src)
1903
+ dst_text = read_text(dst)
1904
+ src_sections = extract_sections(src_text)
1905
+ dst_sections = extract_sections(dst_text)
1906
+
1907
+ issues: list[Issue] = []
1908
+ suggestions: list[str] = []
1909
+
1910
+ # Check required sections survived compression
1911
+ for section in preserved_sections:
1912
+ if section_matches(section, src_sections) and not section_matches(section, dst_sections):
1913
+ issues.append(Issue("warning", "compression_lost_section",
1914
+ f"Compressed version lost '{section}' section"))
1915
+
1916
+ # Check validation keywords survived
1917
+ src_proc = find_procedure_block(src_text) or ""
1918
+ dst_proc = find_procedure_block(dst_text) or ""
1919
+ validation_patterns = [r"\bverif", r"\bcheck\b", r"\bconfirm\b", r"\bvalidat", r"\binspect"]
1920
+ src_has_validation = any(re.search(p, src_proc, re.IGNORECASE) for p in validation_patterns)
1921
+ dst_has_validation = any(re.search(p, dst_proc, re.IGNORECASE) for p in validation_patterns)
1922
+ if src_has_validation and not dst_has_validation:
1923
+ issues.append(Issue("warning", "compression_lost_validation",
1924
+ "Compressed procedure lost validation keywords present in uncompressed"))
1925
+
1926
+ # Check code blocks / examples survived
1927
+ src_code_blocks = len(re.findall(r"```", src_text)) # pairs of ``` = blocks
1928
+ dst_code_blocks = len(re.findall(r"```", dst_text))
1929
+ if src_code_blocks > 0 and dst_code_blocks < src_code_blocks // 2:
1930
+ issues.append(Issue("warning", "compression_lost_example",
1931
+ f"Compressed version has fewer code blocks "
1932
+ f"({dst_code_blocks // 2} vs {src_code_blocks // 2} in source)"))
1933
+
1934
+ # Check anti-pattern / "Do NOT" bullets survived
1935
+ src_donot = len(re.findall(r"(?:Do NOT|NEVER|MUST NOT)\b", src_text))
1936
+ dst_donot = len(re.findall(r"(?:Do NOT|NEVER|MUST NOT)\b", dst_text))
1937
+ if src_donot > 0 and dst_donot < src_donot // 2:
1938
+ issues.append(Issue("warning", "compression_lost_antipattern",
1939
+ f"Compressed version lost anti-pattern constraints "
1940
+ f"({dst_donot} vs {src_donot} in source)"))
1941
+
1942
+ if issues:
1943
+ rel_path = f".agent-src/skills/{skill_dir.name}/SKILL.md"
1944
+ results.append(LintResult(
1945
+ file=rel_path,
1946
+ artifact_type="skill",
1947
+ status="pass_with_warnings",
1948
+ issues=issues,
1949
+ suggestions=suggestions or ["Re-compress to preserve lost content"],
1950
+ ))
1951
+
1952
+ return results
1953
+
1954
+
1955
+ def check_duplication(root: Path) -> list[LintResult]:
1956
+ """Detect skills with highly similar names or descriptions."""
1957
+ results: list[LintResult] = []
1958
+ skills_dir = root / ".agent-src.uncompressed" / "skills"
1959
+ if not skills_dir.exists():
1960
+ return results
1961
+
1962
+ # Collect all skill names and descriptions
1963
+ skill_data: list[tuple[str, str, Path]] = []
1964
+ for skill_dir in sorted(skills_dir.iterdir()):
1965
+ skill_file = skill_dir / "SKILL.md"
1966
+ if not skill_file.exists():
1967
+ continue
1968
+ text = read_text(skill_file)
1969
+ desc = extract_description(text) or ""
1970
+ skill_data.append((skill_dir.name, desc.lower(), skill_file))
1971
+
1972
+ # Check for name prefix overlap (e.g. "laravel" and "laravel-validation")
1973
+ # Only flag if descriptions are also similar
1974
+ for i, (name_a, desc_a, path_a) in enumerate(skill_data):
1975
+ for name_b, desc_b, path_b in skill_data[i + 1:]:
1976
+ # Skip known patterns: skill-X and skill-X-subtype is intentional
1977
+ if name_a == name_b:
1978
+ continue
1979
+ # Check description word overlap
1980
+ if desc_a and desc_b:
1981
+ words_a = set(desc_a.split())
1982
+ words_b = set(desc_b.split())
1983
+ if len(words_a) > 3 and len(words_b) > 3:
1984
+ overlap = len(words_a & words_b) / min(len(words_a), len(words_b))
1985
+ if overlap > 0.7:
1986
+ rel_a = f".agent-src.uncompressed/skills/{name_a}/SKILL.md"
1987
+ results.append(LintResult(
1988
+ file=rel_a,
1989
+ artifact_type="skill",
1990
+ status="pass_with_warnings",
1991
+ issues=[Issue("warning", "similar_description",
1992
+ f"Description highly similar to '{name_b}' ({overlap:.0%} word overlap)")],
1993
+ suggestions=[f"Consider merging with '{name_b}' or differentiating descriptions"],
1994
+ ))
1995
+
1996
+ return results
1997
+
1998
+
1999
+ def compute_exit_code(results: list[LintResult], strict_warnings: bool) -> int:
2000
+ if any(r.status == "fail" for r in results):
2001
+ return 2
2002
+ if any(r.status == "pass_with_warnings" for r in results) and strict_warnings:
2003
+ return 1
2004
+ return 0
2005
+
2006
+
2007
+ def parse_args() -> argparse.Namespace:
2008
+ parser = argparse.ArgumentParser(description="Lint skills and rules.")
2009
+ parser.add_argument("paths", nargs="*", help="Files to lint")
2010
+ parser.add_argument("--all", action="store_true", help="Lint all skills/rules in the repo")
2011
+ parser.add_argument("--changed", action="store_true", help="Lint changed skills/rules")
2012
+ parser.add_argument("--format", choices=["text", "json"], default="text", help="Output format")
2013
+ parser.add_argument("--pairs", action="store_true", help="Check compression pairs (uncompressed vs compressed)")
2014
+ parser.add_argument("--duplicates", action="store_true", help="Detect skills with similar descriptions")
2015
+ parser.add_argument("--compression-quality", action="store_true", help="Check compressed skills preserve key content")
2016
+ parser.add_argument("--strict-warnings", action="store_true", help="Return non-zero on warnings")
2017
+ parser.add_argument("--report", action="store_true", help="Output quality score report")
2018
+ parser.add_argument("--repo-root", default=".", help="Repository root")
2019
+ return parser.parse_args()
2020
+
2021
+
2022
+ def format_report(results: list[LintResult]) -> str:
2023
+ """Generate a quality score report grouped by artifact type."""
2024
+ lines = ["# Quality Report", ""]
2025
+
2026
+ # Group by artifact type
2027
+ by_type: dict[str, list[LintResult]] = {}
2028
+ for r in results:
2029
+ by_type.setdefault(r.artifact_type, []).append(r)
2030
+
2031
+ # Summary table
2032
+ lines.append("| Type | Total | Pass | Warn | Fail | Score |")
2033
+ lines.append("|---|---|---|---|---|---|")
2034
+ total_score = 0.0
2035
+ total_count = 0
2036
+ for atype in sorted(by_type):
2037
+ items = by_type[atype]
2038
+ n = len(items)
2039
+ n_pass = sum(1 for r in items if r.status == "pass")
2040
+ n_warn = sum(1 for r in items if r.status in ("warn", "pass_with_warnings"))
2041
+ n_fail = sum(1 for r in items if r.status == "fail")
2042
+ # Score: pass=10, warn=8, fail=3
2043
+ type_score = (n_pass * 10 + n_warn * 8 + n_fail * 3) / max(n, 1)
2044
+ total_score += type_score * n
2045
+ total_count += n
2046
+ lines.append(f"| {atype} | {n} | {n_pass} | {n_warn} | {n_fail} | {type_score:.1f}/10 |")
2047
+ overall = total_score / max(total_count, 1)
2048
+ lines.append(f"| **TOTAL** | **{total_count}** | | | | **{overall:.1f}/10** |")
2049
+
2050
+ # Top issues
2051
+ issue_counts: dict[str, int] = {}
2052
+ for r in results:
2053
+ for i in r.issues:
2054
+ issue_counts[i.code] = issue_counts.get(i.code, 0) + 1
2055
+ if issue_counts:
2056
+ lines.extend(["", "## Top Issues", ""])
2057
+ lines.append("| Issue | Count | Severity |")
2058
+ lines.append("|---|---|---|")
2059
+ for code, count in sorted(issue_counts.items(), key=lambda x: -x[1])[:15]:
2060
+ # Find severity from first occurrence
2061
+ sev = "?"
2062
+ for r in results:
2063
+ for i in r.issues:
2064
+ if i.code == code:
2065
+ sev = i.severity
2066
+ break
2067
+ if sev != "?":
2068
+ break
2069
+ lines.append(f"| `{code}` | {count} | {sev} |")
2070
+
2071
+ # Files with most issues (top 10)
2072
+ files_with_issues = [
2073
+ (r.file, len(r.issues), r.status)
2074
+ for r in results
2075
+ if r.issues
2076
+ ]
2077
+ files_with_issues.sort(key=lambda x: -x[1])
2078
+ if files_with_issues:
2079
+ lines.extend(["", "## Files with Most Issues (Top 10)", ""])
2080
+ lines.append("| File | Issues | Status |")
2081
+ lines.append("|---|---|---|")
2082
+ for fpath, count, status in files_with_issues[:10]:
2083
+ short = fpath.replace(".agent-src.uncompressed/", "")
2084
+ lines.append(f"| `{short}` | {count} | {status} |")
2085
+
2086
+ # Per-file quality breakdown (skills only)
2087
+ skill_results = [r for r in results if r.artifact_type == "skill" and "/pair-check/" not in r.file]
2088
+ if skill_results:
2089
+ lines.extend(["", "## Per-File Quality (Skills)", ""])
2090
+ lines.append("| Skill | Structure | Validation | Scope | Dependency | Lines |")
2091
+ lines.append("|---|---|---|---|---|---|")
2092
+ for r in sorted(skill_results, key=lambda x: x.file):
2093
+ short = r.file.replace(".agent-src.uncompressed/skills/", "").replace(".agent-src/skills/", "").replace("/SKILL.md", "")
2094
+ codes = {i.code for i in r.issues}
2095
+
2096
+ # Structure: fail if missing required sections
2097
+ struct = "❌" if codes & {"missing_section", "empty_procedure", "unordered_procedure"} else "✅"
2098
+
2099
+ # Validation: weak if missing or vague
2100
+ if codes & {"missing_validation", "vague_validation"}:
2101
+ valid = "❌ weak"
2102
+ elif codes & {"missing_inspect_step"}:
2103
+ valid = "⚠️ partial"
2104
+ else:
2105
+ valid = "✅ strong"
2106
+
2107
+ # Scope: broad if flagged
2108
+ scope = "⚠️ broad" if "broad_scope" in codes else "✅ focused"
2109
+
2110
+ # Guideline dependency
2111
+ if "guideline_dependent_skill" in codes:
2112
+ dep = "❌ high"
2113
+ elif "pointer_only_skill" in codes:
2114
+ dep = "⚠️ medium"
2115
+ else:
2116
+ dep = "✅ low"
2117
+
2118
+ # Line count
2119
+ total_lines = 0
2120
+ try:
2121
+ total_lines = Path(r.file).read_text(encoding="utf-8").count("\n")
2122
+ except OSError:
2123
+ pass
2124
+
2125
+ lines.append(f"| `{short}` | {struct} | {valid} | {scope} | {dep} | {total_lines} |")
2126
+
2127
+ return "\n".join(lines)
2128
+
2129
+
2130
+ def main() -> int:
2131
+ args = parse_args()
2132
+ root = Path(args.repo_root).resolve()
2133
+
2134
+ try:
2135
+ paths: list[Path] = []
2136
+ if args.all or args.report:
2137
+ paths.extend(gather_all_candidate_files(root))
2138
+ if args.changed:
2139
+ paths.extend(gather_changed_candidate_files(root))
2140
+ for raw in args.paths:
2141
+ path = (root / raw).resolve() if not Path(raw).is_absolute() else Path(raw)
2142
+ if path.exists():
2143
+ paths.append(path)
2144
+
2145
+ paths = sorted(set(paths))
2146
+ if not paths:
2147
+ print("No matching skill/rule files found.", file=sys.stderr)
2148
+ return 0
2149
+
2150
+ results = [lint_file(path, repo_root=root) for path in paths]
2151
+
2152
+ # Additional checks
2153
+ if args.pairs or args.report:
2154
+ results.extend(check_compression_pairs(root))
2155
+ if args.duplicates:
2156
+ results.extend(check_duplication(root))
2157
+ if args.compression_quality or args.report:
2158
+ results.extend(check_compression_quality(root))
2159
+
2160
+ if args.report:
2161
+ print(format_report(results))
2162
+ elif args.format == "json":
2163
+ print(format_json(results))
2164
+ else:
2165
+ print(format_text(results))
2166
+
2167
+ return compute_exit_code(results, strict_warnings=args.strict_warnings)
2168
+
2169
+ except Exception as exc: # noqa: BLE001
2170
+ print(f"Internal error: {exc}", file=sys.stderr)
2171
+ return 3
2172
+
2173
+
2174
+ if __name__ == "__main__":
2175
+ raise SystemExit(main())