Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .code-flow/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ budget:

inject:
auto: true
log: false
# 注入时对 spec 内容做保守无损压缩(去行尾空白、折叠多空行、剥 HTML 注释、去重 bullet)
# 缺省视为 true;仅显式 false 关闭
compress: true
Expand Down
22 changes: 16 additions & 6 deletions .code-flow/scripts/cf_inject_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@
save_inject_state,
select_specs_tiered,
)
from cf_log import reset_stdout



HOOK_TYPE = "hook_inject"


def main() -> None:
Expand All @@ -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", "")
Expand All @@ -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 {}
Expand All @@ -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:
Expand Down Expand Up @@ -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}")
Expand Down
92 changes: 92 additions & 0 deletions .code-flow/scripts/cf_log.py
Original file line number Diff line number Diff line change
@@ -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
9 changes: 8 additions & 1 deletion .code-flow/scripts/cf_user_prompt_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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}")
Expand Down
1 change: 1 addition & 0 deletions src/core/code-flow/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ budget:

inject:
auto: true
log: false
# 注入时对 spec 内容做保守无损压缩(去行尾空白、折叠多空行、剥 HTML 注释、去重 bullet)
# 缺省视为 true;仅显式 false 关闭
compress: true
Expand Down
22 changes: 16 additions & 6 deletions src/core/code-flow/scripts/cf_inject_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@
save_inject_state,
select_specs_tiered,
)
from cf_log import reset_stdout



HOOK_TYPE = "hook_inject"


def main() -> None:
Expand All @@ -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", "")
Expand All @@ -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 {}
Expand All @@ -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:
Expand Down Expand Up @@ -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}")
Expand Down
92 changes: 92 additions & 0 deletions src/core/code-flow/scripts/cf_log.py
Original file line number Diff line number Diff line change
@@ -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
9 changes: 8 additions & 1 deletion src/core/code-flow/scripts/cf_user_prompt_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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}")
Expand Down