dsh-global-todo 0.7.2 → 0.9.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 +32 -0
- package/README.md +19 -7
- package/README.zh.md +15 -6
- package/docs/DESIGN.md +50 -0
- package/docs/ROADMAP.md +7 -9
- package/docs/screenshots/panel-detail.png +0 -0
- package/docs/screenshots/panel-inbox.png +0 -0
- package/docs/screenshots/panel-reminders.png +0 -0
- package/docs/screenshots/panel-review.png +0 -0
- package/docs/screenshots/panel-select.png +0 -0
- package/docs/screenshots/panel-settings.png +0 -0
- package/docs/screenshots/panel.png +0 -0
- package/docs/screenshots/tour.gif +0 -0
- package/lib/client.js +171 -3
- package/lib/completion.js +1 -1
- package/lib/index.js +24 -2
- package/lib/model.js +73 -0
- package/lib/routes.js +22 -0
- package/lib/service.js +12 -0
- package/lib/tools.js +45 -1
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,35 @@
|
|
|
1
|
+
## [0.9.0] — 2026-10-20
|
|
2
|
+
|
|
3
|
+
Competitive ground: the hand-off now has a return path, capture carries its source, and the
|
|
4
|
+
first screen says what the plugin is for.
|
|
5
|
+
|
|
6
|
+
- **Session read-back.** Every `assistant/message` updates a bounded in-memory tail (200 sessions,
|
|
7
|
+
400 chars); `GET /session?id=` returns it with `alive` / `dir`, and the detail shows 「会话在跑 /
|
|
8
|
+
已休眠」, the last thing the conversation said, and a button to open it. No disk read per row, no
|
|
9
|
+
new persistence.
|
|
10
|
+
- **Evidence on captured items.** `evidence` is the sentence an item came from, quoted verbatim
|
|
11
|
+
(≤300 chars), asked for by the capture prompt and shown under 「原话」 in the detail. A todo you
|
|
12
|
+
typed yourself simply has none.
|
|
13
|
+
- **A landing page instead of a manual.** Both READMEs open with three sentences, a 20-second
|
|
14
|
+
state tour (`docs/screenshots/tour.gif`, built reproducibly by `npm run tour`), and the install
|
|
15
|
+
command — the reference material moved below the fold.
|
|
16
|
+
|
|
17
|
+
## [0.8.0] — 2026-10-20
|
|
18
|
+
|
|
19
|
+
A todo can carry its plan, and the plan is what the agent receives.
|
|
20
|
+
|
|
21
|
+
- **`steps[]` on a todo**: `{ text, done }`, up to 20 steps of 200 characters. Strings are accepted
|
|
22
|
+
wherever steps are (create, agent tools), a blank line is not a step, and a longer plan is
|
|
23
|
+
refused rather than truncated — twenty steps means the todo should have been split.
|
|
24
|
+
- **The dispatch prompt numbers them**, marking the ones already done. Handing over "1. 对齐版本号
|
|
25
|
+
2. 跑 verify 3. 打 tag" is worth more than handing over a paragraph.
|
|
26
|
+
- **The panel shows progress without opening the row** (`步骤 2/4` in the metadata), renders the
|
|
27
|
+
ticks in the detail, and takes one step per line in the add form.
|
|
28
|
+
- **Agent guidance says so too**: multi-round work should be recorded with 3–6 steps, and a
|
|
29
|
+
finished step is one `global_todo_update`.
|
|
30
|
+
- Replacement semantics (like `links`): the array travels whole through `applyPatch`, so there is
|
|
31
|
+
still one validation and one write path.
|
|
32
|
+
|
|
1
33
|
## [0.7.2] — 2026-10-20
|
|
2
34
|
|
|
3
35
|
The roadmap audited against the tree, and the review feature brought up to the same standard
|
package/README.md
CHANGED
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
# dsh-global-todo
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**A cross-session todo list for DeepSeek Harness: one file you own, written by you and your
|
|
4
|
+
agent, and handed to a conversation in one click.**
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
documents and addresses, and can be handed to an agent session with one click — so
|
|
7
|
-
"continue this" is a button, not a paragraph of context you retype.
|
|
6
|
+
Three things it does that a session-local plan cannot:
|
|
8
7
|
|
|
9
|
-
|
|
8
|
+
1. **One JSON file is the whole list.** `cat`, diff, commit it, put it on a NAS, carry it to
|
|
9
|
+
another machine. Nothing lives inside a session log.
|
|
10
|
+
2. **Hand-off is a button, not a paragraph.** Four modes, the working directory checked, and
|
|
11
|
+
the todo's own **steps** arrive as a numbered plan.
|
|
12
|
+
3. **An agent saying "done" is a claim.** It lands in **待确认 / Needs review** until a person
|
|
13
|
+
confirms it — or sends it back to the list.
|
|
14
|
+
|
|
15
|
+

|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
dsh plugin add dsh-global-todo
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
[中文说明](README.zh.md) · [Every state has a baseline image](#screenshots) · [Design notes](docs/DESIGN.md)
|
|
10
22
|
|
|
11
23
|
---
|
|
12
24
|
|
|
@@ -203,7 +215,7 @@ Your list is not touched by install or uninstall: it lives outside the plugin, a
|
|
|
203
215
|
|
|
204
216
|
| Tool | Purpose |
|
|
205
217
|
|---|---|
|
|
206
|
-
| `global_todo_add` | Record an item — description, attachments, tags, priority, due time |
|
|
218
|
+
| `global_todo_add` | Record an item — description, attachments, steps, tags, priority, due time |
|
|
207
219
|
| `global_todo_note` | Append a progress note — where you are, what blocks you, who must decide |
|
|
208
220
|
| `global_todo_capture` | **Record a batch**: for 「抓一下待办」 or a turn that produced several follow-ups; each entry is validated on its own |
|
|
209
221
|
| `global_todo_list` | Query by state, tag or text; every row carries its creation time |
|
|
@@ -264,7 +276,7 @@ fence when that service is present, and by a strict loopback check otherwise.
|
|
|
264
276
|
| 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
277
|
| 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
278
|
| 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? }` |
|
|
279
|
+
| POST | `/api/global-todo/create` | `{ title, description?, links?, steps?, tags?, priority?, dueAt? }` |
|
|
268
280
|
| POST | `/api/global-todo/update` | `{ id, ...fields, by?: human\|agent }` |
|
|
269
281
|
| POST | `/api/global-todo/status` | `{ id, status, by?: human\|agent }` |
|
|
270
282
|
| POST | `/api/global-todo/remove` | `{ id }` |
|
package/README.zh.md
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
# dsh-global-todo · 全局待办
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**一份跨会话的待办清单:一个属于你的文件,你和 Agent 读写同一份,一键交给某个对话继续干。**
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
还能**一键交给 Agent 继续干**——「接着做这个」是一个按钮,而不是一段你每次都要重打的上下文。
|
|
5
|
+
三件会话级计划做不到的事:
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
1. **一个 JSON 文件就是全部**。能 `cat`、能 diff、能提交进仓库、能放 NAS、换机带走——不藏在任何会话日志里。
|
|
8
|
+
2. **交接是一个按钮,不是一段话**。四种模式、会先检查工作目录,待办自己的**步骤**会作为编号计划一起交出去。
|
|
9
|
+
3. **Agent 说「做完了」不算数**。它先进**待确认**,人点头才算完成,或者被退回清单。
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
dsh plugin add dsh-global-todo
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
[English](README.md) · [每个条件状态都有基线图](#截图) · [设计说明](docs/DESIGN.md)
|
|
9
18
|
|
|
10
19
|
---
|
|
11
20
|
|
|
@@ -128,7 +137,7 @@ dsh plugin --profile web remove dsh-global-todo
|
|
|
128
137
|
|
|
129
138
|
| 工具 | 用途 |
|
|
130
139
|
|---|---|
|
|
131
|
-
| `global_todo_add` |
|
|
140
|
+
| `global_todo_add` | 登记一条:描述、附件、步骤、标签、优先级、截止时间;同时记下「在哪个工作目录、哪个会话里写的」 |
|
|
132
141
|
| `global_todo_note` | 追加一条**进展记录**(做到哪了、卡在哪、需要谁做什么);不改写你的描述 |
|
|
133
142
|
| `global_todo_capture` | **一次登记多条**(批量抓取):用户说「抓一下待办」,或你一轮里发现好几件要跟进的;逐条校验,坏的那条只跳过它自己 |
|
|
134
143
|
| `global_todo_list` | 按状态/标签/关键词查询,每条都带上加入时间 |
|
|
@@ -160,7 +169,7 @@ dsh plugin --profile web remove dsh-global-todo
|
|
|
160
169
|
| POST | `/api/global-todo/bulk` | `{ ids, status?, addTags?, removeTags? }`——一次改一整批;失效的 id 进 `unmatched`,不会让整批失败 |
|
|
161
170
|
| POST | `/api/global-todo/ingest` | `{ items: [{ title, source: { kind }, confidence }], dryRun? }`,带 `Authorization: Bearer <settings.ingestToken>`——外部条目;置信度低于 `ingestConfirmBelow` 的先进收件箱 |
|
|
162
171
|
| GET | `/api/global-todo/health` | —(数据位置、统计、派发能力,以及**工具注册状态**与**自动收尾观察者是否挂上**——这两种失败是静默的,所以必须能查;还有 `metrics`:派发成功率、工具调用次数、清单增长;`autoCollect`:每个候选对话**为什么被问/没被问**、计次,以及设置文件是怎么加载进来的) |
|
|
163
|
-
| POST | `/api/global-todo/create` | `{ title, description?, links?, tags?, priority?, dueAt? }` |
|
|
172
|
+
| POST | `/api/global-todo/create` | `{ title, description?, links?, steps?, tags?, priority?, dueAt? }` |
|
|
164
173
|
| POST | `/api/global-todo/update` | `{ id, ...字段, by?: human\|agent }` |
|
|
165
174
|
| POST | `/api/global-todo/status` | `{ id, status, by?: human\|agent }` |
|
|
166
175
|
| POST | `/api/global-todo/remove` | `{ id }` |
|
package/docs/DESIGN.md
CHANGED
|
@@ -57,6 +57,56 @@ $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
|
+
## Reading the conversation back
|
|
61
|
+
|
|
62
|
+
Handing work over used to be one-way: the panel knew a conversation was bound, and nothing about
|
|
63
|
+
what came of it. The read-back closes that loop with the cheapest mechanism available.
|
|
64
|
+
|
|
65
|
+
- **Where the text comes from.** The plugin already listens on `session/event`; every
|
|
66
|
+
`assistant/message` also updates a bounded cache (200 sessions, 400 characters each). Nothing is
|
|
67
|
+
read from disk per row and nothing is persisted — a stale tail is better than a slow panel, and
|
|
68
|
+
the panel says when it is dormant.
|
|
69
|
+
- **Why not read the session log on demand.** Opening a row is the most common click in the panel.
|
|
70
|
+
A per-row disk read would make it wait on I/O, and a conversation that the Host had unloaded
|
|
71
|
+
answers nothing anyway.
|
|
72
|
+
- **The route is separate** (`GET /session?id=`): `/list` is polled, a tail is not, and folding
|
|
73
|
+
them together would read every session on every poll. A missing tail is a normal answer, not an
|
|
74
|
+
error.
|
|
75
|
+
- **What the person sees:** 会话在跑 / 会话已休眠, the last thing it said (three lines, full text in
|
|
76
|
+
the tooltip), and a button that opens that conversation.
|
|
77
|
+
|
|
78
|
+
## The sentence an item came from
|
|
79
|
+
|
|
80
|
+
A captured todo is somebody's summary of what a person said, which is exactly the step where
|
|
81
|
+
meaning drifts. So capture now carries `evidence`: the sentence, quoted verbatim, at most 300
|
|
82
|
+
characters, stored on the record and shown under 「原话」 in the detail (a quiet blockquote, not a
|
|
83
|
+
badge — it is reference material, not a status).
|
|
84
|
+
|
|
85
|
+
- The capture prompt asks for it in as many words: quote, do not paraphrase.
|
|
86
|
+
- A todo the person typed themselves has no evidence, and that is not an error — the field is
|
|
87
|
+
empty rather than absent, so the panel never has to guess.
|
|
88
|
+
- Too long is refused, like every other text field: evidence that is not the sentence is not
|
|
89
|
+
evidence.
|
|
90
|
+
|
|
91
|
+
## A plan inside the todo
|
|
92
|
+
|
|
93
|
+
A todo is one line, but the work behind it is often five steps. Rather than grow a second object
|
|
94
|
+
(a "project", a "plan file"), a todo may carry `steps[]` — text plus a tick, order is the plan.
|
|
95
|
+
|
|
96
|
+
- **Why inside the todo:** the dispatch prompt is the product here. Handing an agent "ship the
|
|
97
|
+
plugin" is a paragraph; handing it "1. 对齐版本号 2. 跑 verify 3. 打 tag 4. 补截图" is a plan it
|
|
98
|
+
can execute and report against. The steps are also what a person reviews afterwards.
|
|
99
|
+
- **Shape:** `{ text, done }`, capped at `LIMITS.steps` (20) and 200 characters per step. Strings
|
|
100
|
+
are accepted wherever an array of steps is (create, agent tools) so a caller never has to spell
|
|
101
|
+
out `{ done: false }`; a blank line is a typo, not a step, and a plan longer than the cap is
|
|
102
|
+
refused rather than truncated — twenty steps means the todo should have been split.
|
|
103
|
+
- **Replacement, not patching:** `steps` travels as a whole array through `applyPatch`, the same
|
|
104
|
+
rule as `links`. Toggling one tick is a client-side rewrite of the array, which keeps one write
|
|
105
|
+
path and one validation.
|
|
106
|
+
- **Where it shows:** the row's metadata gets `步骤 2/4` (progress without opening anything), the
|
|
107
|
+
detail renders the ticks, the panel's add form takes one step per line, and the dispatch prompt
|
|
108
|
+
numbers them with `[已完成]` on the ones already behind us.
|
|
109
|
+
|
|
60
110
|
## On a wide window, the detail stands beside the list
|
|
61
111
|
|
|
62
112
|
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 —
|
|
155
|
+
### 1. ~~Checklist inside a todo~~ — landed in 0.8.0
|
|
156
156
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
|
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 = '
|
|
53
|
+
const UI_REV = 'ui48'
|
|
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,10 +288,24 @@ 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': '完成记录',
|
|
294
301
|
'detail.notes': '进展记录',
|
|
302
|
+
'detail.readback': '会话回读',
|
|
303
|
+
'detail.evidence': '原话',
|
|
304
|
+
'readback.none': '这条还没有交给过会话。',
|
|
305
|
+
'readback.alive': '会话在跑',
|
|
306
|
+
'readback.dormant': '会话已休眠',
|
|
307
|
+
'readback.silent': '这个会话还没说过话。',
|
|
308
|
+
'readback.open': '打开会话',
|
|
295
309
|
'note.placeholder': '记一条进展:做到哪了、卡在哪、需要谁做什么',
|
|
296
310
|
'note.add': '记录进展',
|
|
297
311
|
'note.by.agent': 'Agent',
|
|
@@ -561,6 +575,13 @@ window.__ModuleLoader__.load({
|
|
|
561
575
|
'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
576
|
'meta.dispatchFailed': 'last hand-off failed',
|
|
563
577
|
'detail.description': 'Description',
|
|
578
|
+
'meta.steps': 'steps {done}/{total}',
|
|
579
|
+
'detail.steps': 'Steps',
|
|
580
|
+
'steps.none': 'No steps yet.',
|
|
581
|
+
'field.steps': 'First step… (one per line)',
|
|
582
|
+
'field.stepsHint': 'One per line; they ship to the agent as a numbered plan.',
|
|
583
|
+
'step.done': 'Mark this step done',
|
|
584
|
+
'step.undo': 'Clear this step',
|
|
564
585
|
'detail.noDescription': 'No description.',
|
|
565
586
|
'detail.links': 'Documents and links',
|
|
566
587
|
'detail.noLinks': 'No attachments. Attach an absolute document path, or a URL.',
|
|
@@ -585,6 +606,13 @@ window.__ModuleLoader__.load({
|
|
|
585
606
|
'group.conversation': 'Conversation',
|
|
586
607
|
'detail.completion': 'Completion',
|
|
587
608
|
'detail.notes': 'Progress notes',
|
|
609
|
+
'detail.readback': 'Session read-back',
|
|
610
|
+
'detail.evidence': 'In the author’s words',
|
|
611
|
+
'readback.none': 'This has never been handed to a conversation.',
|
|
612
|
+
'readback.alive': 'Conversation is running',
|
|
613
|
+
'readback.dormant': 'Conversation is dormant',
|
|
614
|
+
'readback.silent': 'This conversation has not said anything yet.',
|
|
615
|
+
'readback.open': 'Open conversation',
|
|
588
616
|
'note.placeholder': 'Add a note: where this stands, what is blocking it, who needs to act',
|
|
589
617
|
'note.add': 'Add note',
|
|
590
618
|
'note.by.agent': 'Agent',
|
|
@@ -1281,6 +1309,24 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1281
1309
|
.gt-links { display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 0; list-style: none; }
|
|
1282
1310
|
.gt-link { display: flex; gap: 8px; align-items: center; padding: 4px 0; min-width: 0; }
|
|
1283
1311
|
.gt-link-main { display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1; }
|
|
1312
|
+
.gt-evidence { margin: 0; padding: 2px 0 2px 10px; border-left: 2px solid var(--dsw-alias-border-l2);
|
|
1313
|
+
color: var(--dsw-alias-label-secondary); font-size: 13px; line-height: 20px;
|
|
1314
|
+
white-space: pre-wrap; word-break: break-word; }
|
|
1315
|
+
.gt-readback { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
|
|
1316
|
+
.gt-readback-meta { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); }
|
|
1317
|
+
.gt-readback-text { font-size: 13px; line-height: 20px; color: var(--dsw-alias-label-primary);
|
|
1318
|
+
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
|
|
1319
|
+
word-break: break-word; }
|
|
1320
|
+
.gt-textarea-steps { min-height: 76px; }
|
|
1321
|
+
.gt-steps { display: flex; flex-direction: column; gap: 2px; margin: 0; padding: 0; list-style: none; }
|
|
1322
|
+
.gt-step { display: flex; align-items: flex-start; gap: 8px; padding: 3px 0; }
|
|
1323
|
+
.gt-step-check { flex: 0 0 auto; width: 18px; height: 18px; margin-top: 2px; padding: 0; cursor: pointer;
|
|
1324
|
+
border-radius: 5px; border: 1.4px solid var(--dsw-alias-border-l2); background: transparent;
|
|
1325
|
+
color: transparent; display: inline-flex; align-items: center; justify-content: center; }
|
|
1326
|
+
.gt-step[data-done="1"] .gt-step-check { border-color: var(--dsw-alias-state-success-primary);
|
|
1327
|
+
background: var(--dsw-alias-state-success-primary); color: var(--dsw-alias-bg-base); }
|
|
1328
|
+
.gt-step-text { font-size: 13px; line-height: 20px; word-break: break-word; }
|
|
1329
|
+
.gt-step[data-done="1"] .gt-step-text { color: var(--dsw-alias-label-secondary); text-decoration: line-through; }
|
|
1284
1330
|
.gt-note-item { display: flex; flex-direction: column; gap: 4px; padding: 8px 0; }
|
|
1285
1331
|
.gt-note-item + .gt-note-item { border-top: 1px solid var(--dsw-alias-border-l1); }
|
|
1286
1332
|
.gt-note-meta { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--dsw-alias-label-secondary);
|
|
@@ -1612,6 +1658,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1612
1658
|
// viewport; the click looked like it did nothing. Bring the row into view
|
|
1613
1659
|
// when it opens, without yanking the page when it closes.
|
|
1614
1660
|
const rootRef = useRef(null)
|
|
1661
|
+
// What the bound conversation last said. Fetched once per open, because the row is
|
|
1662
|
+
// the only place the question "did it get anywhere" is asked.
|
|
1663
|
+
const [tail, setTail] = useState(null)
|
|
1615
1664
|
const [linkValue, setLinkValue] = useState('')
|
|
1616
1665
|
const [linkTitle, setLinkTitle] = useState('')
|
|
1617
1666
|
const [draft, setDraft] = useState({
|
|
@@ -1621,6 +1670,26 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1621
1670
|
priority: todo.priority,
|
|
1622
1671
|
})
|
|
1623
1672
|
|
|
1673
|
+
useEffect(() => {
|
|
1674
|
+
if (open !== true || todo.sessionId === '') {
|
|
1675
|
+
setTail(null)
|
|
1676
|
+
return undefined
|
|
1677
|
+
}
|
|
1678
|
+
let alive = true
|
|
1679
|
+
call('session', { query: { id: todo.sessionId } })
|
|
1680
|
+
.then((data) => {
|
|
1681
|
+
// A conversation this process has not seen speak answers null: that is not an
|
|
1682
|
+
// error, it is the normal case for an old hand-off.
|
|
1683
|
+
if (alive) setTail(data?.session ?? null)
|
|
1684
|
+
})
|
|
1685
|
+
.catch(() => {
|
|
1686
|
+
if (alive) setTail(null)
|
|
1687
|
+
})
|
|
1688
|
+
return () => {
|
|
1689
|
+
alive = false
|
|
1690
|
+
}
|
|
1691
|
+
}, [open, todo.sessionId])
|
|
1692
|
+
|
|
1624
1693
|
// Re-seed the edit draft whenever the record changes underneath us.
|
|
1625
1694
|
useEffect(() => {
|
|
1626
1695
|
setDraft({
|
|
@@ -1671,6 +1740,12 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1671
1740
|
)
|
|
1672
1741
|
}
|
|
1673
1742
|
meta.push(h('span', { key: 'added', title: new Date(todo.createdAt).toLocaleString() }, ageText(lang, todo.createdAt, now)))
|
|
1743
|
+
// Progress on the plan, when there is one: the row shows where it stands without
|
|
1744
|
+
// opening the detail.
|
|
1745
|
+
if (Array.isArray(todo.steps) && todo.steps.length > 0) {
|
|
1746
|
+
const done = todo.steps.filter((step) => step.done === true).length
|
|
1747
|
+
meta.push(h('span', { key: 'steps' }, t('meta.steps', { done, total: todo.steps.length })))
|
|
1748
|
+
}
|
|
1674
1749
|
if (todo.tags.length > 0) {
|
|
1675
1750
|
const shown = todo.tags.slice(0, 4)
|
|
1676
1751
|
meta.push(
|
|
@@ -2057,6 +2132,37 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2057
2132
|
h(
|
|
2058
2133
|
'div',
|
|
2059
2134
|
{ className: 'gt-section' },
|
|
2135
|
+
h('div', { className: 'gt-section-title' }, t('detail.readback')),
|
|
2136
|
+
tail === null
|
|
2137
|
+
? h('div', { className: 'gt-muted' }, t('readback.none'))
|
|
2138
|
+
: h(
|
|
2139
|
+
'div',
|
|
2140
|
+
{ className: 'gt-readback' },
|
|
2141
|
+
h('div', { className: 'gt-readback-meta' },
|
|
2142
|
+
tail.alive === true ? t('readback.alive') : t('readback.dormant'),
|
|
2143
|
+
tail.tail?.at === undefined ? null : h('span', null, ' · ' + absoluteTime(tail.tail.at)),
|
|
2144
|
+
),
|
|
2145
|
+
tail.tail === null || tail.tail === undefined
|
|
2146
|
+
? h('div', { className: 'gt-muted' }, t('readback.silent'))
|
|
2147
|
+
: h('div', { className: 'gt-readback-text', title: tail.tail.text }, tail.tail.text),
|
|
2148
|
+
typeof ctx?.uiWorkspace?.openSession === 'function'
|
|
2149
|
+
? h(
|
|
2150
|
+
'button',
|
|
2151
|
+
{
|
|
2152
|
+
type: 'button',
|
|
2153
|
+
className: 'gt-btn gt-btn-quiet',
|
|
2154
|
+
onClick: () => {
|
|
2155
|
+
try {
|
|
2156
|
+
ctx.uiWorkspace.openSession(todo.sessionId)
|
|
2157
|
+
} catch {
|
|
2158
|
+
/* opening a conversation is a nicety, never a failure */
|
|
2159
|
+
}
|
|
2160
|
+
},
|
|
2161
|
+
},
|
|
2162
|
+
t('readback.open'),
|
|
2163
|
+
)
|
|
2164
|
+
: null,
|
|
2165
|
+
),
|
|
2060
2166
|
h('div', { className: 'gt-section-title' }, t('detail.notes')),
|
|
2061
2167
|
(todo.notes ?? []).length === 0
|
|
2062
2168
|
? h('div', { className: 'gt-muted' }, t('note.none'))
|
|
@@ -2105,6 +2211,14 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2105
2211
|
),
|
|
2106
2212
|
),
|
|
2107
2213
|
),
|
|
2214
|
+
todo.evidence === '' || todo.evidence === undefined
|
|
2215
|
+
? null
|
|
2216
|
+
: h(
|
|
2217
|
+
'div',
|
|
2218
|
+
{ className: 'gt-section' },
|
|
2219
|
+
h('div', { className: 'gt-section-title' }, t('detail.evidence')),
|
|
2220
|
+
h('blockquote', { className: 'gt-evidence' }, todo.evidence),
|
|
2221
|
+
),
|
|
2108
2222
|
todo.completion === null || todo.completion === undefined
|
|
2109
2223
|
? null
|
|
2110
2224
|
: h(
|
|
@@ -2155,6 +2269,40 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2155
2269
|
),
|
|
2156
2270
|
),
|
|
2157
2271
|
),
|
|
2272
|
+
h(
|
|
2273
|
+
'div',
|
|
2274
|
+
{ className: 'gt-section' },
|
|
2275
|
+
h('div', { className: 'gt-section-title' }, t('detail.steps')),
|
|
2276
|
+
(todo.steps ?? []).length === 0
|
|
2277
|
+
? h('div', { className: 'gt-muted' }, t('steps.none'))
|
|
2278
|
+
: h(
|
|
2279
|
+
'ul',
|
|
2280
|
+
{ className: 'gt-steps' },
|
|
2281
|
+
todo.steps.map((step, index) =>
|
|
2282
|
+
h(
|
|
2283
|
+
'li',
|
|
2284
|
+
{ key: `${index}:${step.text}`, className: 'gt-step', 'data-done': step.done === true ? '1' : '0' },
|
|
2285
|
+
h(
|
|
2286
|
+
'button',
|
|
2287
|
+
{
|
|
2288
|
+
type: 'button',
|
|
2289
|
+
className: 'gt-step-check',
|
|
2290
|
+
'aria-pressed': step.done === true,
|
|
2291
|
+
title: step.done === true ? t('step.undo') : t('step.done'),
|
|
2292
|
+
'aria-label': step.done === true ? t('step.undo') : t('step.done'),
|
|
2293
|
+
disabled: busy,
|
|
2294
|
+
onClick: () =>
|
|
2295
|
+
onAction('steps', todo, {
|
|
2296
|
+
steps: todo.steps.map((item, i) => (i === index ? { text: item.text, done: item.done !== true } : { text: item.text, done: item.done === true })),
|
|
2297
|
+
}),
|
|
2298
|
+
},
|
|
2299
|
+
step.done === true ? h(Icon, { name: 'check', size: 11 }) : null,
|
|
2300
|
+
),
|
|
2301
|
+
h('span', { className: 'gt-step-text' }, step.text),
|
|
2302
|
+
),
|
|
2303
|
+
),
|
|
2304
|
+
),
|
|
2305
|
+
),
|
|
2158
2306
|
h(
|
|
2159
2307
|
'div',
|
|
2160
2308
|
{ className: 'gt-section' },
|
|
@@ -2349,7 +2497,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2349
2497
|
const [more, setMore] = useState(false)
|
|
2350
2498
|
const [collapsed, setCollapsed] = useState({})
|
|
2351
2499
|
const [undo, setUndo] = useState(null)
|
|
2352
|
-
const [draft, setDraft] = useState({ title: '', description: '', links: '', tags: '', priority: 'normal', due: '' })
|
|
2500
|
+
const [draft, setDraft] = useState({ title: '', description: '', links: '', steps: '', tags: '', priority: 'normal', due: '' })
|
|
2353
2501
|
// Conversations come from the shell's own sessions service, declared in this
|
|
2354
2502
|
// plugin's `inject` list the way the shipped panels do it
|
|
2355
2503
|
// (`ui-subagent` / `agent-team`: ["sessions", "uiWorkspace", "slots", "locale"]).
|
|
@@ -2750,6 +2898,11 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2750
2898
|
.split(',')
|
|
2751
2899
|
.map((part) => part.trim())
|
|
2752
2900
|
.filter((part) => part !== '')
|
|
2901
|
+
// One step per line: the shape a person already writes a plan in.
|
|
2902
|
+
const steps = (draft.steps ?? '')
|
|
2903
|
+
.split('\n')
|
|
2904
|
+
.map((line) => line.trim())
|
|
2905
|
+
.filter((line) => line !== '')
|
|
2753
2906
|
await run('__new__', () =>
|
|
2754
2907
|
call('create', {
|
|
2755
2908
|
method: 'POST',
|
|
@@ -2757,13 +2910,14 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2757
2910
|
title,
|
|
2758
2911
|
...(draft.description.trim() === '' ? {} : { description: draft.description.trim() }),
|
|
2759
2912
|
...(links.length === 0 ? {} : { links }),
|
|
2913
|
+
...(steps.length === 0 ? {} : { steps }),
|
|
2760
2914
|
...(tags.length === 0 ? {} : { tags }),
|
|
2761
2915
|
...(draft.priority === 'normal' ? {} : { priority: draft.priority }),
|
|
2762
2916
|
...(draft.due === '' ? {} : { dueAt: new Date(draft.due).toISOString() }),
|
|
2763
2917
|
},
|
|
2764
2918
|
}),
|
|
2765
2919
|
)
|
|
2766
|
-
setDraft({ title: '', description: '', links: '', tags: '', priority: 'normal', due: '' })
|
|
2920
|
+
setDraft({ title: '', description: '', links: '', steps: '', tags: '', priority: 'normal', due: '' })
|
|
2767
2921
|
setMore(false)
|
|
2768
2922
|
}
|
|
2769
2923
|
|
|
@@ -3197,6 +3351,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3197
3351
|
if (kind === 'archive') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'archived' } }))
|
|
3198
3352
|
if (kind === 'unarchive') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'open' } }))
|
|
3199
3353
|
if (kind === 'save') return run(todo.id, () => call('update', { method: 'POST', body: { id: todo.id, ...payload } }))
|
|
3354
|
+
if (kind === 'steps') return run(todo.id, () => call('update', { method: 'POST', body: { id: todo.id, steps: payload.steps } }))
|
|
3200
3355
|
if (kind === 'note') return run(todo.id, () => call('note', { method: 'POST', body: { id: todo.id, ...payload } }))
|
|
3201
3356
|
if (kind === 'link') return run(todo.id, () => call('link', { method: 'POST', body: { id: todo.id, ...payload } }))
|
|
3202
3357
|
if (kind === 'unlink') return run(todo.id, () => call('link', { method: 'POST', body: { id: todo.id, remove: [payload.index] } }))
|
|
@@ -3770,6 +3925,19 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3770
3925
|
}),
|
|
3771
3926
|
h('span', { className: 'gt-hint' }, t('field.linksHint')),
|
|
3772
3927
|
),
|
|
3928
|
+
h(
|
|
3929
|
+
'div',
|
|
3930
|
+
{ className: 'gt-field' },
|
|
3931
|
+
h('label', { className: 'gt-field-label', htmlFor: 'gt-new-steps' }, t('detail.steps')),
|
|
3932
|
+
h('textarea', {
|
|
3933
|
+
id: 'gt-new-steps',
|
|
3934
|
+
className: 'gt-textarea gt-textarea-steps',
|
|
3935
|
+
placeholder: t('field.steps'),
|
|
3936
|
+
value: draft.steps,
|
|
3937
|
+
onChange: (event) => setDraft({ ...draft, steps: event.target.value }),
|
|
3938
|
+
}),
|
|
3939
|
+
h('span', { className: 'gt-hint' }, t('field.stepsHint')),
|
|
3940
|
+
),
|
|
3773
3941
|
h(
|
|
3774
3942
|
'div',
|
|
3775
3943
|
{ className: 'gt-field-row' },
|
package/lib/completion.js
CHANGED
|
@@ -67,7 +67,7 @@ const VERDICT_TOOLS = Object.freeze([
|
|
|
67
67
|
* @param {unknown} message
|
|
68
68
|
* @returns {string}
|
|
69
69
|
*/
|
|
70
|
-
function messageText(message) {
|
|
70
|
+
export function messageText(message) {
|
|
71
71
|
const content = message?.content
|
|
72
72
|
if (!Array.isArray(content)) return ''
|
|
73
73
|
return content
|
package/lib/index.js
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
24
24
|
import path from 'node:path'
|
|
25
25
|
|
|
26
|
-
import { createCompletionWatcher } from './completion.js'
|
|
26
|
+
import { createCompletionWatcher, messageText } from './completion.js'
|
|
27
27
|
import { createDispatcher } from './dispatch.js'
|
|
28
28
|
import { createSettings, explainAutoCollect, idleSessions, retryDelayMs } from './settings.js'
|
|
29
29
|
import { createDiagnostics } from './diagnostics.js'
|
|
@@ -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.
|
|
39
|
+
export const VERSION = '0.9.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(那是用户自己的文字)。',
|
|
@@ -131,6 +132,21 @@ export function apply(ctx, config) {
|
|
|
131
132
|
// reached the wrong conversation once already.
|
|
132
133
|
const recentFile = path.join(path.dirname(file), 'recent-session.json')
|
|
133
134
|
const recentSession = { id: '', watching: typeof ctx?.on === 'function', file: recentFile }
|
|
135
|
+
// What a bound conversation last said, remembered while its events stream by.
|
|
136
|
+
// Read-back has to be cheap: the panel asks on every opened row, and going to the
|
|
137
|
+
// session log per row would make opening a todo wait on disk. Bounded, text-only,
|
|
138
|
+
// never persisted — a stale tail is better than no answer, and it costs no write.
|
|
139
|
+
const SESSION_TAIL_LIMIT = 200
|
|
140
|
+
const sessionTails = new Map()
|
|
141
|
+
const rememberTail = (sessionId, text, at) => {
|
|
142
|
+
if (sessionId === '' || text === '') return
|
|
143
|
+
if (sessionTails.size >= SESSION_TAIL_LIMIT && !sessionTails.has(sessionId)) {
|
|
144
|
+
// Oldest insertion first: a Map keeps insertion order, so one delete is enough.
|
|
145
|
+
const oldest = sessionTails.keys().next()
|
|
146
|
+
if (oldest.done !== true) sessionTails.delete(oldest.value)
|
|
147
|
+
}
|
|
148
|
+
sessionTails.set(sessionId, { text: text.slice(0, 400), at, length: text.length })
|
|
149
|
+
}
|
|
134
150
|
|
|
135
151
|
/** Best-effort: losing this file costs a guess, never a write. */
|
|
136
152
|
const rememberSession = (sessionId) => {
|
|
@@ -157,6 +173,11 @@ export function apply(ctx, config) {
|
|
|
157
173
|
try {
|
|
158
174
|
const session = first?.id !== undefined && first?.header !== undefined ? first : second
|
|
159
175
|
const event = typeof first?.type === 'string' ? first : typeof second?.type === 'string' ? second : undefined
|
|
176
|
+
// Every assistant message updates the read-back cache; only a person typing
|
|
177
|
+
// moves the recent-session pointer.
|
|
178
|
+
if (event !== undefined && event.type === 'assistant/message' && typeof session?.id === 'string') {
|
|
179
|
+
rememberTail(session.id, messageText(event.data?.message), Date.now())
|
|
180
|
+
}
|
|
160
181
|
if (event === undefined || event.type !== 'user/message') return
|
|
161
182
|
// `source.kind === 'user'` means a person typed it: model turns, tool
|
|
162
183
|
// results and plugin injections all carry a different kind.
|
|
@@ -519,6 +540,7 @@ export function apply(ctx, config) {
|
|
|
519
540
|
tools: toolState,
|
|
520
541
|
completion: watcherState,
|
|
521
542
|
target: recentSession,
|
|
543
|
+
sessionTail: (sessionId) => sessionTails.get(sessionId) ?? null,
|
|
522
544
|
settings,
|
|
523
545
|
autoCollect: autoCollectState,
|
|
524
546
|
reminders: reminderState,
|
package/lib/model.js
CHANGED
|
@@ -52,6 +52,13 @@ 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,
|
|
59
|
+
// The sentence the item came from. A captured todo without its source reads as an
|
|
60
|
+
// opinion later; with it, the person can check whether it was heard correctly.
|
|
61
|
+
evidence: 300,
|
|
55
62
|
})
|
|
56
63
|
|
|
57
64
|
/**
|
|
@@ -201,6 +208,11 @@ export function createTodo(input, options = {}) {
|
|
|
201
208
|
dueAt: normalizeDue(raw.dueAt ?? raw.due),
|
|
202
209
|
tags: normalizeTags(raw.tags),
|
|
203
210
|
links,
|
|
211
|
+
// An optional plan inside the todo: the steps a person or an agent broke it into.
|
|
212
|
+
// Shipped to the agent as a numbered list, so hand-off stops being one paragraph.
|
|
213
|
+
steps: normalizeSteps(raw.steps),
|
|
214
|
+
// Where it came from, in the author's own words: empty means "typed here".
|
|
215
|
+
evidence: normalizeText(raw.evidence, '原话', LIMITS.evidence, 'invalid-evidence'),
|
|
204
216
|
createdAt: now,
|
|
205
217
|
updatedAt: now,
|
|
206
218
|
createdBy: by,
|
|
@@ -305,6 +317,14 @@ export function normalizeStored(raw, options = {}) {
|
|
|
305
317
|
}
|
|
306
318
|
})(),
|
|
307
319
|
links,
|
|
320
|
+
evidence: typeof raw.evidence === 'string' ? raw.evidence.slice(0, LIMITS.evidence) : '',
|
|
321
|
+
steps: (() => {
|
|
322
|
+
try {
|
|
323
|
+
return normalizeSteps(raw.steps)
|
|
324
|
+
} catch {
|
|
325
|
+
return []
|
|
326
|
+
}
|
|
327
|
+
})(),
|
|
308
328
|
createdAt,
|
|
309
329
|
updatedAt: Number.isFinite(raw.updatedAt) ? Number(raw.updatedAt) : createdAt,
|
|
310
330
|
createdBy: normalizeActor(raw.createdBy),
|
|
@@ -563,11 +583,30 @@ export function applyPatch(todo, patch, options = {}) {
|
|
|
563
583
|
changed.push('tags')
|
|
564
584
|
}
|
|
565
585
|
}
|
|
586
|
+
if (patch.evidence !== undefined) {
|
|
587
|
+
const evidence = normalizeText(patch.evidence, '原话', LIMITS.evidence, 'invalid-evidence')
|
|
588
|
+
if (evidence !== todo.evidence) {
|
|
589
|
+
todo.evidence = evidence
|
|
590
|
+
changed.push('evidence')
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
if (patch.steps !== undefined) {
|
|
594
|
+
const steps = normalizeSteps(patch.steps)
|
|
595
|
+
const same =
|
|
596
|
+
steps.length === todo.steps.length && steps.every((step, index) => step.text === todo.steps[index].text && step.done === todo.steps[index].done)
|
|
597
|
+
if (!same) {
|
|
598
|
+
todo.steps = steps
|
|
599
|
+
changed.push('steps')
|
|
600
|
+
}
|
|
601
|
+
}
|
|
566
602
|
if (patch.status !== undefined) {
|
|
567
603
|
const status = normalizeEnum(patch.status, STATUSES, 'invalid-status', '状态', todo.status)
|
|
568
604
|
if (status !== todo.status) {
|
|
569
605
|
todo.status = status
|
|
570
606
|
todo.doneAt = status === 'done' ? now : null
|
|
607
|
+
// Reaching done through a patch carries the same review rule as the dedicated
|
|
608
|
+
// status path: an agent's "done" is a claim, a person's is a decision.
|
|
609
|
+
todo.review = status === 'done' && by === 'agent' ? { pending: true, at: now, by } : null
|
|
571
610
|
changed.push('status')
|
|
572
611
|
}
|
|
573
612
|
}
|
|
@@ -626,6 +665,40 @@ export function setStatus(todo, status, options = {}) {
|
|
|
626
665
|
return true
|
|
627
666
|
}
|
|
628
667
|
|
|
668
|
+
/**
|
|
669
|
+
* An optional plan inside a todo.
|
|
670
|
+
*
|
|
671
|
+
* A step is text plus a tick, and the order is the plan. Strings are accepted for the
|
|
672
|
+
* common case (create, or an agent rewriting the plan) so a caller never has to spell out
|
|
673
|
+
* `{ done: false }` for every line.
|
|
674
|
+
*
|
|
675
|
+
* @param {unknown} value
|
|
676
|
+
* @returns {Array<{ text: string, done: boolean }>}
|
|
677
|
+
*/
|
|
678
|
+
export function normalizeSteps(value) {
|
|
679
|
+
if (value === null || value === undefined) return []
|
|
680
|
+
if (!Array.isArray(value)) throw new TodoError('invalid-steps', '步骤必须是数组')
|
|
681
|
+
if (value.length > LIMITS.steps) {
|
|
682
|
+
throw new TodoError('invalid-steps', `步骤最多 ${LIMITS.steps} 条,收到 ${value.length} 条`)
|
|
683
|
+
}
|
|
684
|
+
const out = []
|
|
685
|
+
for (const item of value) {
|
|
686
|
+
if (typeof item === 'string') {
|
|
687
|
+
const text = item.trim().slice(0, LIMITS.step)
|
|
688
|
+
// A blank line in a textarea is a typo, not a step.
|
|
689
|
+
if (text !== '') out.push({ text, done: false })
|
|
690
|
+
continue
|
|
691
|
+
}
|
|
692
|
+
if (item === null || typeof item !== 'object' || Array.isArray(item)) {
|
|
693
|
+
throw new TodoError('invalid-steps', '步骤必须是字符串或 { text, done }')
|
|
694
|
+
}
|
|
695
|
+
const text = typeof item.text === 'string' ? item.text.trim().slice(0, LIMITS.step) : ''
|
|
696
|
+
if (text === '') continue
|
|
697
|
+
out.push({ text, done: item.done === true })
|
|
698
|
+
}
|
|
699
|
+
return out
|
|
700
|
+
}
|
|
701
|
+
|
|
629
702
|
/**
|
|
630
703
|
* @param {object} todo
|
|
631
704
|
* @param {unknown} link
|
package/lib/routes.js
CHANGED
|
@@ -334,6 +334,28 @@ export function createRoutes(deps) {
|
|
|
334
334
|
return { todo: service.annotateLinks(await service.get(id)) }
|
|
335
335
|
}),
|
|
336
336
|
|
|
337
|
+
/**
|
|
338
|
+
* What a bound conversation last said, plus whether it is still around.
|
|
339
|
+
*
|
|
340
|
+
* Deliberately a separate route: the list is polled and a hand-off's tail is not,
|
|
341
|
+
* so folding this into /list would read every session on every poll. A missing
|
|
342
|
+
* tail is the normal answer for a conversation this process has not seen speak.
|
|
343
|
+
*/
|
|
344
|
+
exact('GET', `${ROUTE_BASE}/session`, async (request) => {
|
|
345
|
+
const id = params(request).get('id') ?? ''
|
|
346
|
+
if (id === '') return { session: null }
|
|
347
|
+
const tailOf = typeof deps.sessionTail === 'function' ? deps.sessionTail : () => null
|
|
348
|
+
let alive = false
|
|
349
|
+
let dir = ''
|
|
350
|
+
try {
|
|
351
|
+
alive = dispatcher?.sessionAlive?.(id) === true
|
|
352
|
+
dir = dispatcher?.sessionDir?.(id) ?? ''
|
|
353
|
+
} catch {
|
|
354
|
+
/* a missing dispatcher is not an error here */
|
|
355
|
+
}
|
|
356
|
+
return { session: { id, alive, dir, tail: tailOf(id) } }
|
|
357
|
+
}),
|
|
358
|
+
|
|
337
359
|
/**
|
|
338
360
|
* The whole list as JSON.
|
|
339
361
|
*
|
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) {
|
|
@@ -596,6 +605,9 @@ export function createService(options) {
|
|
|
596
605
|
'该记的:等对方回复、待确认的决定、答应别人的事、还没收尾且需要另开一个会话继续的一步。',
|
|
597
606
|
'每条必要时补描述、挂上相关文档**绝对路径**或网址,并标级别(P0 紧急 / P1 高 / P2 常规 / P3 低)。',
|
|
598
607
|
'',
|
|
608
|
+
'**带上原话**(重要):每条用 `evidence` 逐字引用用户说的那一句(最多 200 字,不要改写、不要总结)。',
|
|
609
|
+
'以后看到这条待办的人,要能一眼核对「有没有听错」;没有原话的抓取,只是一条无从核对的转述。',
|
|
610
|
+
'',
|
|
599
611
|
'记完用**一句话**汇总(例如「记了 2 条:X 与 Y」),不要逐条复述。',
|
|
600
612
|
].join('\n')
|
|
601
613
|
}
|
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:
|
|
@@ -42,9 +70,17 @@ const LINKS_INPUT = {
|
|
|
42
70
|
}
|
|
43
71
|
|
|
44
72
|
/** One todo's worth of input, shared by `add` and `capture`. */
|
|
73
|
+
const EVIDENCE_INPUT = {
|
|
74
|
+
type: 'string',
|
|
75
|
+
description:
|
|
76
|
+
`原话(可选,最多 ${LIMITS.evidence} 字):用户说的那**一句**,逐字引用,用来让人核对有没有听错。`
|
|
77
|
+
+ '抓取时几乎总该带;自己顺手记一条时可以不写。',
|
|
78
|
+
}
|
|
79
|
+
|
|
45
80
|
const ITEM_PROPERTIES = {
|
|
46
81
|
title: { type: 'string', description: `一句话说清要做什么(最多 ${LIMITS.title} 字)` },
|
|
47
82
|
description: { type: 'string', description: '补充说明、验收标准、已知的坑' },
|
|
83
|
+
evidence: EVIDENCE_INPUT,
|
|
48
84
|
links: LINKS_INPUT,
|
|
49
85
|
tags: { type: 'array', items: { type: 'string' }, description: `标签,最多 ${LIMITS.tags} 个` },
|
|
50
86
|
priority: { type: 'string', enum: [...PRIORITIES], description: 'P0 紧急 / P1 高 / P2 常规(缺省)/ P3 低' },
|
|
@@ -92,6 +128,8 @@ const TODO_SHAPE = { type: 'object',
|
|
|
92
128
|
priority: { type: 'string', enum: [...PRIORITIES] },
|
|
93
129
|
dueAt: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
94
130
|
tags: { type: 'array', items: { type: 'string' } },
|
|
131
|
+
evidence: { type: 'string', description: '原话(空串表示是自己记的,不是抓的)' },
|
|
132
|
+
steps: STEPS_SHAPE,
|
|
95
133
|
links: {
|
|
96
134
|
type: 'array',
|
|
97
135
|
items: {
|
|
@@ -152,6 +190,8 @@ export function presentTodo(todo, now = Date.now()) {
|
|
|
152
190
|
priority: todo.priority,
|
|
153
191
|
dueAt: todo.dueAt,
|
|
154
192
|
tags: [...todo.tags],
|
|
193
|
+
evidence: todo.evidence ?? '',
|
|
194
|
+
steps: (todo.steps ?? []).map((step) => ({ text: step.text, done: step.done === true })),
|
|
155
195
|
links: todo.links.map((link) => ({ ...link })),
|
|
156
196
|
createdAt: todo.createdAt,
|
|
157
197
|
createdAtIso: new Date(todo.createdAt).toISOString(),
|
|
@@ -301,6 +341,8 @@ export function createTools(service, options = {}) {
|
|
|
301
341
|
title: { type: 'string', description: `一句话说清要做什么(最多 ${LIMITS.title} 字)` },
|
|
302
342
|
description: { type: 'string', description: '补充说明、验收标准、已知的坑' },
|
|
303
343
|
links: LINKS_INPUT,
|
|
344
|
+
steps: STEPS_INPUT,
|
|
345
|
+
evidence: EVIDENCE_INPUT,
|
|
304
346
|
tags: { type: 'array', items: { type: 'string' }, description: `标签,最多 ${LIMITS.tags} 个` },
|
|
305
347
|
priority: { type: 'string', enum: [...PRIORITIES], description: 'P0 紧急 / P1 高 / P2 常规(缺省)/ P3 低' },
|
|
306
348
|
dueAt: { type: 'string', description: 'ISO-8601 截止时间,例如 2026-10-20T10:00 或 2026-10-20' },
|
|
@@ -528,7 +570,7 @@ export function createTools(service, options = {}) {
|
|
|
528
570
|
{
|
|
529
571
|
name: 'global_todo_update',
|
|
530
572
|
description:
|
|
531
|
-
'改一条全局待办。只传要改的字段;`dueAt: ""` 清空截止时间,`tags: []` 清空标签,`links` 是整体替换(要单独加附件请用 global_todo_link),`status` 可以设为 archived(归档,不删除)。',
|
|
573
|
+
'改一条全局待办。只传要改的字段;`dueAt: ""` 清空截止时间,`tags: []` 清空标签,`links` / `steps` 是整体替换(要单独加附件请用 global_todo_link),`status` 可以设为 archived(归档,不删除)。',
|
|
532
574
|
parameters: {
|
|
533
575
|
type: 'object',
|
|
534
576
|
properties: {
|
|
@@ -542,6 +584,8 @@ export function createTools(service, options = {}) {
|
|
|
542
584
|
dueAt: { oneOf: [{ type: 'string' }, { type: 'null' }], description: '空串或 null 表示清空' },
|
|
543
585
|
tags: { type: 'array', items: { type: 'string' } },
|
|
544
586
|
links: LINKS_INPUT,
|
|
587
|
+
steps: STEPS_INPUT,
|
|
588
|
+
evidence: EVIDENCE_INPUT,
|
|
545
589
|
},
|
|
546
590
|
required: ['id'],
|
|
547
591
|
additionalProperties: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-global-todo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
"verify": "npm run check && npm run check:render && npm test",
|
|
59
59
|
"preview": "node tools/preview.mjs",
|
|
60
60
|
"screenshots": "npm run preview && node tools/screenshot.mjs",
|
|
61
|
+
"tour": "node tools/tour.mjs",
|
|
61
62
|
"prepack": "npm run verify",
|
|
62
63
|
"check:render": "node tools/preview.mjs --check",
|
|
63
64
|
"check:live": "node tools/live-check.mjs",
|