@dennisrongo/dsh-todo 0.4.0 → 0.5.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/README.md +132 -26
- package/lib/bin.js +57 -14
- package/lib/cli.js +57 -14
- package/lib/client.js +217 -34
- package/lib/index.js +421 -11
- package/lib/launch.js +109 -0
- package/lib/scan.js +276 -0
- package/lib/suggest.js +93 -0
- package/lib/typert.host.js +59 -1
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -31,15 +31,23 @@ next to the code it describes.
|
|
|
31
31
|
standup actually asks about: what is moving, and what is stuck.
|
|
32
32
|
- **Priority** — `P0`–`P3`, shown as a chip. Only P0/P1 are coloured, so the
|
|
33
33
|
list flags what is urgent instead of turning into a rainbow.
|
|
34
|
-
- **Release and sprint** — two independent labels: what ships
|
|
35
|
-
(`
|
|
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.
|
|
36
41
|
- **Group by** — None · Status · Release · Sprint · Priority, with collapsible
|
|
37
42
|
section headers carrying their own `done/total` and progress bar. Grouping by
|
|
38
43
|
status gives you a kanban board without drag-and-drop.
|
|
39
44
|
- **Task detail modal** — click a task's title to open the full dialog: a
|
|
40
45
|
roomy description box plus status, priority, release, sprint and due date.
|
|
41
|
-
|
|
42
|
-
|
|
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.
|
|
43
51
|
- **Expandable rows** — the chevron still gives a quick in-row peek without
|
|
44
52
|
leaving the list. Double-click a title to rename it inline. One scannable line
|
|
45
53
|
collapsed is what lets a task carry nine fields without becoming a wall of text.
|
|
@@ -55,8 +63,14 @@ next to the code it describes.
|
|
|
55
63
|
restored (↩) or permanently deleted from the Archive view.
|
|
56
64
|
- **Full editing** — add, check off, click-to-edit the title, edit the
|
|
57
65
|
description and labels, reorder (▲/▼), archive, and delete. Release and sprint
|
|
58
|
-
inputs suggest labels already in use, so
|
|
66
|
+
inputs suggest labels already in use, so values converge on a shared
|
|
59
67
|
vocabulary without a releases table to administer.
|
|
68
|
+
- **Suggest** — a button in the tab header scans the workspace and proposes
|
|
69
|
+
concrete next tasks: unresolved `TODO`/`FIXME`/`HACK` comments, features the
|
|
70
|
+
docs promise but the code does not implement, and modules with no tests. Each
|
|
71
|
+
proposal comes with a one-line rationale, a priority, and a `file:line`
|
|
72
|
+
pointer where one exists. Tick the ones you want and **Add selected** files
|
|
73
|
+
them into the backlog. Never automatic — it runs only when you click it.
|
|
60
74
|
- **Themed** — colors come only from the shell's `--dsw-*` tokens, so it follows
|
|
61
75
|
light/dark automatically. Respects `prefers-reduced-motion`.
|
|
62
76
|
|
|
@@ -101,12 +115,27 @@ dsh profiles set `autoInstallPeers: false`, so this resolves correctly.
|
|
|
101
115
|
pnpm install
|
|
102
116
|
pnpm run build # node build/build.mjs — emits lib/index.js + lib/client.js
|
|
103
117
|
pnpm run typecheck # tsc --noEmit
|
|
104
|
-
pnpm test
|
|
105
|
-
pnpm run test:
|
|
106
|
-
pnpm run test:
|
|
118
|
+
pnpm test # offline: client + CLI, exercising the BUILT lib/ output
|
|
119
|
+
pnpm run test:cli # just the in-process CLI suite
|
|
120
|
+
pnpm run test:integration # spawns the real bin — one full agent workflow
|
|
121
|
+
pnpm run test:icons # headless Chrome: icon sizes and the 40px row budget
|
|
122
|
+
pnpm run test:modal # headless Chrome: the dialog escapes the list's scroll container
|
|
123
|
+
pnpm run test:agent # OPT-IN: a real model drives the CLI (needs a key; costs tokens)
|
|
107
124
|
```
|
|
108
125
|
|
|
109
|
-
`pnpm test` asserts against `lib/`, so **build before testing
|
|
126
|
+
`pnpm test` asserts against `lib/`, so **build before testing** (the script rebuilds first).
|
|
127
|
+
|
|
128
|
+
The CLI has two layers on purpose. `test/cli.test.mjs` calls into `lib/cli.js` in-process;
|
|
129
|
+
`test/cli-integration.mjs` **spawns the real binary** for one realistic agent workflow — plan a
|
|
130
|
+
release, inspect it, hit a refusal, recover from the payload alone, finish and archive — because
|
|
131
|
+
the in-process suite cannot see the shebang'd entry point, `process.exitCode`, the stdout/stderr
|
|
132
|
+
split, or argv as a shell delivers it.
|
|
133
|
+
|
|
134
|
+
`pnpm run test:agent` is the odd one out and is **not** part of `pnpm test`: it hands a real
|
|
135
|
+
model nothing but the binary path and a goal, then asserts on the resulting database rather than
|
|
136
|
+
on anything the model said. It answers a question no offline test can — is `help` enough to
|
|
137
|
+
drive this thing, and is a refusal message enough to recover from? It needs `DEEPSEEK_API_KEY`
|
|
138
|
+
exported (it runs the harness in a throwaway `DSH_HOME`, which has no stored credentials).
|
|
110
139
|
|
|
111
140
|
## Install into a dsh profile
|
|
112
141
|
|
|
@@ -156,6 +185,46 @@ swallow a move.
|
|
|
156
185
|
`clearCompleted` (hard delete of done items) is still exported for callers that
|
|
157
186
|
want it, but it is no longer wired to a button.
|
|
158
187
|
|
|
188
|
+
## Suggest — what to work on next
|
|
189
|
+
|
|
190
|
+
The list holds work someone already thought of. Deciding what to do *next*
|
|
191
|
+
usually happens somewhere else — reading the code and noticing what is missing.
|
|
192
|
+
**Suggest**, in the tab header, moves that into the tab.
|
|
193
|
+
|
|
194
|
+
Clicking it reads the workspace and proposes concrete tasks from three kinds of
|
|
195
|
+
evidence:
|
|
196
|
+
|
|
197
|
+
- **Unresolved comments** — `TODO`, `FIXME` and `HACK`, with the file and line.
|
|
198
|
+
- **Docs-vs-implementation gaps** — the README and the file tree together, so
|
|
199
|
+
what is promised but absent has somewhere to show up.
|
|
200
|
+
- **Untested modules** — source files with no matching test file. A name-based
|
|
201
|
+
hint rather than a coverage run, so it is offered as a hint.
|
|
202
|
+
|
|
203
|
+
Each suggestion arrives as a checkbox row: a title, a one-line rationale, a
|
|
204
|
+
priority, and a `file:line` pointer where there is one to give. Nothing is
|
|
205
|
+
ticked by default — you opted into scanning, not into the results. **Add
|
|
206
|
+
selected** writes the ticked rows into the backlog as real tasks, with the
|
|
207
|
+
rationale as the description. Until then they are **proposals**: nothing is
|
|
208
|
+
stored, and closing the dialog discards them.
|
|
209
|
+
|
|
210
|
+
**Refresh returns genuinely new ideas, not a reshuffle.** Every title already
|
|
211
|
+
shown joins the exclusion set, alongside every unfinished task already in the
|
|
212
|
+
backlog, so the scan is told what not to repeat. Rows you have already ticked
|
|
213
|
+
survive a refresh — the selection is yours, not the model's.
|
|
214
|
+
|
|
215
|
+
> **A scan spends tokens.** It runs a real model session in the background —
|
|
216
|
+
> created, prompted, and archived when the scan finishes or you close the
|
|
217
|
+
> dialog; it never appears in the sidebar and is never navigated to. Nothing is
|
|
218
|
+
> scheduled and nothing is automatic: a scan happens when you click **Suggest**
|
|
219
|
+
> or **Refresh**, and only then.
|
|
220
|
+
|
|
221
|
+
What is sent is a **bounded digest**, not the repository: a capped file tree, a
|
|
222
|
+
capped list of comment matches one line each, and a leading slice of the README
|
|
223
|
+
and `package.json`. Vendored and generated directories (`node_modules`, `lib`,
|
|
224
|
+
`dist`, `vendor`, `target` and the rest) never enter it. Anything left out is
|
|
225
|
+
marked in the digest rather than dropped quietly, so a big repository yields a
|
|
226
|
+
smaller scan rather than a confident one about code it never read.
|
|
227
|
+
|
|
159
228
|
## CLI — for you and for AI agents
|
|
160
229
|
|
|
161
230
|
The same list is reachable from a terminal, so an agent can shell out and manage your tasks
|
|
@@ -181,14 +250,14 @@ experimental-feature warning on stderr; it is harmless, and `2>/dev/null` silenc
|
|
|
181
250
|
```bash
|
|
182
251
|
cd ~/projects/my-app # the workspace IS the current directory
|
|
183
252
|
|
|
184
|
-
dsh-todo add "Fix token refresh" --priority p0 --release
|
|
185
|
-
dsh-todo add "Write migration guide" --sprint
|
|
253
|
+
dsh-todo add "Fix token refresh" --priority p0 --release 1.5 --due 2026-03-14
|
|
254
|
+
dsh-todo add "Write migration guide" --sprint 24
|
|
186
255
|
dsh-todo list
|
|
187
256
|
```
|
|
188
257
|
|
|
189
258
|
```text
|
|
190
|
-
[ ] tmtcfbutukp4j todo p0 Fix token refresh (release=
|
|
191
|
-
[ ] tmtcfbuxr5y9x todo p2 Write migration guide (sprint=
|
|
259
|
+
[ ] tmtcfbutukp4j todo p0 Fix token refresh (release=1.5 due=2026-03-14)
|
|
260
|
+
[ ] tmtcfbuxr5y9x todo p2 Write migration guide (sprint=24)
|
|
192
261
|
[ ] tmtcfbv071e6w in-progress p2 Ship it
|
|
193
262
|
```
|
|
194
263
|
|
|
@@ -198,7 +267,7 @@ That is the whole display contract — it stays greppable and diffable.
|
|
|
198
267
|
Move work along, then file it away:
|
|
199
268
|
|
|
200
269
|
```bash
|
|
201
|
-
dsh-todo update tmtcfbut --status in-progress --sprint
|
|
270
|
+
dsh-todo update tmtcfbut --status in-progress --sprint 24
|
|
202
271
|
dsh-todo show tmtcfbut # everything about one task
|
|
203
272
|
dsh-todo done tmtcfbut
|
|
204
273
|
dsh-todo archive # archive EVERY completed task
|
|
@@ -231,15 +300,15 @@ dsh-todo --workspace ~/projects/other list --open
|
|
|
231
300
|
| `--json` | all | Machine-readable output, **including on errors** |
|
|
232
301
|
| `--status <s>` | add, update, list | `backlog` · `todo` · `in-progress` · `blocked` · `done` |
|
|
233
302
|
| `--priority <p>` | add, update, list | `p0`–`p3` (default `p2`) |
|
|
234
|
-
| `--release <
|
|
235
|
-
| `--sprint <
|
|
303
|
+
| `--release <n[.n[.n]]>` | add, update, list | Numbers only, up to three: `1`, `1.5`, `0.5.1`; `v1.5` is refused |
|
|
304
|
+
| `--sprint <n[.n]>` | add, update, list | Numbers only, one dot at most: `24`, `1.5`; `0.5.1` is refused |
|
|
236
305
|
| `--due <YYYY-MM-DD>` | add, update | A calendar day; impossible dates are refused |
|
|
237
306
|
| `--description <text>` | add, update | Body text — acceptance criteria, repro steps |
|
|
238
307
|
| `--title <text>` | update | Rename |
|
|
239
308
|
| `--open` | list | Everything unfinished, whatever stage |
|
|
240
309
|
| `--archived` | list | Show archived tasks *instead of* active ones |
|
|
241
310
|
|
|
242
|
-
Filters combine, so `list --open --priority p0 --release
|
|
311
|
+
Filters combine, so `list --open --priority p0 --release 1.5` is an AND across all three.
|
|
243
312
|
`--key value` and `--key=value` are both accepted.
|
|
244
313
|
|
|
245
314
|
### Driving it from a script or an agent
|
|
@@ -253,6 +322,7 @@ dsh-todo list --open --json
|
|
|
253
322
|
|
|
254
323
|
```json
|
|
255
324
|
{
|
|
325
|
+
"ok": true,
|
|
256
326
|
"count": 3,
|
|
257
327
|
"items": [
|
|
258
328
|
{
|
|
@@ -260,7 +330,7 @@ dsh-todo list --open --json
|
|
|
260
330
|
"title": "Fix token refresh",
|
|
261
331
|
"status": "todo",
|
|
262
332
|
"priority": "p0",
|
|
263
|
-
"release": "
|
|
333
|
+
"release": "1.5",
|
|
264
334
|
"dueDate": "2026-03-14",
|
|
265
335
|
"createdAt": 1787889639378
|
|
266
336
|
}
|
|
@@ -268,19 +338,41 @@ dsh-todo list --open --json
|
|
|
268
338
|
}
|
|
269
339
|
```
|
|
270
340
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
341
|
+
**Every payload leads with `ok`**, so a caller never has to infer the verdict from the
|
|
342
|
+
shape of what came back. On success `ok` is `true` and the command's own keys follow:
|
|
343
|
+
`list` adds `{ count, items }`; `add` / `update` / `done` / `reopen` / `rm` add
|
|
344
|
+
`{ item, revision }` — the stored task, so a write can be confirmed without a second
|
|
345
|
+
call; `archive` adds `{ archived, revision }`; `show` adds the task itself.
|
|
346
|
+
|
|
347
|
+
A failure returns `ok: false` with `error` and `code`, and exits with that code:
|
|
274
348
|
|
|
275
349
|
```bash
|
|
276
350
|
$ dsh-todo update nope --status done --json; echo "exit=$?"
|
|
277
351
|
{
|
|
352
|
+
"ok": false,
|
|
278
353
|
"error": "no task matching \"nope\"",
|
|
279
354
|
"code": 3
|
|
280
355
|
}
|
|
281
356
|
exit=3
|
|
282
357
|
```
|
|
283
358
|
|
|
359
|
+
A **refused value** says which field it refused, what that field accepts, and what it was
|
|
360
|
+
given — enough for an agent to correct itself without parsing the sentence. Nothing is
|
|
361
|
+
written when a value is refused:
|
|
362
|
+
|
|
363
|
+
```bash
|
|
364
|
+
$ dsh-todo add "Ship it" --release v1.5 --json; echo "exit=$?"
|
|
365
|
+
{
|
|
366
|
+
"ok": false,
|
|
367
|
+
"error": "--release must be a version number like 1.5 or 0.5.1 (up to three numbers) (got \"v1.5\") — nothing was saved",
|
|
368
|
+
"code": 2,
|
|
369
|
+
"field": "release",
|
|
370
|
+
"expected": "a version number like 1.5 or 0.5.1 (up to three numbers)",
|
|
371
|
+
"got": "v1.5"
|
|
372
|
+
}
|
|
373
|
+
exit=2
|
|
374
|
+
```
|
|
375
|
+
|
|
284
376
|
Exit codes are distinct so a script can branch on **why** a command failed:
|
|
285
377
|
|
|
286
378
|
| Code | Means |
|
|
@@ -338,15 +430,29 @@ list. That is the designed reconciliation, not lost data.
|
|
|
338
430
|
| `description` | The body: acceptance criteria, repro steps, links. Its own 5000 cap, because reusing the title's 500 would silently truncate real notes. |
|
|
339
431
|
| `status` | `backlog \| todo \| in-progress \| blocked \| done`. **The source of truth** — there is no separate `done` flag to fall out of sync. |
|
|
340
432
|
| `priority` | `p0`–`p3`, default `p2` so an unranked task sits mid-pile rather than jumping the queue. |
|
|
341
|
-
| `release` | What ships together, e.g. `
|
|
342
|
-
| `sprint` | When it is worked, e.g. `
|
|
433
|
+
| `release` | What ships together, e.g. `1.5` or `0.5.1`. One to three numbers. |
|
|
434
|
+
| `sprint` | When it is worked, e.g. `24`. A single decimal — no patch segment. |
|
|
343
435
|
| `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. |
|
|
344
436
|
|
|
345
|
-
**Release and sprint are separate on purpose.** A task can be worked in
|
|
346
|
-
and ship in
|
|
347
|
-
either question. Both are
|
|
437
|
+
**Release and sprint are separate on purpose.** A task can be worked in sprint 24
|
|
438
|
+
and ship in 1.3; collapsing them into one field loses the ability to answer
|
|
439
|
+
either question. Both are **numeric labels** rather than entities, refused at
|
|
440
|
+
every write path — CLI and UI alike — if they are anything else, so grouping and
|
|
348
441
|
filtering work with no releases table, no CRUD, and no migration to rename one.
|
|
349
442
|
|
|
443
|
+
The two shapes differ, because the fields mean different things:
|
|
444
|
+
|
|
445
|
+
| Field | Pattern | Passes | Refused |
|
|
446
|
+
| --- | --- | --- | --- |
|
|
447
|
+
| `release` | `^\d+(\.\d+){0,2}$` | `1`, `1.5`, `0.5.1` | `v1.5`, `1.2.3.4`, `1.` |
|
|
448
|
+
| `sprint` | `^\d+(\.\d+)?$` | `1`, `1.5`, `24` | `Sprint 24`, `0.5.1` |
|
|
449
|
+
|
|
450
|
+
A release carries a **patch segment** so a fix shipping on top of 0.5 gets its own
|
|
451
|
+
label; a sprint is a point on a calendar, not a shipped artefact, so it takes none.
|
|
452
|
+
Being numeric means labels sort by **version semantics** — compared segment by
|
|
453
|
+
segment, so `1.10` ranks above `1.9` and `0.5.1` sits between `0.5` and `0.6`.
|
|
454
|
+
Labels stored before the rule still load, group, and display unchanged.
|
|
455
|
+
|
|
350
456
|
`completedAt` is written only by the status transition, so it can never claim a
|
|
351
457
|
task is finished that isn't. Absent optional fields are absent *keys*, never
|
|
352
458
|
`''`, 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;
|
|
@@ -36,6 +44,8 @@ function normalizeDueDate(raw) {
|
|
|
36
44
|
var MAX_TEXT = 500;
|
|
37
45
|
var MAX_DESC = 5e3;
|
|
38
46
|
var MAX_LABEL = 60;
|
|
47
|
+
var SUGGESTIONS_DIR = ".dsh";
|
|
48
|
+
var SUGGESTIONS_FILE = `${SUGGESTIONS_DIR}/suggestions.json`;
|
|
39
49
|
|
|
40
50
|
// src/db.ts
|
|
41
51
|
var DOT_DSH = ".dsh";
|
|
@@ -78,6 +88,7 @@ function migrateSchema(db) {
|
|
|
78
88
|
add("release", "release TEXT");
|
|
79
89
|
add("sprint", "sprint TEXT");
|
|
80
90
|
add("due_date", "due_date TEXT");
|
|
91
|
+
add("session_id", "session_id TEXT");
|
|
81
92
|
if (addedTitle && columns.has("text")) {
|
|
82
93
|
db.exec("UPDATE todo SET title = text WHERE title IS NULL");
|
|
83
94
|
}
|
|
@@ -93,7 +104,7 @@ function readList(db) {
|
|
|
93
104
|
const updatedAt = Number(db.prepare("SELECT value FROM meta WHERE key = 'updatedAt'").get()?.value ?? 0);
|
|
94
105
|
const rows = db.prepare(
|
|
95
106
|
`SELECT id, title, description, status, priority, release, sprint, due_date,
|
|
96
|
-
created_at, completed_at, archived_at
|
|
107
|
+
session_id, created_at, completed_at, archived_at
|
|
97
108
|
FROM todo ORDER BY position ASC`
|
|
98
109
|
).all();
|
|
99
110
|
const text = (v) => v === null || v === void 0 ? void 0 : String(v);
|
|
@@ -108,6 +119,7 @@ function readList(db) {
|
|
|
108
119
|
...normalizeLabel(row.release) !== void 0 ? { release: normalizeLabel(row.release) } : {},
|
|
109
120
|
...normalizeLabel(row.sprint) !== void 0 ? { sprint: normalizeLabel(row.sprint) } : {},
|
|
110
121
|
...normalizeDueDate(row.due_date) !== void 0 ? { dueDate: normalizeDueDate(row.due_date) } : {},
|
|
122
|
+
...text(row.session_id) !== void 0 ? { sessionId: text(row.session_id) } : {},
|
|
111
123
|
createdAt: Number(row.created_at),
|
|
112
124
|
...row.completed_at !== null && row.completed_at !== void 0 ? { completedAt: Number(row.completed_at) } : {},
|
|
113
125
|
...row.archived_at !== null && row.archived_at !== void 0 ? { archivedAt: Number(row.archived_at) } : {}
|
|
@@ -121,8 +133,8 @@ function writeList(db, items, revision, updatedAt = Date.now()) {
|
|
|
121
133
|
db.prepare("DELETE FROM todo").run();
|
|
122
134
|
const insert = db.prepare(
|
|
123
135
|
`INSERT INTO todo (id, title, description, status, priority, release, sprint, due_date,
|
|
124
|
-
text, done, created_at, completed_at, archived_at, position)
|
|
125
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
136
|
+
session_id, text, done, created_at, completed_at, archived_at, position)
|
|
137
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
126
138
|
);
|
|
127
139
|
items.forEach((item, index) => {
|
|
128
140
|
insert.run(
|
|
@@ -134,6 +146,7 @@ function writeList(db, items, revision, updatedAt = Date.now()) {
|
|
|
134
146
|
item.release ?? null,
|
|
135
147
|
item.sprint ?? null,
|
|
136
148
|
item.dueDate ?? null,
|
|
149
|
+
item.sessionId ?? null,
|
|
137
150
|
item.title,
|
|
138
151
|
item.status === "done" ? 1 : 0,
|
|
139
152
|
item.createdAt,
|
|
@@ -191,10 +204,13 @@ var CliError = class extends Error {
|
|
|
191
204
|
/**
|
|
192
205
|
* @param message - human-readable reason.
|
|
193
206
|
* @param code - process exit code, from {@link EXIT}.
|
|
207
|
+
* @param details - extra machine-readable fields merged into the `--json`
|
|
208
|
+
* error payload, so an agent can correct itself without parsing the sentence.
|
|
194
209
|
*/
|
|
195
|
-
constructor(message, code = EXIT.usage) {
|
|
210
|
+
constructor(message, code = EXIT.usage, details = {}) {
|
|
196
211
|
super(message);
|
|
197
212
|
this.code = code;
|
|
213
|
+
this.details = details;
|
|
198
214
|
this.name = "CliError";
|
|
199
215
|
}
|
|
200
216
|
};
|
|
@@ -212,6 +228,16 @@ function oneOf(options, key, allowed) {
|
|
|
212
228
|
}
|
|
213
229
|
return raw;
|
|
214
230
|
}
|
|
231
|
+
function assertLabel(field, raw) {
|
|
232
|
+
if (raw === void 0 || raw === "") return;
|
|
233
|
+
if (normalizeVersionLabel(raw, field) !== void 0) return;
|
|
234
|
+
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)";
|
|
235
|
+
throw new CliError(
|
|
236
|
+
`--${field} must be ${shape} (got "${raw}") \u2014 nothing was saved`,
|
|
237
|
+
EXIT.usage,
|
|
238
|
+
{ field, expected: shape, got: raw }
|
|
239
|
+
);
|
|
240
|
+
}
|
|
215
241
|
function resolveWorkspace(options, cwd) {
|
|
216
242
|
return resolve2(str(options, "workspace") ?? cwd);
|
|
217
243
|
}
|
|
@@ -274,6 +300,7 @@ function formatItem(item) {
|
|
|
274
300
|
if (item.release) meta.push(`release=${item.release}`);
|
|
275
301
|
if (item.sprint) meta.push(`sprint=${item.sprint}`);
|
|
276
302
|
if (item.dueDate) meta.push(`due=${item.dueDate}`);
|
|
303
|
+
if (item.sessionId) meta.push(`session=${item.sessionId}`);
|
|
277
304
|
if (isArchived(item)) meta.push("archived");
|
|
278
305
|
return bits.join(" ") + (meta.length ? ` (${meta.join(" ")})` : "");
|
|
279
306
|
}
|
|
@@ -299,9 +326,11 @@ Options
|
|
|
299
326
|
|
|
300
327
|
--status <s> ${STATUSES.join("|")}
|
|
301
328
|
--priority <p> ${PRIORITIES.join("|")}
|
|
302
|
-
--release <
|
|
303
|
-
--sprint <
|
|
329
|
+
--release <n[.n[.n]]> e.g. 1.5 or 0.5.1 (empty string clears)
|
|
330
|
+
--sprint <n[.n]> e.g. 24 (empty string clears)
|
|
304
331
|
--due <YYYY-MM-DD> Calendar day (empty string clears)
|
|
332
|
+
--session <id> Harness session working the task (update only;
|
|
333
|
+
empty string clears)
|
|
305
334
|
--description <text> Body text (empty string clears)
|
|
306
335
|
--title <text> Rename (update only)
|
|
307
336
|
|
|
@@ -311,8 +340,8 @@ Ids may be given as any unambiguous prefix.
|
|
|
311
340
|
|
|
312
341
|
Examples
|
|
313
342
|
dsh-todo list --open --json
|
|
314
|
-
dsh-todo add "Fix token refresh" --priority p0 --release
|
|
315
|
-
dsh-todo update t1a2 --status in-progress --sprint
|
|
343
|
+
dsh-todo add "Fix token refresh" --priority p0 --release 1.5 --due 2026-03-14
|
|
344
|
+
dsh-todo update t1a2 --status in-progress --sprint 24
|
|
316
345
|
dsh-todo done t1a2
|
|
317
346
|
`;
|
|
318
347
|
function run(parsed, cwd, now = Date.now, rand = Math.random) {
|
|
@@ -349,6 +378,7 @@ function run(parsed, cwd, now = Date.now, rand = Math.random) {
|
|
|
349
378
|
`release ${item.release ?? "-"}`,
|
|
350
379
|
`sprint ${item.sprint ?? "-"}`,
|
|
351
380
|
`due ${item.dueDate ?? "-"}`,
|
|
381
|
+
`session ${item.sessionId ?? "-"}`,
|
|
352
382
|
`created ${new Date(item.createdAt).toISOString()}`,
|
|
353
383
|
...item.completedAt ? [`completed ${new Date(item.completedAt).toISOString()}`] : [],
|
|
354
384
|
...item.archivedAt ? [`archived ${new Date(item.archivedAt).toISOString()}`] : [],
|
|
@@ -360,8 +390,12 @@ function run(parsed, cwd, now = Date.now, rand = Math.random) {
|
|
|
360
390
|
const title = positional.join(" ").trim();
|
|
361
391
|
if (!title) throw new CliError("add needs a title");
|
|
362
392
|
const description = str(options, "description");
|
|
363
|
-
const
|
|
364
|
-
const
|
|
393
|
+
const releaseRaw = str(options, "release");
|
|
394
|
+
const sprintRaw = str(options, "sprint");
|
|
395
|
+
assertLabel("release", releaseRaw);
|
|
396
|
+
assertLabel("sprint", sprintRaw);
|
|
397
|
+
const release = normalizeVersionLabel(releaseRaw, "release");
|
|
398
|
+
const sprint = normalizeVersionLabel(sprintRaw, "sprint");
|
|
365
399
|
const dueRaw = str(options, "due");
|
|
366
400
|
if (dueRaw !== void 0 && dueRaw !== "" && normalizeDueDate(dueRaw) === void 0) {
|
|
367
401
|
throw new CliError(`--due must be a real calendar date as YYYY-MM-DD (got "${dueRaw}")`);
|
|
@@ -390,10 +424,13 @@ function run(parsed, cwd, now = Date.now, rand = Math.random) {
|
|
|
390
424
|
const release = str(options, "release");
|
|
391
425
|
const sprint = str(options, "sprint");
|
|
392
426
|
const due = str(options, "due");
|
|
427
|
+
const session = str(options, "session");
|
|
393
428
|
if (due !== void 0 && due !== "" && normalizeDueDate(due) === void 0) {
|
|
394
429
|
throw new CliError(`--due must be a real calendar date as YYYY-MM-DD (got "${due}")`);
|
|
395
430
|
}
|
|
396
|
-
|
|
431
|
+
assertLabel("release", release);
|
|
432
|
+
assertLabel("sprint", sprint);
|
|
433
|
+
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
434
|
throw new CliError("update needs at least one field to change");
|
|
398
435
|
}
|
|
399
436
|
let updated;
|
|
@@ -415,7 +452,7 @@ function run(parsed, cwd, now = Date.now, rand = Math.random) {
|
|
|
415
452
|
}
|
|
416
453
|
for (const [key, raw] of [["release", release], ["sprint", sprint]]) {
|
|
417
454
|
if (raw === void 0) continue;
|
|
418
|
-
const label =
|
|
455
|
+
const label = normalizeVersionLabel(raw, key);
|
|
419
456
|
if (label !== void 0) next[key] = label;
|
|
420
457
|
else delete next[key];
|
|
421
458
|
}
|
|
@@ -424,6 +461,10 @@ function run(parsed, cwd, now = Date.now, rand = Math.random) {
|
|
|
424
461
|
if (value !== void 0) next.dueDate = value;
|
|
425
462
|
else delete next.dueDate;
|
|
426
463
|
}
|
|
464
|
+
if (session !== void 0) {
|
|
465
|
+
if (session) next.sessionId = session.slice(0, MAX_LABEL);
|
|
466
|
+
else delete next.sessionId;
|
|
467
|
+
}
|
|
427
468
|
updated = next;
|
|
428
469
|
return next;
|
|
429
470
|
});
|
|
@@ -489,12 +530,14 @@ function main(argv, cwd = process.cwd()) {
|
|
|
489
530
|
const wantsJson = parsed.options.json === true;
|
|
490
531
|
try {
|
|
491
532
|
const outcome = run(parsed, cwd);
|
|
492
|
-
|
|
533
|
+
const json = outcome.json !== null && typeof outcome.json === "object" && !Array.isArray(outcome.json) ? { ok: true, ...outcome.json } : { ok: true, result: outcome.json };
|
|
534
|
+
console.log(wantsJson ? JSON.stringify(json, null, 2) : outcome.text);
|
|
493
535
|
return EXIT.ok;
|
|
494
536
|
} catch (error) {
|
|
495
537
|
const message = error instanceof Error ? error.message : String(error);
|
|
496
538
|
const code = error instanceof CliError ? error.code : 1;
|
|
497
|
-
|
|
539
|
+
const details = error instanceof CliError ? error.details : {};
|
|
540
|
+
if (wantsJson) console.log(JSON.stringify({ ok: false, error: message, code, ...details }, null, 2));
|
|
498
541
|
else console.error(`dsh-todo: ${message}`);
|
|
499
542
|
return code;
|
|
500
543
|
}
|