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
+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));
}