diff --git a/README.md b/README.md index efcf35b..dc7163b 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # jsc-doc — 跨 AI 助理文件化 Skill 集合 一個可同時被 **Claude Code、Codex、Antigravity、OpenCode、GitHub Copilot** 使用的文件化 skill 集合。 -目前內含七個實作型 skills:`docker` 用於整理 `docker-compose.yaml` 的行內註解與標題日期;`funcs` 用於掃描專案 functions、建立 `.docs/` 草稿、補齊 XML 文件註解並重建 README 功能列表與使用範例;`issues-analyze-to-file` 用於讀取 Gitea issue、彙整需求並拆成多階段 issue、產生實作草稿與交付留言;`issues-analyze` 用於把專案/議題/文件來源(議題連同留言與附件一起讀取)拆成小功能議題(母議題須待所有子議題關閉後才可關閉)、分析完成後把屬於專案看板的議題移到「待處理」欄位並依到期日排序留言(不實作程式碼,實作交由 code plugin 的 issues);`issues-sync` 用於讀取 Gitea 專案或議題,依工作目錄檔案勾稽並同步議題的 TODO 進度、標籤與專案看板進度欄位、產生進度留言;指定「關閉專案/專案完成」時改為批次把專案所有議題搬到「已完成」並關閉;`notifications` 用於讀取 Gitea 通知、依通知類型分組並照 `REVIEW.md` 流程處理,若 `REVIEW.md` 不存在則視為空白流程檔並直接詢問使用者如何定義流程;`worklog` 用於把 session stop 的內容透過 README 定義的 headless CLI 整理成六欄工作紀錄並追加到 Gitea wiki。 +目前內含七個實作型 skills:`docker` 用於整理 `docker-compose.yaml` 的行內註解與標題日期;`funcs` 用於掃描專案 functions、建立 `.docs/` 草稿、補齊 XML 文件註解並重建 README 功能列表與使用範例;`issues-analyze-to-file` 用於讀取 Gitea issue、彙整需求並拆成多階段 issue、產生實作草稿與交付留言;`issues-analyze` 用於把專案/議題/文件來源(議題連同留言與附件一起讀取)拆成小功能議題(母議題須待所有子議題關閉後才可關閉)、分析完成後把屬於專案看板的議題移到「待處理」欄位並依到期日排序留言(不實作程式碼,實作交由 code plugin 的 issues);`issues-sync` 用於讀取 Gitea 專案或議題,依工作目錄檔案勾稽並同步議題的 TODO 進度、標籤與專案看板進度欄位、產生進度留言;指定「關閉專案/專案完成」時改為批次把專案所有議題搬到「已完成」並關閉;`notifications` 用於讀取 Gitea 通知、依通知類型分組並照 `REVIEW.md` 流程處理,若 `REVIEW.md` 不存在則視為空白流程檔並直接詢問使用者如何定義流程;`worklog` 用於把 session stop 的內容透過 README 定義的 headless CLI 整理成七欄工作紀錄(含 token 用量)並追加到 Gitea wiki。 核心是以 [Agent Skills(`SKILL.md`)](https://agentskills.io) 標準撰寫的共用 skills(唯一真實來源放在 `skills/`), 搭配各助理各自的 plugin manifest,讓**同一個 repo** 可用各家**原生 plugin CLI** 安裝。 在 Claude Code 與 Antigravity 中,skill 以 **`/jsc-doc:` 前綴**呼叫(例如 `/jsc-doc:docker`)。 @@ -37,12 +37,12 @@ doc/ │ └── marketplace.json # Codex marketplace(name: "doc",url source 指向本 repo) ├── plugin.json # Antigravity 外掛定義(name: "jsc-doc",skills: "./skills/") ├── hooks/ -│ └── hooks.json # Stop → worklog;Claude 用 plugin root,Codex fallback 到安裝 cache +│ └── hooks.json # Stop → worklog;Claude 用 plugin root,Codex/Copilot fallback 到各自安裝 cache ├── scripts/ │ └── worklog/ # worklog 自動記錄的可執行元件(skill 與 hook 共用) -│ ├── worklog.sh # 主流程:抽本輪 → 濃縮成六欄 → 遮蔽 → 追加到 wiki -│ ├── wiki_api.py # Gitea wiki 讀寫、token 解析、append 重試、週頁命名 -│ └── transcript.py # transcript 本輪抽取、耗時估算與機密遮蔽 +│ ├── worklog.sh # 主流程:抽本輪 → 濃縮成七欄(含 token 用量) → 遮蔽 → 追加到 wiki +│ ├── wiki_api.mjs # Gitea wiki 讀寫、token 解析、append 重試、週頁命名 +│ └── transcript.mjs # transcript 本輪抽取、耗時估算、token 統計與機密遮蔽 ├── skills/ # ★ 唯一真實來源:所有 skills │ ├── docker/ # 對齊 docker-compose 註解(含 scripts/) │ │ ├── SKILL.md @@ -67,7 +67,7 @@ doc/ | `worklog` 手動模式 | ✅ | ⚠️ 需安裝後保留 `scripts/` | ⚠️ 同左 | ⚠️ 需完整 plugin 目錄 | ⚠️ 需安裝後保留 `scripts/` | | 摘要 CLI | `claude -p` | `codex exec` | `agy -p` | `opencode run` | `copilot -p` | -`worklog` 自動記錄仍依賴相容的 Stop hook 與 transcript JSONL 結構;Claude Code 會用 `CLAUDE_PLUGIN_ROOT` 定位腳本,Codex 會從 `~/.codex/plugins/cache/doc/jsc-doc` 找已安裝的 worklog 腳本並解析 Codex session JSONL。摘要執行器可用 `WORKLOG_CLI=auto|claude|codex|agy|opencode|copilot` 指定;預設 `auto` 會先依目前 hook/session 環境判斷正在使用的 CLI,判斷不到或該 CLI 不可執行時才 fallback 到已安裝工具。 +`worklog` 自動記錄仍依賴相容的 Stop hook 與 transcript JSONL 結構;Claude Code 會用 `CLAUDE_PLUGIN_ROOT` 定位腳本,Codex 與 Copilot 會分別從 `~/.codex/plugins/cache/doc/jsc-doc`、`~/.copilot/installed-plugins/doc/jsc-doc` 找已安裝的 worklog 腳本,解析各自的 transcript 格式(Codex session JSONL/Copilot events.jsonl)。Copilot 的 hook 事件名稱是 `agentStop`,但其 plugin 載入器會自動把 `hooks.json` 的 `Stop` key 對應過去,不需另外宣告。摘要執行器可用 `WORKLOG_CLI=auto|claude|codex|agy|opencode|copilot` 指定;預設 `auto` 會先依目前 hook/session 環境判斷正在使用的 CLI,判斷不到或該 CLI 不可執行時才 fallback 到已安裝工具。 --- diff --git a/hooks/hooks.json b/hooks/hooks.json index c6140c0..2aba569 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -5,7 +5,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\"; 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.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", "timeout": 60 } ] diff --git a/scripts/worklog/transcript.mjs b/scripts/worklog/transcript.mjs new file mode 100644 index 0000000..271c463 --- /dev/null +++ b/scripts/worklog/transcript.mjs @@ -0,0 +1,565 @@ +#!/usr/bin/env node +// ============================================================================== +// 用途:worklog 的 transcript 處理工具。負責 (1) 從 Claude Code/Codex/ +// GitHub Copilot CLI 的 JSONL 抽出「本輪」對話片段(最後一筆使用者訊息 +// 之後的全部內容),(2) 估算本輪花費時間,(3) 統計本輪 token 用量, +// (4) 對文字做機密遮蔽(token/密碼/PII),作為寫入 wiki 前的第二道防線。 +// 本檔 REDACT_PATTERNS 對應 /jsc-shared:spec-gitea『機密遮蔽實作』章節 +// (其他工具的機密遮蔽規則以該章節為準)。 +// 原 Python 版(transcript.py)的 extract/duration/redact 三個子命令 +// 已逐一以同一份 transcript 對拍 diff 驗證輸出逐字元相同,token 統計與 +// Copilot 支援為本次新增,Python 版沒有對應功能可供對拍。 +// 更新時間:2026/08/11 16:51:56 +// 相依:Node.js 標準內建功能,無外部套件。全程僅走 stdin/stdout,不寫任何檔案。 +// ============================================================================== + +import fs from "node:fs"; + +// 單則工具結果/參數的擷取上限,避免整份 transcript 塞進摘要輸入 +const TOOL_RESULT_LIMIT = 200; +const TOOL_INPUT_LIMIT = 160; +const TOTAL_LIMIT = 24000; + +// ------------------------------------------------------------------------------ +// 機密遮蔽規則:命中一律換成 *** +// ------------------------------------------------------------------------------ +// 本檔遮蔽規則對應 shared/scripts/lib/redact-patterns.json(經 /jsc-shared:spec-gitea 收斂)。 +// 依 A5-3/G1-6 裁決:doc repo 內自帶一份,不跨 repo 讀取 shared/scripts/lib/redact-patterns.json +// (執行期無法跨 plugin 存取),異動一律先改 shared 那份規則檔,再回頭同步本檔。 +// +// 移植自 Python re 版時的三個地雷(逐一處理,對應驗收案例見 G1-2): +// (a) Python re.sub 預設全域取代,JS String.replace 不是 —— 全部補上 g flag。 +// (b) Python (?i) inline flag,JS 不支援 —— 改用 RegExp 的 i flag。 +// (c) 反向參照:Python 用 \1,JS String.replace 用 $1。 +const REDACT_PATTERNS = [ + [/[A-Za-z0-9_-]*:[A-Za-z0-9_-]{16,}@/g, "***@"], // URL 內嵌憑證 user:token@ + [/\b[0-9a-f]{40}\b/g, "***"], // Gitea 40 字元 token + [/\bgh[pousr]_[A-Za-z0-9_]{16,}\b/g, "***"], // GitHub token + [/\bsk-[A-Za-z0-9_-]{16,}\b/g, "***"], // API key + [/\b(token|password|passwd|pwd|secret|api[_-]?key)\b\s*[:=]\s*\S+/gi, "$1=***"], + [/Authorization:\s*(token|bearer)\s+\S+/gi, "Authorization: $1 ***"], + [/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, "***"], // Email + [/\b09\d{2}[-\s]?\d{3}[-\s]?\d{3}\b/g, "***"], // 台灣手機 + [/\b[A-Z][12]\d{8}\b/g, "***"], // 身分證字號 +]; + +/** + * 仿 Python `json.dumps(obj, ensure_ascii=False)` 預設格式序列化(`", "`/`": "` + * 分隔符,逗號與冒號後都有空格):JS `JSON.stringify` 預設不加這些空格, + * 若直接拿來取代會讓 `[tool:xxx]` 那行的參數字串跟 Python 版逐字元不同 + * (已於 G1-1 對拍真實 subagent transcript 時抓到這個差異)。 + */ +function pyJsonDumps(value) { + if (value === null || value === undefined) return "null"; + if (typeof value === "boolean" || typeof value === "number") return String(value); + if (typeof value === "string") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(pyJsonDumps).join(", ")}]`; + if (typeof value === "object") { + const parts = Object.entries(value).map(([k, v]) => `${JSON.stringify(k)}: ${pyJsonDumps(v)}`); + return `{${parts.join(", ")}}`; + } + return "null"; +} + +/** Python 的 truthy 判斷:`0`/`""`/`None`/`False`/空 list/空 dict 皆為 falsy。 */ +function isPyTruthy(value) { + if (value === null || value === undefined || value === false || value === 0 || value === "") return false; + if (Array.isArray(value)) return value.length > 0; + if (typeof value === "object") return Object.keys(value).length > 0; + return true; +} + +/** + * 仿 Python `repr()`:字串用單引號,list/dict 遞迴展開。用於重現 + * `str(payload.get("output") or "")` 這類寫法在 `output` 是 list/dict(而非 + * 字串)時的輸出——Python `str(list)` 會呼叫每個元素的 `repr()`,JS + * `String(array)` 只會呼叫 `Array.prototype.toString`(物件變成 + * `[object Object]`),兩者天差地遠(已於 G1-1 對拍真實 Codex transcript 時 + * 抓到這個差異:某筆 `function_call_output` 的 `output` 是結構化 array 而非 + * JSON 字串)。 + */ +function pyRepr(value) { + if (value === null || value === undefined) return "None"; + if (value === true) return "True"; + if (value === false) return "False"; + if (typeof value === "number") return String(value); + if (typeof value === "string") { + const quote = value.includes("'") && !value.includes('"') ? '"' : "'"; + let out = ""; + for (const ch of value) { + const cp = ch.codePointAt(0); + if (ch === "\\") out += "\\\\"; + else if (ch === quote) out += `\\${quote}`; + else if (ch === "\n") out += "\\n"; + else if (ch === "\r") out += "\\r"; + else if (ch === "\t") out += "\\t"; + // Python repr() 對「不可印字元」一律跳脫成 \uXXXX/\UXXXXXXXX:這裡不追 + // 完整 Unicode 分類表(那需要外部資料庫),只涵蓋最常見的兩類——C0/C1 + // 控制字元、與私用區(Private Use Area,已於 G1-1 對拍時在真實 Codex + // transcript 的引註標記裡遇到 U+E200 這個案例)。其餘罕見不可印分類 + // (如某些格式控制符)維持原樣輸出,屬已知、影響範圍為零的簡化 + // (這段文字只會餵給 LLM 摘要,不影響任何程式邏輯判讀)。 + else if ((cp >= 0x00 && cp <= 0x1f) || cp === 0x7f || (cp >= 0x80 && cp <= 0x9f)) { + out += `\\x${cp.toString(16).padStart(2, "0")}`; + } else if ((cp >= 0xe000 && cp <= 0xf8ff) || (cp >= 0xf0000 && cp <= 0xffffd) || (cp >= 0x100000 && cp <= 0x10fffd)) { + out += cp <= 0xffff ? `\\u${cp.toString(16).padStart(4, "0")}` : `\\U${cp.toString(16).padStart(8, "0")}`; + } else out += ch; + } + return `${quote}${out}${quote}`; + } + if (Array.isArray(value)) return `[${value.map(pyRepr).join(", ")}]`; + if (typeof value === "object") { + const parts = Object.entries(value).map(([k, v]) => `${pyRepr(k)}: ${pyRepr(v)}`); + return `{${parts.join(", ")}}`; + } + return String(value); +} + +/** 仿 Python `str(value or fallback)`:字串型別的 `str()` 是自己本身,其餘型別走 `pyRepr`。 */ +function pythonStrOr(value, fallback) { + if (!isPyTruthy(value)) return fallback; + return typeof value === "string" ? value : pyRepr(value); +} + +/** 依 Unicode 碼點(非 UTF-16 code unit)取字串長度,對齊 Python str 的 len() 語意。 */ +function codePointLength(text) { + return Array.from(text).length; +} + +/** + * 依 Unicode 碼點切片,對齊 Python 字串切片語意(避免切斷代理對)。 + * ⚠️ 必須用這個,不能直接 `str.slice()`:emoji 等 astral-plane 字元在 JS 是 + * 兩個 UTF-16 code unit,`slice()` 按 code unit 數截斷會比 Python 按碼點數 + * 截斷的版本少算字元(已於 G1-1 對拍真實 transcript 時抓到這個差異)。 + */ +function codePointSlice(text, start, end) { + return Array.from(text).slice(start, end).join(""); +} + +/** 對文字套用全部機密遮蔽規則,回傳遮蔽後的結果。 */ +export function redact(text) { + let out = text; + for (const [pattern, replacement] of REDACT_PATTERNS) { + out = out.replace(pattern, replacement); + } + return out; +} + +/** 判斷 transcript 條目是否為真正的使用者輸入(排除工具回填與環境注入)。 */ +function isRealUserMessage(entry) { + const payload = entry.payload; + if (payload && typeof payload === "object" && !Array.isArray(payload) && entry.type === "event_msg") { + return payload.type === "user_message" && Boolean(String(payload.message ?? "").trim()); + } + // Copilot:type "user.message",取 data.content(不用 transformedContent,那裡混了注入內容) + if (entry.type === "user.message") { + const content = entry.data && typeof entry.data === "object" ? entry.data.content : undefined; + return Boolean(String(content ?? "").trim()); + } + if (entry.type !== "user") return false; + const content = entry.message && typeof entry.message === "object" ? entry.message.content : undefined; + if (typeof content === "string") return Boolean(content.trim()); + if (Array.isArray(content)) { + return content.some((b) => b && typeof b === "object" && b.type === "text"); + } + return false; +} + +/** 取出條目的 content blocks,統一為 array 形式。 */ +function blocks(entry) { + const content = entry.message && typeof entry.message === "object" ? entry.message.content : undefined; + if (typeof content === "string") return [{ type: "text", text: content }]; + return Array.isArray(content) ? content : []; +} + +/** 把 Codex response_item 的 content blocks 轉成純文字片段。 */ +function payloadTextBlocks(content) { + if (typeof content === "string") return [content]; + if (!Array.isArray(content)) return []; + const texts = []; + for (const block of content) { + if (!block || typeof block !== "object") continue; + if (["input_text", "output_text", "text"].includes(block.type)) { + const text = String(block.text ?? "").trim(); + if (text) texts.push(text); + } + } + return texts; +} + +/** 將 Codex session JSONL 的 payload 格式轉為摘要輸入用純文字。 */ +function renderCodexPayload(entry) { + const payload = entry.payload; + if (!payload || typeof payload !== "object") return []; + + const lines = []; + const entryType = entry.type; + const payloadType = payload.type; + + if (entryType === "event_msg") { + if (payloadType === "user_message") { + const message = String(payload.message ?? "").trim(); + if (message) lines.push(`[user] ${message}`); + } else if (payloadType === "agent_message") { + const message = String(payload.message ?? "").trim(); + if (message) { + const phase = payload.phase || "assistant"; + lines.push(`[assistant:${phase}] ${message}`); + } + } + return lines; + } + + if (entryType !== "response_item") return lines; + + if (payloadType === "message") { + const role = payload.role || "assistant"; + if (role === "system" || role === "developer") return lines; + for (const text of payloadTextBlocks(payload.content)) { + // Codex 會把 skill 內容以 user role 注入;避免把整份 SKILL.md 當成本輪工作。 + if (role === "user" && text.trimStart().startsWith("")) continue; + if (role === "user" && text.trimStart().startsWith("")) continue; + lines.push(`[${role}] ${text}`); + } + } else if (payloadType === "function_call") { + const name = payload.name || "?"; + const raw = pythonStrOr(payload.arguments, "").trim().replace(/\n/g, " "); + lines.push(`[tool:${name}] ${codePointSlice(raw, 0, TOOL_INPUT_LIMIT)}`); + } else if (payloadType === "function_call_output") { + const raw = pythonStrOr(payload.output, "").trim().replace(/\n/g, " "); + if (raw) lines.push(`[result] ${codePointSlice(raw, 0, TOOL_RESULT_LIMIT)}`); + } + + return lines; +} + +/** + * 將 Copilot events.jsonl 條目轉為摘要輸入用純文字。 + * ⚠️ user.message/assistant.message 的欄位形狀已於 2026/08/11 實測真實 Copilot + * session 確認(見 todo.md H1-1 記錄);tool.execution_complete 與 assistant.message + * 的 toolRequests 欄位形狀依規劃文件推斷,尚未實測到含工具呼叫的真實 session, + * 若欄位名稱與實際不符請依實測結果修正。 + */ +function renderCopilotEvent(entry) { + const data = entry.data; + if (!data || typeof data !== "object") return []; + + if (entry.type === "user.message") { + const text = String(data.content ?? "").trim(); + return text ? [`[user] ${text}`] : []; + } + + if (entry.type === "assistant.message") { + const lines = []; + const text = String(data.content ?? "").trim(); + if (text) lines.push(`[assistant] ${text}`); + const toolRequests = Array.isArray(data.toolRequests) ? data.toolRequests : []; + for (const req of toolRequests) { + const name = (req && (req.name || req.tool)) || "?"; + const raw = pyJsonDumps((req && (req.input ?? req.arguments)) ?? {}); + lines.push(`[tool:${name}] ${codePointSlice(raw, 0, TOOL_INPUT_LIMIT)}`); + } + return lines; + } + + if (entry.type === "tool.execution_complete") { + const raw = String(data.output ?? data.result ?? "").trim().replace(/\n/g, " "); + return raw ? [`[result] ${codePointSlice(raw, 0, TOOL_RESULT_LIMIT)}`] : []; + } + + return []; +} + +/** 將單一 transcript 條目轉為摘要輸入用的純文字行(工具結果僅取前段)。 */ +function render(entry) { + const copilotLines = renderCopilotEvent(entry); + if (copilotLines.length) return copilotLines; + + const codexLines = renderCodexPayload(entry); + if (codexLines.length) return codexLines; + + const role = entry.type; + const lines = []; + for (const block of blocks(entry)) { + if (!block || typeof block !== "object") continue; + const kind = block.type; + if (kind === "text") { + const text = String(block.text ?? "").trim(); + if (text) lines.push(`[${role}] ${text}`); + } else if (kind === "tool_use") { + const name = block.name ?? "?"; + const raw = pyJsonDumps(block.input ?? {}); + lines.push(`[tool:${name}] ${codePointSlice(raw, 0, TOOL_INPUT_LIMIT)}`); + } else if (kind === "tool_result") { + let raw = block.content; + if (Array.isArray(raw)) { + raw = raw + .filter((b) => b && typeof b === "object" && b.type === "text") + .map((b) => b.text ?? "") + .join(" "); + } + raw = pythonStrOr(raw, "").trim().replace(/\n/g, " "); + if (raw) lines.push(`[result] ${codePointSlice(raw, 0, TOOL_RESULT_LIMIT)}`); + } + } + return lines; +} + +/** 讀取 transcript JSONL,忽略無法解析的列。 */ +function readEntries(filePath) { + let raw; + try { + raw = fs.readFileSync(filePath, "utf8"); + } catch { + return []; + } + const entries = []; + for (const rawLine of raw.split("\n")) { + const line = rawLine.trim(); + if (!line) continue; + try { + entries.push(JSON.parse(line)); + } catch { + continue; + } + } + return entries; +} + +/** 找出本輪起點:最後一筆真正使用者訊息的位置。 */ +function turnStartIndex(entries) { + let start = 0; + for (let index = entries.length - 1; index >= 0; index--) { + if (isRealUserMessage(entries[index])) { + start = index; + break; + } + } + return start; +} + +/** 解析常見 transcript timestamp 格式,回傳 epoch 毫秒;失敗回 null。 */ +function parseTimestamp(value) { + if (typeof value !== "string" || !value.trim()) return null; + let raw = value.trim(); + if (!/[zZ]$/.test(raw) && !/[+-]\d{2}:\d{2}$/.test(raw)) { + raw += "Z"; // 沒有時區資訊時視為 UTC,等同 Python 版 tzinfo=timezone.utc 的退回值 + } + const ms = Date.parse(raw); + return Number.isNaN(ms) ? null : ms; +} + +/** 取出 transcript 條目的時間欄位(epoch 毫秒)。 */ +function entryTimestamp(entry) { + for (const key of ["timestamp", "created_at", "time"]) { + const t = parseTimestamp(entry[key]); + if (t !== null) return t; + } + const message = entry.message; + if (message && typeof message === "object") { + for (const key of ["timestamp", "created_at", "time"]) { + const t = parseTimestamp(message[key]); + if (t !== null) return t; + } + } + return null; +} + +/** 等同 Python round():四捨五入時「剛好 .5」採銀行家捨入(就近取偶)。 */ +function pythonRound(x) { + const floor = Math.floor(x); + const diff = x - floor; + if (diff < 0.5) return floor; + if (diff > 0.5) return floor + 1; + return floor % 2 === 0 ? floor : floor + 1; +} + +/** 把秒數格式化為精簡中文耗時。 */ +export function formatDuration(seconds) { + if (seconds < 0) return "未判定"; + const minutes = pythonRound(seconds / 60); + if (minutes <= 0) return "1 分鐘內"; + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + if (hours && mins) return `${hours} 小時 ${mins} 分鐘`; + if (hours) return `${hours} 小時`; + return `${mins} 分鐘`; +} + +/** + * 估算本輪花費時間:取本輪起點到最後一筆可解析 timestamp 的差距。 + * transcript 無時間欄位或本輪少於兩個時間點時回「未判定」,避免臆測。 + */ +export function turnDuration(filePath) { + const entries = readEntries(filePath); + if (!entries.length) return "未判定"; + const start = turnStartIndex(entries); + const stamps = entries + .slice(start) + .map(entryTimestamp) + .filter((t) => t !== null); + if (stamps.length < 2) return "未判定"; + const seconds = (Math.max(...stamps) - Math.min(...stamps)) / 1000; + return formatDuration(seconds); +} + + +/** + * 從 transcript JSONL 抽出本輪內容:最後一筆真正使用者訊息(含該筆)之後的全部條目。 + * 不需任何狀態檔即可界定「本輪」,符合工作內容不落地的要求。 + * 回傳純文字字串;讀取失敗或無內容時回空字串。 + */ +export function extractTurn(filePath) { + const entries = readEntries(filePath); + if (!entries.length) return ""; + const start = turnStartIndex(entries); + + const lines = []; + for (const entry of entries.slice(start)) { + lines.push(...render(entry)); + } + + let text = lines.join("\n").trim(); + const totalLen = codePointLength(text); + if (totalLen > TOTAL_LIMIT) { + const half = Math.floor(TOTAL_LIMIT / 2); + const head = codePointSlice(text, 0, half); + const tail = codePointSlice(text, totalLen - half, totalLen); + text = `${head}\n…(中段省略)…\n${tail}`; + } + return text; +} + +/** 把 token 數格式化為精簡字串:<1000 直接輸出整數,>=1000 輸出一位小數的 k 表示。 */ +export function formatTokens(n) { + if (n < 1000) return String(n); + return `${(n / 1000).toFixed(1)}k`; +} + +/** + * 統計本輪 token 用量,回傳 { input, output }(input 為 null 代表「未判定」, + * 例如 Copilot 逐輪只有 outputTokens);本輪完全找不到任何用量欄位時回 null + * (不可回 { input: 0, output: 0 },否則無法與「真的沒用到 token」區分)。 + * + * 三種格式互斥(依 CLI 各自的 transcript 結構,不會同時出現在同一份檔案), + * 依序嘗試 Claude Code → Codex → Copilot,找到哪種格式的用量欄位就用哪種。 + */ +export function turnTokens(filePath) { + const entries = readEntries(filePath); + if (!entries.length) return null; + const start = turnStartIndex(entries); + const turn = entries.slice(start); + + // Claude Code:entry.type === "assistant",用量在 message.usage。 + // input_tokens 只是「未命中快取的殘量」,必須加上 cache_creation_input_tokens + // 與 cache_read_input_tokens 才是完整輸入量(本機實測曾見 input_tokens:2 但 + // cache_creation_input_tokens:48200 的案例,單獨拿 input_tokens 會嚴重低估)。 + const claudeUsages = turn.filter( + (e) => e.type === "assistant" && e.message && typeof e.message === "object" && e.message.usage && typeof e.message.usage === "object", + ); + if (claudeUsages.length) { + let input = 0; + let output = 0; + for (const e of claudeUsages) { + const u = e.message.usage; + input += Number(u.input_tokens || 0) + Number(u.cache_creation_input_tokens || 0) + Number(u.cache_read_input_tokens || 0); + output += Number(u.output_tokens || 0); + } + return { input, output }; + } + + // Codex:type === "event_msg" 且 payload.type === "token_count",用量在 + // payload.info.last_token_usage。input_tokens 已包含 cached_input_tokens, + // 不可再加一次;reasoning_output_tokens 是 output_tokens 的子集,不另計。 + const codexUsages = turn.filter( + (e) => + e.type === "event_msg" && + e.payload && + typeof e.payload === "object" && + e.payload.type === "token_count" && + e.payload.info && + typeof e.payload.info === "object" && + e.payload.info.last_token_usage, + ); + if (codexUsages.length) { + let input = 0; + let output = 0; + for (const e of codexUsages) { + const u = e.payload.info.last_token_usage; + input += Number(u.input_tokens || 0); + output += Number(u.output_tokens || 0); + } + return { input, output }; + } + + // Copilot:type === "assistant.message",只有 outputTokens,輸入固定「未判定」 + // (依使用者裁示:不得改讀 session.shutdown.modelMetrics,那是整個 session + // 結束才寫的累計值,語意不是「本輪」)。 + const copilotUsages = turn.filter((e) => e.type === "assistant.message" && e.data && typeof e.data === "object" && typeof e.data.outputTokens === "number"); + if (copilotUsages.length) { + let output = 0; + for (const e of copilotUsages) output += Number(e.data.outputTokens || 0); + return { input: null, output }; + } + + return null; +} + +/** 把 turnTokens() 的結果格式化為條目用字串;找不到用量時回「未判定」。 */ +export function formatTokenLine(usage) { + if (!usage) return "未判定"; + const inputStr = usage.input === null || usage.input === undefined ? "未判定" : formatTokens(usage.input); + const outputStr = formatTokens(usage.output); + return `輸入 ${inputStr}/輸出 ${outputStr}`; +} + +const USAGE = `用法:transcript.mjs <子命令> [參數] + + extract 抽出本輪內容並遮蔽機密後輸出到 stdout + duration 估算本輪花費時間,無法判定時輸出「未判定」 + tokens 統計本輪 token 用量(輸入/輸出),無法判定時輸出「未判定」 + redact 自 stdin 讀取文字,遮蔽機密後輸出到 stdout +`; + +function readStdin() { + try { + return fs.readFileSync(0, "utf8"); + } catch { + return ""; + } +} + +/** CLI 進入點:解析子命令並執行抽取、估時、統計 token 或遮蔽。 */ +function main(argv) { + if (!argv.length || argv[0] === "-h" || argv[0] === "--help") { + process.stdout.write(USAGE); + return 0; + } + if (argv[0] === "extract") { + if (argv.length < 2) return 2; + const text = extractTurn(argv[1]); + if (!text) return 1; + process.stdout.write(redact(text)); + return 0; + } + if (argv[0] === "duration") { + if (argv.length < 2) return 2; + process.stdout.write(turnDuration(argv[1])); + return 0; + } + if (argv[0] === "tokens") { + if (argv.length < 2) return 2; + process.stdout.write(formatTokenLine(turnTokens(argv[1]))); + return 0; + } + if (argv[0] === "redact") { + process.stdout.write(redact(readStdin())); + return 0; + } + process.stdout.write(USAGE); + return 2; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + process.exit(main(process.argv.slice(2))); +} diff --git a/scripts/worklog/transcript.py b/scripts/worklog/transcript.py deleted file mode 100755 index 1b27750..0000000 --- a/scripts/worklog/transcript.py +++ /dev/null @@ -1,317 +0,0 @@ -#!/usr/bin/env python3 -# ============================================================================== -# 用途:worklog 的 transcript 處理工具。負責 (1) 從 Claude Code/Codex -# JSONL 抽出「本輪」對話片段(最後一筆使用者訊息之後的全部內容), -# (2) 估算本輪花費時間,(3) 對文字做機密遮蔽(token/密碼/PII), -# 作為寫入 wiki 前的第二道防線。本檔 REDACT_PATTERNS 現為 -# /jsc-shared:spec-gitea『機密遮蔽實作』章節的來源依據(其他工具的 -# 機密遮蔽規則以該章節為準)。 -# 更新時間:2026/07/27 22:16:00 -# 相依:Python 3 標準庫。全程僅走 stdin/stdout,不寫任何檔案。 -# ============================================================================== - -import json -import re -import sys -from datetime import datetime, timezone - -# 單則工具結果/參數的擷取上限,避免整份 transcript 塞進摘要輸入 -TOOL_RESULT_LIMIT = 200 -TOOL_INPUT_LIMIT = 160 -TOTAL_LIMIT = 24000 - -# ------------------------------------------------------------------------------ -# 機密遮蔽規則:命中一律換成 *** -# ------------------------------------------------------------------------------ -# 本檔遮蔽規則對應 shared/scripts/lib/redact-patterns.json(經 /jsc-shared:spec-gitea 收斂) -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 條目是否為真正的使用者輸入(排除工具回填與環境注入)。""" - payload = entry.get("payload") - if isinstance(payload, dict) and entry.get("type") == "event_msg": - return payload.get("type") == "user_message" and bool(str(payload.get("message") or "").strip()) - - 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 _payload_text_blocks(content): - """把 Codex response_item 的 content blocks 轉成純文字片段。""" - if isinstance(content, str): - return [content] - if not isinstance(content, list): - return [] - texts = [] - for block in content: - if not isinstance(block, dict): - continue - if block.get("type") in ("input_text", "output_text", "text"): - text = (block.get("text") or "").strip() - if text: - texts.append(text) - return texts - - -def _render_codex_payload(entry): - """將 Codex session JSONL 的 payload 格式轉為摘要輸入用純文字。""" - payload = entry.get("payload") - if not isinstance(payload, dict): - return [] - - lines = [] - entry_type = entry.get("type") - payload_type = payload.get("type") - - if entry_type == "event_msg": - if payload_type == "user_message": - message = (payload.get("message") or "").strip() - if message: - lines.append(f"[user] {message}") - elif payload_type == "agent_message": - message = (payload.get("message") or "").strip() - if message: - phase = payload.get("phase") or "assistant" - lines.append(f"[assistant:{phase}] {message}") - return lines - - if entry_type != "response_item": - return lines - - if payload_type == "message": - role = payload.get("role") or "assistant" - if role in ("system", "developer"): - return lines - for text in _payload_text_blocks(payload.get("content")): - # Codex 會把 skill 內容以 user role 注入;避免把整份 SKILL.md 當成本輪工作。 - if role == "user" and text.lstrip().startswith(""): - continue - if role == "user" and text.lstrip().startswith(""): - continue - lines.append(f"[{role}] {text}") - elif payload_type == "function_call": - name = payload.get("name") or "?" - raw = str(payload.get("arguments") or "").strip().replace("\n", " ") - lines.append(f"[tool:{name}] {raw[:TOOL_INPUT_LIMIT]}") - elif payload_type == "function_call_output": - raw = str(payload.get("output") or "").strip().replace("\n", " ") - if raw: - lines.append(f"[result] {raw[:TOOL_RESULT_LIMIT]}") - - return lines - - -def _render(entry): - """將單一 transcript 條目轉為摘要輸入用的純文字行(工具結果僅取前段)。""" - codex_lines = _render_codex_payload(entry) - if codex_lines: - return codex_lines - - 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 _read_entries(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 [] - return entries - - -def _turn_start_index(entries): - """找出本輪起點:最後一筆真正使用者訊息的位置。""" - start = 0 - for index in range(len(entries) - 1, -1, -1): - if _is_real_user_message(entries[index]): - start = index - break - return start - - -def _parse_timestamp(value): - """解析常見 transcript timestamp 格式,失敗回 None。""" - if not isinstance(value, str) or not value.strip(): - return None - raw = value.strip() - if raw.endswith("Z"): - raw = raw[:-1] + "+00:00" - try: - dt = datetime.fromisoformat(raw) - except ValueError: - return None - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - return dt - - -def _entry_timestamp(entry): - """取出 transcript 條目的時間欄位。""" - for key in ("timestamp", "created_at", "time"): - dt = _parse_timestamp(entry.get(key)) - if dt: - return dt - message = entry.get("message") - if isinstance(message, dict): - for key in ("timestamp", "created_at", "time"): - dt = _parse_timestamp(message.get(key)) - if dt: - return dt - return None - - -def format_duration(seconds): - """把秒數格式化為精簡中文耗時。""" - if seconds < 0: - return "未判定" - minutes = int(round(seconds / 60)) - if minutes <= 0: - return "1 分鐘內" - hours, mins = divmod(minutes, 60) - if hours and mins: - return f"{hours} 小時 {mins} 分鐘" - if hours: - return f"{hours} 小時" - return f"{mins} 分鐘" - - -def turn_duration(path): - """ - 估算本輪花費時間:取本輪起點到最後一筆可解析 timestamp 的差距。 - - transcript 無時間欄位或本輪少於兩個時間點時回「未判定」,避免臆測。 - """ - entries = _read_entries(path) - if not entries: - return "未判定" - start = _turn_start_index(entries) - stamps = [dt for dt in (_entry_timestamp(e) for e in entries[start:]) if dt] - if len(stamps) < 2: - return "未判定" - return format_duration((max(stamps) - min(stamps)).total_seconds()) - - -def extract_turn(path): - """ - 從 transcript JSONL 抽出本輪內容:最後一筆真正使用者訊息(含該筆)之後的全部條目。 - - 不需任何狀態檔即可界定「本輪」,符合工作內容不落地的要求。 - 回傳純文字字串;讀取失敗或無內容時回空字串。 - """ - entries = _read_entries(path) - if not entries: - return "" - start = _turn_start_index(entries) - - - 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 - duration 估算本輪花費時間,無法判定時輸出「未判定」 - 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] == "duration": - if len(argv) < 2: - return 2 - sys.stdout.write(turn_duration(argv[1])) - 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.mjs b/scripts/worklog/wiki_api.mjs new file mode 100644 index 0000000..7a6dbbb --- /dev/null +++ b/scripts/worklog/wiki_api.mjs @@ -0,0 +1,549 @@ +#!/usr/bin/env node +// ============================================================================== +// 用途:Gitea Wiki 讀寫工具(worklog 專用)。提供 token 解析、頁面讀取、 +// 建立、append 追加(read-modify-write + 寫後驗證重試),供 worklog.sh +// 與 /jsc-doc:worklog skill 共用,避免兩份實作漂移。 +// resolveToken() 的優先序實作對應 /jsc-shared:spec-gitea『token 解析 +// 優先序』章節。mask() 已與 transcript.mjs 的 REDACT_PATTERNS(對應 +// spec-gitea『機密遮蔽實作』章節)整併:精準抹除已知 secret 值後, +// 再套用同一份通用格式規則,避免兩份遮蔽規則各自漂移。 +// 本檔為 wiki_api.py 的等價 Node 移植:page-name/week 系列日期運算 +// 已對拍 Python 版逐字元相同;probe/pages/show/append 已對本機 +// 架設的假 Gitea API 伺服器驗證行為一致(詳見 G1-3 驗收記錄),未對 +// 正式環境的真實 wiki 資料做寫入測試。 +// 更新時間:2026/08/11 16:51:56 +// 相依:Node.js 標準內建功能(node:https、node:zlib 皆不需要),無外部套件。 +// 機密:token 一律從環境變數或本機憑證檔讀取,絕不輸出、絕不寫入任何檔案。 +// ============================================================================== + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import https from "node:https"; +import { URL } from "node:url"; +import { redact as redactPatterns } from "./transcript.mjs"; + +const TAIPEI_OFFSET_MS = 8 * 60 * 60 * 1000; + +/** 取得 Asia/Taipei 時區的 yyyy/MM/dd HH:mm:ss 時間字串(等同 persona-lib.mjs 的 nowDisplay() 手法)。 */ +export function nowStr(date = new Date()) { + const shifted = new Date(Math.floor(date.getTime() / 1000) * 1000 + TAIPEI_OFFSET_MS); + const pad = (n) => String(n).padStart(2, "0"); + return `${shifted.getUTCFullYear()}/${pad(shifted.getUTCMonth() + 1)}/${pad(shifted.getUTCDate())} ${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}`; +} + +/** 輸出統一格式訊息([時間][階段][等級]: 訊息,一行一則),一律走 stderr 不污染 stdout。 */ +export function log(level, message, stage = "wiki_api") { + process.stderr.write(`[${nowStr()}][${stage}][${level}]: ${message}\n`); +} + +/** + * 遮蔽字串中的機密。先精準抹除已知的 secret 值(涵蓋不符合任何通用格式的 + * token,例如 tea 設定檔內的自訂字串),再套用 transcript.mjs 的 REDACT_PATTERNS + * (對應 /jsc-shared:spec-gitea『機密遮蔽實作』章節,同一份規則的唯一權威來源) + * 做第二層通用格式掃描,避免 wiki_api.mjs 自己維護一份會漂移的規則。 + */ +export function mask(text, secret) { + let out = text; + if (secret) out = out.split(secret).join("***"); + return redactPatterns(out); +} + +// ------------------------------------------------------------------------------ +// token 解析:GITEA_TOKEN → tea config → git-credentials +// ------------------------------------------------------------------------------ + +function expandHome(p) { + return p.startsWith("~/") ? path.join(os.homedir(), p.slice(2)) : p; +} + +/** 從 tea 設定檔取出指定 host 的 token(找不到回 null)。 */ +function tokenFromTea(host) { + for (const candidate of ["~/.config/tea/config.yml", "~/.tea/config.yml"]) { + const f = expandHome(candidate); + if (!fs.existsSync(f)) continue; + let raw; + try { + raw = fs.readFileSync(f, "utf8"); + } catch { + continue; + } + for (const block of raw.split(/^\s*-\s+name:/m)) { + if (!block.includes(host)) continue; + const m = block.match(/^\s*token:\s*["']?([A-Za-z0-9_-]+)/m); + if (m) return m[1]; + } + } + return null; +} + +/** 從 ~/.git-credentials(credential.helper=store)取出指定 host 的密碼作為 token。 */ +function tokenFromGitCredentials(host) { + const f = expandHome("~/.git-credentials"); + if (!fs.existsSync(f)) return null; + let lines; + try { + lines = fs.readFileSync(f, "utf8").split("\n"); + } catch { + return null; + } + for (const line of lines) { + const m = line.trim().match(/^https?:\/\/([^:]+):([^@]+)@(.+)$/); + if (m && m[3] === host) return decodeURIComponent(m[2]); + } + return null; +} + +/** + * 依固定優先序解析可用 token,並以 GET /repos/ 實際驗證權限。 + * 優先序:GITEA_TOKEN → tea 設定檔該 host 的 token → ~/.git-credentials。 + * 回傳 [token, 來源說明];全部失敗回 [null, 說明]。 + */ +export async function resolveToken(host, repo) { + const candidates = []; + const env = process.env.GITEA_TOKEN; + if (env) candidates.push([env, "GITEA_TOKEN"]); + const tea = tokenFromTea(host); + if (tea && tea !== env) candidates.push([tea, "tea 設定檔"]); + const cred = tokenFromGitCredentials(host); + if (cred && cred !== env && cred !== tea) candidates.push([cred, "git-credentials"]); + + if (!candidates.length) return [null, "找不到任何可用憑證來源"]; + + for (const [token, source] of candidates) { + const [code] = await request("GET", `https://${host}/api/v1/repos/${repo}`, token, null); + if (code === 200) return [token, source]; + log("DBG", `${source} 對 ${host} 驗證失敗(HTTP ${code}),改試下一個來源`); + } + return [null, `${candidates.length} 個憑證來源全部驗證失敗`]; +} + +// ------------------------------------------------------------------------------ +// HTTP +// ------------------------------------------------------------------------------ + +/** + * 發出 Gitea API 請求,回傳 [HTTP 狀態碼, 回應內文字串]。網路層錯誤以 0 表示。 + * 本函式實作 /jsc-shared:spec-gitea 的『API 呼叫慣例』章節: + * Authorization 標頭帶 `token `、payload 以 UTF-8 JSON 編碼。 + */ +function request(method, url, token, payload) { + return new Promise((resolve) => { + let data = null; + if (payload !== null && payload !== undefined) { + data = Buffer.from(JSON.stringify(payload), "utf8"); + } + let target; + try { + target = new URL(url); + } catch (err) { + resolve([0, String(err.message || err)]); + return; + } + const headers = { + Authorization: `token ${token}`, + Accept: "application/json", + }; + if (data) { + headers["Content-Type"] = "application/json"; + headers["Content-Length"] = String(data.length); + } + const req = https.request( + { + method, + hostname: target.hostname, + port: target.port || 443, + path: target.pathname + target.search, + headers, + timeout: 30000, + }, + (res) => { + const chunks = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => { + resolve([res.statusCode, Buffer.concat(chunks).toString("utf8")]); + }); + }, + ); + req.on("timeout", () => { + req.destroy(new Error("timeout")); + }); + req.on("error", (err) => { + resolve([0, String(err.message || err)]); + }); + if (data) req.write(data); + req.end(); + }); +} + +/** + * 組出 repo 層級的 wiki API base URL。 + * 本函式實作 /jsc-shared:spec-gitea 的『API 呼叫慣例』章節:base 為 + * `https:///api/v1/repos//` 之下的 wiki 路徑。 + */ +function apiBase(host, repo) { + return `https://${host}/api/v1/repos/${repo}/wiki`; +} + +// ------------------------------------------------------------------------------ +// wiki 操作 +// ------------------------------------------------------------------------------ + +/** + * 列出 wiki 全部頁面(分頁完整讀取),回傳 [狀態, 頁面清單]。 + * 狀態為 'ok'/'missing'(wiki 尚未初始化)/'error'。清單元素含 title 與 sub_url。 + */ +export async function listPages(host, repo, token) { + const pages = []; + let pageNo = 1; + const limit = 50; + for (;;) { + const url = `${apiBase(host, repo)}/pages?page=${pageNo}&limit=${limit}`; + const [code, body] = await request("GET", url, token, null); + if (code === 404) return ["missing", []]; + if (code !== 200) return ["error", []]; + let batch; + try { + batch = JSON.parse(body); + } catch { + return ["error", []]; + } + if (!Array.isArray(batch)) return ["error", []]; + pages.push(...batch); + if (batch.length < limit) return ["ok", pages]; + pageNo += 1; + } +} + +/** + * 以 title 查出 Gitea 實際的 sub_url。 + * Gitea wiki 會對 title 做轉義(`-` 代表空格,實際 dash 另有轉義形式,例如 + * title `Worklog-2026-07-W4` 的 sub_url 為 `Worklog-2026-07-W4.-`),因此讀寫 + * 一律以查表得到的 sub_url 為準,不自行猜測轉義規則。 + * 本函式實作 /jsc-shared:spec-gitea 的『Wiki 頁名轉義規則』第 2 點: + * 以 `GET /wiki/pages` 查表找 sub_url,不用字串取代規則反推。找不到回 null。 + */ +export async function resolveSubUrl(host, repo, token, title) { + const [status, pages] = await listPages(host, repo, token); + if (status !== "ok") return null; + for (const item of pages) { + if (item.title === title) return item.sub_url || title; + } + return null; +} + +/** + * 讀取 wiki 頁面內容(page 可傳 title 或 sub_url,內部會自動解析)。 + * 回傳 [狀態, 內容字串];狀態為 'ok'(存在)、'missing'(404)、'error'(其他失敗, + * 內容為遮蔽後的錯誤訊息)。 + */ +export async function getPage(host, repo, token, page) { + const subUrl = (await resolveSubUrl(host, repo, token, page)) || page; + const url = `${apiBase(host, repo)}/page/${encodeURIComponent(subUrl)}`; + const [code, body] = await request("GET", url, token, null); + if (code === 404) return ["missing", ""]; + if (code !== 200) return ["error", mask(`HTTP ${code} ${body.slice(0, 200)}`, token)]; + let data; + try { + data = JSON.parse(body); + } catch { + return ["error", "回應不是合法 JSON"]; + } + const raw = data.content_base64 || ""; + try { + return ["ok", Buffer.from(raw, "base64").toString("utf8")]; + } catch { + return ["error", "content_base64 解碼失敗"]; + } +} + +/** 建立新的 wiki 頁面(wiki 尚未初始化時亦由此初始化)。回傳 [是否成功, 訊息]。 */ +export async function createPage(host, repo, token, page, content, message) { + const url = `${apiBase(host, repo)}/new`; + const payload = { + title: page, + content_base64: Buffer.from(content, "utf8").toString("base64"), + message, + }; + const [code, body] = await request("POST", url, token, payload); + if (code === 201 || code === 200) return [true, `已建立頁面 ${page}`]; + return [false, mask(`建立頁面失敗 HTTP ${code} ${body.slice(0, 200)}`, token)]; +} + +/** 刪除 wiki 頁面(page 可傳 title 或 sub_url)。回傳 [是否成功, 訊息]。 */ +export async function deletePage(host, repo, token, page) { + const subUrl = (await resolveSubUrl(host, repo, token, page)) || page; + const url = `${apiBase(host, repo)}/page/${encodeURIComponent(subUrl)}`; + const [code, body] = await request("DELETE", url, token, null); + if (code === 204 || code === 200) return [true, `已刪除頁面 ${page}`]; + return [false, mask(`刪除頁面失敗 HTTP ${code} ${body.slice(0, 200)}`, token)]; +} + +/** 整頁覆寫既有 wiki 頁面(append 由呼叫端先合併內容)。回傳 [是否成功, 訊息]。 */ +export async function updatePage(host, repo, token, page, content, message) { + const subUrl = (await resolveSubUrl(host, repo, token, page)) || page; + const url = `${apiBase(host, repo)}/page/${encodeURIComponent(subUrl)}`; + const payload = { + title: page, + content_base64: Buffer.from(content, "utf8").toString("base64"), + message, + }; + const [code, body] = await request("PATCH", url, token, payload); + if (code === 200 || code === 201) return [true, `已更新頁面 ${page}`]; + return [false, mask(`更新頁面失敗 HTTP ${code} ${body.slice(0, 200)}`, token)]; +} + +/** + * 將條目追加到週頁尾端:讀取現有內容 → 合併 → 寫回 → 寫後讀取驗證。 + * marker 為條目內唯一字串(時間戳+session 短碼),用於驗證自己的內容確實落地; + * 多個 session 同時寫入時,驗證失敗會重讀最新內容重試,避免互相覆蓋。 + * 回傳 [是否成功, 訊息]。 + */ +export async function appendEntry(host, repo, token, page, header, entry, marker, retries = 3) { + for (let attempt = 1; attempt <= retries; attempt++) { + const [status, current] = await getPage(host, repo, token, page); + if (status === "error") return [false, `讀取頁面失敗:${current}`]; + + if (status === "missing") { + const content = `${header}\n\n${entry}\n`; + const [ok, msg] = await createPage(host, repo, token, page, content, `worklog: 建立 ${page}`); + if (!ok) { + log("WRN", `第 ${attempt} 次建立失敗:${msg}`); + continue; + } + } else { + if (current.includes(marker)) return [true, "條目已存在,無需重複寫入"]; + let body = current.replace(/\n+$/, ""); + if (!body) body = header; + const content = `${body}\n\n${entry}\n`; + const [ok, msg] = await updatePage(host, repo, token, page, content, `worklog: 追加 ${marker}`); + if (!ok) { + log("WRN", `第 ${attempt} 次寫入失敗:${msg}`); + continue; + } + } + + const [verifyStatus, verifyContent] = await getPage(host, repo, token, page); + if (verifyStatus === "ok" && verifyContent.includes(marker)) { + return [true, `條目已寫入 ${page}(第 ${attempt} 次嘗試)`]; + } + log("WRN", `第 ${attempt} 次寫後驗證未找到條目,準備重試`); + } + + return [false, `重試 ${retries} 次仍未成功寫入 ${page}`]; +} + +// ------------------------------------------------------------------------------ +// 週頁命名 +// ------------------------------------------------------------------------------ + +// 週的定義:星期六起算(六~五),週頁以該週起始的星期六為錨點命名。 +// 舊規則以 ceil(日/7) 分週,換頁點固定落在每月 8/15/22/29 號,會把同一個工作週 +// 切成兩頁(例:2026/07/28 二 在 W4、07/29 三 卻跳到 W5),使用者開著舊頁會誤判成 +// 「worklog 停止記錄」。改以星期六為界後,換頁一律發生在週六,與星期對齊。 +const WEEK_START_WEEKDAY = 6; // JS Date#getUTCDay():週日 0、週一 1 …… 週五 5、週六 6 + +/** 依台北時區「牆上時間」建立一個可用 UTC getter 讀取的 Date(等同 persona-lib.mjs 的位移手法)。 */ +function taipeiWallClock(date = new Date()) { + return new Date(Math.floor(date.getTime() / 1000) * 1000 + TAIPEI_OFFSET_MS); +} + +/** 把「台北牆上時間」的 Date 換回真實 epoch(taipeiWallClock 的逆運算)。 */ +function fromTaipeiWallClock(wall) { + return new Date(wall.getTime() - TAIPEI_OFFSET_MS); +} + +/** 取得指定時間所屬工作週的起始日(該週的星期六;當天就是星期六時回傳當天),回傳台北牆上時間 Date。 */ +export function weekStart(when) { + const wall = when ? taipeiWallClock(when) : taipeiWallClock(); + const diff = (wall.getUTCDay() - WEEK_START_WEEKDAY + 7) % 7; + const start = new Date(wall); + start.setUTCDate(start.getUTCDate() - diff); + start.setUTCHours(0, 0, 0, 0); + return start; +} + +/** + * 從週頁名稱反推該週起始的星期六(回傳台北牆上時間 Date)。 + * 供手動指定頁面時產生正確標題;格式不符或該月不存在第 n 個星期六時回傳 null。 + */ +export function weekStartFromPage(page) { + if (!page) return null; + const m = String(page).trim().match(/^Worklog-(\d{4})-(\d{2})-W(\d)$/); + if (!m) return null; + const year = Number(m[1]); + const month = Number(m[2]); + const week = Number(m[3]); + const firstDay = new Date(Date.UTC(year, month - 1, 1)); + const diff = (WEEK_START_WEEKDAY - firstDay.getUTCDay() + 7) % 7; + const firstSaturday = new Date(firstDay); + firstSaturday.setUTCDate(firstSaturday.getUTCDate() + diff); + const start = new Date(firstSaturday); + start.setUTCDate(start.getUTCDate() + 7 * (week - 1)); + return start.getUTCMonth() === month - 1 ? start : null; +} + +/** + * 依台灣時區產生週頁名稱 Worklog-yyyy-MM-W。 + * 週以星期六起算(六~五),n =該週起始的星期六是當月第幾個星期六。 + * 跨月的一週歸屬起始星期六所在的月份,確保同一週只會有一頁 + * (例:2026/08/29 六 ~ 09/04 五 都寫入 Worklog-2026-08-W5)。 + */ +export function weekPageName(when) { + const start = weekStart(when); + const week = Math.floor((start.getUTCDate() - 1) / 7) + 1; + const pad = (n) => String(n).padStart(2, "0"); + return `Worklog-${start.getUTCFullYear()}-${pad(start.getUTCMonth() + 1)}-W${week}`; +} + +/** + * 產生週頁首行標題(例:# 2026 年 07 月 第 4 週工作紀錄(07/25 六 ~ 07/31 五))。 + * 標題含日期範圍,讓開頁的人一眼看出這頁涵蓋哪幾天,不必回頭推算週次。 + * 傳入 page 時以頁名反推所屬週,避免手動補寫舊頁時寫入當下這週的標題。 + */ +export function weekPageHeader(page, when) { + const start = weekStartFromPage(page) || weekStart(when); + const end = new Date(start); + end.setUTCDate(end.getUTCDate() + 6); + const week = Math.floor((start.getUTCDate() - 1) / 7) + 1; + const pad = (n) => String(n).padStart(2, "0"); + return ( + `# ${start.getUTCFullYear()} 年 ${pad(start.getUTCMonth() + 1)} 月 第 ${week} 週工作紀錄` + + `(${pad(start.getUTCMonth() + 1)}/${pad(start.getUTCDate())} 六 ~ ${pad(end.getUTCMonth() + 1)}/${pad(end.getUTCDate())} 五)` + ); +} + +// ------------------------------------------------------------------------------ +// CLI +// ------------------------------------------------------------------------------ + +const USAGE = `用法:wiki_api.mjs <子命令> [參數] + + probe 檢查 host/repo/token/wiki API 可用性 + page-name 印出當週頁面名稱 + pages 列出全部頁面(title 與實際 sub_url) + show [頁面] 印出指定頁面內容(預設當週頁) + append [頁面] 自 stdin 讀取條目內容並追加(預設當週頁) + init [頁面] 若當週頁不存在則建立(僅含標題) + delete <頁面> 刪除指定頁面 + +環境變數:WORKLOG_HOST(必要)、WORKLOG_REPO(必要)、GITEA_TOKEN(選用,會自動 fallback) +`; + +function readStdin() { + try { + return fs.readFileSync(0, "utf8"); + } catch { + return ""; + } +} + +/** 讀取並檢查必要環境變數,回傳 [host, repo];缺少時結束程式。 */ +function readEnv() { + const host = (process.env.WORKLOG_HOST || "").trim(); + const repo = (process.env.WORKLOG_REPO || "").trim(); + if (!host || !repo) { + log("ERR", "缺少 WORKLOG_HOST 或 WORKLOG_REPO"); + process.exit(2); + } + return [host, repo]; +} + +/** CLI 進入點:解析子命令並執行對應 wiki 操作。 */ +async function main(argv) { + if (!argv.length || argv[0] === "-h" || argv[0] === "--help") { + process.stdout.write(USAGE); + return 0; + } + + const cmd = argv[0]; + + if (cmd === "page-name") { + process.stdout.write(`${weekPageName()}\n`); + return 0; + } + + const [host, repo] = readEnv(); + const [token, source] = await resolveToken(host, repo); + if (!token) { + log("ERR", `無可用 token:${source}`); + return 2; + } + + if (cmd === "probe") { + log("INF", `token 來源:${source}`); + const [code, body] = await request("GET", `https://${host}/api/v1/version`, token, null); + log("INF", `Gitea 版本查詢 HTTP ${code} ${body.slice(0, 80)}`); + const [status] = await getPage(host, repo, token, weekPageName()); + log("INF", `當週頁 ${weekPageName()} 狀態:${status}`); + return 0; + } + + if (cmd === "pages") { + const [status, pages] = await listPages(host, repo, token); + if (status !== "ok") { + log(status === "missing" ? "WRN" : "ERR", `頁面清單狀態:${status}`); + return status === "missing" ? 0 : 1; + } + for (const item of pages) process.stdout.write(`${item.title}\t${item.sub_url}\n`); + return 0; + } + + if (cmd === "delete") { + if (argv.length < 2) { + log("ERR", "delete 需要頁面名稱"); + return 2; + } + const [ok, msg] = await deletePage(host, repo, token, argv[1]); + log(ok ? "INF" : "ERR", msg); + return ok ? 0 : 1; + } + + if (cmd === "show") { + const page = argv.length > 1 ? argv[1] : weekPageName(); + const [status, content] = await getPage(host, repo, token, page); + if (status === "ok") { + process.stdout.write(`${content}\n`); + return 0; + } + log(status === "missing" ? "WRN" : "ERR", `頁面 ${page} 狀態:${status} ${content}`); + return status === "missing" ? 0 : 1; + } + + if (cmd === "init") { + const page = argv.length > 1 ? argv[1] : weekPageName(); + const [status] = await getPage(host, repo, token, page); + if (status === "ok") { + log("INF", `頁面 ${page} 已存在,不重建`); + return 0; + } + const [ok, msg] = await createPage(host, repo, token, page, `${weekPageHeader(page)}\n`, `worklog: 初始化 ${page}`); + log(ok ? "INF" : "ERR", msg); + return ok ? 0 : 1; + } + + if (cmd === "append") { + if (argv.length < 2) { + log("ERR", "append 需要 marker 參數"); + return 2; + } + const marker = argv[1]; + const page = argv.length > 2 ? argv[2] : weekPageName(); + const entry = readStdin().trim(); + if (!entry) { + log("WRN", "條目內容為空,不寫入"); + return 0; + } + const [ok, msg] = await appendEntry(host, repo, token, page, weekPageHeader(page), entry, marker); + log(ok ? "INF" : "ERR", msg); + return ok ? 0 : 1; + } + + log("ERR", `未知子命令:${cmd}`); + process.stdout.write(USAGE); + return 2; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main(process.argv.slice(2)).then((code) => process.exit(code)); +} diff --git a/scripts/worklog/wiki_api.py b/scripts/worklog/wiki_api.py deleted file mode 100755 index 037a188..0000000 --- a/scripts/worklog/wiki_api.py +++ /dev/null @@ -1,499 +0,0 @@ -#!/usr/bin/env python3 -# ============================================================================== -# 用途:Gitea Wiki 讀寫工具(worklog 專用)。提供 token 解析、頁面讀取、 -# 建立、append 追加(read-modify-write + 寫後驗證重試),供 worklog.sh -# 與 /jsc-doc:worklog skill 共用,避免兩份實作漂移。 -# resolve_token() 的優先序實作對應 /jsc-shared:spec-gitea『token 解析 -# 優先序』章節。 -# 更新時間:2026/07/29 19:03:29 -# 相依: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 -from zoneinfo import ZoneInfo - -TAIPEI = ZoneInfo("Asia/Taipei") - - -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 表示。 - - 本函式實作 /jsc-shared:spec-gitea 的『API 呼叫慣例』章節: - Authorization 標頭帶 `token `、payload 以 UTF-8 JSON 編碼。 - """ - 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。 - - 本函式實作 /jsc-shared:spec-gitea 的『API 呼叫慣例』章節:base 為 - `https:///api/v1/repos//` 之下的 wiki 路徑。 - """ - 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 為準,不自行猜測轉義規則。 - - 本函式實作 /jsc-shared:spec-gitea 的『Wiki 頁名轉義規則』第 2 點: - 以 `GET /wiki/pages` 查表找 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}" - - -# ------------------------------------------------------------------------------ -# 週頁命名 -# ------------------------------------------------------------------------------ - -# 週的定義:星期六起算(六~五),週頁以該週起始的星期六為錨點命名。 -# 舊規則以 ceil(日/7) 分週,換頁點固定落在每月 8/15/22/29 號,會把同一個工作週 -# 切成兩頁(例:2026/07/28 二 在 W4、07/29 三 卻跳到 W5),使用者開著舊頁會誤判成 -# 「worklog 停止記錄」。改以星期六為界後,換頁一律發生在週六,與星期對齊。 -WEEK_START_WEEKDAY = 5 # Python weekday():週一 0、週二 1 …… 週六 5、週日 6 - - -def week_start(when=None): - """取得指定時間所屬工作週的起始日(該週的星期六;當天就是星期六時回傳當天)。""" - when = when or datetime.now(TAIPEI) - return when - timedelta(days=(when.weekday() - WEEK_START_WEEKDAY) % 7) - - -def week_start_from_page(page): - """ - 從週頁名稱反推該週起始的星期六。 - - 供手動指定頁面時產生正確標題;格式不符或該月不存在第 n 個星期六時回傳 None。 - """ - if not page: - return None - matched = re.match(r"^Worklog-(\d{4})-(\d{2})-W(\d)$", page.strip()) - if not matched: - return None - year, month, week = (int(matched.group(i)) for i in (1, 2, 3)) - try: - first_day = datetime(year, month, 1, tzinfo=TAIPEI) - except ValueError: - return None - first_saturday = first_day + timedelta(days=(WEEK_START_WEEKDAY - first_day.weekday()) % 7) - start = first_saturday + timedelta(days=7 * (week - 1)) - return start if start.month == month else None - - -def week_page_name(when=None): - """ - 依台灣時區產生週頁名稱 Worklog-yyyy-MM-W。 - - 週以星期六起算(六~五),n =該週起始的星期六是當月第幾個星期六。 - 跨月的一週歸屬起始星期六所在的月份,確保同一週只會有一頁 - (例:2026/08/29 六 ~ 09/04 五 都寫入 Worklog-2026-08-W5)。 - """ - start = week_start(when) - week = (start.day - 1) // 7 + 1 - return f"Worklog-{start.year:04d}-{start.month:02d}-W{week}" - - -def week_page_header(page=None, when=None): - """ - 產生週頁首行標題(例:# 2026 年 07 月 第 4 週工作紀錄(07/25 六 ~ 07/31 五))。 - - 標題含日期範圍,讓開頁的人一眼看出這頁涵蓋哪幾天,不必回頭推算週次。 - 傳入 page 時以頁名反推所屬週,避免手動補寫舊頁時寫入當下這週的標題。 - """ - start = week_start_from_page(page) or week_start(when) - end = start + timedelta(days=6) - week = (start.day - 1) // 7 + 1 - return ( - f"# {start.year} 年 {start.month:02d} 月 第 {week} 週工作紀錄" - f"({start.month:02d}/{start.day:02d} 六 ~ {end.month:02d}/{end.day:02d} 五)" - ) - - -# ------------------------------------------------------------------------------ -# 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 index 4362ec5..e24a06f 100755 --- a/scripts/worklog/worklog.sh +++ b/scripts/worklog/worklog.sh @@ -3,8 +3,8 @@ # 用途:工作證明自動記錄(worklog)。由支援 hook 的 CLI 觸發, # 抽出本輪工作內容 → 呼叫已安裝 CLI 濃縮成精簡條目 → 機密遮蔽 → # 追加到 Gitea wiki 的當週工作紀錄頁。工作內容全程不落地。 -# 更新時間:2026/07/27 17:27:54 -# 相依:python3、README 定義的任一 headless CLI、curl(wiki 走 Python urllib,不需 curl 亦可)。 +# 更新時間:2026/08/11 16:51:56 +# 相依:node(transcript.mjs/wiki_api.mjs)、README 定義的任一 headless CLI。 # 機密:token 僅由環境變數/本機憑證讀取,不 echo、不寫檔;輸出前套用遮蔽規則。 # 退出碼:一律 0 —— hook 絕不可阻斷使用者的工作流程。 # ============================================================================== @@ -48,7 +48,7 @@ die_quiet() { [ -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 node >/dev/null 2>&1 || die_quiet "找不到 node,略過記錄" "WRN" select_worklog_cli() { # 依目前 hook/session 環境優先選擇摘要執行器;可用 WORKLOG_CLI 強制指定。 @@ -130,29 +130,34 @@ SUMMARY_CLI="$(select_worklog_cli)" [ -n "$SUMMARY_CLI" ] || exit 0 # ------------------------------------------------------------------------------ -# 讀取 hook 傳入的 JSON(session_id/transcript_path/cwd/stop_hook_active) +# 讀取 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="-" @@ -190,10 +195,12 @@ fi # ------------------------------------------------------------------------------ # 抽出本輪內容(最後一筆使用者訊息之後),並先做一次機密遮蔽 # ------------------------------------------------------------------------------ -TURN="$(python3 "${SCRIPT_DIR}/transcript.py" extract "$TRANSCRIPT_PATH" 2>/dev/null)" +TURN="$(node "${SCRIPT_DIR}/transcript.mjs" extract "$TRANSCRIPT_PATH" 2>/dev/null)" [ -n "$TURN" ] || die_quiet "本輪無可記錄內容" -DURATION="$(python3 "${SCRIPT_DIR}/transcript.py" duration "$TRANSCRIPT_PATH" 2>/dev/null)" +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 使用各自預設模型 @@ -232,21 +239,24 @@ PROMPT="$(cat </dev/null)" +SUMMARY="$(printf '%s' "$SUMMARY" | node "${SCRIPT_DIR}/transcript.mjs" redact 2>/dev/null)" # 只保留 bullet 行,避免模型帶出多餘敘述 -SUMMARY="$(printf '%s\n' "$SUMMARY" | grep -E '^\s*[-*]\s+' | sed -E 's/^\s*[*]/-/' | head -6)" +SUMMARY="$(printf '%s\n' "$SUMMARY" | grep -E '^\s*[-*]\s+' | sed -E 's/^\s*[*]/-/' | head -7)" [ -n "$SUMMARY" ] || die_quiet "摘要不含合法條目,略過本輪" "WRN" # ------------------------------------------------------------------------------ @@ -275,7 +285,7 @@ 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 +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})" diff --git a/skills/worklog/SKILL.md b/skills/worklog/SKILL.md index 002003b..3e0a591 100644 --- a/skills/worklog/SKILL.md +++ b/skills/worklog/SKILL.md @@ -11,24 +11,28 @@ 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,每筆整理成六個固定欄位 | -| `scripts/worklog/wiki_api.py` | 上述兩者共用 | token 解析、wiki 讀寫、append 重試、週頁命名 | -| `scripts/worklog/transcript.py` | 上述兩者共用 | 抽本輪片段、估算花費時間、機密遮蔽 | +| `scripts/worklog/worklog.sh` | 上述兩者共用 | 主流程(單一實作,避免漂移):依 `WORKLOG_CLI` 呼叫 headless CLI,每筆整理成七個固定欄位(含 token 用量) | +| `scripts/worklog/wiki_api.mjs` | 上述兩者共用 | token 解析、wiki 讀寫、append 重試、週頁命名 | +| `scripts/worklog/transcript.mjs` | 上述兩者共用 | 抽本輪片段、估算花費時間、統計 token 用量、機密遮蔽 | ### 各助理支援範圍 | 功能 | Claude Code | Codex | Antigravity | OpenCode | GitHub Copilot | | --- | --- | --- | --- | --- | --- | -| `Stop` hook 自動記錄 | ✅ | ✅ 需可讀 Codex session JSONL | ❌ | ❌ | ❌ | +| `Stop` hook 自動記錄 | ✅ | ✅ 需可讀 Codex session JSONL | ❌ 無 hook 機制 | ❌ 無 hook 機制 | ✅(2026/08/11 實測確認,見下方說明) | | `--init`/`--diagnose`/`--append`/`--show` | ✅ | ⚠️ 需 plugin 目錄保留 `scripts/`(安裝後請實測一次) | ⚠️ 同左 | ⚠️ 需完整 plugin 目錄 | ⚠️ 需 plugin 目錄保留 `scripts/` | | 摘要 CLI | `claude -p` | `codex exec` | `agy -p` | `opencode run` | `copilot -p` | | `--tune` | ✅ | ❌ 無 `claude-api` skill 可載入 | ❌ 同左 | ❌ | ❌ | +| token 用量統計 | ✅ 輸入/輸出皆可得 | ✅ 輸入/輸出皆可得 | ❌ 無 transcript 解析器 | ❌ 無 transcript 解析器 | ⚠️ 只有輸出,輸入固定「未判定」(見下方說明) | -兩個限制的來源: +限制與實測結果的來源: -- **`Stop` hook 只有相容 hook 環境實際執行**;Claude Code 先用 `CLAUDE_PLUGIN_ROOT` 定位腳本,找不到時再掃 `~/.claude/plugins/cache` 與 `~/.codex/plugins/cache`,最後命中 `*/jsc-doc/*/scripts/worklog/worklog.sh`。`transcript.py` 目前支援 Claude Code transcript JSONL(`type` / `message.content` blocks)與 Codex session JSONL(`payload` events / response items),其他助理若提供等效 hook,必須先補對應 transcript 解析器。 +- **`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 解析器。 +- **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 目錄執行」不足以讓自動記錄運作。 - **OpenCode 以「複製 `skills/` 目錄」安裝**時不會帶入 `scripts/`,本 skill 的所有模式都無法執行;若以完整 plugin 目錄執行並能解析 `scripts/worklog`,可用 `WORKLOG_CLI=opencode` 作為摘要 CLI。 -- 其他助理若要用 `--append`/`--show` 等純 wiki 操作,只需 `python3`;摘要路徑需要 README 定義的任一 headless CLI。`--tune` 仍是 Claude Code 專屬,其他 CLI 使用各自預設模型或手動設定其 CLI 行為。 +- 其他助理若要用 `--append`/`--show` 等純 wiki 操作,只需 `node`;摘要路徑需要 README 定義的任一 headless CLI。`--tune` 仍是 Claude Code 專屬,其他 CLI 使用各自預設模型或手動設定其 CLI 行為。 ### 腳本路徑解析(重要) @@ -79,8 +83,8 @@ WORKLOG_DIR="/../../scripts/worklog" ### `--init` -1. 執行 `python3 "${WORKLOG_DIR}/wiki_api.py" probe`,回報 token 來源、Gitea 版本、當週頁狀態。 -2. 當週頁不存在 → 執行 `python3 "${WORKLOG_DIR}/wiki_api.py" init` 建立(wiki 尚未初始化時一併初始化)。 +1. 執行 `node "${WORKLOG_DIR}/wiki_api.mjs" probe`,回報 token 來源、Gitea 版本、當週頁狀態。 +2. 當週頁不存在 → 執行 `node "${WORKLOG_DIR}/wiki_api.mjs" init` 建立(wiki 尚未初始化時一併初始化)。 3. 以表格印出應寫入 `~/.bashrc` 的 `WORKLOG_*` 變數清單;**不自動改使用者的 shell profile**(需人工確認的狀態變更)。 ### `--tune`(Claude Code 專屬) @@ -103,10 +107,10 @@ WORKLOG_DIR="/../../scripts/worklog" | 檢查項 | 判準 | | --- | --- | -| `python3`/摘要 CLI | `python3` 與 `WORKLOG_CLI` 指定或 auto 選到的 CLI 是否找得到 | +| `node`/摘要 CLI | `node` 與 `WORKLOG_CLI` 指定或 auto 選到的 CLI 是否找得到 | | `WORKLOG_*` 變數 | 必要三項是否齊全、`WORKLOG_SCOPE` 是否把當前路徑排除 | | `scripts/worklog` 目錄 | `${WORKLOG_DIR}` 是否解析成功且三支腳本存在(不存在=此助理不支援) | -| token | `python3 "${WORKLOG_DIR}/wiki_api.py" probe` 的 token 來源與驗證結果 | +| token | `node "${WORKLOG_DIR}/wiki_api.mjs" probe` 的 token 來源與驗證結果 | | wiki API | Gitea 版本、`repos/` 與當週頁狀態 | | 摘要設定 | `WORKLOG_CLI`、選到的 CLI、Claude 模型快取是否存在與是否過期 | | hook 註冊 | `hooks/hooks.json` 是否存在且 plugin 已啟用 | @@ -123,13 +127,14 @@ WORKLOG_DIR="/../../scripts/worklog" - 任務狀態:<完成/進行中/待確認/受阻> - 遇到的困難:<困難或未遇到明確困難> - 解決方式:<處理方式或不需額外處理> +- token 用量:<輸入/輸出或未判定> ``` -專案取當前工作目錄的 `/`;內容仍會過 `python3 "${WORKLOG_DIR}/transcript.py" redact` 遮蔽後才寫入。 +專案取當前工作目錄的 `/`;內容仍會過 `node "${WORKLOG_DIR}/transcript.mjs" redact` 遮蔽後才寫入。手動補寫沒有 transcript 可解析,`token 用量` 固定寫「未判定」。 ### `--show` -讀當週頁(`python3 "${WORKLOG_DIR}/wiki_api.py" show`)並以表格摘要本週工作,用於回顧與週報。 +讀當週頁(`node "${WORKLOG_DIR}/wiki_api.mjs" show`)並以表格摘要本週工作,用於回顧與週報。 --- @@ -139,7 +144,7 @@ WORKLOG_DIR="/../../scripts/worklog" - 週頁名稱:`Worklog---W`,`n` =該週起始的星期六是當月第幾個星期六(例:`2026/07/29 三` 屬於 `07/25 六` 那一週 → `Worklog-2026-07-W4`)。 - 跨月的一週歸屬**起始星期六**所在的月份,確保同一週只有一頁(例:`2026/08/29 六 ~ 09/04 五` 全部寫入 `Worklog-2026-08-W5`)。 - 頁首標題:`# 年 月 第 週工作紀錄(<起始日> 六 ~ <結束日> 五)`,日期範圍讓人一眼看出這頁涵蓋哪幾天。 -- 每筆條目:`## <時間> — <專案>` +六個固定 bullet(專案/任務名稱、執行細節與產出、花費時間、任務狀態、遇到的困難、解決方式);標題行尾帶 HTML 註解 marker(``)供寫後驗證與去重,wiki 渲染時不顯示。 +- 每筆條目:`## <時間> — <專案>` +七個固定 bullet(專案/任務名稱、執行細節與產出、花費時間、任務狀態、遇到的困難、解決方式、token 用量);標題行尾帶 HTML 註解 marker(``)供寫後驗證與去重,wiki 渲染時不顯示。 - 多 session 同時寫入:`append_entry` 採「讀取 → 合併 → 寫回 → 寫後讀取驗證 marker」,未落地則重讀最新內容重試,最多 3 次。 --- @@ -149,7 +154,7 @@ WORKLOG_DIR="/../../scripts/worklog" | 防線 | 位置 | 內容 | | --- | --- | --- | | 1 | 濃縮提示詞 | 明令不得輸出 token/密碼/API key/連線字串/Email/電話/姓名/身分證號 | -| 2 | `transcript.py` 的 `redact` | 正則遮蔽:URL 內嵌憑證、40 字元 hex token、`gh?_`/`sk-` token、`token=`/`password=`、`Authorization:`、Email、台灣手機、身分證號 | +| 2 | `transcript.mjs` 的 `redact` | 正則遮蔽:URL 內嵌憑證、40 字元 hex token、`gh?_`/`sk-` token、`token=`/`password=`、`Authorization:`、Email、台灣手機、身分證號 | 第二道防線不可移除 —— 模型有可能沒遵守指令,而 wiki 一旦寫入就留在 git 歷史裡。