@dennisrongo/dsh-todo 0.3.0 → 0.4.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/README.md +91 -26
- package/lib/bin.js +55 -14
- package/lib/cli.js +55 -14
- package/lib/client.js +117 -68
- package/lib/index.js +9 -3
- package/lib/launch.js +109 -0
- package/lib/typert.host.js +1 -0
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# @dennisrongo/dsh-todo
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@dennisrongo/dsh-todo)
|
|
4
|
+
|
|
5
|
+
**npm:** [`@dennisrongo/dsh-todo`](https://www.npmjs.com/package/@dennisrongo/dsh-todo) ·
|
|
6
|
+
**source:** [dennisrongo/dsh-plugins](https://github.com/dennisrongo/dsh-plugins/tree/main/plugins/dsh-todo)
|
|
7
|
+
|
|
3
8
|
A todo list for the [DeepSeek Harness](https://github.com/deepseek-ai) (dsh) web UI.
|
|
4
9
|
|
|
5
10
|
Registers into the additive `conversation.view` slot — the conversation view
|
|
@@ -26,15 +31,23 @@ next to the code it describes.
|
|
|
26
31
|
standup actually asks about: what is moving, and what is stuck.
|
|
27
32
|
- **Priority** — `P0`–`P3`, shown as a chip. Only P0/P1 are coloured, so the
|
|
28
33
|
list flags what is urgent instead of turning into a rainbow.
|
|
29
|
-
- **Release and sprint** — two independent labels: what ships
|
|
30
|
-
(`
|
|
34
|
+
- **Release and sprint** — two independent numeric labels: what ships
|
|
35
|
+
together (`1.5`, `0.5.1`) and when it is worked (`24`). A task can be in
|
|
36
|
+
both. A **release** takes up to three numbers, so a patch release has a label
|
|
37
|
+
of its own; a **sprint** is a single decimal. Never alpha (`v1.5` is
|
|
38
|
+
refused), so labels sort by version — `1.10` above `1.9`, `0.5.1` above
|
|
39
|
+
`0.5`. The inputs accept only digits and a dot as you type; a value that is
|
|
40
|
+
still not a valid label is flagged with an inline error and never saved.
|
|
31
41
|
- **Group by** — None · Status · Release · Sprint · Priority, with collapsible
|
|
32
42
|
section headers carrying their own `done/total` and progress bar. Grouping by
|
|
33
43
|
status gives you a kanban board without drag-and-drop.
|
|
34
44
|
- **Task detail modal** — click a task's title to open the full dialog: a
|
|
35
45
|
roomy description box plus status, priority, release, sprint and due date.
|
|
36
|
-
|
|
37
|
-
|
|
46
|
+
Focus is trapped inside and returned to the row you came from, and text edits
|
|
47
|
+
commit on close so a stray click never loses them. **Done is the save**: it is
|
|
48
|
+
the only control that refuses to proceed while a release or sprint is invalid.
|
|
49
|
+
Esc, the backdrop and the X always let you out, discarding an unsaved bad
|
|
50
|
+
label rather than trapping you in the dialog.
|
|
38
51
|
- **Expandable rows** — the chevron still gives a quick in-row peek without
|
|
39
52
|
leaving the list. Double-click a title to rename it inline. One scannable line
|
|
40
53
|
collapsed is what lets a task carry nine fields without becoming a wall of text.
|
|
@@ -50,7 +63,7 @@ next to the code it describes.
|
|
|
50
63
|
restored (↩) or permanently deleted from the Archive view.
|
|
51
64
|
- **Full editing** — add, check off, click-to-edit the title, edit the
|
|
52
65
|
description and labels, reorder (▲/▼), archive, and delete. Release and sprint
|
|
53
|
-
inputs suggest labels already in use, so
|
|
66
|
+
inputs suggest labels already in use, so values converge on a shared
|
|
54
67
|
vocabulary without a releases table to administer.
|
|
55
68
|
- **Themed** — colors come only from the shell's `--dsw-*` tokens, so it follows
|
|
56
69
|
light/dark automatically. Respects `prefers-reduced-motion`.
|
|
@@ -96,12 +109,27 @@ dsh profiles set `autoInstallPeers: false`, so this resolves correctly.
|
|
|
96
109
|
pnpm install
|
|
97
110
|
pnpm run build # node build/build.mjs — emits lib/index.js + lib/client.js
|
|
98
111
|
pnpm run typecheck # tsc --noEmit
|
|
99
|
-
pnpm test
|
|
100
|
-
pnpm run test:
|
|
101
|
-
pnpm run test:
|
|
112
|
+
pnpm test # offline: client + CLI, exercising the BUILT lib/ output
|
|
113
|
+
pnpm run test:cli # just the in-process CLI suite
|
|
114
|
+
pnpm run test:integration # spawns the real bin — one full agent workflow
|
|
115
|
+
pnpm run test:icons # headless Chrome: icon sizes and the 40px row budget
|
|
116
|
+
pnpm run test:modal # headless Chrome: the dialog escapes the list's scroll container
|
|
117
|
+
pnpm run test:agent # OPT-IN: a real model drives the CLI (needs a key; costs tokens)
|
|
102
118
|
```
|
|
103
119
|
|
|
104
|
-
`pnpm test` asserts against `lib/`, so **build before testing
|
|
120
|
+
`pnpm test` asserts against `lib/`, so **build before testing** (the script rebuilds first).
|
|
121
|
+
|
|
122
|
+
The CLI has two layers on purpose. `test/cli.test.mjs` calls into `lib/cli.js` in-process;
|
|
123
|
+
`test/cli-integration.mjs` **spawns the real binary** for one realistic agent workflow — plan a
|
|
124
|
+
release, inspect it, hit a refusal, recover from the payload alone, finish and archive — because
|
|
125
|
+
the in-process suite cannot see the shebang'd entry point, `process.exitCode`, the stdout/stderr
|
|
126
|
+
split, or argv as a shell delivers it.
|
|
127
|
+
|
|
128
|
+
`pnpm run test:agent` is the odd one out and is **not** part of `pnpm test`: it hands a real
|
|
129
|
+
model nothing but the binary path and a goal, then asserts on the resulting database rather than
|
|
130
|
+
on anything the model said. It answers a question no offline test can — is `help` enough to
|
|
131
|
+
drive this thing, and is a refusal message enough to recover from? It needs `DEEPSEEK_API_KEY`
|
|
132
|
+
exported (it runs the harness in a throwaway `DSH_HOME`, which has no stored credentials).
|
|
105
133
|
|
|
106
134
|
## Install into a dsh profile
|
|
107
135
|
|
|
@@ -176,14 +204,14 @@ experimental-feature warning on stderr; it is harmless, and `2>/dev/null` silenc
|
|
|
176
204
|
```bash
|
|
177
205
|
cd ~/projects/my-app # the workspace IS the current directory
|
|
178
206
|
|
|
179
|
-
dsh-todo add "Fix token refresh" --priority p0 --release
|
|
180
|
-
dsh-todo add "Write migration guide" --sprint
|
|
207
|
+
dsh-todo add "Fix token refresh" --priority p0 --release 1.5 --due 2026-03-14
|
|
208
|
+
dsh-todo add "Write migration guide" --sprint 24
|
|
181
209
|
dsh-todo list
|
|
182
210
|
```
|
|
183
211
|
|
|
184
212
|
```text
|
|
185
|
-
[ ] tmtcfbutukp4j todo p0 Fix token refresh (release=
|
|
186
|
-
[ ] tmtcfbuxr5y9x todo p2 Write migration guide (sprint=
|
|
213
|
+
[ ] tmtcfbutukp4j todo p0 Fix token refresh (release=1.5 due=2026-03-14)
|
|
214
|
+
[ ] tmtcfbuxr5y9x todo p2 Write migration guide (sprint=24)
|
|
187
215
|
[ ] tmtcfbv071e6w in-progress p2 Ship it
|
|
188
216
|
```
|
|
189
217
|
|
|
@@ -193,7 +221,7 @@ That is the whole display contract — it stays greppable and diffable.
|
|
|
193
221
|
Move work along, then file it away:
|
|
194
222
|
|
|
195
223
|
```bash
|
|
196
|
-
dsh-todo update tmtcfbut --status in-progress --sprint
|
|
224
|
+
dsh-todo update tmtcfbut --status in-progress --sprint 24
|
|
197
225
|
dsh-todo show tmtcfbut # everything about one task
|
|
198
226
|
dsh-todo done tmtcfbut
|
|
199
227
|
dsh-todo archive # archive EVERY completed task
|
|
@@ -226,15 +254,15 @@ dsh-todo --workspace ~/projects/other list --open
|
|
|
226
254
|
| `--json` | all | Machine-readable output, **including on errors** |
|
|
227
255
|
| `--status <s>` | add, update, list | `backlog` · `todo` · `in-progress` · `blocked` · `done` |
|
|
228
256
|
| `--priority <p>` | add, update, list | `p0`–`p3` (default `p2`) |
|
|
229
|
-
| `--release <
|
|
230
|
-
| `--sprint <
|
|
257
|
+
| `--release <n[.n[.n]]>` | add, update, list | Numbers only, up to three: `1`, `1.5`, `0.5.1`; `v1.5` is refused |
|
|
258
|
+
| `--sprint <n[.n]>` | add, update, list | Numbers only, one dot at most: `24`, `1.5`; `0.5.1` is refused |
|
|
231
259
|
| `--due <YYYY-MM-DD>` | add, update | A calendar day; impossible dates are refused |
|
|
232
260
|
| `--description <text>` | add, update | Body text — acceptance criteria, repro steps |
|
|
233
261
|
| `--title <text>` | update | Rename |
|
|
234
262
|
| `--open` | list | Everything unfinished, whatever stage |
|
|
235
263
|
| `--archived` | list | Show archived tasks *instead of* active ones |
|
|
236
264
|
|
|
237
|
-
Filters combine, so `list --open --priority p0 --release
|
|
265
|
+
Filters combine, so `list --open --priority p0 --release 1.5` is an AND across all three.
|
|
238
266
|
`--key value` and `--key=value` are both accepted.
|
|
239
267
|
|
|
240
268
|
### Driving it from a script or an agent
|
|
@@ -248,6 +276,7 @@ dsh-todo list --open --json
|
|
|
248
276
|
|
|
249
277
|
```json
|
|
250
278
|
{
|
|
279
|
+
"ok": true,
|
|
251
280
|
"count": 3,
|
|
252
281
|
"items": [
|
|
253
282
|
{
|
|
@@ -255,7 +284,7 @@ dsh-todo list --open --json
|
|
|
255
284
|
"title": "Fix token refresh",
|
|
256
285
|
"status": "todo",
|
|
257
286
|
"priority": "p0",
|
|
258
|
-
"release": "
|
|
287
|
+
"release": "1.5",
|
|
259
288
|
"dueDate": "2026-03-14",
|
|
260
289
|
"createdAt": 1787889639378
|
|
261
290
|
}
|
|
@@ -263,19 +292,41 @@ dsh-todo list --open --json
|
|
|
263
292
|
}
|
|
264
293
|
```
|
|
265
294
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
295
|
+
**Every payload leads with `ok`**, so a caller never has to infer the verdict from the
|
|
296
|
+
shape of what came back. On success `ok` is `true` and the command's own keys follow:
|
|
297
|
+
`list` adds `{ count, items }`; `add` / `update` / `done` / `reopen` / `rm` add
|
|
298
|
+
`{ item, revision }` — the stored task, so a write can be confirmed without a second
|
|
299
|
+
call; `archive` adds `{ archived, revision }`; `show` adds the task itself.
|
|
300
|
+
|
|
301
|
+
A failure returns `ok: false` with `error` and `code`, and exits with that code:
|
|
269
302
|
|
|
270
303
|
```bash
|
|
271
304
|
$ dsh-todo update nope --status done --json; echo "exit=$?"
|
|
272
305
|
{
|
|
306
|
+
"ok": false,
|
|
273
307
|
"error": "no task matching \"nope\"",
|
|
274
308
|
"code": 3
|
|
275
309
|
}
|
|
276
310
|
exit=3
|
|
277
311
|
```
|
|
278
312
|
|
|
313
|
+
A **refused value** says which field it refused, what that field accepts, and what it was
|
|
314
|
+
given — enough for an agent to correct itself without parsing the sentence. Nothing is
|
|
315
|
+
written when a value is refused:
|
|
316
|
+
|
|
317
|
+
```bash
|
|
318
|
+
$ dsh-todo add "Ship it" --release v1.5 --json; echo "exit=$?"
|
|
319
|
+
{
|
|
320
|
+
"ok": false,
|
|
321
|
+
"error": "--release must be a version number like 1.5 or 0.5.1 (up to three numbers) (got \"v1.5\") — nothing was saved",
|
|
322
|
+
"code": 2,
|
|
323
|
+
"field": "release",
|
|
324
|
+
"expected": "a version number like 1.5 or 0.5.1 (up to three numbers)",
|
|
325
|
+
"got": "v1.5"
|
|
326
|
+
}
|
|
327
|
+
exit=2
|
|
328
|
+
```
|
|
329
|
+
|
|
279
330
|
Exit codes are distinct so a script can branch on **why** a command failed:
|
|
280
331
|
|
|
281
332
|
| Code | Means |
|
|
@@ -333,15 +384,29 @@ list. That is the designed reconciliation, not lost data.
|
|
|
333
384
|
| `description` | The body: acceptance criteria, repro steps, links. Its own 5000 cap, because reusing the title's 500 would silently truncate real notes. |
|
|
334
385
|
| `status` | `backlog \| todo \| in-progress \| blocked \| done`. **The source of truth** — there is no separate `done` flag to fall out of sync. |
|
|
335
386
|
| `priority` | `p0`–`p3`, default `p2` so an unranked task sits mid-pile rather than jumping the queue. |
|
|
336
|
-
| `release` | What ships together, e.g. `
|
|
337
|
-
| `sprint` | When it is worked, e.g. `
|
|
387
|
+
| `release` | What ships together, e.g. `1.5` or `0.5.1`. One to three numbers. |
|
|
388
|
+
| `sprint` | When it is worked, e.g. `24`. A single decimal — no patch segment. |
|
|
338
389
|
| `dueDate` | `YYYY-MM-DD`. A calendar day, not an instant — an epoch would bind it to a timezone and let one task read as two different days. Impossible dates like `2025-02-31` are rejected rather than rolled forward. |
|
|
339
390
|
|
|
340
|
-
**Release and sprint are separate on purpose.** A task can be worked in
|
|
341
|
-
and ship in
|
|
342
|
-
either question. Both are
|
|
391
|
+
**Release and sprint are separate on purpose.** A task can be worked in sprint 24
|
|
392
|
+
and ship in 1.3; collapsing them into one field loses the ability to answer
|
|
393
|
+
either question. Both are **numeric labels** rather than entities, refused at
|
|
394
|
+
every write path — CLI and UI alike — if they are anything else, so grouping and
|
|
343
395
|
filtering work with no releases table, no CRUD, and no migration to rename one.
|
|
344
396
|
|
|
397
|
+
The two shapes differ, because the fields mean different things:
|
|
398
|
+
|
|
399
|
+
| Field | Pattern | Passes | Refused |
|
|
400
|
+
| --- | --- | --- | --- |
|
|
401
|
+
| `release` | `^\d+(\.\d+){0,2}$` | `1`, `1.5`, `0.5.1` | `v1.5`, `1.2.3.4`, `1.` |
|
|
402
|
+
| `sprint` | `^\d+(\.\d+)?$` | `1`, `1.5`, `24` | `Sprint 24`, `0.5.1` |
|
|
403
|
+
|
|
404
|
+
A release carries a **patch segment** so a fix shipping on top of 0.5 gets its own
|
|
405
|
+
label; a sprint is a point on a calendar, not a shipped artefact, so it takes none.
|
|
406
|
+
Being numeric means labels sort by **version semantics** — compared segment by
|
|
407
|
+
segment, so `1.10` ranks above `1.9` and `0.5.1` sits between `0.5` and `0.6`.
|
|
408
|
+
Labels stored before the rule still load, group, and display unchanged.
|
|
409
|
+
|
|
345
410
|
`completedAt` is written only by the status transition, so it can never claim a
|
|
346
411
|
task is finished that isn't. Absent optional fields are absent *keys*, never
|
|
347
412
|
`''`, so "no release" has exactly one representation.
|
package/lib/bin.js
CHANGED
|
@@ -24,6 +24,14 @@ function normalizeLabel(raw) {
|
|
|
24
24
|
const text = raw.replace(/\s+/g, " ").trim().slice(0, MAX_LABEL);
|
|
25
25
|
return text.length > 0 ? text : void 0;
|
|
26
26
|
}
|
|
27
|
+
var RELEASE_LABEL_RE = /^\d+(\.\d+){0,2}$/;
|
|
28
|
+
var SPRINT_LABEL_RE = /^\d+(\.\d+)?$/;
|
|
29
|
+
function normalizeVersionLabel(raw, field) {
|
|
30
|
+
const label = normalizeLabel(raw);
|
|
31
|
+
if (label === void 0) return void 0;
|
|
32
|
+
const pattern = field === "release" ? RELEASE_LABEL_RE : SPRINT_LABEL_RE;
|
|
33
|
+
return pattern.test(label) ? label : void 0;
|
|
34
|
+
}
|
|
27
35
|
var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
28
36
|
function normalizeDueDate(raw) {
|
|
29
37
|
if (typeof raw !== "string") return void 0;
|
|
@@ -78,6 +86,7 @@ function migrateSchema(db) {
|
|
|
78
86
|
add("release", "release TEXT");
|
|
79
87
|
add("sprint", "sprint TEXT");
|
|
80
88
|
add("due_date", "due_date TEXT");
|
|
89
|
+
add("session_id", "session_id TEXT");
|
|
81
90
|
if (addedTitle && columns.has("text")) {
|
|
82
91
|
db.exec("UPDATE todo SET title = text WHERE title IS NULL");
|
|
83
92
|
}
|
|
@@ -93,7 +102,7 @@ function readList(db) {
|
|
|
93
102
|
const updatedAt = Number(db.prepare("SELECT value FROM meta WHERE key = 'updatedAt'").get()?.value ?? 0);
|
|
94
103
|
const rows = db.prepare(
|
|
95
104
|
`SELECT id, title, description, status, priority, release, sprint, due_date,
|
|
96
|
-
created_at, completed_at, archived_at
|
|
105
|
+
session_id, created_at, completed_at, archived_at
|
|
97
106
|
FROM todo ORDER BY position ASC`
|
|
98
107
|
).all();
|
|
99
108
|
const text = (v) => v === null || v === void 0 ? void 0 : String(v);
|
|
@@ -108,6 +117,7 @@ function readList(db) {
|
|
|
108
117
|
...normalizeLabel(row.release) !== void 0 ? { release: normalizeLabel(row.release) } : {},
|
|
109
118
|
...normalizeLabel(row.sprint) !== void 0 ? { sprint: normalizeLabel(row.sprint) } : {},
|
|
110
119
|
...normalizeDueDate(row.due_date) !== void 0 ? { dueDate: normalizeDueDate(row.due_date) } : {},
|
|
120
|
+
...text(row.session_id) !== void 0 ? { sessionId: text(row.session_id) } : {},
|
|
111
121
|
createdAt: Number(row.created_at),
|
|
112
122
|
...row.completed_at !== null && row.completed_at !== void 0 ? { completedAt: Number(row.completed_at) } : {},
|
|
113
123
|
...row.archived_at !== null && row.archived_at !== void 0 ? { archivedAt: Number(row.archived_at) } : {}
|
|
@@ -121,8 +131,8 @@ function writeList(db, items, revision, updatedAt = Date.now()) {
|
|
|
121
131
|
db.prepare("DELETE FROM todo").run();
|
|
122
132
|
const insert = db.prepare(
|
|
123
133
|
`INSERT INTO todo (id, title, description, status, priority, release, sprint, due_date,
|
|
124
|
-
text, done, created_at, completed_at, archived_at, position)
|
|
125
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
134
|
+
session_id, text, done, created_at, completed_at, archived_at, position)
|
|
135
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
126
136
|
);
|
|
127
137
|
items.forEach((item, index) => {
|
|
128
138
|
insert.run(
|
|
@@ -134,6 +144,7 @@ function writeList(db, items, revision, updatedAt = Date.now()) {
|
|
|
134
144
|
item.release ?? null,
|
|
135
145
|
item.sprint ?? null,
|
|
136
146
|
item.dueDate ?? null,
|
|
147
|
+
item.sessionId ?? null,
|
|
137
148
|
item.title,
|
|
138
149
|
item.status === "done" ? 1 : 0,
|
|
139
150
|
item.createdAt,
|
|
@@ -191,10 +202,13 @@ var CliError = class extends Error {
|
|
|
191
202
|
/**
|
|
192
203
|
* @param message - human-readable reason.
|
|
193
204
|
* @param code - process exit code, from {@link EXIT}.
|
|
205
|
+
* @param details - extra machine-readable fields merged into the `--json`
|
|
206
|
+
* error payload, so an agent can correct itself without parsing the sentence.
|
|
194
207
|
*/
|
|
195
|
-
constructor(message, code = EXIT.usage) {
|
|
208
|
+
constructor(message, code = EXIT.usage, details = {}) {
|
|
196
209
|
super(message);
|
|
197
210
|
this.code = code;
|
|
211
|
+
this.details = details;
|
|
198
212
|
this.name = "CliError";
|
|
199
213
|
}
|
|
200
214
|
};
|
|
@@ -212,6 +226,16 @@ function oneOf(options, key, allowed) {
|
|
|
212
226
|
}
|
|
213
227
|
return raw;
|
|
214
228
|
}
|
|
229
|
+
function assertLabel(field, raw) {
|
|
230
|
+
if (raw === void 0 || raw === "") return;
|
|
231
|
+
if (normalizeVersionLabel(raw, field) !== void 0) return;
|
|
232
|
+
const shape = field === "release" ? "a version number like 1.5 or 0.5.1 (up to three numbers)" : "a decimal number like 1.5 (one dot at most)";
|
|
233
|
+
throw new CliError(
|
|
234
|
+
`--${field} must be ${shape} (got "${raw}") \u2014 nothing was saved`,
|
|
235
|
+
EXIT.usage,
|
|
236
|
+
{ field, expected: shape, got: raw }
|
|
237
|
+
);
|
|
238
|
+
}
|
|
215
239
|
function resolveWorkspace(options, cwd) {
|
|
216
240
|
return resolve2(str(options, "workspace") ?? cwd);
|
|
217
241
|
}
|
|
@@ -274,6 +298,7 @@ function formatItem(item) {
|
|
|
274
298
|
if (item.release) meta.push(`release=${item.release}`);
|
|
275
299
|
if (item.sprint) meta.push(`sprint=${item.sprint}`);
|
|
276
300
|
if (item.dueDate) meta.push(`due=${item.dueDate}`);
|
|
301
|
+
if (item.sessionId) meta.push(`session=${item.sessionId}`);
|
|
277
302
|
if (isArchived(item)) meta.push("archived");
|
|
278
303
|
return bits.join(" ") + (meta.length ? ` (${meta.join(" ")})` : "");
|
|
279
304
|
}
|
|
@@ -299,9 +324,11 @@ Options
|
|
|
299
324
|
|
|
300
325
|
--status <s> ${STATUSES.join("|")}
|
|
301
326
|
--priority <p> ${PRIORITIES.join("|")}
|
|
302
|
-
--release <
|
|
303
|
-
--sprint <
|
|
327
|
+
--release <n[.n[.n]]> e.g. 1.5 or 0.5.1 (empty string clears)
|
|
328
|
+
--sprint <n[.n]> e.g. 24 (empty string clears)
|
|
304
329
|
--due <YYYY-MM-DD> Calendar day (empty string clears)
|
|
330
|
+
--session <id> Harness session working the task (update only;
|
|
331
|
+
empty string clears)
|
|
305
332
|
--description <text> Body text (empty string clears)
|
|
306
333
|
--title <text> Rename (update only)
|
|
307
334
|
|
|
@@ -311,8 +338,8 @@ Ids may be given as any unambiguous prefix.
|
|
|
311
338
|
|
|
312
339
|
Examples
|
|
313
340
|
dsh-todo list --open --json
|
|
314
|
-
dsh-todo add "Fix token refresh" --priority p0 --release
|
|
315
|
-
dsh-todo update t1a2 --status in-progress --sprint
|
|
341
|
+
dsh-todo add "Fix token refresh" --priority p0 --release 1.5 --due 2026-03-14
|
|
342
|
+
dsh-todo update t1a2 --status in-progress --sprint 24
|
|
316
343
|
dsh-todo done t1a2
|
|
317
344
|
`;
|
|
318
345
|
function run(parsed, cwd, now = Date.now, rand = Math.random) {
|
|
@@ -349,6 +376,7 @@ function run(parsed, cwd, now = Date.now, rand = Math.random) {
|
|
|
349
376
|
`release ${item.release ?? "-"}`,
|
|
350
377
|
`sprint ${item.sprint ?? "-"}`,
|
|
351
378
|
`due ${item.dueDate ?? "-"}`,
|
|
379
|
+
`session ${item.sessionId ?? "-"}`,
|
|
352
380
|
`created ${new Date(item.createdAt).toISOString()}`,
|
|
353
381
|
...item.completedAt ? [`completed ${new Date(item.completedAt).toISOString()}`] : [],
|
|
354
382
|
...item.archivedAt ? [`archived ${new Date(item.archivedAt).toISOString()}`] : [],
|
|
@@ -360,8 +388,12 @@ function run(parsed, cwd, now = Date.now, rand = Math.random) {
|
|
|
360
388
|
const title = positional.join(" ").trim();
|
|
361
389
|
if (!title) throw new CliError("add needs a title");
|
|
362
390
|
const description = str(options, "description");
|
|
363
|
-
const
|
|
364
|
-
const
|
|
391
|
+
const releaseRaw = str(options, "release");
|
|
392
|
+
const sprintRaw = str(options, "sprint");
|
|
393
|
+
assertLabel("release", releaseRaw);
|
|
394
|
+
assertLabel("sprint", sprintRaw);
|
|
395
|
+
const release = normalizeVersionLabel(releaseRaw, "release");
|
|
396
|
+
const sprint = normalizeVersionLabel(sprintRaw, "sprint");
|
|
365
397
|
const dueRaw = str(options, "due");
|
|
366
398
|
if (dueRaw !== void 0 && dueRaw !== "" && normalizeDueDate(dueRaw) === void 0) {
|
|
367
399
|
throw new CliError(`--due must be a real calendar date as YYYY-MM-DD (got "${dueRaw}")`);
|
|
@@ -390,10 +422,13 @@ function run(parsed, cwd, now = Date.now, rand = Math.random) {
|
|
|
390
422
|
const release = str(options, "release");
|
|
391
423
|
const sprint = str(options, "sprint");
|
|
392
424
|
const due = str(options, "due");
|
|
425
|
+
const session = str(options, "session");
|
|
393
426
|
if (due !== void 0 && due !== "" && normalizeDueDate(due) === void 0) {
|
|
394
427
|
throw new CliError(`--due must be a real calendar date as YYYY-MM-DD (got "${due}")`);
|
|
395
428
|
}
|
|
396
|
-
|
|
429
|
+
assertLabel("release", release);
|
|
430
|
+
assertLabel("sprint", sprint);
|
|
431
|
+
if (status === void 0 && priority === void 0 && title === void 0 && description === void 0 && release === void 0 && sprint === void 0 && due === void 0 && session === void 0) {
|
|
397
432
|
throw new CliError("update needs at least one field to change");
|
|
398
433
|
}
|
|
399
434
|
let updated;
|
|
@@ -415,7 +450,7 @@ function run(parsed, cwd, now = Date.now, rand = Math.random) {
|
|
|
415
450
|
}
|
|
416
451
|
for (const [key, raw] of [["release", release], ["sprint", sprint]]) {
|
|
417
452
|
if (raw === void 0) continue;
|
|
418
|
-
const label =
|
|
453
|
+
const label = normalizeVersionLabel(raw, key);
|
|
419
454
|
if (label !== void 0) next[key] = label;
|
|
420
455
|
else delete next[key];
|
|
421
456
|
}
|
|
@@ -424,6 +459,10 @@ function run(parsed, cwd, now = Date.now, rand = Math.random) {
|
|
|
424
459
|
if (value !== void 0) next.dueDate = value;
|
|
425
460
|
else delete next.dueDate;
|
|
426
461
|
}
|
|
462
|
+
if (session !== void 0) {
|
|
463
|
+
if (session) next.sessionId = session.slice(0, MAX_LABEL);
|
|
464
|
+
else delete next.sessionId;
|
|
465
|
+
}
|
|
427
466
|
updated = next;
|
|
428
467
|
return next;
|
|
429
468
|
});
|
|
@@ -489,12 +528,14 @@ function main(argv, cwd = process.cwd()) {
|
|
|
489
528
|
const wantsJson = parsed.options.json === true;
|
|
490
529
|
try {
|
|
491
530
|
const outcome = run(parsed, cwd);
|
|
492
|
-
|
|
531
|
+
const json = outcome.json !== null && typeof outcome.json === "object" && !Array.isArray(outcome.json) ? { ok: true, ...outcome.json } : { ok: true, result: outcome.json };
|
|
532
|
+
console.log(wantsJson ? JSON.stringify(json, null, 2) : outcome.text);
|
|
493
533
|
return EXIT.ok;
|
|
494
534
|
} catch (error) {
|
|
495
535
|
const message = error instanceof Error ? error.message : String(error);
|
|
496
536
|
const code = error instanceof CliError ? error.code : 1;
|
|
497
|
-
|
|
537
|
+
const details = error instanceof CliError ? error.details : {};
|
|
538
|
+
if (wantsJson) console.log(JSON.stringify({ ok: false, error: message, code, ...details }, null, 2));
|
|
498
539
|
else console.error(`dsh-todo: ${message}`);
|
|
499
540
|
return code;
|
|
500
541
|
}
|
package/lib/cli.js
CHANGED
|
@@ -22,6 +22,14 @@ function normalizeLabel(raw) {
|
|
|
22
22
|
const text = raw.replace(/\s+/g, " ").trim().slice(0, MAX_LABEL);
|
|
23
23
|
return text.length > 0 ? text : void 0;
|
|
24
24
|
}
|
|
25
|
+
var RELEASE_LABEL_RE = /^\d+(\.\d+){0,2}$/;
|
|
26
|
+
var SPRINT_LABEL_RE = /^\d+(\.\d+)?$/;
|
|
27
|
+
function normalizeVersionLabel(raw, field) {
|
|
28
|
+
const label = normalizeLabel(raw);
|
|
29
|
+
if (label === void 0) return void 0;
|
|
30
|
+
const pattern = field === "release" ? RELEASE_LABEL_RE : SPRINT_LABEL_RE;
|
|
31
|
+
return pattern.test(label) ? label : void 0;
|
|
32
|
+
}
|
|
25
33
|
var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
26
34
|
function normalizeDueDate(raw) {
|
|
27
35
|
if (typeof raw !== "string") return void 0;
|
|
@@ -76,6 +84,7 @@ function migrateSchema(db) {
|
|
|
76
84
|
add("release", "release TEXT");
|
|
77
85
|
add("sprint", "sprint TEXT");
|
|
78
86
|
add("due_date", "due_date TEXT");
|
|
87
|
+
add("session_id", "session_id TEXT");
|
|
79
88
|
if (addedTitle && columns.has("text")) {
|
|
80
89
|
db.exec("UPDATE todo SET title = text WHERE title IS NULL");
|
|
81
90
|
}
|
|
@@ -91,7 +100,7 @@ function readList(db) {
|
|
|
91
100
|
const updatedAt = Number(db.prepare("SELECT value FROM meta WHERE key = 'updatedAt'").get()?.value ?? 0);
|
|
92
101
|
const rows = db.prepare(
|
|
93
102
|
`SELECT id, title, description, status, priority, release, sprint, due_date,
|
|
94
|
-
created_at, completed_at, archived_at
|
|
103
|
+
session_id, created_at, completed_at, archived_at
|
|
95
104
|
FROM todo ORDER BY position ASC`
|
|
96
105
|
).all();
|
|
97
106
|
const text = (v) => v === null || v === void 0 ? void 0 : String(v);
|
|
@@ -106,6 +115,7 @@ function readList(db) {
|
|
|
106
115
|
...normalizeLabel(row.release) !== void 0 ? { release: normalizeLabel(row.release) } : {},
|
|
107
116
|
...normalizeLabel(row.sprint) !== void 0 ? { sprint: normalizeLabel(row.sprint) } : {},
|
|
108
117
|
...normalizeDueDate(row.due_date) !== void 0 ? { dueDate: normalizeDueDate(row.due_date) } : {},
|
|
118
|
+
...text(row.session_id) !== void 0 ? { sessionId: text(row.session_id) } : {},
|
|
109
119
|
createdAt: Number(row.created_at),
|
|
110
120
|
...row.completed_at !== null && row.completed_at !== void 0 ? { completedAt: Number(row.completed_at) } : {},
|
|
111
121
|
...row.archived_at !== null && row.archived_at !== void 0 ? { archivedAt: Number(row.archived_at) } : {}
|
|
@@ -119,8 +129,8 @@ function writeList(db, items, revision, updatedAt = Date.now()) {
|
|
|
119
129
|
db.prepare("DELETE FROM todo").run();
|
|
120
130
|
const insert = db.prepare(
|
|
121
131
|
`INSERT INTO todo (id, title, description, status, priority, release, sprint, due_date,
|
|
122
|
-
text, done, created_at, completed_at, archived_at, position)
|
|
123
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
132
|
+
session_id, text, done, created_at, completed_at, archived_at, position)
|
|
133
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
124
134
|
);
|
|
125
135
|
items.forEach((item, index) => {
|
|
126
136
|
insert.run(
|
|
@@ -132,6 +142,7 @@ function writeList(db, items, revision, updatedAt = Date.now()) {
|
|
|
132
142
|
item.release ?? null,
|
|
133
143
|
item.sprint ?? null,
|
|
134
144
|
item.dueDate ?? null,
|
|
145
|
+
item.sessionId ?? null,
|
|
135
146
|
item.title,
|
|
136
147
|
item.status === "done" ? 1 : 0,
|
|
137
148
|
item.createdAt,
|
|
@@ -189,10 +200,13 @@ var CliError = class extends Error {
|
|
|
189
200
|
/**
|
|
190
201
|
* @param message - human-readable reason.
|
|
191
202
|
* @param code - process exit code, from {@link EXIT}.
|
|
203
|
+
* @param details - extra machine-readable fields merged into the `--json`
|
|
204
|
+
* error payload, so an agent can correct itself without parsing the sentence.
|
|
192
205
|
*/
|
|
193
|
-
constructor(message, code = EXIT.usage) {
|
|
206
|
+
constructor(message, code = EXIT.usage, details = {}) {
|
|
194
207
|
super(message);
|
|
195
208
|
this.code = code;
|
|
209
|
+
this.details = details;
|
|
196
210
|
this.name = "CliError";
|
|
197
211
|
}
|
|
198
212
|
};
|
|
@@ -210,6 +224,16 @@ function oneOf(options, key, allowed) {
|
|
|
210
224
|
}
|
|
211
225
|
return raw;
|
|
212
226
|
}
|
|
227
|
+
function assertLabel(field, raw) {
|
|
228
|
+
if (raw === void 0 || raw === "") return;
|
|
229
|
+
if (normalizeVersionLabel(raw, field) !== void 0) return;
|
|
230
|
+
const shape = field === "release" ? "a version number like 1.5 or 0.5.1 (up to three numbers)" : "a decimal number like 1.5 (one dot at most)";
|
|
231
|
+
throw new CliError(
|
|
232
|
+
`--${field} must be ${shape} (got "${raw}") \u2014 nothing was saved`,
|
|
233
|
+
EXIT.usage,
|
|
234
|
+
{ field, expected: shape, got: raw }
|
|
235
|
+
);
|
|
236
|
+
}
|
|
213
237
|
function resolveWorkspace(options, cwd) {
|
|
214
238
|
return resolve2(str(options, "workspace") ?? cwd);
|
|
215
239
|
}
|
|
@@ -272,6 +296,7 @@ function formatItem(item) {
|
|
|
272
296
|
if (item.release) meta.push(`release=${item.release}`);
|
|
273
297
|
if (item.sprint) meta.push(`sprint=${item.sprint}`);
|
|
274
298
|
if (item.dueDate) meta.push(`due=${item.dueDate}`);
|
|
299
|
+
if (item.sessionId) meta.push(`session=${item.sessionId}`);
|
|
275
300
|
if (isArchived(item)) meta.push("archived");
|
|
276
301
|
return bits.join(" ") + (meta.length ? ` (${meta.join(" ")})` : "");
|
|
277
302
|
}
|
|
@@ -297,9 +322,11 @@ Options
|
|
|
297
322
|
|
|
298
323
|
--status <s> ${STATUSES.join("|")}
|
|
299
324
|
--priority <p> ${PRIORITIES.join("|")}
|
|
300
|
-
--release <
|
|
301
|
-
--sprint <
|
|
325
|
+
--release <n[.n[.n]]> e.g. 1.5 or 0.5.1 (empty string clears)
|
|
326
|
+
--sprint <n[.n]> e.g. 24 (empty string clears)
|
|
302
327
|
--due <YYYY-MM-DD> Calendar day (empty string clears)
|
|
328
|
+
--session <id> Harness session working the task (update only;
|
|
329
|
+
empty string clears)
|
|
303
330
|
--description <text> Body text (empty string clears)
|
|
304
331
|
--title <text> Rename (update only)
|
|
305
332
|
|
|
@@ -309,8 +336,8 @@ Ids may be given as any unambiguous prefix.
|
|
|
309
336
|
|
|
310
337
|
Examples
|
|
311
338
|
dsh-todo list --open --json
|
|
312
|
-
dsh-todo add "Fix token refresh" --priority p0 --release
|
|
313
|
-
dsh-todo update t1a2 --status in-progress --sprint
|
|
339
|
+
dsh-todo add "Fix token refresh" --priority p0 --release 1.5 --due 2026-03-14
|
|
340
|
+
dsh-todo update t1a2 --status in-progress --sprint 24
|
|
314
341
|
dsh-todo done t1a2
|
|
315
342
|
`;
|
|
316
343
|
function run(parsed, cwd, now = Date.now, rand = Math.random) {
|
|
@@ -347,6 +374,7 @@ function run(parsed, cwd, now = Date.now, rand = Math.random) {
|
|
|
347
374
|
`release ${item.release ?? "-"}`,
|
|
348
375
|
`sprint ${item.sprint ?? "-"}`,
|
|
349
376
|
`due ${item.dueDate ?? "-"}`,
|
|
377
|
+
`session ${item.sessionId ?? "-"}`,
|
|
350
378
|
`created ${new Date(item.createdAt).toISOString()}`,
|
|
351
379
|
...item.completedAt ? [`completed ${new Date(item.completedAt).toISOString()}`] : [],
|
|
352
380
|
...item.archivedAt ? [`archived ${new Date(item.archivedAt).toISOString()}`] : [],
|
|
@@ -358,8 +386,12 @@ function run(parsed, cwd, now = Date.now, rand = Math.random) {
|
|
|
358
386
|
const title = positional.join(" ").trim();
|
|
359
387
|
if (!title) throw new CliError("add needs a title");
|
|
360
388
|
const description = str(options, "description");
|
|
361
|
-
const
|
|
362
|
-
const
|
|
389
|
+
const releaseRaw = str(options, "release");
|
|
390
|
+
const sprintRaw = str(options, "sprint");
|
|
391
|
+
assertLabel("release", releaseRaw);
|
|
392
|
+
assertLabel("sprint", sprintRaw);
|
|
393
|
+
const release = normalizeVersionLabel(releaseRaw, "release");
|
|
394
|
+
const sprint = normalizeVersionLabel(sprintRaw, "sprint");
|
|
363
395
|
const dueRaw = str(options, "due");
|
|
364
396
|
if (dueRaw !== void 0 && dueRaw !== "" && normalizeDueDate(dueRaw) === void 0) {
|
|
365
397
|
throw new CliError(`--due must be a real calendar date as YYYY-MM-DD (got "${dueRaw}")`);
|
|
@@ -388,10 +420,13 @@ function run(parsed, cwd, now = Date.now, rand = Math.random) {
|
|
|
388
420
|
const release = str(options, "release");
|
|
389
421
|
const sprint = str(options, "sprint");
|
|
390
422
|
const due = str(options, "due");
|
|
423
|
+
const session = str(options, "session");
|
|
391
424
|
if (due !== void 0 && due !== "" && normalizeDueDate(due) === void 0) {
|
|
392
425
|
throw new CliError(`--due must be a real calendar date as YYYY-MM-DD (got "${due}")`);
|
|
393
426
|
}
|
|
394
|
-
|
|
427
|
+
assertLabel("release", release);
|
|
428
|
+
assertLabel("sprint", sprint);
|
|
429
|
+
if (status === void 0 && priority === void 0 && title === void 0 && description === void 0 && release === void 0 && sprint === void 0 && due === void 0 && session === void 0) {
|
|
395
430
|
throw new CliError("update needs at least one field to change");
|
|
396
431
|
}
|
|
397
432
|
let updated;
|
|
@@ -413,7 +448,7 @@ function run(parsed, cwd, now = Date.now, rand = Math.random) {
|
|
|
413
448
|
}
|
|
414
449
|
for (const [key, raw] of [["release", release], ["sprint", sprint]]) {
|
|
415
450
|
if (raw === void 0) continue;
|
|
416
|
-
const label =
|
|
451
|
+
const label = normalizeVersionLabel(raw, key);
|
|
417
452
|
if (label !== void 0) next[key] = label;
|
|
418
453
|
else delete next[key];
|
|
419
454
|
}
|
|
@@ -422,6 +457,10 @@ function run(parsed, cwd, now = Date.now, rand = Math.random) {
|
|
|
422
457
|
if (value !== void 0) next.dueDate = value;
|
|
423
458
|
else delete next.dueDate;
|
|
424
459
|
}
|
|
460
|
+
if (session !== void 0) {
|
|
461
|
+
if (session) next.sessionId = session.slice(0, MAX_LABEL);
|
|
462
|
+
else delete next.sessionId;
|
|
463
|
+
}
|
|
425
464
|
updated = next;
|
|
426
465
|
return next;
|
|
427
466
|
});
|
|
@@ -487,12 +526,14 @@ function main(argv, cwd = process.cwd()) {
|
|
|
487
526
|
const wantsJson = parsed.options.json === true;
|
|
488
527
|
try {
|
|
489
528
|
const outcome = run(parsed, cwd);
|
|
490
|
-
|
|
529
|
+
const json = outcome.json !== null && typeof outcome.json === "object" && !Array.isArray(outcome.json) ? { ok: true, ...outcome.json } : { ok: true, result: outcome.json };
|
|
530
|
+
console.log(wantsJson ? JSON.stringify(json, null, 2) : outcome.text);
|
|
491
531
|
return EXIT.ok;
|
|
492
532
|
} catch (error) {
|
|
493
533
|
const message = error instanceof Error ? error.message : String(error);
|
|
494
534
|
const code = error instanceof CliError ? error.code : 1;
|
|
495
|
-
|
|
535
|
+
const details = error instanceof CliError ? error.details : {};
|
|
536
|
+
if (wantsJson) console.log(JSON.stringify({ ok: false, error: message, code, ...details }, null, 2));
|
|
496
537
|
else console.error(`dsh-todo: ${message}`);
|
|
497
538
|
return code;
|
|
498
539
|
}
|