@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.
- package/README.md +157 -0
- package/package.json +16 -0
- package/src/adapters/claude/CLAUDE.md +51 -0
- package/src/adapters/claude/commands/cf-init.md +262 -0
- package/src/adapters/claude/commands/cf-inject.md +55 -0
- package/src/adapters/claude/commands/cf-learn.md +291 -0
- package/src/adapters/claude/commands/cf-stats.md +86 -0
- package/src/adapters/claude/commands/cf-task/align.md +283 -0
- package/src/adapters/claude/commands/cf-task/archive.md +131 -0
- package/src/adapters/claude/commands/cf-task/block.md +30 -0
- package/src/adapters/claude/commands/cf-task/graph.md +73 -0
- package/src/adapters/claude/commands/cf-task/note.md +239 -0
- package/src/adapters/claude/commands/cf-task/plan.md +270 -0
- package/src/adapters/claude/commands/cf-task/prd.md +138 -0
- package/src/adapters/claude/commands/cf-task/start.md +173 -0
- package/src/adapters/claude/commands/cf-task/status.md +59 -0
- package/src/adapters/claude/commands/cf-validate.md +73 -0
- package/src/adapters/claude/settings.local.json +60 -0
- package/src/adapters/codex/AGENTS.md +51 -0
- package/src/adapters/codex/config.toml +3 -0
- package/src/adapters/codex/hooks.json +48 -0
- package/src/adapters/codex/skills/cf-init/SKILL.md +271 -0
- package/src/adapters/codex/skills/cf-inject/SKILL.md +56 -0
- package/src/adapters/codex/skills/cf-learn/SKILL.md +296 -0
- package/src/adapters/codex/skills/cf-stats/SKILL.md +87 -0
- package/src/adapters/codex/skills/cf-task-align/SKILL.md +284 -0
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +132 -0
- package/src/adapters/codex/skills/cf-task-block/SKILL.md +31 -0
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +74 -0
- package/src/adapters/codex/skills/cf-task-note/SKILL.md +236 -0
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +271 -0
- package/src/adapters/codex/skills/cf-task-prd/SKILL.md +139 -0
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +174 -0
- package/src/adapters/codex/skills/cf-task-status/SKILL.md +60 -0
- package/src/adapters/codex/skills/cf-validate/SKILL.md +74 -0
- package/src/adapters/costrict/CLAUDE.md +51 -0
- package/src/adapters/costrict/commands/cf-init.md +262 -0
- package/src/adapters/costrict/commands/cf-inject.md +55 -0
- package/src/adapters/costrict/commands/cf-learn.md +291 -0
- package/src/adapters/costrict/commands/cf-stats.md +86 -0
- package/src/adapters/costrict/commands/cf-task/align.md +283 -0
- package/src/adapters/costrict/commands/cf-task/archive.md +131 -0
- package/src/adapters/costrict/commands/cf-task/block.md +30 -0
- package/src/adapters/costrict/commands/cf-task/graph.md +73 -0
- package/src/adapters/costrict/commands/cf-task/note.md +239 -0
- package/src/adapters/costrict/commands/cf-task/plan.md +270 -0
- package/src/adapters/costrict/commands/cf-task/prd.md +138 -0
- package/src/adapters/costrict/commands/cf-task/start.md +173 -0
- package/src/adapters/costrict/commands/cf-task/status.md +59 -0
- package/src/adapters/costrict/commands/cf-validate.md +73 -0
- package/src/adapters/costrict/settings.local.json +60 -0
- package/src/adapters/opencode/AGENTS.md +51 -0
- package/src/adapters/opencode/commands/cf-init.md +275 -0
- package/src/adapters/opencode/commands/cf-inject.md +59 -0
- package/src/adapters/opencode/commands/cf-learn.md +295 -0
- package/src/adapters/opencode/commands/cf-stats.md +90 -0
- package/src/adapters/opencode/commands/cf-task/align.md +287 -0
- package/src/adapters/opencode/commands/cf-task/archive.md +135 -0
- package/src/adapters/opencode/commands/cf-task/block.md +34 -0
- package/src/adapters/opencode/commands/cf-task/graph.md +77 -0
- package/src/adapters/opencode/commands/cf-task/note.md +243 -0
- package/src/adapters/opencode/commands/cf-task/plan.md +274 -0
- package/src/adapters/opencode/commands/cf-task/prd.md +142 -0
- package/src/adapters/opencode/commands/cf-task/start.md +177 -0
- package/src/adapters/opencode/commands/cf-task/status.md +63 -0
- package/src/adapters/opencode/commands/cf-validate.md +77 -0
- package/src/adapters/opencode/opencode.json +4 -0
- package/src/adapters/opencode/plugins/code-flow/index.js +132 -0
- package/src/adapters/opencode/plugins/code-flow/package.json +6 -0
- package/src/cli.js +810 -0
- package/src/core/code-flow/config.yml +159 -0
- package/src/core/code-flow/scripts/cf_checks.py +306 -0
- package/src/core/code-flow/scripts/cf_core.py +954 -0
- package/src/core/code-flow/scripts/cf_feedback.py +56 -0
- package/src/core/code-flow/scripts/cf_inject_hook.py +178 -0
- package/src/core/code-flow/scripts/cf_log.py +146 -0
- package/src/core/code-flow/scripts/cf_post_hook.py +196 -0
- package/src/core/code-flow/scripts/cf_scan.py +293 -0
- package/src/core/code-flow/scripts/cf_session_hook.py +42 -0
- package/src/core/code-flow/scripts/cf_stats.py +439 -0
- package/src/core/code-flow/scripts/cf_stop_hook.py +191 -0
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +402 -0
- package/src/core/code-flow/specs/backend/_map.md +56 -0
- package/src/core/code-flow/specs/backend/code-quality-performance.md +47 -0
- package/src/core/code-flow/specs/backend/database.md +39 -0
- package/src/core/code-flow/specs/backend/directory-structure.md +42 -0
- package/src/core/code-flow/specs/backend/logging.md +38 -0
- package/src/core/code-flow/specs/backend/platform-rules.md +43 -0
- package/src/core/code-flow/specs/frontend/_map.md +52 -0
- package/src/core/code-flow/specs/frontend/component-specs.md +56 -0
- package/src/core/code-flow/specs/frontend/directory-structure.md +41 -0
- package/src/core/code-flow/specs/frontend/quality-standards.md +46 -0
- package/src/core/code-flow/specs/shared/_map.md +57 -0
- package/src/core/code-flow/specs/shared/design/design-frontend.md +247 -0
- package/src/core/code-flow/specs/shared/design/design-full.md +494 -0
- package/src/core/code-flow/specs/shared/design/design-lite.md +293 -0
- package/src/core/code-flow/specs/shared/prd-template.md +187 -0
- package/src/core/code-flow/tasks/.gitkeep +0 -0
- 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()
|