feat(worklog): 新增 token 用量統計、支援 Copilot 自動記錄,worklog 腳本改以 Node 實作

- transcript.py/wiki_api.py 等價移植為 transcript.mjs/wiki_api.mjs:extract/duration/redact/probe/pages/show/append 等子命令行為與 Python 版對拍一致;transcript.mjs 額外處理三個 Python→JS 移植地雷(json.dumps 間距、Unicode 碼點切片、Python repr 格式)。
- 新增 tokens 子命令與 turnTokens()/formatTokens():統計本輪 token 用量,Claude Code/Codex/Copilot 三種 transcript 格式皆支援(Copilot 逐輪只有 outputTokens,輸入固定「未判定」)。
- worklog.sh 條目改為七個固定 bullet(新增「token 用量」),hook 輸入解析同時接受 snake_case 與 Copilot 的 camelCase 欄位。
- hooks.json 的腳本定位邏輯補上 ~/.copilot/installed-plugins 搜尋路徑:先前只搜尋 .claude/.codex 的 plugins cache,導致 Copilot 上 hook 事件雖有觸發(agentStop 會被 Copilot 對應到 hooks.json 的 Stop key)卻找不到 worklog.sh。
- SKILL.md/README.md 同步更新腳本檔名、各助理支援範圍(Copilot 改為 ✅ 並附實測說明,Antigravity/OpenCode 補上不支援的具體原因)。
This commit is contained in:
2026-08-12 05:55:10 +00:00
parent ed7f29c10f
commit 0a992ba534
8 changed files with 1174 additions and 861 deletions
+565
View File
@@ -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("<skill>")) continue;
if (role === "user" && text.trimStart().startsWith("<environment_context>")) 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 <transcript 路徑> 抽出本輪內容並遮蔽機密後輸出到 stdout
duration <transcript 路徑> 估算本輪花費時間,無法判定時輸出「未判定」
tokens <transcript 路徑> 統計本輪 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)));
}
-317
View File
@@ -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("<skill>"):
continue
if role == "user" and text.lstrip().startswith("<environment_context>"):
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 <transcript 路徑> 抽出本輪內容並遮蔽機密後輸出到 stdout
duration <transcript 路徑> 估算本輪花費時間,無法判定時輸出「未判定」
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:]))
+549
View File
@@ -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/<repo> 實際驗證權限。
* 優先序: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 <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://<host>/api/v1/repos/<owner>/<repo>` 之下的 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>。
* 週以星期六起算(六~五),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 <marker> [頁面] 自 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));
}
-499
View File
@@ -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/<repo> 實際驗證權限。
優先序: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 <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://<host>/api/v1/repos/<owner>/<repo>` 之下的 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>。
週以星期六起算(六~五),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 <marker> [頁面] 自 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:]))
+33 -23
View File
@@ -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 <<EOF_HOOK
$(printf '%s' "$HOOK_INPUT" | python3 -c '
import json, sys
try:
d = json.load(sys.stdin)
except ValueError:
d = {}
print(
d.get("session_id") or d.get("thread_id") or d.get("conversation_id") or "-",
d.get("transcript_path") or d.get("session_path") or d.get("conversation_path") or d.get("path") or "-",
"1" if d.get("stop_hook_active") else "0",
d.get("cwd", "") or "-",
)
$(printf '%s' "$HOOK_INPUT" | node -e '
let raw = "";
process.stdin.on("data", (c) => { 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 <<EOF_PROMPT
已判定專案:${PROJECT}
已估算花費時間:${DURATION}
已統計 token 用量:${TOKENS}
1. 只輸出 6 個 markdown bullet(以「- 」開頭),不要標題、不要前言、不要結語。
2. 六個 bullet 必須依序使用下列欄位名稱,格式固定為「- 欄位名稱:內容」:
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}
@@ -261,9 +271,9 @@ fi
printf '%s' "$SUMMARY" | grep -qiE '^\s*SKIP\s*$' && die_quiet "模型判定本輪無實質工作產出"
# 第二道防線:對模型輸出再做一次機密遮蔽
SUMMARY="$(printf '%s' "$SUMMARY" | python3 "${SCRIPT_DIR}/transcript.py" redact 2>/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 <!-- %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})"