diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 369bd7a..2fb3b58 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-doc", - "version": "0.1.2", + "version": "0.1.3", "description": "JSC 文件化 skills(Claude Code / Codex / Antigravity / OpenCode / GitHub Copilot CLI):docker 會整理 docker-compose.yaml 的行內註解與標題日期;funcs 會為專案 functions 建立 .docs 草稿、補齊 XML 文件註解並重建 README 功能列表與使用範例;issues-analyze-to-file 會讀取 Gitea issue、彙整需求、拆成多階段 issue 並產生實作草稿與交付留言;issues-analyze 會把專案/議題/文件來源(議題連同留言與附件一起讀取)拆成小功能議題(母議題須待所有子議題關閉後才可關閉)、分析完成後把屬於專案看板的議題移到「待處理」欄位並依到期日實作;issues-sync 會讀取 Gitea 專案或議題、依工作目錄檔案勾稽並同步議題的 TODO 進度、標籤與專案看板進度欄位並產生進度留言(指定關閉專案/專案完成時改為批次把專案所有議題搬到「已完成」並關閉);notifications 會讀取 Gitea 通知、依通知類型分組並照 REVIEW.md 流程處理,沒有流程時詢問使用者並把缺少流程的類別附加到 REVIEW.md;worklog 會以 README 定義的 headless CLI 將每輪工作整理成六欄工作紀錄並追加到 Gitea wiki。所有 skills 以 SKILL.md 為共通標準;於 Claude Code 以 /jsc-doc: 前綴呼叫。", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 53025ed..83235fa 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-doc", - "version": "0.1.2", + "version": "0.1.3", "description": "JSC 文件化 skills:docker 會整理 docker-compose.yaml 的行內註解與標題日期;funcs 會為專案 functions 建立 .docs 草稿、補齊 XML 文件註解並重建 README 功能列表與使用範例;issues-analyze-to-file 會讀取 Gitea issue、彙整需求、拆成多階段 issue 並產生實作草稿與交付留言;issues-analyze 會把專案/議題/文件來源(議題連同留言與附件一起讀取)拆成小功能議題(母議題須待所有子議題關閉後才可關閉)、分析完成後把屬於專案看板的議題移到「待處理」欄位並依到期日實作;issues-sync 會讀取 Gitea 專案或議題、依工作目錄檔案勾稽並同步議題的 TODO 進度、標籤與專案看板進度欄位並產生進度留言(指定關閉專案/專案完成時改為批次把專案所有議題搬到「已完成」並關閉);notifications 會讀取 Gitea 通知、依通知類型分組並照 REVIEW.md 流程處理,沒有流程時詢問使用者並把缺少流程的類別附加到 REVIEW.md;worklog 會以 README 定義的 headless CLI 將每輪工作整理成六欄工作紀錄並追加到 Gitea wiki(自動 Stop hook 僅相容 hook 環境支援)。所有 skills 以 SKILL.md 為共通標準。", "skills": "./skills" } diff --git a/README.md b/README.md index dc7163b..604e5f3 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ doc/ │ └── hooks.json # Stop → worklog;Claude 用 plugin root,Codex/Copilot fallback 到各自安裝 cache ├── scripts/ │ └── worklog/ # worklog 自動記錄的可執行元件(skill 與 hook 共用) -│ ├── worklog.sh # 主流程:抽本輪 → 濃縮成七欄(含 token 用量) → 遮蔽 → 追加到 wiki +│ ├── worklog.mjs # 主流程:抽本輪 → 濃縮成七欄(含 token 用量) → 遮蔽 → 追加到 wiki │ ├── wiki_api.mjs # Gitea wiki 讀寫、token 解析、append 重試、週頁命名 │ └── transcript.mjs # transcript 本輪抽取、耗時估算、token 統計與機密遮蔽 ├── skills/ # ★ 唯一真實來源:所有 skills diff --git a/hooks/hooks.json b/hooks/hooks.json index d94f7ed..5ab9a37 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -18,7 +18,7 @@ "hooks": [ { "type": "command", - "command": "rel='scripts/worklog/worklog.sh'; own='doc'; plug='jsc-doc'; root=\"${CLAUDE_PLUGIN_ROOT:-}\"; if [ -n \"$root\" ] && [ -f \"$root/$rel\" ]; then exec \"$root/$rel\"; fi; for base in \"$HOME/.claude/plugins/cache\" \"$HOME/.codex/plugins/cache\" \"$HOME/.copilot/installed-plugins\"; do for dir in \"$base/$own/$plug\" \"$base\"; do s=$(find \"$dir\" -path \"*/$plug/*/$rel\" -type f 2>/dev/null | sort -V | tail -n 1); if [ -n \"$s\" ]; then exec \"$s\"; fi; done; done; exit 0", + "command": "rel='scripts/worklog/worklog.mjs'; own='doc'; plug='jsc-doc'; root=\"${CLAUDE_PLUGIN_ROOT:-}\"; if [ -n \"$root\" ] && [ -f \"$root/$rel\" ]; then exec node --use-system-ca \"$root/$rel\"; fi; for base in \"$HOME/.claude/plugins/cache\" \"$HOME/.codex/plugins/cache\" \"$HOME/.copilot/installed-plugins\"; do for dir in \"$base/$own/$plug\" \"$base\"; do s=$(find \"$dir\" -path \"*/$plug/*/$rel\" -type f 2>/dev/null | sort -V | tail -n 1); if [ -n \"$s\" ]; then exec node --use-system-ca \"$s\"; fi; done; done; exit 0", "timeout": 60 } ] diff --git a/plugin.json b/plugin.json index e9c7340..81b3e9d 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-doc", - "version": "0.1.2", + "version": "0.1.3", "description": "JSC 文件化 skills:docker 會整理 docker-compose.yaml 的行內註解與標題日期;funcs 會為專案 functions 建立 .docs 草稿、補齊 XML 文件註解並重建 README 功能列表與使用範例;issues-analyze-to-file 會讀取 Gitea issue、彙整需求、拆成多階段 issue 並產生實作草稿與交付留言;issues-analyze 會把專案/議題/文件來源(議題連同留言與附件一起讀取)拆成小功能議題(母議題須待所有子議題關閉後才可關閉)、分析完成後把屬於專案看板的議題移到「待處理」欄位並依到期日實作;issues-sync 會讀取 Gitea 專案或議題、依工作目錄檔案勾稽並同步議題的 TODO 進度、標籤與專案看板進度欄位並產生進度留言(指定關閉專案/專案完成時改為批次把專案所有議題搬到「已完成」並關閉);notifications 會讀取 Gitea 通知、依通知類型分組並照 REVIEW.md 流程處理,沒有流程時詢問使用者並把缺少流程的類別附加到 REVIEW.md;worklog 會以 README 定義的 headless CLI 將每輪工作整理成六欄工作紀錄並追加到 Gitea wiki。所有 skills 以 SKILL.md 為共通標準;於 Antigravity 以 /jsc-doc: 前綴呼叫。", "skills": "./skills" } diff --git a/plugin.meta.json b/plugin.meta.json index cde9f1b..f57dc93 100644 --- a/plugin.meta.json +++ b/plugin.meta.json @@ -1,7 +1,7 @@ { "name": "jsc-doc", "shortName": "doc", - "version": "0.1.2", + "version": "0.1.3", "descriptionCore": "JSC 文件化 skills:docker 會整理 docker-compose.yaml 的行內註解與標題日期;funcs 會為專案 functions 建立 .docs 草稿、補齊 XML 文件註解並重建 README 功能列表與使用範例;issues-analyze-to-file 會讀取 Gitea issue、彙整需求、拆成多階段 issue 並產生實作草稿與交付留言;issues-analyze 會把專案/議題/文件來源(議題連同留言與附件一起讀取)拆成小功能議題(母議題須待所有子議題關閉後才可關閉)、分析完成後把屬於專案看板的議題移到「待處理」欄位並依到期日實作;issues-sync 會讀取 Gitea 專案或議題、依工作目錄檔案勾稽並同步議題的 TODO 進度、標籤與專案看板進度欄位並產生進度留言(指定關閉專案/專案完成時改為批次把專案所有議題搬到「已完成」並關閉);notifications 會讀取 Gitea 通知、依通知類型分組並照 REVIEW.md 流程處理,沒有流程時詢問使用者並把缺少流程的類別附加到 REVIEW.md;worklog 會以 README 定義的 headless CLI 將每輪工作整理成六欄工作紀錄並追加到 Gitea wiki。所有 skills 以 SKILL.md 為共通標準。", "assistants": ["Claude Code", "Codex", "Antigravity", "OpenCode", "GitHub Copilot CLI"], "cliPrefix": "/jsc-doc:", diff --git a/scripts/worklog/wiki_api.mjs b/scripts/worklog/wiki_api.mjs index 7a6dbbb..cd39165 100644 --- a/scripts/worklog/wiki_api.mjs +++ b/scripts/worklog/wiki_api.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node // ============================================================================== // 用途:Gitea Wiki 讀寫工具(worklog 專用)。提供 token 解析、頁面讀取、 -// 建立、append 追加(read-modify-write + 寫後驗證重試),供 worklog.sh +// 建立、append 追加(read-modify-write + 寫後驗證重試),供 worklog.mjs // 與 /jsc-doc:worklog skill 共用,避免兩份實作漂移。 // resolveToken() 的優先序實作對應 /jsc-shared:spec-gitea『token 解析 // 優先序』章節。mask() 已與 transcript.mjs 的 REDACT_PATTERNS(對應 diff --git a/scripts/worklog/worklog.mjs b/scripts/worklog/worklog.mjs new file mode 100644 index 0000000..73bd1b7 --- /dev/null +++ b/scripts/worklog/worklog.mjs @@ -0,0 +1,317 @@ +#!/usr/bin/env node +// ============================================================================== +// 用途:工作證明自動記錄(worklog)。由支援 hook 的 CLI 觸發, +// 抽出本輪工作內容 → 呼叫已安裝 CLI 濃縮成精簡條目 → 機密遮蔽 → +// 追加到 Gitea wiki 的當週工作紀錄頁。工作內容全程不落地。 +// 更新時間:2026/08/17 15:10:00 +// 相依:node 內建模組、同目錄的 transcript.mjs/wiki_api.mjs(直接 import,不再 +// 另外開子行程呼叫),README 定義的任一 headless CLI。 +// 機密:token 僅由環境變數/本機憑證讀取,不 echo、不寫檔;輸出前套用遮蔽規則。 +// 退出碼:一律 0 —— hook 絕不可阻斷使用者的工作流程。 +// ============================================================================== + +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import { extractTurn, turnDuration, formatTokenLine, turnTokens, redact } from "./transcript.mjs"; +import { nowStr, log, resolveToken, appendEntry, weekPageName, weekPageHeader } from "./wiki_api.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const STAGE = "worklog"; +const SUPPORTED_CLIS = ["claude", "codex", "agy", "opencode", "copilot"]; +const FALLBACK_MODEL = "claude-haiku-4-5-20251001"; +const MODEL_CACHE = path.join(os.homedir(), ".claude", "worklog", "model"); +const CACHE_MAX_AGE_DAYS = 30; + +function logW(level, message) { + log(level, message, STAGE); + if (process.env.WORKLOG_ERRLOG && level === "ERR") { + try { + fs.appendFileSync(process.env.WORKLOG_ERRLOG, `[${nowStr()}][${STAGE}][${level}]: ${message}\n`); + } catch { + // 寫錯誤紀錄檔失敗不影響主流程 + } + } +} + +function dieQuiet(message, level = "DBG") { + logW(level, message); + process.exit(0); +} + +function commandExists(cmd) { + const dirs = (process.env.PATH || "").split(path.delimiter); + for (const dir of dirs) { + const candidate = path.join(dir, cmd); + try { + fs.accessSync(candidate, fs.constants.X_OK); + return true; + } catch { + // 繼續找下一個目錄 + } + } + return false; +} + +function readStdin() { + try { + return fs.readFileSync(0, "utf8"); + } catch { + return ""; + } +} + +/** 判斷實際觸發本輪 hook 的助理環境,避免 auto 因 PATH 順序誤顯其他 CLI。 */ +function detectCurrentCli() { + if (process.env.CODEX_THREAD_ID || process.env.CODEX_CI || process.env.CODEX_MANAGED_PACKAGE_ROOT) return "codex"; + if (process.env.CLAUDE_PLUGIN_ROOT || process.env.CLAUDE_CODE_SSE_PORT) return "claude"; + if (process.env.AGY_SESSION_ID || process.env.AGY_WORKSPACE_ID) return "agy"; + if (process.env.OPENCODE_SESSION_ID || process.env.OPENCODE_CONFIG) return "opencode"; + if (process.env.COPILOT_AGENT_ID || process.env.GITHUB_COPILOT_TOKEN) return "copilot"; + return ""; +} + +/** 依目前 hook/session 環境優先選擇摘要執行器;可用 WORKLOG_CLI 強制指定。 */ +function selectWorklogCli() { + const requested = process.env.WORKLOG_CLI || "auto"; + if (requested !== "auto") { + if (!SUPPORTED_CLIS.includes(requested)) { + dieQuiet(`WORKLOG_CLI 不支援:${requested}(可用:auto ${SUPPORTED_CLIS.join(" ")})`, "WRN"); + } + if (!commandExists(requested)) dieQuiet(`找不到 ${requested} CLI,略過記錄`, "WRN"); + return requested; + } + const current = detectCurrentCli(); + if (current) { + if (commandExists(current)) return current; + logW("WRN", `目前環境判定為 ${current},但找不到 ${current} CLI,改用可用摘要 CLI`); + } + for (const cli of SUPPORTED_CLIS) { + if (commandExists(cli)) return cli; + } + dieQuiet(`找不到可用摘要 CLI(需要其一:${SUPPORTED_CLIS.join(" ")})`, "WRN"); + return ""; // 不會執行到,僅安撫型別檢查 +} + +/** 各 CLI 依 README 的 headless 指令呼叫;不把工作內容寫入檔案。 */ +function runSummaryCli(cli, prompt, model) { + const env = { ...process.env, WORKLOG_CHILD: "1" }; + const timeoutMs = 45000; + let argv; + switch (cli) { + case "claude": + argv = ["claude", "-p", prompt, "--model", model]; + break; + case "codex": + argv = ["codex", "exec", prompt]; + break; + case "agy": + argv = ["agy", "-p", prompt]; + break; + case "opencode": + argv = ["opencode", "run", prompt]; + break; + case "copilot": + argv = ["copilot", "-p", prompt]; + break; + default: + return ""; + } + const result = spawnSync(argv[0], argv.slice(1), { env, timeout: timeoutMs, encoding: "utf8" }); + return (result.stdout || "").toString(); +} + +async function main() { + // 遞迴防護:摘要用的子 CLI 行程可能再次觸發 Stop hook,必須在此擋掉 + if (process.env.WORKLOG_CHILD) process.exit(0); + + // 啟用檢查:未設定 WORKLOG_* 的環境完全不動作(他人匯入 plugin 零影響) + if (process.env.WORKLOG_ENABLED !== "1") process.exit(0); + if (!process.env.WORKLOG_HOST) dieQuiet("未設定 WORKLOG_HOST,略過記錄", "WRN"); + if (!process.env.WORKLOG_REPO) dieQuiet("未設定 WORKLOG_REPO,略過記錄", "WRN"); + + const summaryCli = selectWorklogCli(); + if (!summaryCli) process.exit(0); + + // 讀取 hook 傳入的 JSON。欄位名稱大小寫依助理而異:Claude Code/Codex 用 + // snake_case(session_id/transcript_path),Copilot 的 agentStop 事件用 + // camelCase(sessionId/transcriptPath),只有 stop_hook_active 剛好三家都是 + // snake_case。兩種寫法都要接,缺一個 Copilot 就完全取不到值。 + const hookInputRaw = readStdin(); + if (!hookInputRaw) dieQuiet("hook 輸入為空,略過記錄", "WRN"); + + let hookData = {}; + try { + hookData = JSON.parse(hookInputRaw); + } catch { + hookData = {}; + } + const sessionId = hookData.session_id || hookData.sessionId || hookData.thread_id || hookData.conversation_id || "-"; + let transcriptPath = + hookData.transcript_path || hookData.transcriptPath || hookData.session_path || hookData.conversation_path || hookData.path || "-"; + const stopActive = !!(hookData.stop_hook_active || hookData.stopHookActive); + const hookCwd = hookData.cwd || "-"; + + if (stopActive) dieQuiet("stop_hook_active 為 true,避免迴圈不重複記錄"); + + // Codex 的 hook 只給 thread id、不給 transcript 路徑,需要自己找檔案; + // Copilot 的 agentStop 事件已直接帶 transcriptPath(見上方解析),不需要這段 fallback。 + if (!fs.existsSync(transcriptPath) && process.env.CODEX_THREAD_ID) { + const sessionsDir = path.join(os.homedir(), ".codex", "sessions"); + transcriptPath = findFileEndingWith(sessionsDir, `${process.env.CODEX_THREAD_ID}.jsonl`) || "-"; + } + if (!fs.existsSync(transcriptPath)) dieQuiet(`找不到 transcript:${transcriptPath}`, "WRN"); + + // 記錄範圍:WORKLOG_SCOPE 以冒號分隔的路徑前綴,未設定則全部 session 都記 + if (process.env.WORKLOG_SCOPE) { + const scopes = process.env.WORKLOG_SCOPE.split(":"); + const inScope = scopes.some((scope) => hookCwd.startsWith(scope.replace(/\/$/, ""))); + if (!inScope) dieQuiet(`cwd 不在 WORKLOG_SCOPE 範圍內:${hookCwd}`); + } + + // 專案判定:git remote 的 / 優先,其次目錄名 + let project = path.basename(hookCwd); + const gitCheck = spawnSync("git", ["-C", hookCwd, "rev-parse", "--is-inside-work-tree"], { encoding: "utf8" }); + if (gitCheck.status === 0) { + const originResult = spawnSync("git", ["-C", hookCwd, "remote", "get-url", "origin"], { encoding: "utf8" }); + const origin = (originResult.stdout || "").trim(); + if (origin) { + let cleaned = origin.replace(/\.git$/, ""); + cleaned = cleaned.replace(/^.*:\/\//, ""); + cleaned = cleaned.replace(/^[^@]*@/, ""); + const parts = cleaned.split("/").filter(Boolean); + if (parts.length >= 2) project = `${parts[parts.length - 2]}/${parts[parts.length - 1]}`; + } + } + + // 抽出本輪內容(最後一筆使用者訊息之後),並先做一次機密遮蔽 + const turn = redact(extractTurn(transcriptPath)); + if (!turn) dieQuiet("本輪無可記錄內容"); + const duration = turnDuration(transcriptPath) || "未判定"; + const tokens = formatTokenLine(turnTokens(transcriptPath)) || "未判定"; + + // 模型決定:只有 claude CLI 使用 WORKLOG_MODEL/快取檔;其他 CLI 使用各自預設模型 + let model = ""; + let modelNote = ""; + if (summaryCli === "claude") { + if (process.env.WORKLOG_MODEL) { + model = process.env.WORKLOG_MODEL; + } else if (fs.existsSync(MODEL_CACHE)) { + const stat = fs.statSync(MODEL_CACHE); + const ageDays = (Date.now() - stat.mtimeMs) / (1000 * 60 * 60 * 24); + if (ageDays > CACHE_MAX_AGE_DAYS) { + model = FALLBACK_MODEL; + modelNote = " (cli: claude, model: fallback)"; + logW("WRN", `模型快取已超過 ${CACHE_MAX_AGE_DAYS} 天,改用保底模型,建議重跑 /jsc-doc:worklog --tune`); + } else { + const cacheText = fs.readFileSync(MODEL_CACHE, "utf8"); + const m = cacheText.match(/^model=(.*)$/m); + model = m ? m[1].trim() : ""; + } + } + if (!model) { + model = FALLBACK_MODEL; + modelNote = " (cli: claude, model: fallback)"; + logW("WRN", "無模型快取,改用保底模型,建議執行 /jsc-doc:worklog --tune"); + } else if (!modelNote) { + modelNote = " (cli: claude)"; + } + } else { + modelNote = ` (cli: ${summaryCli})`; + } + + // 濃縮:交給選定 CLI 產出精簡條目(子行程帶 WORKLOG_CHILD=1 阻斷遞迴) + const prompt = `你是工作紀錄濃縮器。輸入是一段 AI 助理與使用者的對話片段(含工具呼叫)。 +請濃縮成工作紀錄條目,規則: + +已判定專案:${project} +已估算花費時間:${duration} +已統計 token 用量:${tokens} + +1. 只輸出 7 個 markdown bullet(以「- 」開頭),不要標題、不要前言、不要結語。 +2. 七個 bullet 必須依序使用下列欄位名稱,格式固定為「- 欄位名稱:內容」: + - 專案/任務名稱 + - 執行細節與產出 + - 花費時間 + - 任務狀態 + - 遇到的困難 + - 解決方式 + - token 用量 +3. 使用繁體中文(台灣用語),每個 bullet 一行、不超過 90 字,聚焦「做了什麼、動到什麼、結果如何」。 +4. 保留關鍵事實:檔案/專案/指令/數量/分支/PR/議題編號;不要抄程式碼、不要貼指令全文。 +5. 花費時間優先使用「已估算花費時間」;無法判定時寫「未判定」。 +6. 若沒有遇到明確困難,遇到的困難寫「未遇到明確困難」,解決方式寫「不需額外處理」。 +7. 嚴禁輸出任何憑證與個資:token、密碼、API key、連線字串、Email、電話、姓名、身分證號。 +8. 若這段對話沒有實質工作產出(純閒聊、純提問、僅讀取資訊而未產生結論),只輸出一行:SKIP +9. token 用量一律照抄「已統計 token 用量」,不得自行推算或估計;無法判定時寫「未判定」。 + +對話片段: +${turn}`; + + let summary = runSummaryCli(summaryCli, prompt, model); + if (!summary) { + logW("WRN", `摘要產出為空(CLI ${summaryCli}),略過本輪`); + process.exit(0); + } + if (/^\s*SKIP\s*$/i.test(summary)) dieQuiet("模型判定本輪無實質工作產出"); + + // 第二道防線:對模型輸出再做一次機密遮蔽 + summary = redact(summary); + // 只保留 bullet 行,避免模型帶出多餘敘述 + summary = summary + .split("\n") + .filter((line) => /^\s*[-*]\s+/.test(line)) + .map((line) => line.replace(/^\s*\*/, "-")) + .slice(0, 7) + .join("\n"); + if (!summary) dieQuiet("摘要不含合法條目,略過本輪", "WRN"); + + // 組條目並追加到當週 wiki 頁 + const stamp = nowStr(); + const marker = `worklog:${nowStr().replace(/[^0-9]/g, "")}-${sessionId.slice(0, 8)}`; + const entry = `## ${stamp} — ${project}${modelNote} \n${summary}`; + + await appendToWiki(entry, marker, project); +} + +function findFileEndingWith(dir, suffix) { + try { + for (const name of fs.readdirSync(dir)) { + const full = path.join(dir, name); + const stat = fs.statSync(full); + if (stat.isDirectory()) { + const found = findFileEndingWith(full, suffix); + if (found) return found; + } else if (name.endsWith(suffix)) { + return full; + } + } + } catch { + // 目錄不存在或無法讀取,視為找不到 + } + return null; +} + +async function appendToWiki(entry, marker, project) { + const host = process.env.WORKLOG_HOST; + const repo = process.env.WORKLOG_REPO; + const [token, source] = await resolveToken(host, repo); + if (!token) { + logW("ERR", `寫入 wiki 失敗(專案 ${project}):無可用 token(${source})`); + process.exit(0); + } + const page = weekPageName(); + const [ok, msg] = await appendEntry(host, repo, token, page, weekPageHeader(page), entry, marker); + if (ok) { + logW("INF", `已記錄工作條目(專案 ${project},CLI 摘要來源已套用)`); + } else { + logW("ERR", `寫入 wiki 失敗(專案 ${project}):${msg}`); + } + process.exit(0); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/scripts/worklog/worklog.sh b/scripts/worklog/worklog.sh deleted file mode 100755 index e24a06f..0000000 --- a/scripts/worklog/worklog.sh +++ /dev/null @@ -1,294 +0,0 @@ -#!/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 diff --git a/skills/worklog/SKILL.md b/skills/worklog/SKILL.md index 431d5ac..a4de8a9 100644 --- a/skills/worklog/SKILL.md +++ b/skills/worklog/SKILL.md @@ -11,7 +11,7 @@ description: 工作證明自動記錄(worklog)的操作與維護 skill。搭 | --- | --- | --- | | `hooks/hooks.json` 的 `Stop` hook | harness 自動 | 每輪結束抽本輪內容 → 濃縮 → 遮蔽 → 追加到當週頁 | | 本 skill `/jsc-doc:worklog` | 使用者/助理手動 | `--init`/`--tune`/`--diagnose`/`--append`/`--show` | -| `scripts/worklog/worklog.sh` | 上述兩者共用 | 主流程(單一實作,避免漂移):依 `WORKLOG_CLI` 呼叫 headless CLI,每筆整理成七個固定欄位(含 token 用量) | +| `scripts/worklog/worklog.mjs` | 上述兩者共用 | 主流程(單一實作,避免漂移):依 `WORKLOG_CLI` 呼叫 headless CLI,每筆整理成七個固定欄位(含 token 用量) | | `scripts/worklog/wiki_api.mjs` | 上述兩者共用 | token 解析、wiki 讀寫、append 重試、週頁命名 | | `scripts/worklog/transcript.mjs` | 上述兩者共用 | 抽本輪片段、估算花費時間、統計 token 用量、機密遮蔽 | @@ -27,7 +27,7 @@ description: 工作證明自動記錄(worklog)的操作與維護 skill。搭 限制與實測結果的來源: -- **`Stop` hook 只有相容 hook 環境實際執行**;Claude Code 先用 `CLAUDE_PLUGIN_ROOT` 定位腳本,找不到時依序掃 `~/.claude/plugins/cache`、`~/.codex/plugins/cache`、`~/.copilot/installed-plugins`,最後命中 `*/jsc-doc/*/scripts/worklog/worklog.sh`。`transcript.mjs` 支援 Claude Code transcript JSONL(`type` / `message.content` blocks)、Codex session JSONL(`payload` events / response items)與 Copilot events.jsonl(`type` 為 `user.message`/`assistant.message`/`tool.execution_complete` 的 `data.*` 欄位),其他助理若提供等效 hook,必須先補對應 transcript 解析器。 +- **`Stop` hook 只有相容 hook 環境實際執行**;Claude Code 先用 `CLAUDE_PLUGIN_ROOT` 定位腳本,找不到時依序掃 `~/.claude/plugins/cache`、`~/.codex/plugins/cache`、`~/.copilot/installed-plugins`,最後命中 `*/jsc-doc/*/scripts/worklog/worklog.mjs`。`transcript.mjs` 支援 Claude Code transcript JSONL(`type` / `message.content` blocks)、Codex session JSONL(`payload` events / response items)與 Copilot events.jsonl(`type` 為 `user.message`/`assistant.message`/`tool.execution_complete` 的 `data.*` 欄位),其他助理若提供等效 hook,必須先補對應 transcript 解析器。 - **Copilot 支援細節(2026/08/11 實測)**:Copilot CLI 內部事件名稱是 `agentStop`(不是 `Stop`),欄位為 camelCase(`sessionId`/`transcriptPath`,只有 `stop_hook_active` 例外仍是 snake_case);但實測確認 **Copilot 的 plugin 載入器會把 `hooks/hooks.json` 裡的 `Stop` key 自動對應到它自己的 `agentStop` 事件**,本檔不需要另外宣告 `agentStop` key。transcript 路徑固定為 `~/.copilot/session-state//events.jsonl`,且 hook payload 直接帶 `transcriptPath`,不需要像 Codex 分支那樣自己用 session id 反查檔案。**Copilot 逐輪只記錄 `assistant.message.data.outputTokens`,沒有對應的輸入 token 欄位**;`session.shutdown.modelMetrics` 雖然有完整輸入/輸出,但那是整個 session 結束才寫的累計值,語意不是「本輪」,故 Copilot 的輸入 token 一律固定輸出「未判定」,不得用該欄位冒充本輪數字。 - **Antigravity(`agy`)不支援自動記錄的兩個具體原因**:(1) `agy --help` 沒有任何 hook 相關子指令或設定項,CLI 本身不提供事件觸發點(觸發點缺);(2) 就算有觸發點,`agy` 的對話記錄存在 `~/.gemini/antigravity-cli/conversations/*.db` 內,內容是 `step_payload`/`gen_metadata` 等欄位的 protobuf 二進位 blob、`step_type` 是數字 enum,沒有公開 `.proto` schema 可解析(資料缺)。兩個條件都不成立,之後要支援得兩者都解決,不是單純補一支 transcript 解析器就好。 - **OpenCode 不支援自動記錄的原因**:目前的 skill 目錄安裝法本來就不含 `scripts/`,且其 CLI 同樣未見 hook 機制文件;若之後提供對應的 hook 機制與可讀的 transcript 格式,才有辦法補上,純粹「以完整 plugin 目錄執行」不足以讓自動記錄運作。