@event4u/agent-config 1.13.0 → 1.14.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 (252) hide show
  1. package/.agent-src/commands/agent-handoff.md +3 -0
  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 +5 -1
  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 +5 -0
  11. package/.agent-src/commands/chat-history-resume.md +5 -0
  12. package/.agent-src/commands/chat-history.md +5 -0
  13. package/.agent-src/commands/check-current-md.md +126 -0
  14. package/.agent-src/commands/commit-in-chunks.md +98 -0
  15. package/.agent-src/commands/commit.md +4 -0
  16. package/.agent-src/commands/compress.md +3 -0
  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 +3 -0
  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 +24 -0
  50. package/.agent-src/commands/optimize-agents.md +4 -0
  51. package/.agent-src/commands/optimize-augmentignore.md +3 -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 +4 -1
  64. package/.agent-src/commands/review-changes.md +4 -0
  65. package/.agent-src/commands/review-routing.md +4 -0
  66. package/.agent-src/commands/roadmap-create.md +7 -0
  67. package/.agent-src/commands/roadmap-execute.md +12 -1
  68. package/.agent-src/commands/rule-compliance-audit.md +4 -0
  69. package/.agent-src/commands/set-cost-profile.md +3 -0
  70. package/.agent-src/commands/sync-agent-settings.md +3 -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 +4 -0
  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 +8 -0
  89. package/.agent-src/rules/autonomous-execution.md +158 -0
  90. package/.agent-src/rules/chat-history.md +147 -118
  91. package/.agent-src/rules/cli-output-handling.md +26 -3
  92. package/.agent-src/rules/command-suggestion.md +133 -0
  93. package/.agent-src/rules/commit-policy.md +99 -0
  94. package/.agent-src/rules/direct-answers.md +114 -0
  95. package/.agent-src/rules/docs-sync.md +36 -0
  96. package/.agent-src/rules/downstream-changes.md +10 -9
  97. package/.agent-src/rules/improve-before-implement.md +9 -6
  98. package/.agent-src/rules/language-and-tone.md +81 -6
  99. package/.agent-src/rules/non-destructive-by-default.md +117 -0
  100. package/.agent-src/rules/package-ci-checks.md +4 -0
  101. package/.agent-src/rules/preservation-guard.md +20 -0
  102. package/.agent-src/rules/roadmap-progress-sync.md +103 -30
  103. package/.agent-src/rules/scope-control.md +42 -1
  104. package/.agent-src/rules/size-enforcement.md +1 -3
  105. package/.agent-src/rules/skill-quality.md +3 -8
  106. package/.agent-src/rules/ui-audit-before-build.md +106 -0
  107. package/.agent-src/rules/user-interaction.md +82 -50
  108. package/.agent-src/scripts/update_roadmap_progress.py +17 -5
  109. package/.agent-src/skills/blade-ui/SKILL.md +30 -5
  110. package/.agent-src/skills/command-routing/SKILL.md +32 -0
  111. package/.agent-src/skills/command-writing/SKILL.md +41 -2
  112. package/.agent-src/skills/description-assist/SKILL.md +21 -0
  113. package/.agent-src/skills/estimate-ticket/SKILL.md +0 -1
  114. package/.agent-src/skills/existing-ui-audit/SKILL.md +187 -0
  115. package/.agent-src/skills/fe-design/SKILL.md +72 -60
  116. package/.agent-src/skills/finishing-a-development-branch/SKILL.md +4 -0
  117. package/.agent-src/skills/flux/SKILL.md +31 -4
  118. package/.agent-src/skills/guideline-writing/SKILL.md +24 -2
  119. package/.agent-src/skills/learning-to-rule-or-skill/SKILL.md +51 -9
  120. package/.agent-src/skills/livewire/SKILL.md +30 -4
  121. package/.agent-src/skills/md-language-check/SKILL.md +103 -0
  122. package/.agent-src/skills/php-coder/SKILL.md +24 -0
  123. package/.agent-src/skills/react-shadcn-ui/SKILL.md +121 -0
  124. package/.agent-src/skills/refine-prompt/SKILL.md +220 -0
  125. package/.agent-src/skills/refine-ticket/SKILL.md +2 -4
  126. package/.agent-src/skills/roadmap-management/SKILL.md +10 -3
  127. package/.agent-src/skills/rule-writing/SKILL.md +23 -1
  128. package/.agent-src/skills/skill-writing/SKILL.md +1 -3
  129. package/.agent-src/skills/upstream-contribute/SKILL.md +1 -1
  130. package/.agent-src/skills/using-git-worktrees/SKILL.md +3 -1
  131. package/.agent-src/templates/AGENTS.md +24 -6
  132. package/.agent-src/templates/agent-settings.md +149 -0
  133. package/.agent-src/templates/roadmaps.md +8 -2
  134. package/.agent-src/templates/scripts/implement_ticket/__init__.py +63 -26
  135. package/.agent-src/templates/scripts/implement_ticket/__main__.py +8 -2
  136. package/.agent-src/templates/scripts/telemetry/__init__.py +42 -0
  137. package/.agent-src/templates/scripts/telemetry/aggregator.py +154 -0
  138. package/.agent-src/templates/scripts/telemetry/boundary.py +171 -0
  139. package/.agent-src/templates/scripts/telemetry/engagement.py +238 -0
  140. package/.agent-src/templates/scripts/telemetry/report_renderer.py +170 -0
  141. package/.agent-src/templates/scripts/telemetry/settings.py +112 -0
  142. package/.agent-src/templates/scripts/telemetry_record.py +166 -0
  143. package/.agent-src/templates/scripts/telemetry_report.py +161 -0
  144. package/.agent-src/templates/scripts/telemetry_status.py +142 -0
  145. package/.agent-src/templates/scripts/work_engine/__init__.py +58 -0
  146. package/.agent-src/templates/scripts/work_engine/__main__.py +9 -0
  147. package/.agent-src/templates/scripts/work_engine/cli.py +592 -0
  148. package/.agent-src/templates/scripts/{implement_ticket → work_engine}/delivery_state.py +7 -0
  149. package/.agent-src/templates/scripts/work_engine/directives/__init__.py +33 -0
  150. package/.agent-src/templates/scripts/work_engine/directives/backend/__init__.py +98 -0
  151. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/analyze.py +1 -1
  152. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/implement.py +2 -2
  153. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/memory.py +1 -1
  154. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/plan.py +1 -1
  155. package/.agent-src/templates/scripts/work_engine/directives/backend/refine.py +396 -0
  156. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/report.py +36 -4
  157. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/test.py +2 -2
  158. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/verify.py +2 -2
  159. package/.agent-src/templates/scripts/work_engine/directives/mixed/__init__.py +116 -0
  160. package/.agent-src/templates/scripts/work_engine/directives/mixed/contract.py +254 -0
  161. package/.agent-src/templates/scripts/work_engine/directives/mixed/stitch.py +229 -0
  162. package/.agent-src/templates/scripts/work_engine/directives/mixed/ui.py +231 -0
  163. package/.agent-src/templates/scripts/work_engine/directives/ui/__init__.py +113 -0
  164. package/.agent-src/templates/scripts/work_engine/directives/ui/_passthrough.py +44 -0
  165. package/.agent-src/templates/scripts/work_engine/directives/ui/apply.py +241 -0
  166. package/.agent-src/templates/scripts/work_engine/directives/ui/audit.py +414 -0
  167. package/.agent-src/templates/scripts/work_engine/directives/ui/design.py +335 -0
  168. package/.agent-src/templates/scripts/work_engine/directives/ui/polish.py +510 -0
  169. package/.agent-src/templates/scripts/work_engine/directives/ui/review.py +468 -0
  170. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/__init__.py +119 -0
  171. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/_skipped.py +37 -0
  172. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/apply.py +165 -0
  173. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/refine.py +66 -0
  174. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/report.py +62 -0
  175. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/test.py +115 -0
  176. package/.agent-src/templates/scripts/work_engine/dispatcher.py +331 -0
  177. package/.agent-src/templates/scripts/work_engine/hooks/__init__.py +54 -0
  178. package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +32 -0
  179. package/.agent-src/templates/scripts/work_engine/hooks/builtin/_chat_history_base.py +103 -0
  180. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_append.py +44 -0
  181. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_halt_append.py +42 -0
  182. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_heartbeat.py +50 -0
  183. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_turn_check.py +49 -0
  184. package/.agent-src/templates/scripts/work_engine/hooks/builtin/directive_set_guard.py +53 -0
  185. package/.agent-src/templates/scripts/work_engine/hooks/builtin/halt_surface_audit.py +50 -0
  186. package/.agent-src/templates/scripts/work_engine/hooks/builtin/state_shape_validation.py +52 -0
  187. package/.agent-src/templates/scripts/work_engine/hooks/builtin/trace.py +84 -0
  188. package/.agent-src/templates/scripts/work_engine/hooks/context.py +66 -0
  189. package/.agent-src/templates/scripts/work_engine/hooks/events.py +44 -0
  190. package/.agent-src/templates/scripts/work_engine/hooks/exceptions.py +79 -0
  191. package/.agent-src/templates/scripts/work_engine/hooks/registry.py +60 -0
  192. package/.agent-src/templates/scripts/work_engine/hooks/runner.py +73 -0
  193. package/.agent-src/templates/scripts/work_engine/hooks/settings.py +141 -0
  194. package/.agent-src/templates/scripts/work_engine/intent/__init__.py +47 -0
  195. package/.agent-src/templates/scripts/work_engine/intent/classify.py +280 -0
  196. package/.agent-src/templates/scripts/work_engine/migration/__init__.py +8 -0
  197. package/.agent-src/templates/scripts/work_engine/migration/v0_to_v1.py +199 -0
  198. package/.agent-src/templates/scripts/work_engine/resolvers/__init__.py +22 -0
  199. package/.agent-src/templates/scripts/work_engine/resolvers/diff.py +106 -0
  200. package/.agent-src/templates/scripts/work_engine/resolvers/file.py +113 -0
  201. package/.agent-src/templates/scripts/work_engine/resolvers/prompt.py +90 -0
  202. package/.agent-src/templates/scripts/work_engine/scoring/__init__.py +14 -0
  203. package/.agent-src/templates/scripts/work_engine/scoring/confidence.py +300 -0
  204. package/.agent-src/templates/scripts/work_engine/stack/__init__.py +31 -0
  205. package/.agent-src/templates/scripts/work_engine/stack/detect.py +187 -0
  206. package/.agent-src/templates/scripts/work_engine/state.py +641 -0
  207. package/.claude-plugin/marketplace.json +105 -2
  208. package/AGENTS.md +36 -8
  209. package/CHANGELOG.md +534 -0
  210. package/README.md +125 -4
  211. package/config/agent-settings.template.yml +45 -0
  212. package/config/gitignore-block.txt +4 -0
  213. package/docs/architecture.md +28 -1
  214. package/docs/development.md +1 -1
  215. package/docs/getting-started.md +2 -2
  216. package/docs/installation.md +86 -0
  217. package/docs/showcase.md +204 -0
  218. package/package.json +1 -1
  219. package/scripts/agent-config +199 -0
  220. package/scripts/audit_cloud_compatibility.py +288 -0
  221. package/scripts/build_cloud_bundle.py +458 -0
  222. package/scripts/build_linear_digest.py +263 -0
  223. package/scripts/chat_history.py +796 -7
  224. package/scripts/check_compression.py +139 -0
  225. package/scripts/check_iron_law_prominence.py +143 -0
  226. package/scripts/check_md_language.py +159 -0
  227. package/scripts/check_portability.py +36 -0
  228. package/scripts/check_reply_consistency.py +140 -0
  229. package/scripts/command_suggester/__init__.py +51 -0
  230. package/scripts/command_suggester/cooldown.py +132 -0
  231. package/scripts/command_suggester/loader.py +70 -0
  232. package/scripts/command_suggester/match.py +180 -0
  233. package/scripts/command_suggester/rank.py +120 -0
  234. package/scripts/command_suggester/render.py +86 -0
  235. package/scripts/command_suggester/sanitize.py +113 -0
  236. package/scripts/command_suggester/settings.py +125 -0
  237. package/scripts/command_suggester/types.py +78 -0
  238. package/scripts/hooks/augment-chat-history.sh +56 -0
  239. package/scripts/install-hooks.sh +67 -0
  240. package/scripts/install.py +150 -33
  241. package/scripts/lint_marketplace.py +27 -0
  242. package/scripts/migrate_command_suggestions.py +151 -0
  243. package/scripts/schemas/command.schema.json +41 -0
  244. package/scripts/skill_linter.py +67 -0
  245. package/scripts/sync_agent_settings.py +42 -12
  246. package/templates/consumer-settings/augment-cli-hooks.json +54 -0
  247. package/templates/consumer-settings/claude-settings.json +55 -1
  248. package/.agent-src/templates/scripts/implement_ticket/cli.py +0 -171
  249. package/.agent-src/templates/scripts/implement_ticket/dispatcher.py +0 -134
  250. package/.agent-src/templates/scripts/implement_ticket/steps/__init__.py +0 -49
  251. package/.agent-src/templates/scripts/implement_ticket/steps/refine.py +0 -140
  252. /package/.agent-src/templates/scripts/{implement_ticket → work_engine}/persona_policy.py +0 -0
@@ -0,0 +1,132 @@
1
+ """Suppress recently-shown suggestions per conversation.
2
+
3
+ Cooldown key is `(command_name, evidence)` so two distinct triggers
4
+ for the same command (e.g. `/commit` from "git status shows changes"
5
+ vs. from "save this to git") track separately. The user explicitly
6
+ invoking a command via `/command` clears that command's cooldown so
7
+ the next genuine match surfaces immediately.
8
+
9
+ The store is in-memory; persistence is the agent's job (conversation
10
+ state). Phase 5 wires the per-conversation `disabled_for_conversation`
11
+ flag into the same store.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ import time
17
+ from typing import Mapping
18
+
19
+ from .types import CommandSpec, CooldownState, Match, Settings
20
+
21
+
22
+ _DURATION_RE = re.compile(r"^\s*(\d+)\s*([smhd])\s*$", re.IGNORECASE)
23
+ _DISABLE_DIRECTIVE_RE = re.compile(
24
+ r"(?:^|\s)/command-suggestion-(off|on)\b", re.IGNORECASE
25
+ )
26
+ _EXPLICIT_SLASH_RE = re.compile(r"^\s*/[A-Za-z][A-Za-z0-9_-]*\b")
27
+
28
+
29
+ def is_explicit_slash_invocation(message: str) -> bool:
30
+ """Return True when the message starts with an explicit ``/command``.
31
+
32
+ Per the `command-suggestion` rule, explicit slash invocations
33
+ bypass the suggestion layer entirely \u2014 they're handled by
34
+ `slash-commands` directly. The engine should not score in that
35
+ case. Helper exposed for the runtime caller and the GT-CS4
36
+ golden.
37
+ """
38
+ if not message:
39
+ return False
40
+ return bool(_EXPLICIT_SLASH_RE.match(message))
41
+
42
+
43
+ def detect_disable_directive(message: str) -> bool | None:
44
+ """Detect a `/command-suggestion-off` / `-on` directive in the user message.
45
+
46
+ Returns ``True`` to disable for the rest of the conversation,
47
+ ``False`` to re-enable, ``None`` when no directive is present.
48
+ The latest occurrence in the message wins (order-stable on tie).
49
+ Mutating the `CooldownStore` is the caller's responsibility — this
50
+ helper stays pure so tests don't have to fake time.
51
+ """
52
+ if not message:
53
+ return None
54
+ last: bool | None = None
55
+ for m in _DISABLE_DIRECTIVE_RE.finditer(message):
56
+ last = m.group(1).lower() == "off"
57
+ return last
58
+
59
+
60
+ def parse_cooldown(value: str | None, default_seconds: int) -> int:
61
+ """Convert `'10m'` / `'30s'` / `'1h'` / `'2d'` to seconds.
62
+
63
+ Returns ``default_seconds`` for any malformed or missing input —
64
+ keeping the runtime fail-soft. The schema validator caps the
65
+ string length, so we never see absurd inputs in practice.
66
+ """
67
+ if not value:
68
+ return default_seconds
69
+ m = _DURATION_RE.match(str(value))
70
+ if not m:
71
+ return default_seconds
72
+ n, unit = int(m.group(1)), m.group(2).lower()
73
+ factor = {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit]
74
+ return n * factor
75
+
76
+
77
+ class CooldownStore:
78
+ """Thin wrapper around `CooldownState` with time-aware helpers.
79
+
80
+ Tests inject a fixed `now` to make decay deterministic; runtime
81
+ leaves it as `time.time`.
82
+ """
83
+
84
+ def __init__(self, state: CooldownState | None = None, *, now=time.time):
85
+ self.state = state or CooldownState()
86
+ self._now = now
87
+
88
+ def is_cooled_down(
89
+ self, command: str, evidence: str, *, window_seconds: int
90
+ ) -> bool:
91
+ last = self.state.last_shown.get((command, evidence))
92
+ if last is None:
93
+ return False
94
+ return (self._now() - last) < window_seconds
95
+
96
+ def record_shown(self, matches: list[Match]) -> None:
97
+ ts = self._now()
98
+ for m in matches:
99
+ self.state.last_shown[(m.command, m.evidence)] = ts
100
+
101
+ def record_explicit_invocation(self, command: str) -> None:
102
+ """Clear the cooldown when the user explicitly types `/command`.
103
+
104
+ We drop every entry for that command (across all evidences)
105
+ so a deliberate invocation always produces a clean slate.
106
+ """
107
+ ts = self._now()
108
+ self.state.explicit_invocations[command] = ts
109
+ keys_to_drop = [
110
+ k for k in self.state.last_shown if k[0] == command
111
+ ]
112
+ for k in keys_to_drop:
113
+ del self.state.last_shown[k]
114
+
115
+
116
+ def apply_cooldown(
117
+ matches: list[Match],
118
+ store: CooldownStore,
119
+ settings: Settings,
120
+ specs_by_name: Mapping[str, CommandSpec],
121
+ ) -> list[Match]:
122
+ if store.state.disabled_for_conversation:
123
+ return []
124
+ out: list[Match] = []
125
+ for m in matches:
126
+ spec = specs_by_name.get(m.command)
127
+ per_cmd = spec.cooldown if spec else None
128
+ window = parse_cooldown(per_cmd, settings.cooldown_seconds)
129
+ if store.is_cooled_down(m.command, m.evidence, window_seconds=window):
130
+ continue
131
+ out.append(m)
132
+ return out
@@ -0,0 +1,70 @@
1
+ """Read command frontmatter into `CommandSpec` instances.
2
+
3
+ Reuses the package's stdlib-only `validate_frontmatter.parse_frontmatter`
4
+ so the loader and the linter agree on what counts as well-formed.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from .types import CommandSpec
13
+
14
+ # Sibling stdlib parser — same one the linter calls.
15
+ _SCRIPTS_DIR = Path(__file__).resolve().parent.parent
16
+ sys.path.insert(0, str(_SCRIPTS_DIR))
17
+ from validate_frontmatter import parse_frontmatter # noqa: E402
18
+
19
+
20
+ def load_commands(commands_dir: Path) -> list[CommandSpec]:
21
+ """Load every `*.md` under ``commands_dir`` as a `CommandSpec`.
22
+
23
+ Files without a `suggestion` block are loaded as `eligible=False`
24
+ with empty rationale — keeps tests deterministic on legacy data.
25
+ Bad frontmatter is skipped silently; the linter is the gate, not
26
+ this loader.
27
+ """
28
+ specs: list[CommandSpec] = []
29
+ for path in sorted(commands_dir.glob("*.md")):
30
+ text = path.read_text(encoding="utf-8")
31
+ data, _offset = parse_frontmatter(text)
32
+ if data is None:
33
+ continue
34
+ name = str(data.get("name") or path.stem)
35
+ description = str(data.get("description") or "")
36
+ spec = _spec_from_data(name, description, data.get("suggestion"))
37
+ specs.append(spec)
38
+ return specs
39
+
40
+
41
+ def _spec_from_data(
42
+ name: str, description: str, suggestion: Any
43
+ ) -> CommandSpec:
44
+ if not isinstance(suggestion, dict):
45
+ return CommandSpec(name=name, description=description, eligible=False)
46
+ eligible = suggestion.get("eligible") is True
47
+ if not eligible:
48
+ return CommandSpec(
49
+ name=name,
50
+ description=description,
51
+ eligible=False,
52
+ rationale=str(suggestion.get("rationale") or ""),
53
+ )
54
+ floor = suggestion.get("confidence_floor")
55
+ floor_f: float | None
56
+ try:
57
+ floor_f = float(floor) if floor is not None else None
58
+ except (TypeError, ValueError):
59
+ floor_f = None
60
+ cooldown = suggestion.get("cooldown")
61
+ cooldown_s = str(cooldown) if cooldown is not None else None
62
+ return CommandSpec(
63
+ name=name,
64
+ description=description,
65
+ eligible=True,
66
+ trigger_description=str(suggestion.get("trigger_description") or ""),
67
+ trigger_context=str(suggestion.get("trigger_context") or ""),
68
+ confidence_floor=floor_f,
69
+ cooldown=cooldown_s,
70
+ )
@@ -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."