From 265dd14a2bbbe11f8400eab1f6817427791a6c5a Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 27 Jul 2026 11:20:15 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat(worklog):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E8=AD=89=E6=98=8E=E8=87=AA=E5=8B=95=E8=A8=98?= =?UTF-8?q?=E9=8C=84=20skill=20=E8=88=87=20Stop=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- hooks/hooks.json | 15 ++ scripts/worklog/transcript.py | 157 ++++++++++++ scripts/worklog/wiki_api.py | 434 ++++++++++++++++++++++++++++++++++ scripts/worklog/worklog.sh | 176 ++++++++++++++ skills/worklog/SKILL.md | 134 +++++++++++ 5 files changed, 916 insertions(+) create mode 100644 hooks/hooks.json create mode 100755 scripts/worklog/transcript.py create mode 100755 scripts/worklog/wiki_api.py create mode 100755 scripts/worklog/worklog.sh create mode 100644 skills/worklog/SKILL.md diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..bc27d10 --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/worklog/worklog.sh", + "timeout": 60 + } + ] + } + ] + } +} diff --git a/scripts/worklog/transcript.py b/scripts/worklog/transcript.py new file mode 100755 index 0000000..4c4d449 --- /dev/null +++ b/scripts/worklog/transcript.py @@ -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 抽出本輪內容並遮蔽機密後輸出到 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:])) diff --git a/scripts/worklog/wiki_api.py b/scripts/worklog/wiki_api.py new file mode 100755 index 0000000..0b43b9b --- /dev/null +++ b/scripts/worklog/wiki_api.py @@ -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-credentials(credential.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/ 實際驗證權限。 + + 優先序: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 檢查 host/repo/token/wiki API 可用性 + page-name 印出當週頁面名稱 + pages 列出全部頁面(title 與實際 sub_url) + show [頁面] 印出指定頁面內容(預設當週頁) + append [頁面] 自 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:])) diff --git a/scripts/worklog/worklog.sh b/scripts/worklog/worklog.sh new file mode 100755 index 0000000..0145606 --- /dev/null +++ b/scripts/worklog/worklog.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +# ============================================================================== +# 用途:工作證明自動記錄(worklog)。由 Claude Code 的 Stop hook 觸發, +# 抽出本輪工作內容 → 呼叫模型濃縮成精簡條目 → 機密遮蔽 → +# 追加到 Gitea wiki 的當週工作紀錄頁。工作內容全程不落地。 +# 更新時間:2026/07/27 11:14:16 +# 相依:python3、claude CLI、curl(wiki 走 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 傳入的 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 </ 優先,其次目錄名 +# ------------------------------------------------------------------------------ +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 \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 diff --git a/skills/worklog/SKILL.md b/skills/worklog/SKILL.md new file mode 100644 index 0000000..0821533 --- /dev/null +++ b/skills/worklog/SKILL.md @@ -0,0 +1,134 @@ +--- +name: worklog +description: 工作證明自動記錄(worklog)的操作與維護 skill。搭配 Claude Code 的 Stop hook,把每輪工作內容濃縮成精簡條目並追加到 Gitea wiki 的當週工作紀錄頁(Worklog-yyyy-MM-W<週>),工作內容全程不落地。提供 --init(初始化週頁與環境變數指引)、--tune(判定並快取最適合的摘要模型)、--diagnose(診斷 hook 為何沒動作)、--append(手動補寫一筆)、--show(讀當週頁回顧)五個模式。當使用者說工作證明、工作紀錄、worklog、週報自動化、把工作內容寫到 wiki、記錄到 Gitea wiki、hook 沒有寫入 wiki、補寫工作紀錄、看本週做了什麼、重新判定摘要模型,或提到 WORKLOG_ENABLED/WORKLOG_HOST/WORKLOG_REPO/WORKLOG_MODEL/WORKLOG_SCOPE 時觸發。不適用於:Gitea 議題操作(用 doc-issues-sync/code-issues)、專案文件化(用 doc-funcs)。 +--- + +# worklog — 工作證明自動記錄 + +把「每輪做了什麼」濃縮成一則條目,追加到 Gitea wiki 的當週工作紀錄頁。**自動記錄由 Claude Code 的 `Stop` hook 完成,不需使用者同意、不需人工觸發**;本 skill 負責自動路徑之外的人工操作:初始化、模型判定、診斷、補寫、回顧。 + +| 元件 | 觸發者 | 職責 | +| --- | --- | --- | +| `hooks/hooks.json` 的 `Stop` hook | harness 自動 | 每輪結束抽本輪內容 → 濃縮 → 遮蔽 → 追加到當週頁 | +| 本 skill `/jsc:worklog` | 使用者/助理手動 | `--init`/`--tune`/`--diagnose`/`--append`/`--show` | +| `scripts/worklog/worklog.sh` | 上述兩者共用 | 主流程(單一實作,避免漂移) | +| `scripts/worklog/wiki_api.py` | 上述兩者共用 | token 解析、wiki 讀寫、append 重試、週頁命名 | +| `scripts/worklog/transcript.py` | 上述兩者共用 | 抽本輪片段、機密遮蔽 | + +> `Stop` hook **只有 Claude Code 支援**。Codex/Antigravity/OpenCode 匯入本 plugin 時,只有 skill 可用,自動記錄不會啟動。 + +--- + +## 共用規範(必要前置) + +執行本 skill 前,先以 Skill 工具載入下列共用規範並全程遵守;**任一載入不到時先詢問使用者是否安裝 generic plugin(`https://gitea.jsc.idv.tw/plugins/generic.git`),不安裝則中斷**: + +- `/jsc:spec-output`:繁體中文(台灣用語)、UTF-8 無 BOM、表格與 Mermaid 優先、**寫入外部系統不得洩漏 PII**。 +- `/jsc:spec-execution`:自動執行原則(必要決策才中斷)、不臆測。 +- `/jsc:spec-gitea`:token 機密保護(不 echo、遮蔽、不落地)、API 分頁、host 決定順序。 +- `/jsc:spec-time-log`:時間戳固定 Asia/Taipei `yyyy/MM/dd HH:mm:ss`;訊息格式 `[時間][階段][等級]: 訊息`、一行一則。 + +本 skill 特有補充: + +- **工作內容不落地**:transcript 片段以 pipe 傳遞、wiki 走 API 不 clone,全程不產生暫存檔。唯一允許落地的是**模型快取檔** `~/.claude/worklog/model`(僅含模型 id 與判定時間,不含任何工作內容)。 +- **絕不阻斷**:hook 路徑任何失敗都以 exit 0 結束,只在 stderr 留訊息。 + +--- + +## 環境變數 + +| 變數 | 必要 | 說明 | 未設定 | +| --- | --- | --- | --- | +| `WORKLOG_ENABLED` | ✅ | 總開關,設為 `1` 才啟用 | hook 立即結束,完全不動作 | +| `WORKLOG_HOST` | ✅ | Gitea 主機,如 `gitea.housefun.com.tw` | 不啟用 | +| `WORKLOG_REPO` | ✅ | wiki 所在 repo,如 `H3285/WorkLog` | 不啟用 | +| `WORKLOG_MODEL` | | 強制指定摘要模型 | 讀快取檔 → 保底 `claude-haiku-4-5-20251001` | +| `WORKLOG_SCOPE` | | 冒號分隔的路徑前綴,僅這些路徑下的 session 才記 | 全部 session 都記 | +| `WORKLOG_ERRLOG` | | 錯誤訊息額外寫入的檔案路徑(只記錯誤、不含工作內容) | 只走 stderr | + +**token 不需另設變數**,依固定優先序自動解析並實際驗證: + +``` +GITEA_TOKEN →(對目標 host 驗證失敗時)→ tea 設定檔中該 host 的 token → ~/.git-credentials +``` + +--- + +## 模式 + +### `--init` + +1. 執行 `python3 scripts/worklog/wiki_api.py probe`,回報 token 來源、Gitea 版本、當週頁狀態。 +2. 當週頁不存在 → 執行 `wiki_api.py init` 建立(wiki 尚未初始化時一併初始化)。 +3. 以表格印出應寫入 `~/.bashrc` 的 `WORKLOG_*` 變數清單;**不自動改使用者的 shell profile**(需人工確認的狀態變更)。 + +### `--tune` + +決定「目前最適合的摘要模型」並快取,`Stop` hook 只讀快取、**絕不自行呼叫 AI 判斷**(否則就變成雞生蛋,還會拖慢使用者的等待路徑)。 + +| 步驟 | 動作 | +| --- | --- | +| 1 | 以 Skill 工具載入 `claude-api` 取當下模型清單與定價,**不憑記憶** | +| 2 | 依本任務條件評分:延遲敏感(在使用者等待路徑上)、輸出極短(1~3 行中文)、需嚴守機密過濾指令、每輪都跑一次故成本敏感 | +| 3 | Smoke test:`WORKLOG_CHILD=1 claude -p "回 OK" --model <選定 id>` 確認該模型在此帳號可用 | +| 4 | 寫入 `~/.claude/worklog/model`(`model=`、`tuned_at=<時間>`、`reason=<一行理由>`),並回報選擇與理由 | + +快取超過 **30 天** 視為過期:hook 改用保底模型,並在條目標記 `(model: fallback)`,`--diagnose` 會提醒重跑 `--tune`。 + +### `--diagnose` + +逐項檢查並以表格回報,用於「hook 沒有寫入 wiki」時定位: + +| 檢查項 | 判準 | +| --- | --- | +| `python3`/`claude` CLI | `command -v` 是否找得到 | +| `WORKLOG_*` 變數 | 必要三項是否齊全、`WORKLOG_SCOPE` 是否把當前路徑排除 | +| token | `wiki_api.py probe` 的 token 來源與驗證結果 | +| wiki API | Gitea 版本、`repos/` 與當週頁狀態 | +| 模型快取 | 是否存在、是否過期、目前會用哪支模型 | +| hook 註冊 | `hooks/hooks.json` 是否存在且 plugin 已啟用 | + +### `--append "<內容>"` + +手動補寫一筆(hook 漏記、離線工作、或事後補充)。條目格式與自動路徑一致: + +``` +## <時間> — <專案> +- <內容> +``` + +專案取當前工作目錄的 `/`;內容仍會過 `transcript.py redact` 遮蔽後才寫入。 + +### `--show` + +讀當週頁(`wiki_api.py show`)並以表格摘要本週工作,用於回顧與週報。 + +--- + +## 條目與頁面格式 + +- 週頁名稱:`Worklog---W<該月第幾週>`,第幾週 = `ceil(日/7)`(例:`2026/07/27` → `Worklog-2026-07-W4`)。 +- 頁首標題:`# 月 第 <週> 週工作紀錄`。 +- 每筆條目:`## <時間> — <專案>` + 1~3 個 bullet;標題行尾帶 HTML 註解 marker(``)供寫後驗證與去重,wiki 渲染時不顯示。 +- 多 session 同時寫入:`append_entry` 採「讀取 → 合併 → 寫回 → 寫後讀取驗證 marker」,未落地則重讀最新內容重試,最多 3 次。 + +--- + +## 機密與 PII(兩道防線) + +| 防線 | 位置 | 內容 | +| --- | --- | --- | +| 1 | 濃縮提示詞 | 明令不得輸出 token/密碼/API key/連線字串/Email/電話/姓名/身分證號 | +| 2 | `transcript.py redact` | 正則遮蔽:URL 內嵌憑證、40 字元 hex token、`gh?_`/`sk-` token、`token=`/`password=`、`Authorization:`、Email、台灣手機、身分證號 | + +第二道防線不可移除 —— 模型有可能沒遵守指令,而 wiki 一旦寫入就留在 git 歷史裡。 + +--- + +## 呼叫方式 + +| 助理 | 呼叫 | +| --- | --- | +| Claude Code / Antigravity | `/jsc:worklog --init`、`/jsc:worklog --tune`、`/jsc:worklog --diagnose`、`/jsc:worklog --append "修正 X 的 Y 問題"`、`/jsc:worklog --show` | +| Codex | `$worklog --diagnose`,或用 `/skills` 選單 | +| OpenCode | 描述需求(如「幫我看這週的工作紀錄」)自動觸發 | -- 2.53.0 From 5d5101795ca7fdec85ed9c2e23784dd3dbde8e4c Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 27 Jul 2026 11:20:15 +0800 Subject: [PATCH 2/8] =?UTF-8?q?docs(README):=20=E8=A3=9C=E4=B8=8A=20worklo?= =?UTF-8?q?g=20=E5=85=83=E4=BB=B6=E3=80=81=E7=9B=AE=E9=8C=84=E7=B5=90?= =?UTF-8?q?=E6=A7=8B=E8=88=87=20generic=20=E5=AE=9A=E4=BD=8D=E8=AA=AA?= =?UTF-8?q?=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 92f38e2..6e95eb1 100644 --- a/README.md +++ b/README.md @@ -34,12 +34,22 @@ generic/ ├── .agents/plugins/ │ └── marketplace.json # Codex marketplace(name: "generic",url source 指向本 repo) ├── plugin.json # Antigravity 外掛定義(name: "jsc",skills: "./skills/") +├── hooks/ +│ └── hooks.json # Claude Code hooks(Stop → worklog;其他助理不吃此檔) +├── scripts/ +│ └── worklog/ # worklog 自動記錄的可執行元件(skill 與 hook 共用) +│ ├── worklog.sh # 主流程:抽本輪 → 濃縮 → 遮蔽 → 追加到 wiki +│ ├── wiki_api.py # Gitea wiki 讀寫、token 解析、append 重試、週頁命名 +│ └── transcript.py # transcript 本輪抽取與機密遮蔽 ├── skills/ # ★ 唯一真實來源:所有 skills -│ └── spec-*/SKILL.md # 共用規範 skills(一規範一目錄) +│ ├── spec-*/SKILL.md # 共用規範 skills(一規範一目錄) +│ └── worklog/SKILL.md # 工作證明自動記錄的操作與維護 ├── AGENTS.md # 跨助理共用指引 └── README.md ``` +> generic 的定位是「**共用規範 + 全域自動化**」:`skills/spec-*` 是純規範文件(四家助理通用),`hooks/` 與 `scripts/` 是可執行元件。**`hooks/hooks.json` 只有 Claude Code 會讀**;Codex/Antigravity/OpenCode 匯入時只有 skill 可用,自動記錄不會啟動。 + --- ## 安裝 / 更新 / 移除(各家原生 plugin CLI) @@ -178,6 +188,12 @@ rm -rf ~/.config/opencode/skills/spec-* | `spec-doc-funcs-handoff` | 文件化串接 | code 類 skill 完成後完整執行 /jsc:doc-funcs 的標準流程與統一時間戳 | | `spec-plugin-version` | 版號規則 | 三 manifest 同步 bump、對照 master 確保單調遞增、新 plugin 首發 0.0.1、chore(plugin 版本) commit | +### 全域自動化 + +| Skill | 類型 | 內容 | +| --- | --- | --- | +| `worklog` | 工作證明記錄 | 每輪工作濃縮成條目追加到 Gitea wiki 當週頁(`Worklog-yyyy-MM-W<週>`);`--init`/`--tune`/`--diagnose`/`--append`/`--show` 五個模式;自動記錄由 Claude Code `Stop` hook 完成 | + --- -- 2.53.0 From 08455fb053071b14be670fa7618c60f22d865413 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 27 Jul 2026 11:20:15 +0800 Subject: [PATCH 3/8] =?UTF-8?q?chore(plugin=20=E7=89=88=E6=9C=AC):=20?= =?UTF-8?q?=E4=B8=89=E5=AE=B6=20manifest=20=E5=8D=87=E7=89=88=200.0.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- plugin.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 261bccc..d3a78fc 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc", - "version": "0.0.1", + "version": "0.0.2", "description": "JSC 跨 AI 助理共用 plugin 模板(Claude Code / Codex / Antigravity / OpenCode)。所有 skills 以 SKILL.md 為共通標準,於 Claude Code 以 /jsc: 前綴呼叫。", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 827033e..588010b 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc", - "version": "0.0.1", + "version": "0.0.2", "description": "JSC 跨 AI 助理共用 plugin 模板。所有 skills 以 SKILL.md 為共通標準。", "skills": "./skills" } diff --git a/plugin.json b/plugin.json index 42a1b7b..0e0bdbb 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc", - "version": "0.0.1", + "version": "0.0.2", "description": "JSC 跨 AI 助理共用 plugin 模板。所有 skills 以 SKILL.md 為共通標準;於 Antigravity 以 /jsc: 前綴呼叫。", "skills": "./skills/" } \ No newline at end of file -- 2.53.0 From cad5be26b67e4a5ea8529d57724004b95dc9e73c Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 27 Jul 2026 11:51:41 +0800 Subject: [PATCH 4/8] =?UTF-8?q?fix(worklog):=20=E6=94=B9=E7=94=A8=20plugin?= =?UTF-8?q?=20=E6=A0=B9=E7=B5=95=E5=B0=8D=E8=B7=AF=E5=BE=91=E5=91=BC?= =?UTF-8?q?=E5=8F=AB=E8=85=B3=E6=9C=AC=E4=B8=A6=E6=A8=99=E6=98=8E=E5=90=84?= =?UTF-8?q?=E5=8A=A9=E7=90=86=E6=94=AF=E6=8F=B4=E7=AF=84=E5=9C=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- skills/worklog/SKILL.md | 54 +++++++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/skills/worklog/SKILL.md b/skills/worklog/SKILL.md index 0821533..b2ca641 100644 --- a/skills/worklog/SKILL.md +++ b/skills/worklog/SKILL.md @@ -1,6 +1,6 @@ --- name: worklog -description: 工作證明自動記錄(worklog)的操作與維護 skill。搭配 Claude Code 的 Stop hook,把每輪工作內容濃縮成精簡條目並追加到 Gitea wiki 的當週工作紀錄頁(Worklog-yyyy-MM-W<週>),工作內容全程不落地。提供 --init(初始化週頁與環境變數指引)、--tune(判定並快取最適合的摘要模型)、--diagnose(診斷 hook 為何沒動作)、--append(手動補寫一筆)、--show(讀當週頁回顧)五個模式。當使用者說工作證明、工作紀錄、worklog、週報自動化、把工作內容寫到 wiki、記錄到 Gitea wiki、hook 沒有寫入 wiki、補寫工作紀錄、看本週做了什麼、重新判定摘要模型,或提到 WORKLOG_ENABLED/WORKLOG_HOST/WORKLOG_REPO/WORKLOG_MODEL/WORKLOG_SCOPE 時觸發。不適用於:Gitea 議題操作(用 doc-issues-sync/code-issues)、專案文件化(用 doc-funcs)。 +description: 工作證明自動記錄(worklog)的操作與維護 skill。搭配 Claude Code 的 Stop hook,把每輪工作內容濃縮成精簡條目並追加到 Gitea wiki 的當週工作紀錄頁(Worklog-yyyy-MM-W<週>),工作內容全程不落地。提供 --init(初始化週頁與環境變數指引)、--tune(判定並快取最適合的摘要模型)、--diagnose(診斷 hook 為何沒動作)、--append(手動補寫一筆)、--show(讀當週頁回顧)五個模式。當使用者說工作證明、工作紀錄、worklog、週報自動化、把工作內容寫到 wiki、記錄到 Gitea wiki、hook 沒有寫入 wiki、補寫工作紀錄、看本週做了什麼、重新判定摘要模型,或提到 WORKLOG_ENABLED/WORKLOG_HOST/WORKLOG_REPO/WORKLOG_MODEL/WORKLOG_SCOPE 時觸發。**僅支援 Claude Code/Codex/Antigravity(需 plugin 目錄保留 scripts/);不支援 OpenCode**(skills 目錄安裝不會帶入 scripts/,且自動記錄依賴 Claude Code 的 transcript 格式)。不適用於:Gitea 議題操作(用 doc-issues-sync/code-issues)、專案文件化(用 doc-funcs)。 --- # worklog — 工作證明自動記錄 @@ -15,7 +15,38 @@ description: 工作證明自動記錄(worklog)的操作與維護 skill。搭 | `scripts/worklog/wiki_api.py` | 上述兩者共用 | token 解析、wiki 讀寫、append 重試、週頁命名 | | `scripts/worklog/transcript.py` | 上述兩者共用 | 抽本輪片段、機密遮蔽 | -> `Stop` hook **只有 Claude Code 支援**。Codex/Antigravity/OpenCode 匯入本 plugin 時,只有 skill 可用,自動記錄不會啟動。 +### 各助理支援範圍 + +| 功能 | Claude Code | Codex | Antigravity | OpenCode | +| --- | --- | --- | --- | --- | +| `Stop` hook 自動記錄 | ✅ | ❌ 不讀 `hooks/hooks.json` | ❌ | ❌ | +| `--init`/`--diagnose`/`--append`/`--show` | ✅ | ⚠️ 需 plugin 目錄保留 `scripts/`(安裝後請實測一次) | ⚠️ 同左 | ❌ 缺 `scripts/` | +| `--tune` | ✅ | ❌ 無 `claude-api` skill 可載入 | ❌ 同左 | ❌ | + +兩個限制的來源: + +- **`Stop` hook 只有 Claude Code 讀取** `hooks/hooks.json`;且 `worklog.sh` 解析的是 **Claude Code 專屬的 transcript JSONL 結構**(`type` / `message.content` blocks),所以即使其他助理提供等效 hook 機制,自動記錄也不能直接沿用。 +- **OpenCode 以「複製 `skills/` 目錄」安裝**,不會帶入 `scripts/`,本 skill 的所有模式都無法執行 —— 在 OpenCode 環境請不要觸發本 skill。 +- 其他助理若要用 `--append`/`--show` 等純 wiki 操作,只需 `python3`(不需 `claude` CLI),但 `--tune` 必須改為手動設定 `WORKLOG_MODEL`。 + +### 腳本路徑解析(重要) + +skill 執行時的工作目錄是**使用者的專案目錄**,不是 plugin 根目錄,因此**絕不可用相對路徑呼叫腳本**。先解析出 plugin 根目錄再組絕對路徑: + +| 環境 | plugin 根目錄 | +| --- | --- | +| Claude Code | `${CLAUDE_PLUGIN_ROOT}` | +| 其他助理 | 本 skill 載入時提示的 base directory(`.../skills/worklog`)往上兩層 | + +```bash +# Claude Code +WORKLOG_DIR="${CLAUDE_PLUGIN_ROOT}/scripts/worklog" + +# 其他助理:以 skill base directory 推導(/../.. 即 plugin 根) +WORKLOG_DIR="/../../scripts/worklog" +``` + +以下各模式的指令一律以 `${WORKLOG_DIR}` 表示該目錄。若解析不到或該目錄不存在,回報「plugin 目錄未包含 scripts/worklog,本 skill 在此環境不可用」並停止,不要改用相對路徑重試。 --- @@ -58,14 +89,16 @@ GITEA_TOKEN →(對目標 host 驗證失敗時)→ tea 設定檔中該 host ### `--init` -1. 執行 `python3 scripts/worklog/wiki_api.py probe`,回報 token 來源、Gitea 版本、當週頁狀態。 -2. 當週頁不存在 → 執行 `wiki_api.py init` 建立(wiki 尚未初始化時一併初始化)。 +1. 執行 `python3 "${WORKLOG_DIR}/wiki_api.py" probe`,回報 token 來源、Gitea 版本、當週頁狀態。 +2. 當週頁不存在 → 執行 `python3 "${WORKLOG_DIR}/wiki_api.py" init` 建立(wiki 尚未初始化時一併初始化)。 3. 以表格印出應寫入 `~/.bashrc` 的 `WORKLOG_*` 變數清單;**不自動改使用者的 shell profile**(需人工確認的狀態變更)。 -### `--tune` +### `--tune`(Claude Code 專屬) 決定「目前最適合的摘要模型」並快取,`Stop` hook 只讀快取、**絕不自行呼叫 AI 判斷**(否則就變成雞生蛋,還會拖慢使用者的等待路徑)。 +本模式需要 Claude Code 內建的 `claude-api` skill 與 `claude` CLI,**其他助理無法執行**:請改為手動設定 `WORKLOG_MODEL` 環境變數指定模型,或沿用保底模型。 + | 步驟 | 動作 | | --- | --- | | 1 | 以 Skill 工具載入 `claude-api` 取當下模型清單與定價,**不憑記憶** | @@ -83,7 +116,8 @@ GITEA_TOKEN →(對目標 host 驗證失敗時)→ tea 設定檔中該 host | --- | --- | | `python3`/`claude` CLI | `command -v` 是否找得到 | | `WORKLOG_*` 變數 | 必要三項是否齊全、`WORKLOG_SCOPE` 是否把當前路徑排除 | -| token | `wiki_api.py probe` 的 token 來源與驗證結果 | +| `scripts/worklog` 目錄 | `${WORKLOG_DIR}` 是否解析成功且三支腳本存在(不存在=此助理不支援) | +| token | `python3 "${WORKLOG_DIR}/wiki_api.py" probe` 的 token 來源與驗證結果 | | wiki API | Gitea 版本、`repos/` 與當週頁狀態 | | 模型快取 | 是否存在、是否過期、目前會用哪支模型 | | hook 註冊 | `hooks/hooks.json` 是否存在且 plugin 已啟用 | @@ -97,11 +131,11 @@ GITEA_TOKEN →(對目標 host 驗證失敗時)→ tea 設定檔中該 host - <內容> ``` -專案取當前工作目錄的 `/`;內容仍會過 `transcript.py redact` 遮蔽後才寫入。 +專案取當前工作目錄的 `/`;內容仍會過 `python3 "${WORKLOG_DIR}/transcript.py" redact` 遮蔽後才寫入。 ### `--show` -讀當週頁(`wiki_api.py show`)並以表格摘要本週工作,用於回顧與週報。 +讀當週頁(`python3 "${WORKLOG_DIR}/wiki_api.py" show`)並以表格摘要本週工作,用於回顧與週報。 --- @@ -119,7 +153,7 @@ GITEA_TOKEN →(對目標 host 驗證失敗時)→ tea 設定檔中該 host | 防線 | 位置 | 內容 | | --- | --- | --- | | 1 | 濃縮提示詞 | 明令不得輸出 token/密碼/API key/連線字串/Email/電話/姓名/身分證號 | -| 2 | `transcript.py redact` | 正則遮蔽:URL 內嵌憑證、40 字元 hex token、`gh?_`/`sk-` token、`token=`/`password=`、`Authorization:`、Email、台灣手機、身分證號 | +| 2 | `transcript.py` 的 `redact` | 正則遮蔽:URL 內嵌憑證、40 字元 hex token、`gh?_`/`sk-` token、`token=`/`password=`、`Authorization:`、Email、台灣手機、身分證號 | 第二道防線不可移除 —— 模型有可能沒遵守指令,而 wiki 一旦寫入就留在 git 歷史裡。 @@ -131,4 +165,4 @@ GITEA_TOKEN →(對目標 host 驗證失敗時)→ tea 設定檔中該 host | --- | --- | | Claude Code / Antigravity | `/jsc:worklog --init`、`/jsc:worklog --tune`、`/jsc:worklog --diagnose`、`/jsc:worklog --append "修正 X 的 Y 問題"`、`/jsc:worklog --show` | | Codex | `$worklog --diagnose`,或用 `/skills` 選單 | -| OpenCode | 描述需求(如「幫我看這週的工作紀錄」)自動觸發 | +| OpenCode | **不支援**(skills 目錄安裝不含 `scripts/`) | -- 2.53.0 From 8521682583e14a19e5c2e26d9bc0572487c0f164 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 27 Jul 2026 11:51:42 +0800 Subject: [PATCH 5/8] =?UTF-8?q?docs(=E5=8A=A9=E7=90=86=E9=81=A9=E7=94=A8?= =?UTF-8?q?=E7=AF=84=E5=9C=8D):=20=E8=A3=9C=E5=85=83=E4=BB=B6=E9=81=A9?= =?UTF-8?q?=E7=94=A8=E7=9F=A9=E9=99=A3=E3=80=81OpenCode=20=E9=99=90?= =?UTF-8?q?=E5=88=B6=E8=88=87=E5=8F=AF=E5=9F=B7=E8=A1=8C=E5=85=83=E4=BB=B6?= =?UTF-8?q?=E6=B3=A8=E6=84=8F=E4=BA=8B=E9=A0=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 1 + README.md | 31 ++++++++++++++++++++++++++----- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d84fb8b..99ba349 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,7 @@ - 在處理任務前,先比對使用者需求與各 skill `SKILL.md` frontmatter 的 `description`,若相符請載入並依其步驟執行。 - **呼叫慣例**:在 Claude Code 與 Antigravity 中,這些 skill 以 `/jsc:` 呼叫;Codex 以 `$`、OpenCode 由模型依描述自動觸發 — 兩者沒有 `/jsc:` 前綴,不需強制加。 - 完整清單與每個 skill 的用途,請見 `README.md` 的「Skills 目錄」。 +- 部分 skill 帶可執行元件(`scripts/`)或 hook(`hooks/hooks.json`),**並非四家助理都適用**;載入前請看該 skill `description` 標示的支援範圍與 `README.md` 的「元件對各助理的適用範圍」。`hooks/hooks.json` 只有 Claude Code 會讀;以複製 `skills/` 目錄安裝的環境(OpenCode)不會帶入 `scripts/`,依賴腳本的 skill 一律不可用。 ## 慣例 diff --git a/README.md b/README.md index 6e95eb1..915603a 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,19 @@ generic/ └── README.md ``` -> generic 的定位是「**共用規範 + 全域自動化**」:`skills/spec-*` 是純規範文件(四家助理通用),`hooks/` 與 `scripts/` 是可執行元件。**`hooks/hooks.json` 只有 Claude Code 會讀**;Codex/Antigravity/OpenCode 匯入時只有 skill 可用,自動記錄不會啟動。 +> generic 的定位是「**共用規範 + 全域自動化**」:`skills/spec-*` 是純規範文件(四家助理通用),`hooks/` 與 `scripts/` 是可執行元件。 + +### 元件對各助理的適用範圍 + +| 元件 | Claude Code | Codex | Antigravity | OpenCode | +| --- | --- | --- | --- | --- | +| `skills/spec-*`(十個共用規範) | ✅ | ✅ | ✅ | ✅ | +| `skills/worklog` 的手動模式 | ✅ | ⚠️ 需安裝後保留 `scripts/`(請實測一次) | ⚠️ 同左 | ❌ 不支援 | +| `hooks/hooks.json`(`Stop` 自動記錄) | ✅ | ❌ | ❌ | ❌ | + +- **`hooks/hooks.json` 只有 Claude Code 會讀**。且 `scripts/worklog/worklog.sh` 解析的是 **Claude Code 專屬的 transcript JSONL 結構**,即使其他助理提供等效 hook,自動記錄也不能直接沿用。 +- **OpenCode 不支援 worklog**:OpenCode 以「複製 `skills/` 目錄」安裝,不會帶入 `scripts/`,worklog 的所有模式都無法執行(已在該 skill 的 `description` 標明)。 +- Claude Code 的 plugin cache 是完整 repo clone,`scripts/`(含 `100755` 執行權限)與 `hooks/` 都會帶入;Codex/Antigravity 的安裝目錄是否同樣保留 `scripts/` 尚未實測,第一次安裝後請跑 `/jsc:worklog --diagnose` 確認。 --- @@ -136,10 +148,12 @@ cp -r ~/plugins/generic/skills/* ~/.config/opencode/skills/ git -C ~/plugins/generic pull cp -r ~/plugins/generic/skills/* ~/.config/opencode/skills/ -# 移除 -rm -rf ~/.config/opencode/skills/spec-* +# 移除(逐一移除本 plugin 帶入的 skill 目錄;勿只清 spec-*,否則其他 skill 會殘留) +for s in ~/plugins/generic/skills/*/; do rm -rf "$HOME/.config/opencode/skills/$(basename "$s")"; done ``` +> **worklog 在 OpenCode 不可用**:上面的複製只帶 `skills/`,不含 `scripts/`,worklog 的所有模式都會失敗。請不要在 OpenCode 觸發該 skill(其 `description` 已標明不支援)。 + > **Windows PowerShell**:`cp -r A B` → `Copy-Item A B -Recurse -Force`、`rm -rf X` → `Remove-Item X -Recurse -Force`、`~` → `$HOME`。 - **呼叫**:直接描述需求,模型會依 skill 描述自動透過 skill 工具呼叫。 @@ -192,7 +206,7 @@ rm -rf ~/.config/opencode/skills/spec-* | Skill | 類型 | 內容 | | --- | --- | --- | -| `worklog` | 工作證明記錄 | 每輪工作濃縮成條目追加到 Gitea wiki 當週頁(`Worklog-yyyy-MM-W<週>`);`--init`/`--tune`/`--diagnose`/`--append`/`--show` 五個模式;自動記錄由 Claude Code `Stop` hook 完成 | +| `worklog` | 工作證明記錄 | 每輪工作濃縮成條目追加到 Gitea wiki 當週頁(`Worklog-yyyy-MM-W<週>`);`--init`/`--tune`(Claude Code 專屬)/`--diagnose`/`--append`/`--show` 五個模式;自動記錄由 Claude Code `Stop` hook 完成,**OpenCode 不支援**(見上方適用範圍表) | @@ -210,5 +224,12 @@ rm -rf ~/.config/opencode/skills/spec-* 6. 讓各助理更新: - Claude:`claude plugin update jsc@generic` - Codex:`codex plugin marketplace upgrade generic` - - Antigravity:`git -C ~/jsc-plugin pull && agy plugin uninstall jsc && agy plugin install ~/jsc-plugin` + - Antigravity:`git -C ~/plugins/generic pull && agy plugin uninstall jsc && agy plugin install ~/plugins/generic`(路徑與上方 Antigravity 安裝節一致) - OpenCode:`git pull` 後重新複製 `skills/` + +> **skill 帶可執行元件時**(腳本、hook)額外注意: +> +> - 腳本放 `scripts//`,**不要**放進 `skills/`;hook 定義放 `hooks/hooks.json`,command 用 `${CLAUDE_PLUGIN_ROOT}/...` 絕對路徑。 +> - 腳本要有執行權限並確實入 git(`git ls-files -s` 應顯示 `100755`)。 +> - `SKILL.md` **不可用相對路徑呼叫腳本** —— skill 執行時的工作目錄是使用者的專案目錄;請以 `${CLAUDE_PLUGIN_ROOT}`(其他助理用 skill base directory 往上兩層)組出絕對路徑。 +> - 在 `SKILL.md` 的 `description` 與上方適用範圍表標明支援哪幾家;OpenCode 因只複製 `skills/`,凡依賴 `scripts/` 的 skill 一律不支援。 -- 2.53.0 From 604656caa1bdd42bdbcfb38e72c7091102a46e2f Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 27 Jul 2026 11:51:42 +0800 Subject: [PATCH 6/8] =?UTF-8?q?chore(gitignore):=20=E5=BF=BD=E7=95=A5=20Py?= =?UTF-8?q?thon=20=5F=5Fpycache=5F=5F=20=E8=88=87=E4=BD=8D=E5=85=83?= =?UTF-8?q?=E7=A2=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 710e336..1531e06 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ Thumbs.db # 暫存 *.tmp *.log + +# Python(scripts/ 內腳本被 import 時產生) +__pycache__/ +*.py[cod] -- 2.53.0 From c25307b9b9d318e11c364c87da5379b4f25184d8 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 27 Jul 2026 11:51:42 +0800 Subject: [PATCH 7/8] =?UTF-8?q?chore(plugin=20=E7=89=88=E6=9C=AC):=20?= =?UTF-8?q?=E4=B8=89=E5=AE=B6=20manifest=20=E5=8D=87=E7=89=88=200.0.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- plugin.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index d3a78fc..2533629 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc", - "version": "0.0.2", + "version": "0.0.3", "description": "JSC 跨 AI 助理共用 plugin 模板(Claude Code / Codex / Antigravity / OpenCode)。所有 skills 以 SKILL.md 為共通標準,於 Claude Code 以 /jsc: 前綴呼叫。", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 588010b..094d5a7 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc", - "version": "0.0.2", + "version": "0.0.3", "description": "JSC 跨 AI 助理共用 plugin 模板。所有 skills 以 SKILL.md 為共通標準。", "skills": "./skills" } diff --git a/plugin.json b/plugin.json index 0e0bdbb..147d94f 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc", - "version": "0.0.2", + "version": "0.0.3", "description": "JSC 跨 AI 助理共用 plugin 模板。所有 skills 以 SKILL.md 為共通標準;於 Antigravity 以 /jsc: 前綴呼叫。", "skills": "./skills/" } \ No newline at end of file -- 2.53.0 From c2239dd97d2509ba5f86fa6e5c067fd02f089f9d Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 27 Jul 2026 11:59:54 +0800 Subject: [PATCH 8/8] =?UTF-8?q?revert(plugin=20=E7=89=88=E6=9C=AC):=20?= =?UTF-8?q?=E9=82=84=E5=8E=9F=200.0.3=20=E5=8D=87=E7=89=88=EF=BC=8C?= =?UTF-8?q?=E6=9C=AC=E6=89=B9=E6=9C=80=E7=B5=82=E7=89=88=E6=9C=AC=E7=B6=AD?= =?UTF-8?q?=E6=8C=81=200.0.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.0.2 尚未合併到 master,同一批變更只需最終一個版本。 Co-Authored-By: Claude Opus 5 (1M context) --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- plugin.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 2533629..d3a78fc 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc", - "version": "0.0.3", + "version": "0.0.2", "description": "JSC 跨 AI 助理共用 plugin 模板(Claude Code / Codex / Antigravity / OpenCode)。所有 skills 以 SKILL.md 為共通標準,於 Claude Code 以 /jsc: 前綴呼叫。", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 094d5a7..588010b 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc", - "version": "0.0.3", + "version": "0.0.2", "description": "JSC 跨 AI 助理共用 plugin 模板。所有 skills 以 SKILL.md 為共通標準。", "skills": "./skills" } diff --git a/plugin.json b/plugin.json index 147d94f..0e0bdbb 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc", - "version": "0.0.3", + "version": "0.0.2", "description": "JSC 跨 AI 助理共用 plugin 模板。所有 skills 以 SKILL.md 為共通標準;於 Antigravity 以 /jsc: 前綴呼叫。", "skills": "./skills/" } \ No newline at end of file -- 2.53.0