@event4u/agent-config 1.12.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.
- package/.agent-src/commands/agent-handoff.md +3 -0
- package/.agent-src/commands/agent-status.md +3 -0
- package/.agent-src/commands/agents-audit.md +4 -0
- package/.agent-src/commands/agents-cleanup.md +6 -1
- package/.agent-src/commands/agents-prepare.md +3 -0
- package/.agent-src/commands/analyze-reference-repo.md +4 -0
- package/.agent-src/commands/bug-fix.md +5 -1
- package/.agent-src/commands/bug-investigate.md +4 -0
- package/.agent-src/commands/chat-history-checkpoint.md +126 -0
- package/.agent-src/commands/chat-history-clear.md +5 -0
- package/.agent-src/commands/chat-history-resume.md +5 -0
- package/.agent-src/commands/chat-history.md +5 -0
- package/.agent-src/commands/check-current-md.md +126 -0
- package/.agent-src/commands/commit-in-chunks.md +98 -0
- package/.agent-src/commands/commit.md +4 -0
- package/.agent-src/commands/compress.md +3 -0
- package/.agent-src/commands/context-create.md +4 -0
- package/.agent-src/commands/context-refactor.md +4 -0
- package/.agent-src/commands/copilot-agents-init.md +3 -0
- package/.agent-src/commands/copilot-agents-optimize.md +3 -0
- package/.agent-src/commands/create-pr-description.md +4 -0
- package/.agent-src/commands/create-pr.md +4 -0
- package/.agent-src/commands/do-and-judge.md +4 -1
- package/.agent-src/commands/do-in-steps.md +3 -0
- package/.agent-src/commands/e2e-heal.md +4 -0
- package/.agent-src/commands/e2e-plan.md +4 -0
- package/.agent-src/commands/estimate-ticket.md +4 -1
- package/.agent-src/commands/feature-dev.md +4 -0
- package/.agent-src/commands/feature-explore.md +4 -0
- package/.agent-src/commands/feature-plan.md +4 -0
- package/.agent-src/commands/feature-refactor.md +4 -0
- package/.agent-src/commands/feature-roadmap.md +6 -0
- package/.agent-src/commands/fix-ci.md +4 -0
- package/.agent-src/commands/fix-portability.md +3 -0
- package/.agent-src/commands/fix-pr-bot-comments.md +4 -0
- package/.agent-src/commands/fix-pr-comments.md +4 -0
- package/.agent-src/commands/fix-pr-developer-comments.md +4 -0
- package/.agent-src/commands/fix-references.md +3 -0
- package/.agent-src/commands/fix-seeder.md +4 -0
- package/.agent-src/commands/implement-ticket.md +39 -13
- package/.agent-src/commands/jira-ticket.md +4 -0
- package/.agent-src/commands/judge.md +3 -0
- package/.agent-src/commands/memory-add.md +5 -3
- package/.agent-src/commands/memory-full.md +5 -2
- package/.agent-src/commands/memory-promote.md +7 -6
- package/.agent-src/commands/mode.md +3 -0
- package/.agent-src/commands/module-create.md +4 -0
- package/.agent-src/commands/module-explore.md +4 -0
- package/.agent-src/commands/onboard.md +24 -0
- package/.agent-src/commands/optimize-agents.md +4 -0
- package/.agent-src/commands/optimize-augmentignore.md +3 -0
- package/.agent-src/commands/optimize-rtk-filters.md +3 -0
- package/.agent-src/commands/optimize-skills.md +4 -0
- package/.agent-src/commands/override-create.md +4 -0
- package/.agent-src/commands/override-manage.md +4 -0
- package/.agent-src/commands/package-reset.md +3 -0
- package/.agent-src/commands/package-test.md +3 -0
- package/.agent-src/commands/prepare-for-review.md +4 -0
- package/.agent-src/commands/project-analyze.md +4 -0
- package/.agent-src/commands/project-health.md +4 -0
- package/.agent-src/commands/propose-memory.md +6 -8
- package/.agent-src/commands/quality-fix.md +4 -0
- package/.agent-src/commands/refine-ticket.md +4 -1
- package/.agent-src/commands/review-changes.md +4 -0
- package/.agent-src/commands/review-routing.md +4 -0
- package/.agent-src/commands/roadmap-create.md +7 -0
- package/.agent-src/commands/roadmap-execute.md +12 -1
- package/.agent-src/commands/rule-compliance-audit.md +4 -0
- package/.agent-src/commands/set-cost-profile.md +3 -0
- package/.agent-src/commands/sync-agent-settings.md +3 -0
- package/.agent-src/commands/sync-gitignore.md +3 -0
- package/.agent-src/commands/tests-create.md +4 -0
- package/.agent-src/commands/tests-execute.md +4 -0
- package/.agent-src/commands/threat-model.md +4 -0
- package/.agent-src/commands/update-form-request-messages.md +4 -0
- package/.agent-src/commands/upstream-contribute.md +4 -0
- package/.agent-src/commands/work.md +161 -0
- package/.agent-src/guidelines/agent-infra/engineering-memory-data-format.md +2 -6
- package/.agent-src/guidelines/agent-infra/layered-settings.md +0 -1
- package/.agent-src/guidelines/agent-infra/memory-access.md +0 -7
- package/.agent-src/guidelines/agent-infra/role-contracts.md +2 -4
- package/.agent-src/guidelines/agent-infra/self-improvement-pipeline.md +0 -1
- package/.agent-src/guidelines/php/patterns/strategy.md +180 -2
- package/.agent-src/personas/README.md +0 -1
- package/.agent-src/rules/artifact-drafting-protocol.md +7 -2
- package/.agent-src/rules/artifact-engagement-recording.md +133 -0
- package/.agent-src/rules/ask-when-uncertain.md +18 -13
- package/.agent-src/rules/augment-portability.md +8 -0
- package/.agent-src/rules/autonomous-execution.md +158 -0
- package/.agent-src/rules/chat-history.md +147 -118
- package/.agent-src/rules/cli-output-handling.md +26 -3
- package/.agent-src/rules/command-suggestion.md +133 -0
- package/.agent-src/rules/commit-policy.md +99 -0
- package/.agent-src/rules/direct-answers.md +114 -0
- package/.agent-src/rules/docs-sync.md +36 -0
- package/.agent-src/rules/downstream-changes.md +10 -9
- package/.agent-src/rules/improve-before-implement.md +9 -6
- package/.agent-src/rules/language-and-tone.md +81 -6
- package/.agent-src/rules/non-destructive-by-default.md +117 -0
- package/.agent-src/rules/package-ci-checks.md +4 -0
- package/.agent-src/rules/preservation-guard.md +20 -0
- package/.agent-src/rules/roadmap-progress-sync.md +103 -30
- package/.agent-src/rules/scope-control.md +42 -1
- package/.agent-src/rules/size-enforcement.md +1 -3
- package/.agent-src/rules/skill-quality.md +3 -8
- package/.agent-src/rules/ui-audit-before-build.md +106 -0
- package/.agent-src/rules/user-interaction.md +81 -3
- package/.agent-src/scripts/update_roadmap_progress.py +48 -6
- package/.agent-src/skills/blade-ui/SKILL.md +30 -5
- package/.agent-src/skills/command-routing/SKILL.md +32 -0
- package/.agent-src/skills/command-writing/SKILL.md +41 -2
- package/.agent-src/skills/description-assist/SKILL.md +21 -0
- package/.agent-src/skills/estimate-ticket/SKILL.md +0 -1
- package/.agent-src/skills/existing-ui-audit/SKILL.md +187 -0
- package/.agent-src/skills/fe-design/SKILL.md +72 -60
- package/.agent-src/skills/finishing-a-development-branch/SKILL.md +4 -0
- package/.agent-src/skills/flux/SKILL.md +31 -4
- package/.agent-src/skills/guideline-writing/SKILL.md +24 -2
- package/.agent-src/skills/learning-to-rule-or-skill/SKILL.md +51 -9
- package/.agent-src/skills/livewire/SKILL.md +30 -4
- package/.agent-src/skills/md-language-check/SKILL.md +103 -0
- package/.agent-src/skills/php-coder/SKILL.md +24 -0
- package/.agent-src/skills/react-shadcn-ui/SKILL.md +121 -0
- package/.agent-src/skills/refine-prompt/SKILL.md +220 -0
- package/.agent-src/skills/refine-ticket/SKILL.md +2 -4
- package/.agent-src/skills/roadmap-management/SKILL.md +10 -3
- package/.agent-src/skills/rule-writing/SKILL.md +23 -1
- package/.agent-src/skills/skill-writing/SKILL.md +1 -3
- package/.agent-src/skills/upstream-contribute/SKILL.md +1 -1
- package/.agent-src/skills/using-git-worktrees/SKILL.md +3 -1
- package/.agent-src/templates/AGENTS.md +24 -6
- package/.agent-src/templates/agent-settings.md +149 -0
- package/.agent-src/templates/github-workflows/roadmap-progress-check.yml +63 -0
- package/.agent-src/templates/hooks/pre-commit-roadmap-progress +60 -0
- package/.agent-src/templates/roadmaps.md +8 -2
- package/.agent-src/templates/scripts/implement_ticket/__init__.py +63 -26
- package/.agent-src/templates/scripts/implement_ticket/__main__.py +8 -2
- package/.agent-src/templates/scripts/memory_lookup.py +382 -21
- package/.agent-src/templates/scripts/memory_status.py +110 -9
- package/.agent-src/templates/scripts/telemetry/__init__.py +42 -0
- package/.agent-src/templates/scripts/telemetry/aggregator.py +154 -0
- package/.agent-src/templates/scripts/telemetry/boundary.py +171 -0
- package/.agent-src/templates/scripts/telemetry/engagement.py +238 -0
- package/.agent-src/templates/scripts/telemetry/report_renderer.py +170 -0
- package/.agent-src/templates/scripts/telemetry/settings.py +112 -0
- package/.agent-src/templates/scripts/telemetry_record.py +166 -0
- package/.agent-src/templates/scripts/telemetry_report.py +161 -0
- package/.agent-src/templates/scripts/telemetry_status.py +142 -0
- package/.agent-src/templates/scripts/work_engine/__init__.py +58 -0
- package/.agent-src/templates/scripts/work_engine/__main__.py +9 -0
- package/.agent-src/templates/scripts/work_engine/cli.py +592 -0
- package/.agent-src/templates/scripts/{implement_ticket → work_engine}/delivery_state.py +7 -0
- package/.agent-src/templates/scripts/work_engine/directives/__init__.py +33 -0
- package/.agent-src/templates/scripts/work_engine/directives/backend/__init__.py +98 -0
- package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/analyze.py +1 -1
- package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/implement.py +2 -2
- package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/memory.py +1 -1
- package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/plan.py +1 -1
- package/.agent-src/templates/scripts/work_engine/directives/backend/refine.py +396 -0
- package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/report.py +36 -4
- package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/test.py +2 -2
- package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/verify.py +2 -2
- package/.agent-src/templates/scripts/work_engine/directives/mixed/__init__.py +116 -0
- package/.agent-src/templates/scripts/work_engine/directives/mixed/contract.py +254 -0
- package/.agent-src/templates/scripts/work_engine/directives/mixed/stitch.py +229 -0
- package/.agent-src/templates/scripts/work_engine/directives/mixed/ui.py +231 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui/__init__.py +113 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui/_passthrough.py +44 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui/apply.py +241 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui/audit.py +414 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui/design.py +335 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui/polish.py +510 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui/review.py +468 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/__init__.py +119 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/_skipped.py +37 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/apply.py +165 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/refine.py +66 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/report.py +62 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/test.py +115 -0
- package/.agent-src/templates/scripts/work_engine/dispatcher.py +331 -0
- package/.agent-src/templates/scripts/work_engine/hooks/__init__.py +54 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +32 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/_chat_history_base.py +103 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_append.py +44 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_halt_append.py +42 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_heartbeat.py +50 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_turn_check.py +49 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/directive_set_guard.py +53 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/halt_surface_audit.py +50 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/state_shape_validation.py +52 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/trace.py +84 -0
- package/.agent-src/templates/scripts/work_engine/hooks/context.py +66 -0
- package/.agent-src/templates/scripts/work_engine/hooks/events.py +44 -0
- package/.agent-src/templates/scripts/work_engine/hooks/exceptions.py +79 -0
- package/.agent-src/templates/scripts/work_engine/hooks/registry.py +60 -0
- package/.agent-src/templates/scripts/work_engine/hooks/runner.py +73 -0
- package/.agent-src/templates/scripts/work_engine/hooks/settings.py +141 -0
- package/.agent-src/templates/scripts/work_engine/intent/__init__.py +47 -0
- package/.agent-src/templates/scripts/work_engine/intent/classify.py +280 -0
- package/.agent-src/templates/scripts/work_engine/migration/__init__.py +8 -0
- package/.agent-src/templates/scripts/work_engine/migration/v0_to_v1.py +199 -0
- package/.agent-src/templates/scripts/work_engine/resolvers/__init__.py +22 -0
- package/.agent-src/templates/scripts/work_engine/resolvers/diff.py +106 -0
- package/.agent-src/templates/scripts/work_engine/resolvers/file.py +113 -0
- package/.agent-src/templates/scripts/work_engine/resolvers/prompt.py +90 -0
- package/.agent-src/templates/scripts/work_engine/scoring/__init__.py +14 -0
- package/.agent-src/templates/scripts/work_engine/scoring/confidence.py +300 -0
- package/.agent-src/templates/scripts/work_engine/stack/__init__.py +31 -0
- package/.agent-src/templates/scripts/work_engine/stack/detect.py +187 -0
- package/.agent-src/templates/scripts/work_engine/state.py +641 -0
- package/.claude-plugin/marketplace.json +105 -2
- package/AGENTS.md +36 -8
- package/CHANGELOG.md +558 -0
- package/README.md +146 -4
- package/composer.json +3 -0
- package/config/agent-settings.template.yml +45 -0
- package/config/gitignore-block.txt +4 -0
- package/docs/architecture.md +28 -1
- package/docs/development.md +1 -1
- package/docs/getting-started.md +3 -2
- package/docs/installation.md +86 -0
- package/docs/showcase.md +204 -0
- package/package.json +9 -1
- package/scripts/agent-config +274 -0
- package/scripts/audit_cloud_compatibility.py +288 -0
- package/scripts/build_cloud_bundle.py +458 -0
- package/scripts/build_linear_digest.py +263 -0
- package/scripts/chat_history.py +796 -7
- package/scripts/check_compression.py +139 -0
- package/scripts/check_iron_law_prominence.py +143 -0
- package/scripts/check_md_language.py +159 -0
- package/scripts/check_portability.py +36 -0
- package/scripts/check_reply_consistency.py +140 -0
- package/scripts/command_suggester/__init__.py +51 -0
- package/scripts/command_suggester/cooldown.py +132 -0
- package/scripts/command_suggester/loader.py +70 -0
- package/scripts/command_suggester/match.py +180 -0
- package/scripts/command_suggester/rank.py +120 -0
- package/scripts/command_suggester/render.py +86 -0
- package/scripts/command_suggester/sanitize.py +113 -0
- package/scripts/command_suggester/settings.py +125 -0
- package/scripts/command_suggester/types.py +78 -0
- package/scripts/hooks/augment-chat-history.sh +56 -0
- package/scripts/install-hooks.sh +67 -0
- package/scripts/install.py +150 -33
- package/scripts/lint_marketplace.py +27 -0
- package/scripts/memory_lookup.py +143 -7
- package/scripts/memory_status.py +76 -14
- package/scripts/migrate_command_suggestions.py +151 -0
- package/scripts/postinstall.sh +16 -0
- package/scripts/schemas/command.schema.json +41 -0
- package/scripts/skill_linter.py +67 -0
- package/scripts/sync_agent_settings.py +42 -12
- package/templates/consumer-settings/augment-cli-hooks.json +54 -0
- package/templates/consumer-settings/claude-settings.json +55 -1
- package/.agent-src/templates/scripts/implement_ticket/cli.py +0 -171
- package/.agent-src/templates/scripts/implement_ticket/dispatcher.py +0 -134
- package/.agent-src/templates/scripts/implement_ticket/steps/__init__.py +0 -49
- package/.agent-src/templates/scripts/implement_ticket/steps/refine.py +0 -140
- /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."
|