@wirecat/cli-core 0.511.0 → 0.513.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/calendar/mcp/index.d.ts +46 -10
  3. package/dist/calendar/mcp/index.js +60 -25
  4. package/dist/cli/list-query.d.ts +4 -2
  5. package/dist/cli/list-query.js +3 -3
  6. package/dist/cli/messenger/tasks-command.js +4 -5
  7. package/dist/entities/index.d.ts +35 -8
  8. package/dist/entities/task-results.d.ts +22 -7
  9. package/dist/entities/task-results.js +4 -1
  10. package/dist/entities/task.d.ts +18 -1
  11. package/dist/entities/task.js +12 -1
  12. package/dist/errors/reasons.js +1 -0
  13. package/dist/services/task-rules.js +2 -2
  14. package/dist/services/tasks.d.ts +1 -1
  15. package/dist/services/tasks.js +7 -5
  16. package/dist/store/index.d.ts +4 -1
  17. package/dist/store/index.js +2 -0
  18. package/dist/store/sqlite/knowledge.js +3 -2
  19. package/dist/store/sqlite/manifest.js +1 -0
  20. package/dist/store/sqlite/migrations.generated.js +12 -0
  21. package/dist/store/sqlite/person-preparation.js +1 -1
  22. package/dist/store/sqlite/schema.d.ts +48 -0
  23. package/dist/store/sqlite/schema.js +3 -0
  24. package/dist/store/sqlite/tags.d.ts +1 -2
  25. package/dist/store/sqlite/tags.js +18 -3
  26. package/dist/store/sqlite/task-board.d.ts +16 -0
  27. package/dist/store/sqlite/task-board.js +192 -0
  28. package/dist/store/sqlite/task-candidates.js +1 -1
  29. package/dist/store/sqlite/task-category-settings.js +1 -1
  30. package/dist/store/sqlite/task-close-store.js +6 -4
  31. package/dist/store/sqlite/task-pairs.js +4 -3
  32. package/dist/store/sqlite/task-relations.js +3 -2
  33. package/dist/store/sqlite/tasks.js +16 -5
  34. package/dist/store/sqlite/work-tasks.d.ts +96 -9
  35. package/dist/store/sqlite/work-tasks.js +236 -32
  36. package/dist/tasks/index.d.ts +2 -1
  37. package/dist/tasks/index.js +2 -1
  38. package/dist/tasks/model.d.ts +5 -2
  39. package/dist/tasks/model.js +5 -2
  40. package/dist/tasks/priority.d.ts +18 -0
  41. package/dist/tasks/priority.js +37 -0
  42. package/dist/tasks/service.js +2 -2
  43. package/package.json +1 -1
@@ -1,11 +1,15 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { TASK_TYPES, WORK_TASK_TYPES } from "../../domain/knowledge.js";
3
- import { TASK_STATES } from "../../tasks/index.js";
3
+ import { checkedScore, isClosedState, legacyPriority, PRIORITY_LABELS, priorityBand, priorityLabel, TASK_STATES, } from "../../tasks/index.js";
4
4
  import { folded } from "./folded.js";
5
5
  import { authorActor } from "./memories.js";
6
+ import { CORPORA, drainCorpus } from "./note-index.js";
6
7
  import { requiredThing } from "./notes.js";
8
+ import { queryFilter } from "./query-filter.js";
9
+ import { stemmerCache } from "./stems.js";
10
+ import { BOARD_SCOPE, boardOrder, projectTaskColumns, withoutField } from "./task-board.js";
7
11
  import { closeSuggestionOf, SUGGESTION_COLUMNS } from "./task-close.js";
8
- import { inboxKey } from "./task-keys.js";
12
+ import { INBOX_KEY_LENGTH, inboxKey } from "./task-keys.js";
9
13
  import { taskRelationsStoreOver } from "./task-relations.js";
10
14
  import { taskIdOf, taskRowOf, thingOf } from "./things.js";
11
15
  import { write } from "./transaction.js";
@@ -24,6 +28,14 @@ export const TASK_EVENT_KINDS = [
24
28
  "unlinked",
25
29
  "category",
26
30
  "close-suggestion",
31
+ "agent",
32
+ "claimed",
33
+ "released",
34
+ "next-check",
35
+ "resolution",
36
+ "verdict",
37
+ "close-certainty",
38
+ "tags",
27
39
  ];
28
40
  const iso = (value) => (value == null ? null : new Date(Number(value)).toISOString());
29
41
  export const recordTaskEvent = (database, task, actor, kind, changes, at) => {
@@ -41,10 +53,11 @@ const textOf = (value, what, max) => {
41
53
  const claimOf = (row, at) => row.claimed_by != null && Number(row.claimed_until) >= at
42
54
  ? { claimedBy: String(row.claimed_by), claimedUntil: iso(row.claimed_until) }
43
55
  : { claimedBy: null, claimedUntil: null };
44
- const priorityOf = (value) => {
45
- if (value !== null && !(Number.isInteger(value) && value >= 0 && value <= 4))
46
- throw new CliError("validation_error", "a priority is 0 (urgent) to 4 (low)");
47
- return value;
56
+ /** What a write made on a stale copy is refused with: the revision the caller read is not the task's now. */
57
+ export const checkRevision = (row, expected) => {
58
+ if (expected === undefined || Number(row.revision) === expected)
59
+ return;
60
+ throw new CliError("configuration_error", `task ${String(row.key)} changed since revision ${expected}; it is at ${String(row.revision)}: read it again`, { reason: "task.stale", revision: Number(row.revision) });
48
61
  };
49
62
  export const workTaskOperationsOver = (context) => {
50
63
  const { database, now } = context;
@@ -100,7 +113,10 @@ export const workTaskOperationsOver = (context) => {
100
113
  description: row.description == null ? null : String(row.description),
101
114
  type: row.type,
102
115
  status: row.status,
103
- priority: row.priority == null ? null : Number(row.priority),
116
+ priority: row.priority_score == null ? null : Number(row.priority_score),
117
+ priorityLabel: priorityLabel(row.priority_score == null ? null : Number(row.priority_score)),
118
+ agent: row.agent == null ? null : String(row.agent),
119
+ revision: Number(row.revision),
104
120
  parent: row.parent_id == null ? null : reference(Number(row.parent_id)),
105
121
  source: row.source_locator == null ? null : String(row.source_locator),
106
122
  dueAt: iso(row.due_at),
@@ -120,6 +136,19 @@ export const workTaskOperationsOver = (context) => {
120
136
  updatedAt: iso(row.updated_at),
121
137
  };
122
138
  };
139
+ const stemmerFor = stemmerCache();
140
+ const touch = (task, at) => database.prepare("UPDATE tasks SET updated_at = ? WHERE id = ?").run(at, task);
141
+ const boardWhere = (query, zone, at) => {
142
+ if (query === undefined)
143
+ return { sql: BOARD_SCOPE, params: [] };
144
+ const where = queryFilter(query, projectTaskColumns(at), {
145
+ command: "tasks list",
146
+ now: at,
147
+ ...(zone ? { zone } : {}),
148
+ });
149
+ return { sql: `${BOARD_SCOPE} AND (${where.sql})`, params: where.params };
150
+ };
151
+ const countOf = (where) => Number(database.prepare(`SELECT count(*) AS n FROM tasks t WHERE ${where.sql}`).get(...where.params)?.n ?? 0);
123
152
  const rowById = (id) => database.prepare("SELECT * FROM tasks WHERE id = ?").get(id);
124
153
  const details = (id) => detailsOf(rowById(id));
125
154
  const edit = (task, fields, actor, created = false) => {
@@ -127,13 +156,13 @@ export const workTaskOperationsOver = (context) => {
127
156
  const at = now();
128
157
  const set = [];
129
158
  const values = [];
130
- const change = (column, value, event) => {
159
+ const change = (column, value, event, key = column) => {
131
160
  if ((before[column] ?? null) === value)
132
161
  return;
133
162
  set.push(`${column} = ?`);
134
163
  values.push(value);
135
164
  if (event)
136
- recordTaskEvent(database, task, actor, event, { [column]: [before[column] ?? null, value] }, at);
165
+ recordTaskEvent(database, task, actor, event, { [key]: [before[column] ?? null, value] }, at);
137
166
  };
138
167
  if (fields.title !== undefined) {
139
168
  const title = textOf(fields.title, "a task's title", 300);
@@ -142,24 +171,37 @@ export const workTaskOperationsOver = (context) => {
142
171
  }
143
172
  if (fields.description !== undefined)
144
173
  change("description", fields.description === null ? null : textOf(fields.description, "a description", 20000), created ? undefined : "description");
145
- if (fields.priority !== undefined)
146
- change("priority", priorityOf(fields.priority), "priority");
174
+ if (fields.priority !== undefined) {
175
+ const score = fields.priority === null ? null : checkedScore(fields.priority);
176
+ change("priority_score", score, "priority", "priority");
177
+ change("priority", legacyPriority(score));
178
+ }
179
+ if (fields.agent !== undefined)
180
+ change("agent", fields.agent === null ? null : textOf(fields.agent, "an agent's name", 100), "agent");
147
181
  if (fields.parent !== undefined)
148
182
  change("parent_id", fields.parent === null ? null : parentOf(task, Number(before.project_id), fields.parent));
149
183
  if (fields.dueAt !== undefined)
150
184
  change("due_at", fields.dueAt === null ? null : fields.dueAt.getTime(), "due");
151
- // Moved on every look, so no event: the history would be mostly check-ins.
152
185
  if (fields.nextCheckAt !== undefined)
153
- change("next_check_at", fields.nextCheckAt === null ? null : fields.nextCheckAt.getTime());
186
+ change("next_check_at", fields.nextCheckAt === null ? null : fields.nextCheckAt.getTime(), "next-check");
154
187
  if (fields.status !== undefined) {
155
188
  if (!TASK_STATES.includes(fields.status))
156
189
  throw new CliError("validation_error", `a task's status is one of ${TASK_STATES.join(", ")}`);
157
- const closed = fields.status !== "open";
190
+ if ((fields.status === "todo" || fields.status === "in_progress") &&
191
+ String(database.prepare("SELECT key FROM projects WHERE id = ?").get(Number(before.project_id))?.key).length ===
192
+ INBOX_KEY_LENGTH)
193
+ throw new CliError("validation_error", "a task from a message is open, done or dismissed; todo and in_progress are for a project task");
194
+ const closed = isClosedState(fields.status);
195
+ const reopened = !closed && isClosedState(String(before.status));
158
196
  if (before.status !== fields.status) {
159
197
  change("status", fields.status, "status");
160
- change("closed_at", closed ? at : null);
161
- change("closed_by_type", closed ? actor.type : null);
162
- change("closed_by_id", closed ? actor.id : null);
198
+ if (fields.status === "in_progress" && before.started_at == null)
199
+ change("started_at", at);
200
+ if (closed || reopened) {
201
+ change("closed_at", closed ? at : null);
202
+ change("closed_by_type", closed ? actor.type : null);
203
+ change("closed_by_id", closed ? actor.id : null);
204
+ }
163
205
  if (closed) {
164
206
  change("claimed_by", null);
165
207
  change("claimed_until", null);
@@ -168,6 +210,11 @@ export const workTaskOperationsOver = (context) => {
168
210
  }
169
211
  else
170
212
  change("close_certainty", null);
213
+ // Why it was closed no longer holds once it is open again.
214
+ if (reopened && fields.reason === undefined) {
215
+ change("close_reason", null);
216
+ change("close_reason_folded", null);
217
+ }
171
218
  }
172
219
  }
173
220
  if (fields.reason !== undefined) {
@@ -181,6 +228,67 @@ export const workTaskOperationsOver = (context) => {
181
228
  return;
182
229
  database.prepare(`UPDATE tasks SET ${set.join(", ")}, updated_at = ? WHERE id = ?`).run(...values, at, task);
183
230
  };
231
+ /** The column's tasks in board order, the moving one left out; no priority reads as 0, the bottom of low. */
232
+ const columnOf = (task, status) => database
233
+ .prepare(`SELECT t.id, t.key, coalesce(t.priority_score, 0) AS score FROM tasks t WHERE ${BOARD_SCOPE} ` +
234
+ `AND t.status = ? AND t.id <> ? ORDER BY ${boardOrder("priority", false)}`)
235
+ .all(status, task)
236
+ .map((row) => ({ id: Number(row.id), key: String(row.key), score: Number(row.score) }));
237
+ const moveTask = (task, row, move, actor) => {
238
+ if (move.before !== undefined && move.after !== undefined)
239
+ throw new CliError("validation_error", "give --before or --after, not both");
240
+ if (!database.prepare(`SELECT 1 FROM tasks t WHERE t.id = ? AND ${BOARD_SCOPE}`).get(task))
241
+ throw new CliError("validation_error", "only a project task moves on the board");
242
+ const status = move.status ?? row.status;
243
+ const column = columnOf(task, status);
244
+ const neighbour = move.before ?? move.after;
245
+ if (neighbour === undefined) {
246
+ if (column.length > 0 || move.status === undefined)
247
+ throw new CliError("validation_error", "say where it goes: --before <key> or --after <key>");
248
+ const from = priorityLabel(row.priority_score == null ? null : Number(row.priority_score));
249
+ edit(task, { status: move.status }, actor);
250
+ return { label: { from, to: from, changed: false }, renumbered: [] };
251
+ }
252
+ const target = required(neighbour);
253
+ const at = column.findIndex(({ id }) => id === target);
254
+ if (at === -1)
255
+ throw new CliError("validation_error", `${move.before ?? move.after} is not a ${status} task of a project; a task moves beside one in its column`);
256
+ const index = move.before === undefined ? at + 1 : at;
257
+ const above = column[index - 1];
258
+ const below = column[index];
259
+ const STEP = 1 / 64;
260
+ const score = above === undefined
261
+ ? Math.min(1, (below?.score ?? 0) + STEP)
262
+ : below === undefined
263
+ ? Math.max(0, above.score - STEP)
264
+ : (above.score + below.score) / 2;
265
+ const roomy = (above === undefined || score < above.score) && (below === undefined || score > below.score);
266
+ const from = priorityLabel(row.priority_score == null ? null : Number(row.priority_score));
267
+ const renumbered = [];
268
+ let given = score;
269
+ if (!roomy || (above !== undefined && below !== undefined && above.score - below.score < 1e-9)) {
270
+ const label = priorityLabel((above ?? below)?.score ?? 0);
271
+ const band = priorityBand(label);
272
+ const inBand = (one) => priorityLabel(one.score) === label;
273
+ const ordered = [...column.slice(0, index), { id: task, key: String(row.key), score }, ...column.slice(index)];
274
+ const spread = ordered.filter((one) => one.id === task || inBand(one));
275
+ const gap = (band.to - band.from) / (spread.length + 1);
276
+ spread.forEach((one, place) => {
277
+ const value = band.to - gap * (place + 1);
278
+ if (one.id === task) {
279
+ given = value;
280
+ return;
281
+ }
282
+ if (one.score === value)
283
+ return;
284
+ edit(one.id, { priority: value }, actor);
285
+ renumbered.push(one.key);
286
+ });
287
+ }
288
+ edit(task, { priority: given, ...(move.status === undefined ? {} : { status: move.status }) }, actor);
289
+ const to = priorityLabel(given);
290
+ return { label: { from, to, changed: from !== to }, renumbered };
291
+ };
184
292
  return {
185
293
  ...taskRelationsStoreOver(context, (task, actor, kind, changes, at) => recordTaskEvent(database, task, actor, kind, changes, at)),
186
294
  create: (input, by) => {
@@ -207,16 +315,29 @@ export const workTaskOperationsOver = (context) => {
207
315
  });
208
316
  return details(made);
209
317
  },
210
- edit: async (id, fields, by) => {
318
+ edit: async (id, fields, by, check = {}) => {
211
319
  const task = required(id);
212
- write(database, () => edit(task, fields, authorActor(context, by).actor));
320
+ write(database, () => {
321
+ checkRevision(rowById(task), check.revision);
322
+ edit(task, fields, authorActor(context, by).actor);
323
+ });
213
324
  return details(task);
214
325
  },
215
- assign: (id, { to, role }, by) => {
326
+ move: async (id, move, by, check = {}) => {
327
+ const task = required(id);
328
+ return write(database, () => {
329
+ const row = rowById(task);
330
+ checkRevision(row, check.revision);
331
+ const moved = moveTask(task, row, move, authorActor(context, by).actor);
332
+ return { ...moved, task: details(task) };
333
+ });
334
+ },
335
+ assign: (id, { to, role }, by, check = {}) => {
216
336
  if (!TASK_ROLES.includes(role))
217
337
  throw new CliError("validation_error", `a role is one of ${TASK_ROLES.join(", ")}`);
218
338
  const task = required(id);
219
339
  write(database, () => {
340
+ checkRevision(rowById(task), check.revision);
220
341
  const person = personOf(to);
221
342
  const held = database
222
343
  .prepare("SELECT 1 FROM task_assignments WHERE task_id = ? AND assignee_type = 'person' AND assignee_id = ? AND role = ?")
@@ -227,6 +348,7 @@ export const workTaskOperationsOver = (context) => {
227
348
  database
228
349
  .prepare("INSERT INTO task_assignments (task_id, assignee_type, assignee_id, role, created_at, updated_at) VALUES (?, 'person', ?, ?, ?, ?)")
229
350
  .run(task, person, role, at, at);
351
+ touch(task, at);
230
352
  const changes = { [role]: [null, `person:${person}`] };
231
353
  recordTaskEvent(database, task, authorActor(context, by).actor, "assigned", changes, at);
232
354
  });
@@ -253,17 +375,20 @@ export const workTaskOperationsOver = (context) => {
253
375
  .get(thing.id);
254
376
  return row ? `person:${String(row.id)}` : undefined;
255
377
  },
256
- unassign: async (id, { to, role }, by) => {
378
+ unassign: async (id, { to, role }, by, check = {}) => {
257
379
  const task = required(id);
258
380
  write(database, () => {
381
+ checkRevision(rowById(task), check.revision);
259
382
  const person = personOf(to);
260
383
  const removed = database
261
384
  .prepare("DELETE FROM task_assignments WHERE task_id = ? AND assignee_type = 'person' AND assignee_id = ? AND role = ?")
262
385
  .run(task, person, role).changes;
263
386
  if (removed === 0)
264
387
  return;
388
+ const at = now();
389
+ touch(task, at);
265
390
  const changes = { [role]: [`person:${person}`, null] };
266
- recordTaskEvent(database, task, authorActor(context, by).actor, "unassigned", changes, now());
391
+ recordTaskEvent(database, task, authorActor(context, by).actor, "unassigned", changes, at);
267
392
  });
268
393
  return details(task);
269
394
  },
@@ -287,20 +412,26 @@ export const workTaskOperationsOver = (context) => {
287
412
  .prepare("SELECT * FROM tasks t WHERE deleted_at IS NULL AND (? IS NULL OR project_id = ?) AND (? IS NULL OR type = ?) " +
288
413
  "AND (? IS NULL OR status = ?) AND (? IS NULL OR EXISTS (SELECT 1 FROM task_assignments a " +
289
414
  "WHERE a.task_id = t.id AND a.assignee_type = 'person' AND a.assignee_id = ?)) " +
290
- `AND (${where?.sql || "1"}) ORDER BY coalesce(priority, 5), created_at, id LIMIT ?`)
415
+ `AND (${where?.sql || "1"}) ORDER BY priority_score IS NULL, priority_score DESC, created_at, id LIMIT ?`)
291
416
  .all(projectId, projectId, type ?? null, type ?? null, status ?? null, status ?? null, person, person, ...(where?.params ?? []), Math.min(Math.max(limit, 1), 500))
292
417
  .map(detailsOf);
293
418
  },
294
419
  claim: async (id, holder, until) => {
295
420
  const task = required(id);
296
421
  const at = now();
297
- const won = write(database, () => database
298
- .prepare("UPDATE tasks SET claimed_by = ?, claimed_until = ?, updated_at = ? " +
299
- "WHERE id = ? AND status = 'open' AND (claimed_by IS NULL OR claimed_until < ? OR claimed_by = ?) RETURNING id")
300
- .get(holder, until.getTime(), at, task, at, holder));
422
+ const won = write(database, () => {
423
+ const before = claimOf(rowById(task), at).claimedBy;
424
+ const claimed = database
425
+ .prepare("UPDATE tasks SET claimed_by = ?, claimed_until = ?, updated_at = ? " +
426
+ "WHERE id = ? AND status NOT IN ('done', 'dismissed') AND (claimed_by IS NULL OR claimed_until < ? OR claimed_by = ?) RETURNING id")
427
+ .get(holder, until.getTime(), at, task, at, holder);
428
+ if (claimed)
429
+ recordTaskEvent(database, task, null, "claimed", { claimed_by: [before, holder] }, at);
430
+ return claimed;
431
+ });
301
432
  if (!won) {
302
433
  const row = rowById(task);
303
- if (row.status !== "open")
434
+ if (isClosedState(String(row.status)))
304
435
  throw new CliError("validation_error", `task ${String(row.key)} is ${String(row.status)}; only an open task is claimed`);
305
436
  throw new CliError("validation_error", `${String(row.claimed_by)} holds task ${String(row.key)} until ${iso(row.claimed_until)}; ` +
306
437
  "wait for it, or ask them to release it", { reason: "task.claimed", holder: String(row.claimed_by), until: iso(row.claimed_until) });
@@ -316,18 +447,91 @@ export const workTaskOperationsOver = (context) => {
316
447
  throw new CliError("validation_error", `${claimedBy} holds task ${String(row.key)}; only they or the owner release it`, {
317
448
  reason: "task.claimed",
318
449
  });
450
+ const at = now();
319
451
  database
320
452
  .prepare("UPDATE tasks SET claimed_by = NULL, claimed_until = NULL, updated_at = ? WHERE id = ?")
321
- .run(now(), task);
453
+ .run(at, task);
454
+ if (claimedBy !== null)
455
+ recordTaskEvent(database, task, null, "released", { claimed_by: [claimedBy, null] }, at);
322
456
  });
323
457
  return details(task);
324
458
  },
459
+ board: async (query, { limit, offset = 0, sort = "priority", descending = false, zone }) => {
460
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500)
461
+ throw new CliError("validation_error", "a page of tasks holds 1–500");
462
+ drainCorpus(database, CORPORA.task, stemmerFor);
463
+ const where = boardWhere(query, zone, now());
464
+ const rows = database
465
+ .prepare(`SELECT t.* FROM tasks t WHERE ${where.sql} ORDER BY ${boardOrder(sort, descending)} LIMIT ? OFFSET ?`)
466
+ .all(...where.params, limit, offset);
467
+ return { items: rows.map(detailsOf), total: countOf(where) };
468
+ },
469
+ boardCounts: async (query, { zone } = {}) => {
470
+ drainCorpus(database, CORPORA.task, stemmerFor);
471
+ const at = now();
472
+ const without = (name) => boardWhere(withoutField(query, name), zone, at);
473
+ const grouped = (name, select, from, group) => {
474
+ const where = without(name);
475
+ return database
476
+ .prepare(`SELECT ${select}, count(*) AS n FROM tasks t ${from} WHERE ${where.sql} GROUP BY ${group}`)
477
+ .all(...where.params);
478
+ };
479
+ const project = grouped("project", "p.key, p.name", "JOIN projects p ON p.id = t.project_id", "p.id")
480
+ .map((row) => ({ value: String(row.key), name: String(row.name), count: Number(row.n) }))
481
+ .sort((a, b) => a.value.localeCompare(b.value));
482
+ const assigned = grouped("assignee", "a.assignee_id AS person, pe.name", "LEFT JOIN task_assignments a ON a.task_id = t.id AND a.assignee_type = 'person' AND a.role = 'assignee' " +
483
+ "LEFT JOIN persons pe ON pe.id = a.assignee_id", "a.assignee_id");
484
+ const assignee = assigned
485
+ .map((row) => ({
486
+ value: row.person == null ? "none" : `person:${row.person}`,
487
+ name: row.name == null ? null : String(row.name),
488
+ count: Number(row.n),
489
+ }))
490
+ .sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
491
+ const priorities = new Map();
492
+ for (const row of grouped("priority", "t.priority_score", "", "t.priority_score")) {
493
+ const label = priorityLabel(row.priority_score == null ? null : Number(row.priority_score)) ?? "none";
494
+ priorities.set(label, (priorities.get(label) ?? 0) + Number(row.n));
495
+ }
496
+ const states = new Map(grouped("state", "t.status", "", "t.status").map((row) => [row.status, Number(row.n)]));
497
+ return {
498
+ total: countOf(boardWhere(query, zone, at)),
499
+ project,
500
+ assignee,
501
+ priority: [...[...PRIORITY_LABELS].reverse(), "none"].map((value) => ({
502
+ value,
503
+ count: priorities.get(value) ?? 0,
504
+ })),
505
+ state: TASK_STATES.map((value) => ({ value, count: states.get(value) ?? 0 })),
506
+ };
507
+ },
508
+ taskPeople: async () => database
509
+ .prepare("SELECT p.id, p.name, p.owner, count(DISTINCT t.id) AS n FROM persons p " +
510
+ "LEFT JOIN task_assignments a ON a.assignee_type = 'person' AND a.assignee_id = p.id " +
511
+ "LEFT JOIN tasks t ON t.id = a.task_id AND t.deleted_at IS NULL " +
512
+ "WHERE p.owner = 1 OR t.id IS NOT NULL GROUP BY p.id ORDER BY p.owner DESC, p.name COLLATE NOCASE, p.id")
513
+ .all()
514
+ .map((row) => ({
515
+ person: `person:${row.id}`,
516
+ name: row.name == null ? null : String(row.name),
517
+ owner: Number(row.owner) === 1,
518
+ tasks: Number(row.n),
519
+ })),
325
520
  events: async (id) => database
326
- .prepare("SELECT actor_type, actor_id, kind, changes, created_at FROM task_events WHERE task_id = ? ORDER BY id")
521
+ .prepare("SELECT e.actor_type, e.actor_id, e.kind, e.changes, e.created_at, " +
522
+ "CASE e.actor_type WHEN 'person' THEN p.name WHEN 'bot' THEN b.name END AS actor_name FROM task_events e " +
523
+ "LEFT JOIN persons p ON e.actor_type = 'person' AND p.id = e.actor_id " +
524
+ "LEFT JOIN bots b ON e.actor_type = 'bot' AND b.id = e.actor_id WHERE e.task_id = ? ORDER BY e.id")
327
525
  .all(required(id))
328
526
  .map((row) => ({
329
527
  kind: row.kind,
330
- actor: row.actor_type == null ? null : { type: row.actor_type, id: Number(row.actor_id) },
528
+ actor: row.actor_type == null
529
+ ? null
530
+ : {
531
+ type: row.actor_type,
532
+ id: Number(row.actor_id),
533
+ name: row.actor_name == null ? null : String(row.actor_name),
534
+ },
331
535
  changes: row.changes == null ? null : JSON.parse(String(row.changes)),
332
536
  at: iso(row.created_at),
333
537
  })),
@@ -338,6 +542,6 @@ export const workTasksOver = (context) => {
338
542
  return {
339
543
  ...operations,
340
544
  create: async (input, by) => operations.create(input, by),
341
- assign: async (id, assignment, by) => operations.assign(id, assignment, by),
545
+ assign: async (id, assignment, by, check) => operations.assign(id, assignment, by, check),
342
546
  };
343
547
  };
@@ -1,3 +1,4 @@
1
- export { type ClosedState, closeTask, TASK_KINDS, TASK_ORIGINS, TASK_STATES, TASK_VERDICTS, type Task, type TaskKind, type TaskOrigin, type TaskState, type TaskVerdict, } from "./model.js";
1
+ export { CLOSED_STATES, type ClosedState, closeTask, isClosedState, TASK_KINDS, TASK_ORIGINS, TASK_STATES, TASK_VERDICTS, type Task, type TaskKind, type TaskOrigin, type TaskState, type TaskVerdict, } from "./model.js";
2
+ export { checkedScore, LEGACY_PRIORITY_SCORES, legacyPriority, PRIORITY_LABELS, type PriorityLabel, priorityBand, priorityLabel, priorityOf, } from "./priority.js";
2
3
  export { type CloseOptions, createTaskService, type GroupStats, type NewTask, type TaskService, type TaskServiceOptions, } from "./service.js";
3
4
  export { matches, type TaskFilter, type TaskStore } from "./store.js";
@@ -1,3 +1,4 @@
1
- export { closeTask, TASK_KINDS, TASK_ORIGINS, TASK_STATES, TASK_VERDICTS, } from "./model.js";
1
+ export { CLOSED_STATES, closeTask, isClosedState, TASK_KINDS, TASK_ORIGINS, TASK_STATES, TASK_VERDICTS, } from "./model.js";
2
+ export { checkedScore, LEGACY_PRIORITY_SCORES, legacyPriority, PRIORITY_LABELS, priorityBand, priorityLabel, priorityOf, } from "./priority.js";
2
3
  export { createTaskService, } from "./service.js";
3
4
  export { matches } from "./store.js";
@@ -1,12 +1,15 @@
1
1
  export declare const TASK_KINDS: readonly ["question", "request", "mention", "promise"];
2
- export declare const TASK_STATES: readonly ["open", "done", "dismissed"];
2
+ /** `todo` and `in_progress` are steps of a project task's work; a task from a message is only ever open or closed. */
3
+ export declare const TASK_STATES: readonly ["open", "todo", "in_progress", "done", "dismissed"];
4
+ export declare const CLOSED_STATES: readonly ["done", "dismissed"];
3
5
  export declare const TASK_ORIGINS: readonly ["rule", "agent", "owner"];
4
6
  export declare const TASK_VERDICTS: readonly ["useful", "not_useful"];
5
7
  export type TaskKind = (typeof TASK_KINDS)[number];
6
8
  export type TaskState = (typeof TASK_STATES)[number];
7
9
  export type TaskOrigin = (typeof TASK_ORIGINS)[number];
8
10
  export type TaskVerdict = (typeof TASK_VERDICTS)[number];
9
- export type ClosedState = Exclude<TaskState, "open">;
11
+ export type ClosedState = (typeof CLOSED_STATES)[number];
12
+ export declare const isClosedState: (state: string) => state is ClosedState;
10
13
  /**
11
14
  * A task points at what it is about and never holds its text: `source` is a locator the host
12
15
  * resolves (`msg:…` for a message), so a deleted message leaves nothing behind here.
@@ -1,10 +1,13 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  export const TASK_KINDS = ["question", "request", "mention", "promise"];
3
- export const TASK_STATES = ["open", "done", "dismissed"];
3
+ /** `todo` and `in_progress` are steps of a project task's work; a task from a message is only ever open or closed. */
4
+ export const TASK_STATES = ["open", "todo", "in_progress", "done", "dismissed"];
5
+ export const CLOSED_STATES = ["done", "dismissed"];
4
6
  export const TASK_ORIGINS = ["rule", "agent", "owner"];
5
7
  export const TASK_VERDICTS = ["useful", "not_useful"];
8
+ export const isClosedState = (state) => CLOSED_STATES.includes(state);
6
9
  export function closeTask(task, state, by, at, reason) {
7
- if (task.state !== "open")
10
+ if (isClosedState(task.state))
8
11
  throw new CliError("validation_error", `task ${task.id} is already ${task.state}`, { reason: "task.closed" });
9
12
  const { claimedBy: _by, claimedUntil: _until, ...unclaimed } = task;
10
13
  return { ...unclaimed, state, closedAt: at, closedBy: by, ...(reason === undefined ? {} : { reason }) };
@@ -0,0 +1,18 @@
1
+ /** Quarters of [0, 1], lowest first; a person reads the label, a board column is ordered by the number. */
2
+ export declare const PRIORITY_LABELS: readonly ["low", "medium", "high", "critical"];
3
+ export type PriorityLabel = (typeof PRIORITY_LABELS)[number];
4
+ /** The scores of the old 0 (urgent) … 4 (low), as store version 41 converted them. */
5
+ export declare const LEGACY_PRIORITY_SCORES: readonly [0.875, 0.625, 0.375, 0.1875, 0.0625];
6
+ export declare const priorityLabel: (score: number | null) => PriorityLabel | null;
7
+ export declare const priorityBand: (label: PriorityLabel) => {
8
+ from: number;
9
+ to: number;
10
+ };
11
+ /** The 0–4 integer a build before store version 41 reads: critical 0 … low 3, the bottom of low 4. */
12
+ export declare const legacyPriority: (score: number | null) => number | null;
13
+ export declare const checkedScore: (score: number) => number;
14
+ /**
15
+ * A priority as typed: a label, or 0 (urgent) … 4 (low) as before, which keeps the meaning it had.
16
+ * `none` clears it.
17
+ */
18
+ export declare const priorityOf: (value: string) => number | null;
@@ -0,0 +1,37 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ /** Quarters of [0, 1], lowest first; a person reads the label, a board column is ordered by the number. */
3
+ export const PRIORITY_LABELS = ["low", "medium", "high", "critical"];
4
+ /** The number a label is given: the middle of its quarter, so a move leaves room on either side. */
5
+ const MIDDLE = { low: 0.125, medium: 0.375, high: 0.625, critical: 0.875 };
6
+ /** The scores of the old 0 (urgent) … 4 (low), as store version 41 converted them. */
7
+ export const LEGACY_PRIORITY_SCORES = [0.875, 0.625, 0.375, 0.1875, 0.0625];
8
+ export const priorityLabel = (score) => score === null ? null : PRIORITY_LABELS[Math.min(3, Math.max(0, Math.floor(score * 4)))];
9
+ export const priorityBand = (label) => {
10
+ const at = PRIORITY_LABELS.indexOf(label);
11
+ return { from: at / 4, to: (at + 1) / 4 };
12
+ };
13
+ /** The 0–4 integer a build before store version 41 reads: critical 0 … low 3, the bottom of low 4. */
14
+ export const legacyPriority = (score) => score === null
15
+ ? null
16
+ : score < 0.125
17
+ ? 4
18
+ : 3 - PRIORITY_LABELS.indexOf(priorityLabel(score));
19
+ export const checkedScore = (score) => {
20
+ if (!Number.isFinite(score) || score < 0 || score > 1)
21
+ throw new CliError("validation_error", "a priority score is 0 to 1");
22
+ return score;
23
+ };
24
+ /**
25
+ * A priority as typed: a label, or 0 (urgent) … 4 (low) as before, which keeps the meaning it had.
26
+ * `none` clears it.
27
+ */
28
+ export const priorityOf = (value) => {
29
+ const word = value.trim().toLowerCase();
30
+ if (word === "none")
31
+ return null;
32
+ if (PRIORITY_LABELS.includes(word))
33
+ return MIDDLE[word];
34
+ if (/^[0-4]$/.test(word))
35
+ return LEGACY_PRIORITY_SCORES[Number(word)];
36
+ throw new CliError("validation_error", `a priority is ${PRIORITY_LABELS.join(", ")} or none; 0 (urgent) … 4 (low) still work as critical, high, medium, low, low`);
37
+ };
@@ -1,5 +1,5 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
- import { closeTask } from "./model.js";
2
+ import { closeTask, isClosedState } from "./model.js";
3
3
  export function createTaskService({ store, now = () => new Date(), newId = () => crypto.randomUUID(), }) {
4
4
  async function find(id) {
5
5
  const task = await store.get(id);
@@ -36,7 +36,7 @@ export function createTaskService({ store, now = () => new Date(), newId = () =>
36
36
  for (const task of await store.list(filter))
37
37
  byGroup.set(task.group, [...(byGroup.get(task.group) ?? []), task]);
38
38
  return [...byGroup].map(([group, tasks]) => {
39
- const open = tasks.filter((task) => task.state === "open");
39
+ const open = tasks.filter((task) => !isClosedState(task.state));
40
40
  const closeMs = tasks.flatMap((task) => task.closedAt ? [task.closedAt.getTime() - task.createdAt.getTime()] : []);
41
41
  const oldestOpenAt = open.map((task) => task.createdAt).sort((a, b) => a.getTime() - b.getTime())[0];
42
42
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.511.0",
3
+ "version": "0.513.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",