dsh-global-todo 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## [0.8.0] — 2026-10-20
2
+
3
+ A todo can carry its plan, and the plan is what the agent receives.
4
+
5
+ - **`steps[]` on a todo**: `{ text, done }`, up to 20 steps of 200 characters. Strings are accepted
6
+ wherever steps are (create, agent tools), a blank line is not a step, and a longer plan is
7
+ refused rather than truncated — twenty steps means the todo should have been split.
8
+ - **The dispatch prompt numbers them**, marking the ones already done. Handing over "1. 对齐版本号
9
+ 2. 跑 verify 3. 打 tag" is worth more than handing over a paragraph.
10
+ - **The panel shows progress without opening the row** (`步骤 2/4` in the metadata), renders the
11
+ ticks in the detail, and takes one step per line in the add form.
12
+ - **Agent guidance says so too**: multi-round work should be recorded with 3–6 steps, and a
13
+ finished step is one `global_todo_update`.
14
+ - Replacement semantics (like `links`): the array travels whole through `applyPatch`, so there is
15
+ still one validation and one write path.
16
+
1
17
  ## [0.7.2] — 2026-10-20
2
18
 
3
19
  The roadmap audited against the tree, and the review feature brought up to the same standard
package/README.md CHANGED
@@ -203,7 +203,7 @@ Your list is not touched by install or uninstall: it lives outside the plugin, a
203
203
 
204
204
  | Tool | Purpose |
205
205
  |---|---|
206
- | `global_todo_add` | Record an item — description, attachments, tags, priority, due time |
206
+ | `global_todo_add` | Record an item — description, attachments, steps, tags, priority, due time |
207
207
  | `global_todo_note` | Append a progress note — where you are, what blocks you, who must decide |
208
208
  | `global_todo_capture` | **Record a batch**: for 「抓一下待办」 or a turn that produced several follow-ups; each entry is validated on its own |
209
209
  | `global_todo_list` | Query by state, tag or text; every row carries its creation time |
@@ -264,7 +264,7 @@ fence when that service is present, and by a strict loopback check otherwise.
264
264
  | POST | `/api/global-todo/bulk` | `{ ids, status?, addTags?, removeTags? }` — one change across a selection; a stale id lands in `unmatched` instead of failing the batch |
265
265
  | POST | `/api/global-todo/ingest` | `{ items: [{ title, source: { kind }, confidence }], dryRun? }` with `Authorization: Bearer <settings.ingestToken>` — external items; below `ingestConfirmBelow` they wait in the inbox |
266
266
  | GET | `/api/global-todo/health` | — (store location, counts, dispatch capabilities, plus **tool registration** and **whether the completion watcher attached** — both fail quietly, so both are reported — and `metrics`: dispatch success rate, agent tool calls, list growth; `autoCollect`: why each waiting conversation was or was not asked, counters, and how the settings file loaded) |
267
- | POST | `/api/global-todo/create` | `{ title, description?, links?, tags?, priority?, dueAt? }` |
267
+ | POST | `/api/global-todo/create` | `{ title, description?, links?, steps?, tags?, priority?, dueAt? }` |
268
268
  | POST | `/api/global-todo/update` | `{ id, ...fields, by?: human\|agent }` |
269
269
  | POST | `/api/global-todo/status` | `{ id, status, by?: human\|agent }` |
270
270
  | POST | `/api/global-todo/remove` | `{ id }` |
package/README.zh.md CHANGED
@@ -128,7 +128,7 @@ dsh plugin --profile web remove dsh-global-todo
128
128
 
129
129
  | 工具 | 用途 |
130
130
  |---|---|
131
- | `global_todo_add` | 登记一条:描述、附件、标签、优先级、截止时间;同时记下「在哪个工作目录、哪个会话里写的」 |
131
+ | `global_todo_add` | 登记一条:描述、附件、步骤、标签、优先级、截止时间;同时记下「在哪个工作目录、哪个会话里写的」 |
132
132
  | `global_todo_note` | 追加一条**进展记录**(做到哪了、卡在哪、需要谁做什么);不改写你的描述 |
133
133
  | `global_todo_capture` | **一次登记多条**(批量抓取):用户说「抓一下待办」,或你一轮里发现好几件要跟进的;逐条校验,坏的那条只跳过它自己 |
134
134
  | `global_todo_list` | 按状态/标签/关键词查询,每条都带上加入时间 |
@@ -160,7 +160,7 @@ dsh plugin --profile web remove dsh-global-todo
160
160
  | POST | `/api/global-todo/bulk` | `{ ids, status?, addTags?, removeTags? }`——一次改一整批;失效的 id 进 `unmatched`,不会让整批失败 |
161
161
  | POST | `/api/global-todo/ingest` | `{ items: [{ title, source: { kind }, confidence }], dryRun? }`,带 `Authorization: Bearer <settings.ingestToken>`——外部条目;置信度低于 `ingestConfirmBelow` 的先进收件箱 |
162
162
  | GET | `/api/global-todo/health` | —(数据位置、统计、派发能力,以及**工具注册状态**与**自动收尾观察者是否挂上**——这两种失败是静默的,所以必须能查;还有 `metrics`:派发成功率、工具调用次数、清单增长;`autoCollect`:每个候选对话**为什么被问/没被问**、计次,以及设置文件是怎么加载进来的) |
163
- | POST | `/api/global-todo/create` | `{ title, description?, links?, tags?, priority?, dueAt? }` |
163
+ | POST | `/api/global-todo/create` | `{ title, description?, links?, steps?, tags?, priority?, dueAt? }` |
164
164
  | POST | `/api/global-todo/update` | `{ id, ...字段, by?: human\|agent }` |
165
165
  | POST | `/api/global-todo/status` | `{ id, status, by?: human\|agent }` |
166
166
  | POST | `/api/global-todo/remove` | `{ id }` |
package/docs/DESIGN.md CHANGED
@@ -57,6 +57,25 @@ $DSH_HOME/global-todo/todos.json ← { version: 1, todos: [ … ] }
57
57
  version, and a future migration step would run before the first write, keeping the old
58
58
  file as a backup.
59
59
 
60
+ ## A plan inside the todo
61
+
62
+ A todo is one line, but the work behind it is often five steps. Rather than grow a second object
63
+ (a "project", a "plan file"), a todo may carry `steps[]` — text plus a tick, order is the plan.
64
+
65
+ - **Why inside the todo:** the dispatch prompt is the product here. Handing an agent "ship the
66
+ plugin" is a paragraph; handing it "1. 对齐版本号 2. 跑 verify 3. 打 tag 4. 补截图" is a plan it
67
+ can execute and report against. The steps are also what a person reviews afterwards.
68
+ - **Shape:** `{ text, done }`, capped at `LIMITS.steps` (20) and 200 characters per step. Strings
69
+ are accepted wherever an array of steps is (create, agent tools) so a caller never has to spell
70
+ out `{ done: false }`; a blank line is a typo, not a step, and a plan longer than the cap is
71
+ refused rather than truncated — twenty steps means the todo should have been split.
72
+ - **Replacement, not patching:** `steps` travels as a whole array through `applyPatch`, the same
73
+ rule as `links`. Toggling one tick is a client-side rewrite of the array, which keeps one write
74
+ path and one validation.
75
+ - **Where it shows:** the row's metadata gets `步骤 2/4` (progress without opening anything), the
76
+ detail renders the ticks, the panel's add form takes one step per line, and the dispatch prompt
77
+ numbers them with `[已完成]` on the ones already behind us.
78
+
60
79
  ## On a wide window, the detail stands beside the list
61
80
 
62
81
  Expanding a row inline is right on a narrow window and wrong on a wide one: the list jumps, the
package/docs/ROADMAP.md CHANGED
@@ -152,17 +152,15 @@ reminder) photographed into `docs/screenshots/` by `npm run screenshots`, and
152
152
  Ordered by value against effort, and audited against the tree on 2026-10-20 — "today"
153
153
  means what the code actually does, not what the last plan hoped.
154
154
 
155
- ### 1. Checklist inside a todo — the highest-value gap
155
+ ### 1. ~~Checklist inside a todo~~ — landed in 0.8.0
156
156
 
157
- Large items ("ship the plugin") are a list of steps wearing a title. Proposed: an optional
158
- `steps[]` (text + done) per todo, rendered as checkboxes in the row and the detail, and
159
- shipped to the agent as a numbered plan in the dispatch prompt.
157
+ `steps[]` (text + tick) ships on the todo, renders as `步骤 2/4` in the row and as ticks in the
158
+ detail, is editable from the panel's add form, and travels in the dispatch prompt as a numbered
159
+ plan. `normalizeSteps()` in `lib/model.js`, `dispatchPrompt()` in `lib/service.js`; tests in
160
+ `test/model.test.mjs` and `test/service.test.mjs`.
160
161
 
161
- - Today: nothing named `steps` exists in `lib/`; `grep -rn steps lib` is empty.
162
- - Effort: medium — model (create / `applyPatch` / `normalizeStored`), a tool field, the
163
- panel, and the prompt builder that already assembles the dispatch text.
164
- - Why first: it improves the *quality of every hand-off*, not just one screen. A dispatch
165
- that arrives as "step 2 of 5" is worth more than one that arrives as a paragraph.
162
+ What is left of the original idea: making a step addressable on its own (a tool that ticks step 3
163
+ without resending the array) — not worth a tenth tool today.
166
164
 
167
165
  ### 2. Session read-back — makes hand-off stop being fire-and-forget
168
166
 
Binary file
Binary file
Binary file
Binary file
Binary file
package/lib/client.js CHANGED
@@ -50,7 +50,7 @@ window.__ModuleLoader__.load({
50
50
  * different mechanisms (restart vs. hot reload), so "which one am I looking
51
51
  * at" needs an answer that the bundle itself carries.
52
52
  */
53
- const UI_REV = 'ui46'
53
+ const UI_REV = 'ui47'
54
54
  /** Last language resolved for the shell; module-level so a fetch failure can be worded too. */
55
55
  let activeLang = 'zh'
56
56
 
@@ -288,6 +288,13 @@ window.__ModuleLoader__.load({
288
288
  'meta.session.noDirHint': '这个会话没有工作目录,agent 在里面做不了事;用「派发:开新会话」重派一次',
289
289
  'meta.dispatchFailed': '上次派发失败',
290
290
  'detail.description': '描述',
291
+ 'meta.steps': '步骤 {done}/{total}',
292
+ 'detail.steps': '步骤',
293
+ 'steps.none': '还没有步骤。',
294
+ 'field.steps': '第一步…(一行一步)',
295
+ 'field.stepsHint': '一行一步;派发时会作为编号计划交给 Agent。',
296
+ 'step.done': '标记这一步完成',
297
+ 'step.undo': '取消这一步的完成标记',
291
298
  'detail.noDescription': '没有描述。',
292
299
  'detail.links': '相关文档与地址',
293
300
  'detail.completion': '完成记录',
@@ -561,6 +568,13 @@ window.__ModuleLoader__.load({
561
568
  'meta.session.noDirHint': 'This conversation has no working directory, so an agent cannot do anything in it; hand it off again with a new conversation.',
562
569
  'meta.dispatchFailed': 'last hand-off failed',
563
570
  'detail.description': 'Description',
571
+ 'meta.steps': 'steps {done}/{total}',
572
+ 'detail.steps': 'Steps',
573
+ 'steps.none': 'No steps yet.',
574
+ 'field.steps': 'First step… (one per line)',
575
+ 'field.stepsHint': 'One per line; they ship to the agent as a numbered plan.',
576
+ 'step.done': 'Mark this step done',
577
+ 'step.undo': 'Clear this step',
564
578
  'detail.noDescription': 'No description.',
565
579
  'detail.links': 'Documents and links',
566
580
  'detail.noLinks': 'No attachments. Attach an absolute document path, or a URL.',
@@ -1281,6 +1295,16 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1281
1295
  .gt-links { display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 0; list-style: none; }
1282
1296
  .gt-link { display: flex; gap: 8px; align-items: center; padding: 4px 0; min-width: 0; }
1283
1297
  .gt-link-main { display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1; }
1298
+ .gt-textarea-steps { min-height: 76px; }
1299
+ .gt-steps { display: flex; flex-direction: column; gap: 2px; margin: 0; padding: 0; list-style: none; }
1300
+ .gt-step { display: flex; align-items: flex-start; gap: 8px; padding: 3px 0; }
1301
+ .gt-step-check { flex: 0 0 auto; width: 18px; height: 18px; margin-top: 2px; padding: 0; cursor: pointer;
1302
+ border-radius: 5px; border: 1.4px solid var(--dsw-alias-border-l2); background: transparent;
1303
+ color: transparent; display: inline-flex; align-items: center; justify-content: center; }
1304
+ .gt-step[data-done="1"] .gt-step-check { border-color: var(--dsw-alias-state-success-primary);
1305
+ background: var(--dsw-alias-state-success-primary); color: var(--dsw-alias-bg-base); }
1306
+ .gt-step-text { font-size: 13px; line-height: 20px; word-break: break-word; }
1307
+ .gt-step[data-done="1"] .gt-step-text { color: var(--dsw-alias-label-secondary); text-decoration: line-through; }
1284
1308
  .gt-note-item { display: flex; flex-direction: column; gap: 4px; padding: 8px 0; }
1285
1309
  .gt-note-item + .gt-note-item { border-top: 1px solid var(--dsw-alias-border-l1); }
1286
1310
  .gt-note-meta { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--dsw-alias-label-secondary);
@@ -1671,6 +1695,12 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1671
1695
  )
1672
1696
  }
1673
1697
  meta.push(h('span', { key: 'added', title: new Date(todo.createdAt).toLocaleString() }, ageText(lang, todo.createdAt, now)))
1698
+ // Progress on the plan, when there is one: the row shows where it stands without
1699
+ // opening the detail.
1700
+ if (Array.isArray(todo.steps) && todo.steps.length > 0) {
1701
+ const done = todo.steps.filter((step) => step.done === true).length
1702
+ meta.push(h('span', { key: 'steps' }, t('meta.steps', { done, total: todo.steps.length })))
1703
+ }
1674
1704
  if (todo.tags.length > 0) {
1675
1705
  const shown = todo.tags.slice(0, 4)
1676
1706
  meta.push(
@@ -2155,6 +2185,40 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2155
2185
  ),
2156
2186
  ),
2157
2187
  ),
2188
+ h(
2189
+ 'div',
2190
+ { className: 'gt-section' },
2191
+ h('div', { className: 'gt-section-title' }, t('detail.steps')),
2192
+ (todo.steps ?? []).length === 0
2193
+ ? h('div', { className: 'gt-muted' }, t('steps.none'))
2194
+ : h(
2195
+ 'ul',
2196
+ { className: 'gt-steps' },
2197
+ todo.steps.map((step, index) =>
2198
+ h(
2199
+ 'li',
2200
+ { key: `${index}:${step.text}`, className: 'gt-step', 'data-done': step.done === true ? '1' : '0' },
2201
+ h(
2202
+ 'button',
2203
+ {
2204
+ type: 'button',
2205
+ className: 'gt-step-check',
2206
+ 'aria-pressed': step.done === true,
2207
+ title: step.done === true ? t('step.undo') : t('step.done'),
2208
+ 'aria-label': step.done === true ? t('step.undo') : t('step.done'),
2209
+ disabled: busy,
2210
+ onClick: () =>
2211
+ onAction('steps', todo, {
2212
+ steps: todo.steps.map((item, i) => (i === index ? { text: item.text, done: item.done !== true } : { text: item.text, done: item.done === true })),
2213
+ }),
2214
+ },
2215
+ step.done === true ? h(Icon, { name: 'check', size: 11 }) : null,
2216
+ ),
2217
+ h('span', { className: 'gt-step-text' }, step.text),
2218
+ ),
2219
+ ),
2220
+ ),
2221
+ ),
2158
2222
  h(
2159
2223
  'div',
2160
2224
  { className: 'gt-section' },
@@ -2349,7 +2413,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2349
2413
  const [more, setMore] = useState(false)
2350
2414
  const [collapsed, setCollapsed] = useState({})
2351
2415
  const [undo, setUndo] = useState(null)
2352
- const [draft, setDraft] = useState({ title: '', description: '', links: '', tags: '', priority: 'normal', due: '' })
2416
+ const [draft, setDraft] = useState({ title: '', description: '', links: '', steps: '', tags: '', priority: 'normal', due: '' })
2353
2417
  // Conversations come from the shell's own sessions service, declared in this
2354
2418
  // plugin's `inject` list the way the shipped panels do it
2355
2419
  // (`ui-subagent` / `agent-team`: ["sessions", "uiWorkspace", "slots", "locale"]).
@@ -2750,6 +2814,11 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2750
2814
  .split(',')
2751
2815
  .map((part) => part.trim())
2752
2816
  .filter((part) => part !== '')
2817
+ // One step per line: the shape a person already writes a plan in.
2818
+ const steps = (draft.steps ?? '')
2819
+ .split('\n')
2820
+ .map((line) => line.trim())
2821
+ .filter((line) => line !== '')
2753
2822
  await run('__new__', () =>
2754
2823
  call('create', {
2755
2824
  method: 'POST',
@@ -2757,13 +2826,14 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2757
2826
  title,
2758
2827
  ...(draft.description.trim() === '' ? {} : { description: draft.description.trim() }),
2759
2828
  ...(links.length === 0 ? {} : { links }),
2829
+ ...(steps.length === 0 ? {} : { steps }),
2760
2830
  ...(tags.length === 0 ? {} : { tags }),
2761
2831
  ...(draft.priority === 'normal' ? {} : { priority: draft.priority }),
2762
2832
  ...(draft.due === '' ? {} : { dueAt: new Date(draft.due).toISOString() }),
2763
2833
  },
2764
2834
  }),
2765
2835
  )
2766
- setDraft({ title: '', description: '', links: '', tags: '', priority: 'normal', due: '' })
2836
+ setDraft({ title: '', description: '', links: '', steps: '', tags: '', priority: 'normal', due: '' })
2767
2837
  setMore(false)
2768
2838
  }
2769
2839
 
@@ -3197,6 +3267,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3197
3267
  if (kind === 'archive') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'archived' } }))
3198
3268
  if (kind === 'unarchive') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'open' } }))
3199
3269
  if (kind === 'save') return run(todo.id, () => call('update', { method: 'POST', body: { id: todo.id, ...payload } }))
3270
+ if (kind === 'steps') return run(todo.id, () => call('update', { method: 'POST', body: { id: todo.id, steps: payload.steps } }))
3200
3271
  if (kind === 'note') return run(todo.id, () => call('note', { method: 'POST', body: { id: todo.id, ...payload } }))
3201
3272
  if (kind === 'link') return run(todo.id, () => call('link', { method: 'POST', body: { id: todo.id, ...payload } }))
3202
3273
  if (kind === 'unlink') return run(todo.id, () => call('link', { method: 'POST', body: { id: todo.id, remove: [payload.index] } }))
@@ -3770,6 +3841,19 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3770
3841
  }),
3771
3842
  h('span', { className: 'gt-hint' }, t('field.linksHint')),
3772
3843
  ),
3844
+ h(
3845
+ 'div',
3846
+ { className: 'gt-field' },
3847
+ h('label', { className: 'gt-field-label', htmlFor: 'gt-new-steps' }, t('detail.steps')),
3848
+ h('textarea', {
3849
+ id: 'gt-new-steps',
3850
+ className: 'gt-textarea gt-textarea-steps',
3851
+ placeholder: t('field.steps'),
3852
+ value: draft.steps,
3853
+ onChange: (event) => setDraft({ ...draft, steps: event.target.value }),
3854
+ }),
3855
+ h('span', { className: 'gt-hint' }, t('field.stepsHint')),
3856
+ ),
3773
3857
  h(
3774
3858
  'div',
3775
3859
  { className: 'gt-field-row' },
package/lib/index.js CHANGED
@@ -36,7 +36,7 @@ import { createStore, resolveStorePath } from './store.js'
36
36
  import { createTools } from './tools.js'
37
37
 
38
38
  /** Must equal `package.json`'s version; `npm run check` enforces that. */
39
- export const VERSION = '0.7.2'
39
+ export const VERSION = '0.8.0'
40
40
 
41
41
  /** Plugin id. Also the Loader row id in `cordis.patch.yml`. */
42
42
  export const name = 'dsh-global-todo'
@@ -74,6 +74,7 @@ const GUIDANCE = [
74
74
  '- 用户说「记一下 / 别忘了 / 回头办」。',
75
75
  '**不要记**:你这一步就能做完的、本会话内的执行计划、纯闲聊。宁可少记。',
76
76
  '记的时候:标题一句话说清要做什么;把相关文档**绝对路径**或网址挂进 `links`(接手时会作为上下文一起交出去);需要精确时间的写 `dueAt`。',
77
+ '**大活拆步骤**:一件需要多轮才能做完的事,用 `steps` 记下 3–6 步(`global_todo_add` 的 `steps` 参数)——派发时这些步骤会作为编号计划原样交给接手的 Agent,比一段散文有用得多;做完一步就用 `global_todo_update` 把该步的 `done` 置为 true。',
77
78
  '记完用一句话说明记了什么(别复述整条描述)。做完用 `global_todo_done` 留痕,**不要删**。',
78
79
  '**一轮收尾时顺手划掉**:如果清单里某条就是你刚做完的事,直接 `global_todo_done` 结束它,别留着占地方(插件也会在你安静下来后自动划掉,但你先划更准)。',
79
80
  '做不到 / 卡住了:用 `global_todo_note` 追加一条进展,说明卡在哪、需要用户做什么——**不要**改写待办的 description(那是用户自己的文字)。',
package/lib/model.js CHANGED
@@ -52,6 +52,10 @@ export const LIMITS = Object.freeze({
52
52
  activity: 50,
53
53
  note: 2000,
54
54
  notes: 50,
55
+ // A todo is a unit of work, not a project plan: a list longer than this is a sign the
56
+ // item should be split, and a cap keeps the dispatch prompt from becoming a document.
57
+ steps: 20,
58
+ step: 200,
55
59
  })
56
60
 
57
61
  /**
@@ -201,6 +205,9 @@ export function createTodo(input, options = {}) {
201
205
  dueAt: normalizeDue(raw.dueAt ?? raw.due),
202
206
  tags: normalizeTags(raw.tags),
203
207
  links,
208
+ // An optional plan inside the todo: the steps a person or an agent broke it into.
209
+ // Shipped to the agent as a numbered list, so hand-off stops being one paragraph.
210
+ steps: normalizeSteps(raw.steps),
204
211
  createdAt: now,
205
212
  updatedAt: now,
206
213
  createdBy: by,
@@ -305,6 +312,13 @@ export function normalizeStored(raw, options = {}) {
305
312
  }
306
313
  })(),
307
314
  links,
315
+ steps: (() => {
316
+ try {
317
+ return normalizeSteps(raw.steps)
318
+ } catch {
319
+ return []
320
+ }
321
+ })(),
308
322
  createdAt,
309
323
  updatedAt: Number.isFinite(raw.updatedAt) ? Number(raw.updatedAt) : createdAt,
310
324
  createdBy: normalizeActor(raw.createdBy),
@@ -563,11 +577,23 @@ export function applyPatch(todo, patch, options = {}) {
563
577
  changed.push('tags')
564
578
  }
565
579
  }
580
+ if (patch.steps !== undefined) {
581
+ const steps = normalizeSteps(patch.steps)
582
+ const same =
583
+ steps.length === todo.steps.length && steps.every((step, index) => step.text === todo.steps[index].text && step.done === todo.steps[index].done)
584
+ if (!same) {
585
+ todo.steps = steps
586
+ changed.push('steps')
587
+ }
588
+ }
566
589
  if (patch.status !== undefined) {
567
590
  const status = normalizeEnum(patch.status, STATUSES, 'invalid-status', '状态', todo.status)
568
591
  if (status !== todo.status) {
569
592
  todo.status = status
570
593
  todo.doneAt = status === 'done' ? now : null
594
+ // Reaching done through a patch carries the same review rule as the dedicated
595
+ // status path: an agent's "done" is a claim, a person's is a decision.
596
+ todo.review = status === 'done' && by === 'agent' ? { pending: true, at: now, by } : null
571
597
  changed.push('status')
572
598
  }
573
599
  }
@@ -626,6 +652,40 @@ export function setStatus(todo, status, options = {}) {
626
652
  return true
627
653
  }
628
654
 
655
+ /**
656
+ * An optional plan inside a todo.
657
+ *
658
+ * A step is text plus a tick, and the order is the plan. Strings are accepted for the
659
+ * common case (create, or an agent rewriting the plan) so a caller never has to spell out
660
+ * `{ done: false }` for every line.
661
+ *
662
+ * @param {unknown} value
663
+ * @returns {Array<{ text: string, done: boolean }>}
664
+ */
665
+ export function normalizeSteps(value) {
666
+ if (value === null || value === undefined) return []
667
+ if (!Array.isArray(value)) throw new TodoError('invalid-steps', '步骤必须是数组')
668
+ if (value.length > LIMITS.steps) {
669
+ throw new TodoError('invalid-steps', `步骤最多 ${LIMITS.steps} 条,收到 ${value.length} 条`)
670
+ }
671
+ const out = []
672
+ for (const item of value) {
673
+ if (typeof item === 'string') {
674
+ const text = item.trim().slice(0, LIMITS.step)
675
+ // A blank line in a textarea is a typo, not a step.
676
+ if (text !== '') out.push({ text, done: false })
677
+ continue
678
+ }
679
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) {
680
+ throw new TodoError('invalid-steps', '步骤必须是字符串或 { text, done }')
681
+ }
682
+ const text = typeof item.text === 'string' ? item.text.trim().slice(0, LIMITS.step) : ''
683
+ if (text === '') continue
684
+ out.push({ text, done: item.done === true })
685
+ }
686
+ return out
687
+ }
688
+
629
689
  /**
630
690
  * @param {object} todo
631
691
  * @param {unknown} link
package/lib/service.js CHANGED
@@ -555,6 +555,15 @@ export function createService(options) {
555
555
  if (todo.description !== '') {
556
556
  lines.push('', '描述:', todo.description)
557
557
  }
558
+ // The plan, if there is one: a numbered list is what makes a hand-off concrete, and
559
+ // the ticks say which parts are already behind us.
560
+ const steps = Array.isArray(todo.steps) ? todo.steps : []
561
+ if (steps.length > 0) {
562
+ lines.push('', `步骤(共 ${steps.length} 步):`)
563
+ steps.forEach((step, index) => {
564
+ lines.push(`${index + 1}. ${step.done === true ? '[已完成] ' : ''}${step.text}`)
565
+ })
566
+ }
558
567
  if (todo.links.length > 0) {
559
568
  lines.push('', '相关文档与地址:')
560
569
  for (const link of todo.links) {
package/lib/tools.js CHANGED
@@ -20,6 +20,34 @@ import { LIMITS, PRIORITIES, PRIORITY_LABELS, STATUSES } from './model.js'
20
20
 
21
21
  const text = (value) => [{ type: 'text', text: value }]
22
22
 
23
+ const STEPS_INPUT = {
24
+ type: 'array',
25
+ description:
26
+ '要做成的几步(可选,最多 ' + LIMITS.steps + ' 条)。每步一句话;已完成的写 { text, done: true }。'
27
+ + '派发时会把它们作为编号计划交给 Agent。',
28
+ items: {
29
+ oneOf: [
30
+ { type: 'string' },
31
+ {
32
+ type: 'object',
33
+ properties: { text: { type: 'string' }, done: { type: 'boolean' } },
34
+ required: ['text'],
35
+ additionalProperties: false,
36
+ },
37
+ ],
38
+ },
39
+ }
40
+
41
+ const STEPS_SHAPE = {
42
+ type: 'array',
43
+ items: {
44
+ type: 'object',
45
+ properties: { text: { type: 'string' }, done: { type: 'boolean' } },
46
+ required: ['text'],
47
+ additionalProperties: false,
48
+ },
49
+ }
50
+
23
51
  const LINKS_INPUT = {
24
52
  type: 'array',
25
53
  description:
@@ -92,6 +120,7 @@ const TODO_SHAPE = { type: 'object',
92
120
  priority: { type: 'string', enum: [...PRIORITIES] },
93
121
  dueAt: { oneOf: [{ type: 'string' }, { type: 'null' }] },
94
122
  tags: { type: 'array', items: { type: 'string' } },
123
+ steps: STEPS_SHAPE,
95
124
  links: {
96
125
  type: 'array',
97
126
  items: {
@@ -152,6 +181,7 @@ export function presentTodo(todo, now = Date.now()) {
152
181
  priority: todo.priority,
153
182
  dueAt: todo.dueAt,
154
183
  tags: [...todo.tags],
184
+ steps: (todo.steps ?? []).map((step) => ({ text: step.text, done: step.done === true })),
155
185
  links: todo.links.map((link) => ({ ...link })),
156
186
  createdAt: todo.createdAt,
157
187
  createdAtIso: new Date(todo.createdAt).toISOString(),
@@ -301,6 +331,7 @@ export function createTools(service, options = {}) {
301
331
  title: { type: 'string', description: `一句话说清要做什么(最多 ${LIMITS.title} 字)` },
302
332
  description: { type: 'string', description: '补充说明、验收标准、已知的坑' },
303
333
  links: LINKS_INPUT,
334
+ steps: STEPS_INPUT,
304
335
  tags: { type: 'array', items: { type: 'string' }, description: `标签,最多 ${LIMITS.tags} 个` },
305
336
  priority: { type: 'string', enum: [...PRIORITIES], description: 'P0 紧急 / P1 高 / P2 常规(缺省)/ P3 低' },
306
337
  dueAt: { type: 'string', description: 'ISO-8601 截止时间,例如 2026-10-20T10:00 或 2026-10-20' },
@@ -528,7 +559,7 @@ export function createTools(service, options = {}) {
528
559
  {
529
560
  name: 'global_todo_update',
530
561
  description:
531
- '改一条全局待办。只传要改的字段;`dueAt: ""` 清空截止时间,`tags: []` 清空标签,`links` 是整体替换(要单独加附件请用 global_todo_link),`status` 可以设为 archived(归档,不删除)。',
562
+ '改一条全局待办。只传要改的字段;`dueAt: ""` 清空截止时间,`tags: []` 清空标签,`links` / `steps` 是整体替换(要单独加附件请用 global_todo_link),`status` 可以设为 archived(归档,不删除)。',
532
563
  parameters: {
533
564
  type: 'object',
534
565
  properties: {
@@ -542,6 +573,7 @@ export function createTools(service, options = {}) {
542
573
  dueAt: { oneOf: [{ type: 'string' }, { type: 'null' }], description: '空串或 null 表示清空' },
543
574
  tags: { type: 'array', items: { type: 'string' } },
544
575
  links: LINKS_INPUT,
576
+ steps: STEPS_INPUT,
545
577
  },
546
578
  required: ['id'],
547
579
  additionalProperties: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-global-todo",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "icon": "./docs/icon.svg",
5
5
  "description": "Global, cross-session todos for DeepSeek Harness: one list owned by the human and the agent together, with creation time, attached documents and links, and one-click hand-off to an agent session. 全局待办:跨会话一份清单,记录加入时间,可挂文档与地址,可一键交给 Agent 继续干。",
6
6
  "type": "module",