@michaelxwb/code-flow 0.5.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.
Files changed (99) hide show
  1. package/README.md +157 -0
  2. package/package.json +16 -0
  3. package/src/adapters/claude/CLAUDE.md +51 -0
  4. package/src/adapters/claude/commands/cf-init.md +262 -0
  5. package/src/adapters/claude/commands/cf-inject.md +55 -0
  6. package/src/adapters/claude/commands/cf-learn.md +291 -0
  7. package/src/adapters/claude/commands/cf-stats.md +86 -0
  8. package/src/adapters/claude/commands/cf-task/align.md +283 -0
  9. package/src/adapters/claude/commands/cf-task/archive.md +131 -0
  10. package/src/adapters/claude/commands/cf-task/block.md +30 -0
  11. package/src/adapters/claude/commands/cf-task/graph.md +73 -0
  12. package/src/adapters/claude/commands/cf-task/note.md +239 -0
  13. package/src/adapters/claude/commands/cf-task/plan.md +270 -0
  14. package/src/adapters/claude/commands/cf-task/prd.md +138 -0
  15. package/src/adapters/claude/commands/cf-task/start.md +173 -0
  16. package/src/adapters/claude/commands/cf-task/status.md +59 -0
  17. package/src/adapters/claude/commands/cf-validate.md +73 -0
  18. package/src/adapters/claude/settings.local.json +60 -0
  19. package/src/adapters/codex/AGENTS.md +51 -0
  20. package/src/adapters/codex/config.toml +3 -0
  21. package/src/adapters/codex/hooks.json +48 -0
  22. package/src/adapters/codex/skills/cf-init/SKILL.md +271 -0
  23. package/src/adapters/codex/skills/cf-inject/SKILL.md +56 -0
  24. package/src/adapters/codex/skills/cf-learn/SKILL.md +296 -0
  25. package/src/adapters/codex/skills/cf-stats/SKILL.md +87 -0
  26. package/src/adapters/codex/skills/cf-task-align/SKILL.md +284 -0
  27. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +132 -0
  28. package/src/adapters/codex/skills/cf-task-block/SKILL.md +31 -0
  29. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +74 -0
  30. package/src/adapters/codex/skills/cf-task-note/SKILL.md +236 -0
  31. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +271 -0
  32. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +139 -0
  33. package/src/adapters/codex/skills/cf-task-start/SKILL.md +174 -0
  34. package/src/adapters/codex/skills/cf-task-status/SKILL.md +60 -0
  35. package/src/adapters/codex/skills/cf-validate/SKILL.md +74 -0
  36. package/src/adapters/costrict/CLAUDE.md +51 -0
  37. package/src/adapters/costrict/commands/cf-init.md +262 -0
  38. package/src/adapters/costrict/commands/cf-inject.md +55 -0
  39. package/src/adapters/costrict/commands/cf-learn.md +291 -0
  40. package/src/adapters/costrict/commands/cf-stats.md +86 -0
  41. package/src/adapters/costrict/commands/cf-task/align.md +283 -0
  42. package/src/adapters/costrict/commands/cf-task/archive.md +131 -0
  43. package/src/adapters/costrict/commands/cf-task/block.md +30 -0
  44. package/src/adapters/costrict/commands/cf-task/graph.md +73 -0
  45. package/src/adapters/costrict/commands/cf-task/note.md +239 -0
  46. package/src/adapters/costrict/commands/cf-task/plan.md +270 -0
  47. package/src/adapters/costrict/commands/cf-task/prd.md +138 -0
  48. package/src/adapters/costrict/commands/cf-task/start.md +173 -0
  49. package/src/adapters/costrict/commands/cf-task/status.md +59 -0
  50. package/src/adapters/costrict/commands/cf-validate.md +73 -0
  51. package/src/adapters/costrict/settings.local.json +60 -0
  52. package/src/adapters/opencode/AGENTS.md +51 -0
  53. package/src/adapters/opencode/commands/cf-init.md +275 -0
  54. package/src/adapters/opencode/commands/cf-inject.md +59 -0
  55. package/src/adapters/opencode/commands/cf-learn.md +295 -0
  56. package/src/adapters/opencode/commands/cf-stats.md +90 -0
  57. package/src/adapters/opencode/commands/cf-task/align.md +287 -0
  58. package/src/adapters/opencode/commands/cf-task/archive.md +135 -0
  59. package/src/adapters/opencode/commands/cf-task/block.md +34 -0
  60. package/src/adapters/opencode/commands/cf-task/graph.md +77 -0
  61. package/src/adapters/opencode/commands/cf-task/note.md +243 -0
  62. package/src/adapters/opencode/commands/cf-task/plan.md +274 -0
  63. package/src/adapters/opencode/commands/cf-task/prd.md +142 -0
  64. package/src/adapters/opencode/commands/cf-task/start.md +177 -0
  65. package/src/adapters/opencode/commands/cf-task/status.md +63 -0
  66. package/src/adapters/opencode/commands/cf-validate.md +77 -0
  67. package/src/adapters/opencode/opencode.json +4 -0
  68. package/src/adapters/opencode/plugins/code-flow/index.js +132 -0
  69. package/src/adapters/opencode/plugins/code-flow/package.json +6 -0
  70. package/src/cli.js +810 -0
  71. package/src/core/code-flow/config.yml +159 -0
  72. package/src/core/code-flow/scripts/cf_checks.py +306 -0
  73. package/src/core/code-flow/scripts/cf_core.py +954 -0
  74. package/src/core/code-flow/scripts/cf_feedback.py +56 -0
  75. package/src/core/code-flow/scripts/cf_inject_hook.py +178 -0
  76. package/src/core/code-flow/scripts/cf_log.py +146 -0
  77. package/src/core/code-flow/scripts/cf_post_hook.py +196 -0
  78. package/src/core/code-flow/scripts/cf_scan.py +293 -0
  79. package/src/core/code-flow/scripts/cf_session_hook.py +42 -0
  80. package/src/core/code-flow/scripts/cf_stats.py +439 -0
  81. package/src/core/code-flow/scripts/cf_stop_hook.py +191 -0
  82. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +402 -0
  83. package/src/core/code-flow/specs/backend/_map.md +56 -0
  84. package/src/core/code-flow/specs/backend/code-quality-performance.md +47 -0
  85. package/src/core/code-flow/specs/backend/database.md +39 -0
  86. package/src/core/code-flow/specs/backend/directory-structure.md +42 -0
  87. package/src/core/code-flow/specs/backend/logging.md +38 -0
  88. package/src/core/code-flow/specs/backend/platform-rules.md +43 -0
  89. package/src/core/code-flow/specs/frontend/_map.md +52 -0
  90. package/src/core/code-flow/specs/frontend/component-specs.md +56 -0
  91. package/src/core/code-flow/specs/frontend/directory-structure.md +41 -0
  92. package/src/core/code-flow/specs/frontend/quality-standards.md +46 -0
  93. package/src/core/code-flow/specs/shared/_map.md +57 -0
  94. package/src/core/code-flow/specs/shared/design/design-frontend.md +247 -0
  95. package/src/core/code-flow/specs/shared/design/design-full.md +494 -0
  96. package/src/core/code-flow/specs/shared/design/design-lite.md +293 -0
  97. package/src/core/code-flow/specs/shared/prd-template.md +187 -0
  98. package/src/core/code-flow/tasks/.gitkeep +0 -0
  99. package/src/core/code-flow/validation.yml +48 -0
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env python3
2
+ """cf_feedback: mark a check as false positive (FEAT-02 对话即反馈).
3
+
4
+ The violation feedback text tells the user "误报可直接告诉我"; the agent then
5
+ runs this CLI on the user's behalf — no new slash command, no config editing.
6
+
7
+ Usage: python3 cf_feedback.py ignore <check-id>
8
+ Exit codes: 0 = recorded / 1 = usage error / 2 = unknown check id.
9
+ stdout: one JSON line {"check_id", "fp_count", "hit_count", "disabled"}.
10
+ """
11
+ import json
12
+ import os
13
+ import sys
14
+
15
+ from cf_checks import load_check_state, load_spec_checks, record_false_positive
16
+ from cf_core import build_effective_mapping, load_config, normalize_spec_entry
17
+ from cf_log import append_event
18
+
19
+
20
+ def known_check_ids(project_root: str) -> set:
21
+ """All check ids: declared in any configured spec, or already in state."""
22
+ ids = set(load_check_state(project_root).keys())
23
+ config = load_config(project_root)
24
+ mapping = build_effective_mapping(
25
+ project_root, (config or {}).get("path_mapping") or {}
26
+ )
27
+ specs_root = os.path.join(project_root, ".code-flow", "specs")
28
+ for domain_cfg in mapping.values():
29
+ for entry in (domain_cfg or {}).get("specs") or []:
30
+ rel = normalize_spec_entry(entry).get("path")
31
+ if not rel:
32
+ continue
33
+ checks, _ = load_spec_checks(os.path.join(specs_root, rel))
34
+ ids.update(check["id"] for check in checks)
35
+ return ids
36
+
37
+
38
+ def main(argv: list) -> int:
39
+ if len(argv) != 2 or argv[0] != "ignore":
40
+ sys.stderr.write("usage: cf_feedback.py ignore <check-id>\n")
41
+ return 1
42
+ check_id = argv[1]
43
+ project_root = os.getcwd()
44
+ if check_id not in known_check_ids(project_root):
45
+ sys.stderr.write(f"unknown check id: {check_id}\n")
46
+ return 2
47
+ append_event(
48
+ project_root, "false_positive", {"check_id": check_id}, "cf-feedback"
49
+ )
50
+ result = record_false_positive(project_root, check_id)
51
+ sys.stdout.write(json.dumps(result, ensure_ascii=False) + "\n")
52
+ return 0
53
+
54
+
55
+ if __name__ == "__main__":
56
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env python3
2
+ import json
3
+ import os
4
+ import sys
5
+
6
+ import cf_log
7
+ from cf_core import (
8
+ _log,
9
+ assemble_context,
10
+ build_effective_mapping,
11
+ debug_log,
12
+ ensure_utf8_io,
13
+ extract_context_tags,
14
+ fallback_domains_for_context,
15
+ is_code_file,
16
+ load_config,
17
+ load_inject_state,
18
+ match_domains,
19
+ match_specs_by_tags,
20
+ read_matched_specs,
21
+ resolve_compress,
22
+ resolve_quality_loop,
23
+ resolve_session_id,
24
+ save_inject_state,
25
+ select_specs_tiered,
26
+ )
27
+
28
+
29
+
30
+
31
+
32
+ def main() -> None:
33
+ try:
34
+ ensure_utf8_io()
35
+ raw = sys.stdin.read()
36
+ if not raw.strip():
37
+ return
38
+ data = json.loads(raw)
39
+ tool_name = data.get("tool_name", "")
40
+ tool_input = data.get("tool_input") or {}
41
+ file_path = tool_input.get("file_path", "")
42
+ if tool_name not in {"Edit", "Write", "MultiEdit"}:
43
+ return
44
+ if not isinstance(file_path, str) or not file_path:
45
+ return
46
+
47
+ project_root = os.getcwd()
48
+ abs_path = file_path
49
+ if not os.path.isabs(abs_path):
50
+ abs_path = os.path.join(project_root, file_path)
51
+ rel_path = os.path.relpath(abs_path, project_root)
52
+
53
+ config = load_config(project_root)
54
+ if not config:
55
+ return
56
+ inject_config = config.get("inject") or {}
57
+ if inject_config.get("auto") is False:
58
+ return
59
+ if not is_code_file(rel_path, inject_config):
60
+ return
61
+ compress_enabled = resolve_compress(inject_config)
62
+
63
+ mapping = config.get("path_mapping") or {}
64
+ effective_mapping = build_effective_mapping(project_root, mapping)
65
+ domains = match_domains(rel_path, effective_mapping)
66
+
67
+ sid = resolve_session_id(data)
68
+
69
+ # Quality-loop instrumentation (FEAT-00): every code-file edit is an
70
+ # event — correction pairing and stop-check need them even when no
71
+ # spec matches below. append_event never raises (hot path safe).
72
+ quality_loop = resolve_quality_loop(config)
73
+ if quality_loop["enabled"]:
74
+ cf_log.append_event(
75
+ project_root, "edit",
76
+ {"file": rel_path.replace(os.sep, "/"), "tool": tool_name},
77
+ sid,
78
+ )
79
+
80
+ # Extract context tags from file path
81
+ context_tags = extract_context_tags(rel_path)
82
+ if not domains:
83
+ domains = sorted(fallback_domains_for_context(effective_mapping, context_tags))
84
+ if not domains:
85
+ return
86
+
87
+ # Budget config
88
+ budget_cfg = config.get("budget") or {}
89
+ l1_budget = 1700
90
+ map_max = 400
91
+ try:
92
+ l1_budget = int(budget_cfg.get("l1_max", 1700))
93
+ except (ValueError, TypeError):
94
+ pass
95
+ try:
96
+ map_max = int(budget_cfg.get("map_max", 400))
97
+ except (ValueError, TypeError):
98
+ pass
99
+
100
+ # Strict tag-based matching per domain. No bulk-load fallback: when a
101
+ # Tier 1 spec's tags don't intersect context_tags, the spec is NOT
102
+ # injected. Tier 0 (_map.md) uses the "*" wildcard so it still reaches
103
+ # the model as navigation. Re-inject every call (session dedup removed).
104
+ all_matched = []
105
+ for domain in domains:
106
+ domain_cfg = effective_mapping.get(domain) or {}
107
+ specs_config = domain_cfg.get("specs") or []
108
+ matched, _ = match_specs_by_tags(specs_config, context_tags)
109
+ if matched:
110
+ specs = read_matched_specs(
111
+ project_root, domain, matched, compress=compress_enabled
112
+ )
113
+ all_matched.extend(specs)
114
+
115
+ if not all_matched:
116
+ return
117
+
118
+ selected = select_specs_tiered(all_matched, l1_budget, map_max)
119
+ if not selected:
120
+ return
121
+
122
+ # Load state with session isolation (deferred until after match success)
123
+ state = load_inject_state(project_root)
124
+ same_session = state.get("session_id") == sid
125
+ if same_session:
126
+ injected_specs = set(state.get("injected_specs") or [])
127
+ else:
128
+ injected_specs = set()
129
+
130
+ # Update state with newly injected spec paths. Preserve
131
+ # UserPromptSubmit-owned fields (prompt_count / prompt_inject_window)
132
+ # verbatim so cross-hook writes don't reset the dedup window.
133
+ new_injected = injected_specs | {s["path"] for s in selected}
134
+ debug_log(f"inject_hook injected={[s['path'] for s in selected]} path={rel_path}", project_root)
135
+ new_state = {
136
+ "session_id": sid,
137
+ "injected_specs": sorted(new_injected),
138
+ "last_file": abs_path,
139
+ }
140
+ if same_session:
141
+ if "prompt_count" in state:
142
+ new_state["prompt_count"] = state["prompt_count"]
143
+ if "prompt_inject_window" in state:
144
+ new_state["prompt_inject_window"] = state["prompt_inject_window"]
145
+ save_inject_state(project_root, new_state)
146
+
147
+ if quality_loop["enabled"]:
148
+ cf_log.append_event(
149
+ project_root, "inject",
150
+ {"specs": [s["path"] for s in selected],
151
+ "mode": "full", "source": "pretooluse"},
152
+ sid,
153
+ )
154
+
155
+ payload = {
156
+ "hookSpecificOutput": {
157
+ "hookEventName": "PreToolUse",
158
+ "additionalContext": assemble_context(
159
+ selected, "## Active Specs (auto-injected)"
160
+ ),
161
+ }
162
+ }
163
+ if os.environ.get("CF_DEBUG") == "1":
164
+ payload["debug"] = {
165
+ "target": abs_path,
166
+ "domains": sorted(domains),
167
+ "context_tags": sorted(context_tags),
168
+ "matched_specs": [s["path"] for s in selected],
169
+ }
170
+ sys.stdout.write(json.dumps(payload, ensure_ascii=False))
171
+ except Exception as exc:
172
+ # Fix #9: log errors to stderr instead of silently swallowing
173
+ _log(f"cf_inject_hook error: {exc}")
174
+ return
175
+
176
+
177
+ if __name__ == "__main__":
178
+ main()
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env python3
2
+ """Session activity log: JSONL event stream for the quality-loop feature.
3
+
4
+ Design contract (spec-quality-loop.design.md §3.3):
5
+ - One event per line: {"v", "ts", "sid", "event", "data"}.
6
+ - append is O(1) single-write; failures are swallowed (stderr only) so the
7
+ hook protocol is never broken by logging (RULE-01 / E-03).
8
+ - Reads are line-tolerant: corrupt lines are skipped, never raised (RULE-05).
9
+ - Rotation: when the live file reaches MAX_BYTES it is archived to
10
+ sessions/YYYY-MM/session-log-<ts>.jsonl and a fresh file starts (B-02).
11
+ """
12
+ import json
13
+ import os
14
+ import sys
15
+ from datetime import datetime, timedelta
16
+
17
+ LOG_NAME = ".session-log.jsonl"
18
+ SESSIONS_DIR = "sessions"
19
+ MAX_BYTES = 5 * 1024 * 1024
20
+ SCHEMA_VERSION = 1
21
+
22
+ KNOWN_EVENTS = (
23
+ "inject",
24
+ "edit",
25
+ "violation",
26
+ "correction",
27
+ "false_positive",
28
+ "degrade",
29
+ "stop_check",
30
+ )
31
+
32
+
33
+ def _log(msg: str) -> None:
34
+ sys.stderr.write(f"[cf-log] {msg}\n")
35
+
36
+
37
+ def log_path(project_root: str) -> str:
38
+ return os.path.join(project_root, ".code-flow", LOG_NAME)
39
+
40
+
41
+ def _archive_dir(project_root: str, month: str) -> str:
42
+ return os.path.join(project_root, ".code-flow", SESSIONS_DIR, month)
43
+
44
+
45
+ def _rotate_if_needed(project_root: str, path: str) -> None:
46
+ try:
47
+ if os.path.getsize(path) < MAX_BYTES:
48
+ return
49
+ except OSError:
50
+ return
51
+ now = datetime.now()
52
+ month_dir = _archive_dir(project_root, now.strftime("%Y-%m"))
53
+ os.makedirs(month_dir, exist_ok=True)
54
+ target = os.path.join(
55
+ month_dir, f"session-log-{now.strftime('%Y%m%dT%H%M%S')}.jsonl"
56
+ )
57
+ os.replace(path, target)
58
+
59
+
60
+ def append_event(project_root: str, event: str, data: dict, sid: str) -> bool:
61
+ """Append one event line. Never raises; returns False on failure (E-03)."""
62
+ try:
63
+ path = log_path(project_root)
64
+ os.makedirs(os.path.dirname(path), exist_ok=True)
65
+ _rotate_if_needed(project_root, path)
66
+ line = json.dumps(
67
+ {
68
+ "v": SCHEMA_VERSION,
69
+ "ts": datetime.now().isoformat(timespec="seconds"),
70
+ "sid": sid,
71
+ "event": event,
72
+ "data": data or {},
73
+ },
74
+ ensure_ascii=False,
75
+ )
76
+ with open(path, "a", encoding="utf-8") as f:
77
+ f.write(line + "\n")
78
+ return True
79
+ except Exception as exc:
80
+ _log(f"append_event failed: {exc}")
81
+ return False
82
+
83
+
84
+ def degrade(project_root: str, component: str, error: object, sid: str = "") -> None:
85
+ """Record a degrade event (FEAT-02). Never raises — degrading the
86
+ degrade-logger itself must stay invisible (RULE-01)."""
87
+ append_event(
88
+ project_root,
89
+ "degrade",
90
+ {"component": component, "error": str(error)[:200]},
91
+ sid,
92
+ )
93
+
94
+
95
+ def _candidate_files(project_root: str, days: int) -> list:
96
+ """Live log + archived months overlapping the [now-days, now] window."""
97
+ paths = []
98
+ base = os.path.join(project_root, ".code-flow", SESSIONS_DIR)
99
+ if os.path.isdir(base):
100
+ cutoff = datetime.now() - timedelta(days=days)
101
+ for month in sorted(os.listdir(base)):
102
+ try:
103
+ month_start = datetime.strptime(month, "%Y-%m")
104
+ except ValueError:
105
+ continue
106
+ # keep a month if any part of it can fall inside the window
107
+ if month_start >= cutoff.replace(day=1):
108
+ month_dir = os.path.join(base, month)
109
+ paths.extend(
110
+ os.path.join(month_dir, name)
111
+ for name in sorted(os.listdir(month_dir))
112
+ if name.endswith(".jsonl")
113
+ )
114
+ live = log_path(project_root)
115
+ if os.path.exists(live):
116
+ paths.append(live)
117
+ return paths
118
+
119
+
120
+ def read_events(project_root: str, days: int = 30, events: tuple = ()) -> list:
121
+ """Read events within the window, oldest first. Corrupt lines skipped."""
122
+ cutoff = (datetime.now() - timedelta(days=days)).isoformat(timespec="seconds")
123
+ wanted = set(events) if events else None
124
+ out = []
125
+ for path in _candidate_files(project_root, days):
126
+ try:
127
+ with open(path, "r", encoding="utf-8") as f:
128
+ for raw in f:
129
+ raw = raw.strip()
130
+ if not raw:
131
+ continue
132
+ try:
133
+ item = json.loads(raw)
134
+ except Exception:
135
+ continue
136
+ if not isinstance(item, dict) or "event" not in item:
137
+ continue
138
+ if wanted is not None and item["event"] not in wanted:
139
+ continue
140
+ if str(item.get("ts", "")) < cutoff:
141
+ continue
142
+ out.append(item)
143
+ except Exception as exc:
144
+ _log(f"read_events skipped {path}: {exc}")
145
+ continue
146
+ return out
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env python3
2
+ """PostToolUse Hook: compliance feedback after Edit/Write (FEAT-01).
3
+
4
+ Flow (spec-quality-loop.design.md §3.4):
5
+ edited file → matched domains → aggregate frontmatter checks of their
6
+ constraint specs → run against on-disk content → feedback via
7
+ additionalContext (advice only, never blocking) → violation events.
8
+
9
+ Protocol invariants (RULE-01/02):
10
+ - always exit 0, stdout is JSON-only or empty (no-op);
11
+ - violations only warn — there is no error severity;
12
+ - same check+file reported once per session (RISK-05);
13
+ - any internal failure degrades silently (stderr + degrade event).
14
+
15
+ Input (stdin): {"tool_name", "tool_input": {"file_path"}, "session_id"}
16
+ Output: {"hookSpecificOutput": {"hookEventName": "PostToolUse",
17
+ "additionalContext": "..."}} or empty.
18
+ """
19
+ import json
20
+ import os
21
+ import sys
22
+
23
+ import cf_log
24
+ from cf_checks import (
25
+ load_check_state,
26
+ load_spec_checks,
27
+ record_hits,
28
+ run_checks,
29
+ save_check_state,
30
+ )
31
+ from cf_core import (
32
+ _log,
33
+ build_effective_mapping,
34
+ ensure_utf8_io,
35
+ extract_context_tags,
36
+ fallback_domains_for_context,
37
+ is_code_file,
38
+ load_config,
39
+ match_domains,
40
+ normalize_path,
41
+ normalize_spec_entry,
42
+ resolve_quality_loop,
43
+ resolve_session_id,
44
+ )
45
+
46
+ FEEDBACK_HINT = '如认为误报,直接告诉我"这是误报",我会标记忽略。'
47
+ _REPORTED_KEY = "_reported"
48
+
49
+
50
+ def _collect_checks(project_root: str, domains: list, mapping: dict) -> list:
51
+ """Aggregate checks of all constraint specs in matched domains.
52
+
53
+ Each check is annotated with its source spec path. Checks apply whenever
54
+ their own `files` glob matches — independent of injection tag gating.
55
+ """
56
+ specs_root = os.path.join(project_root, ".code-flow", "specs")
57
+ collected = []
58
+ seen_specs = set()
59
+ for domain in domains:
60
+ for entry in (mapping.get(domain) or {}).get("specs") or []:
61
+ cfg = normalize_spec_entry(entry)
62
+ rel = cfg.get("path")
63
+ if not rel or rel in seen_specs or not cfg.get("tags"):
64
+ continue
65
+ seen_specs.add(rel)
66
+ checks, _ = load_spec_checks(os.path.join(specs_root, rel))
67
+ for check in checks:
68
+ item = dict(check)
69
+ item["spec"] = rel
70
+ collected.append(item)
71
+ return collected
72
+
73
+
74
+ def _session_reported(state: dict, sid: str) -> set:
75
+ session = state.get(_REPORTED_KEY) or {}
76
+ if session.get("sid") != sid:
77
+ return set()
78
+ return set(session.get("keys") or [])
79
+
80
+
81
+ def _save_reported(project_root: str, state: dict, sid: str, keys: set) -> None:
82
+ state[_REPORTED_KEY] = {"sid": sid, "keys": sorted(keys)}
83
+ save_check_state(project_root, state)
84
+
85
+
86
+ def _feedback_text(violations: list) -> str:
87
+ lines = ["## Spec 合规反馈 (auto-check)", ""]
88
+ for v in violations:
89
+ lines.append(f"⚠ {v['message']}(规则: {v['spec']}#{v['check_id']})")
90
+ lines.append(f" 违规行 {v['line_no']}: {v['line']}")
91
+ lines.append("")
92
+ lines.append("请修正以上违规后再继续。" + FEEDBACK_HINT)
93
+ return "\n".join(lines)
94
+
95
+
96
+ def main() -> None:
97
+ try:
98
+ ensure_utf8_io()
99
+ raw = sys.stdin.read()
100
+ if not raw.strip():
101
+ return
102
+ data = json.loads(raw)
103
+ tool_name = data.get("tool_name", "")
104
+ file_path = (data.get("tool_input") or {}).get("file_path", "")
105
+ if tool_name not in {"Edit", "Write", "MultiEdit"}:
106
+ return
107
+ if not isinstance(file_path, str) or not file_path:
108
+ return
109
+
110
+ project_root = os.getcwd()
111
+ config = load_config(project_root)
112
+ if not config:
113
+ return
114
+ quality_loop = resolve_quality_loop(config)
115
+ if not quality_loop["post_check"]:
116
+ return
117
+
118
+ abs_path = file_path
119
+ if not os.path.isabs(abs_path):
120
+ abs_path = os.path.join(project_root, file_path)
121
+ rel_path = normalize_path(os.path.relpath(abs_path, project_root))
122
+ if not is_code_file(rel_path, config.get("inject") or {}):
123
+ return
124
+
125
+ sid = resolve_session_id(data)
126
+ mapping = build_effective_mapping(
127
+ project_root, config.get("path_mapping") or {}
128
+ )
129
+ domains = match_domains(rel_path, mapping)
130
+ if not domains:
131
+ domains = sorted(fallback_domains_for_context(
132
+ mapping, extract_context_tags(rel_path)
133
+ ))
134
+ if not domains:
135
+ return
136
+
137
+ checks = _collect_checks(project_root, domains, mapping)
138
+ if not checks:
139
+ return
140
+
141
+ try:
142
+ with open(abs_path, "r", encoding="utf-8") as f:
143
+ content = f.read()
144
+ except Exception:
145
+ return
146
+
147
+ state = load_check_state(project_root)
148
+ violations, skipped = run_checks(checks, rel_path, content, state)
149
+ for item in skipped:
150
+ cf_log.degrade(
151
+ project_root, "post_check",
152
+ f"{item['check_id']}:{item['reason']}", sid,
153
+ )
154
+ if not violations:
155
+ return
156
+
157
+ spec_by_check = {c["id"]: c["spec"] for c in checks}
158
+ reported = _session_reported(state, sid)
159
+ fresh = []
160
+ for v in violations:
161
+ key = f"{v['check_id']}|{v['file']}"
162
+ if key in reported:
163
+ continue
164
+ v["spec"] = spec_by_check.get(v["check_id"], "")
165
+ fresh.append(v)
166
+ reported.add(key)
167
+ if not fresh:
168
+ return
169
+
170
+ # hit_count feeds the false-positive rate denominator (RULE-04)
171
+ record_hits(project_root, sorted({v["check_id"] for v in fresh}))
172
+ state = load_check_state(project_root)
173
+ _save_reported(project_root, state, sid, reported)
174
+
175
+ for v in fresh:
176
+ cf_log.append_event(
177
+ project_root, "violation",
178
+ {"check_id": v["check_id"], "spec": v["spec"],
179
+ "file": v["file"], "severity": v["severity"]},
180
+ sid,
181
+ )
182
+
183
+ payload = {
184
+ "hookSpecificOutput": {
185
+ "hookEventName": "PostToolUse",
186
+ "additionalContext": _feedback_text(fresh),
187
+ }
188
+ }
189
+ sys.stdout.write(json.dumps(payload, ensure_ascii=False))
190
+ except Exception as exc:
191
+ _log(f"cf_post_hook error: {exc}")
192
+ return
193
+
194
+
195
+ if __name__ == "__main__":
196
+ main()