Files
doc/scripts/worklog/worklog.sh
T

252 lines
11 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# ==============================================================================
# 用途:工作證明自動記錄(worklog)。由支援 hook 的 CLI 觸發,
# 抽出本輪工作內容 → 呼叫已安裝 CLI 濃縮成精簡條目 → 機密遮蔽 →
# 追加到 Gitea wiki 的當週工作紀錄頁。工作內容全程不落地。
# 更新時間:2026/07/27 22:16:00
# 相依:python3、README 定義的任一 headless CLI、curlwiki 走 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 傳入的 JSONsession_idtranscript_pathcwdstop_hook_active
# ------------------------------------------------------------------------------
HOOK_INPUT="$(cat)"
[ -n "$HOOK_INPUT" ] || die_quiet "hook 輸入為空,略過記錄" "WRN"
read -r SESSION_ID TRANSCRIPT_PATH STOP_ACTIVE HOOK_CWD <<EOF_HOOK
$(printf '%s' "$HOOK_INPUT" | python3 -c '
import json, sys
try:
d = json.load(sys.stdin)
except ValueError:
d = {}
print(
d.get("session_id") or d.get("thread_id") or d.get("conversation_id") or "-",
d.get("transcript_path") or d.get("session_path") or d.get("conversation_path") or d.get("path") or "-",
"1" if d.get("stop_hook_active") else "0",
d.get("cwd", "") or "-",
)
')
EOF_HOOK
[ "$STOP_ACTIVE" = "1" ] && die_quiet "stop_hook_active 為 true,避免迴圈不重複記錄"
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 的 <owner>/<repo> 優先,其次目錄名
# ------------------------------------------------------------------------------
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 <<EOF_PROMPT
你是工作紀錄濃縮器。輸入是一段 AI 助理與使用者的對話片段(含工具呼叫)。
請濃縮成工作紀錄條目,規則:
已判定專案:${PROJECT}
已估算花費時間:${DURATION}
1. 只輸出 6 個 markdown bullet(以「- 」開頭),不要標題、不要前言、不要結語。
2. 六個 bullet 必須依序使用下列欄位名稱,格式固定為「- 欄位名稱:內容」:
- 專案/任務名稱
- 執行細節與產出
- 花費時間
- 任務狀態
- 遇到的困難
- 解決方式
3. 使用繁體中文(台灣用語),每個 bullet 一行、不超過 90 字,聚焦「做了什麼、動到什麼、結果如何」。
4. 保留關鍵事實:檔案/專案/指令/數量/分支/PR/議題編號;不要抄程式碼、不要貼指令全文。
5. 花費時間優先使用「已估算花費時間」;無法判定時寫「未判定」。
6. 若沒有遇到明確困難,遇到的困難寫「未遇到明確困難」,解決方式寫「不需額外處理」。
7. 嚴禁輸出任何憑證與個資:token、密碼、API key、連線字串、Email、電話、姓名、身分證號。
8. 若這段對話沒有實質工作產出(純閒聊、純提問、僅讀取資訊而未產生結論),只輸出一行:SKIP
對話片段:
${TURN}
EOF_PROMPT
)"
SUMMARY="$(run_summary_cli "$SUMMARY_CLI" "$PROMPT" "$MODEL")"
if [ -z "$SUMMARY" ]; then
log "WRN" "摘要產出為空(CLI ${SUMMARY_CLI}),略過本輪"
exit 0
fi
printf '%s' "$SUMMARY" | grep -qiE '^\s*SKIP\s*$' && die_quiet "模型判定本輪無實質工作產出"
# 第二道防線:對模型輸出再做一次機密遮蔽
SUMMARY="$(printf '%s' "$SUMMARY" | python3 "${SCRIPT_DIR}/transcript.py" redact 2>/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 <!-- %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