From 2139f7dd01a690539340e1108d509798ab536ab2 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 15 Oct 2026 07:31:09 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=9A=8E=E6=AE=B5=206=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E5=9B=9B=E5=BE=85=E8=BE=A6=E4=BA=8B=E9=A0=85=EF=BC=88?= =?UTF-8?q?=E5=85=A8=E5=9F=9F=E5=BE=85=E8=BE=A6=E3=80=81=E9=A1=9E=E5=9E=8B?= =?UTF-8?q?=E8=87=AA=E8=A8=82=E3=80=81=E5=88=B0=E9=BB=9E=E6=8F=90=E9=86=92?= =?UTF-8?q?=E3=80=81=E7=A2=BA=E8=AA=8D=E5=BE=8C=E5=9F=B7=E8=A1=8C=E4=B8=A6?= =?UTF-8?q?=E5=9B=9E=E5=A0=B1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .claude-plugin/plugin.json | 6 ++ README.md | 8 +++ hooks/register.tsx | 67 +++++++++++++++++++++ hooks/skilltool.ts | 2 + hooks/todo.test.ts | 118 +++++++++++++++++++++++++++++++++++++ hooks/todo.ts | 88 +++++++++++++++++++++++++++ skills/jsc-anima/README.md | 2 + 7 files changed, 291 insertions(+) create mode 100644 hooks/todo.test.ts create mode 100644 hooks/todo.ts diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 2d0ca47..e168b57 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -188,6 +188,12 @@ "description": "覆蓋目前角色的數值軸,只存本機", "default": -1 }, + "todoRemind": { + "type": "boolean", + "title": "待辦到點提醒", + "description": "session 開著時,待辦到點由角色提醒;確認後才執行", + "default": true + }, "wikiUrl": { "type": "string", "title": "wiki 位址", diff --git a/README.md b/README.md index 94c5f75..792e82e 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,14 @@ 讀寫都在 wiki,本機只做 10 分鐘快取,離線時沿用快取。認證優先讀 tea 登入,其次 `GITEA_TOKEN`,token 不寫入任何檔案。 +## 待辦事項 + +啟動角色後可用 `/jsc-anima:anima-todo`(全域一份,記錄類型、時間、任務與所屬專案): + +- `add <類型> <任務>`、`update <編號> …`、`list`、`done <編號>` +- 類型預設:提醒、開發、研究、維護;`type <名稱>` 新增自訂類型。 +- session 開著時,到點由角色提醒(可在 /config 關閉);你輸入 `run <編號>` 確認後才執行,結果由角色告知。 + ## 安裝 ``` diff --git a/hooks/register.tsx b/hooks/register.tsx index e10e718..7e5f395 100644 --- a/hooks/register.tsx +++ b/hooks/register.tsx @@ -13,6 +13,8 @@ import { layout, meter, parseFields, remainColor, SEPARATOR, usedColor } from '. import type { Segment } from './statusline' import { CACHE_MS, addEntry, commandName, commitMessage, pageName, parseCatalog, parseWriteArgs, renderHome, renderPage, renderSidebar, teaToken, wikiUrlFromRemote } from './wiki' import type { Entry } from './wiki' +import { DEFAULT_TYPES, RUN_SYSTEM, TODO_COMMAND, TODO_KEY, TYPES_KEY, USAGE, dueNow, nextId, parseArgs as parseTodo, reminderText, renderList } from './todo' +import type { Todo } from './todo' import { NEED_ACTIVE, TOOL_COMMAND, registerTools, runSkillTool } from './skilltool' const ACTIVE_KEY = 'active' @@ -169,6 +171,64 @@ async function readWiki($: Engine, cfg: WikiCfg, name: string): Promise undefined) } +async function todoRun($: Engine, name: string, persona: string, args: string): Promise { + const todos = (((await $.store.get(TODO_KEY)) as Todo[] | undefined) ?? []).slice() + const types = ((await $.store.get(TYPES_KEY)) as string[] | undefined) ?? DEFAULT_TYPES + const p = parseTodo(args) + if (!p) return USAGE + const save = () => $.store.set(TODO_KEY, todos) + const find = (id: number) => todos.find(t => t.id === id) + if (p.op === 'list') return renderList(todos) + if (p.op === 'type') { + if (types.includes(p.name)) return `類型「${p.name}」已存在。` + await $.store.set(TYPES_KEY, [...types, p.name]) + return `已新增類型「${p.name}」。` + } + if (p.op === 'add' || p.op === 'update') { + if (!types.includes(p.type)) return `沒有「${p.type}」這個類型,現有:${types.join('、')}。可用 type 新增。` + const project = (await $.session.cwd().catch(() => '')) || '' + if (p.op === 'add') { + const t: Todo = { id: nextId(todos), type: p.type, at: p.at, task: p.task, project, status: 'pending' } + todos.push(t) + await save() + return `已建立待辦 #${t.id}。` + } + const t = find(p.id) + if (!t) return `找不到待辦 #${p.id}。` + Object.assign(t, { type: p.type, at: p.at, task: p.task, status: 'pending', result: undefined }) + await save() + return `已更新待辦 #${t.id}。` + } + const t = find(p.id) + if (!t) return `找不到待辦 #${p.id}。` + if (t.status === 'done') return `待辦 #${t.id} 已經完成。` + if (p.op === 'done') { + t.status = 'done' + await save() + return `待辦 #${t.id} 已標記完成。` + } + // 使用者輸入 run 即為確認;執行結果由角色告知。 + const res = await $.model + .complete({ model: 'sonnet', prompt: `[${t.type}] ${t.task}`, system: `${RUN_SYSTEM}\n角色:${name}。${persona}`, maxTokens: 2000, timeoutMs: 120000 }) + .catch(() => undefined) + if (!res || !res.isAnswered || !res.text.trim()) return `待辦 #${t.id} 執行失敗,仍保留為待辦。` + t.status = 'done' + t.result = res.text.trim() + await save() + return withPrefix(name, t.result) +} + +async function remindDue($: Engine, name: string): Promise { + const todos = ((await $.store.get(TODO_KEY)) as Todo[] | undefined) ?? [] + const due = dueNow(todos, await $.clock.now()) + if (!due.length) return + for (const t of due) { + t.status = 'reminded' + $.ui.log(withPrefix(name, reminderText(t))) + } + await $.store.set(TODO_KEY, todos) +} + const affinityKey = (slug: string): string => `affinity:${slug}` async function loadAffinity($: Engine, slug: string): Promise { const v = (await $.store.get(affinityKey(slug))) as Affinity | undefined @@ -229,6 +289,8 @@ export const register: Register = (on, options) => { await registerTools($) } if (active) await registerWikiTools($, wikiCfg(), wikiCommands) + const ch = findCharacter(active) + if (ch && flag('todoRemind', true)) await remindDue($, ch.name) } return next(e) }) @@ -244,6 +306,10 @@ export const register: Register = (on, options) => { if (e.command === TOOL_COMMAND) { return { text: findCharacter(active) ? await runSkillTool($, e.args) : NEED_ACTIVE } } + if (e.command === TODO_COMMAND) { + const c = findCharacter(active) + return { text: c ? await todoRun($, c.name, c.persona, String(e.args ?? '')) : NEED_ACTIVE } + } if (e.command === AFFINITY_COMMAND) { const c = findCharacter(active) if (!c) return { text: '請先啟動角色。' } @@ -297,6 +363,7 @@ export const register: Register = (on, options) => { on('prompt.submit', async ($, e, next) => { const c = isEnabled ? findCharacter(active) : undefined + if (c && flag('todoRemind', true)) await remindDue($, c.name) if (!c || e.command || e.isMeta) return next(e) prefixed = false const axes = applyOverrides(c.axes, overrides()) diff --git a/hooks/skilltool.ts b/hooks/skilltool.ts index 1708ac3..e54905d 100644 --- a/hooks/skilltool.ts +++ b/hooks/skilltool.ts @@ -1,5 +1,6 @@ import type { Engine } from 'claude-code' +import { TODO_COMMAND } from './todo' import { GUIDE_SKILL, SKILL_DIR, SKILL_PREFIX, bumpVersion, commandOf, generatePrompt, guideSkillMd, parseArgs, parseEntries, parseGenerated, renderGuide, setPluginVersion, setReadmeVersion, upsertEntry, validateSkill, @@ -10,6 +11,7 @@ export const NEED_ACTIVE = '請先啟動角色(/jsc-anima:{作品代號}-{角 export async function registerTools($: Engine): Promise { await $.command.register({ name: TOOL_COMMAND, description: '依描述產生助理技能並建立 PR:<技能名稱> <描述>' }) + await $.command.register({ name: TODO_COMMAND, description: '待辦事項:add/update/list/type/run/done' }) } const MODEL = 'sonnet' diff --git a/hooks/todo.test.ts b/hooks/todo.test.ts new file mode 100644 index 0000000..b290933 --- /dev/null +++ b/hooks/todo.test.ts @@ -0,0 +1,118 @@ +import { expect, mock, test } from 'claude-code/testing' + +const NOW = new Date(2026, 9, 15, 10, 0).getTime() + +async function setup($: any, on: any, opts: { reply?: string; cwd?: string } = {}) { + const cwd = { value: opts.cwd ?? '/proj-a' } + const logs: string[] = [] + const prompts: string[] = [] + mock.store(on) + const clock = mock.clock(on, { now: NOW }) + on('command.register', () => ({ value: { name: 'x' } }) as never) + on('session.start', () => ({ cwd: opts.cwd ?? '/proj-a' }) as never) + on('session.cwd', () => ({ value: cwd.value })) + on('command.run', () => ({ text: '' })) + on('prompt.compose', () => ({ sections: [] })) + on('prompt.submit', (_: any, e: any) => e as never) + on('ui.log', (_: any, e: any) => { + logs.push(String(e.text ?? e.message ?? JSON.stringify(e))) + return { value: undefined } as never + }) + on('model.complete', (_: any, e: any) => { + prompts.push(String(e.prompt)) + return { value: opts.reply === undefined ? { isAnswered: true, text: '已經處理好了', usage: {} } : { isAnswered: opts.reply !== '', text: opts.reply, usage: {} } } as never + }) + await $.session.start({ source: 'startup', cwd: opts.cwd ?? '/proj-a' } as never) + return { logs, prompts, clock, cwd } +} + +const todo = ($: any, args: string) => $.command.run({ command: 'anima-todo', args } as never) +const activate = ($: any) => $.command.run({ command: 'sao-asuna', args: '' } as never) + +test('未啟動角色時待辦被拒絕並提示先啟動', async ($, on) => { + await setup($, on) + const r = await todo($, 'list') + expect(r.text).toContain('請先啟動角色') +}) + +test('啟動後建立、更新與列出待辦,記錄所屬專案', async ($, on) => { + await setup($, on) + await activate($) + expect((await todo($, 'add 開發 2026-10-16 09:30 重構路由')).text).toContain('#1') + expect((await todo($, 'add 提醒 2026-10-15 11:00 喝水')).text).toContain('#2') + expect((await todo($, 'update 1 研究 2026-10-17 08:00 讀文件')).text).toContain('已更新') + const list = (await todo($, 'list')).text + expect(list).toContain('#1 [研究] 2026-10-17 08:00 讀文件') + expect(list).toContain('專案:/proj-a') + expect(list.indexOf('#2')).toBeLessThan(list.indexOf('#1')) +}) + +test('類型預設四種且可自訂,未知類型被拒絕', async ($, on) => { + await setup($, on) + await activate($) + for (const t of ['提醒', '開發', '研究', '維護']) { + expect((await todo($, `add ${t} 2026-10-16 09:00 x`)).text).toContain('已建立') + } + expect((await todo($, 'add 運動 2026-10-16 09:00 跑步')).text).toContain('沒有「運動」') + expect((await todo($, 'type 運動')).text).toContain('已新增') + expect((await todo($, 'add 運動 2026-10-16 09:00 跑步')).text).toContain('已建立') +}) + +test('參數格式錯誤時顯示用法', async ($, on) => { + await setup($, on) + await activate($) + expect((await todo($, 'add 開發 明天 重構')).text).toContain('用法') + expect((await todo($, 'add 開發 2026-02-31 09:00 x')).text).toContain('用法') +}) + +test('到點時由角色提醒一次,確認前不執行', async ($, on) => { + const { logs, prompts } = await setup($, on) + await activate($) + await todo($, 'add 維護 2026-10-15 09:00 清理暫存') + await $.prompt.submit({ text: '嗨', command: undefined, isMeta: false } as never) + expect(logs.length).toBe(1) + expect(logs[0]).toContain('亞絲娜 >') + expect(logs[0]).toContain('清理暫存') + expect(logs[0]).toContain('run 1') + await $.prompt.submit({ text: '再一次', command: undefined, isMeta: false } as never) + expect(logs.length).toBe(1) + expect(prompts.some(p => p.includes('清理暫存'))).toBe(false) +}) + +test('未到點不提醒', async ($, on) => { + const { logs } = await setup($, on) + await activate($) + await todo($, 'add 提醒 2026-10-16 09:00 明天的事') + await $.prompt.submit({ text: '嗨', command: undefined, isMeta: false } as never) + expect(logs.length).toBe(0) +}) + +test('確認後執行,並由角色告知結果', async ($, on) => { + const { prompts } = await setup($, on, { reply: '清好了三個暫存目錄' }) + await activate($) + await todo($, 'add 維護 2026-10-15 09:00 清理暫存') + const r = await todo($, 'run 1') + expect(prompts.some(p => p.includes('清理暫存'))).toBe(true) + expect(r.text).toBe('亞絲娜 > 清好了三個暫存目錄') + expect((await todo($, 'list')).text).toContain('完成') + expect((await todo($, 'run 1')).text).toContain('已經完成') +}) + +test('執行失敗時保留為待辦', async ($, on) => { + await setup($, on, { reply: '' }) + await activate($) + await todo($, 'add 開發 2026-10-15 09:00 重構') + expect((await todo($, 'run 1')).text).toContain('執行失敗') + expect((await todo($, 'list')).text).not.toContain('完成') +}) + +test('待辦全域一份:換專案仍看得到', async ($, on) => { + const { cwd } = await setup($, on, { cwd: '/proj-a' }) + await activate($) + await todo($, 'add 研究 2026-10-16 09:00 讀論文') + cwd.value = '/proj-b' + await todo($, 'add 研究 2026-10-16 10:00 另一專案的事') + const list = (await todo($, 'list')).text + expect(list).toContain('專案:/proj-a') + expect(list).toContain('專案:/proj-b') +}) diff --git a/hooks/todo.ts b/hooks/todo.ts new file mode 100644 index 0000000..bbe55b9 --- /dev/null +++ b/hooks/todo.ts @@ -0,0 +1,88 @@ +// 工具四(待辦事項)的純邏輯:解析、渲染、到點判斷。讀寫與指令在 register.tsx。 + +export const TODO_COMMAND = 'anima-todo' +export const TODO_KEY = 'todos' +export const TYPES_KEY = 'todoTypes' +export const DEFAULT_TYPES = ['提醒', '開發', '研究', '維護'] + +export type Status = 'pending' | 'reminded' | 'done' +export type Todo = { id: number; type: string; at: number; task: string; project: string; status: Status; result?: string } + +export const USAGE = + '用法:\n' + + '/jsc-anima:anima-todo add <類型> <任務> 建立\n' + + '/jsc-anima:anima-todo update <編號> <類型> <任務> 更新\n' + + '/jsc-anima:anima-todo list 列出全部\n' + + '/jsc-anima:anima-todo type <名稱> 新增自訂類型\n' + + '/jsc-anima:anima-todo run <編號> 確認後執行\n' + + '/jsc-anima:anima-todo done <編號> 標記完成(不執行)' + +const pad = (n: number) => String(n).padStart(2, '0') + +export const fmtTime = (ms: number): string => { + const d = new Date(ms) + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` +} + +// 排程時間以本機時區解讀。 +export const parseTime = (date: string | undefined, time: string | undefined): number | undefined => { + const d = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date ?? '') + const t = /^(\d{1,2}):(\d{2})$/.exec(time ?? '') + if (!d || !t) return undefined + const [y, mo, da, h, mi] = [d[1], d[2], d[3], t[1], t[2]].map(Number) as [number, number, number, number, number] + const out = new Date(y, mo - 1, da, h, mi) + if (out.getMonth() !== mo - 1 || out.getDate() !== da || h > 23 || mi > 59) return undefined + return out.getTime() +} + +export type Parsed = + | { op: 'add'; type: string; at: number; task: string } + | { op: 'update'; id: number; type: string; at: number; task: string } + | { op: 'list' } + | { op: 'type'; name: string } + | { op: 'run' | 'done'; id: number } + +const spec = (w: string[]): { type: string; at: number; task: string } | undefined => { + const [type, date, time, ...rest] = w + const at = parseTime(date, time) + const task = rest.join(' ').trim() + return type && at !== undefined && task ? { type, at, task } : undefined +} + +export const parseArgs = (args: string): Parsed | undefined => { + const w = args.trim().split(/\s+/).filter(Boolean) + const [op, ...rest] = w + const id = Number(rest[0]) + if (op === 'list' && rest.length === 0) return { op } + if (op === 'type' && rest.length === 1) return { op, name: rest[0]! } + if ((op === 'run' || op === 'done') && rest.length === 1 && Number.isInteger(id) && id > 0) return { op, id } + if (op === 'add') { + const s = spec(rest) + return s ? { op, ...s } : undefined + } + if (op === 'update' && Number.isInteger(id) && id > 0) { + const s = spec(rest.slice(1)) + return s ? { op, id, ...s } : undefined + } + return undefined +} + +export const dueNow = (todos: Todo[], now: number): Todo[] => todos.filter(t => t.status === 'pending' && t.at <= now) + +export const nextId = (todos: Todo[]): number => todos.reduce((m, t) => Math.max(m, t.id), 0) + 1 + +const STATUS_TEXT: Record = { pending: '待辦', reminded: '已提醒', done: '完成' } + +export const renderList = (todos: Todo[]): string => + todos.length === 0 + ? '目前沒有待辦。' + : todos + .slice() + .sort((a, b) => a.at - b.at) + .map(t => `#${t.id} [${t.type}] ${fmtTime(t.at)} ${t.task}(${STATUS_TEXT[t.status]},專案:${t.project || '無'})`) + .join('\n') + +export const reminderText = (t: Todo): string => + `到點了:[${t.type}] ${t.task}(#${t.id},專案:${t.project || '無'})。要我執行的話請輸入 /jsc-anima:${TODO_COMMAND} run ${t.id},確認前我不會動手。` + +export const RUN_SYSTEM = '你要執行使用者確認過的待辦,簡短回報你做了什麼與結果;沒做到的事如實說明。' diff --git a/skills/jsc-anima/README.md b/skills/jsc-anima/README.md index 506ecc1..12aff9a 100644 --- a/skills/jsc-anima/README.md +++ b/skills/jsc-anima/README.md @@ -14,3 +14,5 @@ ``` 產生新技能:`/jsc-anima:anima-skill <技能名稱> <描述>`,通過驗證與實跑後自動對預設分支建立 PR。 + +待辦事項:`/jsc-anima:anima-todo add 提醒 2026-10-16 09:00 開會`,到點由角色提醒,`run <編號>` 確認後才執行。 -- 2.53.0