diff --git a/.code-flow/config.yml b/.code-flow/config.yml index 1ab1b7c..128d9dc 100644 --- a/.code-flow/config.yml +++ b/.code-flow/config.yml @@ -8,6 +8,7 @@ budget: inject: auto: true + log: false # 注入时对 spec 内容做保守无损压缩(去行尾空白、折叠多空行、剥 HTML 注释、去重 bullet) # 缺省视为 true;仅显式 false 关闭 compress: true diff --git a/.code-flow/scripts/cf_inject_hook.py b/.code-flow/scripts/cf_inject_hook.py index cb801f3..da27916 100644 --- a/.code-flow/scripts/cf_inject_hook.py +++ b/.code-flow/scripts/cf_inject_hook.py @@ -21,9 +21,9 @@ save_inject_state, select_specs_tiered, ) +from cf_log import reset_stdout - - +HOOK_TYPE = "hook_inject" def main() -> None: @@ -32,6 +32,19 @@ def main() -> None: if not raw.strip(): return data = json.loads(raw) + + # Resolve session id early (needed for both state and logging) + sid = resolve_session_id(data) + + # Load config to check if logging is enabled + project_root = os.getcwd() + config = load_config(project_root) + inject_config = config.get("inject") or {} if config else {} + + # Enable logging if configured (default off) + if inject_config.get("log") is True: + reset_stdout(HOOK_TYPE + sid) + tool_name = data.get("tool_name", "") tool_input = data.get("tool_input") or {} file_path = tool_input.get("file_path", "") @@ -40,13 +53,11 @@ def main() -> None: if not isinstance(file_path, str) or not file_path: return - project_root = os.getcwd() abs_path = file_path if not os.path.isabs(abs_path): abs_path = os.path.join(project_root, file_path) rel_path = os.path.relpath(abs_path, project_root) - config = load_config(project_root) if not config: return inject_config = config.get("inject") or {} @@ -61,7 +72,6 @@ def main() -> None: domains = match_domains(rel_path, effective_mapping) # Load state with session isolation (fix #10) - sid = resolve_session_id(data) state = load_inject_state(project_root) state_sid = state.get("session_id", "") if state_sid != sid: @@ -135,7 +145,7 @@ def main() -> None: "context_tags": sorted(context_tags), "matched_specs": [s["path"] for s in selected], } - sys.stdout.write(json.dumps(payload)) + sys.stdout.write(json.dumps(payload, ensure_ascii=False)) except Exception as exc: # Fix #9: log errors to stderr instead of silently swallowing _log(f"cf_inject_hook error: {exc}") diff --git a/.code-flow/scripts/cf_log.py b/.code-flow/scripts/cf_log.py new file mode 100644 index 0000000..f8ec9a1 --- /dev/null +++ b/.code-flow/scripts/cf_log.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +# coding: utf-8 +import atexit +import datetime +import os +import random +import sys + +LOG_ROOT = os.path.normpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "../logs") +) + +# fix#2: module-level save of original stdout, enables restore_stdout() rollback +_original_stdout = sys.stdout + + +class StdoutLogTee: + def __init__(self, prefix: str | None = None) -> None: + self._file_path = self._init_log_path(prefix) # fix#5: private method + # fix#2: bind to real stdout, prevent chain tee on repeated calls + self._stream = _original_stdout + # fix#3: track actual bytes, replace bool _has_content + self._bytes_written = 0 + self._closed = False + self._file = open(self._file_path, "a", encoding="utf-8") + atexit.register(self.close) + + def write(self, data: str) -> int: + # fix#1: stdout write first; file write failure degrades to stderr warning, does not block main flow + n = self._stream.write(data) + self._bytes_written += len(data) # fix#3 + try: + self._file.write(data) + except OSError as err: + self._err_log(f"log write error: {err}") + return n + + def flush(self) -> None: + self._stream.flush() + self._file.flush() + + def isatty(self) -> bool: + return self._stream.isatty() + + def _err_log(self, err_msg: str) -> None: + # fix#5: private method + print(err_msg, file=sys.stderr) + + def close(self) -> None: + if self._closed: + return + self._closed = True + # fix#4: unregister on manual close, prevent handler accumulation + atexit.unregister(self.close) + try: + self._file.close() + except OSError as err: + self._err_log(f"close error: {err}") + finally: + # fix#4: inside finally, empty-file cleanup runs even if file.close() raises + # fix#3: use _bytes_written, avoid false positive from write("") + if not self._bytes_written and os.path.isfile(self._file_path): + try: + os.remove(self._file_path) + except OSError as err: + self._err_log(f"remove empty logfile error: {err}") + + def _init_log_path(self, prefix: str | None) -> str: + if not prefix: + prefix = "default" + now = datetime.datetime.now() + timestamp = now.strftime("%Y%m%d_%H%M%S") + f"{now.microsecond // 1000:03d}" + rand_suffix = f"{random.randint(0, 999):03d}" + log_file_name = f"{timestamp}_{rand_suffix}.log" + + log_dir = os.path.join(LOG_ROOT, prefix) + log_path = os.path.join(log_dir, log_file_name) + + # makr sure log_dir + os.makedirs(log_dir, exist_ok=True) + return log_path + + +def reset_stdout(prefix: str | None = None) -> "StdoutLogTee": + tee = StdoutLogTee(prefix) + sys.stdout = tee + return tee + + +def restore_stdout() -> None: + # fix#2: new, roll back to original stdout + sys.stdout = _original_stdout diff --git a/.code-flow/scripts/cf_user_prompt_hook.py b/.code-flow/scripts/cf_user_prompt_hook.py index 73ad8cf..3c3bbb2 100644 --- a/.code-flow/scripts/cf_user_prompt_hook.py +++ b/.code-flow/scripts/cf_user_prompt_hook.py @@ -34,6 +34,10 @@ save_inject_state, select_specs_tiered, ) +from cf_log import reset_stdout + + +HOOK_TYPE = "hook_user_prompt" # Match bare paths, @-prefixed paths, and backtick-quoted paths _PATH_RE = re.compile(r'[@`]?([a-zA-Z0-9_.][a-zA-Z0-9_./\-]*\.[a-zA-Z]{1,6})\b') @@ -79,6 +83,9 @@ def main() -> None: if inject_config.get("auto") is False: return compress_enabled = resolve_compress(inject_config) + # Enable logging if configured (default off) + if inject_config.get("log") is True: + reset_stdout(HOOK_TYPE + sid) mapping = config.get("path_mapping") or {} effective_mapping = build_effective_mapping(project_root, mapping) @@ -165,7 +172,7 @@ def main() -> None: "matched_specs": [s["path"] for s in selected], } - sys.stdout.write(json.dumps(payload)) + sys.stdout.write(json.dumps(payload, ensure_ascii=False)) except Exception as exc: _log(f"cf_user_prompt_hook error: {exc}") diff --git a/src/core/code-flow/config.yml b/src/core/code-flow/config.yml index 309e2ad..ab09eba 100644 --- a/src/core/code-flow/config.yml +++ b/src/core/code-flow/config.yml @@ -8,6 +8,7 @@ budget: inject: auto: true + log: false # 注入时对 spec 内容做保守无损压缩(去行尾空白、折叠多空行、剥 HTML 注释、去重 bullet) # 缺省视为 true;仅显式 false 关闭 compress: true diff --git a/src/core/code-flow/scripts/cf_inject_hook.py b/src/core/code-flow/scripts/cf_inject_hook.py index cb801f3..da27916 100644 --- a/src/core/code-flow/scripts/cf_inject_hook.py +++ b/src/core/code-flow/scripts/cf_inject_hook.py @@ -21,9 +21,9 @@ save_inject_state, select_specs_tiered, ) +from cf_log import reset_stdout - - +HOOK_TYPE = "hook_inject" def main() -> None: @@ -32,6 +32,19 @@ def main() -> None: if not raw.strip(): return data = json.loads(raw) + + # Resolve session id early (needed for both state and logging) + sid = resolve_session_id(data) + + # Load config to check if logging is enabled + project_root = os.getcwd() + config = load_config(project_root) + inject_config = config.get("inject") or {} if config else {} + + # Enable logging if configured (default off) + if inject_config.get("log") is True: + reset_stdout(HOOK_TYPE + sid) + tool_name = data.get("tool_name", "") tool_input = data.get("tool_input") or {} file_path = tool_input.get("file_path", "") @@ -40,13 +53,11 @@ def main() -> None: if not isinstance(file_path, str) or not file_path: return - project_root = os.getcwd() abs_path = file_path if not os.path.isabs(abs_path): abs_path = os.path.join(project_root, file_path) rel_path = os.path.relpath(abs_path, project_root) - config = load_config(project_root) if not config: return inject_config = config.get("inject") or {} @@ -61,7 +72,6 @@ def main() -> None: domains = match_domains(rel_path, effective_mapping) # Load state with session isolation (fix #10) - sid = resolve_session_id(data) state = load_inject_state(project_root) state_sid = state.get("session_id", "") if state_sid != sid: @@ -135,7 +145,7 @@ def main() -> None: "context_tags": sorted(context_tags), "matched_specs": [s["path"] for s in selected], } - sys.stdout.write(json.dumps(payload)) + sys.stdout.write(json.dumps(payload, ensure_ascii=False)) except Exception as exc: # Fix #9: log errors to stderr instead of silently swallowing _log(f"cf_inject_hook error: {exc}") diff --git a/src/core/code-flow/scripts/cf_log.py b/src/core/code-flow/scripts/cf_log.py new file mode 100644 index 0000000..5921a3e --- /dev/null +++ b/src/core/code-flow/scripts/cf_log.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +# coding: utf-8 +import atexit +import datetime +import os +import random +import sys + +LOG_ROOT = os.path.normpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "../logs") +) + +# fix#2: module-level save of original stdout, enables restore_stdout() rollback +_original_stdout = sys.stdout + + +class StdoutLogTee: + def __init__(self, prefix: str | None = None) -> None: + self._file_path = self._init_log_path(prefix) # fix#5: private method + # fix#2: bind to real stdout, prevent chain tee on repeated calls + self._stream = _original_stdout + # fix#3: track actual bytes, replace bool _has_content + self._bytes_written = 0 + self._closed = False + self._file = open(self._file_path, "a", encoding="utf-8") + atexit.register(self.close) + + def write(self, data: str) -> int: + # fix#1: stdout write first; file write failure degrades to stderr warning, does not block main flow + n = self._stream.write(data) + self._bytes_written += len(data) # fix#3 + try: + self._file.write(data) + except OSError as err: + self._err_log(f"log write error: {err}") + return n + + def flush(self) -> None: + self._stream.flush() + self._file.flush() + + def isatty(self) -> bool: + return self._stream.isatty() + + def _err_log(self, err_msg: str) -> None: + # fix#5: private method + print(err_msg, file=sys.stderr) + + def close(self) -> None: + if self._closed: + return + self._closed = True + # fix#4: unregister on manual close, prevent handler accumulation + atexit.unregister(self.close) + try: + self._file.close() + except OSError as err: + self._err_log(f"close error: {err}") + finally: + # fix#4: inside finally, empty-file cleanup runs even if file.close() raises + # fix#3: use _bytes_written, avoid false positive from write("") + if not self._bytes_written and os.path.isfile(self._file_path): + try: + os.remove(self._file_path) + except OSError as err: + self._err_log(f"remove empty logfile error: {err}") + + def _init_log_path(self, prefix: str | None) -> str: + if not prefix: + prefix = "default" + now = datetime.datetime.now() + timestamp = now.strftime("%Y%m%d_%H%M%S") + f"{now.microsecond // 1000:03d}" + rand_suffix = f"{random.randint(0, 999):03d}" + log_file_name = f"{timestamp}_{rand_suffix}.log" + + log_path = os.path.join(LOG_ROOT, prefix, log_file_name) + log_dir = os.path.join(LOG_ROOT, prefix) + + # makr sure log_dir + os.makedirs(log_dir, exist_ok=True) + return log_path + + +def reset_stdout(prefix: str | None = None) -> "StdoutLogTee": + tee = StdoutLogTee(prefix) + sys.stdout = tee + return tee + + +def restore_stdout() -> None: + # fix#2: new, roll back to original stdout + sys.stdout = _original_stdout diff --git a/src/core/code-flow/scripts/cf_user_prompt_hook.py b/src/core/code-flow/scripts/cf_user_prompt_hook.py index 73ad8cf..3c3bbb2 100644 --- a/src/core/code-flow/scripts/cf_user_prompt_hook.py +++ b/src/core/code-flow/scripts/cf_user_prompt_hook.py @@ -34,6 +34,10 @@ save_inject_state, select_specs_tiered, ) +from cf_log import reset_stdout + + +HOOK_TYPE = "hook_user_prompt" # Match bare paths, @-prefixed paths, and backtick-quoted paths _PATH_RE = re.compile(r'[@`]?([a-zA-Z0-9_.][a-zA-Z0-9_./\-]*\.[a-zA-Z]{1,6})\b') @@ -79,6 +83,9 @@ def main() -> None: if inject_config.get("auto") is False: return compress_enabled = resolve_compress(inject_config) + # Enable logging if configured (default off) + if inject_config.get("log") is True: + reset_stdout(HOOK_TYPE + sid) mapping = config.get("path_mapping") or {} effective_mapping = build_effective_mapping(project_root, mapping) @@ -165,7 +172,7 @@ def main() -> None: "matched_specs": [s["path"] for s in selected], } - sys.stdout.write(json.dumps(payload)) + sys.stdout.write(json.dumps(payload, ensure_ascii=False)) except Exception as exc: _log(f"cf_user_prompt_hook error: {exc}")