#!/usr/bin/env bash # ============================================================================== # 用途:工作證明自動記錄(worklog)。由支援 hook 的 CLI 觸發, # 抽出本輪工作內容 → 呼叫已安裝 CLI 濃縮成精簡條目 → 機密遮蔽 → # 追加到 Gitea wiki 的當週工作紀錄頁。工作內容全程不落地。 # 更新時間:2026/07/27 22:16:00 # 相依:python3、README 定義的任一 headless CLI、curl(wiki 走 Python urllib,不需 curl 亦可)。 # 機密:token 僅由環境變數/本機憑證讀取,不 echo、不寫檔;輸出前套用遮蔽規則。 # 退出碼:一律 0 —— hook 絕不可阻斷使用者的工作流程。 # ============================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" STAGE="worklog" SUPPORTED_CLIS="claude codex agy opencode copilot" FALLBACK_MODEL="claude-haiku-4-5-20251001" MODEL_CACHE="${HOME}/.claude/worklog/model" CACHE_MAX_AGE_DAYS=30 # ------------------------------------------------------------------------------ # 共用函式 # ------------------------------------------------------------------------------ log() { # 輸出統一格式訊息([時間][階段][等級]: 訊息,一行一則),一律走 stderr local level="$1" message="$2" stamp stamp="$(TZ='Asia/Taipei' date +'%Y/%m/%d %H:%M:%S')" printf '[%s][%s][%s]: %s\n' "$stamp" "$STAGE" "$level" "$message" >&2 if [ -n "${WORKLOG_ERRLOG:-}" ] && [ "$level" = "ERR" ]; then printf '[%s][%s][%s]: %s\n' "$stamp" "$STAGE" "$level" "$message" >> "${WORKLOG_ERRLOG}" 2>/dev/null fi } die_quiet() { # 記錄原因後以 0 結束:hook 不得阻斷使用者流程 log "${2:-DBG}" "$1" exit 0 } # ------------------------------------------------------------------------------ # 遞迴防護:摘要用的子 CLI 行程可能再次觸發 Stop hook,必須在此擋掉 # ------------------------------------------------------------------------------ [ -n "${WORKLOG_CHILD:-}" ] && exit 0 # ------------------------------------------------------------------------------ # 啟用檢查:未設定 WORKLOG_* 的環境完全不動作(他人匯入 plugin 零影響) # ------------------------------------------------------------------------------ [ "${WORKLOG_ENABLED:-}" = "1" ] || exit 0 [ -n "${WORKLOG_HOST:-}" ] || die_quiet "未設定 WORKLOG_HOST,略過記錄" "WRN" [ -n "${WORKLOG_REPO:-}" ] || die_quiet "未設定 WORKLOG_REPO,略過記錄" "WRN" command -v python3 >/dev/null 2>&1 || die_quiet "找不到 python3,略過記錄" "WRN" select_worklog_cli() { # 依 README 定義的 headless CLI 選擇摘要執行器;可用 WORKLOG_CLI 強制指定。 local requested="${WORKLOG_CLI:-auto}" cli if [ "$requested" != "auto" ]; then case " ${SUPPORTED_CLIS} " in *" ${requested} "*) ;; *) die_quiet "WORKLOG_CLI 不支援:${requested}(可用:auto ${SUPPORTED_CLIS})" "WRN" ;; esac command -v "$requested" >/dev/null 2>&1 || die_quiet "找不到 ${requested} CLI,略過記錄" "WRN" printf '%s' "$requested" return 0 fi for cli in $SUPPORTED_CLIS; do if command -v "$cli" >/dev/null 2>&1; then printf '%s' "$cli" return 0 fi done die_quiet "找不到可用摘要 CLI(需要其一:${SUPPORTED_CLIS})" "WRN" } run_summary_cli() { # 各 CLI 依 README 的 headless 指令呼叫;不把工作內容寫入檔案。 local cli="$1" prompt="$2" model="$3" case "$cli" in claude) WORKLOG_CHILD=1 timeout 45 claude -p "$prompt" --model "$model" 2>/dev/null ;; codex) WORKLOG_CHILD=1 timeout 45 codex exec "$prompt" 2>/dev/null ;; agy) WORKLOG_CHILD=1 timeout 45 agy -p "$prompt" 2>/dev/null ;; opencode) WORKLOG_CHILD=1 timeout 45 opencode run "$prompt" 2>/dev/null ;; copilot) WORKLOG_CHILD=1 timeout 45 copilot -p "$prompt" 2>/dev/null ;; esac } SUMMARY_CLI="$(select_worklog_cli)" [ -n "$SUMMARY_CLI" ] || exit 0 # ------------------------------------------------------------------------------ # 讀取 hook 傳入的 JSON(session_id/transcript_path/cwd/stop_hook_active) # ------------------------------------------------------------------------------ HOOK_INPUT="$(cat)" [ -n "$HOOK_INPUT" ] || die_quiet "hook 輸入為空,略過記錄" "WRN" read -r SESSION_ID TRANSCRIPT_PATH STOP_ACTIVE HOOK_CWD </dev/null)" [ -n "$TRANSCRIPT_PATH" ] || TRANSCRIPT_PATH="-" fi [ -f "$TRANSCRIPT_PATH" ] || die_quiet "找不到 transcript:${TRANSCRIPT_PATH}" "WRN" # ------------------------------------------------------------------------------ # 記錄範圍:WORKLOG_SCOPE 以冒號分隔的路徑前綴,未設定則全部 session 都記 # ------------------------------------------------------------------------------ if [ -n "${WORKLOG_SCOPE:-}" ]; then in_scope=0 IFS=':' read -r -a scopes <<< "${WORKLOG_SCOPE}" for scope in "${scopes[@]}"; do case "$HOOK_CWD" in "${scope%/}"*) in_scope=1 ;; esac done [ "$in_scope" = "1" ] || die_quiet "cwd 不在 WORKLOG_SCOPE 範圍內:${HOOK_CWD}" fi # ------------------------------------------------------------------------------ # 專案判定:git remote 的 / 優先,其次目錄名 # ------------------------------------------------------------------------------ PROJECT="$(basename "$HOOK_CWD")" if git -C "$HOOK_CWD" rev-parse --is-inside-work-tree >/dev/null 2>&1; then origin="$(git -C "$HOOK_CWD" remote get-url origin 2>/dev/null)" if [ -n "$origin" ]; then cleaned="${origin%.git}" cleaned="${cleaned##*://}" cleaned="${cleaned#*@}" owner_repo="$(printf '%s' "$cleaned" | awk -F/ 'NF>=2 {print $(NF-1)"/"$NF}')" [ -n "$owner_repo" ] && PROJECT="$owner_repo" fi fi # ------------------------------------------------------------------------------ # 抽出本輪內容(最後一筆使用者訊息之後),並先做一次機密遮蔽 # ------------------------------------------------------------------------------ TURN="$(python3 "${SCRIPT_DIR}/transcript.py" extract "$TRANSCRIPT_PATH" 2>/dev/null)" [ -n "$TURN" ] || die_quiet "本輪無可記錄內容" DURATION="$(python3 "${SCRIPT_DIR}/transcript.py" duration "$TRANSCRIPT_PATH" 2>/dev/null)" [ -n "$DURATION" ] || DURATION="未判定" # ------------------------------------------------------------------------------ # 模型決定:只有 claude CLI 使用 WORKLOG_MODEL/快取檔;其他 CLI 使用各自預設模型 # ------------------------------------------------------------------------------ MODEL="" MODEL_NOTE="" if [ "$SUMMARY_CLI" = "claude" ]; then if [ -n "${WORKLOG_MODEL:-}" ]; then MODEL="${WORKLOG_MODEL}" elif [ -f "$MODEL_CACHE" ]; then if [ -n "$(find "$MODEL_CACHE" -mtime "+${CACHE_MAX_AGE_DAYS}" 2>/dev/null)" ]; then MODEL="$FALLBACK_MODEL" MODEL_NOTE=" (cli: claude, model: fallback)" log "WRN" "模型快取已超過 ${CACHE_MAX_AGE_DAYS} 天,改用保底模型,建議重跑 /jsc:worklog --tune" else MODEL="$(grep -m1 -E '^model=' "$MODEL_CACHE" 2>/dev/null | cut -d= -f2- | tr -d '[:space:]')" fi fi if [ -z "$MODEL" ]; then MODEL="$FALLBACK_MODEL" MODEL_NOTE=" (cli: claude, model: fallback)" log "WRN" "無模型快取,改用保底模型,建議執行 /jsc:worklog --tune" elif [ -z "$MODEL_NOTE" ]; then MODEL_NOTE=" (cli: claude)" fi else MODEL_NOTE=" (cli: ${SUMMARY_CLI})" fi # ------------------------------------------------------------------------------ # 濃縮:交給選定 CLI 產出精簡條目(子行程帶 WORKLOG_CHILD=1 阻斷遞迴) # ------------------------------------------------------------------------------ PROMPT="$(cat </dev/null)" # 只保留 bullet 行,避免模型帶出多餘敘述 SUMMARY="$(printf '%s\n' "$SUMMARY" | grep -E '^\s*[-*]\s+' | sed -E 's/^\s*[*]/-/' | head -6)" [ -n "$SUMMARY" ] || die_quiet "摘要不含合法條目,略過本輪" "WRN" # ------------------------------------------------------------------------------ # 組條目並追加到當週 wiki 頁 # ------------------------------------------------------------------------------ STAMP="$(TZ='Asia/Taipei' date +'%Y/%m/%d %H:%M:%S')" MARKER="worklog:$(TZ='Asia/Taipei' date +'%Y%m%d%H%M%S')-${SESSION_ID:0:8}" ENTRY="$(printf '## %s — %s%s \n%s\n' "$STAMP" "$PROJECT" "$MODEL_NOTE" "$MARKER" "$SUMMARY")" export WORKLOG_HOST WORKLOG_REPO if printf '%s' "$ENTRY" | python3 "${SCRIPT_DIR}/wiki_api.py" append "$MARKER" 2>&1 | grep -q '\[ERR\]'; then log "ERR" "寫入 wiki 失敗(專案 ${PROJECT})" else log "INF" "已記錄工作條目(專案 ${PROJECT},CLI ${SUMMARY_CLI})" fi exit 0