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

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

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

550 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// ==============================================================================
// 用途:Gitea Wiki 讀寫工具(worklog 專用)。提供 token 解析、頁面讀取、
// 建立、append 追加(read-modify-write + 寫後驗證重試),供 worklog.mjs
// 與 /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));
}