#!/usr/bin/env bash # ============================================================================== # 用途:工作證明自動記錄(worklog)。由支援 hook 的 CLI 觸發, # 抽出本輪工作內容 → 呼叫已安裝 CLI 濃縮成精簡條目 → 機密遮蔽 → # 追加到 Gitea wiki 的當週工作紀錄頁。工作內容全程不落地。 # 更新時間:2026/08/11 16:51:56 # 相依:node(transcript.mjs/wiki_api.mjs)、README 定義的任一 headless CLI。 # 機密: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 node >/dev/null 2>&1 || die_quiet "找不到 node,略過記錄" "WRN" select_worklog_cli() { # 依目前 hook/session 環境優先選擇摘要執行器;可用 WORKLOG_CLI 強制指定。 local requested="${WORKLOG_CLI:-auto}" cli current_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 current_cli="$(detect_current_cli)" if [ -n "$current_cli" ]; then if command -v "$current_cli" >/dev/null 2>&1; then printf '%s' "$current_cli" return 0 fi log "WRN" "目前環境判定為 ${current_cli},但找不到 ${current_cli} CLI,改用可用摘要 CLI" 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" } detect_current_cli() { # 判斷實際觸發本輪 hook 的助理環境,避免 auto 因 PATH 順序誤顯其他 CLI。 if [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_CI:-}" ] || [ -n "${CODEX_MANAGED_PACKAGE_ROOT:-}" ]; then printf 'codex' return 0 fi if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] || [ -n "${CLAUDE_CODE_SSE_PORT:-}" ]; then printf 'claude' return 0 fi if [ -n "${AGY_SESSION_ID:-}" ] || [ -n "${AGY_WORKSPACE_ID:-}" ]; then printf 'agy' return 0 fi if [ -n "${OPENCODE_SESSION_ID:-}" ] || [ -n "${OPENCODE_CONFIG:-}" ]; then printf 'opencode' return 0 fi if [ -n "${COPILOT_AGENT_ID:-}" ] || [ -n "${GITHUB_COPILOT_TOKEN:-}" ]; then printf 'copilot' return 0 fi return 0 } 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。欄位名稱大小寫依助理而異:Claude Code/Codex 用 # snake_case(session_id/transcript_path),Copilot 的 agentStop 事件用 # camelCase(sessionId/transcriptPath,實測見 H1-1),只有 stop_hook_active # 剛好三家都是 snake_case。兩種寫法都要接,缺一個 Copilot 就完全取不到值。 # ------------------------------------------------------------------------------ HOOK_INPUT="$(cat)" [ -n "$HOOK_INPUT" ] || die_quiet "hook 輸入為空,略過記錄" "WRN" read -r SESSION_ID TRANSCRIPT_PATH STOP_ACTIVE HOOK_CWD < { raw += c; }); process.stdin.on("end", () => { let d = {}; try { d = JSON.parse(raw); } catch { d = {}; } const sessionId = d.session_id || d.sessionId || d.thread_id || d.conversation_id || "-"; const transcriptPath = d.transcript_path || d.transcriptPath || d.session_path || d.conversation_path || d.path || "-"; const stopActive = (d.stop_hook_active || d.stopHookActive) ? "1" : "0"; const cwd = d.cwd || "-"; console.log(sessionId, transcriptPath, stopActive, cwd); }); ') EOF_HOOK [ "$STOP_ACTIVE" = "1" ] && die_quiet "stop_hook_active 為 true,避免迴圈不重複記錄" # Codex 的 hook 只給 thread id、不給 transcript 路徑,需要自己找檔案; # Copilot 的 agentStop 事件已直接帶 transcriptPath(見上方解析),不需要這段 fallback。 if [ ! -f "$TRANSCRIPT_PATH" ] && [ -n "${CODEX_THREAD_ID:-}" ]; then TRANSCRIPT_PATH="$(find "${HOME}/.codex/sessions" -type f -name "*${CODEX_THREAD_ID}.jsonl" -print -quit 2>/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="$(node "${SCRIPT_DIR}/transcript.mjs" extract "$TRANSCRIPT_PATH" 2>/dev/null)" [ -n "$TURN" ] || die_quiet "本輪無可記錄內容" DURATION="$(node "${SCRIPT_DIR}/transcript.mjs" duration "$TRANSCRIPT_PATH" 2>/dev/null)" [ -n "$DURATION" ] || DURATION="未判定" TOKENS="$(node "${SCRIPT_DIR}/transcript.mjs" tokens "$TRANSCRIPT_PATH" 2>/dev/null)" [ -n "$TOKENS" ] || TOKENS="未判定" # ------------------------------------------------------------------------------ # 模型決定:只有 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-doc: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-doc: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 -7)" [ -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" | node "${SCRIPT_DIR}/wiki_api.mjs" append "$MARKER" 2>&1 | grep -q '\[ERR\]'; then log "ERR" "寫入 wiki 失敗(專案 ${PROJECT})" else log "INF" "已記錄工作條目(專案 ${PROJECT},CLI ${SUMMARY_CLI})" fi exit 0