Compare commits

...
2 Commits
7 changed files with 291 additions and 0 deletions
+6
View File
@@ -188,6 +188,12 @@
"description": "覆蓋目前角色的數值軸,只存本機",
"default": -1
},
"todoRemind": {
"type": "boolean",
"title": "待辦到點提醒",
"description": "session 開著時,待辦到點由角色提醒;確認後才執行",
"default": true
},
"wikiUrl": {
"type": "string",
"title": "wiki 位址",
+8
View File
@@ -15,6 +15,14 @@
讀寫都在 wiki,本機只做 10 分鐘快取,離線時沿用快取。認證優先讀 tea 登入,其次 `GITEA_TOKEN`,token 不寫入任何檔案。
## 待辦事項
啟動角色後可用 `/jsc-anima:anima-todo`(全域一份,記錄類型、時間、任務與所屬專案):
- `add <類型> <YYYY-MM-DD> <HH:MM> <任務>`、`update <編號> …`、`list`、`done <編號>`
- 類型預設:提醒、開發、研究、維護;`type <名稱>` 新增自訂類型。
- session 開著時,到點由角色提醒(可在 /config 關閉);你輸入 `run <編號>` 確認後才執行,結果由角色告知。
## 安裝
```
+67
View File
@@ -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<string |
return await $.fs.read(`${w.dir}/${pageName(e)}.md`).catch(() => undefined)
}
async function todoRun($: Engine, name: string, persona: string, args: string): Promise<string> {
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<void> {
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<Affinity> {
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())
+2
View File
@@ -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<void> {
await $.command.register({ name: TOOL_COMMAND, description: '依描述產生助理技能並建立 PR:<技能名稱> <描述>' })
await $.command.register({ name: TODO_COMMAND, description: '待辦事項:add/update/list/type/run/done' })
}
const MODEL = 'sonnet'
+118
View File
@@ -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')
})
+88
View File
@@ -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 <類型> <YYYY-MM-DD> <HH:MM> <任務> 建立\n' +
'/jsc-anima:anima-todo update <編號> <類型> <YYYY-MM-DD> <HH:MM> <任務> 更新\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<Status, string> = { 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 = '你要執行使用者確認過的待辦,簡短回報你做了什麼與結果;沒做到的事如實說明。'
+2
View File
@@ -14,3 +14,5 @@
```
產生新技能:`/jsc-anima:anima-skill <技能名稱> <描述>`,通過驗證與實跑後自動對預設分支建立 PR。
待辦事項:`/jsc-anima:anima-todo add 提醒 2026-10-16 09:00 開會`,到點由角色提醒,`run <編號>` 確認後才執行。