Skip to content

Commit 27c813c

Browse files
author
loong10k
committed
feat: 门禁触发词边界 + 定向语言子集 + MCP 官方 SDK 三工具 + 失败日志落盘
OpenSpec: 2026-09-22-fix-gate-trigger-and-mcp(组 1/2/3/4) - 触发启发式:英文触发词改词边界(pushed/deployment 误触发清零), 行动意图或句首祈使才进门禁;QUESTION_MARKERS 加回归锁定 - 定向语言:_detect_languages_in_text 从消息提取语言子集, run_gate 支持 languages 参数;无命中回退全量探测 - MCP:run_check.py --mcp 走官方 mcp SDK(1.26),暴露 check_code_style / auto_fix / list_languages 三工具;根 requirements.txt - 失败日志:run_per_language 完整 stdout+stderr 合并落盘 <log_dir>/.codeguard-last.log,CLI --log-dir/--quiet,失败打摘要行+日志路径 测试:tests/test_gate_trigger.py(7) + tests/test_mcp_server.py(5) 全绿
1 parent 7fa79ab commit 27c813c

14 files changed

Lines changed: 744 additions & 35 deletions

File tree

‎hooks/gate_lib.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,11 @@ def _gate_cache_key(project_root: Path, languages: list) -> str | None:
122122
GATE_CACHE_TTL = 60 # 秒:UPS 软门禁与紧随的 PreToolUse 硬门禁之间复用
123123

124124

125-
def run_gate(project_root: Path, cfg: dict) -> tuple[list, list]:
126-
"""运行全量 linter 门禁(跨进程结果缓存 + 并行执行)。
125+
def run_gate(project_root: Path, cfg: dict, languages: list | None = None) -> tuple[list, list]:
126+
"""运行 linter 门禁(跨进程结果缓存 + 并行执行)。
127+
128+
`languages` 可选:调用方(UserPromptSubmit)可传入用户消息里提到的
129+
语言子集,门禁只跑该子集;不传则按 `detect_languages()` 全量探测。
127130
128131
软门禁(UserPromptSubmit)与硬门禁(PreToolUse)在正常提交路径上
129132
会对同一状态连跑两次全量 lint——缓存键含 HEAD 与暂存区指纹,
@@ -134,7 +137,8 @@ def run_gate(project_root: Path, cfg: dict) -> tuple[list, list]:
134137
- skipped: [str] 无法验证的说明(工具未装/超时),不阻塞
135138
"""
136139
import time as _time
137-
languages = detect_languages(project_root)
140+
if languages is None:
141+
languages = detect_languages(project_root)
138142
if not languages:
139143
return [], []
140144
enabled = cfg.get("enabled_languages", [])

‎hooks/user_prompt_validator.py‎

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,24 @@
2929
)
3030

3131
# 触发此钩子的关键词(中英)
32+
# 英文按词边界匹配(防止 pushed/deployment 误命中),中文子串即可。
33+
# 2026-09-22 实测回归:`我刚才 pushed 了`、`关于 deployment 策略的讨论`
34+
# 必须静默(子串匹配曾把它们当提交意图跑全仓 lint)。
35+
import re as _re
36+
3237
TRIGGER_PATTERNS = [
3338
"commit", "push", "deploy", "提交", "发布", "部署",
3439
]
40+
_TRIGGER_RE = _re.compile(
41+
r"\b(?:commit|push|deploy)\b|提交|发布|部署",
42+
_re.IGNORECASE,
43+
)
44+
# 行动意图短语:触发词出现且(句首祈使 或 命中行动短语)才进门禁。
45+
_ACTION_INTENT_RE = _re.compile(
46+
r"请帮我|帮我|请|马上|立刻|现在|下一步|继续|please|now|next|proceed|go ahead|"
47+
r"commit this|push this|deploy this",
48+
_re.IGNORECASE,
49+
)
3550

3651

3752
def notify(title: str, message: str) -> None:
@@ -57,12 +72,41 @@ def notify(title: str, message: str) -> None:
5772

5873

5974
def is_trigger(user_text: str) -> bool:
75+
"""触发条件:(1) 命中触发词(英文按词边界)(2) 非疑问句 (3) 行动意图或句首祈使。
76+
77+
(3) 缺一不可:`我刚才 pushed 了` 有触发词但无行动意图 → 静默;
78+
`commit this now` 句首祈使 → 触发;`请帮我 commit` 行动短语 → 触发。
79+
"""
6080
if not user_text:
6181
return False
62-
text = user_text.lower()
63-
if not any(p in text for p in TRIGGER_PATTERNS):
82+
if any(q in user_text for q in QUESTION_MARKERS):
83+
return False
84+
m = _TRIGGER_RE.search(user_text)
85+
if not m:
6486
return False
65-
return not any(q in user_text for q in QUESTION_MARKERS)
87+
# 句首祈使:触发词本身(或前面只有空白/常见介词)位于句首。
88+
stripped = user_text.lstrip()
89+
leading = stripped[: m.start()].strip().lower()
90+
if leading in ("", "git", "to", "the", "a", "an", "帮我", "请", "请帮我"):
91+
return True
92+
return bool(_ACTION_INTENT_RE.search(user_text))
93+
94+
95+
def _detect_languages_in_text(user_text: str, language_ids: list[str]) -> list[str]:
96+
"""从用户消息中提取提到的语言子集(2.1/2.2/2.3)。
97+
98+
与 `languages.json` 的 `id` 字段做大小写无关匹配;没有命中返回空列表,
99+
由调用方回退到 `detect_languages()` 全量探测(保持原有能力)。
100+
"""
101+
if not user_text or not language_ids:
102+
return []
103+
text = user_text.lower()
104+
hits = []
105+
for lang_id in language_ids:
106+
# 词边界避免 go 命中 gone/going、c 命中 cmake 等子串误命中。
107+
if _re.search(rf"\b{_re.escape(lang_id.lower())}\b", text):
108+
hits.append(lang_id)
109+
return hits
66110

67111

68112
def read_user_text() -> str:
@@ -86,7 +130,10 @@ def main() -> int:
86130

87131
cfg = load_user_config()
88132
project_root = find_project_root(os.getcwd()) or Path(os.getcwd())
89-
failures, skipped = run_gate(project_root, cfg)
133+
# 2.2: 消息里提到了具体语言时只跑子集;没提到则回退全量探测(2.3)。
134+
detected = detect_languages(project_root)
135+
subset = _detect_languages_in_text(user_text, detected)
136+
failures, skipped = run_gate(project_root, cfg, languages=(subset or None))
90137
# 提交内容安全检查:.venv/node_modules/.env/密钥等不应入库
91138
violations = check_commit_safety(project_root, "commit")
92139

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
schema: spec-driven
2+
created: 2026-09-22
3+
goal: 修复提交门禁触发启发式的过度敏感,让 MCP server 真正可用,并改进 linter 失败信息的可读性。
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# fix-gate-trigger-and-mcp
2+
3+
修复 codeguard-plugin UserPromptSubmit 钩子的触发启发式过于敏感、让 `--mcp` 入口真正可用、并改进 linter 失败信息的可读性。
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
## Context
2+
3+
本变更的判定来自 2026-09-22 codeguard-plugin 实测排错:同一 session 里 codeguard-plugin 0.6.5 已发布,被消费的 plugin(stitch-design-plugin、codeguard-skills 等)作为基础设施出错时的第一道防线。
4+
5+
- **触发启发式现状**(2026-09-22 实测复核):`is_trigger()` 用**子串**匹配触发词;`QUESTION_MARKERS` 已同时覆盖 `?` 与 `?`。因此纯英文问句 `Should I commit this?` 本来就被排除。**真实漏洞是子串匹配**:`pushed` 命中 `push`、`deployment` 命中 `deploy`——不含行动意图的动词变体照样跑全仓 lint。修复方向是词边界 + 行动意图组合,而非再扩问号表。
6+
- **MCP 现状**:`scripts/run_check.py:115-123` 写 "MCP server mode not yet wired with official SDK."。`commands/check.json` 和 `commands/fix.json` 提示词里只指引 `python3 scripts/run_check.py`,没有提 MCP。意味着 MCP 入口是"摆设",agent 唯一可用路径仍是 shell 子进程。
7+
- **失败输出现状**:`scripts/run_check.py:96` 取 stderr 最后 3 行的第 1 行再截 120 字符。实测 ruff 对单条违规的描述就超过 120 字符(路径 + 行号 + 错误码 + 描述),截断后用户得重跑或加 `--output-format=concise` 才能定位。
8+
9+
## Goals / Non-Goals
10+
11+
**Goals:**
12+
13+
- 让"使用过程中的情况"这种含"发布"二字但意图为询问的句子不再误触发全仓 lint。
14+
- 让英文问句(带 `?`)同样被识别为非提交意图。
15+
- 触发后只跑用户消息中实际提到的语言(提速 + 减少噪声)。
16+
- `--mcp` 走官方 `mcp` Python SDK,暴露三个工具;agent 不再被强制 spawn 子进程。
17+
- 失败时 stderr 完整写入磁盘,终端只给摘要。
18+
19+
**Non-Goals:**
20+
21+
- 不修改 `commands/check.json` / `commands/fix.json` 的提示词结构(已存在的命令可继续工作;仅在 README 里增加 MCP 用法说明)。
22+
- 不修改 vendored 技能(codeguard-* 走 vendor 流程;本变更涉及的语言与脚本都是仓内自有代码)。
23+
- 不重写 `commands/init.json` / `assets/templates/pre-commit-config.yaml`(与本变更无直接关系,且已在前置审计中标记为独立改进项)。
24+
- 不引入自动 commit / 自动 push——`/check` 与 `/fix` 仍只读 + 只修,提交门禁仍由 PreToolUse 钩子把守。
25+
26+
## Decisions
27+
28+
1. **触发判定改为词边界 + 行动意图双条件**:先 `re.search(r"\b(commit|push|deploy)\b|提交|发布|部署", text, re.I)`(中文词无边界问题,按原样保留子串;英文词加 `\b`)。再要求同时命中行动意图:`re.search(r"(请帮我|帮我|请|马上|立刻|现在|下一步|继续|please|now|next|proceed|go ahead|commit this|push this|deploy this)", text, re.I)` **或** 触发词位于句首(祈使语气,如 `commit this now`)。两者其一即进入 lint 路径;否则静默退出 0。`QUESTION_MARKERS` 仅加回归用例,不改内容。
29+
2. **英文 `?` 加入 `QUESTION_MARKERS`**:把 `?` 加入列表,并把"问句句尾 `?` 紧跟在触发词后"做特例豁免(提问「要不要 commit 这个?」即使包含 commit 也不进入门禁)。
30+
3. **trigger 后只跑检测到的语言**:从用户消息中正则提取候选语言 token(与 `languages.json` 的 `id` 字段做大小写无关匹配),命中的子集运行;没有命中则 fallback 到 `detect_languages()` 全量(保持原有探测能力)。
31+
4. **`--mcp` 走官方 SDK**:使用 `mcp.server.Server` + `stdio_server()` 注册三个工具:
32+
- `check_code_style(path, languages?)`:跑 lint,返回 `{language, passed, exit_code, stderr_path}` 结构。
33+
- `auto_fix(path, languages?)`:调 formatter,再跑 lint 验证。
34+
- `list_languages()`:返回 `languages.json` 的 `id` 与 `name`,便于 agent 端校验输入。
35+
删掉占位 `mcp_main` 分支;新增 `requirements-test.txt` 引入 `mcp>=1.0`。
36+
5. **失败信息落盘**:`scripts/run_check.py` 增加一个可选 `--log-dir` 参数(默认 `out/`),将完整 stderr 写到 `out/.codeguard-last.log`;终端输出改为"完整日志:<abs path>"。保留 `--quiet` 反向开关用于 CI。
37+
38+
## Risks / Trade-offs
39+
40+
- **触发判定变严后真触发是否会漏判?**:在排错里手工构造 12 条真实用户消息("请帮我 commit"、"commit this" 等)逐条验证。漏判成本 = 用户主动提交时不被门禁拦下,违反 #1 优先级(保安全)。需要红蓝两套回归。
41+
- **MCP 引入新依赖**:`mcp` SDK 是 1.0+ 包,依赖较重。在 vendored 技能外,仓内 `requirements-test.txt` 增加一行 `mcp>=1.0`;CI 安装会自动取到。
42+
- **log 文件泄漏**:写到 `out/.codeguard-last.log` 默认随仓库 `.gitignore`(`out/` 已忽略),不污染版本库;若仓库没忽略 `out/`,本变更需在 `design.md` Migration Plan 加一行 `.gitignore` 检查。
43+
44+
## Migration Plan
45+
46+
1. 修改 `hooks/user_prompt_validator.py`:扩展 `TRIGGER_PATTERNS` / `QUESTION_MARKERS`,新增 `_detect_languages_in_text` 辅助函数,在 `main()` 中按子集运行门禁。
47+
2. 重写 `scripts/run_check.py` 的失败输出逻辑与 `mcp_main()` 实现。
48+
3. 在 `requirements-test.txt` 增加 `mcp>=1.0`。
49+
4. 在 README.md 与 README.zh-CN.md 补 MCP 用法一段(不修改 commands/check.json 本身)。
50+
5. 跑 `python3 tests/run_all.py`(141 项)+ `python3 -m unittest discover -s tests -p 'test_*.py'` 全绿。
51+
6. `openspec validate --strict` 通过后按 `AGENTS.md` 强制发版规则 bump minor 版本(MCP 服务器属新功能:0.6.6 → 0.7.0),归档本变更。
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
## Why
2+
3+
本变更解决三个在真实使用中持续踩中的痛点:
4+
5+
1. **提交门禁触发过于敏感(子串匹配)**:`hooks/user_prompt_validator.py` 的 `is_trigger()` 对 `TRIGGER_PATTERNS = ["commit", "push", "deploy", "提交", "发布", "部署"]` 做**子串**匹配。实测(2026-09-22)真实误触发样例:`我刚才 pushed 了`(命中 `push`)、`关于 deployment 策略的讨论`(命中 `deploy`)——历史行为记录在该文件 `QUESTION_MARKERS` 的注释里(`「git 提交和推送,是不能把 .venv 排除掉么」被当成提交意图`)。纯词边界匹配可消除这类误触发;同时 `?` 已在 `QUESTION_MARKERS` 中,但**触发词与行动意图的组合判定**缺失,导致"非提交语境里的动词"照样进全仓门禁。
6+
2. **`--mcp` 是占位**:`scripts/run_check.py:115-123` 的 `mcp_main()` 明文写"MCP server mode not yet wired with official SDK"。文档承诺的 MCP server 入口实际不可用,AI agent 想在不改 shell spawn 的前提下复用 codeguard 失败,只能重新发明。
7+
3. **失败输出截断**:`scripts/run_check.py:96` 失败时仅 `stderr.splitlines()[-3:][0][:120]`,路径 + 错误码 + 长描述常超过 120 字符,截断后用户得自己翻完整日志。
8+
9+
## What Changes
10+
11+
- 触发启发式改为词边界匹配 + 行动意图组合判定(触发词必须出现在祈使/请求语境,而非任意子串),trigger 后只跑"当前消息提到的语言"而非 `detect_languages()` 的全量。`QUESTION_MARKERS` 保留现有中英双标点并加回归用例锁定。
12+
- `--mcp` 走官方 `mcp` Python SDK:注册 `check_code_style` / `auto_fix` / `list_languages` 三个 stdio JSON-RPC 工具,删除占位分支。
13+
- linter 失败:完整 stderr 写入 `out/.codeguard-last.log`,终端只显示问题摘要与日志路径。
14+
15+
## Capabilities
16+
17+
### New Capabilities
18+
19+
- `gate-trigger-policy`: Defines when the UserPromptSubmit hook runs the lint gate and which languages it checks; explicitly documents when it stays silent.
20+
- `mcp-tool-server`: Defines the stdio JSON-RPC surface exposed by `scripts/run_check.py --mcp`, the three tools, their inputs and outputs, and the failure envelope.
21+
22+
### Modified Capabilities
23+
24+
None.
25+
26+
## Impact
27+
28+
- `hooks/user_prompt_validator.py`:触发判定与语言选择逻辑改动;外部行为变化仅在误触发场景(更少误触发为正向变化)。
29+
- `scripts/run_check.py`:新增官方 SDK 依赖(`mcp` 包需写入 `requirements.txt` 或类似清单),新增 `out/.codeguard-last.log` 落盘逻辑,删除占位 `mcp_main` 分支。
30+
- 新建根 `requirements.txt` 声明 `mcp>=1.0`(本仓当前没有任何 requirements 文件;`mcp` 1.26.0 已在开发环境安装,CI 需显式安装)。
31+
- 不修改 vendored 技能(codeguard-* 走 vendor 流程);不修改 marketplace 清单。
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
## ADDED Requirements
2+
3+
### Requirement: The UserPromptSubmit hook SHALL run the lint gate only when intent is commit-like
4+
The `UserPromptSubmit` hook MUST evaluate the user's prompt for commit intent using word-boundary matching of trigger words (`commit|push|deploy|发布|部署`) and a separate action-verb check. The hook SHALL exit silently when either (a) no trigger word is present, (b) the only trigger occurrence is followed by `?`, or (c) a trigger word appears in an interrogative sentence identified by `QUESTION_MARKERS` (`?`, `?`, `么`, `吗`, `如何`, `怎么`, `有没有`, `是不是`, `什么是`, `哪些`).
5+
6+
#### Scenario: User asks a question containing a trigger word
7+
8+
- **WHEN** the user prompt is `使用过程中的情况` or `Should I commit this?` or `先读项目结构`
9+
- **THEN** the hook exits 0 with empty stdout; no linter runs
10+
11+
#### Scenario: User issues a commit-like imperative
12+
13+
- **WHEN** the user prompt is `请帮我 commit` or `commit this now` or `请帮我 push`
14+
- **THEN** the hook enters the gate path and invokes `run_gate()`
15+
16+
#### Scenario: Trigger word followed by question mark is exempt
17+
18+
- **WHEN** the user prompt is `commit?` or `要不要 push?` or `should I deploy?`
19+
- **THEN** the hook exits silently regardless of other QUESTION_MARKERS content
20+
21+
### Requirement: The gate SHALL run only languages named in the user message when they can be identified
22+
When the trigger fires, the hook MUST extract candidate language ids from the user prompt using case-insensitive token matching against `scripts/languages.json` ids. When the intersection is non-empty, the gate SHALL run only that subset. When the intersection is empty, the gate SHALL fall back to the existing `detect_languages()` behavior.
23+
24+
#### Scenario: User mentions one language explicitly
25+
26+
- **WHEN** the user prompt is `请帮我 commit 这次 ruff 改动`
27+
- **THEN** the gate runs only Python; other detected languages are skipped for this invocation
28+
29+
#### Scenario: User mentions no language
30+
31+
- **WHEN** the user prompt is `请帮我 commit` with no language token
32+
- **THEN** the gate runs every language detected by `detect_languages()` (current behavior preserved)
33+
34+
### Requirement: Question markers SHALL cover both Chinese and English
35+
`QUESTION_MARKERS` MUST include the ASCII `?` in addition to the existing Chinese markers, so that an English prompt ending with `?` is recognized as a question even without other markers.
36+
37+
#### Scenario: ASCII question mark without any other marker
38+
39+
- **WHEN** the user prompt is `Should I push this?`
40+
- **THEN** the hook exits silently because `?` is a recognized question marker
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
## ADDED Requirements
2+
3+
### Requirement: The MCP server SHALL expose three tools over stdio JSON-RPC
4+
`scripts/run_check.py --mcp` MUST start an stdio JSON-RPC server using the official `mcp` Python SDK and register exactly three tools: `check_code_style`, `auto_fix`, and `list_languages`. The server MUST NOT take any other CLI flag in MCP mode.
5+
6+
#### Scenario: Listing tools at startup
7+
8+
- **WHEN** a client sends `tools/list`
9+
- **THEN** the response enumerates `check_code_style`, `auto_fix`, `list_languages` with their input schemas
10+
11+
### Requirement: `check_code_style` SHALL return a structured envelope with full stderr on disk
12+
`check_code_style(path, languages?)` MUST run the configured linters, write the full stderr of any failing invocation to `<log_dir>/.codeguard-last.log` (default `<project>/out/.codeguard-last.log`), and return a JSON envelope with one entry per language: `{language, passed: bool, exit_code: int, stderr_path: str, log_path: str}`.
13+
14+
#### Scenario: One language fails
15+
16+
- **WHEN** the path contains a single Python project whose ruff lint fails
17+
- **THEN** the envelope contains one entry with `passed=false`, `exit_code` from ruff, `stderr_path` pointing at the on-disk log, and `log_path` echoing the same path
18+
19+
#### Scenario: All languages pass
20+
21+
- **WHEN** every linter exits 0
22+
- **THEN** every entry has `passed=true`, `exit_code=0`, and the log file is either empty or absent (no stderr written)
23+
24+
### Requirement: `auto_fix` SHALL run formatters and re-verify
25+
`auto_fix(path, languages?)` MUST invoke the per-language formatter chain (`format` from `scripts/languages.json`) and then re-run `check_code_style` on the same scope before returning. The response envelope MUST contain both `fixed: bool` (whether any file was modified) and the `check_code_style` envelope for the post-fix re-check.
26+
27+
#### Scenario: Formatter succeeds and lint passes
28+
29+
- **WHEN** ruff `--fix` removes unused imports and the re-check passes
30+
- **THEN** `fixed=true` and the embedded `check_code_style` envelope is all-pass
31+
32+
#### Scenario: Formatter succeeds but lint still fails
33+
34+
- **WHEN** ruff `--fix` ran but a remaining error is non-fixable
35+
- **THEN** `fixed=true` and the embedded `check_code_style` envelope contains at least one failing entry
36+
37+
### Requirement: `list_languages` SHALL return id + name only
38+
`list_languages()` MUST return a JSON array of `{id, name}` objects from `scripts/languages.json`, omitting the rest of each language record (lint command, format command, install hint, etc.) so internal commands are not exposed as part of the MCP surface.
39+
40+
#### Scenario: Listing all supported languages
41+
42+
- **WHEN** the client calls `list_languages`
43+
- **THEN** the response enumerates every entry in `scripts/languages.json` with exactly two fields (`id`, `name`) per record

0 commit comments

Comments
 (0)