dsh-global-todo 0.6.8 → 0.7.1
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 +29 -0
- package/README.md +1 -0
- package/README.zh.md +1 -0
- package/docs/DESIGN.md +45 -9
- package/lib/client.js +43 -5
- package/lib/index.js +1 -1
- package/lib/model.js +80 -1
- package/lib/routes.js +6 -0
- package/lib/service.js +29 -0
- package/lib/tools.js +3 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
|
+
## [0.7.1] — 2026-10-20
|
|
2
|
+
|
|
3
|
+
Documentation for the one piece of layout that cannot be "cleaned up".
|
|
4
|
+
|
|
5
|
+
- **`docs/DESIGN.md` now records why the wide-screen detail card is `position: fixed`**, with the
|
|
6
|
+
measurements from the failed attempts (an absolute card came out 348×3051 at 1440; container
|
|
7
|
+
queries make the panel the containing block for fixed descendants), the real cause (the panel
|
|
8
|
+
scrolls with the host page, not `.gt-groups`), and the rules for changing it safely.
|
|
9
|
+
- `/api/global-todo/review` added to both README route tables (it shipped in 0.7.0 without its
|
|
10
|
+
row).
|
|
11
|
+
|
|
12
|
+
## [0.7.0] — 2026-10-20
|
|
13
|
+
|
|
14
|
+
An agent's "done" becomes a claim a person confirms, instead of a fact.
|
|
15
|
+
|
|
16
|
+
- **Completing as an agent now leaves a review mark.** `todo.review = { pending, at, by }` is set
|
|
17
|
+
by the model whenever the actor is `agent` (both `setStatus … done` and the completion path the
|
|
18
|
+
tools use); a person finishing something sets none, and anything that leaves `done` clears it.
|
|
19
|
+
- **`POST /api/global-todo/review`** answers it: `confirm` keeps the todo finished and drops the
|
|
20
|
+
mark, `reject` puts it back on the list with the completion record removed — "not actually done"
|
|
21
|
+
is the only honest reading of a rejected claim. Answering twice is refused, not silently
|
|
22
|
+
accepted.
|
|
23
|
+
- **The panel shows it**: a `待确认` chip on the row, a `待确认` filter that is the done list
|
|
24
|
+
narrowed to pending marks, and `确认完成` / `退回清单` in the row menu. `counts.review` drives the
|
|
25
|
+
chip and the filter.
|
|
26
|
+
- **Agents see it too**: `presentTodo` carries `review`, so an agent can tell that its completion
|
|
27
|
+
is still waiting rather than assuming it landed.
|
|
28
|
+
- Two new model tests cover the rule (agent vs person, confirm vs reject, no double answers).
|
|
29
|
+
|
|
1
30
|
## [0.6.8] — 2026-10-20
|
|
2
31
|
|
|
3
32
|
Every conditional state now has a baseline image, and the harness refuses to render a blank
|
package/README.md
CHANGED
|
@@ -268,6 +268,7 @@ fence when that service is present, and by a strict loopback check otherwise.
|
|
|
268
268
|
| POST | `/api/global-todo/status` | `{ id, status, by?: human\|agent }` |
|
|
269
269
|
| POST | `/api/global-todo/remove` | `{ id }` |
|
|
270
270
|
| POST | `/api/global-todo/restore` | `{ todo }` — put a removed record back verbatim (undo) |
|
|
271
|
+
| POST | `/api/global-todo/review` | `{ id, decision: confirm\|reject }` — answer an agent's completion |
|
|
271
272
|
| POST | `/api/global-todo/link` | `{ id, add?: [...], remove?: [value \| index] }` |
|
|
272
273
|
| POST | `/api/global-todo/dispatch` | `{ id, mode?: auto\|bound\|new\|current, sessionId?, dir? }` |
|
|
273
274
|
| POST | `/api/global-todo/assign` | `{ ids, items?, target?, dir? }` — hand several todos over in one call; an id that matches nothing comes back instead of being sent elsewhere |
|
package/README.zh.md
CHANGED
|
@@ -164,6 +164,7 @@ dsh plugin --profile web remove dsh-global-todo
|
|
|
164
164
|
| POST | `/api/global-todo/status` | `{ id, status, by?: human\|agent }` |
|
|
165
165
|
| POST | `/api/global-todo/remove` | `{ id }` |
|
|
166
166
|
| POST | `/api/global-todo/restore` | `{ todo }` —— 原样还原被删除的记录(撤销) |
|
|
167
|
+
| POST | `/api/global-todo/review` | `{ id, decision: confirm\|reject }` —— 回答 Agent 的完成声明 |
|
|
167
168
|
| POST | `/api/global-todo/link` | `{ id, add?: [...], remove?: [值 或 下标] }` |
|
|
168
169
|
| POST | `/api/global-todo/dispatch` | `{ id, mode?: auto\|bound\|new\|current, sessionId?, dir? }` |
|
|
169
170
|
| POST | `/api/global-todo/assign` | `{ ids, items?, target?, dir? }`——一次把多条交给 Agent;配不上的 id 原样报回,不擅自送到别处 |
|
package/docs/DESIGN.md
CHANGED
|
@@ -60,15 +60,51 @@ $DSH_HOME/global-todo/todos.json ← { version: 1, todos: [ … ] }
|
|
|
60
60
|
## On a wide window, the detail stands beside the list
|
|
61
61
|
|
|
62
62
|
Expanding a row inline is right on a narrow window and wrong on a wide one: the list jumps, the
|
|
63
|
-
row you were reading slides away, and the rest of the screen sits empty.
|
|
64
|
-
row's detail is lifted out of the flow into a card
|
|
65
|
-
|
|
66
|
-
padding equal to
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
(`.gt-root[data-wide-detail]`
|
|
70
|
-
detail implementation — a second render path for wide screens would drift from the
|
|
71
|
-
the first field change.
|
|
63
|
+
row you were reading slides away, and the rest of the screen sits empty. When the panel itself is
|
|
64
|
+
at least 960px wide, the open row's detail is lifted out of the flow into a card at the panel's
|
|
65
|
+
right edge; only the list gives way (the header and toolbar keep full width), and the list gets
|
|
66
|
+
360px of right padding equal to the card. The breakpoint follows the **panel's** measured width,
|
|
67
|
+
not the viewport: the slot is ~265px narrower than the window, so a viewport query opened the
|
|
68
|
+
two-column layout while the panel still had no room for it. This is a CSS-only override over
|
|
69
|
+
unchanged markup (`.gt-root[data-wide]` / `[data-wide-detail]`) precisely so there is still
|
|
70
|
+
exactly one detail implementation — a second render path for wide screens would drift from the
|
|
71
|
+
inline one at the first field change.
|
|
72
|
+
|
|
73
|
+
### Why the card is `position: fixed` and not `sticky`
|
|
74
|
+
|
|
75
|
+
Three ways to do this were tried; only one survives the host's layout.
|
|
76
|
+
|
|
77
|
+
1. **`position: sticky`** — the card is a child of the row, so sticky is bounded by the row's
|
|
78
|
+
box: it never leaves the 72–94px tall item. Moving the card out of the row would mean a second
|
|
79
|
+
detail render path.
|
|
80
|
+
2. **`position: absolute` inside a positioned wrapper around the list** — measured on
|
|
81
|
+
2026-10-20: at 1440 the card came out **348 × 3051px**, at 1280 **348 × 3203px**. The card
|
|
82
|
+
stretched to the full height of the list's *content* and scrolled away with the page. Reason:
|
|
83
|
+
**the scrolling ancestor of this panel is the host page, not `.gt-groups`** — the panel is a
|
|
84
|
+
column in someone else's scroller, so `.gt-groups` never scrolls and an absolutely positioned
|
|
85
|
+
box is bracketed by the whole content column instead of the visible area.
|
|
86
|
+
3. **`container-type: inline-size` + container queries** — wanted, because the breakpoint would
|
|
87
|
+
then be CSS. But inline-size containment applies `contain: layout`, which makes the panel the
|
|
88
|
+
containing block for `position: fixed` descendants. The card (and the settings popover) would
|
|
89
|
+
silently switch from viewport-anchored to panel-anchored and land in the wrong place.
|
|
90
|
+
|
|
91
|
+
So: **`position: fixed` plus one measurement of the panel and list boxes**, taken when a row
|
|
92
|
+
opens and again on resize (and on a `ResizeObserver` of the column, because collapsing the host
|
|
93
|
+
sidebar changes the panel's width without a window resize). The measured values land in
|
|
94
|
+
`--gt-detail-top/right/bottom`; the CSS fallbacks assume the centred 1040px column, which is what
|
|
95
|
+
the static previews render.
|
|
96
|
+
|
|
97
|
+
### Changing this safely
|
|
98
|
+
|
|
99
|
+
- Keep **one** detail implementation: if a change needs different markup for wide screens, it is
|
|
100
|
+
the wrong change.
|
|
101
|
+
- Keep the breakpoint on the **panel** width (960px). A viewport media query was already shipped
|
|
102
|
+
once and put a 360px card over a 416px list at 1100.
|
|
103
|
+
- Never let the card cover the list: it is `.gt-groups` padding that reserves the room, and the
|
|
104
|
+
two numbers (360 padding / 348 card + 12 gap) have to move together.
|
|
105
|
+
- Verify in the real GUI, not the preview, at **1440 / 1280 / 1100**: the card must be ≈348px wide,
|
|
106
|
+
the list must end before it, and the open row must stay visible next to it. The preview cannot
|
|
107
|
+
catch this class of bug — it is server-rendered, so no measurement effect ever runs there.
|
|
72
108
|
## A shared file changes without asking
|
|
73
109
|
|
|
74
110
|
The list is deliberately portable — a notes repo, a synced folder, a NAS — so another machine
|
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 = 'ui45'
|
|
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
|
|
|
@@ -107,6 +107,12 @@ window.__ModuleLoader__.load({
|
|
|
107
107
|
'settings.saved': '设置已保存',
|
|
108
108
|
'settings.about': '关于',
|
|
109
109
|
'action.keys': '快捷键',
|
|
110
|
+
'review.confirm': '确认完成',
|
|
111
|
+
'review.reject': '退回清单',
|
|
112
|
+
'review.badge': '待确认',
|
|
113
|
+
'review.hint': 'Agent 标记完成,等你确认;确认后才算真的完成。',
|
|
114
|
+
'review.explain': '以下由 Agent 标记完成,确认后才算真的完成。',
|
|
115
|
+
'filter.review': '待确认',
|
|
110
116
|
'keys.search': '聚焦搜索',
|
|
111
117
|
'keys.new': '开始记一条',
|
|
112
118
|
'keys.save': '保存这条(在标题里)',
|
|
@@ -373,6 +379,12 @@ window.__ModuleLoader__.load({
|
|
|
373
379
|
'settings.saved': 'Settings saved',
|
|
374
380
|
'settings.about': 'About',
|
|
375
381
|
'action.keys': 'Keyboard shortcuts',
|
|
382
|
+
'review.confirm': 'Confirm done',
|
|
383
|
+
'review.reject': 'Send back',
|
|
384
|
+
'review.badge': 'Needs review',
|
|
385
|
+
'review.hint': 'The agent says this is done; confirm it or send it back.',
|
|
386
|
+
'review.explain': 'Finished by the agent. Confirm before it counts as done.',
|
|
387
|
+
'filter.review': 'Review',
|
|
376
388
|
'keys.search': 'Focus the search box',
|
|
377
389
|
'keys.new': 'Start a new todo',
|
|
378
390
|
'keys.save': 'Save it (from the title field)',
|
|
@@ -1210,6 +1222,8 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1210
1222
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1211
1223
|
.gt-truncated { color: var(--dsw-alias-state-warn-primary); font-size: 12px; }
|
|
1212
1224
|
.gt-bulk-tag { flex: 0 1 120px; min-width: 90px; }
|
|
1225
|
+
.gt-review { padding: 1px 6px; border-radius: 999px; font-size: 11px; font-weight: 600;
|
|
1226
|
+
color: var(--dsw-alias-state-warn-primary); border: 1px solid var(--dsw-alias-state-warn-primary); }
|
|
1213
1227
|
.gt-inbox { margin-left: 6px; padding: 1px 6px; border-radius: 999px; font-size: 11px; font-weight: 600;
|
|
1214
1228
|
color: var(--dsw-alias-state-warn-primary); border: 1px solid var(--dsw-alias-state-warn-primary);
|
|
1215
1229
|
vertical-align: 1px; }
|
|
@@ -1674,6 +1688,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1674
1688
|
if (todo.completion !== null && todo.completion !== undefined) {
|
|
1675
1689
|
meta.push(h('span', { key: 'completed', title: todo.completion.summary }, t('meta.completed', { when: absoluteTime(todo.completion.at) })))
|
|
1676
1690
|
}
|
|
1691
|
+
if (todo.review?.pending === true) {
|
|
1692
|
+
meta.push(h('span', { key: 'review', className: 'gt-review', title: t('review.hint') }, t('review.badge')))
|
|
1693
|
+
}
|
|
1677
1694
|
meta.push(h('span', { key: 'by' }, t(`meta.by.${todo.createdBy === 'agent' ? 'agent' : 'human'}`)))
|
|
1678
1695
|
if (todo.sessionId !== '') {
|
|
1679
1696
|
const stranded = todo.sessionAlive === true && (todo.sessionDir ?? '') === ''
|
|
@@ -1884,6 +1901,12 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1884
1901
|
h(Menu, {
|
|
1885
1902
|
label: t('action.menu'),
|
|
1886
1903
|
items: [
|
|
1904
|
+
...(todo.review?.pending === true
|
|
1905
|
+
? [
|
|
1906
|
+
{ key: 'confirmReview', label: t('review.confirm'), icon: 'check', onSelect: () => onAction('review-confirm', todo) },
|
|
1907
|
+
{ key: 'rejectReview', label: t('review.reject'), icon: 'refresh', danger: true, onSelect: () => onAction('review-reject', todo) },
|
|
1908
|
+
]
|
|
1909
|
+
: []),
|
|
1887
1910
|
...(todo.tags.some((tag) => tag.toLowerCase() === 'inbox')
|
|
1888
1911
|
? [
|
|
1889
1912
|
{ key: 'confirm', label: t('action.confirm'), icon: 'check', onSelect: () => onAction('confirm', todo) },
|
|
@@ -2488,8 +2511,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2488
2511
|
const reload = useCallback(
|
|
2489
2512
|
async (next = {}) => {
|
|
2490
2513
|
const selected = next.filter ?? filter
|
|
2491
|
-
//
|
|
2492
|
-
|
|
2514
|
+
// Two views sit on top of a status instead of being one: the inbox is a tag over
|
|
2515
|
+
// active records, and review is "done, but a person has not agreed yet".
|
|
2516
|
+
const status = selected === 'inbox' ? 'active' : selected === 'review' ? 'done' : selected
|
|
2493
2517
|
const search = next.query ?? query
|
|
2494
2518
|
try {
|
|
2495
2519
|
const data = await call('list', {
|
|
@@ -2502,7 +2526,12 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2502
2526
|
if (!mounted.current) return
|
|
2503
2527
|
setState((current) => ({
|
|
2504
2528
|
...current,
|
|
2505
|
-
|
|
2529
|
+
// The review view is the done list narrowed to records whose agent
|
|
2530
|
+
// completion nobody has answered yet.
|
|
2531
|
+
todos:
|
|
2532
|
+
selected === 'review'
|
|
2533
|
+
? data.todos.filter((todo) => todo.review?.pending === true)
|
|
2534
|
+
: data.todos,
|
|
2506
2535
|
counts: data.counts,
|
|
2507
2536
|
matched: Number.isInteger(data.matched) ? data.matched : data.todos.length,
|
|
2508
2537
|
truncated: data.truncated === true,
|
|
@@ -3144,7 +3173,15 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3144
3173
|
}
|
|
3145
3174
|
|
|
3146
3175
|
const onAction = (kind, todo, payload) => {
|
|
3147
|
-
if (kind === 'done') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'done' } }))
|
|
3176
|
+
if (kind === 'done') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'done', by: 'human' } }))
|
|
3177
|
+
// Answering an agent's completion. Confirming keeps it done; rejecting sends it back
|
|
3178
|
+
// to the list, because "not actually done" is the only honest reading.
|
|
3179
|
+
if (kind === 'review-confirm') {
|
|
3180
|
+
return run(todo.id, () => call('review', { method: 'POST', body: { id: todo.id, decision: 'confirm' } }))
|
|
3181
|
+
}
|
|
3182
|
+
if (kind === 'review-reject') {
|
|
3183
|
+
return run(todo.id, () => call('review', { method: 'POST', body: { id: todo.id, decision: 'reject' } }))
|
|
3184
|
+
}
|
|
3148
3185
|
if (kind === 'reopen') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'open' } }))
|
|
3149
3186
|
if (kind === 'doing') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'doing' } }))
|
|
3150
3187
|
// Confirming keeps the record on the list; discarding archives it. Both only remove
|
|
@@ -3766,6 +3803,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3766
3803
|
// day one is furniture, not a control.
|
|
3767
3804
|
...(counts.archived > 0 ? [['archived', counts.archived]] : []),
|
|
3768
3805
|
...(counts.inbox > 0 ? [['inbox', counts.inbox]] : []),
|
|
3806
|
+
...(counts.review > 0 ? [['review', counts.review]] : []),
|
|
3769
3807
|
].map(([key, count]) => filterChip(key, key === 'all' ? undefined : count)),
|
|
3770
3808
|
),
|
|
3771
3809
|
h('span', { className: 'gt-divider' }),
|
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.
|
|
39
|
+
export const VERSION = '0.7.1'
|
|
40
40
|
|
|
41
41
|
/** Plugin id. Also the Loader row id in `cordis.patch.yml`. */
|
|
42
42
|
export const name = 'dsh-global-todo'
|
package/lib/model.js
CHANGED
|
@@ -217,6 +217,9 @@ export function createTodo(input, options = {}) {
|
|
|
217
217
|
// beside the status rather than inside it, because "done" alone says
|
|
218
218
|
// nothing about what was done.
|
|
219
219
|
completion: null,
|
|
220
|
+
// Set when an agent finishes something: the person who owns the list confirms it or
|
|
221
|
+
// sends it back. An agent's "done" is a claim, not a decision.
|
|
222
|
+
review: null,
|
|
220
223
|
// An append-only trail of what happened while the todo was open. Unlike the
|
|
221
224
|
// description (the user's own text) this is where an agent reports progress or
|
|
222
225
|
// says it is blocked, so reporting never has to rewrite someone's words.
|
|
@@ -312,6 +315,7 @@ export function normalizeStored(raw, options = {}) {
|
|
|
312
315
|
originSessionId: typeof raw.originSessionId === 'string' ? raw.originSessionId : '',
|
|
313
316
|
lastDispatch: raw.lastDispatch !== null && typeof raw.lastDispatch === 'object' ? raw.lastDispatch : null,
|
|
314
317
|
completion: normalizeCompletion(raw.completion),
|
|
318
|
+
review: normalizeReview(raw.review),
|
|
315
319
|
notes: normalizeNotes(raw.notes),
|
|
316
320
|
activity: Array.isArray(raw.activity)
|
|
317
321
|
? raw.activity
|
|
@@ -328,6 +332,64 @@ export function normalizeStored(raw, options = {}) {
|
|
|
328
332
|
return todo
|
|
329
333
|
}
|
|
330
334
|
|
|
335
|
+
/**
|
|
336
|
+
* A stored review mark, repaired rather than rejected.
|
|
337
|
+
*
|
|
338
|
+
* Only one shape exists today — an agent finished this and the owner has not answered —
|
|
339
|
+
* so anything else is dropped instead of guessed at.
|
|
340
|
+
*
|
|
341
|
+
* @param {unknown} raw
|
|
342
|
+
* @returns {{ pending: true, at: number, by: string } | null}
|
|
343
|
+
*/
|
|
344
|
+
function normalizeReview(raw) {
|
|
345
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
|
|
346
|
+
if (raw.pending !== true) return null
|
|
347
|
+
return {
|
|
348
|
+
pending: true,
|
|
349
|
+
at: Number.isFinite(raw.at) ? Number(raw.at) : Date.now(),
|
|
350
|
+
by: normalizeActor(raw.by),
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Answer the question an agent left behind.
|
|
356
|
+
*
|
|
357
|
+
* Confirm keeps the todo finished and drops the mark; reject puts it back on the list,
|
|
358
|
+
* because "not actually done" is the only honest reading of a rejected claim.
|
|
359
|
+
*
|
|
360
|
+
* @param {object} todo
|
|
361
|
+
* @param {'confirm' | 'reject'} decision
|
|
362
|
+
* @param {{ now?: number, by?: 'human' | 'agent', reason?: string }} [options]
|
|
363
|
+
* @returns {boolean} whether the record changed
|
|
364
|
+
*/
|
|
365
|
+
export function reviewTodo(todo, decision, options = {}) {
|
|
366
|
+
const now = options.now ?? Date.now()
|
|
367
|
+
const by = normalizeActor(options.by ?? 'human')
|
|
368
|
+
const next = normalizeEnum(decision, ['confirm', 'reject'], 'invalid-decision', '决定', 'confirm')
|
|
369
|
+
if (todo.review === null || todo.review === undefined) return false
|
|
370
|
+
todo.review = null
|
|
371
|
+
todo.updatedAt = now
|
|
372
|
+
todo.updatedBy = by
|
|
373
|
+
const reason = typeof options.reason === 'string' ? options.reason.trim().slice(0, 120) : ''
|
|
374
|
+
if (next === 'reject') {
|
|
375
|
+
todo.status = 'open'
|
|
376
|
+
todo.doneAt = null
|
|
377
|
+
todo.completion = null
|
|
378
|
+
pushActivity(
|
|
379
|
+
todo,
|
|
380
|
+
{ kind: 'reopened', by, at: now, summary: reason === '' ? '确认未完成,退回清单' : reason },
|
|
381
|
+
options.activityLimit ?? LIMITS.activity,
|
|
382
|
+
)
|
|
383
|
+
return true
|
|
384
|
+
}
|
|
385
|
+
pushActivity(
|
|
386
|
+
todo,
|
|
387
|
+
{ kind: 'reviewed', by, at: now, summary: reason === '' ? '确认完成' : reason },
|
|
388
|
+
options.activityLimit ?? LIMITS.activity,
|
|
389
|
+
)
|
|
390
|
+
return true
|
|
391
|
+
}
|
|
392
|
+
|
|
331
393
|
/**
|
|
332
394
|
* A stored completion record, repaired rather than rejected.
|
|
333
395
|
*
|
|
@@ -428,6 +490,7 @@ export function completeTodo(todo, evidence = {}, options = {}) {
|
|
|
428
490
|
todo.doneAt = now
|
|
429
491
|
todo.updatedAt = now
|
|
430
492
|
todo.updatedBy = by
|
|
493
|
+
todo.review = by === 'agent' ? { pending: true, at: now, by } : null
|
|
431
494
|
todo.completion = {
|
|
432
495
|
at: now,
|
|
433
496
|
by,
|
|
@@ -546,6 +609,9 @@ export function setStatus(todo, status, options = {}) {
|
|
|
546
609
|
// A completion record describes a finished job, so reopening clears it: a
|
|
547
610
|
// record that still claims the work is done is worse than no record.
|
|
548
611
|
if (next !== 'done') todo.completion = null
|
|
612
|
+
// Who finished it decides whether a person still has to agree: an agent's "done" is a
|
|
613
|
+
// claim, the owner's is a decision. Leaving done drops the mark with the record.
|
|
614
|
+
todo.review = next === 'done' && by === 'agent' ? { pending: true, at: now, by } : null
|
|
549
615
|
todo.updatedAt = now
|
|
550
616
|
todo.updatedBy = by
|
|
551
617
|
const kind = next === 'done' ? 'done' : next === 'open' ? 'reopened' : 'status'
|
|
@@ -666,7 +732,19 @@ export function isOverdue(todo, now = Date.now()) {
|
|
|
666
732
|
* @returns {{ total: number, open: number, doing: number, done: number, cancelled: number, archived: number, overdue: number }}
|
|
667
733
|
*/
|
|
668
734
|
export function counts(todos, now = Date.now()) {
|
|
669
|
-
const out = {
|
|
735
|
+
const out = {
|
|
736
|
+
total: todos.length,
|
|
737
|
+
open: 0,
|
|
738
|
+
doing: 0,
|
|
739
|
+
done: 0,
|
|
740
|
+
cancelled: 0,
|
|
741
|
+
archived: 0,
|
|
742
|
+
overdue: 0,
|
|
743
|
+
inbox: 0,
|
|
744
|
+
// Finished by an agent and not yet confirmed by a person. Counted separately from
|
|
745
|
+
// done because "the agent says so" is not the same fact as "a person agreed".
|
|
746
|
+
review: 0,
|
|
747
|
+
}
|
|
670
748
|
for (const todo of todos) {
|
|
671
749
|
if (todo.status in out) out[todo.status] += 1
|
|
672
750
|
if (isOverdue(todo, now)) out.overdue += 1
|
|
@@ -675,6 +753,7 @@ export function counts(todos, now = Date.now()) {
|
|
|
675
753
|
if ((todo.status === 'open' || todo.status === 'doing') && (todo.tags ?? []).some((tag) => tag.toLowerCase() === 'inbox')) {
|
|
676
754
|
out.inbox += 1
|
|
677
755
|
}
|
|
756
|
+
if (todo.status === 'done' && todo.review?.pending === true) out.review += 1
|
|
678
757
|
}
|
|
679
758
|
return out
|
|
680
759
|
}
|
package/lib/routes.js
CHANGED
|
@@ -578,6 +578,12 @@ export function createRoutes(deps) {
|
|
|
578
578
|
return { todo: await service.setStatus(body.id, body.status ?? 'done', { by }) }
|
|
579
579
|
}),
|
|
580
580
|
|
|
581
|
+
// Answering the mark an agent's completion leaves behind: confirm, or send it back.
|
|
582
|
+
exact('POST', `${ROUTE_BASE}/review`, async (request) => {
|
|
583
|
+
const body = await readJsonBody(request)
|
|
584
|
+
return { todo: await service.review(body.id, body.decision ?? 'confirm', { by: 'human', reason: body.reason }) }
|
|
585
|
+
}),
|
|
586
|
+
|
|
581
587
|
exact('POST', `${ROUTE_BASE}/remove`, async (request) => {
|
|
582
588
|
const body = await readJsonBody(request)
|
|
583
589
|
return { todo: await service.remove(body.id, { by: 'human' }) }
|
package/lib/service.js
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
pushActivity,
|
|
32
32
|
recordDispatch as recordDispatchOn,
|
|
33
33
|
removeLink as removeLinkFrom,
|
|
34
|
+
reviewTodo,
|
|
34
35
|
setStatus as setStatusOn,
|
|
35
36
|
} from './model.js'
|
|
36
37
|
|
|
@@ -318,6 +319,33 @@ export function createService(options) {
|
|
|
318
319
|
})
|
|
319
320
|
}
|
|
320
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Answer the review mark an agent's completion left behind.
|
|
324
|
+
*
|
|
325
|
+
* Kept separate from {@link setStatus} because it is a different decision: the record is
|
|
326
|
+
* already done, and the owner is either agreeing or sending it back — not changing a
|
|
327
|
+
* status that nobody asked about. A record without a pending mark is refused, so the
|
|
328
|
+
* panel cannot confirm something twice.
|
|
329
|
+
*
|
|
330
|
+
* @param {string} id
|
|
331
|
+
* @param {'confirm' | 'reject'} decision
|
|
332
|
+
* @param {{ by?: 'human' | 'agent', reason?: string }} [context]
|
|
333
|
+
* @returns {Promise<object>} the todo after the decision
|
|
334
|
+
*/
|
|
335
|
+
async function review(id, decision, context = {}) {
|
|
336
|
+
return store.update((doc) => {
|
|
337
|
+
const todo = requireTodo(doc, id)
|
|
338
|
+
if (todo.review?.pending !== true) {
|
|
339
|
+
throw new TodoError('invalid-status', '这条待办没有等你确认的完成标记')
|
|
340
|
+
}
|
|
341
|
+
const changed = reviewTodo(todo, decision, {
|
|
342
|
+
...modelOptions(context.by ?? 'human'),
|
|
343
|
+
reason: context.reason,
|
|
344
|
+
})
|
|
345
|
+
return changed ? { next: doc.todos, result: todo } : { result: todo }
|
|
346
|
+
})
|
|
347
|
+
}
|
|
348
|
+
|
|
321
349
|
/**
|
|
322
350
|
* Put a wrongly-finished todo back on the list.
|
|
323
351
|
*
|
|
@@ -763,6 +791,7 @@ export function createService(options) {
|
|
|
763
791
|
create,
|
|
764
792
|
update,
|
|
765
793
|
setStatus,
|
|
794
|
+
review,
|
|
766
795
|
reopen,
|
|
767
796
|
complete,
|
|
768
797
|
note,
|
package/lib/tools.js
CHANGED
|
@@ -162,6 +162,9 @@ export function presentTodo(todo, now = Date.now()) {
|
|
|
162
162
|
originDir: todo.originDir ?? '',
|
|
163
163
|
originSessionId: todo.originSessionId ?? '',
|
|
164
164
|
notes: (todo.notes ?? []).map((entry) => ({ at: entry.at, by: entry.by, text: entry.text })),
|
|
165
|
+
// Present so an agent can see it left a claim waiting: a record an agent finished is
|
|
166
|
+
// not settled until a person confirms it, and saying so beats assuming it landed.
|
|
167
|
+
review: todo.review?.pending === true ? { pending: true, at: todo.review.at, by: todo.review.by } : null,
|
|
165
168
|
completion:
|
|
166
169
|
todo.completion === null || todo.completion === undefined
|
|
167
170
|
? null
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-global-todo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
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",
|