Files
doc/scripts/worklog/worklog.mjs
T
jiantw83andClaude Sonnet 5 d31aa53fc3 feat(worklog): worklog.sh 改寫為 worklog.mjs(node 全面模組化)
新增 scripts/worklog/worklog.mjs,逐段對應改寫 worklog.sh:log/nowStr 改
import 自 wiki_api.mjs,extractTurn/turnDuration/turnTokens/redact 改
import 自 transcript.mjs,appendEntry/resolveToken/weekPageName/
weekPageHeader 改 import 自 wiki_api.mjs,取代原本各自開子行程呼叫;
stdin JSON 解析、遞迴防護、啟用檢查、Codex transcript fallback、
WORKLOG_SCOPE 過濾、專案判定、模型決定與快取過期、濃縮 prompt 九條規則
逐字保留,退出碼一律 0。hooks/hooks.json 的 Stop hook 改用 node 執行;
README/worklog SKILL.md/wiki_api.mjs 檔頭註解同步更新指向 worklog.mjs。

舊 worklog.sh 暫未刪除:依規劃需先在 WORKLOG_ENABLED=1 的環境實測 hook
真的會觸發並成功寫入週頁,這個環境目前未設定,留待後續驗證後再處理。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 15:12:31 +08:00

318 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 的 <owner>/<repo> 優先,其次目錄名
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} <!-- ${marker} -->\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();
}