Compare commits

..
Author SHA1 Message Date
瑞达 441c23c0ac fix(todo): resolve dry-run targets before preview 2026-08-26 10:54:07 +08:00
瑞达 9b0a23b7d0 fix(todo): preserve epoch zero due times 2026-08-26 09:54:18 +08:00
瑞达 7764b5c4ca Merge remote-tracking branch 'origin/main' into codex/todo-skill-optimization-run1 2026-08-26 09:36:33 +08:00
瑞达 867c7e517b fix(todo): reject invalid numeric due times 2026-08-26 09:36:29 +08:00
瑞达 9c08207657 Merge remote-tracking branch 'origin/main' into codex/todo-skill-optimization-run1 2026-08-25 22:05:05 +08:00
瑞达 bd5eac3b50 fix(todo): bind batch confirmation to preview digest 2026-08-25 22:04:14 +08:00
瑞达 f1b41426ce Merge remote-tracking branch 'origin/main' into codex/todo-skill-optimization-run1 2026-08-25 20:54:30 +08:00
瑞达 465b414c2d Merge remote-tracking branch 'origin/main' into codex/todo-skill-optimization-run1 2026-08-25 20:02:31 +08:00
瑞达 ca811acc51 fix(todo): confirm batch creation before writes 2026-08-25 20:01:46 +08:00
瑞达 d3efec96ce Merge remote-tracking branch 'origin/main' into codex/todo-skill-optimization-run1 2026-08-25 19:16:14 +08:00
瑞达 2b319b2547 fix(todo): mark post-write verification failures 2026-08-21 15:23:07 +08:00
瑞达 e194008375 Merge remote-tracking branch 'origin/main' into codex/todo-skill-optimization-run1
# Conflicts:
#	internal/shortcut/todo/lifecycle.go
2026-08-21 14:51:29 +08:00
瑞达 b71509dc44 docs(todo): refine composite lifecycle routing 2026-08-21 14:50:19 +08:00
瑞达 2052a87e5e docs(todo): preserve golden route table columns 2026-08-19 11:01:59 +08:00
瑞达 2168cae031 Revert "fix(todo): confirm batch creation before writes"
This reverts commit 053b6372a3e8d82d4de3384dac9f672f732a15ac.
2026-08-19 11:01:59 +08:00
瑞达 3a2a60d155 fix(todo): confirm batch creation before writes 2026-08-19 11:01:59 +08:00
瑞达 3f0e75ea0a test(todo): cover validation branches 2026-08-19 11:01:59 +08:00
瑞达 63fe34a239 test(todo): keep script checks bytecode-free 2026-08-19 11:01:59 +08:00
瑞达 8bc19b3d3b fix(todo): preserve published schema compatibility 2026-08-19 11:01:59 +08:00
瑞达 03860cdf69 docs(changelog): avoid reserved placeholder wording 2026-08-19 11:01:59 +08:00
瑞达 05cdedcd52 docs(todo): reconcile skill guidance with main 2026-08-19 11:01:59 +08:00
瑞达 5adbbfebdb docs(changelog): note Todo workflow hardening 2026-08-19 11:01:59 +08:00
瑞达 6914a02e83 feat(todo): harden skill workflows and write safety 2026-08-19 11:01:59 +08:00
24 changed files with 1710 additions and 775 deletions
+7
View File
@@ -0,0 +1,7 @@
---
category: Changed
---
- **DingTalk task workflows** — adds strict write receipts and read-back verification,
executable parameter constraints, local dry-run plans for write shortcuts, bounded
list scripts, and per-item verification ledgers for batch creation.
+31 -20
View File
@@ -15,6 +15,7 @@ package smart
import (
"encoding/json"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
@@ -52,6 +53,7 @@ var Assign = shortcut.Shortcut{
PrimaryCLIPath: "todo +assign",
},
Description: "按姓名给某人创建并指派一条待办(自动解析 userId)",
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewPlan, RemoteReads: true},
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"已验证的单人指派待办","properties":{"taskId":{"type":"string","description":"新待办稳定 taskId"},"subject":{"type":"string","description":"待办标题"},"executorId":{"type":"string","description":"解析出的执行人 userId"},"verified":{"type":"boolean","description":"是否完成详情读回核验"}},"required":["taskId","subject","executorId","verified"],"additionalProperties":false}`)},
Interface: &contract.InterfaceSpec{
Mode: "composite",
@@ -59,10 +61,11 @@ var Assign = shortcut.Shortcut{
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "按姓名给某人创建并指派一条待办(自动解析 userId)",
UseWhen: []string{"当你想把一件事指派给某位同事、但只知道对方姓名不想先查 userId 时使用;内部先按姓名解析出唯一 userId,再创建待办并把 TA 设为执行人。会真实创建待办。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws todo +assign --to 张三 --task \"整理周报\""},
AgentSummary: "按姓名给某人创建并指派一条待办(自动解析 userId)",
UseWhen: []string{"当你想把一件事指派给某位同事、但只知道对方姓名不想先查 userId 时使用;内部先按姓名解析出唯一 userId,再创建待办并把 TA 设为执行人。会真实创建待办。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws todo +assign --to 张三 --task \"整理周报\""},
ExampleDispositions: todoStatefulPreviewExampleDispositions(),
},
},
Flags: []shortcut.Flag{
@@ -72,43 +75,51 @@ var Assign = shortcut.Shortcut{
},
Tips: []string{`dws todo +assign --to 张三 --task "整理周报"`},
Execute: func(rt *shortcut.RuntimeContext) error {
name := strings.TrimSpace(rt.Str("to"))
task := strings.TrimSpace(rt.Str("task"))
var dueMillis int64
dueProvided := rt.Changed("due")
if dueProvided {
var err error
dueMillis, err = shortcutRemindParseMillis("due", rt.Str("due"))
if err != nil {
return err
}
}
// Step 1 — resolve the assignee name to a unique userId.
user, err := resolveUser(rt, rt.Str("to"))
user, err := resolveUser(rt, name)
if err != nil {
return err
}
if rt.DryRun() {
preview := map[string]any{"dryRun": true, "executed": false, "preview_kind": "plan", "subject": task, "assigneeQuery": name, "executorId": user.userID}
if dueProvided {
preview["dueTime"] = dueMillis
}
return rt.Output(preview)
}
// Step 2 — create the todo with that user as executor. create_personal_todo
// accepts executorIds directly, so assignment is part of creation.
vo := map[string]any{
"subject": rt.Str("task"),
"subject": task,
"executorIds": []string{user.userID},
}
if rt.Changed("due") {
// create_personal_todo stores dueTime as epoch millis (int64); the todo
// helper feeds --due through parseISOTimeToMillis, so mirror that here
// (shared with +remind's --at) rather than passing a raw ISO string.
ms, err := shortcutRemindParseMillis("due", rt.Str("due"))
if err != nil {
return err
}
vo["dueTime"] = ms
if dueProvided {
vo["dueTime"] = dueMillis
}
params := map[string]any{
"PersonalTodoCreateVO": vo,
}
if rt.DryRun() {
return rt.Output(map[string]any{"dryRun": true, "executed": false, "subject": rt.Str("task")})
}
data, err := rt.CallMCPWriteDataStrict("todo", "create_personal_todo", params)
if err != nil {
return err
}
taskID, _, err := todoshortcut.VerifyCreatedTodo(rt, data, "todo/create_personal_todo", rt.Str("task"))
taskID, _, err := todoshortcut.VerifyCreatedTodo(rt, data, "todo/create_personal_todo", task)
if err != nil {
return err
}
return rt.Output(map[string]any{"taskId": taskID, "subject": rt.Str("task"), "executorId": user.userID, "verified": true})
return rt.Output(map[string]any{"taskId": taskID, "subject": task, "executorId": user.userID, "verified": true})
},
}
+12 -8
View File
@@ -67,6 +67,7 @@ var AssignMulti = shortcut.Shortcut{
PrimaryCLIPath: "todo +assign-multi",
},
Description: "把一条待办按姓名一次性指派给多个人(自动把每个姓名解析成 userId)",
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewPlan, RemoteReads: true},
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"已验证的多人指派待办","properties":{"taskId":{"type":"string","description":"新待办稳定 taskId"},"subject":{"type":"string","description":"待办标题"},"executors":{"type":"array","description":"已解析执行人摘要","items":{"type":"string"}},"count":{"type":"integer","description":"执行人数"},"verified":{"type":"boolean","description":"是否完成详情读回核验"}},"required":["taskId","subject","executors","count","verified"],"additionalProperties":false}`)},
Interface: &contract.InterfaceSpec{
Mode: "composite",
@@ -74,10 +75,11 @@ var AssignMulti = shortcut.Shortcut{
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "把一条待办按姓名一次性指派给多个人(自动把每个姓名解析成 userId)",
UseWhen: []string{"当你想把同一条待办同时指派给好几个同事、但手上只有他们的姓名而不是 userId 时使用;内部会把 --to 里的每个姓名逐个解析成唯一 userId,只要有任何一个姓名查不到或者重名有歧义,就把这些问题一次性汇总报错、并且完全不创建待办(不会建出只指派了一半人的残缺待办)。全部姓名都解析成功后,才用这些 userId 一次性创建这条待办并指派给所有人。会真实创建一条新的待办。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws todo +assign-multi --to \"张三,李四\" --task \"周五前提交排期\""},
AgentSummary: "把一条待办按姓名一次性指派给多个人(自动把每个姓名解析成 userId)",
UseWhen: []string{"当你想把同一条待办同时指派给好几个同事、但手上只有他们的姓名而不是 userId 时使用;内部会把 --to 里的每个姓名逐个解析成唯一 userId,只要有任何一个姓名查不到或者重名有歧义,就把这些问题一次性汇总报错、并且完全不创建待办(不会建出只指派了一半人的残缺待办)。全部姓名都解析成功后,才用这些 userId 一次性创建这条待办并指派给所有人。会真实创建一条新的待办。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws todo +assign-multi --to \"张三,李四\" --task \"周五前提交排期\""},
ExampleDispositions: todoStatefulPreviewExampleDispositions(),
},
},
Flags: []shortcut.Flag{
@@ -103,7 +105,6 @@ var AssignMulti = shortcut.Shortcut{
if len(names) == 0 {
return apperrors.NewValidation("--to 不能为空,请至少提供一个执行人姓名")
}
// Resolve every name up front. Collect all failures and abort before any
// write, so we never create a todo assigned to only some of the people.
var (
@@ -128,6 +129,12 @@ var AssignMulti = shortcut.Shortcut{
if len(executorIDs) == 0 {
return apperrors.NewValidation("没有解析出任何有效的执行人 userId,已中止")
}
if rt.DryRun() {
return rt.Output(map[string]any{
"dryRun": true, "executed": false, "preview_kind": "plan", "subject": task,
"assigneeQueries": names, "executorIds": executorIDs, "executors": resolved, "count": len(executorIDs),
})
}
// Create the todo once, assigning all resolved executors. Params mirror the
// todo helper's `task create` call site verbatim.
@@ -137,9 +144,6 @@ var AssignMulti = shortcut.Shortcut{
"executorIds": executorIDs,
},
}
if rt.DryRun() {
return rt.Output(map[string]any{"dryRun": true, "executed": false, "subject": task, "count": len(executorIDs)})
}
data, err := rt.CallMCPWriteDataStrict("todo", "create_personal_todo", params)
if err != nil {
return err
+26 -13
View File
@@ -16,6 +16,7 @@ package smart
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
@@ -59,6 +60,7 @@ var Remind = shortcut.Shortcut{
PrimaryCLIPath: "todo +remind",
},
Description: "给自己创建一条带可选截止时间的待办",
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewPlan, RemoteReads: false},
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"已验证的自用待办","properties":{"taskId":{"type":"string","description":"新待办稳定 taskId"},"subject":{"type":"string","description":"待办标题"},"verified":{"type":"boolean","description":"是否完成详情读回核验"}},"required":["taskId","subject","verified"],"additionalProperties":false}`)},
Interface: &contract.InterfaceSpec{
Mode: "composite",
@@ -77,10 +79,28 @@ var Remind = shortcut.Shortcut{
},
Flags: []shortcut.Flag{
{Name: "task", Type: shortcut.FlagString, Desc: "待办标题/内容", Required: true},
{Name: "at", Type: shortcut.FlagString, Desc: "截止时间(ISO8601,可选,不是提醒时间;如 2026-03-10T18:00:00+08:00)"},
{Name: "at", Type: shortcut.FlagString, Desc: "截止时间(ISO8601,可选,写入 dueTime,不是提醒时间)"},
},
Tips: []string{`dws todo +remind --task "交周报" --at 2026-03-10T18:00:00+08:00`},
Execute: func(rt *shortcut.RuntimeContext) error {
task := strings.TrimSpace(rt.Str("task"))
var dueMillis int64
atProvided := rt.Changed("at")
if atProvided {
var err error
dueMillis, err = shortcutRemindParseMillis("at", rt.Str("at"))
if err != nil {
return err
}
}
if rt.DryRun() {
preview := map[string]any{"dryRun": true, "executed": false, "preview_kind": "plan", "subject": task, "executor": "current_user"}
if atProvided {
preview["dueTime"] = dueMillis
}
return rt.Output(preview)
}
profile, err := rt.CallMCPData("contact", "get_current_user_profile", nil)
if err != nil {
return err
@@ -91,36 +111,29 @@ var Remind = shortcut.Shortcut{
}
vo := map[string]any{
"subject": rt.Str("task"),
"subject": task,
"executorIds": []string{userID},
}
// Optional due time. The todo helper feeds --due through
// parseISOTimeToMillis and stores dueTime as epoch milliseconds (int64),
// so we do the same here rather than passing a raw string.
if rt.Changed("at") {
ms, err := shortcutRemindParseMillis("at", rt.Str("at"))
if err != nil {
return err
}
vo["dueTime"] = ms
if atProvided {
vo["dueTime"] = dueMillis
}
params := map[string]any{
"PersonalTodoCreateVO": vo,
}
if rt.DryRun() {
return rt.Output(map[string]any{"dryRun": true, "executed": false, "subject": rt.Str("task")})
}
data, err := rt.CallMCPWriteDataStrict("todo", "create_personal_todo", params)
if err != nil {
return err
}
taskID, _, err := todoshortcut.VerifyCreatedTodo(rt, data, "todo/create_personal_todo", rt.Str("task"))
taskID, _, err := todoshortcut.VerifyCreatedTodo(rt, data, "todo/create_personal_todo", task)
if err != nil {
return err
}
return rt.Output(map[string]any{"taskId": taskID, "subject": rt.Str("task"), "verified": true})
return rt.Output(map[string]any{"taskId": taskID, "subject": task, "verified": true})
},
}
@@ -65,7 +65,68 @@ func TestRemindShortcutRejectsInvalidAtBeforeTodoCreate(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), "--at 时间格式无效") {
t.Fatalf("error = %v, want invalid --at validation", err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "get_current_user_profile" {
t.Fatalf("tool calls = %#v, want profile read only", fake.calls)
if len(fake.calls) != 0 {
t.Fatalf("tool calls = %#v, want local validation before any remote read", fake.calls)
}
}
func TestTodoCreateShortcutsPreserveEpochZeroDueTime(t *testing.T) {
const epoch = "1970-01-01T00:00:00Z"
tests := []struct {
name string
flag string
dryArgs []string
liveArgs []string
drySteps map[string][]calendarSmartTestStep
wantDryCalls int
}{
{
name: "assign due",
flag: "due",
dryArgs: []string{"todo", "+assign", "--to", "张三", "--task", "交周报", "--due", epoch, "--dry-run", "--yes"},
liveArgs: []string{"todo", "+assign", "--to", "张三", "--task", "交周报", "--due", epoch, "--yes"},
drySteps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}},
wantDryCalls: 1,
},
{
name: "remind at",
flag: "at",
dryArgs: []string{"todo", "+remind", "--task", "交周报", "--at", epoch, "--dry-run", "--yes"},
liveArgs: []string{"todo", "+remind", "--task", "交周报", "--at", epoch, "--yes"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
previewCaller := &calendarSmartTestCaller{steps: tt.drySteps}
preview, _, err := runCalendarSmartCLI(t, previewCaller, tt.dryArgs...)
if err != nil {
t.Fatalf("dry-run with --%s: %v", tt.flag, err)
}
if got, exists := preview["dueTime"]; !exists || got != float64(0) {
t.Fatalf("dry-run dueTime = %#v, exists=%v, want numeric zero", got, exists)
}
if got := len(previewCaller.calls); got != tt.wantDryCalls {
t.Fatalf("dry-run tool calls = %#v", previewCaller.calls)
}
if previewCaller.counts["todo/create_personal_todo"] != 0 {
t.Fatalf("dry-run attempted a write: %#v", previewCaller.calls)
}
fake := &platformCoverageCaller{}
if _, _, err := runCalendarSmartCLI(t, fake, tt.liveArgs...); err != nil {
t.Fatalf("live request with --%s: %v", tt.flag, err)
}
if len(fake.calls) != 3 {
t.Fatalf("tool calls = %#v, want resolver, create, and verification", fake.calls)
}
request, ok := fake.calls[1].args["PersonalTodoCreateVO"].(map[string]any)
if !ok {
t.Fatalf("PersonalTodoCreateVO = %#v, want object", fake.calls[1].args["PersonalTodoCreateVO"])
}
if got, exists := request["dueTime"]; !exists || got != int64(0) {
t.Fatalf("create dueTime = %#v, exists=%v, want int64 zero", got, exists)
}
})
}
}
@@ -177,21 +177,45 @@ func TestCrossPlatformCoverageCalendarSmartReadCommands(t *testing.T) {
}
func TestCrossPlatformCoverageTodoSmartWriteBranches(t *testing.T) {
for name, args := range map[string][]string{
"assign-empty-task": {"todo", "+assign", "--task", " ", "--to", "fixture person", "--yes"},
"assign-bad-due": {"todo", "+assign", "--task", "task", "--to", "fixture person", "--due", "tomorrow", "--yes"},
"remind-empty-task": {"todo", "+remind", "--task", " ", "--yes"},
} {
t.Run(name, func(t *testing.T) {
if _, _, err := runCalendarSmartCLI(t, &calendarSmartTestCaller{}, args...); err == nil {
t.Fatal("invalid todo shortcut arguments accepted")
}
})
}
assignDry := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}}}
if _, _, err := runCalendarSmartCLI(t, assignDry, "todo", "+assign", "--task", "task", "--to", "fixture person", "--due", smartCoverageEnd, "--dry-run", "--yes"); err != nil {
assignPreview, _, err := runCalendarSmartCLI(t, assignDry, "todo", "+assign", "--task", "task", "--to", "fixture person", "--due", smartCoverageEnd, "--dry-run")
if err != nil {
t.Fatal(err)
}
if assignPreview["executorId"] != "user-placeholder" || assignDry.counts["todo/create_personal_todo"] != 0 {
t.Fatalf("assign preview/calls = %#v/%#v", assignPreview, assignDry.calls)
}
if _, _, err := runCalendarSmartCLI(t, &calendarSmartTestCaller{}, "todo", "+assign", "--task", "task", "--to", "fixture person", "--yes"); err == nil {
t.Fatal("assign contact resolution failure accepted")
}
for name, responses := range map[string]map[string][]calendarSmartTestStep{
"call": {"contact/search_contact_by_key_word": {{text: smartContact().text}}, "todo/create_personal_todo": {{err: errors.New("write")}}},
"verify": {"contact/search_contact_by_key_word": {{text: smartContact().text}}, "todo/create_personal_todo": {{text: `{"result":{}}`}}},
"success": {"contact/search_contact_by_key_word": {{text: smartContact().text}}, "todo/create_personal_todo": {{text: `{"success":true,"result":{"taskId":"task-placeholder"}}`}}, "todo/get_todo_detail": {{text: `{"success":true,"result":{"todoDetailModel":{"taskId":"task-placeholder","subject":"task"}}}`}}},
} {
t.Run("assign-"+name, func(t *testing.T) {
_, _, err := runCalendarSmartCLI(t, &calendarSmartTestCaller{steps: responses}, "todo", "+assign", "--task", "task", "--to", "fixture person", "--yes")
caller := &calendarSmartTestCaller{steps: responses}
_, _, err := runCalendarSmartCLI(t, caller, "todo", "+assign", "--task", "task", "--to", "fixture person", "--due", smartCoverageEnd, "--yes")
if name == "success" {
if err != nil {
t.Fatal(err)
}
request := caller.calls[1].args["PersonalTodoCreateVO"].(map[string]any)
if got := request["executorIds"].([]string); len(got) != 1 || got[0] != assignPreview["executorId"] {
t.Fatalf("assign executorIds = %#v, preview = %#v", got, assignPreview)
}
} else if err == nil {
t.Fatal("bad assign path accepted")
}
@@ -199,26 +223,42 @@ func TestCrossPlatformCoverageTodoSmartWriteBranches(t *testing.T) {
}
multiDry := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}}}
if _, _, err := runCalendarSmartCLI(t, multiDry, "todo", "+assign-multi", "--task", "task", "--to", "fixture person", "--dry-run", "--yes"); err != nil {
multiPreview, _, err := runCalendarSmartCLI(t, multiDry, "todo", "+assign-multi", "--task", "task", "--to", "fixture person", "--dry-run")
if err != nil {
t.Fatal(err)
}
multiPreviewIDs := multiPreview["executorIds"].([]any)
if len(multiPreviewIDs) != 1 || multiPreviewIDs[0] != "user-placeholder" || multiDry.counts["todo/create_personal_todo"] != 0 {
t.Fatalf("assign-multi preview/calls = %#v/%#v", multiPreview, multiDry.calls)
}
multiSuccess := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}, "todo/create_personal_todo": {{text: `{"success":true,"result":{"taskId":"task-placeholder"}}`}}, "todo/get_todo_detail": {{text: `{"success":true,"result":{"todoDetailModel":{"taskId":"task-placeholder","subject":"task"}}}`}}}}
if _, _, err := runCalendarSmartCLI(t, multiSuccess, "todo", "+assign-multi", "--task", "task", "--to", "fixture person", "--yes"); err != nil {
t.Fatal(err)
}
multiRequest := multiSuccess.calls[1].args["PersonalTodoCreateVO"].(map[string]any)
if got := multiRequest["executorIds"].([]string); len(got) != 1 || got[0] != multiPreviewIDs[0] {
t.Fatalf("assign-multi executorIds = %#v, preview = %#v", got, multiPreview)
}
multiFailure := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}, "todo/create_personal_todo": {{err: errors.New("write")}}}}
if _, _, err := runCalendarSmartCLI(t, multiFailure, "todo", "+assign-multi", "--task", "task", "--to", "fixture person", "--yes"); err == nil {
t.Fatal("assign-multi write failure accepted")
}
doneDry := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"todo/get_user_todos_in_current_org": {{text: `{"success":true,"result":{"todoCards":[{"taskId":"task-placeholder","subject":"needle"}],"hasMore":false}}`}}}}
if _, _, err := runCalendarSmartCLI(t, doneDry, "todo", "+todo-done", "--task", "needle", "--dry-run", "--yes"); err != nil {
donePreview, _, err := runCalendarSmartCLI(t, doneDry, "todo", "+todo-done", "--task", "needle", "--dry-run")
if err != nil {
t.Fatal(err)
}
if donePreview["taskId"] != "task-placeholder" || doneDry.counts["todo/update_todo_done_status"] != 0 {
t.Fatalf("todo-done preview/calls = %#v/%#v", donePreview, doneDry.calls)
}
doneSuccess := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"todo/get_user_todos_in_current_org": {{text: `{"success":true,"result":{"todoCards":[{"taskId":"task-placeholder","subject":"needle"}],"hasMore":false}}`}}, "todo/update_todo_done_status": {{text: `{"success":true}`}}, "todo/get_todo_detail": {{text: `{"success":true,"result":{"todoDetailModel":{"taskId":"task-placeholder","isDone":true}}}`}}}}
if _, _, err := runCalendarSmartCLI(t, doneSuccess, "todo", "+todo-done", "--task", "needle", "--yes"); err != nil {
t.Fatal(err)
}
if got := doneSuccess.calls[1].args["taskId"]; got != donePreview["taskId"] {
t.Fatalf("todo-done taskId = %#v, preview = %#v", got, donePreview)
}
doneCall := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"todo/get_user_todos_in_current_org": {{text: `{"success":true,"result":{"todoCards":[{"taskId":"task-placeholder","subject":"needle"}],"hasMore":false}}`}}, "todo/update_todo_done_status": {{err: errors.New("write")}}}}
if _, _, err := runCalendarSmartCLI(t, doneCall, "todo", "+todo-done", "--task", "needle", "--yes"); err == nil {
t.Fatal("todo-done write failure accepted")
@@ -232,6 +272,10 @@ func TestCrossPlatformCoverageTodoSmartWriteBranches(t *testing.T) {
if _, _, err := runCalendarSmartCLI(t, remindDry, "todo", "+remind", "--task", "task", "--at", smartCoverageEnd, "--dry-run", "--yes"); err != nil {
t.Fatal(err)
}
remindProfileFailure := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"contact/get_current_user_profile": {{err: errors.New("profile")}}}}
if _, _, err := runCalendarSmartCLI(t, remindProfileFailure, "todo", "+remind", "--task", "task", "--yes"); err == nil {
t.Fatal("remind profile failure accepted")
}
remindSuccess := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"contact/get_current_user_profile": {{text: `{"result":{"userId":"user-placeholder"}}`}}, "todo/create_personal_todo": {{text: `{"success":true,"result":{"taskId":"task-placeholder"}}`}}, "todo/get_todo_detail": {{text: `{"success":true,"result":{"todoDetailModel":{"taskId":"task-placeholder","subject":"task"}}}`}}}}
if _, _, err := runCalendarSmartCLI(t, remindSuccess, "todo", "+remind", "--task", "task", "--yes"); err != nil {
t.Fatal(err)
+12 -8
View File
@@ -65,6 +65,7 @@ var TodoDone = shortcut.Shortcut{
PrimaryCLIPath: "todo +todo-done",
},
Description: "按标题关键词把我的某条待办标记完成(自动定位 taskId)",
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewPlan, RemoteReads: true},
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"已验证的完成结果","properties":{"taskId":{"type":"string","description":"已完成待办 taskId"},"subject":{"type":"string","description":"已完成待办标题"},"verified":{"type":"boolean","description":"是否完成详情读回核验"}},"required":["taskId","subject","verified"],"additionalProperties":false}`)},
Interface: &contract.InterfaceSpec{
Mode: "composite",
@@ -72,10 +73,11 @@ var TodoDone = shortcut.Shortcut{
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "按标题关键词把我的某条待办标记完成(自动定位 taskId)",
UseWhen: []string{"当你只记得某条待办的标题关键词、想直接把它标记完成,却不想先翻列表复制 taskId 时使用;内部先拉取你当前组织下作为执行人的待办列表,按标题(subject)包含关键词匹配:没匹配到会提示「没找到匹配待办」,匹配到多条会列出候选(标题+taskId)让你写得更精确,唯一命中时才把它标记为已完成。这会真实修改待办完成状态。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws todo +todo-done --task 周报"},
AgentSummary: "按标题关键词把我的某条待办标记完成(自动定位 taskId)",
UseWhen: []string{"当你只记得某条待办的标题关键词、想直接把它标记完成,却不想先翻列表复制 taskId 时使用;内部先拉取你当前组织下作为执行人的待办列表,按标题(subject)包含关键词匹配:没匹配到会提示「没找到匹配待办」,匹配到多条会列出候选(标题+taskId)让你写得更精确,唯一命中时才把它标记为已完成。这会真实修改待办完成状态。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws todo +todo-done --task 周报"},
ExampleDispositions: todoStatefulPreviewExampleDispositions(),
},
},
Flags: []shortcut.Flag{
@@ -87,7 +89,6 @@ var TodoDone = shortcut.Shortcut{
if keyword == "" {
return apperrors.NewValidation("请用 --task 提供待办标题关键词")
}
// Step 1 — list ALL my todos across pages (roleTypes defaults to executor,
// mirroring helpers.buildListTodoTaskArgs) so a match beyond the first page
// is still found instead of yielding a false "没找到匹配待办".
@@ -108,12 +109,15 @@ var TodoDone = shortcut.Shortcut{
"%q 匹配到 %d 条待办,请用更精确的关键词,或用 `dws todo task done --task-id` 指定:%s",
keyword, len(matches), strings.Join(shortcutTodoLabels(matches), ";")))
}
if rt.DryRun() {
return rt.Output(map[string]any{
"dryRun": true, "executed": false, "preview_kind": "plan", "taskQuery": keyword,
"taskId": matches[0].taskID, "subject": matches[0].subject,
})
}
// Step 3 — mark it done. taskId + isDone mirror helpers `todo task done`
// (update_todo_done_status, isDone passed as a string).
if rt.DryRun() {
return rt.Output(map[string]any{"dryRun": true, "executed": false, "taskId": matches[0].taskID})
}
data, err := rt.CallMCPWriteDataStrict("todo", "update_todo_done_status", map[string]any{
"taskId": matches[0].taskID,
"isDone": "true",
+11
View File
@@ -17,6 +17,7 @@ import (
"fmt"
"strconv"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
@@ -32,6 +33,16 @@ const (
todoMaxPages = 40
)
func todoStatefulPreviewExampleDispositions() []contract.ExampleDisposition {
index := 0
return []contract.ExampleDisposition{{
Index: &index, Mode: contract.ExampleDispositionModeContractOnly,
ReasonCode: contract.ExampleDispositionReasonStatefulPreflight,
Reason: "dry-run must resolve the current remote todo or user identity before preview; the isolated Agent example runner has no remote fixture",
Reviewed: true,
}}
}
// shortcutListAllTodoCards pages through get_user_todos_in_current_org, merging
// every page's todoCards into one slice so callers match/filter across the FULL
// list instead of silently seeing only the first page. base carries the call's
+13 -8
View File
@@ -38,19 +38,24 @@ func TestAllShortcutsTodoSmartContractsAndRelatedAlias(t *testing.T) {
name string
rollout output.RolloutState
result bool
dryRun bool
}{
{"related", RelatedTasks.OutputRollout, RelatedTasks.Contract.Result != nil},
{"due-today", DueToday.OutputRollout, DueToday.Contract.Result != nil},
{"assign", Assign.OutputRollout, Assign.Contract.Result != nil},
{"assign-multi", AssignMulti.OutputRollout, AssignMulti.Contract.Result != nil},
{"created", CreatedTodos.OutputRollout, CreatedTodos.Contract.Result != nil},
{"overdue", Overdue.OutputRollout, Overdue.Contract.Result != nil},
{"todo-done", TodoDone.OutputRollout, TodoDone.Contract.Result != nil},
{"remind", Remind.OutputRollout, Remind.Contract.Result != nil},
{"related", RelatedTasks.OutputRollout, RelatedTasks.Contract.Result != nil, RelatedTasks.Contract.DryRun != nil},
{"due-today", DueToday.OutputRollout, DueToday.Contract.Result != nil, DueToday.Contract.DryRun != nil},
{"assign", Assign.OutputRollout, Assign.Contract.Result != nil, Assign.Contract.DryRun != nil},
{"assign-multi", AssignMulti.OutputRollout, AssignMulti.Contract.Result != nil, AssignMulti.Contract.DryRun != nil},
{"created", CreatedTodos.OutputRollout, CreatedTodos.Contract.Result != nil, CreatedTodos.Contract.DryRun != nil},
{"overdue", Overdue.OutputRollout, Overdue.Contract.Result != nil, Overdue.Contract.DryRun != nil},
{"todo-done", TodoDone.OutputRollout, TodoDone.Contract.Result != nil, TodoDone.Contract.DryRun != nil},
{"remind", Remind.OutputRollout, Remind.Contract.Result != nil, Remind.Contract.DryRun != nil},
} {
if item.rollout != output.RolloutUnifiedActive || !item.result {
t.Errorf("%s missing unified Result contract", item.name)
}
wantDryRun := item.name == "assign" || item.name == "assign-multi" || item.name == "todo-done" || item.name == "remind"
if item.dryRun != wantDryRun {
t.Errorf("%s dry-run declaration=%v, want %v", item.name, item.dryRun, wantDryRun)
}
}
if !strings.Contains(Assign.Intent, "创建") {
t.Fatal("legacy +assign semantics must remain create-and-assign")
+63 -11
View File
@@ -5,6 +5,7 @@ package todo
import (
"encoding/json"
stderrors "errors"
"fmt"
"strconv"
"strings"
@@ -66,13 +67,19 @@ func requireTodoResponse(data map[string]any, operation string) (map[string]any,
}
func requireTodoWriteReceipt(data map[string]any, operation string) error {
data, err := requireTodoResponse(data, operation)
if err != nil {
return err
if len(data) == 0 {
return todoWriteResponseError(operation, "empty_tool_response", "写响应为空,远端效果未知")
}
success, ok := data["success"].(bool)
if !ok || !success {
return todoResponseError(operation, "missing_success_receipt", "写响应没有明确的 success=true 回执")
raw, ok := data["success"]
if !ok {
return todoWriteResponseError(operation, "missing_success_receipt", "写响应没有明确的 success=true 回执")
}
success, valid := raw.(bool)
if !valid {
return todoWriteResponseError(operation, "malformed_success", "写响应 success 字段不是布尔值")
}
if !success {
return todoWriteResponseError(operation, "remote_failure", "服务明确返回 success=false")
}
return nil
}
@@ -307,14 +314,14 @@ func VerifyCreatedTodo(rt *shortcut.RuntimeContext, data map[string]any, operati
}
taskID := todoCreatedTaskID(data)
if taskID == "" {
return "", nil, todoResponseError(operation, "missing_stable_id", "创建响应缺少稳定 taskId;远端效果未知")
return "", nil, todoWriteResponseError(operation, "missing_stable_id", "创建响应缺少稳定 taskId;远端效果未知")
}
detail, err := readTodoDetail(rt, taskID)
if err != nil {
return "", nil, err
return "", nil, todoWriteVerificationError(operation, err)
}
if subject, _ := detail["subject"].(string); expectedSubject != "" && subject != expectedSubject {
return "", nil, todoResponseError(operation, "verification_mismatch", "创建后读回的标题不一致")
return "", nil, todoWriteResponseError(operation, "verification_mismatch", "创建后读回的标题不一致")
}
return taskID, detail, nil
}
@@ -327,11 +334,11 @@ func VerifyDoneStatus(rt *shortcut.RuntimeContext, data map[string]any, taskID s
}
detail, err := readTodoDetail(rt, taskID)
if err != nil {
return err
return todoWriteVerificationError("todo/update_todo_done_status", err)
}
actual, ok := detail["isDone"].(bool)
if !ok || actual != expected {
return todoResponseError("todo/update_todo_done_status", "verification_mismatch", "写后读回 isDone 不一致或缺失")
return todoWriteResponseError("todo/update_todo_done_status", "verification_mismatch", "写后读回 isDone 不一致或缺失")
}
return nil
}
@@ -345,3 +352,48 @@ func todoResponseError(operation, reason, message string) error {
apperrors.WithReason(reason),
)
}
// todoWriteResponseError marks failures found only after a write request was
// submitted. Callers must reconcile the returned resource or receipt before
// retrying, especially for non-idempotent create/comment operations.
func todoWriteResponseError(operation, reason, message string) error {
return apperrors.NewAPI(message,
apperrors.WithOperation(operation),
apperrors.WithOrigin("mcp"),
apperrors.WithFailureStage("response_validation"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithReason(reason),
)
}
// todoWriteVerificationError upgrades a read-back failure after a confirmed
// write receipt. It preserves the original reason and cause while making the
// unsafe retry boundary explicit to callers.
func todoWriteVerificationError(operation string, cause error) error {
reason := "write_verification_failed"
origin := "mcp"
var typed *apperrors.Error
if stderrors.As(cause, &typed) {
if strings.TrimSpace(typed.Reason) != "" {
reason = typed.Reason
}
if strings.TrimSpace(typed.Origin) != "" {
origin = typed.Origin
}
}
options := []apperrors.Option{
apperrors.WithOperation(operation),
apperrors.WithOrigin(origin),
apperrors.WithFailureStage("write_verification"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithReason(reason),
apperrors.WithCause(cause),
}
message := "写操作已提交,但读回核验失败;请先查询对账,禁止直接重试"
if typed != nil && typed.Category == apperrors.CategoryAuth {
return apperrors.NewAuth(message, options...)
}
return apperrors.NewAPI(message, options...)
}
+31 -17
View File
@@ -40,6 +40,11 @@ func todoContract(command, description, useWhen string, result *contract.ResultS
}
}
func withTodoDryRun(decl corecmd.ContractDecl, kind string, remoteReads bool) corecmd.ContractDecl {
decl.DryRun = &contract.DryRunSpec{PreviewKind: kind, RemoteReads: remoteReads}
return decl
}
func todoWriteSafety(idempotency string) contract.SafetySpec {
return contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: idempotency}
}
@@ -89,14 +94,14 @@ func createTodo(rt *shortcut.RuntimeContext) error {
}
taskID := todoCreatedTaskID(data)
if taskID == "" {
return todoResponseError("todo/create_personal_todo", "missing_stable_id", "创建响应缺少稳定 taskId;远端效果未知")
return todoWriteResponseError("todo/create_personal_todo", "missing_stable_id", "创建响应缺少稳定 taskId;远端效果未知")
}
detail, err := readTodoDetail(rt, taskID)
if err != nil {
return err
return todoWriteVerificationError("todo/create_personal_todo", err)
}
if subject, _ := detail["subject"].(string); subject != title {
return todoResponseError("todo/create_personal_todo", "verification_mismatch", "创建后读回的标题不一致")
return todoWriteResponseError("todo/create_personal_todo", "verification_mismatch", "创建后读回的标题不一致")
}
return rt.Output(map[string]any{"taskId": taskID, "verified": true, "todo": detail})
}
@@ -106,12 +111,12 @@ var Create = shortcut.Shortcut{
Service: "todo", Command: "+create", Product: "todo",
Description: "创建待办并读回验证", Intent: "创建一条个人待办;只有取得稳定 taskId 且详情读回一致才报告成功。",
Risk: shortcut.RiskWrite, Safety: todoWriteSafety("non_idempotent"),
Contract: todoContract("+create", "创建待办并读回验证", "标题和执行人已确定,需要创建待办时使用", todoObjectResult("已验证的新建待办"), `dws todo +create --title "提交报告" --executors <USER_ID>`),
Contract: withTodoDryRun(todoContract("+create", "创建待办并读回验证", "标题和执行人已确定,需要创建待办时使用", todoObjectResult("已验证的新建待办"), `dws todo +create --title "提交报告" --executors <USER_ID>`), contract.DryRunPreviewRequest, false),
Flags: []shortcut.Flag{
{Name: "title", Type: shortcut.FlagString, Desc: "待办标题", Required: true},
{Name: "executors", Type: shortcut.FlagStringSlice, Desc: "执行人 userId", Required: true},
{Name: "due", Type: shortcut.FlagString, Desc: "截止时间(ISO8601)"},
{Name: "priority", Type: shortcut.FlagInt, Desc: "优先级 10/20/30/40"},
{Name: "priority", Type: shortcut.FlagInt, Desc: "优先级;--priority 仅接受 10/20/30/40"},
},
Execute: createTodo,
}
@@ -154,14 +159,14 @@ func updateTodo(rt *shortcut.RuntimeContext) error {
}
detail, err := readTodoDetail(rt, taskID)
if err != nil {
return err
return todoWriteVerificationError("todo/update_todo_task", err)
}
for key, expected := range request {
if key == "taskId" {
continue
}
if !todoUpdateFieldMatches(key, detail[key], expected) {
return todoResponseError("todo/update_todo_task", "verification_mismatch", "更新后读回字段 "+key+" 不一致")
return todoWriteResponseError("todo/update_todo_task", "verification_mismatch", "更新后读回字段 "+key+" 不一致")
}
}
return rt.Output(map[string]any{"taskId": taskID, "verified": true, "todo": detail})
@@ -214,12 +219,12 @@ var Update = shortcut.Shortcut{
Service: "todo", Command: "+update", Product: "todo",
Description: "更新待办并读回验证", Intent: "按 taskId 修改标题、截止时间或优先级,并严格读回核验。",
Risk: shortcut.RiskWrite, Safety: todoWriteSafety("idempotent"),
Contract: todoContract("+update", "更新待办并读回验证", "已知 taskId 且需要修改待办字段时使用", todoObjectResult("已验证的更新结果"), `dws todo +update --task-id <TASK_ID> --title "新标题"`),
Contract: withTodoDryRun(todoContract("+update", "更新待办并读回验证", "已知 taskId 且需要修改待办字段时使用", todoObjectResult("已验证的更新结果"), `dws todo +update --task-id <TASK_ID> --title "新标题"`), contract.DryRunPreviewRequest, false),
Flags: []shortcut.Flag{
{Name: "task-id", Type: shortcut.FlagString, Desc: "待办 taskId", Required: true},
{Name: "title", Type: shortcut.FlagString, Desc: "新标题"},
{Name: "due", Type: shortcut.FlagString, Desc: "新截止时间(ISO8601)"},
{Name: "priority", Type: shortcut.FlagInt, Desc: "新优先级 10/20/30/40"},
{Name: "priority", Type: shortcut.FlagInt, Desc: "新优先级;--priority 仅接受 10/20/30/40"},
},
Execute: updateTodo,
}
@@ -249,11 +254,11 @@ func setTodoDone(rt *shortcut.RuntimeContext, target bool) error {
}
detail, err := readTodoDetail(rt, taskID)
if err != nil {
return err
return todoWriteVerificationError("todo/update_todo_done_status", err)
}
done, ok := detail["isDone"].(bool)
if !ok || done != target {
return todoResponseError("todo/update_todo_done_status", "verification_mismatch", "完成状态读回不一致或缺失")
return todoWriteResponseError("todo/update_todo_done_status", "verification_mismatch", "完成状态读回不一致或缺失")
}
return rt.Output(map[string]any{"taskId": taskID, "isDone": target, "verified": true, "alreadyInTargetState": false})
}
@@ -267,7 +272,7 @@ func doneShortcut(command string, target bool) shortcut.Shortcut {
OutputRollout: output.RolloutUnifiedActive,
Service: "todo", Command: command, Product: "todo", Description: description, Intent: description,
Risk: shortcut.RiskWrite, Safety: todoWriteSafety("idempotent"),
Contract: todoContract(command, description, "已知 taskId 且需要改变完成状态时使用", todoObjectResult("已验证的待办状态"), "dws todo "+command+" --task-id <TASK_ID>"),
Contract: withTodoDryRun(todoContract(command, description, "已知 taskId 且需要改变完成状态时使用", todoObjectResult("已验证的待办状态"), "dws todo "+command+" --task-id <TASK_ID>"), contract.DryRunPreviewRequest, false),
Flags: []shortcut.Flag{{Name: "task-id", Type: shortcut.FlagString, Desc: "待办 taskId", Required: true}},
Execute: func(rt *shortcut.RuntimeContext) error { return setTodoDone(rt, target) },
}
@@ -323,7 +328,7 @@ var Comment = shortcut.Shortcut{
Service: "todo", Command: "+comment", Product: "todo",
Description: "添加待办评论并读回验证", Intent: "向指定待办添加评论;取得稳定 commentId 并从评论列表读回才报告成功。",
Risk: shortcut.RiskWrite, Safety: todoWriteSafety("non_idempotent"),
Contract: todoContract("+comment", "添加待办评论并读回验证", "已知 taskId 且需要发表明确评论时使用", todoObjectResult("已验证的新评论"), `dws todo +comment --task-id <TASK_ID> --content "已处理"`),
Contract: withTodoDryRun(todoContract("+comment", "添加待办评论并读回验证", "已知 taskId 且需要发表明确评论时使用", todoObjectResult("已验证的新评论"), `dws todo +comment --task-id <TASK_ID> --content "已处理"`), contract.DryRunPreviewRequest, false),
Flags: []shortcut.Flag{
{Name: "task-id", Type: shortcut.FlagString, Desc: "待办 taskId", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "评论内容", Required: true},
@@ -353,7 +358,7 @@ var Comment = shortcut.Shortcut{
}
after, err := listAllTodoComments(rt, taskID)
if err != nil {
return err
return todoWriteVerificationError("todo/add_todo_comment", err)
}
matches := make([]map[string]any, 0, 1)
for _, comment := range after {
@@ -364,7 +369,7 @@ var Comment = shortcut.Shortcut{
}
}
if len(matches) != 1 {
return todoResponseError("todo/add_todo_comment", "verification_ambiguous", "评论写入后无法在完整列表中唯一识别新增评论")
return todoWriteResponseError("todo/add_todo_comment", "verification_ambiguous", "评论写入后无法在完整列表中唯一识别新增评论")
}
commentID := todoStableString(matches[0], "commentId", "id")
return rt.Output(map[string]any{"taskId": taskID, "commentId": commentID, "content": content, "verified": true})
@@ -401,10 +406,10 @@ var Reminder = shortcut.Shortcut{
Service: "todo", Command: "+reminder", Product: "todo",
Description: "设置或清除待办提醒(仅终端回执)", Intent: "设置或清除提醒;上游无提醒读接口,结果固定 verified=false。",
Risk: shortcut.RiskWrite, Safety: todoWriteSafety("unknown"),
Contract: todoContract("+reminder", "设置或清除待办提醒(仅终端回执)", "接受无法读回核验且需要设置/清除提醒时使用", &contract.ResultSpec{
Contract: withTodoDryRun(todoContract("+reminder", "设置或清除待办提醒(仅终端回执)", "接受无法读回核验且需要设置/清除提醒时使用", &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
DataSchema: json.RawMessage(`{"type":"object","description":"提醒写入终端回执","properties":{"taskId":{"type":"string","description":"待办 taskId"},"action":{"type":"string","description":"set 或 clear"},"terminalReceipt":{"type":"boolean","description":"是否取得终端成功回执"},"verified":{"type":"boolean","description":"固定 false;上游无法读回提醒规则"}},"required":["taskId","action","terminalReceipt","verified"],"additionalProperties":false}`),
}, `dws todo +reminder --task-id <TASK_ID> --base-time dueTime --due-date-offset -30`),
}, `dws todo +reminder --task-id <TASK_ID> --base-time dueTime --due-date-offset -30`), contract.DryRunPreviewRequest, false),
Flags: []shortcut.Flag{
{Name: "task-id", Type: shortcut.FlagString, Desc: "待办 taskId", Required: true},
{Name: "clear", Type: shortcut.FlagBool, Desc: "清除全部提醒规则"},
@@ -419,6 +424,9 @@ var Reminder = shortcut.Shortcut{
if clear == (baseTime != "") {
return apperrors.NewValidation("必须且只能选择 --clear 或 --base-time")
}
if clear && (rt.Changed("due-date-offset") || rt.Changed("at")) {
return apperrors.NewValidation("--clear 不能与 --due-date-offset 或 --at 同时使用")
}
tool := "reset_todo_reminder"
params := map[string]any{"todoReminderUpdateRequest": map[string]any{"taskId": taskID, "reminderRules": []any{}}}
action := "clear"
@@ -428,11 +436,17 @@ var Reminder = shortcut.Shortcut{
if !rt.Changed("due-date-offset") {
return apperrors.NewValidation("--base-time=dueTime 要求 --due-date-offset")
}
if rt.Changed("at") {
return apperrors.NewValidation("--base-time=dueTime 不能同时提供 --at")
}
request["dueDateOffset"] = strconv.Itoa(rt.Int("due-date-offset"))
} else {
if !rt.Changed("at") {
return apperrors.NewValidation("--base-time=customTime 要求 --at")
}
if rt.Changed("due-date-offset") {
return apperrors.NewValidation("--base-time=customTime 不能同时提供 --due-date-offset")
}
millis, err := parseTodoMillis("at", rt.Str("at"))
if err != nil {
return err
+11 -8
View File
@@ -64,18 +64,21 @@ var GetMyTasks = shortcut.Shortcut{
},
},
Flags: []shortcut.Flag{
{Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "页码"},
{Name: "size", Type: shortcut.FlagString, Default: "20", Desc: "每页数量"},
{Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "页码;--page 必须大于 0;--size 必须在 1-20"},
{Name: "size", Type: shortcut.FlagString, Default: "20", Desc: "每页数量;--page 必须大于 0;--size 必须在 1-20"},
{Name: "status", Type: shortcut.FlagString, Enum: []string{"true", "false"}, Desc: "true=已完成, false=未完成"},
{Name: "priority", Type: shortcut.FlagStringSlice, Desc: "优先级过滤: 10/20/30/40"},
{Name: "role-types", Type: shortcut.FlagStringSlice, Default: "executor", Desc: "角色类型: creator/executor/participant"},
{Name: "priority", Type: shortcut.FlagStringSlice, Desc: "优先级过滤;--priority 仅接受 10/20/30/40"},
{Name: "role-types", Type: shortcut.FlagStringSlice, Default: "executor", Desc: "角色类型;--role-types 仅接受 creator/executor/participant"},
{Name: "plan-finish-start", Type: shortcut.FlagInt, Desc: "截止时间范围开始(Unix 毫秒时间戳)"},
{Name: "plan-finish-end", Type: shortcut.FlagInt, Desc: "截止时间范围结束(Unix 毫秒时间戳)"},
{Name: "all", Type: shortcut.FlagBool, Desc: "遍历全部分页;达到安全页数上限仍有下一页时失败"},
{Name: "max-pages", Type: shortcut.FlagInt, Default: "40", Desc: "--all 的最大页数(1-40)"},
{Name: "all", Type: shortcut.FlagBool, Desc: "遍历全部分页;--max-pages 仅用于 --all,且必须在 1-40"},
{Name: "max-pages", Type: shortcut.FlagInt, Default: "40", Desc: "--all 的最大页数;--max-pages 仅用于 --all,且必须在 1-40"},
},
Tips: []string{`dws todo +get-my-tasks --status false --priority 40,30`},
Execute: func(rt *shortcut.RuntimeContext) error {
if rt.Changed("max-pages") && !rt.Bool("all") {
return todoResponseError("todo/+get-my-tasks", "invalid_page_limit", "--max-pages 只能与 --all 一起使用")
}
page, err := strconv.Atoi(rt.Str("page"))
if err != nil || page < 1 {
return todoResponseError("todo/+get-my-tasks", "invalid_page", "--page 必须是大于 0 的整数")
@@ -587,8 +590,8 @@ var ListComment = shortcut.Shortcut{
},
Flags: []shortcut.Flag{
{Name: "task-id", Type: shortcut.FlagString, Desc: "待办任务 ID", Required: true},
{Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "页码"},
{Name: "size", Type: shortcut.FlagString, Default: "20", Desc: "每页数量"},
{Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "页码;--page 必须大于 0;--size 必须在 1-20"},
{Name: "size", Type: shortcut.FlagString, Default: "20", Desc: "每页数量;--page 必须大于 0;--size 必须在 1-20"},
},
Tips: []string{`dws todo +list-comment --task-id <taskId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
@@ -14,6 +14,7 @@ import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
@@ -83,6 +84,18 @@ func todoRuntimeForTest(t *testing.T, declaration shortcut.Shortcut, values map[
return shortcut.RuntimeContextForTest(cmd, declaration)
}
func requireTodoExecutionStartedError(t *testing.T, err error, wantReason string) {
t.Helper()
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error type = %T, want *errors.Error", err)
}
if typed.Reason != wantReason || typed.ExecutionStarted == nil || !*typed.ExecutionStarted ||
!typed.RetryableSet || typed.Retryable || typed.Cause == nil {
t.Fatalf("post-write failure = %#v", typed)
}
}
func TestCrossPlatformCoverageTodoCommonStrictBranches(t *testing.T) {
for _, response := range []map[string]any{{}, {"success": "yes"}, {"success": false}} {
if _, err := requireTodoResponse(response, "todo/test"); err == nil {
@@ -92,7 +105,7 @@ func TestCrossPlatformCoverageTodoCommonStrictBranches(t *testing.T) {
if _, err := requireTodoResponse(map[string]any{"result": map[string]any{}}, "todo/test"); err != nil {
t.Fatalf("response without optional success rejected: %v", err)
}
for _, response := range []map[string]any{{}, {"result": map[string]any{}}, {"success": false}} {
for _, response := range []map[string]any{{}, {"result": map[string]any{}}, {"success": "yes"}, {"success": false}} {
if err := requireTodoWriteReceipt(response, "todo/test"); err == nil {
t.Fatalf("bad receipt accepted: %#v", response)
}
@@ -297,6 +310,12 @@ func TestCrossPlatformCoverageTodoCreateAndUpdate(t *testing.T) {
if err := runTodoCoverage(t, Create, createSuccess, "--title", "x", "--executors", "u", "--yes"); err != nil {
t.Fatal(err)
}
createReadFailure := &todoCoverageCaller{responses: map[string][]string{
"create_personal_todo": {`{"success":true,"result":{"taskId":"task-1"}}`},
"get_todo_detail": {`{"success":true,"result":{}}`},
}}
err := runTodoCoverage(t, Create, createReadFailure, "--title", "x", "--executors", "u", "--yes")
requireTodoExecutionStartedError(t, err, "missing_detail")
updateCases := map[string]map[string][]string{
"call": {"update_todo_task": {"__ERROR__"}},
@@ -463,13 +482,22 @@ func TestCrossPlatformCoverageTodoCommentAndReminder(t *testing.T) {
if err := runTodoCoverage(t, Comment, commentSuccess, "--task-id", "task-1", "--content", "body", "--yes"); err != nil {
t.Fatal(err)
}
commentReadFailure := &todoCoverageCaller{responses: map[string][]string{
"list_todo_comment": {emptyPage, `{"success":true,"result":{}}`},
"add_todo_comment": {`{"success":true}`},
}}
err := runTodoCoverage(t, Comment, commentReadFailure, "--task-id", "task-1", "--content", "body", "--yes")
requireTodoExecutionStartedError(t, err, "missing_collection")
for name, args := range map[string][]string{
"none": {"--task-id", "task-1", "--yes"},
"both": {"--task-id", "task-1", "--clear", "--base-time", "dueTime", "--yes"},
"due-offset": {"--task-id", "task-1", "--base-time", "dueTime", "--yes"},
"custom-at": {"--task-id", "task-1", "--base-time", "customTime", "--yes"},
"custom-value": {"--task-id", "task-1", "--base-time", "customTime", "--at", "bad", "--yes"},
"none": {"--task-id", "task-1", "--yes"},
"both": {"--task-id", "task-1", "--clear", "--base-time", "dueTime", "--yes"},
"clear-offset": {"--task-id", "task-1", "--clear", "--due-date-offset", "-30", "--yes"},
"due-offset": {"--task-id", "task-1", "--base-time", "dueTime", "--yes"},
"due-at": {"--task-id", "task-1", "--base-time", "dueTime", "--due-date-offset", "-30", "--at", todoCoverageTime, "--yes"},
"custom-at": {"--task-id", "task-1", "--base-time", "customTime", "--yes"},
"custom-offset": {"--task-id", "task-1", "--base-time", "customTime", "--at", todoCoverageTime, "--due-date-offset", "-30", "--yes"},
"custom-value": {"--task-id", "task-1", "--base-time", "customTime", "--at", "bad", "--yes"},
} {
t.Run("reminder-"+name, func(t *testing.T) {
if err := runTodoCoverage(t, Reminder, &todoCoverageCaller{responses: map[string][]string{}}, args...); err == nil {
@@ -576,11 +604,12 @@ func TestCrossPlatformCoverageTodoVerificationHelpers(t *testing.T) {
func TestCrossPlatformCoverageTodoGetMyTasksAndListLeaves(t *testing.T) {
for name, args := range map[string][]string{
"page": {"--page", "0"},
"size": {"--size", "21"},
"priority": {"--priority", "11"},
"role": {"--role-types", "owner"},
"max": {"--all", "--max-pages", "0"},
"page": {"--page", "0"},
"size": {"--size", "21"},
"priority": {"--priority", "11"},
"role": {"--role-types", "owner"},
"max": {"--all", "--max-pages", "0"},
"max-alone": {"--max-pages", "1"},
} {
t.Run(name, func(t *testing.T) {
if err := runTodoCoverage(t, GetMyTasks, &todoCoverageCaller{responses: map[string][]string{}}, args...); err == nil {
+59 -13
View File
@@ -4,9 +4,11 @@
package todo
import (
"errors"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
)
@@ -47,26 +49,70 @@ func TestAllShortcutsTodoLifecycleContractsAreComplete(t *testing.T) {
rollout output.RolloutState
result bool
safety string
dryRun bool
}{
{"create", Create.OutputRollout, Create.Contract.Result != nil, Create.Safety.Confirmation},
{"update", Update.OutputRollout, Update.Contract.Result != nil, Update.Safety.Confirmation},
{"complete", Complete.OutputRollout, Complete.Contract.Result != nil, Complete.Safety.Confirmation},
{"reopen", Reopen.OutputRollout, Reopen.Contract.Result != nil, Reopen.Safety.Confirmation},
{"search", Search.OutputRollout, Search.Contract.Result != nil, Search.Safety.Confirmation},
{"comment", Comment.OutputRollout, Comment.Contract.Result != nil, Comment.Safety.Confirmation},
{"upload-attachment", UploadAttachment.OutputRollout, UploadAttachment.Contract.Result != nil, UploadAttachment.Safety.Confirmation},
{"reminder", Reminder.OutputRollout, Reminder.Contract.Result != nil, Reminder.Safety.Confirmation},
{"get-my-tasks", GetMyTasks.OutputRollout, GetMyTasks.Contract.Result != nil, GetMyTasks.Safety.Confirmation},
{"list-sub", ListSub.OutputRollout, ListSub.Contract.Result != nil, ListSub.Safety.Confirmation},
{"get", Get.OutputRollout, Get.Contract.Result != nil, Get.Safety.Confirmation},
{"list-attachment", ListAttachment.OutputRollout, ListAttachment.Contract.Result != nil, ListAttachment.Safety.Confirmation},
{"list-comment", ListComment.OutputRollout, ListComment.Contract.Result != nil, ListComment.Safety.Confirmation},
{"create", Create.OutputRollout, Create.Contract.Result != nil, Create.Safety.Confirmation, Create.Contract.DryRun != nil},
{"update", Update.OutputRollout, Update.Contract.Result != nil, Update.Safety.Confirmation, Update.Contract.DryRun != nil},
{"complete", Complete.OutputRollout, Complete.Contract.Result != nil, Complete.Safety.Confirmation, Complete.Contract.DryRun != nil},
{"reopen", Reopen.OutputRollout, Reopen.Contract.Result != nil, Reopen.Safety.Confirmation, Reopen.Contract.DryRun != nil},
{"search", Search.OutputRollout, Search.Contract.Result != nil, Search.Safety.Confirmation, Search.Contract.DryRun != nil},
{"comment", Comment.OutputRollout, Comment.Contract.Result != nil, Comment.Safety.Confirmation, Comment.Contract.DryRun != nil},
{"upload-attachment", UploadAttachment.OutputRollout, UploadAttachment.Contract.Result != nil, UploadAttachment.Safety.Confirmation, UploadAttachment.Contract.DryRun != nil},
{"reminder", Reminder.OutputRollout, Reminder.Contract.Result != nil, Reminder.Safety.Confirmation, Reminder.Contract.DryRun != nil},
{"get-my-tasks", GetMyTasks.OutputRollout, GetMyTasks.Contract.Result != nil, GetMyTasks.Safety.Confirmation, GetMyTasks.Contract.DryRun != nil},
{"list-sub", ListSub.OutputRollout, ListSub.Contract.Result != nil, ListSub.Safety.Confirmation, ListSub.Contract.DryRun != nil},
{"get", Get.OutputRollout, Get.Contract.Result != nil, Get.Safety.Confirmation, Get.Contract.DryRun != nil},
{"list-attachment", ListAttachment.OutputRollout, ListAttachment.Contract.Result != nil, ListAttachment.Safety.Confirmation, ListAttachment.Contract.DryRun != nil},
{"list-comment", ListComment.OutputRollout, ListComment.Contract.Result != nil, ListComment.Safety.Confirmation, ListComment.Contract.DryRun != nil},
} {
if item.rollout != output.RolloutUnifiedActive || !item.result || item.safety == "" {
t.Errorf("%s contract incomplete: rollout=%q result=%v safety=%q", item.name, item.rollout, item.result, item.safety)
}
wantDryRun := item.safety == "user_required" && item.name != "upload-attachment"
if item.dryRun != wantDryRun {
t.Errorf("%s dry-run declaration=%v, want %v", item.name, item.dryRun, wantDryRun)
}
}
if text := string(Reminder.Contract.Result.DataSchema); !strings.Contains(text, `"verified"`) || !strings.Contains(Reminder.Intent, "verified=false") {
t.Fatalf("reminder must publish terminal-only verification boundary: %s / %s", text, Reminder.Intent)
}
}
func TestCrossPlatformCoverageTodoWriteReceiptMarksExecutionStarted(t *testing.T) {
err := requireTodoWriteReceipt(map[string]any{}, "todo/create_personal_todo")
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error type = %T, want *errors.Error", err)
}
if typed.ExecutionStarted == nil || !*typed.ExecutionStarted || !typed.RetryableSet || typed.Retryable {
t.Fatalf("write failure safety = started %v retryable_set %v retryable %v", typed.ExecutionStarted, typed.RetryableSet, typed.Retryable)
}
}
func TestCrossPlatformCoverageTodoWriteVerificationPreservesReason(t *testing.T) {
for _, tc := range []struct {
name string
cause error
wantReason string
wantOrigin string
wantType apperrors.Category
}{
{"api", todoResponseError("todo/get_todo_detail", "missing_detail", "missing detail"), "missing_detail", "mcp", apperrors.CategoryAPI},
{"plain", errors.New("read timeout"), "write_verification_failed", "mcp", apperrors.CategoryAPI},
{"auth", apperrors.NewAuth("expired", apperrors.WithReason("auth_expired"), apperrors.WithOrigin("gateway")), "auth_expired", "gateway", apperrors.CategoryAuth},
} {
t.Run(tc.name, func(t *testing.T) {
err := todoWriteVerificationError("todo/create_personal_todo", tc.cause)
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error type = %T, want *errors.Error", err)
}
if typed.Category != tc.wantType || typed.Reason != tc.wantReason || typed.Origin != tc.wantOrigin ||
typed.FailureStage != "write_verification" ||
typed.ExecutionStarted == nil || !*typed.ExecutionStarted ||
!typed.RetryableSet || typed.Retryable || !errors.Is(err, tc.cause) {
t.Fatalf("verification failure = %#v, cause preserved=%v", typed, errors.Is(err, tc.cause))
}
})
}
}
+65 -69
View File
@@ -11,11 +11,13 @@ metadata:
# 钉钉待办 Skill
## 前置条件 — 执行操作前必读
## 执行契约
> **CRITICAL — 执行任何 `dws` 操作前,MUST 先用 Read 工具完整读取 [`dingtalk-shared`](../dingtalk-shared/SKILL.md)。**该轻量文件包含全局执行契约、安全底线及 shared references 的按需加载导航;不要预加载其全部 references。
> 命令参考:[todo.md](references/todo.md);剧本:[02-task.md](references/02-task.md)。
- 执行第一个 `dws` 操作前完整读取 [`dingtalk-shared`](../dingtalk-shared/SKILL.md);当前任务已加载则不重复读取。
- 先把请求拆成有序步骤,再逐步选入口。已知命令直接执行,不先查 Help、Schema 或 Shortcut Catalog;只有当前 leaf 的 flag 或安全语义确实不明时才查精确 leaf。
- 所有命令加 `--format json`,按结构化业务返回判断结果。后续 ID 只取自本次真实返回;零匹配、多匹配或类型不明时停止并消歧。
- 写操作遵循最终 Runtime gate。需要确认时先说明对象、动作和影响,用户确认后才追加 `--yes`;不要把 `--yes` 写入存储示例。
- 写后必须核验。非幂等写超时、缺少稳定 ID 或读回失败时先查询对账,禁止盲目重放。
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcuts(无专用脚本/recipe 时优先)
@@ -44,80 +46,74 @@ metadata:
| `dws todo +update` | write | 更新待办并读回验证 |
<!-- VISIBLE_SHORTCUTS_END -->
## 意图表
## 路由优先级
| 用户说 | 命令 |
|--------|------|
| "建一条待办给张三" | `dws todo task create --title "<标题>" --executors <userId>` |
| "较高 / 高优先级待办" | `dws todo task create ... --priority 30`(10低/20普通/30较高/40紧急) |
| "紧急 / 最高优先级 / 立即处理" | `dws todo task create ... --priority 40` |
| "循环待办(每天)" | `dws todo task create ... --due "<首次截止ISO>" --recurrence "DTSTART:<UTC>\nRRULE:FREQ=DAILY;INTERVAL=1"` |
| "批量建待办" | 按 SOP-4 逐条创建、收集 `taskId` 并批量回读 |
| "今天 / 本周未完成待办" | `python scripts/todo_daily_summary.py [today\|tomorrow\|week]` |
| "逾期待办" | `python scripts/todo_overdue_check.py` |
| "标记完成 / 重开" | `dws todo task done --task-id <taskId> --status true\|false` |
| "修改标题/截止时间/优先级" | `dws todo task update --task-id <taskId> ...` |
| "删除待办" | `dws todo task delete --task-id <taskId>`(需用户确认) |
上面的通用 Shortcut 优先规则只适用于**一个 Shortcut 完整覆盖当前步骤**的情况。不要因为请求里出现“创建”就默认使用 `+remind` / `+create`。
## 标准 SOP(必遵流程)
1. **先选创建入口**:按姓名指派用 `+assign` / `+assign-multi`;给自己记一条且后续只有搜索、详情或清理时用 `+remind`;已有真实 `userId` 且只需创建、回读和清理时用 `+create`。
2. **组合生命周期从原子创建开始**:创建后还要按状态/优先级/角色/日期/页码列举,或继续更新、完成/重开、提醒、评论、附件、成员、子待办、标签,或一次创建多个对象时,使用 `todo task create`。不要用创建 Shortcut 代替第一步。
3. **后续按步骤选最窄入口**:聚合、搜索和核验完整由 Shortcut 覆盖时使用 `+get-my-tasks`、`+get-related-tasks`、`+due-today`、`+overdue`、`+search`、`+get`、`+complete`、`+reopen`、`+update`、`+comment`、`+reminder` 或 `+list-*`;需要原子特有参数、动态子资源 ID、多对象或中间状态时用原子命令。
4. **确定性批量/汇总使用脚本**:批量创建、今天/明天/本周汇总、逾期扫描分别使用 bundled script。
5. **跨步骤只传稳定 ID**:Shortcut 与原子命令可以共存,但只传递规范化后的 `taskId`、`commentId`、`attachmentId`、`tagCode`、`userId`;不要假设两类入口的完整返回结构相同。
> 命中以下意图**必须**按对应 SOP 顺序执行;**禁止**跳步、替换命令、编造 taskId。每条命令必须带 `--format json`。创建/完成/删除后**必须**回读验证,不要凭创建返回或口头计划就结束。
## Golden Routes
### SOP-1 建待办(create-todo)
| 用户意图 | 首选入口 | 关键结果 / 边界 |
|---|---|---|
| 给自己创建,随后只搜索、看详情或清理 | `dws todo +remind --task "<标题>" [--at "<截止ISO>"] --format json` | 自动解析当前用户;`--at` 是截止时间,不是提醒时间 |
| “给张三建待办” | `dws todo +assign --to "张三" --task "<标题>" --format json` | 姓名必须唯一解析后才创建 |
| “给张三、李四建同一条待办” | `dws todo +assign-multi --to "张三,李四" --task "<标题>" --format json` | 任一姓名不唯一则零写入 |
| 已有 `userId`,只创建、回读和清理 | `dws todo +create --title "<标题>" --executors <USER_ID> [--due "<截止ISO>"] [--priority 10\|20\|30\|40] --format json` | 返回稳定 `taskId`,并读回核验标题 |
| 创建后还要筛选、变更资源或创建多个对象 | `dws todo task create --title "<标题>" --executors <USER_ID> ... --format json` | 从 `result.taskId` 进入组合生命周期;不要以 `+remind` / `+create` 起步 |
| 今天到期 / 已逾期 | `dws todo +due-today --format json` / `dws todo +overdue --format json` | 均有界拉全分页;空集合也是成功结果 |
| 当前组织下我的执行待办 | `dws todo +get-my-tasks --all --status false --format json` | `--all` 达到 40 页仍未耗尽会失败,不伪装完整 |
| 与我相关的全部待办 | `dws todo +get-related-tasks --format json` | 创建人、执行人、参与人三种角色并集,按 `taskId` 去重 |
| 按标题关键词查询 | `dws todo +search --query "<关键词>" --format json` | 搜索与 list 不混用;跨全部分页匹配 |
| 已知 `taskId` 查详情 | `dws todo +get --task-id <TASK_ID> --format json` | 详情必须回传同一个稳定 `taskId` |
| 已知 `taskId` 完成 / 重开 | `dws todo +complete --task-id <TASK_ID> --format json` / `dws todo +reopen ...` | 先读当前状态,避免重复写,再读回核验 |
| 只记得标题,标记完成 | `dws todo +todo-done --task "<关键词>" --format json` | 仅唯一命中时写;零个或多个候选均停止 |
| 修改标题、截止时间或优先级 | `dws todo +update --task-id <TASK_ID> ... --format json` | 至少指定一个待改字段;写后逐字段核验 |
| 设置独立提醒 | `dws todo +reminder --task-id <TASK_ID> --base-time customTime --at "<提醒ISO>" --format json` | 上游无提醒查询接口,只能返回终端写回执,`verified=false` |
| 基于截止时间提前提醒 | `dws todo +reminder --task-id <TASK_ID> --base-time dueTime --due-date-offset -30 --format json` | 待办必须已有截止时间;偏移单位为分钟 |
| 清除全部提醒 | `dws todo +reminder --task-id <TASK_ID> --clear --format json` | 清除写操作;不能与提醒参数混用 |
| 批量创建 | `python scripts/todo_batch_create.py <todos.json> --dry-run` | 预览返回稳定 `planDigest`;执行必须提交用户确认的同一摘要,内容变化会在零调用时拒绝 |
| 今天/明天/本周汇总 | `python scripts/todo_daily_summary.py today\|tomorrow\|week` | 走 `+get-my-tasks --all`,只纳入范围内且有截止时间的未完成待办 |
**触发**:建待办/任务提醒/指派任务/TODO。
## 低频原子能力
1. **解析执行者(必须)**:指定姓名 → `dws aisearch person --query "<姓名>" --dimension name --format json` 取 `userId`;未指定 → `dws contact user get-self --format json` 取当前用户 `userId`;多人逐个搜索后英文逗号拼接。
2. **执行(必须)**:`dws todo task create --title "<标题>" --executors <userId>[,<userId2>...] --priority <10/20/30/40> --format json`;有截止时间加 `--due "<ISO>"`;循环待办加 `--due "<首次截止ISO>" --recurrence "DTSTART:<UTC>\nRRULE:FREQ=DAILY;INTERVAL=1"`。
3. **验证(必须)**:从返回取 `taskId`/`todoTaskId`,立即 `dws todo task get --task-id <taskId> --format json` 回读。
组合请求先读 [组合生命周期](references/02-task.md)。以下常用原子命令已审定,直接执行,不要先猜别名或查 Help:
**禁止**:跳过执行者解析直接传姓名、用 `task detail` 取详情(正确是 `task get`)、创建后不回读。
| 意图 | 命令骨架 |
|---|---|
| 解析自己 / 姓名 | `dws contact user get-self --format json` / `dws aisearch person --query "<姓名>" --dimension name --format json` |
| 创建 / 子待办 | `dws todo task create ...` / `dws todo task create-sub --parent-id <PARENT_ID> --title "<标题>" --executors <USER_ID> ...` |
| 列表 / 详情 | `dws todo task list [--status true\|false] [--priority ...] [--role-types ...] [--page N --size N] ...` / `dws todo task get --task-id <TASK_ID>` |
| 更新 / 完成或重开 | `dws todo task update --task-id <TASK_ID> ...` / `dws todo task done --task-id <TASK_ID> --status true\|false` |
| 增删执行人 / 参与人 | `task add-executor` / `task remove-executor` / `task add-participant` / `task remove-participant`,均传真实 `taskId` 与 `userId` |
| 评论 | `comment add` / `comment list` / `comment delete`;删除使用列表返回的真实 `commentId` |
| 附件 | `task add-attachment --file <绝对路径>` / `task list-attachment` / `task remove-attachment` |
| 提醒 | `task add-reminder` 添加单条;`task reset-reminder` 替换全部或清空 |
| 标签 | `tag create` / `tag list` / `tag update` / `tag add` / `tag delete`;只使用真实 `tagCode` |
| 删除待办 | `dws todo task delete --task-id <TASK_ID> --format json` |
### SOP-2 查询待办(query-todo)
删除类操作必须由用户确认;若用户在当前请求中已明确授权“办完后删除/清理本次创建对象”,该授权只覆盖本次记录的精确 ID。附件上传会真实传输本地文件,不能用来试探权限。
**触发**:查待办/今天本周待办/未完成/已完成。
## 关键约束
1. **执行(必须)**:`dws todo task list --status false|true --format json`(`false`=未完成、`true`=已完成、不传=全部);`hasMore=true` 必须翻页。
2. **摘要脚本(必须)**:今天/本周未完成 → `python scripts/todo_daily_summary.py today|tomorrow|week`;逾期 → `python scripts/todo_overdue_check.py`。
3. **详情(必须)**:`dws todo task get --task-id <taskId> --format json`;按主题筛选先 `task list` 再按标题过滤,**禁止**编造主题查询 flag。
- 标题、URL、展示序号都不是 `taskId`。已知 ID 直接行动;未知 ID 用列表/搜索定位,零匹配或多匹配时停止。
- 待办公开命令统一使用 `--task-id`;`--id` / `--ids` 只是隐藏兼容别名,不要写入新命令或示例。
- 优先级:低=10、普通=20、较高/高/重要=30、紧急/最高/P0=40。
- `--due` / `+remind --at` 表示 deadline;独立 reminder 必须走 `+reminder`。
- 自定义时间提醒的原子 flag 是 `--reminder-time-stamp`;不要把 Shortcut 的 `--at` 套到 `task add-reminder`。
- `task list` 使用 `--status`,不要写 `--done`;详情是 `task get`,不存在 `task detail`。
- “待办标签”始终属于 Todo,使用 `dws todo tag ...`;绝不能解释成 Git tag、通讯录标签或其他产品标签。
- 创建和评论是非幂等写。超时、缺少稳定 ID 或读回失败时保留“可能已提交/未核验”状态,先查询对账,禁止盲目重放。
- 所有命令加 `--format json`;写 Shortcut 按 Runtime 安全契约确认,确认前不得自行附加 `--yes`。
- 会后行动项来自听记时先走 `dingtalk-minutes`;OA 审批走 `dingtalk-misc`;时间块和会议走 `dingtalk-calendar`。
**禁止**:写 `--done true`(用 `--status true`)、编造主题筛选参数。
## 按需参考
### SOP-3 完成 / 重开 / 改 / 删(mutate-todo)
**触发**:标记完成/重开/改标题截止优先级/删待办。
1. **执行(必须)**:完成/重开 `dws todo task done --task-id <taskId> --status true|false --format json`;修改 `dws todo task update --task-id <taskId> ...`;删除 `dws todo task delete --task-id <taskId>`(**必须**先与用户确认)。
2. **验证(必须)**:`task done`/`update` 后用 `task get` 或对应 `task list --status ...` 回读确认;`delete` 后用 `task get` 确认已不存在或列表已移除。
**禁止**:未确认就删除、用 `update --done`(首选 `task done --status`)、改动后不回读。
### SOP-4 批量建待办(batch-create)
**触发**:批量建待办/一次建多条。
1. **解析(必须)**:执行者姓名先批量解析成真实 `userId`;单批最多 30 条。
2. **执行(必须)**:对每条待办执行 `dws todo task create --title "<标题>" --executors <userId> --priority <10/20/30/40> [--due "<ISO>"] --format json`,逐条收集返回的 `taskId`/`todoTaskId`。可并行执行,但不得丢失“输入条目 → taskId”对应关系。
3. **验证(必须)**:对全部新建 `taskId` 执行 `dws todo task get --task-id <taskId> --format json` 回读;多 ID 按共享并行规则处理,全部成功后才能报告批量创建完成。
**禁止**:只统计创建命令退出码、不保留 taskId、创建后不回读、在执行者位置传姓名。
## 参数硬约束
- 任务详情只用 `dws todo task get --task-id <taskId>`;不要写 `task detail`。
- 完成状态首选 `dws todo task done --task-id <taskId> --status true|false`;若用 `update`,也必须是 `--task-id` + `--done true|false`。
- 查询列表完成状态用 `dws todo task list --status false|true --format json`。不要写 `--done true` 作为可见参数,虽然兼容但不作为推荐写法。
- `--id` / `--ids` 是隐藏兼容别名,文档和生成命令统一写 `--task-id`,减少模型漂移。
- 优先级映射:低=10,普通=20,较高/高/重要=30,紧急/最高/P0/马上处理=40;不要把"较高"写成 40。
- 截止时间必须是 ISO-8601。相对日期按当前日期计算;例如周五说"下周二"就是紧接下一个自然周的周二,不要再加一周。
- 创建、标记完成、重开、删除后必须 `task get` 或对应 `task list --status ...` 验证,不要只凭创建返回或口头计划结束。
- 所有 dws 命令带 `--format json`。
## 跨产品协作
- 执行人是人名 → 先用 `dingtalk-aisearch` 拿 `userId`
- 会后从听记自动建待办 → 切到 `dingtalk-minutes`
- 项目进度汇总写文档 → 切到 `dingtalk-doc`
## 局部意图与短流程
- [局部意图消歧](references/intent-guide.md);[短流程](references/lite-recipes.md)。
- [局部意图消歧](references/intent-guide.md)
- [轻量流程](references/lite-recipes.md)
- [组合流程](references/02-task.md)
- [完整命令参考](references/todo.md)
@@ -1,19 +1,70 @@
# 任务管理
# Todo 组合生命周期
> **SKILL.md** 中 #2 仅内联 **lite**:`create-todo`、`todo-query-ops`。其中 `todo-query-ops` 统一覆盖 list/get/complete/reopen/topic-filter。下列 recipe 已迁出速查表,命中时读本文件对应行。重型 **full** 见下表「行动指南」。命令细节见 [todo.md](./todo.md)。
当一个请求包含多个资源动作并需要传递 `taskId`、`commentId`、`attachmentId`、`tagCode` 或 `userId` 时使用本文件。先拆步骤,再逐步选择能完整覆盖当前步骤的 Shortcut 或原子命令;不要因为第一步是创建就让 `+remind` / `+create` 接管整个流程。
## Recipe 速查(非 SKILL lite)
## 通用骨架
| Recipe | 步骤(命令均须 `--format json`,下略) |
|--------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `create-priority-todo` | 1. 确定执行者(同 [SKILL.md](../SKILL.md) 中 `create-todo` 步骤 1)<br>2. `todo task create --title "<标题>" --executors <userId>[,<userId2>...] --priority <10/20/30/40>`(可选 `--due "<截止ISO>"`;10低/20普通/30较高/40紧急)→ 取 `todoTaskId`<br>3. `todo task get --task-id <todoTaskId>` 回读标题、执行者、优先级和截止时间 |
| `create-recurring-todo` | 1. 确定执行者(同 `create-todo` 步骤 1)<br>2. `todo task create --title "<标题>" --executors <userId> --due "<首次截止ISO>" --priority <10/20/30/40> --recurrence "DTSTART:<UTC时间>\nRRULE:FREQ=DAILY;INTERVAL=1"`(`--due` 必填;仅支持按天循环,见 [todo.md](./todo.md))→ 取 `todoTaskId`<br>3. `todo task get --task-id <todoTaskId>` 回读循环规则和任务字段 |
| `reschedule-todo` | 1. `todo task list --status false` → 取 `todoTaskId`<br>2. `todo task update --task-id <todoTaskId> --due "<新截止时间>"` |
1. 列出完整动作序列和需要传递的 ID;同一链路使用同一个 profile。
2. 选择创建入口:
- 按姓名创建并指派:`+assign` / `+assign-multi`。
- 给自己简单创建,后续只有搜索、详情或清理:`+remind`。
- 已有真实 `userId`,后续只有回读或清理:`+create`。
- 后续需要列表筛选、更新、完成/重开、提醒、评论、附件、成员、子待办、标签或多对象:使用原子 `task create`。
3. 原子创建前解析执行人:未指定执行人用 `dws contact user get-self --format json`;指定姓名用 `dws aisearch person --query "<姓名>" --dimension name --format json`,唯一匹配后取 `userId`。
4. 原子创建:
## Full / 组合(固定路线)
```bash
dws todo task create --title "<标题>" --executors <USER_ID> [--priority 10|20|30|40] [--due "<截止ISO>"] [--recurrence "<规则>"] --format json
```
| Recipe | 行动指南(固定路线) |
|--------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| generate-progress-report | 1. 按[「多源并行采集」](recipes/conventions.md#多源并行采集公共模式)执行<br>2. 交叉比对各源数据<br>3. `doc create --name "<报告名>" --content "<报告内容>"` |
| batch-create-todo | 1. 按[「多源并行采集」](recipes/conventions.md#多源并行采集公共模式)执行 → 从结果提取任务条目<br>2. 每条:`aisearch person --query "<姓名>" --dimension name` → 取真实 `userId`;同名时先消歧<br>3. 逐条执行 `todo task create --title "<标题>" --executors <userId> --priority <10/20/30/40>`,从每次响应收集真实 `todoTaskId`;单批超 30 条须用户确认<br>4. 对全部 `todoTaskId` 并行执行 `todo task get --task-id <todoTaskId>`,逐项核对标题、执行者、优先级和截止时间;不能只以退出码或创建响应作为成功证据 |
| assign-and-notify | 1. `aisearch person --query "<姓名>" --dimension name` → 取 `userId`<br>2. `todo task create --title "<标题>" --executors <userId> --priority <10/20/30/40>` → 取 `todoTaskId`<br>3. `todo task get --task-id <todoTaskId>` 回读任务,确认无误后再通知<br>4. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message send --conversation-id <openConversationId> --content "<通知内容>"` |
5. 原子创建只从成功响应的 `result.taskId` 取 ID;创建 Shortcut 从其成功业务结果的 `taskId` 取 ID。不要跨入口猜字段层级。
6. 按下表执行后续步骤。Shortcut 完整覆盖一个步骤时可使用其核验能力;需要原子特有参数、动态子资源 ID、多对象或中间状态时使用原子命令。
7. 只清理本次创建且账本中已有精确 ID 的对象。删除后用 `task get` 不存在或对应列表移除验证。
## 步骤路由表
| 当前步骤 | 首选条件 | 入口 / 核验 |
|---|---|---|
| 当前组织我的未完成待办 | 需要有界拉全 | `+get-my-tasks --all --status false` |
| 与我相关的全部待办 | 需要按创建人/执行人/参与人合并去重 | `+get-related-tasks` |
| 今天到期 / 逾期 | 需要完整聚合 | `+due-today` / `+overdue` |
| 按标题关键词定位 | 需要跨页唯一匹配 | `+search --query "<关键词>"` |
| 按状态/优先级/角色/日期/页码列举 | 需要指定筛选条件或控制分页 | `task list --status ... --priority ... --role-types ... --plan-finish-date-start ... --plan-finish-date-end ... --page N --size N`;`hasMore=true` 继续下一页 |
| 已知 ID 查详情 | 需要稳定投影和 ID 核验 | `+get --task-id <TASK_ID>`;需要原始字段时用 `task get` |
| 单次更新并读回 | Shortcut flags 完整覆盖 | `+update --task-id <TASK_ID> ...` |
| 更新后还需中间列表或多对象对比 | 需要原子中间状态 | `task update --task-id <TASK_ID> ...`,再 `task get/list` |
| 单次完成 / 重开并读回 | 不需要按状态列表检查 | `+complete` / `+reopen` |
| 完成/重开后按状态列表确认,或处理多个/子待办 | 需要中间状态 | `task done --task-id <TASK_ID> --status true\|false`,再 `task list/get` |
| 创建子待办 | 需要父 ID、执行人和子 ID | `task create-sub --parent-id <PARENT_ID> --title "<标题>" --executors <USER_ID> ...`,取 `result.taskId` |
| 列出子待办 | 只需聚合读取 | `+list-sub --task-id <PARENT_ID>`;需要原始子 ID/状态时用 `task list-sub` |
| 增删执行人 | 成员变更 | `task add-executor/remove-executor --task-id <TASK_ID> --executors <USER_IDS>`,再 `task get` |
| 增删参与人 | 关注关系变更 | `task add-participant/remove-participant --task-id <TASK_ID> --participants <USER_IDS>`,再 `task get` |
| 添加一条评论并读回 | 不需要删除评论 | `+comment --task-id <TASK_ID> --content "<内容>"` |
| 多评论、评论 ID 或删除评论 | 需要动态评论 ID | `comment add --task-id <TASK_ID> --content "<内容>"`;`comment list --task-id <TASK_ID>`;从列表取真实 ID 后 `comment delete --task-id <TASK_ID> --comment-id <COMMENT_ID>` |
| 上传附件 | 本地文件已存在 | `task add-attachment --task-id <TASK_ID> --file <绝对路径>`,再列附件 |
| 查看或移除附件 | 只读可用 Shortcut;移除需要动态 ID | `+list-attachment --task-id <TASK_ID>`;需要 `attachmentId` 时用 `task list-attachment`,再 `task remove-attachment --task-id <TASK_ID> --attachment-id <ATTACHMENT_ID>` |
| 添加或清除一条提醒意图 | Shortcut 参数完整覆盖 | `+reminder`;只报告终端写回执 |
| 多提醒、整体替换或清空后继续重设 | 需要原子规则数组 | 截止偏移:`task add-reminder --base-time dueTime --due-date-offset -30`;自定义:`--base-time customTime --reminder-time-stamp "<提醒ISO>"`;替换/清空:`task reset-reminder [--reminder-rules '<JSON数组>']` |
| 标签生命周期 | 需要真实 `tagCode` | `tag create --name "<名称>"`;`tag list`;改名 `tag update --user-tags '[{"code":"<TAG_CODE>","name":"<新名称>"}]'`;关联 `tag add --task-id <TASK_ID> --tag-codes <TAG_CODES>`;删除 `tag delete --tag-codes <TAG_CODES>` |
| 删除待办 | 已获得本次授权和真实 ID | `task delete --task-id <TASK_ID>`,再 `task get` 验证不存在 |
所有表中命令都加前缀 `dws todo` 和后缀 `--format json`。
## 动态 ID 账本
| ID | 只允许来自 | 禁止来源 |
|---|---|---|
| `taskId` | 原子 `task create/create-sub/get/list` 或 Todo Shortcut 的成功业务返回 | 标题、URL、展示序号、其他 case |
| `commentId` | 同一 `taskId` 的 `+comment` 成功结果或 `comment list` 返回 | 评论文本或猜测 |
| `attachmentId` | 同一 `taskId` 的 `task list-attachment` 返回 | 文件名或本地路径 |
| `tagCode` | `todo tag create/list` 返回的 `result.userTags[].code` | 标签名或 Git tag |
| `userId` | `contact user get-self` 或 `aisearch person` 的唯一匹配 | 姓名、`me/self`、其他 profile |
“待办标签”只能调用 `dws todo tag ...`;禁止运行 `git tag`。本地待办附件只能调用 `dws todo task add-attachment`,不要改走 Drive。
## 失败与恢复
- `unknown command/flag`:查该精确 leaf 的 `--help` 后最多修正一次,不轮询相似命令。
- 创建、评论等非幂等写超时或返回不明:保留“可能已提交”,先按标题/父 ID 查询对账,不自动重试。
- 提醒接口没有查询能力:成功时只报告服务端接受写入;失败时保留原错误。
- 任一步失败后仍按账本清理已创建的临时对象;未取得稳定 ID 的对象不得猜 ID 清理。
@@ -1,11 +1,15 @@
# todo 局部意图消歧
# Todo 局部意图消歧
本文件从单 Skill `intent-guide.md` 拆分而来,仅保留与本产品相关的跨产品消歧规则。
| 用户说 | 应该用 | 不要用 | 边界 |
|---|---|---|---|
| “帮我记一下明天要做的事” | `todo +remind` | `doc` | 这是个人待办;`--at` 写截止时间 |
| “给自己留一个明天下午的时间块” | `calendar event create` | `todo` | 时间块属于日历事件 |
| “明早 9 点提醒我提交周报” | 先创建待办,再用 `todo +reminder --base-time customTime --at ...` | 把 `+remind --at` 当提醒 | 独立提醒与截止时间是两种资源字段 |
| “截止前 30 分钟提醒” | `todo +reminder --base-time dueTime --due-date-offset -30` | `calendar` | 待办必须先有截止时间 |
| “每天重复提醒我” | `todo task create --due ... --recurrence ...`,必要时再加 reminder | 只写 reminder | recurrence 管重复待办;reminder 管单条待办提醒规则 |
| “创建/改名/删除待办标签” | `dws todo tag create/update/delete` | `git tag`、通讯录标签、其他产品标签 | “待办标签”由 Todo 产品拥有;后续只传真实 `tagCode` |
| “提交日报/周报” | `dingtalk-misc` | `todo` | 日志产品不是个人待办 |
| “审批这个申请” | `dingtalk-misc` | `todo` | OA 审批任务与个人待办不同 |
| “把会议行动项建成待办” | 先走 `dingtalk-minutes` 取真实行动项,再进入 Todo 创建路线 | 凭标题猜行动项 | 来源证据归听记,任务对象归 Todo |
| 用户说... | 真实意图 | 应该用 | 不要用 | 理由 |
|---|---|---|---|---|
| "帮我记一下明天要做的事" | 创建个人待办 | `todo` | `doc` | 个人待办提醒,非文档内容 |
| "给自己留一个明天下午的时间块/建个个人日程" | 创建个人日程 | `calendar event create` | `todo` | 个人 schedule 仍属于日历事件,不是待办 |
| "明早 9 点提醒我提交周报" | 创建个人待办,但需先声明 reminder 边界 | `todo` | `calendar` | todo 当前只支持 dueTime 截止时间,不支持独立精确 reminder |
| "帮我创建一个待办提醒" | 个人待办 | `todo` | `report` | 个人任务提醒,不是日志汇报 |
| "把最近几次关于XX的会议汇总成报告" | 按主题汇总多次听记 | #5 generate-topic-report | #7 meeting-followup | #7 是单次会议听记跟进;多次会议按主题汇总属于工作汇报 |
提醒写入目前没有对应的查询接口,成功响应只能证明服务端接受了写请求,不能声称已经读回核验规则。
@@ -1,151 +1,68 @@
# todo Lite Recipe
# Todo 单步与短流程
本文件从单 Skill `lite-recipes.md` 拆分而来,仅保留与本产品相关的轻量流程。
用于单步 Todo 意图,也用于组合请求中被 Shortcut 完整覆盖的独立步骤。组合请求先按 [02-task.md](02-task.md) 拆分;涉及列表筛选、资源写入、多对象或动态子资源 ID 时,不要用创建 Shortcut 代替原子 `task create`。
## #2 任务管理
## 创建
### create-todo
1. 确定执行者:指定姓名 → `aisearch person --query "<姓名>" --dimension name` → `userId`;未指定 → `contact user get-self` → `userId`;多人 → 逐个搜索逗号拼接。
2. 创建:`todo task create --title "<标题>" --executors <userId>[,<userId2>...] --priority <优先级>`(可选 `--due "<截止ISO>"`)→ `todoTaskId`
### todo-query-ops
- 查询:`todo task list [--status false|true]`(不传=全部)
- 详情:`todo task get --task-id <id>`
- 完成/重开:`todo task done --task-id <id> --status <true|false>`
- 按主题筛选:list 后按标题关键词过滤
## #7 听记与会后
> 产品命令完整参考见 [minutes.md](../../dingtalk-minutes/references/minutes.md)。full recipe 见 [07-minutes.md](../../dingtalk-minutes/references/07-minutes.md)。
### minutes-query(查询与获取)
> **scope 选择铁律(P2 真实 badcase)**:`list` 后的 scope 决定查询范围,最高频误判是把"我能访问的所有听记"错选成 `mine`:
> - `mine` = **仅我自己创建/发起**的听记(范围最窄)
> - `shared` = **仅他人共享给我**的听记
> - `all` = **我可访问的全部**(= mine ∪ shared,范围最广)
> - **判定口诀**:query 含"访问/权限/可见/能看到/所有/我的"等覆盖范围语义 → 一律走 `all`;**仅当**明确说"我创建的/我发起的/我录的" → 才走 `mine`。**不要因为句子里有"我"字就退化成 `mine`。**
> - 错误:`我能访问的所有听记` → `list mine`(漏掉共享给我的,判定不通过)
> - 正确:`我能访问的所有听记` → `dws minutes list all --format json`
> **选对象铁律(0605 P2 EDD badcase 提炼,命令对了但选错听记 = 整任务失败)**:list/搜索拿到结果后,必须按语义精准锁定目标听记,详见 [minutes.md](../../dingtalk-minutes/references/minutes.md)「选对象铁律 S1~S6」。速记:
> - **S1 跨组织汇总**:以 list 返回的 `taskUuid + title + organizationName` 三元组为准逐条照抄,组织与听记不可张冠李戴。
> - **S2 "最近一次某类会议"**:先 `--query "<主题词>"`(如周会)过滤出该类,再在候选里取时间最新;主题匹配优先级高于时间。
> - **S3 比时长最长**:必须读 `durationMicros` 字段做数值比较,禁止凭印象/标题猜,口头结论与操作的 taskUuid 须自洽。
> - **S4 内容为空**:锁定 taskUuid 后所有 get/update 复用同一 id;某字段为空就如实说,**禁止偷偷切换到另一条听记**。
> - **S5 模糊日期匹配不到**:日期可能是"会议主题日期"而非"创建日期",按标题关键词搜,精确日期没命中就放宽 ±7 天/同主题候选请用户确认,**禁止直接报"找不到"**。搜索回退策略:① 先 `--query "<主题关键词>"` 不带日期搜 → ② 若结果过多则加 `--start/--end` 扩大到 ±7 天 → ③ 列出候选让用户确认。
> - **S6 给标题没给 id**:必须先 `list all --query "<标题关键词>"` 定位 taskUuid 再 update/get,禁止凭记忆直接填 `--id` 跳过定位。
**列表查询**(`list` 后**必须**跟 scope:`mine`/`shared`/`all`,默认补 `all`):
### 给自己
```bash
# 我可访问的所有听记(默认)
dws minutes list all --format json
# 按关键词服务端搜索(严禁全量拉取后本地 grep)
dws minutes list all --query "周会" --format json
# 按时间范围筛选(ISO-8601 格式)
dws minutes list mine --start "2026-05-01T00:00:00+08:00" --end "2026-05-25T23:59:59+08:00" --format json
# 关键词 + 时间组合
dws minutes list all --query "需求评审" --start "2026-05-25T00:00:00+08:00" --end "2026-05-25T23:59:59+08:00" --format json
# 限制条数
dws minutes list mine --limit 5 --format json
# 共享给我的听记
dws minutes list shared --query "ROI" --format json
dws todo +remind --task "提交周报" --at "2026-08-19T18:00:00+08:00" --format json
```
| 参数 | 说明 |
|------|------|
| `--query "<关键词>"` | 服务端关键词搜索 |
| `--start "<ISO-8601>"` | 开始时间 |
| `--end "<ISO-8601>"` | 结束时间 |
| `--limit <N>` | 每页条数,默认 10(`--max` 为兼容别名) |
| `--cursor "<token>"` | 分页 token,首页留空(`--next-token` 为兼容别名) |
**获取详情**:
- 批量基础信息:`minutes get batch --ids <uuid1,uuid2,...>`
- 单篇摘要:`minutes get summary --id <taskUuid>`
- 转写原文(自动翻页):`minutes get transcription --id <taskUuid>`(返回 `nextToken` 时用 `--next-token <token>` 继续)
- 关键词:`minutes get keywords --id <taskUuid>`
- 待办事项:`minutes get todos --id <taskUuid>`
- 基础信息:`minutes get info --id <taskUuid>`
- 音频地址:`minutes get audio --id <taskUuid>`
> `--id`/`--uuid`/`--task-uuid` 三者等价。推荐 `--id`。
### minutes-edit(编辑与替换)
- **替换转写文字**:`minutes replace-text --id <taskUuid> --search "旧文字" --replace "新文字"`
- 执行前检查特殊字符(引号/书名号/括号等),若包含先提示用户确认去除
- 替换成功后追问是否加热词:`minutes hot-word add --words "新文字"`
- **替换发言人**:先统一搜人取得 dingUid → `minutes speaker replace --id <taskUuid> --from "发言人X" --to "姓名" --target-uid <userId>`
- 查询 dingUid:`aisearch person --query "姓名" --dimension name --format json` → 取 `userId`
- 多个匹配 → 列出候选让用户选;无匹配 → 不带 `--target-uid` 执行
- **修改标题**:`minutes update title --id <taskUuid> --title "新标题"`
- **修改摘要**:`minutes update summary --id <taskUuid> --content "新内容"`
- **热词管理**:`minutes hot-word add --words "词1,词2"` / `minutes hot-word list`
- **思维导图**:`minutes mind-graph create --id <taskUuid>` → `mind-graph status --id <taskUuid>` 轮询至完成
### minutes-tag(标签/分组查询)
- 查询标签列表:`minutes tag list` → 返回用户在听记页面创建的所有标签/分组(含 tagId 和名称)
- 按标签查听记:`minutes tag query --tag-id <tagId> [--limit 20] [--cursor <token>]`
- tagId 来自 `tag list` 返回值,不可编造
- 支持分页,`--cursor` 传入上一次返回的 nextToken
**典型链路**:用户说"帮我看看'周会'标签下的听记" →
1. `dws minutes tag list --format json` → 按名称匹配找到 tagId
2. `dws minutes tag query --tag-id <tagId> --format json`
### minutes-permission(权限管理)
- 添加成员:`minutes permission add --ids <uuid1,uuid2> --member-uids <uid1,uid2> --policy 4`
- 需先通过 `aisearch person --query "<姓名>" --dimension name` 获取目标 userId
- policy:0=不可见 / 1=仅查看 / 2=查看+下载 / 3=查看+下载+编辑 / 4=全部权限
- 移除成员:`minutes permission remove --ids <uuid1,uuid2> --member-uids <uid1,uid2>`
### minutes-upload(音频上传)
`--at` 是截止时间。需要 9 点弹出独立提醒时,在取得 `taskId` 后再执行:
```bash
# 创建上传会话
dws minutes upload create --file-name "meeting.mp3" --file-size 61565431 --format json
# 上传完成后确认
dws minutes upload complete --session-id <sid> --format json
# 取消上传
dws minutes upload cancel --session-id <sid> --format json
dws todo +reminder --task-id <TASK_ID> --base-time customTime --at "2026-08-19T09:00:00+08:00" --format json
```
### 最佳实践案例速查(详见 [minutes.md](../../dingtalk-minutes/references/minutes.md))
### 按姓名指派
| 案例 | 场景 | 正确链路 |
|------|------|----------|
| 案例 1 | 听记 URL + 创建思维导图 | 提取 taskUuid → `mind-graph create` → `mind-graph status` 轮询;**禁止**走 app-development 或前端库 |
| 案例 2 | 替换文字后未引导热词 | 检查特殊字符 → `replace-text` → 追问加热词 `hot-word add` |
| 案例 3 | 查听记拉了不必要的转写 | 用户只要列表 → `list` 即可,**不要**自动拉 `get transcription` |
| 案例 4 | 拉完转写只输出时间线原文 | 拉完后追问按发言人聚类 → 引导匹配 → 调用 `speaker replace` 写回 |
| 案例 5 | 查某人说了什么不引导替换 | 推断发言人 → **用户确认** → 结构化总结 → 引导 `speaker replace` |
| 案例 6 | 通讯录+部门+转写三路印证 | Step 3 画像 + Step 4 `aisearch person` 并发 → 置信度 ≥70% → 确认 → 替换 |
| 案例 7 | grep 花名误判未参会 | **禁止**在转写文本里 grep 人名判参会;**必须**调 `aisearch person` |
| 案例 8 | 听记类 query 不走 dws | **禁止**用 session_search/browser_use/activity:search 替代 dws;模糊请求先 `list mine` |
| 案例 9 | 按标签筛选听记 | `tag list` → 按名称匹配 tagId → `tag query --tag-id <tagId>`;**禁止**编造 tagId |
```bash
dws todo +assign --to "张三" --task "周五前提交排期" --format json
dws todo +assign-multi --to "张三,李四" --task "周五前提交排期" --format json
```
### 听记取数深度约束(0609 点踩 case 提炼)
姓名必须唯一解析。多人场景任一姓名失败时整条待办不会创建。
> 详细说明见 [minutes.md](../../dingtalk-minutes/references/minutes.md)。
### 已有 userId
- **转写原文硬约束**:用户诉求含「聚焦原话/逐字/沟通细节/具体讨论了什么」等词时,**必须先调 `get transcription` 翻页拉全**,禁止仅凭 summary 出稿
- **数据源下钻**:听记维度**必须 `get summary`(或 `get transcription`)读正文**,严禁只取标题列表;scope 用 `all`;空时换窗重试或标注
- **听记链接解析**:聊天消息中遇到听记链接(`flash_minutes_detail`/`SHANJI`)→ 解析 `minutesId` → 调 `minutes get summary/transcription`,禁止把链接降级为关键词
- **忠实性约束**:源数据无某要素(行动项/责任人/数字)时禁止生成;统计字段基于实际取数计数,不得编造
- **多源全覆盖**:用户枚举多数据源时每个来源都必须调对应工具;瞬时错误重试;如实声明缺失来源,禁编无来源数字
```bash
dws todo +create --title "修复线上问题" --executors <USER_ID> --priority 40 --format json
```
### 间接意图识别铁律
成功结果必须含稳定 `taskId` 且 `verified=true`。缺少 ID、写后读回失败或超时都不能重放创建;先搜索/列表对账。创建后还要按状态/优先级/角色/日期/页码列举,或更新、完成/重开、提醒、评论、附件、成员、子待办、标签,或创建多个对象时,创建步骤改用原子 `task create`。
query 未提"听记"但任务产出依赖会议讨论内容时(报告/总结/日报/复盘/商业分析/市场感知),听记采集是**必跑前置步骤**:
## 查询与定位
1. **铁律 A**:任务含"会议/讨论/沟通"信息需求 → `dws minutes list` 必跑
2. **铁律 B**:用户说"文档啥也没有" → 听记优先级更高(唯一结构化数据源)
3. **铁律 C**:多源聚合场景 → 每个被提及的数据源都必须有采集动作,听记侧 0 调用 = 严重失败
```bash
dws todo +get-my-tasks --all --status false --format json
dws todo +get-related-tasks --format json
dws todo +search --query "周报" --format json
dws todo +get --task-id <TASK_ID> --format json
```
- list 用于枚举,search 用于标题关键词,get 用于已知稳定 ID。
- 空集合是正常成功;响应结构缺失、分页未耗尽或 ID 缺失必须失败。
- 零匹配或多匹配时不得自行选择第一条。
- 用户明确要求状态、优先级、角色、日期或页码筛选时,使用 `todo task list` 的对应 flag;不要先拉取无关数据再在本地猜范围。
## 完成、重开与更新
```bash
dws todo +complete --task-id <TASK_ID> --format json
dws todo +reopen --task-id <TASK_ID> --format json
dws todo +update --task-id <TASK_ID> --title "新标题" --format json
```
只记得标题时用 `+todo-done --task "<关键词>"`;它只会在唯一命中时修改。单次变更优先使用这些自带读回的 Shortcut;需要中间列表、多对象对比或原子特有参数时切到 [组合生命周期](02-task.md) 的原子路线。
## 汇总与批量
```bash
python scripts/todo_daily_summary.py today
python scripts/todo_overdue_check.py
python scripts/todo_batch_create.py todos.json --dry-run
```
批量脚本单批最多 30 条。先用 dry-run 展示精确批次和稳定 `planDigest`;取得用户对该摘要的明确确认后,执行时必须同时传 `--confirm-digest <PLAN_DIGEST>` 与 `--yes`。脚本重新规范化输入并核对摘要,不匹配时零调用拒绝;匹配后只把 Runtime 确认传给逐条创建,回读不携带该标记。输出保留逐项 ledger;`unknown` 表示写可能已提交,`unverified` 表示已有 `taskId` 但读回未通过,两者都需对账,不能自动重试。
+16 -13
View File
@@ -108,7 +108,7 @@ Usage:
dws todo task delete [flags]
Example:
dws todo task delete --task-id <taskId>
dws todo task delete --task-id <taskId> --yes
dws todo task delete --task-id <taskId>
Flags:
--task-id string 待办任务 ID (必填)
```
@@ -146,7 +146,7 @@ Usage:
dws todo comment delete [flags]
Example:
dws todo comment delete --task-id <taskId> --comment-id <commentId>
dws todo comment delete --task-id <taskId> --comment-id <commentId> --yes
dws todo comment delete --task-id <taskId> --comment-id <commentId>
Flags:
--task-id string 待办任务 ID (必填)
--comment-id string 评论 ID (必填)
@@ -237,7 +237,7 @@ Usage:
dws todo task remove-attachment [flags]
Example:
dws todo task remove-attachment --task-id <taskId> --attachment-id <attachmentId>
dws todo task remove-attachment --task-id <taskId> --attachment-id <attachmentId> --yes
dws todo task remove-attachment --task-id <taskId> --attachment-id <attachmentId>
Flags:
--attachment-id string 待办附件 ID (必填)
--task-id string 待办任务 ID (必填)
@@ -314,7 +314,7 @@ Usage:
dws todo tag delete [flags]
Example:
dws todo tag delete --tag-codes code1,code2
dws todo tag delete --tag-codes code1,code2 --yes
dws todo tag delete --tag-codes code1,code2
Flags:
--tag-codes string 要删除的标签编码列表,逗号分隔 (必填)
--yes 跳过交互确认,直接执行删除
@@ -410,7 +410,7 @@ dws todo task update --task-id <taskId> --title "新标题" --priority 40 --form
dws todo task done --task-id <taskId> --status true --format json
# 6. 删除待办
dws todo task delete --task-id <taskId> --yes --format json
dws todo task delete --task-id <taskId> --format json
# 7. 给待办新增评论
dws todo comment add --task-id <taskId> --content "已开始处理" --format json
@@ -419,7 +419,7 @@ dws todo comment add --task-id <taskId> --content "已开始处理" --format jso
dws todo comment list --task-id <taskId> --page 1 --size 20 --format json
# 9. 删除待办评论
dws todo comment delete --task-id <taskId> --comment-id <commentId> --yes --format json
dws todo comment delete --task-id <taskId> --comment-id <commentId> --format json
# 10. 添加待办执行人
dws todo task add-executor --task-id <taskId> --executors userId1,userId2 --format json
@@ -446,7 +446,7 @@ dws todo task add-attachment --task-id <taskId> --file /path/to/file.pdf --forma
# 20. 查询待办附件列表
dws todo task list-attachment --task-id <taskId> --format json
# 21. 删除待办附件
dws todo task remove-attachment --task-id <taskId> --attachment-id <attachmentId> --yes --format json
dws todo task remove-attachment --task-id <taskId> --attachment-id <attachmentId> --format json
# 22. 查询待办标签列表
dws todo tag list --format json
@@ -457,7 +457,7 @@ dws todo tag add --task-id <taskId> --tag-codes code1,code2 --format json
# 25. 更新待办标签
dws todo tag update --user-tags '[{"tagCode":"code1","name":"新名称"}]' --format json
# 26. 删除待办标签
dws todo tag delete --tag-codes code1,code2 --yes --format json
dws todo tag delete --tag-codes code1,code2 --format json
```
## 上下文传递表
@@ -476,9 +476,10 @@ dws todo tag delete --tag-codes code1,code2 --yes --format json
- 优先级值: 10=低, 20=普通, 30=较高, 40=紧急
- `--due` 是截止时间 dueTime,不是提醒时间;使用 ISO-8601 格式(如 2026-03-10T18:00:00+08:00)
- 当前不支持单独的 `reminder` / `remind-at` 精确提醒能力;不要把 `--due` 解释成“几点提醒”
- `--due` 与 `todo +remind --at` 都只写截止时间 dueTime,不要解释成“几点提醒”
- `--recurrence`:仅在与 `--due` 同时设置时有效;当前仅支持按天循环。字符串内需含换行,示例:`DTSTART:20260320T020000Z\nRRULE:FREQ=DAILY;INTERVAL=1`(DTSTART 表示首次截止时间,需与业务约定一致)
- 若用户的真实诉求是“到点提醒我”,需要先说明能力边界;当前 CLI 只能表达 deadline / recurrence,不能表达独立 reminder schedule
- 独立精确提醒使用 `todo +reminder --base-time customTime --at <ISO>`,或原子命令 `task add-reminder --base-time customTime --reminder-time-stamp <ISO>`
- 基于截止时间的提前提醒使用 `todo +reminder --base-time dueTime --due-date-offset <分钟>`;待办必须已有截止时间
- `task list` 的 `--status` 对应 MCP `get_user_todos_in_current_org` 的 `todoStatus` 参数
- `task list` 的 `--priority` 支持逗号分隔多个优先级值(如 `40,30,10`),用于同时筛选多个优先级
- `task list` 的 `--role-types` 支持 `creator`/`executor`/`participant`,可在一次调用中同时传入多个角色用逗号分隔(如 `--role-types creator,executor`),无需分多次查询;不传时默认按 `executor` 查询
@@ -486,7 +487,7 @@ dws todo tag delete --tag-codes code1,code2 --yes --format json
- todo 是个人待办管理产品
- `task update` 可同时修改标题/优先级/截止时间/完成状态
- `task done` 专用于修改执行者的完成状态,与 `task update --done` 作用不同
- `task delete` 为不可逆操作,建议加 `--yes` 并与用户确认
- `task delete` 为不可逆操作;先与用户确认,再由运行时确认门执行
- `comment delete` 同样为不可逆操作,执行前需用户确认;`--comment-id` 可通过 `comment list` 获取
- `task add-executor` / `task remove-executor` 用于管理待办的执行人,`--executors` 支持逗号分隔的多个 userId
- `task add-participant` / `task remove-participant` 用于管理待办的参与人,`--participants` 支持逗号分隔的多个 userId
@@ -501,7 +502,7 @@ dws todo tag delete --tag-codes code1,code2 --yes --format json
- `tag add` 用于给指定待办打标签,`--task-id` 可通过 `task list` 或 `task create` 获取;`--tag-codes` 可通过 `tag list` 获取
- `tag create` 用于创建新标签,`--name` 为标签名称 (必填)
- `tag update` 用于更新已有标签信息,`--user-tags` 格式同 `tag create`
- `tag delete` 用于删除标签定义,为不可逆操作,执行前需用户确认;传 `--yes` 可跳过交互提示,建议加 `--yes` 并与用户确认
- `tag delete` 用于删除标签定义,为不可逆操作,执行前需用户确认
- `tag add`(给待办打标签)与 `tag delete`(删除标签定义)作用不同:前者是关联关系,后者是删除标签本身
@@ -510,5 +511,7 @@ dws todo tag delete --tag-codes code1,code2 --yes --format json
| 脚本 | 场景 | 用法 |
|------|------|------|
| [todo_daily_summary.py](../scripts/todo_daily_summary.py) | 查看今天/明天/本周未完成待办汇总 | `python todo_daily_summary.py today` |
| [todo_batch_create.py](../scripts/todo_batch_create.py) | 从 JSON 文件批量创建待办 | `python todo_batch_create.py todos.json` |
| [todo_batch_create.py](../scripts/todo_batch_create.py) | 从 JSON 文件批量创建待办 | `python todo_batch_create.py todos.json --dry-run` |
| [todo_overdue_check.py](../scripts/todo_overdue_check.py) | 扫描逾期待办输出逾期清单 | `python todo_overdue_check.py` |
批量创建必须先预览精确批次和 `planDigest`;用户明确确认该摘要后,执行时同时提交 `--confirm-digest <PLAN_DIGEST>` 与 `--yes`。脚本会重新规范化输入并核对摘要:任何实际写入内容变化都会在首次 `dws` 调用前拒绝。
@@ -1,159 +1,334 @@
#!/usr/bin/env python3
"""
从 JSON 文件批量创建待办(含优先级、截止时间、执行者)
"""Create up to 30 Todo tasks, preserve every task ID, and verify each task."""
用法:
python todo_batch_create.py todos.json
python todo_batch_create.py todos.json --dry-run
todos.json 格式:
[
{"title": "修复线上Bug", "executors": "userId1,userId2", "priority": 40},
{"title": "写周报", "executors": "userId1", "due": "2026-03-15"},
{"title": "代码评审", "executors": "userId1"},
{"title": "每日站会", "executors": "userId1", "due": "2026-03-20",
"recurrence": "DTSTART:20260320T020000Z\\nRRULE:FREQ=DAILY;INTERVAL=1"}
]
字段说明:
- title: 待办标题 (必填)
- executors: 执行者 userId,多人逗号分隔 (必填)
- priority: 优先级 10=低/20=普通/30=较高/40=紧急 (可选)
- due: 截止日期 YYYY-MM-DD 或毫秒时间戳 (可选)
- recurrence: 循环待办规则 (可选,需同时有 due);字符串内需含换行,与 dws --recurrence 一致
"""
import sys
import argparse
import hashlib
import json
import subprocess
import re
import sys
from datetime import datetime
from pathlib import Path
from typing import List, Dict, Any, Optional
from typing import Any, Dict, Iterable, List, Optional
from zoneinfo import ZoneInfo
ALLOWED_PRIORITIES = {10, 20, 30, 40}
DATE_PATTERN = re.compile(r'^\d{4}-\d{2}-\d{2}$')
TIMEZONE = ZoneInfo("Asia/Shanghai")
MAX_ITEMS = 30
MAX_FILE_SIZE = 10 * 1024 * 1024
ALLOWED_PRIORITIES = {10, 20, 30, 40}
PLAN_DIGEST_DOMAIN = b"dws-todo-batch-plan-v1\x00"
MIN_EPOCH_MILLISECONDS = 1_000_000_000_000
MAX_EPOCH_MILLISECONDS = 9_999_999_999_999
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Dict[str, Any]]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return {'dry_run': True}
class ScriptError(RuntimeError):
def __init__(self, message: str, *, commit_unknown: bool = False):
super().__init__(message)
self.commit_unknown = commit_unknown
def run_dws_json(
args: List[str], dws: str = "dws", *, write_started: bool = False
) -> Dict[str, Any]:
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
[dws, *args],
capture_output=True,
text=True,
stdin=subprocess.DEVNULL,
timeout=120,
)
if result.returncode != 0:
print(f" ✗ 错误:{result.stderr.strip()}")
return None
return json.loads(result.stdout)
except subprocess.TimeoutExpired:
print(' ✗ 命令执行超时', file=sys.stderr)
return None
except (json.JSONDecodeError, FileNotFoundError) as e:
print(f" ✗ 错误:{e}", file=sys.stderr)
except subprocess.TimeoutExpired as exc:
raise ScriptError("dws timed out", commit_unknown=write_started) from exc
except FileNotFoundError as exc:
raise ScriptError(str(exc), commit_unknown=False) from exc
payload: Optional[Dict[str, Any]] = None
if result.stdout.strip():
try:
decoded = json.loads(result.stdout)
if isinstance(decoded, dict):
payload = decoded
except json.JSONDecodeError:
payload = None
if result.returncode != 0:
error = payload.get("error", {}) if payload else {}
started = error.get("execution_started") if isinstance(error, dict) else None
message = ""
if isinstance(error, dict):
message = str(error.get("message") or error.get("hint") or "")
message = message or result.stderr.strip() or f"dws exited {result.returncode}"
raise ScriptError(
message,
commit_unknown=write_started and started is not False,
)
if payload is None:
raise ScriptError("dws returned non-object or invalid JSON", commit_unknown=write_started)
if payload.get("ok") is False or payload.get("success") is False:
error = payload.get("error", {})
started = error.get("execution_started") if isinstance(error, dict) else None
message = str(error.get("message") if isinstance(error, dict) else payload)
raise ScriptError(message, commit_unknown=write_started and started is not False)
if payload.get("outcome") == "pending":
raise ScriptError("create outcome is pending", commit_unknown=write_started)
return payload
def walk_objects(value: Any) -> Iterable[Dict[str, Any]]:
if isinstance(value, dict):
yield value
for child in value.values():
yield from walk_objects(child)
elif isinstance(value, list):
for child in value:
yield from walk_objects(child)
def first_string(payload: Dict[str, Any], keys: Iterable[str]) -> str:
for obj in walk_objects(payload):
for key in keys:
value = obj.get(key)
if value not in (None, ""):
return str(value)
return ""
def normalize_due(value: Any) -> Optional[str]:
if value in (None, ""):
return None
raw = str(value).strip()
if raw.isdigit():
milliseconds = int(raw)
if not MIN_EPOCH_MILLISECONDS <= milliseconds <= MAX_EPOCH_MILLISECONDS:
raise ScriptError(
"epoch-millisecond due time must be a 13-digit value "
f"between {MIN_EPOCH_MILLISECONDS} and {MAX_EPOCH_MILLISECONDS}: "
f"{raw}"
)
try:
return datetime.fromtimestamp(milliseconds / 1000, TIMEZONE).isoformat()
except (OSError, OverflowError, ValueError) as exc:
raise ScriptError(f"invalid epoch-millisecond due time: {raw}") from exc
if len(raw) == 10:
try:
day = datetime.strptime(raw, "%Y-%m-%d").replace(
hour=23, minute=59, second=59, tzinfo=TIMEZONE
)
return day.isoformat()
except ValueError as exc:
raise ScriptError(f"invalid due date: {raw}") from exc
try:
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError as exc:
raise ScriptError(
f"due must be YYYY-MM-DD, epoch milliseconds, or ISO-8601: {raw}"
) from exc
if parsed.tzinfo is None:
raise ScriptError(f"ISO due time must include a timezone: {raw}")
return parsed.isoformat()
def parse_due(due_value) -> Optional[str]:
if not due_value:
return None
due_str = str(due_value)
if due_str.isdigit() and len(due_str) >= 10:
return due_str
if DATE_PATTERN.match(due_str):
dt = datetime.strptime(due_str, '%Y-%m-%d')
dt = dt.replace(hour=23, minute=59, second=59)
return str(int(dt.timestamp() * 1000))
print(f" ⚠ 无法解析截止时间:{due_value},跳过")
return None
def validate_todo(item: Dict[str, Any], idx: int) -> bool:
if not isinstance(item, dict):
print(f" ✗ #{idx+1} 不是有效对象")
return False
if not item.get('title', '').strip():
print(f" ✗ #{idx+1} 缺少 title")
return False
if not item.get('executors', '').strip():
print(f" ✗ #{idx+1} 缺少 executors")
return False
priority = item.get('priority')
if priority is not None and int(priority) not in ALLOWED_PRIORITIES:
print(f" ✗ #{idx+1} 无效优先级:{priority}")
return False
recurrence = item.get('recurrence')
if recurrence and not str(recurrence).strip():
print(f" ✗ #{idx+1} recurrence 不能为空字符串")
return False
if recurrence and not item.get('due'):
print(f" ✗ #{idx+1} 设置 recurrence 时必须提供 due")
return False
return True
def main():
dry_run = '--dry-run' in sys.argv
args = [a for a in sys.argv[1:] if a != '--dry-run']
if not args:
print(__doc__)
sys.exit(1)
file_path = Path(args[0])
if not file_path.exists():
print(f"错误:文件不存在:{file_path}")
sys.exit(1)
if file_path.stat().st_size > MAX_FILE_SIZE:
print(f"错误:文件过大 (限制 {MAX_FILE_SIZE // 1024}KB)")
sys.exit(1)
with open(file_path, 'r', encoding='utf-8') as f:
todos = json.load(f)
if not isinstance(todos, list) or not todos:
print('错误:JSON 文件必须是非空数组')
sys.exit(1)
for i, item in enumerate(todos):
if not validate_todo(item, i):
sys.exit(1)
print(f"📋 准备创建 {len(todos)} 条待办\n")
success, fail = 0, 0
for i, item in enumerate(todos):
title = item['title'].strip()
cmd_args = [
'todo', 'task', 'create',
'--title', title,
'--executors', item['executors'].strip(),
'--format', 'json',
]
priority = item.get('priority')
def validate(items: Any) -> List[Dict[str, Any]]:
if not isinstance(items, list) or not items:
raise ScriptError("input must be a non-empty JSON array")
if len(items) > MAX_ITEMS:
raise ScriptError(f"a batch may contain at most {MAX_ITEMS} tasks")
validated: List[Dict[str, Any]] = []
for index, item in enumerate(items, 1):
if not isinstance(item, dict):
raise ScriptError(f"item {index} must be an object")
title = str(item.get("title") or "").strip()
executors = str(item.get("executors") or "").strip()
if not title or not executors:
raise ScriptError(f"item {index} requires non-empty title and executors")
priority = item.get("priority")
if priority is not None:
cmd_args.extend(['--priority', str(int(priority))])
due = parse_due(item.get('due'))
if due:
cmd_args.extend(['--due', due])
recurrence = item.get('recurrence')
if recurrence:
rr = str(recurrence).replace('\\n', '\n')
cmd_args.extend(['--recurrence', rr])
result = run_dws(cmd_args, dry_run=dry_run)
if result:
print(f" ✓ [{i+1}/{len(todos)}] {title}")
success += 1
else:
print(f" ✗ [{i+1}/{len(todos)}] {title}")
fail += 1
print(f"\n完成: 成功 {success}, 失败 {fail}")
sys.exit(0 if fail == 0 else 1)
try:
priority = int(priority)
except (TypeError, ValueError) as exc:
raise ScriptError(f"item {index} has invalid priority") from exc
if priority not in ALLOWED_PRIORITIES:
raise ScriptError(
f"item {index} priority must be one of {sorted(ALLOWED_PRIORITIES)}"
)
due = normalize_due(item.get("due"))
recurrence = item.get("recurrence")
if recurrence and not due:
raise ScriptError(f"item {index} recurrence requires due")
validated.append(
{
"title": title,
"executors": executors,
"priority": priority,
"due": due,
"recurrence": (
str(recurrence).replace("\\n", "\n") if recurrence else None
),
}
)
return validated
if __name__ == '__main__':
main()
def batch_plan_digest(items: List[Dict[str, Any]]) -> str:
canonical = json.dumps(
items,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
).encode("utf-8")
digest = hashlib.sha256(PLAN_DIGEST_DOMAIN + canonical).hexdigest()
return f"sha256:{digest}"
def run(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("input", type=Path)
parser.add_argument("--dws", default="dws")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument(
"--yes",
action="store_true",
help="confirm execution of the exact validated batch after user approval",
)
parser.add_argument(
"--confirm-digest",
help="planDigest from the user-approved dry-run output",
)
args = parser.parse_args(argv)
try:
if not args.input.is_file():
raise ScriptError(f"input file not found: {args.input}")
if args.input.stat().st_size > MAX_FILE_SIZE:
raise ScriptError(f"input exceeds {MAX_FILE_SIZE} bytes")
items = validate(json.loads(args.input.read_text(encoding="utf-8")))
except (OSError, json.JSONDecodeError, ScriptError) as exc:
print(json.dumps({"complete": False, "error": str(exc)}, ensure_ascii=False))
return 2
plan_digest = batch_plan_digest(items)
if not args.dry_run and (not args.yes or not args.confirm_digest):
print(
json.dumps(
{
"complete": False,
"dryRun": False,
"reason": "confirmation_required",
"executionStarted": False,
"error": (
"preview the exact batch with --dry-run; after the user "
"confirms that planDigest, rerun with --yes and "
"--confirm-digest <planDigest>"
),
},
ensure_ascii=False,
)
)
return 2
if not args.dry_run and args.confirm_digest != plan_digest:
print(
json.dumps(
{
"complete": False,
"dryRun": False,
"reason": "plan_mismatch",
"executionStarted": False,
"confirmedPlanDigest": args.confirm_digest,
"actualPlanDigest": plan_digest,
"error": (
"the validated batch differs from the confirmed dry-run; "
"preview again and obtain confirmation for the new planDigest"
),
},
ensure_ascii=False,
)
)
return 2
ledger: List[Dict[str, Any]] = []
for item in items:
create = [
"todo",
"task",
"create",
"--title",
item["title"],
"--executors",
item["executors"],
]
if item["priority"] is not None:
create.extend(["--priority", str(item["priority"])])
if item["due"]:
create.extend(["--due", item["due"]])
if item["recurrence"]:
create.extend(["--recurrence", item["recurrence"]])
create.extend(["--format", "json"])
if args.dry_run:
ledger.append({"title": item["title"], "command": [args.dws, *create]})
continue
# The script-level confirmation covers this exact validated batch. Pass the
# Runtime bypass only to the writes; readback remains an ordinary read.
create.append("--yes")
entry: Dict[str, Any] = {"title": item["title"], "status": "unknown"}
try:
created = run_dws_json(create, args.dws, write_started=True)
identifier = first_string(created, ("taskId", "todoTaskId"))
if not identifier:
raise ScriptError(
"create response did not contain a stable taskId",
commit_unknown=True,
)
entry["taskId"] = identifier
try:
detail = run_dws_json(
[
"todo",
"task",
"get",
"--task-id",
identifier,
"--format",
"json",
],
args.dws,
)
actual_title = first_string(detail, ("subject", "title"))
if actual_title != item["title"]:
raise ScriptError(
f"readback title mismatch: expected {item['title']!r}, "
f"got {actual_title!r}"
)
entry["status"] = "verified"
except ScriptError as exc:
entry.update({"status": "unverified", "error": str(exc)})
except ScriptError as exc:
entry.update(
{
"status": "unknown" if exc.commit_unknown else "failed",
"error": str(exc),
}
)
ledger.append(entry)
complete = args.dry_run or all(item.get("status") == "verified" for item in ledger)
output = {
"complete": complete,
"dryRun": args.dry_run,
"planDigest": plan_digest,
"requestedCount": len(items),
"verifiedCount": sum(item.get("status") == "verified" for item in ledger),
"failedCount": sum(item.get("status") == "failed" for item in ledger),
"unverifiedCount": sum(item.get("status") == "unverified" for item in ledger),
"unknownCount": sum(item.get("status") == "unknown" for item in ledger),
"ledger": ledger,
}
print(json.dumps(output, ensure_ascii=False, indent=2))
return 0 if complete else 2
if __name__ == "__main__":
sys.exit(run())
@@ -1,178 +1,155 @@
#!/usr/bin/env python3
"""
查询今天/明天/本周未完成的待办并汇总输出
"""Return open Todo tasks due today, tomorrow, or this week."""
用法:
python todo_daily_summary.py # 默认查今天
python todo_daily_summary.py today # 今天的待办
python todo_daily_summary.py tomorrow # 明天的待办
python todo_daily_summary.py week # 本周的待办
python todo_daily_summary.py --dry-run # 仅显示将执行的命令
"""
import sys
import argparse
import json
import subprocess
import sys
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
PRIORITY_MAP = {10: '低', 20: '普通', 30: '较高', 40: '紧急'}
PAGE_SIZE = 50
MAX_PAGES = 10
from typing import Any, Dict, List, Optional, Tuple
from zoneinfo import ZoneInfo
def run_dws(args: List[str], dry_run: bool = False) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return None
TIMEZONE = ZoneInfo("Asia/Shanghai")
MAX_PAGES = 40
class ScriptError(RuntimeError):
pass
def run_dws_json(args: List[str], dws: str = "dws") -> Dict[str, Any]:
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
[dws, *args], capture_output=True, text=True, timeout=120
)
if result.returncode != 0:
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
return None
return json.loads(result.stdout)
except subprocess.TimeoutExpired:
print('错误:命令执行超时', file=sys.stderr)
return None
except (json.JSONDecodeError, FileNotFoundError) as e:
print(f"错误:{e}", file=sys.stderr)
return None
def get_date_range(scope: str):
now = datetime.now()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
if scope == 'today':
return today_start, today_start + timedelta(days=1)
elif scope == 'tomorrow':
tmr = today_start + timedelta(days=1)
return tmr, tmr + timedelta(days=1)
elif scope == 'week':
week_start = today_start - timedelta(days=today_start.weekday())
return week_start, week_start + timedelta(days=7)
return today_start, today_start + timedelta(days=1)
def fetch_all_todos(
dry_run: bool = False,
) -> List[Dict[str, Any]]:
all_todos: List[Dict[str, Any]] = []
for page in range(1, MAX_PAGES + 1):
data = run_dws([
'todo', 'task', 'list',
'--page', str(page),
'--size', str(PAGE_SIZE),
'--status', 'false',
'--format', 'json',
], dry_run=dry_run)
if dry_run:
return []
if not data:
break
if isinstance(data, list):
items = data
elif isinstance(data, dict):
inner = data.get('result', data)
if isinstance(inner, dict):
items = inner.get('todoCards', [])
elif isinstance(inner, list):
items = inner
else:
items = []
else:
items = []
if not items or not isinstance(items, list):
break
all_todos.extend(items)
if len(items) < PAGE_SIZE:
break
return all_todos
def format_priority(p) -> str:
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
raise ScriptError(str(exc)) from exc
if result.returncode != 0:
raise ScriptError(result.stderr.strip() or f"dws exited {result.returncode}")
try:
return PRIORITY_MAP.get(int(p), str(p))
except (ValueError, TypeError):
return '普通'
payload = json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise ScriptError("dws returned non-JSON output") from exc
if not isinstance(payload, dict):
raise ScriptError("dws returned a non-object JSON payload")
if payload.get("ok") is False or payload.get("success") is False:
error = payload.get("error", {})
raise ScriptError(str(error.get("message") if isinstance(error, dict) else payload))
return payload
def format_due(due_ms) -> str:
if not due_ms:
return '无截止时间'
def date_range(scope: str, now: Optional[datetime] = None) -> Tuple[datetime, datetime]:
current = now.astimezone(TIMEZONE) if now else datetime.now(TIMEZONE)
today = current.replace(hour=0, minute=0, second=0, microsecond=0)
if scope == "today":
return today, today + timedelta(days=1)
if scope == "tomorrow":
start = today + timedelta(days=1)
return start, start + timedelta(days=1)
start = today - timedelta(days=today.weekday())
return start, start + timedelta(days=7)
def extract_cards(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
data: Any = payload.get("data", payload)
if isinstance(data, dict) and "todos" in data:
if data.get("complete") is not True:
raise ScriptError("Todo traversal did not prove endpoint exhaustion")
value = data["todos"]
else:
legacy = payload.get("result", payload)
value = legacy.get("todoCards") if isinstance(legacy, dict) else None
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
raise ScriptError("Todo response is missing a valid todos[] collection")
return value
def due_millis(item: Dict[str, Any]) -> Optional[int]:
value = item.get("dueTime") or item.get("planFinishDate") or item.get("due")
if value in (None, ""):
return None
try:
dt = datetime.fromtimestamp(int(due_ms) / 1000)
return dt.strftime('%Y-%m-%d %H:%M')
except (ValueError, TypeError, OSError):
return str(due_ms)
return int(value)
except (TypeError, ValueError) as exc:
raise ScriptError(f"invalid due time for task {task_id(item)!r}: {value!r}") from exc
def filter_by_due(
todos: List[Dict[str, Any]], start: datetime, end: datetime,
) -> List[Dict[str, Any]]:
start_ms = int(start.timestamp() * 1000)
end_ms = int(end.timestamp() * 1000)
result = []
for t in todos:
due = t.get('dueTime') or t.get('due')
if not due:
result.append(t)
continue
try:
due_val = int(due)
if start_ms <= due_val < end_ms:
result.append(t)
except (ValueError, TypeError):
result.append(t)
return result
def task_id(item: Dict[str, Any]) -> str:
value = item.get("taskId") or item.get("id") or item.get("todoTaskId")
return "" if value is None else str(value)
def print_summary(
todos: List[Dict[str, Any]], scope: str,
start: datetime, end: datetime,
):
scope_label = {
'today': '今天', 'tomorrow': '明天', 'week': '本周',
}.get(scope, scope)
print(f"\n📋 {scope_label}未完成待办 "
f"({start.strftime('%m-%d')} ~ {end.strftime('%m-%d')})")
print('=' * 50)
if not todos:
print(' ✅ 暂无待办,轻松一下!')
return
urgent = [t for t in todos if format_priority(
t.get('priority')) == '紧急']
if urgent:
print(f"\n🔴 紧急 ({len(urgent)} 条)")
for t in urgent:
title = t.get('subject') or t.get('title', '无标题')
print(f" • {title} ⏰ {format_due(t.get('dueTime'))}")
normal = [t for t in todos if t not in urgent]
if normal:
print(f"\n📌 其他 ({len(normal)} 条)")
for t in normal:
title = t.get('subject') or t.get('title', '无标题')
pri = format_priority(t.get('priority'))
print(f" • [{pri}] {title} ⏰ {format_due(t.get('dueTime'))}")
print(f"\n合计: {len(todos)} 条待办")
def run(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"scope", nargs="?", choices=("today", "tomorrow", "week"), default="today"
)
parser.add_argument("--dws", default="dws")
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args(argv)
start, end = date_range(args.scope)
start_ms, end_ms = int(start.timestamp() * 1000), int(end.timestamp() * 1000)
command = [
"todo",
"+get-my-tasks",
"--status",
"false",
"--plan-finish-start",
str(start_ms),
"--plan-finish-end",
str(end_ms),
"--all",
"--max-pages",
str(MAX_PAGES),
"--format",
"json",
]
if args.dry_run:
print(json.dumps({"command": [args.dws, *command]}, ensure_ascii=False))
return 0
try:
cards = extract_cards(run_dws_json(command, args.dws))
selected = []
for item in cards:
due = due_millis(item)
if due is None or due < start_ms or due >= end_ms:
continue
selected.append(
{
"taskId": task_id(item),
"title": item.get("subject") or item.get("title") or "",
"priority": item.get("priority"),
"dueTime": due,
"dueTimeISO": datetime.fromtimestamp(
due / 1000, TIMEZONE
).isoformat(),
}
)
print(
json.dumps(
{
"complete": True,
"scope": args.scope,
"timezone": str(TIMEZONE),
"range": {
"start": start.isoformat(),
"endExclusive": end.isoformat(),
},
"count": len(selected),
"todos": selected,
},
ensure_ascii=False,
indent=2,
)
)
return 0
except ScriptError as exc:
print(json.dumps({"complete": False, "error": str(exc)}, ensure_ascii=False))
return 2
def main():
dry_run = '--dry-run' in sys.argv
args = [a for a in sys.argv[1:] if a != '--dry-run']
scope = args[0] if args else 'today'
if scope not in ('today', 'tomorrow', 'week'):
print(__doc__)
sys.exit(1)
start, end = get_date_range(scope)
todos = fetch_all_todos(dry_run=dry_run)
if dry_run:
return
filtered = filter_by_due(todos, start, end)
print_summary(filtered, scope, start, end)
if __name__ == '__main__':
main()
if __name__ == "__main__":
sys.exit(run())
@@ -1,132 +1,102 @@
#!/usr/bin/env python3
"""
扫描已过截止时间但未完成的待办,输出逾期清单
"""Return overdue open Todo tasks through the bounded Todo Shortcut."""
用法:
python todo_overdue_check.py
python todo_overdue_check.py --dry-run
"""
import sys
import argparse
import json
import subprocess
import sys
from datetime import datetime
from typing import List, Dict, Any, Optional
PAGE_SIZE = 50
MAX_PAGES = 10
PRIORITY_MAP = {10: '低', 20: '普通', 30: '较高', 40: '紧急'}
from typing import Any, Dict, List, Optional
from zoneinfo import ZoneInfo
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return None
TIMEZONE = ZoneInfo("Asia/Shanghai")
class ScriptError(RuntimeError):
pass
def run_dws_json(args: List[str], dws: str = "dws") -> Dict[str, Any]:
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
[dws, *args], capture_output=True, text=True, timeout=120
)
if result.returncode != 0:
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f"错误:{e}", file=sys.stderr)
return None
def fetch_all_undone(dry_run: bool = False) -> List[Dict[str, Any]]:
all_todos: List[Dict[str, Any]] = []
for page in range(1, MAX_PAGES + 1):
data = run_dws([
'todo', 'task', 'list',
'--page', str(page), '--size', str(PAGE_SIZE),
'--status', 'false', '--format', 'json',
], dry_run=dry_run)
if dry_run or not data:
break
if isinstance(data, list):
items = data
elif isinstance(data, dict):
inner = data.get('result', data)
if isinstance(inner, dict):
items = inner.get('todoCards', [])
elif isinstance(inner, list):
items = inner
else:
items = []
else:
items = []
if not items or not isinstance(items, list):
break
all_todos.extend(items)
if len(items) < PAGE_SIZE:
break
return all_todos
def find_overdue(todos: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
now_ms = int(datetime.now().timestamp() * 1000)
overdue = []
for t in todos:
due = t.get('dueTime') or t.get('due')
if not due:
continue
try:
if int(due) < now_ms:
overdue.append(t)
except (ValueError, TypeError):
continue
return overdue
def days_overdue(due_ms) -> int:
now = datetime.now()
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
raise ScriptError(str(exc)) from exc
if result.returncode != 0:
raise ScriptError(result.stderr.strip() or f"dws exited {result.returncode}")
try:
due_dt = datetime.fromtimestamp(int(due_ms) / 1000)
return max(0, (now - due_dt).days)
except (ValueError, TypeError, OSError):
payload = json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise ScriptError("dws returned non-JSON output") from exc
if not isinstance(payload, dict):
raise ScriptError("dws returned a non-object JSON payload")
if payload.get("ok") is False or payload.get("success") is False:
error = payload.get("error", {})
raise ScriptError(str(error.get("message") if isinstance(error, dict) else payload))
return payload
def extract_overdue(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
data: Any = payload.get("data", payload)
value = data.get("overdue") if isinstance(data, dict) else None
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
raise ScriptError("Todo response is missing a valid overdue[] collection")
return value
def run(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dws", default="dws")
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args(argv)
command = ["todo", "+overdue", "--format", "json"]
if args.dry_run:
print(json.dumps({"command": [args.dws, *command]}, ensure_ascii=False))
return 0
def main():
dry_run = '--dry-run' in sys.argv
todos = fetch_all_undone(dry_run=dry_run)
if dry_run:
return
overdue = find_overdue(todos)
overdue.sort(
key=lambda t: int(t.get('dueTime') or t.get('due', 0))
)
print(f"\n⏰ 逾期待办检查 ({datetime.now().strftime('%Y-%m-%d %H:%M')})")
print('=' * 50)
if not overdue:
print(' ✅ 没有逾期待办,继续保持!')
return
for t in overdue:
title = t.get('subject') or t.get('title', '无标题')
due = t.get('dueTime') or t.get('due')
days = days_overdue(due)
pri = PRIORITY_MAP.get(
int(t.get('priority', 20)), '普通'
try:
now = datetime.now(TIMEZONE)
items = []
for item in extract_overdue(run_dws_json(command, args.dws)):
task = item.get("taskId")
due = item.get("dueTime")
if task in (None, "") or due in (None, ""):
raise ScriptError("overdue item is missing taskId or dueTime")
try:
due_ms = int(due)
due_at = datetime.fromtimestamp(due_ms / 1000, TIMEZONE)
except (OSError, TypeError, ValueError) as exc:
raise ScriptError(f"invalid overdue dueTime: {due!r}") from exc
items.append(
{
"taskId": str(task),
"title": item.get("subject") or item.get("title") or "",
"dueTime": due_ms,
"dueTimeISO": due_at.isoformat(),
"daysOverdue": max(0, (now.date() - due_at.date()).days),
}
)
items.sort(key=lambda item: item["dueTime"])
print(
json.dumps(
{
"complete": True,
"timezone": str(TIMEZONE),
"checkedAt": now.isoformat(),
"count": len(items),
"todos": items,
},
ensure_ascii=False,
indent=2,
)
)
due_str = datetime.fromtimestamp(
int(due) / 1000
).strftime('%Y-%m-%d')
print(f" 🔴 [{pri}] {title}")
print(f" 截止: {due_str} 逾期: {days} 天")
print(f"\n合计: {len(overdue)} 条逾期待办")
sys.exit(1 if overdue else 0)
return 0
except ScriptError as exc:
print(json.dumps({"complete": False, "error": str(exc)}, ensure_ascii=False))
return 2
if __name__ == '__main__':
main()
if __name__ == "__main__":
sys.exit(run())
+517
View File
@@ -0,0 +1,517 @@
#!/usr/bin/env python3
"""Regression tests for the Todo Skill Golden Routes and deterministic scripts."""
import contextlib
import importlib.util
import io
import json
import re
import sys
import tempfile
import unittest
from datetime import timedelta
from pathlib import Path
from unittest import mock
sys.dont_write_bytecode = True
ROOT = Path(__file__).resolve().parents[2]
TODO_ROOT = ROOT / "skills" / "multi" / "dingtalk-todo"
def load_script(filename):
path = TODO_ROOT / "scripts" / filename
spec = importlib.util.spec_from_file_location(f"todo_test_{path.stem}", path)
if spec is None or spec.loader is None:
raise RuntimeError(f"cannot load {path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
DAILY = load_script("todo_daily_summary.py")
OVERDUE = load_script("todo_overdue_check.py")
BATCH = load_script("todo_batch_create.py")
class TodoSkillAlignmentTest(unittest.TestCase):
def test_golden_routes_prefer_verified_shortcuts(self):
skill = (TODO_ROOT / "SKILL.md").read_text(encoding="utf-8")
for route in (
"todo +remind",
"todo +assign",
"todo +create",
"todo +get-my-tasks",
"todo +search",
"todo +complete",
"todo +update",
"todo +reminder",
):
with self.subTest(route=route):
self.assertIn(route, skill)
self.assertIn("## Golden Routes", skill)
self.assertIn("只有当前 leaf 的 flag 或安全语义确实不明时才查精确 leaf", skill)
self.assertLessEqual(len(skill.encode("utf-8")), 16000)
def test_composite_lifecycle_starts_with_atomic_create(self):
skill = (TODO_ROOT / "SKILL.md").read_text(encoding="utf-8")
lifecycle = (TODO_ROOT / "references" / "02-task.md").read_text(
encoding="utf-8"
)
self.assertIn("组合生命周期从原子创建开始", skill)
self.assertIn("不要用创建 Shortcut 代替第一步", skill)
self.assertIn("dws todo task create --title", lifecycle)
self.assertIn("dws contact user get-self --format json", lifecycle)
self.assertIn(
'task create-sub --parent-id <PARENT_ID> --title "<标题>" --executors <USER_ID>',
lifecycle,
)
self.assertNotIn("dws contact me --format json", lifecycle)
def test_step_routing_keeps_shortcuts_and_dynamic_id_boundaries(self):
lifecycle = (TODO_ROOT / "references" / "02-task.md").read_text(
encoding="utf-8"
)
for route in (
"+get-my-tasks",
"+get-related-tasks",
"+due-today",
"+overdue",
"+search",
"+get",
"+complete",
"+reopen",
"+update",
"+comment",
"+reminder",
"+list-sub",
"+list-attachment",
):
with self.subTest(route=route):
self.assertIn(route, lifecycle)
for stable_id in (
"taskId",
"commentId",
"attachmentId",
"tagCode",
"userId",
):
with self.subTest(stable_id=stable_id):
self.assertIn(stable_id, lifecycle)
self.assertIn("禁止运行 `git tag`", lifecycle)
def test_golden_route_table_keeps_exactly_three_columns(self):
skill = (TODO_ROOT / "SKILL.md").read_text(encoding="utf-8")
table = skill.split("## Golden Routes", 1)[1].split("## 低频原子能力", 1)[0]
for row in (line for line in table.splitlines() if line.startswith("|")):
with self.subTest(row=row):
self.assertEqual(5, len(re.split(r"(?<!\\)\|", row)), row)
def test_all_markdown_tables_keep_consistent_column_counts(self):
for document in TODO_ROOT.rglob("*.md"):
expected_columns = None
for line_number, line in enumerate(
document.read_text(encoding="utf-8").splitlines(), start=1
):
if line.startswith("|") and line.endswith("|"):
columns = len(re.split(r"(?<!\\)\|", line))
if expected_columns is None:
expected_columns = columns
with self.subTest(document=document.name, line=line_number):
self.assertEqual(expected_columns, columns, line)
else:
expected_columns = None
def test_references_are_todo_only_and_reminder_contract_is_consistent(self):
combined = "\n".join(
path.read_text(encoding="utf-8")
for path in (TODO_ROOT / "references").rglob("*.md")
)
self.assertNotIn("## #7 听记与会后", combined)
self.assertNotIn("minutes list all", combined)
self.assertNotIn("当前不支持单独的 `reminder`", combined)
self.assertIn("提醒写入目前没有对应的查询接口", combined)
def test_markdown_links_resolve(self):
missing = []
pattern = re.compile(r"\[[^\]]+\]\(([^)#]+)(?:#[^)]+)?\)")
for document in TODO_ROOT.rglob("*.md"):
for target in pattern.findall(document.read_text(encoding="utf-8")):
if "://" not in target and not (document.parent / target).resolve().exists():
missing.append(f"{document.relative_to(ROOT)} -> {target}")
self.assertEqual([], missing)
class TodoDailySummaryTest(unittest.TestCase):
def test_uses_bounded_shortcut_and_excludes_missing_due(self):
start, _ = DAILY.date_range("today")
inside = int((start + timedelta(hours=9)).timestamp() * 1000)
outside = int((start + timedelta(days=2)).timestamp() * 1000)
calls = []
def fake_run(args, dws="dws"):
calls.append(args)
return {
"ok": True,
"outcome": "success",
"data": {
"complete": True,
"count": 3,
"todos": [
{"taskId": "in", "subject": "inside", "dueTime": inside},
{"taskId": "none", "subject": "no due"},
{"taskId": "out", "subject": "outside", "dueTime": outside},
],
},
}
stdout = io.StringIO()
with mock.patch.object(DAILY, "run_dws_json", side_effect=fake_run):
with contextlib.redirect_stdout(stdout):
code = DAILY.run(["today"])
self.assertEqual(0, code)
self.assertEqual(1, len(calls))
self.assertEqual(["todo", "+get-my-tasks"], calls[0][:2])
self.assertIn("--all", calls[0])
self.assertIn("--plan-finish-start", calls[0])
self.assertEqual(
["in"], [item["taskId"] for item in json.loads(stdout.getvalue())["todos"]]
)
def test_incomplete_traversal_fails_closed(self):
payload = {"ok": True, "data": {"complete": False, "todos": []}}
stdout = io.StringIO()
with mock.patch.object(DAILY, "run_dws_json", return_value=payload):
with contextlib.redirect_stdout(stdout):
code = DAILY.run(["today"])
self.assertEqual(2, code)
self.assertFalse(json.loads(stdout.getvalue())["complete"])
class TodoOverdueTest(unittest.TestCase):
def test_uses_overdue_shortcut_and_empty_is_success(self):
calls = []
def fake_run(args, dws="dws"):
calls.append(args)
return {"ok": True, "outcome": "success", "data": {"overdue": []}}
stdout = io.StringIO()
with mock.patch.object(OVERDUE, "run_dws_json", side_effect=fake_run):
with contextlib.redirect_stdout(stdout):
code = OVERDUE.run([])
self.assertEqual(0, code)
self.assertEqual(["todo", "+overdue", "--format", "json"], calls[0])
self.assertEqual(0, json.loads(stdout.getvalue())["count"])
class TodoBatchCreateTest(unittest.TestCase):
def test_batch_uses_iso_due_captures_id_and_reads_back(self):
with tempfile.TemporaryDirectory() as raw:
source = Path(raw) / "todos.json"
source.write_text(
json.dumps(
[
{
"title": "reviewed task",
"executors": "user1",
"priority": 40,
"due": "2026-08-18",
}
]
),
encoding="utf-8",
)
calls = []
def fake_subprocess_run(argv, **kwargs):
calls.append((argv, kwargs))
if argv[1:4] == ["todo", "task", "create"]:
payload = {"result": {"taskId": "task-1"}}
else:
payload = {
"ok": True,
"data": {
"todoDetailModel": {
"taskId": "task-1",
"subject": "reviewed task",
}
},
}
return mock.Mock(
returncode=0,
stdout=json.dumps(payload),
stderr="",
)
stdout = io.StringIO()
preview_stdout = io.StringIO()
with mock.patch.object(BATCH.subprocess, "run") as preview_run_dws:
with contextlib.redirect_stdout(preview_stdout):
preview_code = BATCH.run(
[str(source), "--dry-run", "--dws", "fake-dws"]
)
self.assertEqual(0, preview_code)
preview_run_dws.assert_not_called()
plan_digest = json.loads(preview_stdout.getvalue())["planDigest"]
with mock.patch.object(
BATCH.subprocess, "run", side_effect=fake_subprocess_run
):
with contextlib.redirect_stdout(stdout):
code = BATCH.run(
[
str(source),
"--yes",
"--confirm-digest",
plan_digest,
"--dws",
"fake-dws",
]
)
self.assertEqual(0, code)
self.assertEqual(2, len(calls))
self.assertEqual(
[
"fake-dws",
"todo",
"task",
"create",
"--title",
"reviewed task",
"--executors",
"user1",
"--priority",
"40",
"--due",
"2026-08-18T23:59:59+08:00",
"--format",
"json",
"--yes",
],
calls[0][0],
)
self.assertEqual(
[
"fake-dws",
"todo",
"task",
"get",
"--task-id",
"task-1",
"--format",
"json",
],
calls[1][0],
)
for _, kwargs in calls:
self.assertTrue(kwargs["capture_output"])
self.assertTrue(kwargs["text"])
self.assertIs(BATCH.subprocess.DEVNULL, kwargs["stdin"])
self.assertEqual(120, kwargs["timeout"])
payload = json.loads(stdout.getvalue())
self.assertTrue(payload["complete"])
self.assertEqual(plan_digest, payload["planDigest"])
self.assertEqual("verified", payload["ledger"][0]["status"])
def test_unconfirmed_batch_stops_before_first_dws_call(self):
with tempfile.TemporaryDirectory() as raw:
source = Path(raw) / "todos.json"
source.write_text(
'[{"title":"reviewed task","executors":"user1"}]',
encoding="utf-8",
)
stdout = io.StringIO()
with mock.patch.object(BATCH.subprocess, "run") as run_dws:
with contextlib.redirect_stdout(stdout):
code = BATCH.run([str(source)])
self.assertEqual(2, code)
run_dws.assert_not_called()
payload = json.loads(stdout.getvalue())
self.assertFalse(payload["complete"])
self.assertEqual("confirmation_required", payload["reason"])
self.assertFalse(payload["executionStarted"])
def test_yes_without_confirmed_digest_stops_before_first_dws_call(self):
with tempfile.TemporaryDirectory() as raw:
source = Path(raw) / "todos.json"
source.write_text(
'[{"title":"reviewed task","executors":"user1"}]',
encoding="utf-8",
)
stdout = io.StringIO()
with mock.patch.object(BATCH.subprocess, "run") as run_dws:
with contextlib.redirect_stdout(stdout):
code = BATCH.run([str(source), "--yes"])
self.assertEqual(2, code)
run_dws.assert_not_called()
payload = json.loads(stdout.getvalue())
self.assertEqual("confirmation_required", payload["reason"])
self.assertFalse(payload["executionStarted"])
def test_dry_run_previews_exact_batch_without_confirmation_bypass(self):
with tempfile.TemporaryDirectory() as raw:
source = Path(raw) / "todos.json"
source.write_text(
'[{"title":"reviewed task","executors":"user1"}]',
encoding="utf-8",
)
stdout = io.StringIO()
with mock.patch.object(BATCH.subprocess, "run") as run_dws:
with contextlib.redirect_stdout(stdout):
code = BATCH.run([str(source), "--dry-run"])
self.assertEqual(0, code)
run_dws.assert_not_called()
payload = json.loads(stdout.getvalue())
self.assertTrue(payload["complete"])
self.assertTrue(payload["dryRun"])
self.assertRegex(payload["planDigest"], r"^sha256:[0-9a-f]{64}$")
self.assertEqual(
[
"dws",
"todo",
"task",
"create",
"--title",
"reviewed task",
"--executors",
"user1",
"--format",
"json",
],
payload["ledger"][0]["command"],
)
self.assertNotIn("--yes", payload["ledger"][0]["command"])
def test_plan_digest_is_stable_for_equivalent_validated_content(self):
first = BATCH.validate(
[
{
"title": " reviewed task ",
"executors": "user1",
"priority": "40",
"due": "2026-08-18",
}
]
)
second = BATCH.validate(
[
{
"due": "2026-08-18T23:59:59+08:00",
"priority": 40,
"executors": "user1",
"title": "reviewed task",
}
]
)
self.assertEqual(
BATCH.batch_plan_digest(first), BATCH.batch_plan_digest(second)
)
def test_changed_file_rejects_confirmed_digest_before_first_dws_call(self):
with tempfile.TemporaryDirectory() as raw:
source = Path(raw) / "todos.json"
source.write_text(
'[{"title":"reviewed task","executors":"user1"}]',
encoding="utf-8",
)
preview_stdout = io.StringIO()
with mock.patch.object(BATCH.subprocess, "run") as preview_run_dws:
with contextlib.redirect_stdout(preview_stdout):
preview_code = BATCH.run([str(source), "--dry-run"])
self.assertEqual(0, preview_code)
preview_run_dws.assert_not_called()
confirmed_digest = json.loads(preview_stdout.getvalue())["planDigest"]
source.write_text(
'[{"title":"changed task","executors":"user1"}]',
encoding="utf-8",
)
stdout = io.StringIO()
with mock.patch.object(BATCH.subprocess, "run") as run_dws:
with contextlib.redirect_stdout(stdout):
code = BATCH.run(
[
str(source),
"--yes",
"--confirm-digest",
confirmed_digest,
]
)
self.assertEqual(2, code)
run_dws.assert_not_called()
payload = json.loads(stdout.getvalue())
self.assertEqual("plan_mismatch", payload["reason"])
self.assertFalse(payload["executionStarted"])
self.assertEqual(confirmed_digest, payload["confirmedPlanDigest"])
self.assertNotEqual(confirmed_digest, payload["actualPlanDigest"])
def test_short_numeric_due_stops_before_first_dws_call(self):
for due in ("0", "123", "2026", "0000000000123"):
with self.subTest(due=due), tempfile.TemporaryDirectory() as raw:
source = Path(raw) / "todos.json"
source.write_text(
json.dumps(
[
{
"title": "must not be created",
"executors": "user1",
"due": due,
}
]
),
encoding="utf-8",
)
stdout = io.StringIO()
with mock.patch.object(BATCH.subprocess, "run") as run_dws:
with contextlib.redirect_stdout(stdout):
code = BATCH.run(
[
str(source),
"--yes",
"--confirm-digest",
"sha256:" + "0" * 64,
]
)
self.assertEqual(2, code)
run_dws.assert_not_called()
payload = json.loads(stdout.getvalue())
self.assertFalse(payload["complete"])
self.assertIn("13-digit", payload["error"])
def test_modern_epoch_millisecond_due_is_accepted(self):
self.assertEqual(
"2025-01-01T08:00:00+08:00",
BATCH.normalize_due("1735689600000"),
)
def test_possible_commit_is_preserved_as_unknown(self):
with tempfile.TemporaryDirectory() as raw:
source = Path(raw) / "todos.json"
source.write_text(
'[{"title":"x","executors":"u"}]', encoding="utf-8"
)
failure = BATCH.ScriptError("timeout", commit_unknown=True)
items = BATCH.validate(json.loads(source.read_text(encoding="utf-8")))
plan_digest = BATCH.batch_plan_digest(items)
stdout = io.StringIO()
with mock.patch.object(BATCH, "run_dws_json", side_effect=failure):
with contextlib.redirect_stdout(stdout):
code = BATCH.run(
[
str(source),
"--yes",
"--confirm-digest",
plan_digest,
]
)
self.assertEqual(2, code)
payload = json.loads(stdout.getvalue())
self.assertEqual(1, payload["unknownCount"])
self.assertEqual("unknown", payload["ledger"][0]["status"])
if __name__ == "__main__":
unittest.main()
+21
View File
@@ -0,0 +1,21 @@
package unit
import (
"os"
"os/exec"
"testing"
)
func TestCrossPlatformCoverageTodoSkillScripts(t *testing.T) {
python, err := exec.LookPath("python3")
if err != nil {
t.Skip("python3 is not installed")
}
cmd := exec.Command(python, "test/scripts/todo_skill_scripts_test.py")
cmd.Dir = "../.."
cmd.Env = append(os.Environ(), "PYTHONDONTWRITEBYTECODE=1")
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("todo skill script tests failed: %v\n%s", err, output)
}
}