feat(worklog): 新增工作證明自動記錄 skill 與 Stop hook

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeffery
2026-07-27 11:20:15 +08:00
co-authored by Claude Opus 5
parent a2b0a51a35
commit 265dd14a2b
5 changed files with 916 additions and 0 deletions
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env bash
# ==============================================================================
# 用途:工作證明自動記錄(worklog)。由 Claude Code 的 Stop hook 觸發,
# 抽出本輪工作內容 → 呼叫模型濃縮成精簡條目 → 機密遮蔽 →
# 追加到 Gitea wiki 的當週工作紀錄頁。工作內容全程不落地。
# 更新時間:2026/07/27 11:14:16
# 相依:python3、claude CLI、curlwiki 走 Python urllib,不需 curl 亦可)。
# 機密:token 僅由環境變數/本機憑證讀取,不 echo、不寫檔;輸出前套用遮蔽規則。
# 退出碼:一律 0 —— hook 絕不可阻斷使用者的工作流程。
# ==============================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STAGE="worklog"
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
}
# ------------------------------------------------------------------------------
# 遞迴防護:摘要用的子 claude 行程會再次觸發 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"
command -v claude >/dev/null 2>&1 || die_quiet "找不到 claude CLI,略過記錄" "WRN"
# ------------------------------------------------------------------------------
# 讀取 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("transcript_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,避免迴圈不重複記錄"
[ -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 "本輪無可記錄內容"
# ------------------------------------------------------------------------------
# 模型決定:WORKLOG_MODEL → 快取檔(/jsc:worklog --tune 產生)→ 保底
# ------------------------------------------------------------------------------
MODEL=""
MODEL_NOTE=""
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=" (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=" (model: fallback)"
log "WRN" "無模型快取,改用保底模型,建議執行 /jsc:worklog --tune"
fi
# ------------------------------------------------------------------------------
# 濃縮:交給模型產出精簡條目(子行程帶 WORKLOG_CHILD=1 阻斷遞迴)
# ------------------------------------------------------------------------------
PROMPT="$(cat <<'EOF_PROMPT'
你是工作紀錄濃縮器。輸入是一段 AI 助理與使用者的對話片段(含工具呼叫)。
請濃縮成工作紀錄條目,規則:
1. 只輸出 1 到 3 個 markdown bullet(以「- 」開頭),不要標題、不要前言、不要結語。
2. 使用繁體中文(台灣用語),每個 bullet 一行、不超過 60 字,聚焦「做了什麼、動到什麼、結果如何」。
3. 保留關鍵事實:檔案/專案/指令/數量/分支/PR/議題編號;不要抄程式碼、不要貼指令全文。
4. 嚴禁輸出任何憑證與個資:token、密碼、API key、連線字串、Email、電話、姓名、身分證號。
5. 若這段對話沒有實質工作產出(純閒聊、純提問、僅讀取資訊而未產生結論),只輸出一行:SKIP
EOF_PROMPT
)"
SUMMARY="$(printf '%s' "$TURN" | WORKLOG_CHILD=1 timeout 45 claude -p "$PROMPT" --model "$MODEL" 2>/dev/null)"
if [ -z "$SUMMARY" ]; then
log "WRN" "摘要產出為空(模型 ${MODEL}),略過本輪"
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 -5)"
[ -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},模型 ${MODEL}"
fi
exit 0