@event4u/agent-config 1.13.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (291) hide show
  1. package/.agent-src/commands/agent-handoff.md +4 -1
  2. package/.agent-src/commands/agent-status.md +3 -0
  3. package/.agent-src/commands/agents-audit.md +4 -0
  4. package/.agent-src/commands/agents-cleanup.md +6 -1
  5. package/.agent-src/commands/agents-prepare.md +3 -0
  6. package/.agent-src/commands/analyze-reference-repo.md +4 -0
  7. package/.agent-src/commands/bug-fix.md +7 -3
  8. package/.agent-src/commands/bug-investigate.md +4 -0
  9. package/.agent-src/commands/chat-history-checkpoint.md +126 -0
  10. package/.agent-src/commands/chat-history-clear.md +6 -1
  11. package/.agent-src/commands/chat-history-resume.md +7 -2
  12. package/.agent-src/commands/chat-history.md +7 -2
  13. package/.agent-src/commands/check-current-md.md +137 -0
  14. package/.agent-src/commands/commit-in-chunks.md +118 -0
  15. package/.agent-src/commands/commit.md +4 -0
  16. package/.agent-src/commands/compress.md +37 -2
  17. package/.agent-src/commands/context-create.md +4 -0
  18. package/.agent-src/commands/context-refactor.md +4 -0
  19. package/.agent-src/commands/copilot-agents-init.md +3 -0
  20. package/.agent-src/commands/copilot-agents-optimize.md +3 -0
  21. package/.agent-src/commands/create-pr-description.md +4 -0
  22. package/.agent-src/commands/create-pr.md +4 -0
  23. package/.agent-src/commands/do-and-judge.md +4 -1
  24. package/.agent-src/commands/do-in-steps.md +3 -0
  25. package/.agent-src/commands/e2e-heal.md +4 -0
  26. package/.agent-src/commands/e2e-plan.md +4 -0
  27. package/.agent-src/commands/estimate-ticket.md +4 -1
  28. package/.agent-src/commands/feature-dev.md +4 -0
  29. package/.agent-src/commands/feature-explore.md +4 -0
  30. package/.agent-src/commands/feature-plan.md +4 -0
  31. package/.agent-src/commands/feature-refactor.md +4 -0
  32. package/.agent-src/commands/feature-roadmap.md +6 -0
  33. package/.agent-src/commands/fix-ci.md +4 -0
  34. package/.agent-src/commands/fix-portability.md +5 -2
  35. package/.agent-src/commands/fix-pr-bot-comments.md +4 -0
  36. package/.agent-src/commands/fix-pr-comments.md +4 -0
  37. package/.agent-src/commands/fix-pr-developer-comments.md +4 -0
  38. package/.agent-src/commands/fix-references.md +3 -0
  39. package/.agent-src/commands/fix-seeder.md +4 -0
  40. package/.agent-src/commands/implement-ticket.md +39 -13
  41. package/.agent-src/commands/jira-ticket.md +4 -0
  42. package/.agent-src/commands/judge.md +3 -0
  43. package/.agent-src/commands/memory-add.md +5 -3
  44. package/.agent-src/commands/memory-full.md +5 -2
  45. package/.agent-src/commands/memory-promote.md +7 -6
  46. package/.agent-src/commands/mode.md +3 -0
  47. package/.agent-src/commands/module-create.md +4 -0
  48. package/.agent-src/commands/module-explore.md +4 -0
  49. package/.agent-src/commands/onboard.md +33 -0
  50. package/.agent-src/commands/optimize-agents.md +4 -0
  51. package/.agent-src/commands/optimize-augmentignore.md +12 -0
  52. package/.agent-src/commands/optimize-rtk-filters.md +3 -0
  53. package/.agent-src/commands/optimize-skills.md +4 -0
  54. package/.agent-src/commands/override-create.md +4 -0
  55. package/.agent-src/commands/override-manage.md +4 -0
  56. package/.agent-src/commands/package-reset.md +3 -0
  57. package/.agent-src/commands/package-test.md +3 -0
  58. package/.agent-src/commands/prepare-for-review.md +4 -0
  59. package/.agent-src/commands/project-analyze.md +4 -0
  60. package/.agent-src/commands/project-health.md +4 -0
  61. package/.agent-src/commands/propose-memory.md +6 -8
  62. package/.agent-src/commands/quality-fix.md +4 -0
  63. package/.agent-src/commands/refine-ticket.md +12 -7
  64. package/.agent-src/commands/review-changes.md +39 -8
  65. package/.agent-src/commands/review-routing.md +4 -0
  66. package/.agent-src/commands/roadmap-create.md +18 -0
  67. package/.agent-src/commands/roadmap-execute.md +14 -1
  68. package/.agent-src/commands/rule-compliance-audit.md +4 -0
  69. package/.agent-src/commands/set-cost-profile.md +11 -0
  70. package/.agent-src/commands/sync-agent-settings.md +12 -0
  71. package/.agent-src/commands/sync-gitignore.md +3 -0
  72. package/.agent-src/commands/tests-create.md +4 -0
  73. package/.agent-src/commands/tests-execute.md +6 -3
  74. package/.agent-src/commands/threat-model.md +4 -0
  75. package/.agent-src/commands/update-form-request-messages.md +4 -0
  76. package/.agent-src/commands/upstream-contribute.md +4 -0
  77. package/.agent-src/commands/work.md +161 -0
  78. package/.agent-src/guidelines/agent-infra/engineering-memory-data-format.md +2 -6
  79. package/.agent-src/guidelines/agent-infra/layered-settings.md +0 -1
  80. package/.agent-src/guidelines/agent-infra/memory-access.md +0 -7
  81. package/.agent-src/guidelines/agent-infra/role-contracts.md +2 -4
  82. package/.agent-src/guidelines/agent-infra/self-improvement-pipeline.md +0 -1
  83. package/.agent-src/guidelines/php/patterns/strategy.md +180 -2
  84. package/.agent-src/personas/README.md +0 -1
  85. package/.agent-src/rules/artifact-drafting-protocol.md +7 -2
  86. package/.agent-src/rules/artifact-engagement-recording.md +133 -0
  87. package/.agent-src/rules/ask-when-uncertain.md +18 -13
  88. package/.agent-src/rules/augment-portability.md +64 -37
  89. package/.agent-src/rules/autonomous-execution.md +158 -0
  90. package/.agent-src/rules/chat-history-cadence.md +109 -0
  91. package/.agent-src/rules/chat-history-ownership.md +123 -0
  92. package/.agent-src/rules/chat-history-visibility.md +96 -0
  93. package/.agent-src/rules/cli-output-handling.md +27 -4
  94. package/.agent-src/rules/command-suggestion.md +134 -0
  95. package/.agent-src/rules/commit-policy.md +109 -0
  96. package/.agent-src/rules/direct-answers.md +114 -0
  97. package/.agent-src/rules/docs-sync.md +36 -0
  98. package/.agent-src/rules/downstream-changes.md +10 -9
  99. package/.agent-src/rules/improve-before-implement.md +9 -6
  100. package/.agent-src/rules/language-and-tone.md +85 -6
  101. package/.agent-src/rules/non-destructive-by-default.md +117 -0
  102. package/.agent-src/rules/package-ci-checks.md +4 -0
  103. package/.agent-src/rules/preservation-guard.md +20 -0
  104. package/.agent-src/rules/roadmap-progress-sync.md +159 -27
  105. package/.agent-src/rules/role-mode-adherence.md +1 -1
  106. package/.agent-src/rules/scope-control.md +42 -1
  107. package/.agent-src/rules/size-enforcement.md +2 -3
  108. package/.agent-src/rules/skill-quality.md +3 -8
  109. package/.agent-src/rules/ui-audit-before-build.md +106 -0
  110. package/.agent-src/rules/user-interaction.md +107 -51
  111. package/.agent-src/scripts/update_roadmap_progress.py +73 -9
  112. package/.agent-src/skills/blade-ui/SKILL.md +47 -3
  113. package/.agent-src/skills/command-routing/SKILL.md +32 -0
  114. package/.agent-src/skills/command-writing/SKILL.md +52 -2
  115. package/.agent-src/skills/description-assist/SKILL.md +21 -0
  116. package/.agent-src/skills/estimate-ticket/SKILL.md +0 -1
  117. package/.agent-src/skills/existing-ui-audit/SKILL.md +202 -0
  118. package/.agent-src/skills/fe-design/SKILL.md +78 -61
  119. package/.agent-src/skills/file-editor/SKILL.md +9 -0
  120. package/.agent-src/skills/finishing-a-development-branch/SKILL.md +4 -0
  121. package/.agent-src/skills/flux/SKILL.md +31 -4
  122. package/.agent-src/skills/guideline-writing/SKILL.md +24 -2
  123. package/.agent-src/skills/learning-to-rule-or-skill/SKILL.md +51 -9
  124. package/.agent-src/skills/livewire/SKILL.md +49 -4
  125. package/.agent-src/skills/md-language-check/SKILL.md +103 -0
  126. package/.agent-src/skills/php-coder/SKILL.md +24 -0
  127. package/.agent-src/skills/react-shadcn-ui/SKILL.md +121 -0
  128. package/.agent-src/skills/refine-prompt/SKILL.md +220 -0
  129. package/.agent-src/skills/refine-ticket/SKILL.md +32 -28
  130. package/.agent-src/skills/roadmap-management/SKILL.md +24 -11
  131. package/.agent-src/skills/rule-writing/SKILL.md +23 -1
  132. package/.agent-src/skills/skill-writing/SKILL.md +3 -5
  133. package/.agent-src/skills/upstream-contribute/SKILL.md +3 -3
  134. package/.agent-src/skills/using-git-worktrees/SKILL.md +3 -1
  135. package/.agent-src/templates/AGENTS.md +24 -6
  136. package/.agent-src/templates/agent-settings.md +149 -0
  137. package/.agent-src/templates/roadmaps.md +11 -4
  138. package/.agent-src/templates/scripts/implement_ticket/__init__.py +63 -26
  139. package/.agent-src/templates/scripts/implement_ticket/__main__.py +8 -2
  140. package/.agent-src/templates/scripts/memory_lookup.py +1 -1
  141. package/.agent-src/templates/scripts/telemetry/__init__.py +42 -0
  142. package/.agent-src/templates/scripts/telemetry/aggregator.py +154 -0
  143. package/.agent-src/templates/scripts/telemetry/boundary.py +171 -0
  144. package/.agent-src/templates/scripts/telemetry/engagement.py +238 -0
  145. package/.agent-src/templates/scripts/telemetry/report_renderer.py +170 -0
  146. package/.agent-src/templates/scripts/telemetry/settings.py +112 -0
  147. package/.agent-src/templates/scripts/telemetry_record.py +166 -0
  148. package/.agent-src/templates/scripts/telemetry_report.py +161 -0
  149. package/.agent-src/templates/scripts/telemetry_status.py +142 -0
  150. package/.agent-src/templates/scripts/work_engine/__init__.py +58 -0
  151. package/.agent-src/templates/scripts/work_engine/__main__.py +9 -0
  152. package/.agent-src/templates/scripts/work_engine/cli.py +195 -0
  153. package/.agent-src/templates/scripts/work_engine/cli_args.py +116 -0
  154. package/.agent-src/templates/scripts/{implement_ticket → work_engine}/delivery_state.py +10 -3
  155. package/.agent-src/templates/scripts/work_engine/directives/__init__.py +33 -0
  156. package/.agent-src/templates/scripts/work_engine/directives/backend/__init__.py +98 -0
  157. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/analyze.py +1 -1
  158. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/implement.py +3 -3
  159. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/memory.py +2 -2
  160. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/plan.py +2 -2
  161. package/.agent-src/templates/scripts/work_engine/directives/backend/refine.py +396 -0
  162. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/report.py +37 -5
  163. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/test.py +2 -2
  164. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/verify.py +2 -2
  165. package/.agent-src/templates/scripts/work_engine/directives/mixed/__init__.py +116 -0
  166. package/.agent-src/templates/scripts/work_engine/directives/mixed/contract.py +254 -0
  167. package/.agent-src/templates/scripts/work_engine/directives/mixed/stitch.py +229 -0
  168. package/.agent-src/templates/scripts/work_engine/directives/mixed/ui.py +231 -0
  169. package/.agent-src/templates/scripts/work_engine/directives/ui/__init__.py +113 -0
  170. package/.agent-src/templates/scripts/work_engine/directives/ui/_passthrough.py +44 -0
  171. package/.agent-src/templates/scripts/work_engine/directives/ui/apply.py +241 -0
  172. package/.agent-src/templates/scripts/work_engine/directives/ui/audit.py +414 -0
  173. package/.agent-src/templates/scripts/work_engine/directives/ui/design.py +335 -0
  174. package/.agent-src/templates/scripts/work_engine/directives/ui/polish.py +510 -0
  175. package/.agent-src/templates/scripts/work_engine/directives/ui/review.py +468 -0
  176. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/__init__.py +119 -0
  177. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/_skipped.py +37 -0
  178. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/apply.py +165 -0
  179. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/refine.py +66 -0
  180. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/report.py +62 -0
  181. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/test.py +115 -0
  182. package/.agent-src/templates/scripts/work_engine/dispatcher.py +331 -0
  183. package/.agent-src/templates/scripts/work_engine/emitters.py +43 -0
  184. package/.agent-src/templates/scripts/work_engine/errors.py +19 -0
  185. package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +76 -0
  186. package/.agent-src/templates/scripts/work_engine/hooks/__init__.py +54 -0
  187. package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +32 -0
  188. package/.agent-src/templates/scripts/work_engine/hooks/builtin/_chat_history_base.py +103 -0
  189. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_append.py +44 -0
  190. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_halt_append.py +42 -0
  191. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_heartbeat.py +50 -0
  192. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_turn_check.py +49 -0
  193. package/.agent-src/templates/scripts/work_engine/hooks/builtin/directive_set_guard.py +53 -0
  194. package/.agent-src/templates/scripts/work_engine/hooks/builtin/halt_surface_audit.py +50 -0
  195. package/.agent-src/templates/scripts/work_engine/hooks/builtin/state_shape_validation.py +52 -0
  196. package/.agent-src/templates/scripts/work_engine/hooks/builtin/trace.py +84 -0
  197. package/.agent-src/templates/scripts/work_engine/hooks/context.py +66 -0
  198. package/.agent-src/templates/scripts/work_engine/hooks/events.py +44 -0
  199. package/.agent-src/templates/scripts/work_engine/hooks/exceptions.py +79 -0
  200. package/.agent-src/templates/scripts/work_engine/hooks/registry.py +60 -0
  201. package/.agent-src/templates/scripts/work_engine/hooks/runner.py +73 -0
  202. package/.agent-src/templates/scripts/work_engine/hooks/settings.py +141 -0
  203. package/.agent-src/templates/scripts/work_engine/input_builders.py +163 -0
  204. package/.agent-src/templates/scripts/work_engine/intent/__init__.py +47 -0
  205. package/.agent-src/templates/scripts/work_engine/intent/classify.py +280 -0
  206. package/.agent-src/templates/scripts/work_engine/migration/__init__.py +8 -0
  207. package/.agent-src/templates/scripts/work_engine/migration/v0_to_v1.py +231 -0
  208. package/.agent-src/templates/scripts/{implement_ticket → work_engine}/persona_policy.py +1 -1
  209. package/.agent-src/templates/scripts/work_engine/resolvers/__init__.py +22 -0
  210. package/.agent-src/templates/scripts/work_engine/resolvers/diff.py +106 -0
  211. package/.agent-src/templates/scripts/work_engine/resolvers/file.py +113 -0
  212. package/.agent-src/templates/scripts/work_engine/resolvers/prompt.py +90 -0
  213. package/.agent-src/templates/scripts/work_engine/scoring/__init__.py +14 -0
  214. package/.agent-src/templates/scripts/work_engine/scoring/confidence.py +300 -0
  215. package/.agent-src/templates/scripts/work_engine/stack/__init__.py +31 -0
  216. package/.agent-src/templates/scripts/work_engine/stack/detect.py +187 -0
  217. package/.agent-src/templates/scripts/work_engine/state.py +641 -0
  218. package/.agent-src/templates/scripts/work_engine/state_io.py +202 -0
  219. package/.claude-plugin/marketplace.json +105 -2
  220. package/AGENTS.md +38 -8
  221. package/CHANGELOG.md +609 -0
  222. package/README.md +136 -14
  223. package/config/agent-settings.template.yml +45 -0
  224. package/config/gitignore-block.txt +4 -0
  225. package/docs/MIGRATION.md +122 -0
  226. package/docs/architecture.md +111 -35
  227. package/docs/contracts/STABILITY.md +95 -0
  228. package/docs/contracts/adr-chat-history-split.md +132 -0
  229. package/docs/contracts/adr-command-suggestion.md +146 -0
  230. package/docs/contracts/adr-implement-ticket-runtime.md +122 -0
  231. package/docs/contracts/adr-product-ui-track.md +384 -0
  232. package/docs/contracts/adr-prompt-driven-execution.md +187 -0
  233. package/docs/contracts/agent-memory-contract.md +149 -0
  234. package/docs/contracts/artifact-engagement-flow.md +262 -0
  235. package/docs/contracts/command-clusters.md +126 -0
  236. package/docs/contracts/command-suggestion-flow.md +148 -0
  237. package/docs/contracts/implement-ticket-flow.md +628 -0
  238. package/docs/contracts/linear-ai-rules-inclusion.md +143 -0
  239. package/docs/contracts/linear-ai-three-layers.md +131 -0
  240. package/docs/contracts/rule-interactions.md +107 -0
  241. package/docs/contracts/rule-interactions.yml +142 -0
  242. package/docs/contracts/ui-stack-extension.md +236 -0
  243. package/docs/contracts/ui-track-flow.md +338 -0
  244. package/docs/development.md +1 -1
  245. package/docs/getting-started.md +3 -3
  246. package/docs/installation.md +124 -2
  247. package/docs/migrations/commands-1.15.0.md +112 -0
  248. package/docs/showcase.md +204 -0
  249. package/docs/ui-track-mental-model.md +121 -0
  250. package/package.json +1 -1
  251. package/scripts/agent-config +199 -0
  252. package/scripts/audit_cloud_compatibility.py +288 -0
  253. package/scripts/build_cloud_bundle.py +458 -0
  254. package/scripts/build_linear_digest.py +263 -0
  255. package/scripts/chat_history.py +796 -7
  256. package/scripts/check_compression.py +139 -0
  257. package/scripts/check_iron_law_prominence.py +143 -0
  258. package/scripts/check_md_language.py +159 -0
  259. package/scripts/check_portability.py +38 -0
  260. package/scripts/check_public_links.py +185 -0
  261. package/scripts/check_references.py +1 -0
  262. package/scripts/check_reply_consistency.py +140 -0
  263. package/scripts/command_suggester/__init__.py +51 -0
  264. package/scripts/command_suggester/cooldown.py +132 -0
  265. package/scripts/command_suggester/loader.py +70 -0
  266. package/scripts/command_suggester/match.py +180 -0
  267. package/scripts/command_suggester/rank.py +120 -0
  268. package/scripts/command_suggester/render.py +86 -0
  269. package/scripts/command_suggester/sanitize.py +113 -0
  270. package/scripts/command_suggester/settings.py +125 -0
  271. package/scripts/command_suggester/types.py +78 -0
  272. package/scripts/hooks/augment-chat-history.sh +56 -0
  273. package/scripts/install-hooks.sh +67 -0
  274. package/scripts/install.py +150 -33
  275. package/scripts/lint_marketplace.py +27 -0
  276. package/scripts/lint_no_new_atomic_commands.py +179 -0
  277. package/scripts/lint_rule_interactions.py +149 -0
  278. package/scripts/memory_lookup.py +1 -1
  279. package/scripts/migrate_command_suggestions.py +151 -0
  280. package/scripts/release.py +297 -64
  281. package/scripts/schemas/command.schema.json +41 -0
  282. package/scripts/skill_linter.py +81 -0
  283. package/scripts/sync_agent_settings.py +42 -12
  284. package/scripts/update_counts.py +10 -0
  285. package/templates/consumer-settings/augment-cli-hooks.json +54 -0
  286. package/templates/consumer-settings/claude-settings.json +55 -1
  287. package/.agent-src/rules/chat-history.md +0 -171
  288. package/.agent-src/templates/scripts/implement_ticket/cli.py +0 -171
  289. package/.agent-src/templates/scripts/implement_ticket/dispatcher.py +0 -134
  290. package/.agent-src/templates/scripts/implement_ticket/steps/__init__.py +0 -49
  291. package/.agent-src/templates/scripts/implement_ticket/steps/refine.py +0 -140
@@ -0,0 +1,180 @@
1
+ """Score eligible commands against a user message + recent context.
2
+
3
+ Deterministic, no ML, no third-party deps. Two signals combine into
4
+ a 0.0–1.0 score:
5
+
6
+ * **Description match** — strongest single signal.
7
+ - Long phrase substring (≥ 10 chars) → 0.65.
8
+ - Short phrase substring (6–9 chars) → 0.4.
9
+ - Otherwise content-word overlap (≥ 4-char tokens, stop-words
10
+ stripped) scaled to 0.4.
11
+ * **Context match** — supporting evidence.
12
+ - Structural pattern (ticket key, file path, glob) co-occurring
13
+ in the message → 0.5.
14
+ - Otherwise content-word overlap scaled to 0.3.
15
+
16
+ Total `score = min(1.0, description_score + context_score)`. A long
17
+ phrase hit alone clears the default 0.6 floor; structural patterns
18
+ alone do not (anti-noise) — they need a description signal too.
19
+ A `Match.has_structural_bonus` flag lets the ranker know when a
20
+ short, otherwise-ambiguous prompt is actually specific (ticket
21
+ keys, paths) so it can override vague-input suppression.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import re
26
+ from typing import Iterable
27
+
28
+ from .sanitize import sanitize_context, sanitize_message
29
+ from .types import CommandSpec, Match
30
+
31
+ _TICKET_RE = re.compile(r"[A-Z][A-Z0-9]+-\d+")
32
+ _WORD_RE = re.compile(r"[A-Za-z][A-Za-z0-9_-]{3,}")
33
+ _PATH_RE = re.compile(r"[A-Za-z0-9_./-]+/[A-Za-z0-9_.*-]+")
34
+ _STOPWORDS: frozenset[str] = frozenset({
35
+ "this", "that", "with", "from", "have", "what", "when", "they",
36
+ "them", "into", "would", "could", "should", "about", "there",
37
+ "these", "those", "their", "your", "mine", "ours", "yours",
38
+ "show", "tell", "make", "want", "need", "like", "just", "some",
39
+ "many", "more", "most", "less", "than", "then", "also", "very",
40
+ })
41
+
42
+
43
+ def _tokens(text: str) -> set[str]:
44
+ return {
45
+ w.lower() for w in _WORD_RE.findall(text or "")
46
+ if w.lower() not in _STOPWORDS
47
+ }
48
+
49
+
50
+ def _phrases(trigger_description: str) -> list[str]:
51
+ return [
52
+ p.strip().lower() for p in (trigger_description or "").split(",")
53
+ if p.strip()
54
+ ]
55
+
56
+
57
+ def _phrase_substring_hit(phrases: list[str], hay: str) -> str | None:
58
+ """Return the longest phrase that occurs as a substring, else None.
59
+
60
+ Falls back to a hyphen-normalized hay so e.g. `"create pr"` still
61
+ matches `"create-pr"` in a path or branch reference.
62
+ """
63
+ best: str | None = None
64
+ hay_norm = hay.replace("-", " ")
65
+ for p in sorted(phrases, key=len, reverse=True):
66
+ if len(p) < 6:
67
+ continue
68
+ if p in hay or p in hay_norm:
69
+ return p
70
+ return best
71
+
72
+
73
+ def _structural_bonus(spec: CommandSpec, message: str) -> str | None:
74
+ """Heavy-signal patterns that score context fully (0.5).
75
+
76
+ Ticket keys (`ABC-123`) and file paths in the spec's
77
+ `trigger_context` only count when they actually appear in the
78
+ message — `trigger_context` advertises *which* signals matter,
79
+ the message provides them.
80
+ """
81
+ ctx_lower = (spec.trigger_context or "").lower()
82
+ msg_lower = message.lower()
83
+ if "ticket" in ctx_lower or "proj-" in ctx_lower or "[a-z]+-[0-9]+" in ctx_lower:
84
+ m = _TICKET_RE.search(message)
85
+ if m:
86
+ return m.group(0)
87
+ for path in _PATH_RE.findall(spec.trigger_context or ""):
88
+ if path.lower() in msg_lower:
89
+ return path
90
+ return None
91
+
92
+
93
+ def _description_score(spec: CommandSpec, message: str, ctx_text: str) -> tuple[float, str]:
94
+ phrases = _phrases(spec.trigger_description)
95
+ hay = (message + " \n " + ctx_text).lower()
96
+ hit = _phrase_substring_hit(phrases, hay)
97
+ if hit:
98
+ # Long phrase substring is the strongest signal — clears the
99
+ # default 0.6 floor on its own. Short phrases need context.
100
+ return (0.65 if len(hit) >= 10 else 0.4), hit
101
+ spec_tokens = _tokens(spec.trigger_description)
102
+ if not spec_tokens:
103
+ return 0.0, ""
104
+ msg_tokens = _tokens(message) | _tokens(ctx_text)
105
+ common = spec_tokens & msg_tokens
106
+ if not common:
107
+ return 0.0, ""
108
+ score = 0.4 * (len(common) / len(spec_tokens))
109
+ return score, sorted(common)[0]
110
+
111
+
112
+ def _context_score(
113
+ spec: CommandSpec, message: str, ctx_text: str
114
+ ) -> tuple[float, str, bool]:
115
+ """Returns (score, evidence, has_structural_bonus)."""
116
+ bonus = _structural_bonus(spec, message)
117
+ if bonus:
118
+ return 0.5, bonus, True
119
+ spec_tokens = _tokens(spec.trigger_context)
120
+ if not spec_tokens:
121
+ return 0.0, "", False
122
+ msg_tokens = _tokens(message) | _tokens(ctx_text)
123
+ common = spec_tokens & msg_tokens
124
+ if not common:
125
+ return 0.0, "", False
126
+ score = 0.3 * (len(common) / len(spec_tokens))
127
+ return score, sorted(common)[0], False
128
+
129
+
130
+ def match(
131
+ message: str,
132
+ context: Iterable[str] = (),
133
+ commands: Iterable[CommandSpec] = (),
134
+ *,
135
+ sanitize: bool = True,
136
+ ) -> list[Match]:
137
+ """Return scored matches sorted by descending score (ties stable).
138
+
139
+ Eligible commands only; ineligible ones are silently skipped. The
140
+ caller is responsible for ranking, cooldown, and rendering.
141
+
142
+ ``sanitize`` (default ``True``) strips fenced/inline code and
143
+ previous suggestion-block echoes from both the message and the
144
+ last 2 turns of context. The flag is exposed for tests that
145
+ exercise the raw scoring path; runtime callers should leave it
146
+ on.
147
+ """
148
+ if sanitize:
149
+ message = sanitize_message(message)
150
+ cleaned_ctx = sanitize_context(context)
151
+ else:
152
+ cleaned_ctx = list(context)
153
+ ctx_text = "\n".join(cleaned_ctx[-2:]) # last 2 turns max
154
+ matches: list[Match] = []
155
+ for spec in commands:
156
+ if not spec.eligible:
157
+ continue
158
+ d_score, d_evidence = _description_score(spec, message, ctx_text)
159
+ c_score, c_evidence, structural = _context_score(spec, message, ctx_text)
160
+ score = round(min(1.0, d_score + c_score), 4)
161
+ if score <= 0:
162
+ continue
163
+ if d_score > 0 and c_score > 0:
164
+ kind = "both"
165
+ evidence = d_evidence if len(d_evidence) >= len(c_evidence) else c_evidence
166
+ elif d_score > 0:
167
+ kind = "description"
168
+ evidence = d_evidence
169
+ else:
170
+ kind = "context"
171
+ evidence = c_evidence
172
+ matches.append(Match(
173
+ command=spec.name,
174
+ score=score,
175
+ matched_trigger=kind,
176
+ evidence=evidence,
177
+ has_structural_bonus=structural,
178
+ ))
179
+ matches.sort(key=lambda m: (-m.score, m.command))
180
+ return matches
@@ -0,0 +1,120 @@
1
+ """Rank scored matches into the final candidate list.
2
+
3
+ Pipeline:
4
+ 1. Drop commands whose name is in `settings.blocklist`.
5
+ 2. Drop matches below the effective `confidence_floor` (per-command
6
+ override if set, else global).
7
+ 3. Anti-noise:
8
+ - vague-input suppression (short message + many candidates, no structural bonus)
9
+ - lonely-band suppression (single match below `floor + 0.1`, no structural bonus)
10
+ - continuation suppression (message is pure follow-through, no new intent)
11
+ 4. Sort by score desc; tie-break:
12
+ - structural bonus wins (named entities outrank generic verbs)
13
+ - longer matched evidence wins (more specific trigger)
14
+ - alphabetic command name wins (stable, deterministic)
15
+ 5. Cap at `settings.max_options`.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from typing import Iterable, Mapping
21
+
22
+ from .types import CommandSpec, Match, Settings
23
+
24
+ _LONELY_BAND = 0.1 # roadmap Phase 4: floor + 0.1 lonely-match threshold
25
+
26
+ _CONTINUATION_PHRASES: frozenset[str] = frozenset({
27
+ # English
28
+ "ok", "okay", "yes", "no", "sure", "go", "do it", "go on",
29
+ "continue", "next", "proceed", "more", "again",
30
+ # German
31
+ "ja", "nein", "weiter", "mach weiter", "los", "machen",
32
+ "weitermachen", "fortfahren", "nochmal",
33
+ })
34
+
35
+
36
+ def _floor_for(name: str, specs_by_name: Mapping[str, CommandSpec], settings: Settings) -> float:
37
+ spec = specs_by_name.get(name)
38
+ if spec and spec.confidence_floor is not None:
39
+ return spec.confidence_floor
40
+ return settings.confidence_floor
41
+
42
+
43
+ def _vague_input_suppression(message: str, matches: list[Match]) -> bool:
44
+ """Short prompts hitting many commands are usually too ambiguous.
45
+
46
+ Suppress when:
47
+ - message has < 6 words
48
+ - more than 2 matches survived the floor
49
+ - none of the matches carry a structural bonus (ticket key, path)
50
+
51
+ A structural bonus means the prompt was specific even if short
52
+ — `"setze ABC-123 um"` is 3 words but unambiguous.
53
+ """
54
+ word_count = len(message.split())
55
+ if word_count >= 6 or len(matches) <= 2:
56
+ return False
57
+ return not any(m.has_structural_bonus for m in matches)
58
+
59
+
60
+ def _sub_floor_lonely_suppression(matches: list[Match], floor: float) -> bool:
61
+ """Single match whose score sits within `floor + _LONELY_BAND`.
62
+
63
+ Roadmap Phase 4 sets this band at 0.1 — a single signal that
64
+ barely clears the floor is too uncertain to interrupt for. A
65
+ structural bonus (ticket key, path) overrides the suppression
66
+ because the match is already grounded in a specific entity.
67
+ """
68
+ if len(matches) != 1:
69
+ return False
70
+ if matches[0].has_structural_bonus:
71
+ return False
72
+ return matches[0].score < floor + _LONELY_BAND
73
+
74
+
75
+ def _continuation_suppression(message: str, matches: list[Match]) -> bool:
76
+ """Pure follow-through messages carry no new intent — suppress.
77
+
78
+ Triggers when the message reduces to a known continuation phrase
79
+ (`ok`, `weiter`, `mach weiter`, …) once trailing punctuation is
80
+ stripped. A structural bonus (ticket key, path) overrides — even
81
+ `"weiter mit ABC-123"` is a fresh intent signal.
82
+ """
83
+ stripped = re.sub(r"[\s\W_]+", " ", message or "", flags=re.UNICODE).strip().lower()
84
+ if not stripped:
85
+ return False
86
+ if stripped not in _CONTINUATION_PHRASES:
87
+ return False
88
+ return not any(m.has_structural_bonus for m in matches)
89
+
90
+
91
+ def _tie_break_key(m: Match) -> tuple[float, int, int, str]:
92
+ # Score desc, structural bonus first, longer evidence first, alpha last.
93
+ return (-m.score, 0 if m.has_structural_bonus else 1, -len(m.evidence), m.command)
94
+
95
+
96
+ def rank(
97
+ matches: Iterable[Match],
98
+ settings: Settings,
99
+ specs_by_name: Mapping[str, CommandSpec],
100
+ *,
101
+ raw_message: str = "",
102
+ ) -> list[Match]:
103
+ if not settings.enabled:
104
+ return []
105
+ blocked = set(settings.blocklist)
106
+ candidates: list[Match] = [m for m in matches if m.command not in blocked]
107
+ above_floor: list[Match] = [
108
+ m for m in candidates
109
+ if m.score >= _floor_for(m.command, specs_by_name, settings)
110
+ ]
111
+ if _continuation_suppression(raw_message, above_floor):
112
+ return []
113
+ if _vague_input_suppression(raw_message, above_floor):
114
+ return []
115
+ if _sub_floor_lonely_suppression(above_floor, settings.confidence_floor):
116
+ return []
117
+ above_floor.sort(key=_tie_break_key)
118
+ if settings.max_options > 0:
119
+ return above_floor[: settings.max_options]
120
+ return above_floor
@@ -0,0 +1,86 @@
1
+ """Render ranked matches as a numbered-options block.
2
+
3
+ Output strictly conforms to `user-interaction` Iron Law:
4
+ * Every option is one numbered line.
5
+ * Options block stays neutral (no inline `(recommended)` tag).
6
+ * Exactly one `Recommendation: N — …` line follows the block.
7
+ * The last numbered option is the **as-is** escape hatch — always
8
+ present, no exceptions.
9
+
10
+ The renderer is purely structural — it does not pick a recommendation
11
+ based on free judgment. The first match (highest score, most
12
+ specific evidence) becomes the recommendation; ties leave the line
13
+ out so the agent doesn't fabricate a tie-break the user didn't ask
14
+ for.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from typing import Mapping
19
+
20
+ from .types import CommandSpec, Match
21
+
22
+
23
+ AS_IS_LABEL = "Just run the prompt as-is, no command"
24
+
25
+
26
+ def render(
27
+ matches: list[Match],
28
+ specs_by_name: Mapping[str, CommandSpec],
29
+ *,
30
+ as_is_label: str = AS_IS_LABEL,
31
+ ) -> str:
32
+ """Return the numbered-options block as plain markdown text.
33
+
34
+ Empty `matches` ⇒ empty string. The rule never emits anything
35
+ when nothing crossed the floor.
36
+ """
37
+ if not matches:
38
+ return ""
39
+ lines: list[str] = []
40
+ for i, m in enumerate(matches, start=1):
41
+ spec = specs_by_name.get(m.command)
42
+ desc = spec.description if spec and spec.description else ""
43
+ # Trim long descriptions for one-line option labels.
44
+ if len(desc) > 120:
45
+ desc = desc[:117].rstrip() + "..."
46
+ slash = f"/{m.command}"
47
+ if desc:
48
+ lines.append(f"> {i}. {slash} — {desc}")
49
+ else:
50
+ lines.append(f"> {i}. {slash}")
51
+ as_is_index = len(matches) + 1
52
+ lines.append(f"> {as_is_index}. {as_is_label}")
53
+ block = "\n".join(lines)
54
+ rec_line = _recommendation_line(matches, specs_by_name)
55
+ if rec_line:
56
+ return block + "\n\n" + rec_line
57
+ return block
58
+
59
+
60
+ def _recommendation_line(
61
+ matches: list[Match], specs_by_name: Mapping[str, CommandSpec]
62
+ ) -> str:
63
+ """Single-source recommendation per `user-interaction` Iron Law.
64
+
65
+ No recommendation when the top two matches are within 0.05 of
66
+ each other — surfacing a winner there would be fabrication.
67
+ """
68
+ if not matches:
69
+ return ""
70
+ if len(matches) >= 2 and (matches[0].score - matches[1].score) < 0.05:
71
+ return ""
72
+ top = matches[0]
73
+ spec = specs_by_name.get(top.command)
74
+ name = top.command
75
+ rationale = _rationale_for(top, spec)
76
+ return f"**Recommendation: 1 — /{name}** — {rationale}"
77
+
78
+
79
+ def _rationale_for(match: Match, spec: CommandSpec | None) -> str:
80
+ if match.matched_trigger == "both":
81
+ why = f"both the request and context match (`{match.evidence}`)"
82
+ elif match.matched_trigger == "description":
83
+ why = f"the request matches its trigger description (`{match.evidence}`)"
84
+ else:
85
+ why = f"the surrounding context matches its trigger (`{match.evidence}`)"
86
+ return f"{why}. Pick the last option to skip the command and run the prompt as written."
@@ -0,0 +1,113 @@
1
+ """Sanitize matcher input to prevent self-echo and quoted-code triggering.
2
+
3
+ The suggestion engine scores against the user's raw message and the
4
+ last 2 turns of context. Two adversarial inputs would otherwise
5
+ re-trigger the engine on its own output or on user-pasted code:
6
+
7
+ * **Self-echo** — the previous turn's suggestion block (`> 1. /commit
8
+ — …`) is part of the conversation context. Scoring against it
9
+ re-surfaces the same commands turn after turn.
10
+ * **Quoted code** — user-pasted snippets that mention a command
11
+ (`` `/implement-ticket` ``, fenced ``` ```bash\ngit commit``` ```)
12
+ read like real intent signals to the substring matcher.
13
+
14
+ Both patterns are stripped here before the matcher sees them. The
15
+ sanitiser is **conservative**: only well-formed Markdown fences,
16
+ inline-code spans, and the engine's own suggestion-line shape are
17
+ removed. Plain prose is untouched so legitimate intent ("commit my
18
+ changes please") still scores.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from typing import Iterable
24
+
25
+ # Triple-backtick fence — handles language hints (```bash …```) and
26
+ # unhinted blocks alike. Non-greedy so adjacent fences don't merge.
27
+ _CODE_FENCE_RE = re.compile(r"```.*?```", re.DOTALL)
28
+ # Inline code span. Excludes empty `` `` `` and respects single-line scope.
29
+ _INLINE_CODE_RE = re.compile(r"`[^`\n]+`")
30
+ # Suggestion-block line shape from `render.py`:
31
+ # > 1. /implement-ticket — drive ticket end-to-end…
32
+ # > 2. /refine-ticket — tighten the AC…
33
+ # Numbered-options lines starting with `>` and a `/command` token.
34
+ _SUGGESTION_LINE_RE = re.compile(
35
+ r"^\s*>\s*\d+\.\s*/[A-Za-z][A-Za-z0-9_-]*\b.*$",
36
+ re.MULTILINE,
37
+ )
38
+ # As-is escape hatch line — recognisable suffix from render.py.
39
+ _AS_IS_LINE_RE = re.compile(
40
+ r"^\s*>\s*\d+\.\s*Just run the prompt as-is.*$",
41
+ re.MULTILINE | re.IGNORECASE,
42
+ )
43
+ # Header line emitted by render.py.
44
+ _SUGGESTION_HEADER_RE = re.compile(
45
+ r"^\s*>\s*💡\s*Your request matches a command.*$",
46
+ re.MULTILINE,
47
+ )
48
+ # Recommendation line right after the block.
49
+ _RECOMMENDATION_LINE_RE = re.compile(
50
+ r"^\s*\*\*Recommendation:\s*\d+\b.*$",
51
+ re.MULTILINE,
52
+ )
53
+
54
+
55
+ def strip_code_blocks(text: str) -> str:
56
+ """Remove fenced and inline code spans.
57
+
58
+ Fenced blocks first (greedy across newlines, non-greedy across
59
+ fences), then inline backticks. Plain text outside code is left
60
+ bit-identical.
61
+ """
62
+ if not text:
63
+ return text
64
+ out = _CODE_FENCE_RE.sub(" ", text)
65
+ out = _INLINE_CODE_RE.sub(" ", out)
66
+ return out
67
+
68
+
69
+ def strip_suggestion_echo(text: str) -> str:
70
+ """Remove lines that look like the engine's own previous output.
71
+
72
+ Matches the four shapes `render.py` emits:
73
+
74
+ * the `> 💡 …` header
75
+ * `> N. /command — …` numbered options
76
+ * the `> N. Just run the prompt as-is …` escape hatch
77
+ * the `**Recommendation: N — …` follow-up line
78
+
79
+ Anything else (including user-authored quotes that happen to
80
+ mention a command) is preserved — only the engine's distinctive
81
+ block shape is filtered.
82
+ """
83
+ if not text:
84
+ return text
85
+ out = _SUGGESTION_HEADER_RE.sub("", text)
86
+ out = _SUGGESTION_LINE_RE.sub("", out)
87
+ out = _AS_IS_LINE_RE.sub("", out)
88
+ out = _RECOMMENDATION_LINE_RE.sub("", out)
89
+ return out
90
+
91
+
92
+ def sanitize_message(message: str) -> str:
93
+ """Apply both filters to a single user message.
94
+
95
+ Order matters: strip code first (a `/command` inside a fence is
96
+ code, not an echo), then strip echoes from what remains.
97
+ """
98
+ return strip_suggestion_echo(strip_code_blocks(message))
99
+
100
+
101
+ def sanitize_context(context_lines: Iterable[str]) -> list[str]:
102
+ """Apply `sanitize_message` to each line of recent-turn context.
103
+
104
+ Returns a new list — the caller's list is untouched. Empty strings
105
+ after sanitising are kept out of the result so they don't dilute
106
+ token-overlap scoring.
107
+ """
108
+ out: list[str] = []
109
+ for line in context_lines:
110
+ cleaned = sanitize_message(line)
111
+ if cleaned and cleaned.strip():
112
+ out.append(cleaned)
113
+ return out
@@ -0,0 +1,125 @@
1
+ """Read `commands.suggestion.*` from `.agent-settings.yml` into `Settings`.
2
+
3
+ Mirror of the chat-history pattern (`scripts/chat_history.py`):
4
+
5
+ * Lazy PyYAML import — the engine works without yaml installed when no
6
+ settings file is present (test fixtures, cloud bundles).
7
+ * Default-permissive: a missing file or missing section returns
8
+ `Settings()` defaults (suggestion layer enabled). Only an explicit
9
+ `enabled: false` flips the master switch off.
10
+ * Malformed YAML / unreadable file → defaults; the suggester degrades
11
+ silently rather than crashing the turn.
12
+ * Type-coerces with bounded fallbacks (floors clamped 0.0-1.0, ints
13
+ non-negative, blocklist forced to a tuple of strings).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+ from typing import Any, Iterable
19
+
20
+ from .types import Settings
21
+
22
+ DEFAULT_SETTINGS_FILE = ".agent-settings.yml"
23
+
24
+ _DEFAULT = Settings()
25
+
26
+
27
+ def load_settings(settings_path: Path | str | None = None) -> Settings:
28
+ """Return a `Settings` instance hydrated from `.agent-settings.yml`.
29
+
30
+ Parameters
31
+ ----------
32
+ settings_path:
33
+ Explicit override. ``None`` resolves to ``./.agent-settings.yml``
34
+ relative to the current working directory — same convention as
35
+ ``chat_history``.
36
+ """
37
+ path = Path(settings_path) if settings_path else Path(DEFAULT_SETTINGS_FILE)
38
+ raw = _read_section(path)
39
+ if raw is None:
40
+ return _DEFAULT
41
+ return _settings_from_raw(raw)
42
+
43
+
44
+ def _read_section(path: Path) -> dict[str, Any] | None:
45
+ """Return the ``commands.suggestion`` mapping or ``None`` on any miss."""
46
+ if not path.is_file():
47
+ return None
48
+ try:
49
+ import yaml # type: ignore[import-untyped]
50
+ except ImportError:
51
+ return None
52
+ try:
53
+ with path.open(encoding="utf-8") as fh:
54
+ data = yaml.safe_load(fh) or {}
55
+ except (OSError, yaml.YAMLError):
56
+ return None
57
+ if not isinstance(data, dict):
58
+ return None
59
+ commands = data.get("commands")
60
+ if not isinstance(commands, dict):
61
+ return None
62
+ section = commands.get("suggestion")
63
+ if not isinstance(section, dict):
64
+ return None
65
+ return section
66
+
67
+
68
+ def _settings_from_raw(raw: dict[str, Any]) -> Settings:
69
+ return Settings(
70
+ enabled=_coerce_bool(raw.get("enabled"), _DEFAULT.enabled),
71
+ confidence_floor=_coerce_floor(
72
+ raw.get("confidence_floor"), _DEFAULT.confidence_floor
73
+ ),
74
+ cooldown_seconds=_coerce_nonneg_int(
75
+ raw.get("cooldown_seconds"), _DEFAULT.cooldown_seconds
76
+ ),
77
+ max_options=_coerce_nonneg_int(
78
+ raw.get("max_options"), _DEFAULT.max_options
79
+ ),
80
+ blocklist=_coerce_str_tuple(raw.get("blocklist")),
81
+ )
82
+
83
+
84
+ def _coerce_bool(value: Any, default: bool) -> bool:
85
+ if isinstance(value, bool):
86
+ return value
87
+ if value is None:
88
+ return default
89
+ if isinstance(value, str):
90
+ s = value.strip().lower()
91
+ if s in ("true", "yes", "on", "1"):
92
+ return True
93
+ if s in ("false", "no", "off", "0"):
94
+ return False
95
+ return default
96
+
97
+
98
+ def _coerce_floor(value: Any, default: float) -> float:
99
+ try:
100
+ f = float(value) # type: ignore[arg-type]
101
+ except (TypeError, ValueError):
102
+ return default
103
+ if f < 0.0:
104
+ return 0.0
105
+ if f > 1.0:
106
+ return 1.0
107
+ return f
108
+
109
+
110
+ def _coerce_nonneg_int(value: Any, default: int) -> int:
111
+ try:
112
+ i = int(value) # type: ignore[arg-type]
113
+ except (TypeError, ValueError):
114
+ return default
115
+ return i if i >= 0 else default
116
+
117
+
118
+ def _coerce_str_tuple(value: Any) -> tuple[str, ...]:
119
+ if not isinstance(value, Iterable) or isinstance(value, (str, bytes)):
120
+ return ()
121
+ out: list[str] = []
122
+ for item in value:
123
+ if isinstance(item, str) and item.strip():
124
+ out.append(item.strip())
125
+ return tuple(out)