@@ -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 + / . t e s t ( l i n e ) )
. 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 ( ) ;
}