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>
This commit is contained in:
@@ -40,7 +40,7 @@ doc/
|
||||
│ └── hooks.json # Stop → worklog;Claude 用 plugin root,Codex/Copilot fallback 到各自安裝 cache
|
||||
├── scripts/
|
||||
│ └── worklog/ # worklog 自動記錄的可執行元件(skill 與 hook 共用)
|
||||
│ ├── worklog.sh # 主流程:抽本輪 → 濃縮成七欄(含 token 用量) → 遮蔽 → 追加到 wiki
|
||||
│ ├── worklog.mjs # 主流程:抽本輪 → 濃縮成七欄(含 token 用量) → 遮蔽 → 追加到 wiki
|
||||
│ ├── wiki_api.mjs # Gitea wiki 讀寫、token 解析、append 重試、週頁命名
|
||||
│ └── transcript.mjs # transcript 本輪抽取、耗時估算、token 統計與機密遮蔽
|
||||
├── skills/ # ★ 唯一真實來源:所有 skills
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "rel='scripts/worklog/worklog.sh'; own='doc'; plug='jsc-doc'; root=\"${CLAUDE_PLUGIN_ROOT:-}\"; if [ -n \"$root\" ] && [ -f \"$root/$rel\" ]; then exec \"$root/$rel\"; fi; for base in \"$HOME/.claude/plugins/cache\" \"$HOME/.codex/plugins/cache\" \"$HOME/.copilot/installed-plugins\"; do for dir in \"$base/$own/$plug\" \"$base\"; do s=$(find \"$dir\" -path \"*/$plug/*/$rel\" -type f 2>/dev/null | sort -V | tail -n 1); if [ -n \"$s\" ]; then exec \"$s\"; fi; done; done; exit 0",
|
||||
"command": "rel='scripts/worklog/worklog.mjs'; own='doc'; plug='jsc-doc'; root=\"${CLAUDE_PLUGIN_ROOT:-}\"; if [ -n \"$root\" ] && [ -f \"$root/$rel\" ]; then exec node \"$root/$rel\"; fi; for base in \"$HOME/.claude/plugins/cache\" \"$HOME/.codex/plugins/cache\" \"$HOME/.copilot/installed-plugins\"; do for dir in \"$base/$own/$plug\" \"$base\"; do s=$(find \"$dir\" -path \"*/$plug/*/$rel\" -type f 2>/dev/null | sort -V | tail -n 1); if [ -n \"$s\" ]; then exec node \"$s\"; fi; done; done; exit 0",
|
||||
"timeout": 60
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
// ==============================================================================
|
||||
// 用途:Gitea Wiki 讀寫工具(worklog 專用)。提供 token 解析、頁面讀取、
|
||||
// 建立、append 追加(read-modify-write + 寫後驗證重試),供 worklog.sh
|
||||
// 建立、append 追加(read-modify-write + 寫後驗證重試),供 worklog.mjs
|
||||
// 與 /jsc-doc:worklog skill 共用,避免兩份實作漂移。
|
||||
// resolveToken() 的優先序實作對應 /jsc-shared:spec-gitea『token 解析
|
||||
// 優先序』章節。mask() 已與 transcript.mjs 的 REDACT_PATTERNS(對應
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
#!/usr/bin/env node
|
||||
// ==============================================================================
|
||||
// 用途:工作證明自動記錄(worklog)。由支援 hook 的 CLI 觸發,
|
||||
// 抽出本輪工作內容 → 呼叫已安裝 CLI 濃縮成精簡條目 → 機密遮蔽 →
|
||||
// 追加到 Gitea wiki 的當週工作紀錄頁。工作內容全程不落地。
|
||||
// 更新時間:2026/08/17 15:10:00
|
||||
// 相依:node 內建模組、同目錄的 transcript.mjs/wiki_api.mjs(直接 import,不再
|
||||
// 另外開子行程呼叫),README 定義的任一 headless CLI。
|
||||
// 機密:token 僅由環境變數/本機憑證讀取,不 echo、不寫檔;輸出前套用遮蔽規則。
|
||||
// 退出碼:一律 0 —— hook 絕不可阻斷使用者的工作流程。
|
||||
// ==============================================================================
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { extractTurn, turnDuration, formatTokenLine, turnTokens, redact } from "./transcript.mjs";
|
||||
import { nowStr, log, resolveToken, appendEntry, weekPageName, weekPageHeader } from "./wiki_api.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const STAGE = "worklog";
|
||||
const SUPPORTED_CLIS = ["claude", "codex", "agy", "opencode", "copilot"];
|
||||
const FALLBACK_MODEL = "claude-haiku-4-5-20251001";
|
||||
const MODEL_CACHE = path.join(os.homedir(), ".claude", "worklog", "model");
|
||||
const CACHE_MAX_AGE_DAYS = 30;
|
||||
|
||||
function logW(level, message) {
|
||||
log(level, message, STAGE);
|
||||
if (process.env.WORKLOG_ERRLOG && level === "ERR") {
|
||||
try {
|
||||
fs.appendFileSync(process.env.WORKLOG_ERRLOG, `[${nowStr()}][${STAGE}][${level}]: ${message}\n`);
|
||||
} catch {
|
||||
// 寫錯誤紀錄檔失敗不影響主流程
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function dieQuiet(message, level = "DBG") {
|
||||
logW(level, message);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function commandExists(cmd) {
|
||||
const dirs = (process.env.PATH || "").split(path.delimiter);
|
||||
for (const dir of dirs) {
|
||||
const candidate = path.join(dir, cmd);
|
||||
try {
|
||||
fs.accessSync(candidate, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
// 繼續找下一個目錄
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function readStdin() {
|
||||
try {
|
||||
return fs.readFileSync(0, "utf8");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** 判斷實際觸發本輪 hook 的助理環境,避免 auto 因 PATH 順序誤顯其他 CLI。 */
|
||||
function detectCurrentCli() {
|
||||
if (process.env.CODEX_THREAD_ID || process.env.CODEX_CI || process.env.CODEX_MANAGED_PACKAGE_ROOT) return "codex";
|
||||
if (process.env.CLAUDE_PLUGIN_ROOT || process.env.CLAUDE_CODE_SSE_PORT) return "claude";
|
||||
if (process.env.AGY_SESSION_ID || process.env.AGY_WORKSPACE_ID) return "agy";
|
||||
if (process.env.OPENCODE_SESSION_ID || process.env.OPENCODE_CONFIG) return "opencode";
|
||||
if (process.env.COPILOT_AGENT_ID || process.env.GITHUB_COPILOT_TOKEN) return "copilot";
|
||||
return "";
|
||||
}
|
||||
|
||||
/** 依目前 hook/session 環境優先選擇摘要執行器;可用 WORKLOG_CLI 強制指定。 */
|
||||
function selectWorklogCli() {
|
||||
const requested = process.env.WORKLOG_CLI || "auto";
|
||||
if (requested !== "auto") {
|
||||
if (!SUPPORTED_CLIS.includes(requested)) {
|
||||
dieQuiet(`WORKLOG_CLI 不支援:${requested}(可用:auto ${SUPPORTED_CLIS.join(" ")})`, "WRN");
|
||||
}
|
||||
if (!commandExists(requested)) dieQuiet(`找不到 ${requested} CLI,略過記錄`, "WRN");
|
||||
return requested;
|
||||
}
|
||||
const current = detectCurrentCli();
|
||||
if (current) {
|
||||
if (commandExists(current)) return current;
|
||||
logW("WRN", `目前環境判定為 ${current},但找不到 ${current} CLI,改用可用摘要 CLI`);
|
||||
}
|
||||
for (const cli of SUPPORTED_CLIS) {
|
||||
if (commandExists(cli)) return cli;
|
||||
}
|
||||
dieQuiet(`找不到可用摘要 CLI(需要其一:${SUPPORTED_CLIS.join(" ")})`, "WRN");
|
||||
return ""; // 不會執行到,僅安撫型別檢查
|
||||
}
|
||||
|
||||
/** 各 CLI 依 README 的 headless 指令呼叫;不把工作內容寫入檔案。 */
|
||||
function runSummaryCli(cli, prompt, model) {
|
||||
const env = { ...process.env, WORKLOG_CHILD: "1" };
|
||||
const timeoutMs = 45000;
|
||||
let argv;
|
||||
switch (cli) {
|
||||
case "claude":
|
||||
argv = ["claude", "-p", prompt, "--model", model];
|
||||
break;
|
||||
case "codex":
|
||||
argv = ["codex", "exec", prompt];
|
||||
break;
|
||||
case "agy":
|
||||
argv = ["agy", "-p", prompt];
|
||||
break;
|
||||
case "opencode":
|
||||
argv = ["opencode", "run", prompt];
|
||||
break;
|
||||
case "copilot":
|
||||
argv = ["copilot", "-p", prompt];
|
||||
break;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
const result = spawnSync(argv[0], argv.slice(1), { env, timeout: timeoutMs, encoding: "utf8" });
|
||||
return (result.stdout || "").toString();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// 遞迴防護:摘要用的子 CLI 行程可能再次觸發 Stop hook,必須在此擋掉
|
||||
if (process.env.WORKLOG_CHILD) process.exit(0);
|
||||
|
||||
// 啟用檢查:未設定 WORKLOG_* 的環境完全不動作(他人匯入 plugin 零影響)
|
||||
if (process.env.WORKLOG_ENABLED !== "1") process.exit(0);
|
||||
if (!process.env.WORKLOG_HOST) dieQuiet("未設定 WORKLOG_HOST,略過記錄", "WRN");
|
||||
if (!process.env.WORKLOG_REPO) dieQuiet("未設定 WORKLOG_REPO,略過記錄", "WRN");
|
||||
|
||||
const summaryCli = selectWorklogCli();
|
||||
if (!summaryCli) process.exit(0);
|
||||
|
||||
// 讀取 hook 傳入的 JSON。欄位名稱大小寫依助理而異:Claude Code/Codex 用
|
||||
// snake_case(session_id/transcript_path),Copilot 的 agentStop 事件用
|
||||
// camelCase(sessionId/transcriptPath),只有 stop_hook_active 剛好三家都是
|
||||
// snake_case。兩種寫法都要接,缺一個 Copilot 就完全取不到值。
|
||||
const hookInputRaw = readStdin();
|
||||
if (!hookInputRaw) dieQuiet("hook 輸入為空,略過記錄", "WRN");
|
||||
|
||||
let hookData = {};
|
||||
try {
|
||||
hookData = JSON.parse(hookInputRaw);
|
||||
} catch {
|
||||
hookData = {};
|
||||
}
|
||||
const sessionId = hookData.session_id || hookData.sessionId || hookData.thread_id || hookData.conversation_id || "-";
|
||||
let transcriptPath =
|
||||
hookData.transcript_path || hookData.transcriptPath || hookData.session_path || hookData.conversation_path || hookData.path || "-";
|
||||
const stopActive = !!(hookData.stop_hook_active || hookData.stopHookActive);
|
||||
const hookCwd = hookData.cwd || "-";
|
||||
|
||||
if (stopActive) dieQuiet("stop_hook_active 為 true,避免迴圈不重複記錄");
|
||||
|
||||
// Codex 的 hook 只給 thread id、不給 transcript 路徑,需要自己找檔案;
|
||||
// Copilot 的 agentStop 事件已直接帶 transcriptPath(見上方解析),不需要這段 fallback。
|
||||
if (!fs.existsSync(transcriptPath) && process.env.CODEX_THREAD_ID) {
|
||||
const sessionsDir = path.join(os.homedir(), ".codex", "sessions");
|
||||
transcriptPath = findFileEndingWith(sessionsDir, `${process.env.CODEX_THREAD_ID}.jsonl`) || "-";
|
||||
}
|
||||
if (!fs.existsSync(transcriptPath)) dieQuiet(`找不到 transcript:${transcriptPath}`, "WRN");
|
||||
|
||||
// 記錄範圍:WORKLOG_SCOPE 以冒號分隔的路徑前綴,未設定則全部 session 都記
|
||||
if (process.env.WORKLOG_SCOPE) {
|
||||
const scopes = process.env.WORKLOG_SCOPE.split(":");
|
||||
const inScope = scopes.some((scope) => hookCwd.startsWith(scope.replace(/\/$/, "")));
|
||||
if (!inScope) dieQuiet(`cwd 不在 WORKLOG_SCOPE 範圍內:${hookCwd}`);
|
||||
}
|
||||
|
||||
// 專案判定:git remote 的 <owner>/<repo> 優先,其次目錄名
|
||||
let project = path.basename(hookCwd);
|
||||
const gitCheck = spawnSync("git", ["-C", hookCwd, "rev-parse", "--is-inside-work-tree"], { encoding: "utf8" });
|
||||
if (gitCheck.status === 0) {
|
||||
const originResult = spawnSync("git", ["-C", hookCwd, "remote", "get-url", "origin"], { encoding: "utf8" });
|
||||
const origin = (originResult.stdout || "").trim();
|
||||
if (origin) {
|
||||
let cleaned = origin.replace(/\.git$/, "");
|
||||
cleaned = cleaned.replace(/^.*:\/\//, "");
|
||||
cleaned = cleaned.replace(/^[^@]*@/, "");
|
||||
const parts = cleaned.split("/").filter(Boolean);
|
||||
if (parts.length >= 2) project = `${parts[parts.length - 2]}/${parts[parts.length - 1]}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 抽出本輪內容(最後一筆使用者訊息之後),並先做一次機密遮蔽
|
||||
const turn = redact(extractTurn(transcriptPath));
|
||||
if (!turn) dieQuiet("本輪無可記錄內容");
|
||||
const duration = turnDuration(transcriptPath) || "未判定";
|
||||
const tokens = formatTokenLine(turnTokens(transcriptPath)) || "未判定";
|
||||
|
||||
// 模型決定:只有 claude CLI 使用 WORKLOG_MODEL/快取檔;其他 CLI 使用各自預設模型
|
||||
let model = "";
|
||||
let modelNote = "";
|
||||
if (summaryCli === "claude") {
|
||||
if (process.env.WORKLOG_MODEL) {
|
||||
model = process.env.WORKLOG_MODEL;
|
||||
} else if (fs.existsSync(MODEL_CACHE)) {
|
||||
const stat = fs.statSync(MODEL_CACHE);
|
||||
const ageDays = (Date.now() - stat.mtimeMs) / (1000 * 60 * 60 * 24);
|
||||
if (ageDays > CACHE_MAX_AGE_DAYS) {
|
||||
model = FALLBACK_MODEL;
|
||||
modelNote = " (cli: claude, model: fallback)";
|
||||
logW("WRN", `模型快取已超過 ${CACHE_MAX_AGE_DAYS} 天,改用保底模型,建議重跑 /jsc-doc:worklog --tune`);
|
||||
} else {
|
||||
const cacheText = fs.readFileSync(MODEL_CACHE, "utf8");
|
||||
const m = cacheText.match(/^model=(.*)$/m);
|
||||
model = m ? m[1].trim() : "";
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
model = FALLBACK_MODEL;
|
||||
modelNote = " (cli: claude, model: fallback)";
|
||||
logW("WRN", "無模型快取,改用保底模型,建議執行 /jsc-doc:worklog --tune");
|
||||
} else if (!modelNote) {
|
||||
modelNote = " (cli: claude)";
|
||||
}
|
||||
} else {
|
||||
modelNote = ` (cli: ${summaryCli})`;
|
||||
}
|
||||
|
||||
// 濃縮:交給選定 CLI 產出精簡條目(子行程帶 WORKLOG_CHILD=1 阻斷遞迴)
|
||||
const prompt = `你是工作紀錄濃縮器。輸入是一段 AI 助理與使用者的對話片段(含工具呼叫)。
|
||||
請濃縮成工作紀錄條目,規則:
|
||||
|
||||
已判定專案:${project}
|
||||
已估算花費時間:${duration}
|
||||
已統計 token 用量:${tokens}
|
||||
|
||||
1. 只輸出 7 個 markdown bullet(以「- 」開頭),不要標題、不要前言、不要結語。
|
||||
2. 七個 bullet 必須依序使用下列欄位名稱,格式固定為「- 欄位名稱:內容」:
|
||||
- 專案/任務名稱
|
||||
- 執行細節與產出
|
||||
- 花費時間
|
||||
- 任務狀態
|
||||
- 遇到的困難
|
||||
- 解決方式
|
||||
- token 用量
|
||||
3. 使用繁體中文(台灣用語),每個 bullet 一行、不超過 90 字,聚焦「做了什麼、動到什麼、結果如何」。
|
||||
4. 保留關鍵事實:檔案/專案/指令/數量/分支/PR/議題編號;不要抄程式碼、不要貼指令全文。
|
||||
5. 花費時間優先使用「已估算花費時間」;無法判定時寫「未判定」。
|
||||
6. 若沒有遇到明確困難,遇到的困難寫「未遇到明確困難」,解決方式寫「不需額外處理」。
|
||||
7. 嚴禁輸出任何憑證與個資:token、密碼、API key、連線字串、Email、電話、姓名、身分證號。
|
||||
8. 若這段對話沒有實質工作產出(純閒聊、純提問、僅讀取資訊而未產生結論),只輸出一行:SKIP
|
||||
9. token 用量一律照抄「已統計 token 用量」,不得自行推算或估計;無法判定時寫「未判定」。
|
||||
|
||||
對話片段:
|
||||
${turn}`;
|
||||
|
||||
let summary = runSummaryCli(summaryCli, prompt, model);
|
||||
if (!summary) {
|
||||
logW("WRN", `摘要產出為空(CLI ${summaryCli}),略過本輪`);
|
||||
process.exit(0);
|
||||
}
|
||||
if (/^\s*SKIP\s*$/i.test(summary)) dieQuiet("模型判定本輪無實質工作產出");
|
||||
|
||||
// 第二道防線:對模型輸出再做一次機密遮蔽
|
||||
summary = redact(summary);
|
||||
// 只保留 bullet 行,避免模型帶出多餘敘述
|
||||
summary = summary
|
||||
.split("\n")
|
||||
.filter((line) => /^\s*[-*]\s+/.test(line))
|
||||
.map((line) => line.replace(/^\s*\*/, "-"))
|
||||
.slice(0, 7)
|
||||
.join("\n");
|
||||
if (!summary) dieQuiet("摘要不含合法條目,略過本輪", "WRN");
|
||||
|
||||
// 組條目並追加到當週 wiki 頁
|
||||
const stamp = nowStr();
|
||||
const marker = `worklog:${nowStr().replace(/[^0-9]/g, "")}-${sessionId.slice(0, 8)}`;
|
||||
const entry = `## ${stamp} — ${project}${modelNote} <!-- ${marker} -->\n${summary}`;
|
||||
|
||||
await appendToWiki(entry, marker, project);
|
||||
}
|
||||
|
||||
function findFileEndingWith(dir, suffix) {
|
||||
try {
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
const full = path.join(dir, name);
|
||||
const stat = fs.statSync(full);
|
||||
if (stat.isDirectory()) {
|
||||
const found = findFileEndingWith(full, suffix);
|
||||
if (found) return found;
|
||||
} else if (name.endsWith(suffix)) {
|
||||
return full;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 目錄不存在或無法讀取,視為找不到
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function appendToWiki(entry, marker, project) {
|
||||
const host = process.env.WORKLOG_HOST;
|
||||
const repo = process.env.WORKLOG_REPO;
|
||||
const [token, source] = await resolveToken(host, repo);
|
||||
if (!token) {
|
||||
logW("ERR", `寫入 wiki 失敗(專案 ${project}):無可用 token(${source})`);
|
||||
process.exit(0);
|
||||
}
|
||||
const page = weekPageName();
|
||||
const [ok, msg] = await appendEntry(host, repo, token, page, weekPageHeader(page), entry, marker);
|
||||
if (ok) {
|
||||
logW("INF", `已記錄工作條目(專案 ${project},CLI 摘要來源已套用)`);
|
||||
} else {
|
||||
logW("ERR", `寫入 wiki 失敗(專案 ${project}):${msg}`);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main();
|
||||
}
|
||||
@@ -11,7 +11,7 @@ description: 工作證明自動記錄(worklog)的操作與維護 skill。搭
|
||||
| --- | --- | --- |
|
||||
| `hooks/hooks.json` 的 `Stop` hook | harness 自動 | 每輪結束抽本輪內容 → 濃縮 → 遮蔽 → 追加到當週頁 |
|
||||
| 本 skill `/jsc-doc:worklog` | 使用者/助理手動 | `--init`/`--tune`/`--diagnose`/`--append`/`--show` |
|
||||
| `scripts/worklog/worklog.sh` | 上述兩者共用 | 主流程(單一實作,避免漂移):依 `WORKLOG_CLI` 呼叫 headless CLI,每筆整理成七個固定欄位(含 token 用量) |
|
||||
| `scripts/worklog/worklog.mjs` | 上述兩者共用 | 主流程(單一實作,避免漂移):依 `WORKLOG_CLI` 呼叫 headless CLI,每筆整理成七個固定欄位(含 token 用量) |
|
||||
| `scripts/worklog/wiki_api.mjs` | 上述兩者共用 | token 解析、wiki 讀寫、append 重試、週頁命名 |
|
||||
| `scripts/worklog/transcript.mjs` | 上述兩者共用 | 抽本輪片段、估算花費時間、統計 token 用量、機密遮蔽 |
|
||||
|
||||
@@ -27,7 +27,7 @@ description: 工作證明自動記錄(worklog)的操作與維護 skill。搭
|
||||
|
||||
限制與實測結果的來源:
|
||||
|
||||
- **`Stop` hook 只有相容 hook 環境實際執行**;Claude Code 先用 `CLAUDE_PLUGIN_ROOT` 定位腳本,找不到時依序掃 `~/.claude/plugins/cache`、`~/.codex/plugins/cache`、`~/.copilot/installed-plugins`,最後命中 `*/jsc-doc/*/scripts/worklog/worklog.sh`。`transcript.mjs` 支援 Claude Code transcript JSONL(`type` / `message.content` blocks)、Codex session JSONL(`payload` events / response items)與 Copilot events.jsonl(`type` 為 `user.message`/`assistant.message`/`tool.execution_complete` 的 `data.*` 欄位),其他助理若提供等效 hook,必須先補對應 transcript 解析器。
|
||||
- **`Stop` hook 只有相容 hook 環境實際執行**;Claude Code 先用 `CLAUDE_PLUGIN_ROOT` 定位腳本,找不到時依序掃 `~/.claude/plugins/cache`、`~/.codex/plugins/cache`、`~/.copilot/installed-plugins`,最後命中 `*/jsc-doc/*/scripts/worklog/worklog.mjs`。`transcript.mjs` 支援 Claude Code transcript JSONL(`type` / `message.content` blocks)、Codex session JSONL(`payload` events / response items)與 Copilot events.jsonl(`type` 為 `user.message`/`assistant.message`/`tool.execution_complete` 的 `data.*` 欄位),其他助理若提供等效 hook,必須先補對應 transcript 解析器。
|
||||
- **Copilot 支援細節(2026/08/11 實測)**:Copilot CLI 內部事件名稱是 `agentStop`(不是 `Stop`),欄位為 camelCase(`sessionId`/`transcriptPath`,只有 `stop_hook_active` 例外仍是 snake_case);但實測確認 **Copilot 的 plugin 載入器會把 `hooks/hooks.json` 裡的 `Stop` key 自動對應到它自己的 `agentStop` 事件**,本檔不需要另外宣告 `agentStop` key。transcript 路徑固定為 `~/.copilot/session-state/<sessionId>/events.jsonl`,且 hook payload 直接帶 `transcriptPath`,不需要像 Codex 分支那樣自己用 session id 反查檔案。**Copilot 逐輪只記錄 `assistant.message.data.outputTokens`,沒有對應的輸入 token 欄位**;`session.shutdown.modelMetrics` 雖然有完整輸入/輸出,但那是整個 session 結束才寫的累計值,語意不是「本輪」,故 Copilot 的輸入 token 一律固定輸出「未判定」,不得用該欄位冒充本輪數字。
|
||||
- **Antigravity(`agy`)不支援自動記錄的兩個具體原因**:(1) `agy --help` 沒有任何 hook 相關子指令或設定項,CLI 本身不提供事件觸發點(觸發點缺);(2) 就算有觸發點,`agy` 的對話記錄存在 `~/.gemini/antigravity-cli/conversations/*.db` 內,內容是 `step_payload`/`gen_metadata` 等欄位的 protobuf 二進位 blob、`step_type` 是數字 enum,沒有公開 `.proto` schema 可解析(資料缺)。兩個條件都不成立,之後要支援得兩者都解決,不是單純補一支 transcript 解析器就好。
|
||||
- **OpenCode 不支援自動記錄的原因**:目前的 skill 目錄安裝法本來就不含 `scripts/`,且其 CLI 同樣未見 hook 機制文件;若之後提供對應的 hook 機制與可讀的 transcript 格式,才有辦法補上,純粹「以完整 plugin 目錄執行」不足以讓自動記錄運作。
|
||||
|
||||
Reference in New Issue
Block a user