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
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
# ==============================================================================
# 用途:worklog 的 transcript 處理工具。負責 (1) 從 Claude Code transcript
# JSONL 抽出「本輪」對話片段(最後一筆使用者訊息之後的全部內容),
# (2) 對文字做機密遮蔽(token/密碼/PII),作為寫入 wiki 前的第二道防線。
# 更新時間:2026/07/27 11:14:16
# 相依:Python 3 標準庫。全程僅走 stdin/stdout,不寫任何檔案。
# ==============================================================================
import json
import re
import sys
# 單則工具結果/參數的擷取上限,避免整份 transcript 塞進摘要輸入
TOOL_RESULT_LIMIT = 200
TOOL_INPUT_LIMIT = 160
TOTAL_LIMIT = 24000
# ------------------------------------------------------------------------------
# 機密遮蔽規則:命中一律換成 ***
# ------------------------------------------------------------------------------
REDACT_PATTERNS = [
(r"[A-Za-z0-9_\-]*:[A-Za-z0-9_\-]{16,}@", "***@"), # URL 內嵌憑證 user:token@
(r"\b[0-9a-f]{40}\b", "***"), # Gitea 40 字元 token
(r"\bgh[pousr]_[A-Za-z0-9_]{16,}\b", "***"), # GitHub token
(r"\bsk-[A-Za-z0-9\-_]{16,}\b", "***"), # API key
(r"(?i)\b(token|password|passwd|pwd|secret|api[_-]?key)\b\s*[:=]\s*\S+", r"\1=***"),
(r"(?i)Authorization:\s*(token|bearer)\s+\S+", r"Authorization: \1 ***"),
(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}", "***"), # Email
(r"\b09\d{2}[-\s]?\d{3}[-\s]?\d{3}\b", "***"), # 台灣手機
(r"\b[A-Z][12]\d{8}\b", "***"), # 身分證字號
]
def redact(text):
"""對文字套用全部機密遮蔽規則,回傳遮蔽後的結果。"""
for pattern, replacement in REDACT_PATTERNS:
text = re.sub(pattern, replacement, text)
return text
def _is_real_user_message(entry):
"""判斷 transcript 條目是否為真正的使用者輸入(排除 tool_result 回填的 user 條目)。"""
if entry.get("type") != "user":
return False
content = entry.get("message", {}).get("content")
if isinstance(content, str):
return bool(content.strip())
if isinstance(content, list):
return any(b.get("type") == "text" for b in content if isinstance(b, dict))
return False
def _blocks(entry):
"""取出條目的 content blocks,統一為 list 形式。"""
content = entry.get("message", {}).get("content")
if isinstance(content, str):
return [{"type": "text", "text": content}]
return content if isinstance(content, list) else []
def _render(entry):
"""將單一 transcript 條目轉為摘要輸入用的純文字行(工具結果僅取前段)。"""
role = entry.get("type")
lines = []
for block in _blocks(entry):
if not isinstance(block, dict):
continue
kind = block.get("type")
if kind == "text":
text = (block.get("text") or "").strip()
if text:
lines.append(f"[{role}] {text}")
elif kind == "tool_use":
name = block.get("name", "?")
raw = json.dumps(block.get("input", {}), ensure_ascii=False)
lines.append(f"[tool:{name}] {raw[:TOOL_INPUT_LIMIT]}")
elif kind == "tool_result":
raw = block.get("content")
if isinstance(raw, list):
raw = " ".join(
b.get("text", "") for b in raw if isinstance(b, dict) and b.get("type") == "text"
)
raw = str(raw or "").strip().replace("\n", " ")
if raw:
lines.append(f"[result] {raw[:TOOL_RESULT_LIMIT]}")
return lines
def extract_turn(path):
"""
從 transcript JSONL 抽出本輪內容:最後一筆真正使用者訊息(含該筆)之後的全部條目。
不需任何狀態檔即可界定「本輪」,符合工作內容不落地的要求。
回傳純文字字串;讀取失敗或無內容時回空字串。
"""
try:
with open(path, encoding="utf-8") as fh:
entries = []
for line in fh:
line = line.strip()
if not line:
continue
try:
entries.append(json.loads(line))
except ValueError:
continue
except OSError:
return ""
start = 0
for index in range(len(entries) - 1, -1, -1):
if _is_real_user_message(entries[index]):
start = index
break
lines = []
for entry in entries[start:]:
lines.extend(_render(entry))
text = "\n".join(lines).strip()
if len(text) > TOTAL_LIMIT:
head = text[: TOTAL_LIMIT // 2]
tail = text[-TOTAL_LIMIT // 2 :]
text = f"{head}\n…(中段省略)…\n{tail}"
return text
USAGE = """用法:transcript.py <子命令> [參數]
extract <transcript 路徑> 抽出本輪內容並遮蔽機密後輸出到 stdout
redact 自 stdin 讀取文字,遮蔽機密後輸出到 stdout
"""
def main(argv):
"""CLI 進入點:解析子命令並執行抽取或遮蔽。"""
if not argv or argv[0] in ("-h", "--help"):
print(USAGE)
return 0
if argv[0] == "extract":
if len(argv) < 2:
return 2
text = extract_turn(argv[1])
if not text:
return 1
sys.stdout.write(redact(text))
return 0
if argv[0] == "redact":
sys.stdout.write(redact(sys.stdin.read()))
return 0
print(USAGE)
return 2
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+434
View File
@@ -0,0 +1,434 @@
#!/usr/bin/env python3
# ==============================================================================
# 用途:Gitea Wiki 讀寫工具(worklog 專用)。提供 token 解析、頁面讀取、
# 建立、append 追加(read-modify-write + 寫後驗證重試),供 worklog.sh
# 與 /jsc:worklog skill 共用,避免兩份實作漂移。
# 更新時間:2026/07/27 11:14:16
# 相依:Python 3 標準庫(urllib、base64、json、re)。不需 requests、不需 jq。
# 機密:token 一律從環境變數或本機憑證檔讀取,絕不輸出、絕不寫入任何檔案。
# ==============================================================================
import base64
import json
import os
import re
import sys
import ssl
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timedelta, timezone
TAIPEI = timezone(timedelta(hours=8))
def _ssl_context():
"""
建立 TLS 連線設定:維持完整憑證鏈驗證,僅關閉 VERIFY_X509_STRICT。
Python 3.13 起預設啟用 X509 嚴格檢查,內部 CA 憑證若缺少 Subject Key
Identifier 會被拒絕(curl 不做此檢查,故 curl 可連而 Python 不行)。
此處只放寬擴充欄位的嚴格檢查,主機名稱與憑證鏈驗證仍完整保留。
"""
ctx = ssl.create_default_context()
ctx.verify_flags &= ~ssl.VERIFY_X509_STRICT
return ctx
SSL_CONTEXT = _ssl_context()
def now_str():
"""取得台灣時區的 yyyy/MM/dd HH:mm:ss 時間字串。"""
return datetime.now(TAIPEI).strftime("%Y/%m/%d %H:%M:%S")
def log(level, message, stage="wiki_api"):
"""輸出統一格式訊息([時間][階段][等級]: 訊息,一行一則),一律走 stderr 不污染 stdout。"""
print(f"[{now_str()}][{stage}][{level}]: {message}", file=sys.stderr)
def mask(text, secret):
"""將字串中的 secret 遮蔽為 ***,避免 token 洩漏到輸出。"""
if not secret:
return text
return text.replace(secret, "***")
# ------------------------------------------------------------------------------
# token 解析:GITEA_TOKEN → tea config → git-credentials
# ------------------------------------------------------------------------------
def _token_from_tea(host):
"""從 tea 設定檔取出指定 host 的 token(找不到回 None)。"""
for path in ("~/.config/tea/config.yml", "~/.tea/config.yml"):
f = os.path.expanduser(path)
if not os.path.isfile(f):
continue
try:
raw = open(f, encoding="utf-8").read()
except OSError:
continue
for block in re.split(r"(?m)^\s*-\s+name:", raw):
if host not in block:
continue
m = re.search(r"(?m)^\s*token:\s*[\"']?([A-Za-z0-9_\-]+)", block)
if m:
return m.group(1)
return None
def _token_from_git_credentials(host):
"""從 ~/.git-credentialscredential.helper=store)取出指定 host 的密碼作為 token。"""
f = os.path.expanduser("~/.git-credentials")
if not os.path.isfile(f):
return None
try:
lines = open(f, encoding="utf-8").read().splitlines()
except OSError:
return None
for line in lines:
m = re.match(r"https?://([^:]+):([^@]+)@(.+)$", line.strip())
if m and m.group(3) == host:
return urllib.parse.unquote(m.group(2))
return None
def resolve_token(host, repo):
"""
依固定優先序解析可用 token,並以 GET /repos/<repo> 實際驗證權限。
優先序:GITEA_TOKEN → tea 設定檔該 host 的 token → ~/.git-credentials。
回傳 (token, 來源說明);全部失敗回 (None, 說明)。
"""
candidates = []
env = os.environ.get("GITEA_TOKEN")
if env:
candidates.append((env, "GITEA_TOKEN"))
tea = _token_from_tea(host)
if tea and tea != env:
candidates.append((tea, "tea 設定檔"))
cred = _token_from_git_credentials(host)
if cred and cred not in (env, tea):
candidates.append((cred, "git-credentials"))
if not candidates:
return None, "找不到任何可用憑證來源"
for token, source in candidates:
code, _ = _request("GET", f"https://{host}/api/v1/repos/{repo}", token, None)
if code == 200:
return token, source
log("DBG", f"{source}{host} 驗證失敗(HTTP {code}),改試下一個來源")
return None, f"{len(candidates)} 個憑證來源全部驗證失敗"
# ------------------------------------------------------------------------------
# HTTP
# ------------------------------------------------------------------------------
def _request(method, url, token, payload):
"""發出 Gitea API 請求,回傳 (HTTP 狀態碼, 回應內文字串)。網路層錯誤以 0 表示。"""
data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Authorization", f"token {token}")
req.add_header("Accept", "application/json")
if data:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=30, context=SSL_CONTEXT) as resp:
return resp.status, resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")
except Exception as e: # 網路錯誤、逾時
return 0, str(e)
def _api_base(host, repo):
"""組出 repo 層級的 wiki API base URL。"""
return f"https://{host}/api/v1/repos/{repo}/wiki"
# ------------------------------------------------------------------------------
# wiki 操作
# ------------------------------------------------------------------------------
def list_pages(host, repo, token):
"""
列出 wiki 全部頁面(分頁完整讀取),回傳 (狀態, 頁面清單)。
狀態為 'ok''missing'wiki 尚未初始化)/'error'。清單元素含 title 與 sub_url。
"""
pages = []
page_no = 1
limit = 50
while True:
url = f"{_api_base(host, repo)}/pages?page={page_no}&limit={limit}"
code, body = _request("GET", url, token, None)
if code == 404:
return "missing", []
if code != 200:
return "error", []
try:
batch = json.loads(body)
except ValueError:
return "error", []
if not isinstance(batch, list):
return "error", []
pages.extend(batch)
if len(batch) < limit:
return "ok", pages
page_no += 1
def resolve_sub_url(host, repo, token, title):
"""
以 title 查出 Gitea 實際的 sub_url。
Gitea wiki 會對 title 做轉義(`-` 代表空格,實際 dash 另有轉義形式,例如
title `Worklog-2026-07-W4` 的 sub_url 為 `Worklog-2026-07-W4.-`),因此讀寫
一律以查表得到的 sub_url 為準,不自行猜測轉義規則。
找不到回 None。
"""
status, pages = list_pages(host, repo, token)
if status != "ok":
return None
for item in pages:
if item.get("title") == title:
return item.get("sub_url") or title
return None
def get_page(host, repo, token, page):
"""
讀取 wiki 頁面內容(page 可傳 title 或 sub_url,內部會自動解析)。
回傳 (狀態, 內容字串);狀態為 'ok'(存在)、'missing'(404,頁面或 wiki 尚未建立)、
'error'(其他失敗,內容為遮蔽後的錯誤訊息)。
"""
sub_url = resolve_sub_url(host, repo, token, page) or page
url = f"{_api_base(host, repo)}/page/{urllib.parse.quote(sub_url)}"
code, body = _request("GET", url, token, None)
if code == 404:
return "missing", ""
if code != 200:
return "error", mask(f"HTTP {code} {body[:200]}", token)
try:
data = json.loads(body)
except ValueError:
return "error", "回應不是合法 JSON"
raw = data.get("content_base64") or ""
try:
return "ok", base64.b64decode(raw).decode("utf-8", "replace")
except Exception:
return "error", "content_base64 解碼失敗"
def create_page(host, repo, token, page, content, message):
"""建立新的 wiki 頁面(wiki 尚未初始化時亦由此初始化)。回傳 (是否成功, 訊息)。"""
url = f"{_api_base(host, repo)}/new"
payload = {
"title": page,
"content_base64": base64.b64encode(content.encode("utf-8")).decode("ascii"),
"message": message,
}
code, body = _request("POST", url, token, payload)
if code in (201, 200):
return True, f"已建立頁面 {page}"
return False, mask(f"建立頁面失敗 HTTP {code} {body[:200]}", token)
def delete_page(host, repo, token, page):
"""刪除 wiki 頁面(page 可傳 title 或 sub_url)。回傳 (是否成功, 訊息)。"""
sub_url = resolve_sub_url(host, repo, token, page) or page
url = f"{_api_base(host, repo)}/page/{urllib.parse.quote(sub_url)}"
code, body = _request("DELETE", url, token, None)
if code in (204, 200):
return True, f"已刪除頁面 {page}"
return False, mask(f"刪除頁面失敗 HTTP {code} {body[:200]}", token)
def update_page(host, repo, token, page, content, message):
"""整頁覆寫既有 wiki 頁面(append 由呼叫端先合併內容)。回傳 (是否成功, 訊息)。"""
sub_url = resolve_sub_url(host, repo, token, page) or page
url = f"{_api_base(host, repo)}/page/{urllib.parse.quote(sub_url)}"
payload = {
"title": page,
"content_base64": base64.b64encode(content.encode("utf-8")).decode("ascii"),
"message": message,
}
code, body = _request("PATCH", url, token, payload)
if code in (200, 201):
return True, f"已更新頁面 {page}"
return False, mask(f"更新頁面失敗 HTTP {code} {body[:200]}", token)
def append_entry(host, repo, token, page, header, entry, marker, retries=3):
"""
將條目追加到週頁尾端:讀取現有內容 → 合併 → 寫回 → 寫後讀取驗證。
marker 為條目內唯一字串(時間戳+session 短碼),用於驗證自己的內容確實落地;
多個 session 同時寫入時,驗證失敗會重讀最新內容重試,避免互相覆蓋。
回傳 (是否成功, 訊息)。
"""
for attempt in range(1, retries + 1):
status, current = get_page(host, repo, token, page)
if status == "error":
return False, f"讀取頁面失敗:{current}"
if status == "missing":
content = f"{header}\n\n{entry}\n"
ok, msg = create_page(host, repo, token, page, content, f"worklog: 建立 {page}")
if not ok:
# wiki 已存在但頁面不存在時,建立可能失敗;下一輪改走更新
log("WRN", f"{attempt} 次建立失敗:{msg}")
continue
else:
if marker in current:
return True, "條目已存在,無需重複寫入"
body = current.rstrip("\n")
if not body:
body = header
content = f"{body}\n\n{entry}\n"
ok, msg = update_page(host, repo, token, page, content, f"worklog: 追加 {marker}")
if not ok:
log("WRN", f"{attempt} 次寫入失敗:{msg}")
continue
verify_status, verify_content = get_page(host, repo, token, page)
if verify_status == "ok" and marker in verify_content:
return True, f"條目已寫入 {page}(第 {attempt} 次嘗試)"
log("WRN", f"{attempt} 次寫後驗證未找到條目,準備重試")
return False, f"重試 {retries} 次仍未成功寫入 {page}"
# ------------------------------------------------------------------------------
# 週頁命名
# ------------------------------------------------------------------------------
def week_page_name(when=None):
"""依台灣時區產生週頁名稱 Worklog-yyyy-MM-W<該月第幾週>(第幾週=ceil(日/7))。"""
when = when or datetime.now(TAIPEI)
week = (when.day + 6) // 7
return f"Worklog-{when.year:04d}-{when.month:02d}-W{week}"
def week_page_header(page=None, when=None):
"""產生週頁首行標題(例:# 2026 年 07 月 第 4 週工作紀錄)。"""
when = when or datetime.now(TAIPEI)
week = (when.day + 6) // 7
return f"# {when.year}{when.month:02d} 月 第 {week} 週工作紀錄"
# ------------------------------------------------------------------------------
# CLI
# ------------------------------------------------------------------------------
USAGE = """用法:wiki_api.py <子命令> [參數]
probe 檢查 hostrepotokenwiki API 可用性
page-name 印出當週頁面名稱
pages 列出全部頁面(title 與實際 sub_url
show [頁面] 印出指定頁面內容(預設當週頁)
append <marker> [頁面] 自 stdin 讀取條目內容並追加(預設當週頁)
init [頁面] 若當週頁不存在則建立(僅含標題)
delete <頁面> 刪除指定頁面
環境變數:WORKLOG_HOST(必要)、WORKLOG_REPO(必要)、GITEA_TOKEN(選用,會自動 fallback
"""
def _env():
"""讀取並檢查必要環境變數,回傳 (host, repo);缺少時結束程式。"""
host = os.environ.get("WORKLOG_HOST", "").strip()
repo = os.environ.get("WORKLOG_REPO", "").strip()
if not host or not repo:
log("ERR", "缺少 WORKLOG_HOST 或 WORKLOG_REPO")
sys.exit(2)
return host, repo
def main(argv):
"""CLI 進入點:解析子命令並執行對應 wiki 操作。"""
if not argv or argv[0] in ("-h", "--help"):
print(USAGE)
return 0
cmd = argv[0]
if cmd == "page-name":
print(week_page_name())
return 0
host, repo = _env()
token, source = resolve_token(host, repo)
if not token:
log("ERR", f"無可用 token{source}")
return 2
if cmd == "probe":
log("INF", f"token 來源:{source}")
code, body = _request("GET", f"https://{host}/api/v1/version", token, None)
log("INF", f"Gitea 版本查詢 HTTP {code} {body[:80]}")
status, _ = get_page(host, repo, token, week_page_name())
log("INF", f"當週頁 {week_page_name()} 狀態:{status}")
return 0
if cmd == "pages":
status, pages = list_pages(host, repo, token)
if status != "ok":
log("WRN" if status == "missing" else "ERR", f"頁面清單狀態:{status}")
return 0 if status == "missing" else 1
for item in pages:
print(f"{item.get('title')}\t{item.get('sub_url')}")
return 0
if cmd == "delete":
if len(argv) < 2:
log("ERR", "delete 需要頁面名稱")
return 2
ok, msg = delete_page(host, repo, token, argv[1])
log("INF" if ok else "ERR", msg)
return 0 if ok else 1
if cmd == "show":
page = argv[1] if len(argv) > 1 else week_page_name()
status, content = get_page(host, repo, token, page)
if status == "ok":
print(content)
return 0
log("WRN" if status == "missing" else "ERR", f"頁面 {page} 狀態:{status} {content}")
return 0 if status == "missing" else 1
if cmd == "init":
page = argv[1] if len(argv) > 1 else week_page_name()
status, _ = get_page(host, repo, token, page)
if status == "ok":
log("INF", f"頁面 {page} 已存在,不重建")
return 0
ok, msg = create_page(host, repo, token, page, week_page_header(page) + "\n", f"worklog: 初始化 {page}")
log("INF" if ok else "ERR", msg)
return 0 if ok else 1
if cmd == "append":
if len(argv) < 2:
log("ERR", "append 需要 marker 參數")
return 2
marker = argv[1]
page = argv[2] if len(argv) > 2 else week_page_name()
entry = sys.stdin.read().strip()
if not entry:
log("WRN", "條目內容為空,不寫入")
return 0
ok, msg = append_entry(host, repo, token, page, week_page_header(page), entry, marker)
log("INF" if ok else "ERR", msg)
return 0 if ok else 1
log("ERR", f"未知子命令:{cmd}")
print(USAGE)
return 2
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+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