#!/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(); }