@michaelxwb/code-flow 0.5.2 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/migrations/spec-workflow-0.6.0.md +42 -0
- package/package.json +4 -2
- package/src/adapters/claude/CLAUDE.md +11 -17
- package/src/adapters/claude/commands/cf-init.md +5 -4
- package/src/adapters/claude/commands/cf-spec.md +63 -0
- package/src/adapters/claude/commands/cf-sync.md +25 -0
- package/src/adapters/claude/commands/cf-task/align.md +23 -1
- package/src/adapters/claude/commands/cf-task/archive.md +7 -0
- package/src/adapters/claude/commands/cf-task/plan.md +16 -0
- package/src/adapters/claude/commands/cf-task/prd.md +24 -0
- package/src/adapters/claude/commands/cf-task/start.md +13 -10
- package/src/adapters/claude/settings.local.json +1 -11
- package/src/adapters/codex/AGENTS.md +11 -17
- package/src/adapters/codex/hooks.json +6 -4
- package/src/adapters/codex/skills/cf-init/SKILL.md +5 -4
- package/src/adapters/codex/skills/cf-spec/SKILL.md +56 -0
- package/src/adapters/codex/skills/cf-sync/SKILL.md +30 -0
- package/src/adapters/codex/skills/cf-task-align/SKILL.md +23 -1
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +7 -0
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +16 -0
- package/src/adapters/codex/skills/cf-task-prd/SKILL.md +24 -0
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +13 -10
- package/src/adapters/costrict/CLAUDE.md +11 -17
- package/src/adapters/costrict/commands/cf-init.md +5 -4
- package/src/adapters/costrict/commands/cf-spec.md +63 -0
- package/src/adapters/costrict/commands/cf-sync.md +25 -0
- package/src/adapters/costrict/commands/cf-task/align.md +23 -1
- package/src/adapters/costrict/commands/cf-task/archive.md +7 -0
- package/src/adapters/costrict/commands/cf-task/plan.md +16 -0
- package/src/adapters/costrict/commands/cf-task/prd.md +24 -0
- package/src/adapters/costrict/commands/cf-task/start.md +13 -10
- package/src/adapters/costrict/settings.local.json +1 -11
- package/src/adapters/opencode/AGENTS.md +11 -17
- package/src/adapters/opencode/commands/cf-init.md +4 -3
- package/src/adapters/opencode/commands/cf-spec.md +55 -0
- package/src/adapters/opencode/commands/cf-sync.md +29 -0
- package/src/adapters/opencode/commands/cf-task/align.md +24 -1
- package/src/adapters/opencode/commands/cf-task/archive.md +8 -0
- package/src/adapters/opencode/commands/cf-task/plan.md +17 -0
- package/src/adapters/opencode/commands/cf-task/prd.md +25 -0
- package/src/adapters/opencode/commands/cf-task/start.md +14 -10
- package/src/adapters/opencode/plugins/code-flow/index.js +3 -4
- package/src/cli.js +51 -2
- package/src/core/code-flow/config.yml +14 -16
- package/src/core/code-flow/scripts/cf_checks.py +30 -0
- package/src/core/code-flow/scripts/cf_core.py +36 -256
- package/src/core/code-flow/scripts/cf_log.py +8 -0
- package/src/core/code-flow/scripts/cf_post_hook.py +6 -9
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +88 -0
- package/src/core/code-flow/scripts/cf_session_state.py +37 -0
- package/src/core/code-flow/scripts/cf_spec_context.py +1470 -0
- package/src/core/code-flow/scripts/cf_spec_gate.py +230 -0
- package/src/core/code-flow/scripts/cf_spec_metadata.py +304 -0
- package/src/core/code-flow/scripts/cf_spec_migrate.py +512 -0
- package/src/core/code-flow/scripts/cf_spec_resolver.py +271 -0
- package/src/core/code-flow/scripts/cf_spec_router.py +149 -0
- package/src/core/code-flow/scripts/cf_spec_session.py +118 -0
- package/src/core/code-flow/scripts/cf_spec_verify.py +340 -0
- package/src/core/code-flow/scripts/cf_stats.py +40 -5
- package/src/core/code-flow/scripts/cf_stop_hook.py +43 -2
- package/src/core/code-flow/scripts/cf_sync.py +140 -0
- package/src/core/code-flow/scripts/cf_task_runtime.py +184 -0
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +115 -345
- package/src/core/code-flow/specs/backend/code-quality-performance.md +13 -1
- package/src/core/code-flow/specs/backend/database.md +13 -1
- package/src/core/code-flow/specs/backend/directory-structure.md +13 -1
- package/src/core/code-flow/specs/backend/logging.md +13 -1
- package/src/core/code-flow/specs/backend/platform-rules.md +13 -1
- package/src/core/code-flow/specs/frontend/component-specs.md +13 -1
- package/src/core/code-flow/specs/frontend/directory-structure.md +13 -1
- package/src/core/code-flow/specs/frontend/quality-standards.md +13 -1
- package/src/core/code-flow/specs/shared/design/design-frontend.md +10 -0
- package/src/core/code-flow/specs/shared/design/design-full.md +10 -0
- package/src/core/code-flow/specs/shared/design/design-lite.md +10 -0
- package/src/core/code-flow/specs/shared/prd-template.md +14 -3
- package/src/migrate/spec-workflow.js +238 -0
- package/src/adapters/claude/commands/cf-inject.md +0 -55
- package/src/adapters/codex/skills/cf-inject/SKILL.md +0 -56
- package/src/adapters/costrict/commands/cf-inject.md +0 -55
- package/src/adapters/opencode/commands/cf-inject.md +0 -59
- package/src/core/code-flow/scripts/cf_inject_hook.py +0 -178
- package/src/core/code-flow/scripts/cf_session_hook.py +0 -42
|
@@ -1,157 +1,108 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
2
|
+
"""UserPromptSubmit hook for the Context-first three-branch router."""
|
|
3
3
|
|
|
4
|
-
This hook works for both Claude Code and Codex adapters. It extracts:
|
|
5
|
-
1. File paths from the prompt (for context_tags)
|
|
6
|
-
2. Keywords from the prompt (for prompt_tags via extract_prompt_tags)
|
|
7
|
-
|
|
8
|
-
Both tag sets are used to match specs for injection.
|
|
9
|
-
|
|
10
|
-
Input (stdin): {"prompt": "...", "session_id": "..."}
|
|
11
|
-
Output (stdout): {"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": "..."}}
|
|
12
|
-
or empty on no-op
|
|
13
|
-
"""
|
|
14
4
|
import json
|
|
15
5
|
import os
|
|
16
6
|
import re
|
|
17
7
|
import sys
|
|
8
|
+
|
|
18
9
|
import cf_log
|
|
10
|
+
from cf_checks import detect_correction
|
|
19
11
|
from cf_core import (
|
|
20
12
|
_log,
|
|
21
|
-
assemble_context,
|
|
22
|
-
build_effective_mapping,
|
|
23
|
-
build_spec_catalog,
|
|
24
|
-
resolve_quality_loop,
|
|
25
|
-
debug_log,
|
|
26
13
|
ensure_utf8_io,
|
|
27
|
-
estimate_tokens,
|
|
28
|
-
extract_context_tags,
|
|
29
|
-
extract_prompt_tags,
|
|
30
|
-
fallback_domains_for_context,
|
|
31
14
|
load_config,
|
|
32
|
-
load_inject_state,
|
|
33
|
-
match_domains,
|
|
34
|
-
match_specs_by_tags,
|
|
35
15
|
normalize_path,
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
resolve_inject_mode,
|
|
16
|
+
resolve_enforcement,
|
|
17
|
+
resolve_quality_loop,
|
|
39
18
|
resolve_session_id,
|
|
40
|
-
save_inject_state,
|
|
41
|
-
select_specs_tiered,
|
|
42
19
|
)
|
|
20
|
+
from cf_session_state import load_session_state, save_session_state
|
|
21
|
+
from cf_spec_router import RouterError, route_prompt
|
|
43
22
|
|
|
44
|
-
# Pseudo spec-path key used in prompt_inject_window to dedup catalog emission.
|
|
45
|
-
_CATALOG_KEY = "__catalog__"
|
|
46
23
|
|
|
47
|
-
# Match bare paths, @-prefixed paths, and backtick-quoted paths.
|
|
48
|
-
# Right boundary uses an ASCII-only negative lookahead instead of \b. Python's
|
|
49
|
-
# \b is Unicode-aware on every platform, so "src/cli.js中" never terminated
|
|
50
|
-
# the match — common Chinese phrasing dropped real paths. The explicit ASCII
|
|
51
|
-
# class is locale- and OS-independent, so Windows/macOS/Linux behave the same.
|
|
52
|
-
# Character class includes `\` so Windows-style paths (`src\cli.js`) pasted
|
|
53
|
-
# from Explorer also match; `normalize_path()` downstream converts to `/`.
|
|
54
24
|
_PATH_RE = re.compile(r'[@`]?([a-zA-Z0-9_.][a-zA-Z0-9_./\\\-]*\.[a-zA-Z]{1,6})(?![a-zA-Z0-9_])')
|
|
55
25
|
_EXT_RE = re.compile(r'\.(py|js|ts|go|rs|java|rb|cs|cpp|c|h)$')
|
|
56
26
|
|
|
27
|
+
_COMPRESS_INTERVAL = 25
|
|
28
|
+
_REINJECT_INTERVAL = 25
|
|
29
|
+
_REMINDER_TEXT = (
|
|
30
|
+
"⚠ 本会话已处理较多轮次,上下文较长;批量任务建议在任务间压缩上下文,或拆到新会话继续。"
|
|
31
|
+
)
|
|
32
|
+
_WARN_TEXT = "⚠ Spec Workflow 校验未通过(warn 模式):{message} — 建议运行 cf-spec doctor 检查。"
|
|
33
|
+
|
|
57
34
|
|
|
58
|
-
def extract_paths_from_prompt(prompt: str) -> list:
|
|
59
|
-
"""Extract
|
|
35
|
+
def extract_paths_from_prompt(prompt: str) -> list[str]:
|
|
36
|
+
"""Extract explicit file paths without semantic keyword inference."""
|
|
60
37
|
paths = []
|
|
61
38
|
seen = set()
|
|
62
|
-
for
|
|
63
|
-
candidate = normalize_path(
|
|
64
|
-
|
|
65
|
-
if candidate in seen:
|
|
66
|
-
continue
|
|
67
|
-
if '/' in candidate or _EXT_RE.search(candidate):
|
|
39
|
+
for match in _PATH_RE.finditer(prompt):
|
|
40
|
+
candidate = normalize_path(match.group(1).lstrip('@`'))
|
|
41
|
+
if candidate not in seen and ('/' in candidate or _EXT_RE.search(candidate)):
|
|
68
42
|
paths.append(candidate)
|
|
69
43
|
seen.add(candidate)
|
|
70
44
|
return paths
|
|
71
45
|
|
|
72
46
|
|
|
73
|
-
def
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
) -> None:
|
|
81
|
-
"""Inject the compact spec catalog (inject.mode=catalog, no path evidence).
|
|
47
|
+
def _payload(text: str, mode: str) -> dict[str, object]:
|
|
48
|
+
payload: dict[str, object] = {
|
|
49
|
+
"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": text}
|
|
50
|
+
}
|
|
51
|
+
if os.environ.get("CF_DEBUG") == "1":
|
|
52
|
+
payload["debug"] = {"mode": mode}
|
|
53
|
+
return payload
|
|
82
54
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
""
|
|
87
|
-
try:
|
|
88
|
-
catalog_max = int(budget_cfg.get("catalog_max", 200))
|
|
89
|
-
except (ValueError, TypeError):
|
|
90
|
-
catalog_max = 200
|
|
91
|
-
catalog = build_spec_catalog(project_root, effective_mapping, catalog_max)
|
|
92
|
-
if not catalog:
|
|
93
|
-
debug_log(f"user_prompt_hook catalog_empty session={sid}", project_root)
|
|
55
|
+
|
|
56
|
+
def _record_correction(root: str, prompt: str, paths: list[str], sid: str) -> None:
|
|
57
|
+
config = load_config(root)
|
|
58
|
+
if not config or not resolve_quality_loop(config)["correction_capture"]:
|
|
94
59
|
return
|
|
60
|
+
correction = detect_correction(prompt)
|
|
61
|
+
if correction:
|
|
62
|
+
cf_log.append_event(root, "correction", {"phrase": correction["phrase"], "prompt_head": prompt[:200], "files": paths}, sid)
|
|
95
63
|
|
|
96
|
-
state = load_inject_state(project_root)
|
|
97
|
-
same_session = state.get("session_id") == sid
|
|
98
|
-
if same_session:
|
|
99
|
-
injected_specs = set(state.get("injected_specs") or [])
|
|
100
|
-
prompt_window = dict(state.get("prompt_inject_window") or {})
|
|
101
|
-
prompt_count = int(state.get("prompt_count") or 0) + 1
|
|
102
|
-
else:
|
|
103
|
-
injected_specs = set()
|
|
104
|
-
prompt_window = {}
|
|
105
|
-
prompt_count = 1
|
|
106
64
|
|
|
65
|
+
def _session_reminder(root: str, sid: str) -> str:
|
|
66
|
+
"""One-line context-compress nudge once per COMPRESS_INTERVAL prompts per session.
|
|
67
|
+
|
|
68
|
+
Gated by quality_loop.compress_reminder; disabled → zero IO. Never raises: a
|
|
69
|
+
corrupt state file degrades to no reminder rather than breaking the hook.
|
|
70
|
+
"""
|
|
71
|
+
config = load_config(root)
|
|
72
|
+
if not config or not resolve_quality_loop(config)["compress_reminder"]:
|
|
73
|
+
return ""
|
|
74
|
+
state = load_session_state(root)
|
|
107
75
|
try:
|
|
108
|
-
|
|
76
|
+
if state.get("session_id") != sid:
|
|
77
|
+
state = {"session_id": sid, "prompt_count": 0, "next_remind_at": _COMPRESS_INTERVAL}
|
|
78
|
+
state["prompt_count"] = int(state.get("prompt_count", 0)) + 1
|
|
79
|
+
reminder = ""
|
|
80
|
+
if state["prompt_count"] >= int(state.get("next_remind_at", _COMPRESS_INTERVAL)):
|
|
81
|
+
reminder = _REMINDER_TEXT
|
|
82
|
+
state["next_remind_at"] = int(state["next_remind_at"]) + _COMPRESS_INTERVAL
|
|
109
83
|
except (ValueError, TypeError):
|
|
110
|
-
|
|
84
|
+
return ""
|
|
85
|
+
save_session_state(root, state)
|
|
86
|
+
return reminder
|
|
111
87
|
|
|
112
|
-
last = prompt_window.get(_CATALOG_KEY)
|
|
113
|
-
emit = (
|
|
114
|
-
dedup_window <= 0
|
|
115
|
-
or last is None
|
|
116
|
-
or prompt_count - int(last) >= dedup_window
|
|
117
|
-
)
|
|
118
|
-
if emit:
|
|
119
|
-
prompt_window[_CATALOG_KEY] = prompt_count
|
|
120
|
-
save_inject_state(project_root, {
|
|
121
|
-
"session_id": sid,
|
|
122
|
-
"injected_specs": sorted(injected_specs),
|
|
123
|
-
"last_file": state.get("last_file", "") if same_session else "",
|
|
124
|
-
"prompt_count": prompt_count,
|
|
125
|
-
"prompt_inject_window": prompt_window,
|
|
126
|
-
})
|
|
127
88
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
f"user_prompt_hook catalog_deduped session={sid} prompt_count={prompt_count}",
|
|
131
|
-
project_root,
|
|
132
|
-
)
|
|
133
|
-
return
|
|
89
|
+
def _route_with_enforcement(root: str, paths: list[str], sid: str, enforcement: str) -> tuple[object, object]:
|
|
90
|
+
"""Route with enforcement-aware failure handling.
|
|
134
91
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
"additionalContext": catalog,
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
if os.environ.get("CF_DEBUG") == "1":
|
|
153
|
-
payload["debug"] = {"mode": "catalog", "prompt_count": prompt_count}
|
|
154
|
-
sys.stdout.write(json.dumps(payload, ensure_ascii=False))
|
|
92
|
+
required — fail closed with the SPEC_WORKFLOW_BLOCKED message;
|
|
93
|
+
warn — degrade to a short advisory message;
|
|
94
|
+
inject — degrade silently.
|
|
95
|
+
Returns (result, error_payload); exactly one is not None.
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
return route_prompt(root, paths, sid), None
|
|
99
|
+
except RouterError as exc:
|
|
100
|
+
if enforcement == "required":
|
|
101
|
+
message = f"SPEC_WORKFLOW_BLOCKED [{exc.code}]: {exc}. Run cf-spec doctor before continuing."
|
|
102
|
+
return None, _payload(message, "blocked")
|
|
103
|
+
if enforcement == "warn":
|
|
104
|
+
return None, _payload(_WARN_TEXT.format(message=str(exc)), "warn")
|
|
105
|
+
return None, None
|
|
155
106
|
|
|
156
107
|
|
|
157
108
|
def main() -> None:
|
|
@@ -164,238 +115,57 @@ def main() -> None:
|
|
|
164
115
|
prompt = data.get("prompt", "")
|
|
165
116
|
if not isinstance(prompt, str) or not prompt.strip():
|
|
166
117
|
return
|
|
167
|
-
|
|
168
|
-
project_root = os.getcwd()
|
|
169
|
-
|
|
170
|
-
# Resolve session_id consistently with PreToolUse hook
|
|
118
|
+
root = os.getcwd()
|
|
171
119
|
sid = resolve_session_id(data)
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
)
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
if not config:
|
|
180
|
-
debug_log(f"user_prompt_hook no_config session={sid}", project_root)
|
|
181
|
-
return
|
|
182
|
-
|
|
183
|
-
inject_config = config.get("inject") or {}
|
|
184
|
-
if inject_config.get("auto") is False:
|
|
185
|
-
return
|
|
186
|
-
compress_enabled = resolve_compress(inject_config)
|
|
187
|
-
|
|
188
|
-
mapping = config.get("path_mapping") or {}
|
|
189
|
-
effective_mapping = build_effective_mapping(project_root, mapping)
|
|
190
|
-
|
|
191
|
-
budget_cfg = config.get("budget") or {}
|
|
192
|
-
l1_budget = 1700
|
|
193
|
-
map_max = 400
|
|
194
|
-
try:
|
|
195
|
-
l1_budget = int(budget_cfg.get("l1_max", 1700))
|
|
196
|
-
except (ValueError, TypeError):
|
|
197
|
-
pass
|
|
198
|
-
try:
|
|
199
|
-
map_max = int(budget_cfg.get("map_max", 400))
|
|
200
|
-
except (ValueError, TypeError):
|
|
201
|
-
pass
|
|
202
|
-
|
|
203
|
-
# Derive context_tags from file paths mentioned in the prompt
|
|
204
|
-
candidate_paths = extract_paths_from_prompt(prompt)
|
|
205
|
-
context_tags: set = set()
|
|
206
|
-
matched_domains: set = set()
|
|
207
|
-
for cp in candidate_paths:
|
|
208
|
-
context_tags.update(extract_context_tags(cp))
|
|
209
|
-
matched_domains.update(match_domains(cp, effective_mapping))
|
|
210
|
-
|
|
211
|
-
# NEW: Extract prompt_tags from user prompt keywords
|
|
212
|
-
prompt_tags = extract_prompt_tags(prompt)
|
|
213
|
-
debug_log(f"user_prompt_hook paths={candidate_paths} context_tags={sorted(context_tags)} prompt_tags={sorted(prompt_tags)}", project_root)
|
|
214
|
-
|
|
215
|
-
# Catalog mode: prompts without explicit path evidence get the compact
|
|
216
|
-
# spec catalog and the model pulls relevant specs itself — keyword-table
|
|
217
|
-
# guessing systematically missed open vocabulary. Path-bearing prompts
|
|
218
|
-
# (matched_domains non-empty here means a prompt path hit a domain
|
|
219
|
-
# pattern) keep deterministic full injection: paths are strong evidence.
|
|
220
|
-
mode = resolve_inject_mode(inject_config)
|
|
221
|
-
quality_loop = resolve_quality_loop(config)
|
|
222
|
-
|
|
223
|
-
# FEAT-04 信号①:纠正句式 → correction 事件(仅本地,NFR-SEC-01)。
|
|
224
|
-
# 配对(信号②)由 cf-learn 离线完成,hook 热路径只记原始事件。
|
|
225
|
-
if quality_loop["correction_capture"]:
|
|
226
|
-
from cf_checks import detect_correction
|
|
227
|
-
correction = detect_correction(prompt)
|
|
228
|
-
if correction:
|
|
229
|
-
cf_log.append_event(
|
|
230
|
-
project_root, "correction",
|
|
231
|
-
{"phrase": correction["phrase"],
|
|
232
|
-
"prompt_head": prompt[:200],
|
|
233
|
-
"files": candidate_paths},
|
|
234
|
-
sid,
|
|
235
|
-
)
|
|
236
|
-
debug_log(
|
|
237
|
-
f"user_prompt_hook correction phrase={correction['phrase']!r}",
|
|
238
|
-
project_root,
|
|
239
|
-
)
|
|
240
|
-
|
|
241
|
-
if mode == "catalog" and not matched_domains:
|
|
242
|
-
_emit_catalog(
|
|
243
|
-
project_root, sid, inject_config, budget_cfg,
|
|
244
|
-
effective_mapping, quality_loop,
|
|
245
|
-
)
|
|
120
|
+
config = load_config(root)
|
|
121
|
+
enforcement = resolve_enforcement(config) if config else "required"
|
|
122
|
+
paths = extract_paths_from_prompt(prompt)
|
|
123
|
+
_record_correction(root, prompt, paths, sid)
|
|
124
|
+
result, error_payload = _route_with_enforcement(root, paths, sid, enforcement)
|
|
125
|
+
if error_payload is not None:
|
|
126
|
+
sys.stdout.write(json.dumps(error_payload, ensure_ascii=False))
|
|
246
127
|
return
|
|
247
|
-
|
|
248
|
-
# Fallback: unresolved domains → prefer domain-name hint in tags, then all domains
|
|
249
|
-
if not matched_domains:
|
|
250
|
-
matched_domains = fallback_domains_for_context(effective_mapping, context_tags)
|
|
251
|
-
debug_log(f"user_prompt_hook fallback_domains={sorted(matched_domains)}", project_root)
|
|
252
|
-
|
|
253
|
-
# Strict tag-based matching per domain. No bulk-load fallback: when a
|
|
254
|
-
# Tier 1 spec's tags don't intersect (context_tags ∪ prompt_tags), the
|
|
255
|
-
# spec is NOT injected. Tier 0 (_map.md) uses "*" so navigation still
|
|
256
|
-
# reaches the model. Re-inject every call (session dedup removed).
|
|
257
|
-
all_matched = []
|
|
258
|
-
for domain in matched_domains:
|
|
259
|
-
domain_cfg = effective_mapping.get(domain) or {}
|
|
260
|
-
specs_config = domain_cfg.get("specs") or []
|
|
261
|
-
matched, _ = match_specs_by_tags(specs_config, context_tags, prompt_tags)
|
|
262
|
-
if matched:
|
|
263
|
-
specs = read_matched_specs(
|
|
264
|
-
project_root, domain, matched, compress=compress_enabled
|
|
265
|
-
)
|
|
266
|
-
all_matched.extend(specs)
|
|
267
|
-
|
|
268
|
-
if not all_matched:
|
|
269
|
-
debug_log(f"user_prompt_hook no_specs_matched session={sid}", project_root)
|
|
128
|
+
if result is None:
|
|
270
129
|
return
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
130
|
+
reminder = ""
|
|
131
|
+
if config and resolve_quality_loop(config)["compress_reminder"]:
|
|
132
|
+
reminder = _session_reminder(root, sid)
|
|
133
|
+
text = result.text
|
|
134
|
+
if result.mode == "task" and result.context_sha256:
|
|
135
|
+
# Dedup: inject the projection once per session/context, refreshing
|
|
136
|
+
# at the compress cadence so long sessions do not re-pay 12k chars
|
|
137
|
+
# on every prompt.
|
|
138
|
+
state = load_session_state(root)
|
|
139
|
+
count = int(state.get("prompt_count", 0))
|
|
140
|
+
dirty = False
|
|
141
|
+
if not (config and resolve_quality_loop(config)["compress_reminder"]):
|
|
142
|
+
# compress_reminder off → _session_reminder never bumps the
|
|
143
|
+
# counter; maintain the cadence here so the periodic refresh
|
|
144
|
+
# still fires after compaction.
|
|
145
|
+
count += 1
|
|
146
|
+
state["prompt_count"] = count
|
|
147
|
+
dirty = True
|
|
148
|
+
injected = state.get("injected_sha256")
|
|
149
|
+
last_inject = int(state.get("last_inject_prompt", 0))
|
|
150
|
+
if injected == result.context_sha256 and count - last_inject < _REINJECT_INTERVAL:
|
|
151
|
+
text = ""
|
|
152
|
+
else:
|
|
153
|
+
state["injected_sha256"] = result.context_sha256
|
|
154
|
+
state["last_inject_prompt"] = count
|
|
155
|
+
dirty = True
|
|
156
|
+
if dirty:
|
|
157
|
+
save_session_state(root, state)
|
|
158
|
+
if not text and not reminder:
|
|
274
159
|
return
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
else:
|
|
284
|
-
injected_specs = set()
|
|
285
|
-
prompt_window = {}
|
|
286
|
-
prompt_count = 1
|
|
287
|
-
|
|
288
|
-
# TTL-window dedup: skip specs re-injected within the last N prompts in
|
|
289
|
-
# this session. dedup_window <= 0 disables dedup (every call re-injects).
|
|
290
|
-
# The window bounds — not removes — re-injection, so an auto-compaction
|
|
291
|
-
# that drops earlier context can still recover specs after N prompts.
|
|
292
|
-
try:
|
|
293
|
-
dedup_window = int(inject_config.get("dedup_window", 5))
|
|
294
|
-
except (ValueError, TypeError):
|
|
295
|
-
dedup_window = 5
|
|
296
|
-
|
|
297
|
-
if dedup_window > 0:
|
|
298
|
-
emitted = [
|
|
299
|
-
s for s in selected
|
|
300
|
-
if (s["path"] not in prompt_window)
|
|
301
|
-
or (prompt_count - int(prompt_window[s["path"]]) >= dedup_window)
|
|
302
|
-
]
|
|
303
|
-
else:
|
|
304
|
-
emitted = list(selected)
|
|
305
|
-
|
|
306
|
-
# Compare by path so this stays correct if `emitted` is ever rebuilt
|
|
307
|
-
# from copies instead of filtered references to the same dicts.
|
|
308
|
-
emitted_paths = {s["path"] for s in emitted}
|
|
309
|
-
skipped = [s["path"] for s in selected if s["path"] not in emitted_paths]
|
|
310
|
-
|
|
311
|
-
# Catalog-mode path turns also carry the catalog (same dedup window):
|
|
312
|
-
# the path pins one domain's full specs, the catalog keeps every other
|
|
313
|
-
# spec reachable for cross-domain asks ("改 cli.js 部署 hook 脚本"
|
|
314
|
-
# would otherwise lose the scripts constraints entirely).
|
|
315
|
-
catalog_text = ""
|
|
316
|
-
if mode == "catalog":
|
|
317
|
-
last = prompt_window.get(_CATALOG_KEY)
|
|
318
|
-
catalog_due = (
|
|
319
|
-
dedup_window <= 0
|
|
320
|
-
or last is None
|
|
321
|
-
or prompt_count - int(last) >= dedup_window
|
|
322
|
-
)
|
|
323
|
-
if catalog_due:
|
|
324
|
-
try:
|
|
325
|
-
catalog_max = int(budget_cfg.get("catalog_max", 200))
|
|
326
|
-
except (ValueError, TypeError):
|
|
327
|
-
catalog_max = 200
|
|
328
|
-
catalog_text = build_spec_catalog(
|
|
329
|
-
project_root, effective_mapping, catalog_max
|
|
330
|
-
)
|
|
331
|
-
if catalog_text:
|
|
332
|
-
prompt_window[_CATALOG_KEY] = prompt_count
|
|
333
|
-
|
|
334
|
-
# Always persist prompt_count so the dedup window keeps advancing even
|
|
335
|
-
# on fully-deduped turns. Preserve PreToolUse-owned fields verbatim.
|
|
336
|
-
for s in emitted:
|
|
337
|
-
prompt_window[s["path"]] = prompt_count
|
|
338
|
-
save_inject_state(project_root, {
|
|
339
|
-
"session_id": sid,
|
|
340
|
-
"injected_specs": sorted(injected_specs | {s["path"] for s in emitted}),
|
|
341
|
-
"last_file": state.get("last_file", "") if same_session else "",
|
|
342
|
-
"prompt_count": prompt_count,
|
|
343
|
-
"prompt_inject_window": prompt_window,
|
|
344
|
-
})
|
|
345
|
-
|
|
346
|
-
if not emitted and not catalog_text:
|
|
347
|
-
debug_log(
|
|
348
|
-
f"user_prompt_hook dedup_all_skipped session={sid} "
|
|
349
|
-
f"prompt_count={prompt_count} skipped={skipped}",
|
|
350
|
-
project_root,
|
|
351
|
-
)
|
|
352
|
-
return
|
|
353
|
-
|
|
354
|
-
debug_log(
|
|
355
|
-
f"user_prompt_hook injected={[s['path'] for s in emitted]} "
|
|
356
|
-
f"skipped={skipped} catalog_appended={bool(catalog_text)} "
|
|
357
|
-
f"prompt_count={prompt_count} session={sid}",
|
|
358
|
-
project_root,
|
|
359
|
-
)
|
|
360
|
-
|
|
361
|
-
if quality_loop["enabled"]:
|
|
362
|
-
cf_log.append_event(
|
|
363
|
-
project_root, "inject",
|
|
364
|
-
{"specs": [s["path"] for s in emitted],
|
|
365
|
-
"mode": mode, "source": "prompt",
|
|
366
|
-
"catalog_appended": bool(catalog_text)},
|
|
367
|
-
sid,
|
|
368
|
-
)
|
|
369
|
-
|
|
370
|
-
parts = []
|
|
371
|
-
if emitted:
|
|
372
|
-
parts.append(assemble_context(emitted, "## Active Specs (auto-injected)"))
|
|
373
|
-
if catalog_text:
|
|
374
|
-
parts.append(catalog_text)
|
|
375
|
-
payload = {
|
|
376
|
-
"hookSpecificOutput": {
|
|
377
|
-
"hookEventName": "UserPromptSubmit",
|
|
378
|
-
"additionalContext": "\n\n".join(parts),
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
if os.environ.get("CF_DEBUG") == "1":
|
|
382
|
-
payload["debug"] = {
|
|
383
|
-
"mode": mode,
|
|
384
|
-
"candidate_paths": candidate_paths,
|
|
385
|
-
"domains": sorted(matched_domains),
|
|
386
|
-
"context_tags": sorted(context_tags),
|
|
387
|
-
"prompt_tags": sorted(prompt_tags),
|
|
388
|
-
"matched_specs": [s["path"] for s in emitted],
|
|
389
|
-
"deduped_specs": skipped,
|
|
390
|
-
"prompt_count": prompt_count,
|
|
391
|
-
"dedup_window": dedup_window,
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
sys.stdout.write(json.dumps(payload, ensure_ascii=False))
|
|
395
|
-
|
|
396
|
-
except Exception as exc:
|
|
160
|
+
if text:
|
|
161
|
+
cf_log.append_event(root, "inject", {"specs": list(result.specs), "mode": result.mode, "source": result.mode}, sid)
|
|
162
|
+
if text and reminder:
|
|
163
|
+
text = text.rstrip() + "\n\n" + reminder
|
|
164
|
+
elif reminder:
|
|
165
|
+
text = reminder
|
|
166
|
+
sys.stdout.write(json.dumps(_payload(text, result.mode if text else "reminder"), ensure_ascii=False))
|
|
167
|
+
except (json.JSONDecodeError, UnicodeError, OSError, ValueError) as exc:
|
|
397
168
|
_log(f"cf_user_prompt_hook error: {exc}")
|
|
398
|
-
return
|
|
399
169
|
|
|
400
170
|
|
|
401
171
|
if __name__ == "__main__":
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
---
|
|
2
|
+
id: backend-code-quality-performance
|
|
2
3
|
description: 写后端代码时适用:错误处理、测试、超时重试、缓存等质量与性能约束
|
|
4
|
+
stages: [design, plan, code, review]
|
|
5
|
+
enforcement: required
|
|
6
|
+
verifiers:
|
|
7
|
+
- rule: RULE-backend-quality-001
|
|
8
|
+
type: manual
|
|
9
|
+
config:
|
|
10
|
+
checklist: Confirm all Guidance and Avoid items for this Spec.
|
|
11
|
+
owner: project-owner
|
|
3
12
|
---
|
|
4
13
|
|
|
5
14
|
# Backend Code Quality & Performance
|
|
@@ -29,6 +38,9 @@ except Exception:
|
|
|
29
38
|
|
|
30
39
|
|
|
31
40
|
## Rules
|
|
41
|
+
- [RULE-backend-quality-001] The implementation must satisfy every applicable item in Guidance and avoid every item in Avoid.
|
|
42
|
+
|
|
43
|
+
## Guidance
|
|
32
44
|
- 所有公开函数 / 方法必须有类型注解(type hints / 类型签名)
|
|
33
45
|
- 异常必须显式处理或显式上抛,禁止 `except Exception: pass` / `catch (e) {}` 静默吞掉
|
|
34
46
|
- 外部依赖调用(HTTP / RPC / DB)必须设置超时,关键调用补重试 + 指数退避
|
|
@@ -40,7 +52,7 @@ except Exception:
|
|
|
40
52
|
- 资源(连接、文件、锁)使用 `with` / `using` / `defer` 确保释放
|
|
41
53
|
- 性能敏感路径加监控指标(QPS / P95 延迟 / 错误率)
|
|
42
54
|
|
|
43
|
-
##
|
|
55
|
+
## Avoid
|
|
44
56
|
- 禁止在请求链路中吞掉异常导致客户端拿到错误结果却无日志
|
|
45
57
|
- 禁止无超时的外部调用(容易导致线程 / 协程泄漏)
|
|
46
58
|
- 禁止用循环模拟批量操作(DB 批量 / 网络批量必须用原生批量 API)
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
---
|
|
2
|
+
id: backend-database
|
|
2
3
|
description: 涉及数据库/ORM/迁移/查询时适用:schema 与数据访问约束
|
|
4
|
+
stages: [design, plan, code, review]
|
|
5
|
+
enforcement: required
|
|
6
|
+
verifiers:
|
|
7
|
+
- rule: RULE-backend-database-001
|
|
8
|
+
type: manual
|
|
9
|
+
config:
|
|
10
|
+
checklist: Confirm all Guidance and Avoid items for this Spec.
|
|
11
|
+
owner: project-owner
|
|
3
12
|
---
|
|
4
13
|
|
|
5
14
|
# Backend Database
|
|
@@ -19,6 +28,9 @@ cur.execute(f"SELECT * FROM users WHERE email = '{email}'")
|
|
|
19
28
|
```
|
|
20
29
|
|
|
21
30
|
## Rules
|
|
31
|
+
- [RULE-backend-database-001] The implementation must satisfy every applicable item in Guidance and avoid every item in Avoid.
|
|
32
|
+
|
|
33
|
+
## Guidance
|
|
22
34
|
- 所有 SQL 必须参数化,禁止字符串拼接 / 模板插值用户输入
|
|
23
35
|
- 迁移脚本必须可回滚,或写成幂等脚本(`IF NOT EXISTS` / `ON CONFLICT`)
|
|
24
36
|
- 事务边界明确:跨表写入必须在同一事务内,禁止"半提交"状态
|
|
@@ -32,7 +44,7 @@ cur.execute(f"SELECT * FROM users WHERE email = '{email}'")
|
|
|
32
44
|
- 缓存与数据库一致性:先写库再失效缓存(`cache-aside`)
|
|
33
45
|
- CRUD 基类统一实现 `get / list / create / update / delete / bulk_*`,子类只扩展模型特有查询
|
|
34
46
|
|
|
35
|
-
##
|
|
47
|
+
## Avoid
|
|
36
48
|
- 禁止在事务内发起外部 HTTP / RPC 调用,超时会导致连接池耗尽
|
|
37
49
|
- 禁止在循环中执行单条 `INSERT` / `UPDATE`,必须批量化
|
|
38
50
|
- 禁止在 ORM 之外手写 SQL 时绕过参数绑定
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
---
|
|
2
|
+
id: backend-directory-structure
|
|
2
3
|
description: 新建/移动后端文件时适用:目录结构与模块组织约束
|
|
4
|
+
stages: [design, plan, code, review]
|
|
5
|
+
enforcement: required
|
|
6
|
+
verifiers:
|
|
7
|
+
- rule: RULE-backend-directory-001
|
|
8
|
+
type: manual
|
|
9
|
+
config:
|
|
10
|
+
checklist: Confirm all Guidance and Avoid items for this Spec.
|
|
11
|
+
owner: project-owner
|
|
3
12
|
---
|
|
4
13
|
|
|
5
14
|
# Backend Directory Structure
|
|
@@ -22,6 +31,9 @@ if order.status == 1 and role == "admin": # 魔法数字 / 魔法字符串
|
|
|
22
31
|
```
|
|
23
32
|
|
|
24
33
|
## Rules
|
|
34
|
+
- [RULE-backend-directory-001] The implementation must satisfy every applicable item in Guidance and avoid every item in Avoid.
|
|
35
|
+
|
|
36
|
+
## Guidance
|
|
25
37
|
- 接口层放 `api/`,业务逻辑放 `services/`,数据模型放 `models/`,禁止跨层倒置依赖
|
|
26
38
|
- 入口文件(`main.*`)只做框架装配,不写业务代码
|
|
27
39
|
- 配置统一放 `config/`,禁止在业务代码中直接读 `os.environ` / `process.env`
|
|
@@ -35,7 +47,7 @@ if order.status == 1 and role == "admin": # 魔法数字 / 魔法字符串
|
|
|
35
47
|
- 常量命名用 UPPER_SNAKE_CASE,枚举优先使用语言原生 `Enum`
|
|
36
48
|
- 测试目录与源码同构(`tests/services/order/test_*`)
|
|
37
49
|
|
|
38
|
-
##
|
|
50
|
+
## Avoid
|
|
39
51
|
- 禁止在根目录堆放脚本与临时代码,临时脚本放 `scripts/` 并命名清晰
|
|
40
52
|
- 禁止在 `models/` 里写业务逻辑,模型仅定义结构与简单关联
|
|
41
53
|
- 禁止把常量直接写在业务代码里(如 `if status == 1` / `role == "admin"`),必须引用 `constants/` 中的命名常量
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
---
|
|
2
|
+
id: backend-logging
|
|
2
3
|
description: 涉及日志/调试/监控时适用:日志级别、格式、追踪约束
|
|
4
|
+
stages: [design, plan, code, review]
|
|
5
|
+
enforcement: required
|
|
6
|
+
verifiers:
|
|
7
|
+
- rule: RULE-backend-logging-001
|
|
8
|
+
type: manual
|
|
9
|
+
config:
|
|
10
|
+
checklist: Confirm all Guidance and Avoid items for this Spec.
|
|
11
|
+
owner: project-owner
|
|
3
12
|
---
|
|
4
13
|
|
|
5
14
|
# Backend Logging
|
|
@@ -21,6 +30,9 @@ logger.error("failed") # 丢失原始错误与上下文
|
|
|
21
30
|
```
|
|
22
31
|
|
|
23
32
|
## Rules
|
|
33
|
+
- [RULE-backend-logging-001] The implementation must satisfy every applicable item in Guidance and avoid every item in Avoid.
|
|
34
|
+
|
|
35
|
+
## Guidance
|
|
24
36
|
- 关键路径(请求入口、外部调用、DB 写入、异常分支)必须输出结构化日志
|
|
25
37
|
- 日志中禁止出现明文密码、token、身份证号等敏感字段,需在记录前脱敏
|
|
26
38
|
- 每条请求日志必须包含 `request_id`,串联整个调用链
|
|
@@ -32,7 +44,7 @@ logger.error("failed") # 丢失原始错误与上下文
|
|
|
32
44
|
- 高频路径用采样日志,避免 IO 阻塞主流程
|
|
33
45
|
- 日志输出到 stdout/stderr,由部署环境采集,禁止业务代码写文件
|
|
34
46
|
|
|
35
|
-
##
|
|
47
|
+
## Avoid
|
|
36
48
|
- 禁止在循环或热路径中无脱敏地打印请求体
|
|
37
49
|
- 禁止用 `print` / `console.log` 替代日志框架
|
|
38
50
|
- 禁止吞掉异常仅打 `logger.error("failed")`,必须保留原始错误与上下文
|