Merge pull request 'refactor(generic 規範): 移除 worklog 並補 Copilot 說明' (#6) from develop into master
Reviewed-on: plugins/generic#6 Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
This commit was merged in pull request #6.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "generic",
|
"name": "generic",
|
||||||
"description": "JSC 跨 AI 助理共用 skills 的 Claude Code marketplace。",
|
"description": "JSC 跨 AI 助理共用規範 skills 的 Claude Code marketplace。",
|
||||||
"owner": {
|
"owner": {
|
||||||
"name": "JSC"
|
"name": "JSC"
|
||||||
},
|
},
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
{
|
{
|
||||||
"name": "jsc",
|
"name": "jsc",
|
||||||
"source": "./",
|
"source": "./",
|
||||||
"description": "JSC 共用 skills(跨 AI 助理)"
|
"description": "JSC 共用規範 skills(跨 AI 助理)"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "jsc",
|
"name": "jsc",
|
||||||
"version": "0.0.2",
|
"version": "0.0.3",
|
||||||
"description": "JSC 跨 AI 助理共用 plugin 模板(Claude Code / Codex / Antigravity / OpenCode)。所有 skills 以 SKILL.md 為共通標準,於 Claude Code 以 /jsc: 前綴呼叫。",
|
"description": "JSC 跨 AI 助理共用規範 plugin(Claude Code / Codex / Antigravity / OpenCode)。所有 skills 以 SKILL.md 為共通標準,於 Claude Code 以 /jsc: 前綴呼叫。",
|
||||||
"skills": "./skills",
|
"skills": "./skills",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "JSC"
|
"name": "JSC"
|
||||||
},
|
},
|
||||||
"homepage": "https://gitea.jsc.idv.tw/plugins/generic",
|
"homepage": "https://gitea.jsc.idv.tw/plugins/generic",
|
||||||
"repository": "https://gitea.jsc.idv.tw/plugins/generic.git",
|
"repository": "https://gitea.jsc.idv.tw/plugins/generic.git",
|
||||||
"keywords": ["template", "skills", "cross-tool", "jsc"]
|
"keywords": ["spec", "skills", "cross-tool", "jsc"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jsc",
|
"name": "jsc",
|
||||||
"version": "0.0.2",
|
"version": "0.0.3",
|
||||||
"description": "JSC 跨 AI 助理共用 plugin 模板。所有 skills 以 SKILL.md 為共通標準。",
|
"description": "JSC 跨 AI 助理共用規範 plugin。所有 skills 以 SKILL.md 為共通標準。",
|
||||||
"skills": "./skills"
|
"skills": "./skills"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# jsc — 跨 AI 助理 Plugin 模板
|
# jsc — 跨 AI 助理共用規範 Plugin
|
||||||
|
|
||||||
一個可同時被 **Claude Code、Codex、Antigravity、OpenCode** 安裝的 plugin 模板。
|
一個可同時被 **Claude Code、Codex、Antigravity、OpenCode、GitHub Copilot** 使用的共用規範 plugin。
|
||||||
核心是以 [Agent Skills(`SKILL.md`)](https://agentskills.io) 標準撰寫的共用 skills(唯一真實來源放在 `skills/`),
|
核心是以 [Agent Skills(`SKILL.md`)](https://agentskills.io) 標準撰寫的共用 skills(唯一真實來源放在 `skills/`),
|
||||||
搭配各助理各自的 plugin manifest,讓**同一個 repo** 可用各家**原生 plugin CLI** 安裝。
|
搭配各助理各自的 plugin manifest,讓**同一個 repo** 可用各家**原生 plugin CLI** 安裝。
|
||||||
在 Claude Code 與 Antigravity 中,skill 以 **`/jsc:` 前綴**呼叫(例如 `/jsc:spec-output`)。
|
在 Claude Code 與 Antigravity 中,skill 以 **`/jsc:` 前綴**呼叫(例如 `/jsc:spec-output`)。
|
||||||
@@ -15,8 +15,9 @@
|
|||||||
| Codex | `codex plugin`(marketplace) | `$<name>` 或 `/skills` 選單 | ❌(用 `$name`) |
|
| Codex | `codex plugin`(marketplace) | `$<name>` 或 `/skills` 選單 | ❌(用 `$name`) |
|
||||||
| Antigravity | `agy plugin install` | `/jsc:<name>` 或自動觸發 | ✅ |
|
| Antigravity | `agy plugin install` | `/jsc:<name>` 或自動觸發 | ✅ |
|
||||||
| OpenCode | skills 目錄(複製/clone) | 描述需求自動觸發 | ❌(依名稱) |
|
| OpenCode | skills 目錄(複製/clone) | 描述需求自動觸發 | ❌(依名稱) |
|
||||||
|
| GitHub Copilot | repository instructions(`AGENTS.md` / `.github/copilot-instructions.md`) | 描述需求並要求讀取對應 `SKILL.md` | ❌(依指示檔) |
|
||||||
|
|
||||||
> Codex 不支援自訂前綴(skill 以 `$name` 呼叫);OpenCode 由模型依描述自動呼叫。兩者皆**不強制**前綴。
|
> Codex 不支援自訂前綴(skill 以 `$name` 呼叫);OpenCode 與 Copilot 由模型依描述與指示檔觸發。三者皆**不強制**前綴。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -34,37 +35,17 @@ generic/
|
|||||||
├── .agents/plugins/
|
├── .agents/plugins/
|
||||||
│ └── marketplace.json # Codex marketplace(name: "generic",url source 指向本 repo)
|
│ └── marketplace.json # Codex marketplace(name: "generic",url source 指向本 repo)
|
||||||
├── plugin.json # Antigravity 外掛定義(name: "jsc",skills: "./skills/")
|
├── plugin.json # Antigravity 外掛定義(name: "jsc",skills: "./skills/")
|
||||||
├── hooks/
|
|
||||||
│ └── hooks.json # Claude Code hooks(Stop → worklog;其他助理不吃此檔)
|
|
||||||
├── scripts/
|
|
||||||
│ └── worklog/ # worklog 自動記錄的可執行元件(skill 與 hook 共用)
|
|
||||||
│ ├── worklog.sh # 主流程:抽本輪 → 濃縮 → 遮蔽 → 追加到 wiki
|
|
||||||
│ ├── wiki_api.py # Gitea wiki 讀寫、token 解析、append 重試、週頁命名
|
|
||||||
│ └── transcript.py # transcript 本輪抽取與機密遮蔽
|
|
||||||
├── skills/ # ★ 唯一真實來源:所有 skills
|
├── skills/ # ★ 唯一真實來源:所有 skills
|
||||||
│ ├── spec-*/SKILL.md # 共用規範 skills(一規範一目錄)
|
│ └── spec-*/SKILL.md # 共用規範 skills(一規範一目錄)
|
||||||
│ └── worklog/SKILL.md # 工作證明自動記錄的操作與維護
|
|
||||||
├── AGENTS.md # 跨助理共用指引
|
├── AGENTS.md # 跨助理共用指引
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
> generic 的定位是「**共用規範 + 全域自動化**」:`skills/spec-*` 是純規範文件(四家助理通用),`hooks/` 與 `scripts/` 是可執行元件。
|
> generic 的定位是「**共用規範**」:`skills/spec-*` 是 code/doc plugins 共用的流程與安全規範。工作紀錄自動化 `worklog` 已移到 `doc` plugin。
|
||||||
|
|
||||||
### 元件對各助理的適用範圍
|
|
||||||
|
|
||||||
| 元件 | Claude Code | Codex | Antigravity | OpenCode |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| `skills/spec-*`(十個共用規範) | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
| `skills/worklog` 的手動模式 | ✅ | ⚠️ 需安裝後保留 `scripts/`(請實測一次) | ⚠️ 同左 | ❌ 不支援 |
|
|
||||||
| `hooks/hooks.json`(`Stop` 自動記錄) | ✅ | ❌ | ❌ | ❌ |
|
|
||||||
|
|
||||||
- **`hooks/hooks.json` 只有 Claude Code 會讀**。且 `scripts/worklog/worklog.sh` 解析的是 **Claude Code 專屬的 transcript JSONL 結構**,即使其他助理提供等效 hook,自動記錄也不能直接沿用。
|
|
||||||
- **OpenCode 不支援 worklog**:OpenCode 以「複製 `skills/` 目錄」安裝,不會帶入 `scripts/`,worklog 的所有模式都無法執行(已在該 skill 的 `description` 標明)。
|
|
||||||
- Claude Code 的 plugin cache 是完整 repo clone,`scripts/`(含 `100755` 執行權限)與 `hooks/` 都會帶入;Codex/Antigravity 的安裝目錄是否同樣保留 `scripts/` 尚未實測,第一次安裝後請跑 `/jsc:worklog --diagnose` 確認。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 安裝 / 更新 / 移除(各家原生 plugin CLI)
|
## 安裝 / 更新 / 移除(各助理)
|
||||||
|
|
||||||
> 指令中的 repo 網址換成你的:`https://gitea.jsc.idv.tw/plugins/generic.git`
|
> 指令中的 repo 網址換成你的:`https://gitea.jsc.idv.tw/plugins/generic.git`
|
||||||
>
|
>
|
||||||
@@ -152,12 +133,32 @@ cp -r ~/plugins/generic/skills/* ~/.config/opencode/skills/
|
|||||||
for s in ~/plugins/generic/skills/*/; do rm -rf "$HOME/.config/opencode/skills/$(basename "$s")"; done
|
for s in ~/plugins/generic/skills/*/; do rm -rf "$HOME/.config/opencode/skills/$(basename "$s")"; done
|
||||||
```
|
```
|
||||||
|
|
||||||
> **worklog 在 OpenCode 不可用**:上面的複製只帶 `skills/`,不含 `scripts/`,worklog 的所有模式都會失敗。請不要在 OpenCode 觸發該 skill(其 `description` 已標明不支援)。
|
|
||||||
|
|
||||||
> **Windows PowerShell**:`cp -r A B` → `Copy-Item A B -Recurse -Force`、`rm -rf X` → `Remove-Item X -Recurse -Force`、`~` → `$HOME`。
|
> **Windows PowerShell**:`cp -r A B` → `Copy-Item A B -Recurse -Force`、`rm -rf X` → `Remove-Item X -Recurse -Force`、`~` → `$HOME`。
|
||||||
|
|
||||||
- **呼叫**:直接描述需求,模型會依 skill 描述自動透過 skill 工具呼叫。
|
- **呼叫**:直接描述需求,模型會依 skill 描述自動透過 skill 工具呼叫。
|
||||||
|
|
||||||
|
### GitHub Copilot
|
||||||
|
|
||||||
|
GitHub Copilot 目前不是這類 `SKILL.md` plugin 的原生執行環境;安裝方式是把本 repo 作為 repository instructions 的來源,讓 Copilot 讀 `AGENTS.md` 與對應 `SKILL.md`。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安裝到目標 repo(把 generic plugin vendored 進 .github/jsc-plugins/generic)
|
||||||
|
git clone https://gitea.jsc.idv.tw/plugins/generic.git .github/jsc-plugins/generic
|
||||||
|
cp .github/jsc-plugins/generic/AGENTS.md AGENTS.md
|
||||||
|
|
||||||
|
# 更新
|
||||||
|
git -C .github/jsc-plugins/generic pull
|
||||||
|
cp .github/jsc-plugins/generic/AGENTS.md AGENTS.md
|
||||||
|
|
||||||
|
# 移除
|
||||||
|
git rm -r .github/jsc-plugins/generic
|
||||||
|
# 若 AGENTS.md 只供此 plugin 使用,再移除;若已有其他內容,請只刪除 JSC generic 相關段落
|
||||||
|
# git rm AGENTS.md
|
||||||
|
```
|
||||||
|
|
||||||
|
- 也可把既有 `.github/copilot-instructions.md` 改成引用 `AGENTS.md` 與 `.github/jsc-plugins/generic/skills/<skill>/SKILL.md`。
|
||||||
|
- **呼叫**:在 Copilot Chat/Coding Agent 任務中描述需求,例如「請依 `.github/jsc-plugins/generic/skills/spec-output/SKILL.md` 說明輸出規範」。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 用 CLI 直接執行 skill(headless / 一次性)
|
## 用 CLI 直接執行 skill(headless / 一次性)
|
||||||
@@ -170,10 +171,11 @@ for s in ~/plugins/generic/skills/*/; do rm -rf "$HOME/.config/opencode/skills/$
|
|||||||
| Codex | `codex exec "<prompt>"` | `codex exec '$spec-output'` |
|
| Codex | `codex exec "<prompt>"` | `codex exec '$spec-output'` |
|
||||||
| Antigravity | `agy -p "<prompt>"` | `agy -p "/jsc:spec-output"` |
|
| Antigravity | `agy -p "<prompt>"` | `agy -p "/jsc:spec-output"` |
|
||||||
| OpenCode | `opencode run "<message>"` | `opencode run "說明 JSC 共用輸出規範的內容"` |
|
| OpenCode | `opencode run "<message>"` | `opencode run "說明 JSC 共用輸出規範的內容"` |
|
||||||
|
| GitHub Copilot | Copilot Chat / Coding Agent 任務 | `請依 .github/jsc-plugins/generic/skills/spec-output/SKILL.md 說明輸出規範` |
|
||||||
|
|
||||||
- Claude / Antigravity 支援 `/jsc:` 前綴,直接 `-p "/jsc:<name>"` 即可。
|
- Claude / Antigravity 支援 `/jsc:` 前綴,直接 `-p "/jsc:<name>"` 即可。
|
||||||
- Codex 以 `$<name>` 觸發;在 shell 請用**單引號**避免 `$` 被展開:`codex exec '$spec-output'`。
|
- Codex 以 `$<name>` 觸發;在 shell 請用**單引號**避免 `$` 被展開:`codex exec '$spec-output'`。
|
||||||
- OpenCode 沒有前綴,用自然語言描述需求,模型會自動透過 skill 工具呼叫。
|
- OpenCode 與 Copilot 沒有前綴,用自然語言描述需求;Copilot 任務中請明確指出要讀取的 `SKILL.md`。
|
||||||
- 帶引數就接在後面,例如 `claude -p "/jsc:spec-output 參數"`、`codex exec '$spec-output 參數'`。
|
- 帶引數就接在後面,例如 `claude -p "/jsc:spec-output 參數"`、`codex exec '$spec-output 參數'`。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -202,12 +204,6 @@ for s in ~/plugins/generic/skills/*/; do rm -rf "$HOME/.config/opencode/skills/$
|
|||||||
| `spec-doc-funcs-handoff` | 文件化串接 | code 類 skill 完成後完整執行 /jsc:doc-funcs 的標準流程與統一時間戳 |
|
| `spec-doc-funcs-handoff` | 文件化串接 | code 類 skill 完成後完整執行 /jsc:doc-funcs 的標準流程與統一時間戳 |
|
||||||
| `spec-plugin-version` | 版號規則 | 三 manifest 同步 bump、對照 master 確保單調遞增、新 plugin 首發 0.0.1、chore(plugin 版本) commit |
|
| `spec-plugin-version` | 版號規則 | 三 manifest 同步 bump、對照 master 確保單調遞增、新 plugin 首發 0.0.1、chore(plugin 版本) commit |
|
||||||
|
|
||||||
### 全域自動化
|
|
||||||
|
|
||||||
| Skill | 類型 | 內容 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `worklog` | 工作證明記錄 | 每輪工作濃縮成條目追加到 Gitea wiki 當週頁(`Worklog-yyyy-MM-W<週>`);`--init`/`--tune`(Claude Code 專屬)/`--diagnose`/`--append`/`--show` 五個模式;自動記錄由 Claude Code `Stop` hook 完成,**OpenCode 不支援**(見上方適用範圍表) |
|
|
||||||
|
|
||||||
<!-- JSC-SKILLS:END -->
|
<!-- JSC-SKILLS:END -->
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -226,6 +222,7 @@ for s in ~/plugins/generic/skills/*/; do rm -rf "$HOME/.config/opencode/skills/$
|
|||||||
- Codex:`codex plugin marketplace upgrade generic`
|
- Codex:`codex plugin marketplace upgrade generic`
|
||||||
- Antigravity:`git -C ~/plugins/generic pull && agy plugin uninstall jsc && agy plugin install ~/plugins/generic`(路徑與上方 Antigravity 安裝節一致)
|
- Antigravity:`git -C ~/plugins/generic pull && agy plugin uninstall jsc && agy plugin install ~/plugins/generic`(路徑與上方 Antigravity 安裝節一致)
|
||||||
- OpenCode:`git pull` 後重新複製 `skills/`
|
- OpenCode:`git pull` 後重新複製 `skills/`
|
||||||
|
- GitHub Copilot:更新 `.github/jsc-plugins/generic` 後同步 `AGENTS.md` 或 `.github/copilot-instructions.md`
|
||||||
|
|
||||||
> **skill 帶可執行元件時**(腳本、hook)額外注意:
|
> **skill 帶可執行元件時**(腳本、hook)額外注意:
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"hooks": {
|
|
||||||
"Stop": [
|
|
||||||
{
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/worklog/worklog.sh",
|
|
||||||
"timeout": 60
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jsc",
|
"name": "jsc",
|
||||||
"version": "0.0.2",
|
"version": "0.0.3",
|
||||||
"description": "JSC 跨 AI 助理共用 plugin 模板。所有 skills 以 SKILL.md 為共通標準;於 Antigravity 以 /jsc: 前綴呼叫。",
|
"description": "JSC 跨 AI 助理共用規範 plugin。所有 skills 以 SKILL.md 為共通標準;於 Antigravity 以 /jsc: 前綴呼叫。",
|
||||||
"skills": "./skills/"
|
"skills": "./skills/"
|
||||||
}
|
}
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# ==============================================================================
|
|
||||||
# 用途:worklog 的 transcript 處理工具。負責 (1) 從 Claude Code transcript
|
|
||||||
# JSONL 抽出「本輪」對話片段(最後一筆使用者訊息之後的全部內容),
|
|
||||||
# (2) 對文字做機密遮蔽(token/密碼/PII),作為寫入 wiki 前的第二道防線。
|
|
||||||
# 更新時間:2026/07/27 11:14:16
|
|
||||||
# 相依:Python 3 標準庫。全程僅走 stdin/stdout,不寫任何檔案。
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# 單則工具結果/參數的擷取上限,避免整份 transcript 塞進摘要輸入
|
|
||||||
TOOL_RESULT_LIMIT = 200
|
|
||||||
TOOL_INPUT_LIMIT = 160
|
|
||||||
TOTAL_LIMIT = 24000
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 機密遮蔽規則:命中一律換成 ***
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
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 條目是否為真正的使用者輸入(排除 tool_result 回填的 user 條目)。"""
|
|
||||||
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 _render(entry):
|
|
||||||
"""將單一 transcript 條目轉為摘要輸入用的純文字行(工具結果僅取前段)。"""
|
|
||||||
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 extract_turn(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 ""
|
|
||||||
|
|
||||||
start = 0
|
|
||||||
for index in range(len(entries) - 1, -1, -1):
|
|
||||||
if _is_real_user_message(entries[index]):
|
|
||||||
start = index
|
|
||||||
break
|
|
||||||
|
|
||||||
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
|
|
||||||
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] == "redact":
|
|
||||||
sys.stdout.write(redact(sys.stdin.read()))
|
|
||||||
return 0
|
|
||||||
print(USAGE)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main(sys.argv[1:]))
|
|
||||||
@@ -1,434 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# ==============================================================================
|
|
||||||
# 用途:Gitea Wiki 讀寫工具(worklog 專用)。提供 token 解析、頁面讀取、
|
|
||||||
# 建立、append 追加(read-modify-write + 寫後驗證重試),供 worklog.sh
|
|
||||||
# 與 /jsc:worklog skill 共用,避免兩份實作漂移。
|
|
||||||
# 更新時間:2026/07/27 11:14:16
|
|
||||||
# 相依: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
|
|
||||||
|
|
||||||
TAIPEI = timezone(timedelta(hours=8))
|
|
||||||
|
|
||||||
|
|
||||||
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 表示。"""
|
|
||||||
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。"""
|
|
||||||
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 為準,不自行猜測轉義規則。
|
|
||||||
找不到回 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}"
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 週頁命名
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def week_page_name(when=None):
|
|
||||||
"""依台灣時區產生週頁名稱 Worklog-yyyy-MM-W<該月第幾週>(第幾週=ceil(日/7))。"""
|
|
||||||
when = when or datetime.now(TAIPEI)
|
|
||||||
week = (when.day + 6) // 7
|
|
||||||
return f"Worklog-{when.year:04d}-{when.month:02d}-W{week}"
|
|
||||||
|
|
||||||
|
|
||||||
def week_page_header(page=None, when=None):
|
|
||||||
"""產生週頁首行標題(例:# 2026 年 07 月 第 4 週工作紀錄)。"""
|
|
||||||
when = when or datetime.now(TAIPEI)
|
|
||||||
week = (when.day + 6) // 7
|
|
||||||
return f"# {when.year} 年 {when.month:02d} 月 第 {week} 週工作紀錄"
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 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:]))
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# ==============================================================================
|
|
||||||
# 用途:工作證明自動記錄(worklog)。由 Claude Code 的 Stop hook 觸發,
|
|
||||||
# 抽出本輪工作內容 → 呼叫模型濃縮成精簡條目 → 機密遮蔽 →
|
|
||||||
# 追加到 Gitea wiki 的當週工作紀錄頁。工作內容全程不落地。
|
|
||||||
# 更新時間:2026/07/27 11:14:16
|
|
||||||
# 相依:python3、claude CLI、curl(wiki 走 Python urllib,不需 curl 亦可)。
|
|
||||||
# 機密:token 僅由環境變數/本機憑證讀取,不 echo、不寫檔;輸出前套用遮蔽規則。
|
|
||||||
# 退出碼:一律 0 —— hook 絕不可阻斷使用者的工作流程。
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
STAGE="worklog"
|
|
||||||
FALLBACK_MODEL="claude-haiku-4-5-20251001"
|
|
||||||
MODEL_CACHE="${HOME}/.claude/worklog/model"
|
|
||||||
CACHE_MAX_AGE_DAYS=30
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 共用函式
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
log() {
|
|
||||||
# 輸出統一格式訊息([時間][階段][等級]: 訊息,一行一則),一律走 stderr
|
|
||||||
local level="$1" message="$2" stamp
|
|
||||||
stamp="$(TZ='Asia/Taipei' date +'%Y/%m/%d %H:%M:%S')"
|
|
||||||
printf '[%s][%s][%s]: %s\n' "$stamp" "$STAGE" "$level" "$message" >&2
|
|
||||||
if [ -n "${WORKLOG_ERRLOG:-}" ] && [ "$level" = "ERR" ]; then
|
|
||||||
printf '[%s][%s][%s]: %s\n' "$stamp" "$STAGE" "$level" "$message" >> "${WORKLOG_ERRLOG}" 2>/dev/null
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
die_quiet() {
|
|
||||||
# 記錄原因後以 0 結束:hook 不得阻斷使用者流程
|
|
||||||
log "${2:-DBG}" "$1"
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 遞迴防護:摘要用的子 claude 行程會再次觸發 Stop hook,必須在此擋掉
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
[ -n "${WORKLOG_CHILD:-}" ] && exit 0
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 啟用檢查:未設定 WORKLOG_* 的環境完全不動作(他人匯入 plugin 零影響)
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
[ "${WORKLOG_ENABLED:-}" = "1" ] || exit 0
|
|
||||||
[ -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 claude >/dev/null 2>&1 || die_quiet "找不到 claude CLI,略過記錄" "WRN"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 讀取 hook 傳入的 JSON(session_id/transcript_path/cwd/stop_hook_active)
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
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("transcript_path", "-") or "-",
|
|
||||||
"1" if d.get("stop_hook_active") else "0",
|
|
||||||
d.get("cwd", "") or "-",
|
|
||||||
)
|
|
||||||
')
|
|
||||||
EOF_HOOK
|
|
||||||
|
|
||||||
[ "$STOP_ACTIVE" = "1" ] && die_quiet "stop_hook_active 為 true,避免迴圈不重複記錄"
|
|
||||||
[ -f "$TRANSCRIPT_PATH" ] || die_quiet "找不到 transcript:${TRANSCRIPT_PATH}" "WRN"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 記錄範圍:WORKLOG_SCOPE 以冒號分隔的路徑前綴,未設定則全部 session 都記
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
if [ -n "${WORKLOG_SCOPE:-}" ]; then
|
|
||||||
in_scope=0
|
|
||||||
IFS=':' read -r -a scopes <<< "${WORKLOG_SCOPE}"
|
|
||||||
for scope in "${scopes[@]}"; do
|
|
||||||
case "$HOOK_CWD" in "${scope%/}"*) in_scope=1 ;; esac
|
|
||||||
done
|
|
||||||
[ "$in_scope" = "1" ] || die_quiet "cwd 不在 WORKLOG_SCOPE 範圍內:${HOOK_CWD}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 專案判定:git remote 的 <owner>/<repo> 優先,其次目錄名
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
PROJECT="$(basename "$HOOK_CWD")"
|
|
||||||
if git -C "$HOOK_CWD" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
||||||
origin="$(git -C "$HOOK_CWD" remote get-url origin 2>/dev/null)"
|
|
||||||
if [ -n "$origin" ]; then
|
|
||||||
cleaned="${origin%.git}"
|
|
||||||
cleaned="${cleaned##*://}"
|
|
||||||
cleaned="${cleaned#*@}"
|
|
||||||
owner_repo="$(printf '%s' "$cleaned" | awk -F/ 'NF>=2 {print $(NF-1)"/"$NF}')"
|
|
||||||
[ -n "$owner_repo" ] && PROJECT="$owner_repo"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 抽出本輪內容(最後一筆使用者訊息之後),並先做一次機密遮蔽
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
TURN="$(python3 "${SCRIPT_DIR}/transcript.py" extract "$TRANSCRIPT_PATH" 2>/dev/null)"
|
|
||||||
[ -n "$TURN" ] || die_quiet "本輪無可記錄內容"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 模型決定:WORKLOG_MODEL → 快取檔(/jsc:worklog --tune 產生)→ 保底
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
MODEL=""
|
|
||||||
MODEL_NOTE=""
|
|
||||||
if [ -n "${WORKLOG_MODEL:-}" ]; then
|
|
||||||
MODEL="${WORKLOG_MODEL}"
|
|
||||||
elif [ -f "$MODEL_CACHE" ]; then
|
|
||||||
if [ -n "$(find "$MODEL_CACHE" -mtime "+${CACHE_MAX_AGE_DAYS}" 2>/dev/null)" ]; then
|
|
||||||
MODEL="$FALLBACK_MODEL"
|
|
||||||
MODEL_NOTE=" (model: fallback)"
|
|
||||||
log "WRN" "模型快取已超過 ${CACHE_MAX_AGE_DAYS} 天,改用保底模型,建議重跑 /jsc:worklog --tune"
|
|
||||||
else
|
|
||||||
MODEL="$(grep -m1 -E '^model=' "$MODEL_CACHE" 2>/dev/null | cut -d= -f2- | tr -d '[:space:]')"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
if [ -z "$MODEL" ]; then
|
|
||||||
MODEL="$FALLBACK_MODEL"
|
|
||||||
MODEL_NOTE=" (model: fallback)"
|
|
||||||
log "WRN" "無模型快取,改用保底模型,建議執行 /jsc:worklog --tune"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 濃縮:交給模型產出精簡條目(子行程帶 WORKLOG_CHILD=1 阻斷遞迴)
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
PROMPT="$(cat <<'EOF_PROMPT'
|
|
||||||
你是工作紀錄濃縮器。輸入是一段 AI 助理與使用者的對話片段(含工具呼叫)。
|
|
||||||
請濃縮成工作紀錄條目,規則:
|
|
||||||
|
|
||||||
1. 只輸出 1 到 3 個 markdown bullet(以「- 」開頭),不要標題、不要前言、不要結語。
|
|
||||||
2. 使用繁體中文(台灣用語),每個 bullet 一行、不超過 60 字,聚焦「做了什麼、動到什麼、結果如何」。
|
|
||||||
3. 保留關鍵事實:檔案/專案/指令/數量/分支/PR/議題編號;不要抄程式碼、不要貼指令全文。
|
|
||||||
4. 嚴禁輸出任何憑證與個資:token、密碼、API key、連線字串、Email、電話、姓名、身分證號。
|
|
||||||
5. 若這段對話沒有實質工作產出(純閒聊、純提問、僅讀取資訊而未產生結論),只輸出一行:SKIP
|
|
||||||
EOF_PROMPT
|
|
||||||
)"
|
|
||||||
|
|
||||||
SUMMARY="$(printf '%s' "$TURN" | WORKLOG_CHILD=1 timeout 45 claude -p "$PROMPT" --model "$MODEL" 2>/dev/null)"
|
|
||||||
if [ -z "$SUMMARY" ]; then
|
|
||||||
log "WRN" "摘要產出為空(模型 ${MODEL}),略過本輪"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
printf '%s' "$SUMMARY" | grep -qiE '^\s*SKIP\s*$' && die_quiet "模型判定本輪無實質工作產出"
|
|
||||||
|
|
||||||
# 第二道防線:對模型輸出再做一次機密遮蔽
|
|
||||||
SUMMARY="$(printf '%s' "$SUMMARY" | python3 "${SCRIPT_DIR}/transcript.py" redact 2>/dev/null)"
|
|
||||||
# 只保留 bullet 行,避免模型帶出多餘敘述
|
|
||||||
SUMMARY="$(printf '%s\n' "$SUMMARY" | grep -E '^\s*[-*]\s+' | sed -E 's/^\s*[*]/-/' | head -5)"
|
|
||||||
[ -n "$SUMMARY" ] || die_quiet "摘要不含合法條目,略過本輪" "WRN"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 組條目並追加到當週 wiki 頁
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
STAMP="$(TZ='Asia/Taipei' date +'%Y/%m/%d %H:%M:%S')"
|
|
||||||
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
|
|
||||||
log "ERR" "寫入 wiki 失敗(專案 ${PROJECT})"
|
|
||||||
else
|
|
||||||
log "INF" "已記錄工作條目(專案 ${PROJECT},模型 ${MODEL})"
|
|
||||||
fi
|
|
||||||
|
|
||||||
exit 0
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
---
|
|
||||||
name: worklog
|
|
||||||
description: 工作證明自動記錄(worklog)的操作與維護 skill。搭配 Claude Code 的 Stop hook,把每輪工作內容濃縮成精簡條目並追加到 Gitea wiki 的當週工作紀錄頁(Worklog-yyyy-MM-W<週>),工作內容全程不落地。提供 --init(初始化週頁與環境變數指引)、--tune(判定並快取最適合的摘要模型)、--diagnose(診斷 hook 為何沒動作)、--append(手動補寫一筆)、--show(讀當週頁回顧)五個模式。當使用者說工作證明、工作紀錄、worklog、週報自動化、把工作內容寫到 wiki、記錄到 Gitea wiki、hook 沒有寫入 wiki、補寫工作紀錄、看本週做了什麼、重新判定摘要模型,或提到 WORKLOG_ENABLED/WORKLOG_HOST/WORKLOG_REPO/WORKLOG_MODEL/WORKLOG_SCOPE 時觸發。**僅支援 Claude Code/Codex/Antigravity(需 plugin 目錄保留 scripts/);不支援 OpenCode**(skills 目錄安裝不會帶入 scripts/,且自動記錄依賴 Claude Code 的 transcript 格式)。不適用於:Gitea 議題操作(用 doc-issues-sync/code-issues)、專案文件化(用 doc-funcs)。
|
|
||||||
---
|
|
||||||
|
|
||||||
# worklog — 工作證明自動記錄
|
|
||||||
|
|
||||||
把「每輪做了什麼」濃縮成一則條目,追加到 Gitea wiki 的當週工作紀錄頁。**自動記錄由 Claude Code 的 `Stop` hook 完成,不需使用者同意、不需人工觸發**;本 skill 負責自動路徑之外的人工操作:初始化、模型判定、診斷、補寫、回顧。
|
|
||||||
|
|
||||||
| 元件 | 觸發者 | 職責 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `hooks/hooks.json` 的 `Stop` hook | harness 自動 | 每輪結束抽本輪內容 → 濃縮 → 遮蔽 → 追加到當週頁 |
|
|
||||||
| 本 skill `/jsc:worklog` | 使用者/助理手動 | `--init`/`--tune`/`--diagnose`/`--append`/`--show` |
|
|
||||||
| `scripts/worklog/worklog.sh` | 上述兩者共用 | 主流程(單一實作,避免漂移) |
|
|
||||||
| `scripts/worklog/wiki_api.py` | 上述兩者共用 | token 解析、wiki 讀寫、append 重試、週頁命名 |
|
|
||||||
| `scripts/worklog/transcript.py` | 上述兩者共用 | 抽本輪片段、機密遮蔽 |
|
|
||||||
|
|
||||||
### 各助理支援範圍
|
|
||||||
|
|
||||||
| 功能 | Claude Code | Codex | Antigravity | OpenCode |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| `Stop` hook 自動記錄 | ✅ | ❌ 不讀 `hooks/hooks.json` | ❌ | ❌ |
|
|
||||||
| `--init`/`--diagnose`/`--append`/`--show` | ✅ | ⚠️ 需 plugin 目錄保留 `scripts/`(安裝後請實測一次) | ⚠️ 同左 | ❌ 缺 `scripts/` |
|
|
||||||
| `--tune` | ✅ | ❌ 無 `claude-api` skill 可載入 | ❌ 同左 | ❌ |
|
|
||||||
|
|
||||||
兩個限制的來源:
|
|
||||||
|
|
||||||
- **`Stop` hook 只有 Claude Code 讀取** `hooks/hooks.json`;且 `worklog.sh` 解析的是 **Claude Code 專屬的 transcript JSONL 結構**(`type` / `message.content` blocks),所以即使其他助理提供等效 hook 機制,自動記錄也不能直接沿用。
|
|
||||||
- **OpenCode 以「複製 `skills/` 目錄」安裝**,不會帶入 `scripts/`,本 skill 的所有模式都無法執行 —— 在 OpenCode 環境請不要觸發本 skill。
|
|
||||||
- 其他助理若要用 `--append`/`--show` 等純 wiki 操作,只需 `python3`(不需 `claude` CLI),但 `--tune` 必須改為手動設定 `WORKLOG_MODEL`。
|
|
||||||
|
|
||||||
### 腳本路徑解析(重要)
|
|
||||||
|
|
||||||
skill 執行時的工作目錄是**使用者的專案目錄**,不是 plugin 根目錄,因此**絕不可用相對路徑呼叫腳本**。先解析出 plugin 根目錄再組絕對路徑:
|
|
||||||
|
|
||||||
| 環境 | plugin 根目錄 |
|
|
||||||
| --- | --- |
|
|
||||||
| Claude Code | `${CLAUDE_PLUGIN_ROOT}` |
|
|
||||||
| 其他助理 | 本 skill 載入時提示的 base directory(`.../skills/worklog`)往上兩層 |
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Claude Code
|
|
||||||
WORKLOG_DIR="${CLAUDE_PLUGIN_ROOT}/scripts/worklog"
|
|
||||||
|
|
||||||
# 其他助理:以 skill base directory 推導(<base>/../.. 即 plugin 根)
|
|
||||||
WORKLOG_DIR="<skill base directory>/../../scripts/worklog"
|
|
||||||
```
|
|
||||||
|
|
||||||
以下各模式的指令一律以 `${WORKLOG_DIR}` 表示該目錄。若解析不到或該目錄不存在,回報「plugin 目錄未包含 scripts/worklog,本 skill 在此環境不可用」並停止,不要改用相對路徑重試。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 共用規範(必要前置)
|
|
||||||
|
|
||||||
執行本 skill 前,先以 Skill 工具載入下列共用規範並全程遵守;**任一載入不到時先詢問使用者是否安裝 generic plugin(`https://gitea.jsc.idv.tw/plugins/generic.git`),不安裝則中斷**:
|
|
||||||
|
|
||||||
- `/jsc:spec-output`:繁體中文(台灣用語)、UTF-8 無 BOM、表格與 Mermaid 優先、**寫入外部系統不得洩漏 PII**。
|
|
||||||
- `/jsc:spec-execution`:自動執行原則(必要決策才中斷)、不臆測。
|
|
||||||
- `/jsc:spec-gitea`:token 機密保護(不 echo、遮蔽、不落地)、API 分頁、host 決定順序。
|
|
||||||
- `/jsc:spec-time-log`:時間戳固定 Asia/Taipei `yyyy/MM/dd HH:mm:ss`;訊息格式 `[時間][階段][等級]: 訊息`、一行一則。
|
|
||||||
|
|
||||||
本 skill 特有補充:
|
|
||||||
|
|
||||||
- **工作內容不落地**:transcript 片段以 pipe 傳遞、wiki 走 API 不 clone,全程不產生暫存檔。唯一允許落地的是**模型快取檔** `~/.claude/worklog/model`(僅含模型 id 與判定時間,不含任何工作內容)。
|
|
||||||
- **絕不阻斷**:hook 路徑任何失敗都以 exit 0 結束,只在 stderr 留訊息。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 環境變數
|
|
||||||
|
|
||||||
| 變數 | 必要 | 說明 | 未設定 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `WORKLOG_ENABLED` | ✅ | 總開關,設為 `1` 才啟用 | hook 立即結束,完全不動作 |
|
|
||||||
| `WORKLOG_HOST` | ✅ | Gitea 主機,如 `gitea.housefun.com.tw` | 不啟用 |
|
|
||||||
| `WORKLOG_REPO` | ✅ | wiki 所在 repo,如 `H3285/WorkLog` | 不啟用 |
|
|
||||||
| `WORKLOG_MODEL` | | 強制指定摘要模型 | 讀快取檔 → 保底 `claude-haiku-4-5-20251001` |
|
|
||||||
| `WORKLOG_SCOPE` | | 冒號分隔的路徑前綴,僅這些路徑下的 session 才記 | 全部 session 都記 |
|
|
||||||
| `WORKLOG_ERRLOG` | | 錯誤訊息額外寫入的檔案路徑(只記錯誤、不含工作內容) | 只走 stderr |
|
|
||||||
|
|
||||||
**token 不需另設變數**,依固定優先序自動解析並實際驗證:
|
|
||||||
|
|
||||||
```
|
|
||||||
GITEA_TOKEN →(對目標 host 驗證失敗時)→ tea 設定檔中該 host 的 token → ~/.git-credentials
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 模式
|
|
||||||
|
|
||||||
### `--init`
|
|
||||||
|
|
||||||
1. 執行 `python3 "${WORKLOG_DIR}/wiki_api.py" probe`,回報 token 來源、Gitea 版本、當週頁狀態。
|
|
||||||
2. 當週頁不存在 → 執行 `python3 "${WORKLOG_DIR}/wiki_api.py" init` 建立(wiki 尚未初始化時一併初始化)。
|
|
||||||
3. 以表格印出應寫入 `~/.bashrc` 的 `WORKLOG_*` 變數清單;**不自動改使用者的 shell profile**(需人工確認的狀態變更)。
|
|
||||||
|
|
||||||
### `--tune`(Claude Code 專屬)
|
|
||||||
|
|
||||||
決定「目前最適合的摘要模型」並快取,`Stop` hook 只讀快取、**絕不自行呼叫 AI 判斷**(否則就變成雞生蛋,還會拖慢使用者的等待路徑)。
|
|
||||||
|
|
||||||
本模式需要 Claude Code 內建的 `claude-api` skill 與 `claude` CLI,**其他助理無法執行**:請改為手動設定 `WORKLOG_MODEL` 環境變數指定模型,或沿用保底模型。
|
|
||||||
|
|
||||||
| 步驟 | 動作 |
|
|
||||||
| --- | --- |
|
|
||||||
| 1 | 以 Skill 工具載入 `claude-api` 取當下模型清單與定價,**不憑記憶** |
|
|
||||||
| 2 | 依本任務條件評分:延遲敏感(在使用者等待路徑上)、輸出極短(1~3 行中文)、需嚴守機密過濾指令、每輪都跑一次故成本敏感 |
|
|
||||||
| 3 | Smoke test:`WORKLOG_CHILD=1 claude -p "回 OK" --model <選定 id>` 確認該模型在此帳號可用 |
|
|
||||||
| 4 | 寫入 `~/.claude/worklog/model`(`model=<id>`、`tuned_at=<時間>`、`reason=<一行理由>`),並回報選擇與理由 |
|
|
||||||
|
|
||||||
快取超過 **30 天** 視為過期:hook 改用保底模型,並在條目標記 `(model: fallback)`,`--diagnose` 會提醒重跑 `--tune`。
|
|
||||||
|
|
||||||
### `--diagnose`
|
|
||||||
|
|
||||||
逐項檢查並以表格回報,用於「hook 沒有寫入 wiki」時定位:
|
|
||||||
|
|
||||||
| 檢查項 | 判準 |
|
|
||||||
| --- | --- |
|
|
||||||
| `python3`/`claude` CLI | `command -v` 是否找得到 |
|
|
||||||
| `WORKLOG_*` 變數 | 必要三項是否齊全、`WORKLOG_SCOPE` 是否把當前路徑排除 |
|
|
||||||
| `scripts/worklog` 目錄 | `${WORKLOG_DIR}` 是否解析成功且三支腳本存在(不存在=此助理不支援) |
|
|
||||||
| token | `python3 "${WORKLOG_DIR}/wiki_api.py" probe` 的 token 來源與驗證結果 |
|
|
||||||
| wiki API | Gitea 版本、`repos/<repo>` 與當週頁狀態 |
|
|
||||||
| 模型快取 | 是否存在、是否過期、目前會用哪支模型 |
|
|
||||||
| hook 註冊 | `hooks/hooks.json` 是否存在且 plugin 已啟用 |
|
|
||||||
|
|
||||||
### `--append "<內容>"`
|
|
||||||
|
|
||||||
手動補寫一筆(hook 漏記、離線工作、或事後補充)。條目格式與自動路徑一致:
|
|
||||||
|
|
||||||
```
|
|
||||||
## <時間> — <專案> <!-- worklog:<時間戳>-manual -->
|
|
||||||
- <內容>
|
|
||||||
```
|
|
||||||
|
|
||||||
專案取當前工作目錄的 `<owner>/<repo>`;內容仍會過 `python3 "${WORKLOG_DIR}/transcript.py" redact` 遮蔽後才寫入。
|
|
||||||
|
|
||||||
### `--show`
|
|
||||||
|
|
||||||
讀當週頁(`python3 "${WORKLOG_DIR}/wiki_api.py" show`)並以表格摘要本週工作,用於回顧與週報。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 條目與頁面格式
|
|
||||||
|
|
||||||
- 週頁名稱:`Worklog-<yyyy>-<MM>-W<該月第幾週>`,第幾週 = `ceil(日/7)`(例:`2026/07/27` → `Worklog-2026-07-W4`)。
|
|
||||||
- 頁首標題:`# <yyyy> 年 <MM> 月 第 <週> 週工作紀錄`。
|
|
||||||
- 每筆條目:`## <時間> — <專案>` + 1~3 個 bullet;標題行尾帶 HTML 註解 marker(`<!-- worklog:… -->`)供寫後驗證與去重,wiki 渲染時不顯示。
|
|
||||||
- 多 session 同時寫入:`append_entry` 採「讀取 → 合併 → 寫回 → 寫後讀取驗證 marker」,未落地則重讀最新內容重試,最多 3 次。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 機密與 PII(兩道防線)
|
|
||||||
|
|
||||||
| 防線 | 位置 | 內容 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 1 | 濃縮提示詞 | 明令不得輸出 token/密碼/API key/連線字串/Email/電話/姓名/身分證號 |
|
|
||||||
| 2 | `transcript.py` 的 `redact` | 正則遮蔽:URL 內嵌憑證、40 字元 hex token、`gh?_`/`sk-` token、`token=`/`password=`、`Authorization:`、Email、台灣手機、身分證號 |
|
|
||||||
|
|
||||||
第二道防線不可移除 —— 模型有可能沒遵守指令,而 wiki 一旦寫入就留在 git 歷史裡。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 呼叫方式
|
|
||||||
|
|
||||||
| 助理 | 呼叫 |
|
|
||||||
| --- | --- |
|
|
||||||
| Claude Code / Antigravity | `/jsc:worklog --init`、`/jsc:worklog --tune`、`/jsc:worklog --diagnose`、`/jsc:worklog --append "修正 X 的 Y 問題"`、`/jsc:worklog --show` |
|
|
||||||
| Codex | `$worklog --diagnose`,或用 `/skills` 選單 |
|
|
||||||
| OpenCode | **不支援**(skills 目錄安裝不含 `scripts/`) |
|
|
||||||
Reference in New Issue
Block a user