@wirecat/cli-core 0.512.0 → 0.514.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,52 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.514.0 — 01.11.2026
11
+
12
+ ### Fixed
13
+
14
+ - **A failed run's record masks a WhatsApp id as `[id]`**: `<number>[:device]@s.whatsapp.net`, a
15
+ group's `<number>-<number>@g.us`, `@c.us`, `@broadcast`, `@newsletter` and an `@lid` of any length. Before, such an
16
+ id was masked only when it looked like an email, and a 16-digit `@lid` or a `@newsletter` id stayed in the record.
17
+ - **The parity check requires `projects` and `projects evidence` in max and tg**, which ship them in max-cli 0.94.0
18
+ and tg-cli 0.110.0 (tg-cli#798, max-cli#886); wa stays planned.
19
+
20
+ ## 0.513.0 — 01.11.2026
21
+
22
+ ### Added
23
+
24
+ - **`tasks.board(query, { limit, offset, sort, descending, zone })` and `tasks.boardCounts(query)`** list the owner's
25
+ project tasks across every project (an account's inbox is left out) with a Lucene query over `PROJECT_TASK_FIELDS`:
26
+ `state`, `kind`, `project` (`project:*` for all), `assignee` (`any`, `none`, `"person:12"`), `priority`,
27
+ `due` and `closed` with ranges, `overdue`, `ready`, and `text`, the words of the title and description
28
+ (prefixes; a task key too). The board sorts by `TASK_SORTS` and returns the total; the counts give how many tasks
29
+ hold each project, assignee, priority and state, each without the query's own terms on that field.
30
+ - **Store version 41: a decimal priority, an agent and a revision on every task** (`minCompatible` stays 34).
31
+ `tasks.priority_score` holds 0 to 1, higher first; `priorityLabel` reads it as low, medium, high or critical by
32
+ quarters. The old 0 (urgent) … 4 (low) became 0.875, 0.625, 0.375, 0.1875 and 0.0625, and the integer `priority`
33
+ column is still written from the score, so an older build reads a familiar number; when an older build writes the
34
+ integer, a trigger moves the score with it. `tasks.agent` names the agent working on a task, apart from its
35
+ assignees and its claim. `tasks.revision` is bumped by a trigger on every update, by any build.
36
+ - **`tasks.move(id, { status, before | after }, by)`** puts a project task beside another in its board column by giving
37
+ it a priority between its neighbours; the answer says when the label changed, and which tasks were spread out again
38
+ when the neighbours left no room. Into an empty column it needs no neighbour.
39
+ - **A stale write is refused**: `edit`, `assign`, `unassign` and `move` take `{ revision }` and refuse another with
40
+ `configuration_error`, reason `task.stale`, and the task's current revision. `assign` and `unassign` now bump the
41
+ task's revision too.
42
+ - **Every task write is in `task_events`**, in the same transaction: new kinds `agent`, `claimed`, `released`,
43
+ `next-check` (a follow-up time used to leave no event), `resolution`, `verdict`, `close-certainty` and `tags` (a
44
+ tag added to or removed from a task). The event id only grows, so it orders changes.
45
+ - **`priority:` and `agent:` query fields** on the board: `priority:high`, `priority:none`, `priority:[medium TO
46
+ critical]`, `priority:[0.6 TO 1]`; `agent:hermes`, `agent:none`.
47
+ - **The `task` entity** may carry `priority_label`, `agent`, `revision`, `assignments`, `ready` and `blocked_by`, as a
48
+ project task list gives them.
49
+
50
+ ### Changed — may break callers
51
+
52
+ - **A task's `priority` is a number from 0 to 1, higher first**, in `TaskDetails`, `TaskFields` and the task card
53
+ (`priority_label` beside it); it was 0 (urgent) … 4 (low). Lists order by it highest first. `tasks update
54
+ --priority` takes low, medium, high, critical or none, and still reads 0–4 as before.
55
+
10
56
  ## 0.512.0 — 01.11.2026
11
57
 
12
58
  ### Added
@@ -8,6 +8,7 @@ import { aheadOf, momentOf } from "../../services/moment.js";
8
8
  import { unlistedNote } from "../../services/task-categories.js";
9
9
  import { ARRIVAL_WINDOW, applyTaskRulesToChats } from "../../services/task-rules.js";
10
10
  import { CLAIM_LEASE_MS, closedStateOf, TASK_PAIR_QUERY, TASK_QUERY, taskLinkKindOf, taskTypeOf, } from "../../services/tasks.js";
11
+ import { PRIORITY_LABELS, priorityOf } from "../../tasks/index.js";
11
12
  import { actorOf, holderOf as holderIn } from "../author.js";
12
13
  import { environmentOf } from "../context.js";
13
14
  import { destroysNothing, LOCAL_READ, LOCAL_WRITE, ownerOnlyOption } from "../effects.js";
@@ -35,6 +36,8 @@ const suggestionLine = ({ as, by, certainty, reason }) => `close suggested: ${as
35
36
  const card = (task) => [
36
37
  `${task.key} ${task.status} ${task.type} ${task.category ?? "—"} ${task.title}`,
37
38
  `due ${when(task.dueAt)} next check ${when(task.nextCheckAt)} source ${task.source ?? "—"}`,
39
+ ...(task.priorityLabel === null ? [] : [`priority ${task.priorityLabel}`]),
40
+ ...(task.agent === null ? [] : [`agent ${task.agent}`]),
38
41
  ...(task.claimedBy === null ? [] : [`claimed by ${task.claimedBy} until ${when(task.claimedUntil)}`]),
39
42
  ...task.assignments.filter((one) => one.role === "assignee").map((one) => `assignee ${one.to}`),
40
43
  ...(task.closeSuggestion === null ? [] : [suggestionLine(task.closeSuggestion)]),
@@ -205,11 +208,7 @@ export const tasksCommand = (messenger) => {
205
208
  .description("update a task's text, schedule, category, assignees, priority or parent; nothing reaches the messenger")
206
209
  .argument("<task>", "the task's id or key, as tasks list shows it")
207
210
  .option("--category <name>", `${categoryHelp}; a change is kept in the task's history`)
208
- .option("--priority <n>", "priority from 0 (urgent) through 4 (low)", (value) => {
209
- if (!/^[0-4]$/.test(value))
210
- throw new CliError("validation_error", "--priority takes 0 through 4");
211
- return Number(value);
212
- })
211
+ .option("--priority <n>", `${PRIORITY_LABELS.join(", ")} or none; 0 (urgent) … 4 (low) still read as before`, priorityOf)
213
212
  .option("--parent <id>", "parent task in the same project; none clears it"), "send Alex the API token").action(async function (id) {
214
213
  const context = messengerContext(this, messenger);
215
214
  refuseLocalWrite(context, messenger.app.command, "tasks.update");
@@ -4765,6 +4765,18 @@ export declare const ENTITIES: {
4765
4765
  readonly chat: v.OptionalSchema<v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>, undefined>;
4766
4766
  readonly source_message: v.OptionalSchema<v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>, undefined>;
4767
4767
  readonly priority: v.OptionalSchema<v.NullableSchema<v.NumberSchema<undefined>, undefined>, undefined>;
4768
+ readonly priority_label: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<readonly ["low", "medium", "high", "critical"], undefined>, undefined>, undefined>;
4769
+ readonly agent: v.OptionalSchema<v.NullableSchema<v.StringSchema<undefined>, undefined>, undefined>;
4770
+ readonly revision: v.OptionalSchema<v.NumberSchema<undefined>, undefined>;
4771
+ readonly assignments: v.OptionalSchema<v.ArraySchema<v.StrictObjectSchema<{
4772
+ readonly to: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
4773
+ readonly role: v.PicklistSchema<["assignee", "reviewer", "watcher"], undefined>;
4774
+ }, undefined>, undefined>, undefined>;
4775
+ readonly ready: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
4776
+ readonly blocked_by: v.OptionalSchema<v.ArraySchema<v.StrictObjectSchema<{
4777
+ readonly task: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
4778
+ readonly state: v.PicklistSchema<["open", "dismissed", "unavailable"], undefined>;
4779
+ }, undefined>, undefined>, undefined>;
4768
4780
  readonly parent: v.OptionalSchema<v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>, undefined>;
4769
4781
  readonly due_at: v.OptionalSchema<v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>, undefined>;
4770
4782
  readonly closed_at: v.OptionalSchema<v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>, undefined>;
@@ -4822,6 +4834,9 @@ export declare const ENTITIES: {
4822
4834
  readonly type: v.PicklistSchema<readonly ["bug", "feature", "chore", "question", "request", "mention", "promise"], undefined>;
4823
4835
  readonly status: v.PicklistSchema<readonly ["open", "todo", "in_progress", "done", "dismissed"], undefined>;
4824
4836
  readonly priority: v.NullableSchema<v.NumberSchema<undefined>, undefined>;
4837
+ readonly priority_label: v.NullableSchema<v.PicklistSchema<readonly ["low", "medium", "high", "critical"], undefined>, undefined>;
4838
+ readonly agent: v.NullableSchema<v.StringSchema<undefined>, undefined>;
4839
+ readonly revision: v.NumberSchema<undefined>;
4825
4840
  readonly parent: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
4826
4841
  readonly source: v.NullableSchema<v.StringSchema<undefined>, undefined>;
4827
4842
  readonly due_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
@@ -4923,6 +4938,9 @@ export declare const ENTITIES: {
4923
4938
  readonly type: v.PicklistSchema<readonly ["bug", "feature", "chore", "question", "request", "mention", "promise"], undefined>;
4924
4939
  readonly status: v.PicklistSchema<readonly ["open", "todo", "in_progress", "done", "dismissed"], undefined>;
4925
4940
  readonly priority: v.NullableSchema<v.NumberSchema<undefined>, undefined>;
4941
+ readonly priority_label: v.NullableSchema<v.PicklistSchema<readonly ["low", "medium", "high", "critical"], undefined>, undefined>;
4942
+ readonly agent: v.NullableSchema<v.StringSchema<undefined>, undefined>;
4943
+ readonly revision: v.NumberSchema<undefined>;
4926
4944
  readonly parent: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
4927
4945
  readonly source: v.NullableSchema<v.StringSchema<undefined>, undefined>;
4928
4946
  readonly due_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
@@ -5025,6 +5043,9 @@ export declare const ENTITIES: {
5025
5043
  readonly type: v.PicklistSchema<readonly ["bug", "feature", "chore", "question", "request", "mention", "promise"], undefined>;
5026
5044
  readonly status: v.PicklistSchema<readonly ["open", "todo", "in_progress", "done", "dismissed"], undefined>;
5027
5045
  readonly priority: v.NullableSchema<v.NumberSchema<undefined>, undefined>;
5046
+ readonly priority_label: v.NullableSchema<v.PicklistSchema<readonly ["low", "medium", "high", "critical"], undefined>, undefined>;
5047
+ readonly agent: v.NullableSchema<v.StringSchema<undefined>, undefined>;
5048
+ readonly revision: v.NumberSchema<undefined>;
5028
5049
  readonly parent: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
5029
5050
  readonly source: v.NullableSchema<v.StringSchema<undefined>, undefined>;
5030
5051
  readonly due_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
@@ -5155,6 +5176,9 @@ export declare const ENTITIES: {
5155
5176
  readonly type: v.PicklistSchema<readonly ["bug", "feature", "chore", "question", "request", "mention", "promise"], undefined>;
5156
5177
  readonly status: v.PicklistSchema<readonly ["open", "todo", "in_progress", "done", "dismissed"], undefined>;
5157
5178
  readonly priority: v.NullableSchema<v.NumberSchema<undefined>, undefined>;
5179
+ readonly priority_label: v.NullableSchema<v.PicklistSchema<readonly ["low", "medium", "high", "critical"], undefined>, undefined>;
5180
+ readonly agent: v.NullableSchema<v.StringSchema<undefined>, undefined>;
5181
+ readonly revision: v.NumberSchema<undefined>;
5158
5182
  readonly parent: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
5159
5183
  readonly source: v.NullableSchema<v.StringSchema<undefined>, undefined>;
5160
5184
  readonly due_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
@@ -5250,6 +5274,9 @@ export declare const ENTITIES: {
5250
5274
  readonly type: v.PicklistSchema<readonly ["bug", "feature", "chore", "question", "request", "mention", "promise"], undefined>;
5251
5275
  readonly status: v.PicklistSchema<readonly ["open", "todo", "in_progress", "done", "dismissed"], undefined>;
5252
5276
  readonly priority: v.NullableSchema<v.NumberSchema<undefined>, undefined>;
5277
+ readonly priority_label: v.NullableSchema<v.PicklistSchema<readonly ["low", "medium", "high", "critical"], undefined>, undefined>;
5278
+ readonly agent: v.NullableSchema<v.StringSchema<undefined>, undefined>;
5279
+ readonly revision: v.NumberSchema<undefined>;
5253
5280
  readonly parent: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
5254
5281
  readonly source: v.NullableSchema<v.StringSchema<undefined>, undefined>;
5255
5282
  readonly due_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
@@ -9,6 +9,9 @@ export declare const taskCard: v.StrictObjectSchema<{
9
9
  readonly type: v.PicklistSchema<readonly ["bug", "feature", "chore", "question", "request", "mention", "promise"], undefined>;
10
10
  readonly status: v.PicklistSchema<readonly ["open", "todo", "in_progress", "done", "dismissed"], undefined>;
11
11
  readonly priority: v.NullableSchema<v.NumberSchema<undefined>, undefined>;
12
+ readonly priority_label: v.NullableSchema<v.PicklistSchema<readonly ["low", "medium", "high", "critical"], undefined>, undefined>;
13
+ readonly agent: v.NullableSchema<v.StringSchema<undefined>, undefined>;
14
+ readonly revision: v.NumberSchema<undefined>;
12
15
  readonly parent: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
13
16
  readonly source: v.NullableSchema<v.StringSchema<undefined>, undefined>;
14
17
  readonly due_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
@@ -110,6 +113,9 @@ export declare const taskCreated: v.StrictObjectSchema<{
110
113
  readonly type: v.PicklistSchema<readonly ["bug", "feature", "chore", "question", "request", "mention", "promise"], undefined>;
111
114
  readonly status: v.PicklistSchema<readonly ["open", "todo", "in_progress", "done", "dismissed"], undefined>;
112
115
  readonly priority: v.NullableSchema<v.NumberSchema<undefined>, undefined>;
116
+ readonly priority_label: v.NullableSchema<v.PicklistSchema<readonly ["low", "medium", "high", "critical"], undefined>, undefined>;
117
+ readonly agent: v.NullableSchema<v.StringSchema<undefined>, undefined>;
118
+ readonly revision: v.NumberSchema<undefined>;
113
119
  readonly parent: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
114
120
  readonly source: v.NullableSchema<v.StringSchema<undefined>, undefined>;
115
121
  readonly due_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
@@ -212,6 +218,9 @@ export declare const taskLinks: v.UnionSchema<[v.StrictObjectSchema<{
212
218
  readonly type: v.PicklistSchema<readonly ["bug", "feature", "chore", "question", "request", "mention", "promise"], undefined>;
213
219
  readonly status: v.PicklistSchema<readonly ["open", "todo", "in_progress", "done", "dismissed"], undefined>;
214
220
  readonly priority: v.NullableSchema<v.NumberSchema<undefined>, undefined>;
221
+ readonly priority_label: v.NullableSchema<v.PicklistSchema<readonly ["low", "medium", "high", "critical"], undefined>, undefined>;
222
+ readonly agent: v.NullableSchema<v.StringSchema<undefined>, undefined>;
223
+ readonly revision: v.NumberSchema<undefined>;
215
224
  readonly parent: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
216
225
  readonly source: v.NullableSchema<v.StringSchema<undefined>, undefined>;
217
226
  readonly due_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
@@ -342,6 +351,9 @@ export declare const taskMerge: v.StrictObjectSchema<{
342
351
  readonly type: v.PicklistSchema<readonly ["bug", "feature", "chore", "question", "request", "mention", "promise"], undefined>;
343
352
  readonly status: v.PicklistSchema<readonly ["open", "todo", "in_progress", "done", "dismissed"], undefined>;
344
353
  readonly priority: v.NullableSchema<v.NumberSchema<undefined>, undefined>;
354
+ readonly priority_label: v.NullableSchema<v.PicklistSchema<readonly ["low", "medium", "high", "critical"], undefined>, undefined>;
355
+ readonly agent: v.NullableSchema<v.StringSchema<undefined>, undefined>;
356
+ readonly revision: v.NumberSchema<undefined>;
345
357
  readonly parent: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
346
358
  readonly source: v.NullableSchema<v.StringSchema<undefined>, undefined>;
347
359
  readonly due_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
@@ -437,6 +449,9 @@ export declare const taskUnmerge: v.StrictObjectSchema<{
437
449
  readonly type: v.PicklistSchema<readonly ["bug", "feature", "chore", "question", "request", "mention", "promise"], undefined>;
438
450
  readonly status: v.PicklistSchema<readonly ["open", "todo", "in_progress", "done", "dismissed"], undefined>;
439
451
  readonly priority: v.NullableSchema<v.NumberSchema<undefined>, undefined>;
452
+ readonly priority_label: v.NullableSchema<v.PicklistSchema<readonly ["low", "medium", "high", "critical"], undefined>, undefined>;
453
+ readonly agent: v.NullableSchema<v.StringSchema<undefined>, undefined>;
454
+ readonly revision: v.NumberSchema<undefined>;
440
455
  readonly parent: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>;
441
456
  readonly source: v.NullableSchema<v.StringSchema<undefined>, undefined>;
442
457
  readonly due_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
@@ -1,7 +1,7 @@
1
1
  import * as v from "valibot";
2
2
  import { TASK_TYPES } from "../domain/knowledge.js";
3
3
  import { LINK_KINDS, LINK_SOURCES } from "../store/sqlite/link-kinds.js";
4
- import { TASK_KINDS, TASK_ORIGINS, TASK_STATES, TASK_VERDICTS } from "../tasks/index.js";
4
+ import { PRIORITY_LABELS, TASK_KINDS, TASK_ORIGINS, TASK_STATES, TASK_VERDICTS } from "../tasks/index.js";
5
5
  import { count, id, reference, text, time } from "./fields.js";
6
6
  import { closeSuggestion } from "./task.js";
7
7
  import { taskPair } from "./task-pair.js";
@@ -30,6 +30,9 @@ export const taskCard = v.strictObject({
30
30
  type: v.picklist(TASK_TYPES),
31
31
  status: v.picklist(TASK_STATES),
32
32
  priority: v.nullable(v.number()),
33
+ priority_label: v.nullable(v.picklist(PRIORITY_LABELS)),
34
+ agent: v.nullable(text),
35
+ revision: v.number(),
33
36
  parent: v.nullable(reference),
34
37
  source: v.nullable(text),
35
38
  due_at: nullableTime,
@@ -24,7 +24,24 @@ export declare const task: v.StrictObjectSchema<{
24
24
  readonly chat: v.OptionalSchema<v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>, undefined>;
25
25
  /** The message it came from. */
26
26
  readonly source_message: v.OptionalSchema<v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>, undefined>;
27
+ /** 0 to 1, higher first; `priority_label` reads it as low, medium, high or critical. */
27
28
  readonly priority: v.OptionalSchema<v.NullableSchema<v.NumberSchema<undefined>, undefined>, undefined>;
29
+ readonly priority_label: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<readonly ["low", "medium", "high", "critical"], undefined>, undefined>, undefined>;
30
+ /** The agent working on it, apart from its assignees and its claim. */
31
+ readonly agent: v.OptionalSchema<v.NullableSchema<v.StringSchema<undefined>, undefined>, undefined>;
32
+ /** Give it back with `--revision` to refuse a write made on a stale copy. */
33
+ readonly revision: v.OptionalSchema<v.NumberSchema<undefined>, undefined>;
34
+ readonly assignments: v.OptionalSchema<v.ArraySchema<v.StrictObjectSchema<{
35
+ readonly to: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
36
+ readonly role: v.PicklistSchema<["assignee", "reviewer", "watcher"], undefined>;
37
+ }, undefined>, undefined>, undefined>;
38
+ /** Open work whose blocked-by tasks are all done. */
39
+ readonly ready: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
40
+ /** The blocked-by tasks not done yet: open (any open state), dismissed, or unavailable. */
41
+ readonly blocked_by: v.OptionalSchema<v.ArraySchema<v.StrictObjectSchema<{
42
+ readonly task: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
43
+ readonly state: v.PicklistSchema<["open", "dismissed", "unavailable"], undefined>;
44
+ }, undefined>, undefined>, undefined>;
28
45
  readonly parent: v.OptionalSchema<v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>, undefined>;
29
46
  readonly due_at: v.OptionalSchema<v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>, undefined>;
30
47
  readonly closed_at: v.OptionalSchema<v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>, undefined>;
@@ -1,6 +1,6 @@
1
1
  import * as v from "valibot";
2
2
  import { TASK_TYPES } from "../domain/knowledge.js";
3
- import { TASK_ORIGINS, TASK_STATES } from "../tasks/index.js";
3
+ import { PRIORITY_LABELS, TASK_ORIGINS, TASK_STATES } from "../tasks/index.js";
4
4
  import { id, reference, text, time } from "./fields.js";
5
5
  export const closeSuggestion = v.strictObject({
6
6
  as: v.picklist(["done", "dismissed"]),
@@ -27,7 +27,18 @@ export const task = v.strictObject({
27
27
  chat: v.optional(v.nullable(reference)),
28
28
  /** The message it came from. */
29
29
  source_message: v.optional(v.nullable(reference)),
30
+ /** 0 to 1, higher first; `priority_label` reads it as low, medium, high or critical. */
30
31
  priority: v.optional(v.nullable(v.number())),
32
+ priority_label: v.optional(v.nullable(v.picklist(PRIORITY_LABELS))),
33
+ /** The agent working on it, apart from its assignees and its claim. */
34
+ agent: v.optional(v.nullable(text)),
35
+ /** Give it back with `--revision` to refuse a write made on a stale copy. */
36
+ revision: v.optional(v.number()),
37
+ assignments: v.optional(v.array(v.strictObject({ to: reference, role: v.picklist(["assignee", "reviewer", "watcher"]) }))),
38
+ /** Open work whose blocked-by tasks are all done. */
39
+ ready: v.optional(v.boolean()),
40
+ /** The blocked-by tasks not done yet: open (any open state), dismissed, or unavailable. */
41
+ blocked_by: v.optional(v.array(v.strictObject({ task: reference, state: v.picklist(["open", "dismissed", "unavailable"]) }))),
31
42
  parent: v.optional(v.nullable(reference)),
32
43
  due_at: v.optional(v.nullable(time)),
33
44
  closed_at: v.optional(v.nullable(time)),
@@ -120,6 +120,7 @@ const OTHERS = [
120
120
  validation("task.category-unknown", "the category is not in the task category list"),
121
121
  validation("task.claimed", "someone else holds the task"),
122
122
  validation("task.closed", "the task is already closed"),
123
+ ["task.stale", "configuration_error", "the task changed since it was read; read it again"],
123
124
  validation("task.dependency-cycle", "the task would depend on itself"),
124
125
  validation("task.duplicate-candidates", "open tasks may be the same job: attach to one, or add it as new"),
125
126
  validation("task.certainty-required", "an agent closes a task with its certainty, 0 to 1"),
@@ -117,6 +117,8 @@ export const endpointTemplate = (raw) => {
117
117
  /** A phone number as people type it with its country code, and one written as digits only without the "+". */
118
118
  export const INTERNATIONAL_PHONE = /\+\d[\d\s()-]{6,}\d/.source;
119
119
  export const BARE_PHONE = /(^|[^\w-])\d{11,15}\b/.source;
120
+ /** A WhatsApp jid: `<number>[:device]@s.whatsapp.net`, a group's `<number>-<time>@g.us`, an `@lid` of any length. */
121
+ const MESSENGER_ID = /\b\d+(?:-\d+)?(?::\d+)?@(?:s\.whatsapp\.net|g\.us|c\.us|lid|broadcast|newsletter)\b/g;
120
122
  /**
121
123
  * An error's sentence with what could be somebody's words or a secret taken out: anything in quotes, an email, a
122
124
  * phone number, a secret `withoutSecrets` finds, a bot token, a bearer or a long opaque string, a URL's query, the
@@ -128,6 +130,7 @@ export const redactText = (text) => {
128
130
  let out = home && home !== "/" ? text.split(home).join("~") : text;
129
131
  out = withoutSecrets(out.replace(/"[^"\n]*"|“[^”\n]*”|«[^»\n]*»/g, '"[redacted]"'))
130
132
  .replace(/\b(bearer|basic|token|key|password|secret)(\s*[:=]\s*|\s+)[^\s'"]+/gi, "$1$2[redacted]")
133
+ .replace(MESSENGER_ID, "[id]")
131
134
  .replace(/[\w.+-]+@[\w-]+\.[\w.-]+/g, "[email]")
132
135
  .replace(new RegExp(INTERNATIONAL_PHONE, "g"), "[number]")
133
136
  .replace(new RegExp(BARE_PHONE, "g"), "$1[number]")
@@ -5,7 +5,7 @@ export type { TagRedirectIndex, TagRedirectResolution, TagRedirectSnapshot } fro
5
5
  export { createTagRedirectIndex } from "../domain/tag-redirects.js";
6
6
  export { note } from "../entities/note.js";
7
7
  export type { FindingEvidence, FindingOutcome, MeetingFactFinding, MeetingFinding, MeetingFindingsInput, MeetingFindingsReceipt, MeetingTaskFinding, } from "../meetings/index.js";
8
- export { CLOSED_STATES, isClosedState, TASK_STATES } from "../tasks/index.js";
8
+ export { CLOSED_STATES, isClosedState, PRIORITY_LABELS, type PriorityLabel, priorityLabel, priorityOf, TASK_STATES, type TaskState, } from "../tasks/index.js";
9
9
  export type { AccountMail, AccountMeetings, AccountStore } from "./account-store.js";
10
10
  export type { DocumentTransferOptions, DocumentTransferPreview, DocumentTransferReceipt, } from "./document-transfer/index.js";
11
11
  export { applyDocumentTransfer, previewDocumentTransfer } from "./document-transfer/index.js";
@@ -65,6 +65,6 @@ export type { TaskRelation, TaskRelationKind, TaskRelationsStore } from "./sqlit
65
65
  export { TASK_RELATION_KINDS } from "./sqlite/task-relations.js";
66
66
  export type { StoreTaskStore } from "./sqlite/tasks.js";
67
67
  export type { TagPreflight, VocabularyAlias, VocabularyAliasReceipt, VocabularyAudit, VocabularyAuditOptions, VocabularyCatalog, VocabularyEntry, VocabularyKind, VocabularyOptions, VocabularyResolution, VocabularyStore, VocabularyTarget, } from "./sqlite/vocabulary.js";
68
- export type { BoardCounts, BoardOptions, NewWorkTask, TaskAssignment, TaskDetails, TaskEvent, TaskFields, TaskRole, TaskType, WorkTaskType, } from "./sqlite/work-tasks.js";
68
+ export type { BoardCounts, BoardOptions, NewWorkTask, RevisionCheck, TaskAssignment, TaskDetails, TaskEvent, TaskFields, TaskMove, TaskMoved, TaskRole, TaskType, WorkTaskType, } from "./sqlite/work-tasks.js";
69
69
  export { TASK_EVENT_KINDS, TASK_ROLES } from "./sqlite/work-tasks.js";
70
70
  export { type AccountAccounts, type AccountAttachments, type AccountCounters, type AccountKey, type AccountMentions, type AccountMessageFilter, type AccountMessages, type AccountPeople, type AccountsStore, type AccountVectors, type AttachmentsStore, type AttachmentTextEntry, type AttachmentView, type ChatStats, type ChatsStore, type ConversationsStore, type CountersStore, type DeletionScope, type Delta, type FileAttachment, type IdentityRef, type IngestedVia, type LinkedIdentity, type LinkOptions, MENTION_QUERY, MENTION_STATUSES, type MentionsStore, type MessageFilter, type MessageStore, type MessagesStore, OTHER_SOURCES, openStore, type PeopleStore, type PersonFacts, type PersonRecord, type QueriesStore, type Range, type RankedEvidence, type RankedStoreFound, type RankedStoreRow, type RankingEvidenceItem, type RankingEvidenceRequest, type RankingRequest, type RosterStore, type SearchCommand, type SearchesStore, type SearchIndexStore, type SearchRecord, type StoredAccount, type StoredChatFilter, type StoredHit, type StoredSearch, type StoredTag, type StoreOptions, type SyncStore, type TagFilter, type TagsStore, type TagTarget, type TaskTagTarget, type TextOrigin, type VectorsStore, } from "./store.js";
@@ -1,7 +1,7 @@
1
1
  export { FACT_STATUSES, FACT_TYPES, MEMORY_KINDS, MEMORY_SCOPES, MEMORY_STATUSES, TASK_TYPES, WORK_TASK_TYPES, } from "../domain/knowledge.js";
2
2
  export { createTagRedirectIndex } from "../domain/tag-redirects.js";
3
3
  export { note } from "../entities/note.js";
4
- export { CLOSED_STATES, isClosedState, TASK_STATES } from "../tasks/index.js";
4
+ export { CLOSED_STATES, isClosedState, PRIORITY_LABELS, priorityLabel, priorityOf, TASK_STATES, } from "../tasks/index.js";
5
5
  export { applyDocumentTransfer, previewDocumentTransfer } from "./document-transfer/index.js";
6
6
  export { PRAGMAS } from "./driver.js";
7
7
  export { EVIDENCE_FINDINGS_KIND, validateEvidenceFindings, } from "./evidence-findings.js";
@@ -54,6 +54,7 @@ export const MANIFEST = [
54
54
  { name: "20261029224107_version-38-folded-triggers", version: 38, minCompatible: 34 },
55
55
  { name: "20261030114008_version-39-email-locations", version: 39, minCompatible: 34 },
56
56
  { name: "20261030225520_version-40-note-trigrams", version: 40, minCompatible: 34 },
57
+ { name: "20261031230945_version-41-task-fields", version: 41, minCompatible: 34 },
57
58
  ];
58
59
  export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
59
60
  const migrations = [];
@@ -847,5 +847,17 @@ export const GENERATED = [
847
847
  "CREATE TRIGGER document_trigrams_bd BEFORE DELETE ON documents BEGIN\n DELETE FROM document_trigrams WHERE rowid = old.id;\nEND;",
848
848
  "-- The trigrams hold JS-normalized text, which SQL cannot produce: fillTrigrams fills the rows stored before this\n-- version, up to `watermark`, and a search reads the rest of them by their text meanwhile.\nINSERT INTO search_index_state (name, watermark, filled_through, terms_through, normalizer_version)\n SELECT 'note_trigrams', coalesce(max(id), 0), 0, 0, 1 FROM notes\n UNION ALL SELECT 'document_trigrams', coalesce(max(id), 0), 0, 0, 1 FROM documents;"
849
849
  ]
850
+ },
851
+ {
852
+ "name": "20261031230945_version-41-task-fields",
853
+ "statements": [
854
+ "ALTER TABLE `tasks` ADD `priority_score` real;",
855
+ "ALTER TABLE `tasks` ADD `agent` text;",
856
+ "ALTER TABLE `tasks` ADD `revision` integer DEFAULT 1 NOT NULL;",
857
+ "-- 0 urgent … 4 low land inside the critical, high, medium and low quarters, with room on either side for moves.\nUPDATE tasks SET priority_score = CASE priority WHEN 0 THEN 0.875 WHEN 1 THEN 0.625 WHEN 2 THEN 0.375 WHEN 3 THEN 0.1875 WHEN 4 THEN 0.0625 END WHERE priority IS NOT NULL;",
858
+ "-- A build before version 41 writes only `priority`; these keep the score in step with what it wrote.\nCREATE TRIGGER tasks_priority_score_ai AFTER INSERT ON tasks WHEN new.priority IS NOT NULL AND new.priority_score IS NULL BEGIN\n UPDATE tasks SET priority_score = CASE new.priority WHEN 0 THEN 0.875 WHEN 1 THEN 0.625 WHEN 2 THEN 0.375 WHEN 3 THEN 0.1875 WHEN 4 THEN 0.0625 END WHERE id = new.id;\nEND;",
859
+ "CREATE TRIGGER tasks_priority_score_au AFTER UPDATE OF priority ON tasks WHEN new.priority IS NOT old.priority AND new.priority_score IS old.priority_score BEGIN\n UPDATE tasks SET priority_score = CASE new.priority WHEN 0 THEN 0.875 WHEN 1 THEN 0.625 WHEN 2 THEN 0.375 WHEN 3 THEN 0.1875 WHEN 4 THEN 0.0625 END WHERE id = new.id;\nEND;",
860
+ "-- Every build's write bumps it, older ones too, so a stale copy is caught whoever wrote last.\nCREATE TRIGGER tasks_revision_au AFTER UPDATE ON tasks WHEN new.revision IS old.revision BEGIN\n UPDATE tasks SET revision = old.revision + 1 WHERE id = new.id;\nEND;"
861
+ ]
850
862
  }
851
863
  ];
@@ -12683,6 +12683,54 @@ export declare const tasks: import("drizzle-orm/sqlite-core").SQLiteTableWithCol
12683
12683
  identity: undefined;
12684
12684
  generated: undefined;
12685
12685
  }, {}>;
12686
+ priorityScore: import("drizzle-orm/sqlite-core").SQLiteColumn<{
12687
+ name: string;
12688
+ tableName: "tasks";
12689
+ dataType: "number double";
12690
+ data: number;
12691
+ driverParam: number;
12692
+ notNull: false;
12693
+ hasDefault: false;
12694
+ isPrimaryKey: false;
12695
+ isAutoincrement: false;
12696
+ hasRuntimeDefault: false;
12697
+ enumValues: undefined;
12698
+ baseColumn: never;
12699
+ identity: undefined;
12700
+ generated: undefined;
12701
+ }, {}>;
12702
+ agent: import("drizzle-orm/sqlite-core").SQLiteColumn<{
12703
+ name: string;
12704
+ tableName: "tasks";
12705
+ dataType: "string";
12706
+ data: string;
12707
+ driverParam: string;
12708
+ notNull: false;
12709
+ hasDefault: false;
12710
+ isPrimaryKey: false;
12711
+ isAutoincrement: false;
12712
+ hasRuntimeDefault: false;
12713
+ enumValues: [string, ...string[]];
12714
+ baseColumn: never;
12715
+ identity: undefined;
12716
+ generated: undefined;
12717
+ }, {}>;
12718
+ revision: import("drizzle-orm/sqlite-core").SQLiteColumn<{
12719
+ name: string;
12720
+ tableName: "tasks";
12721
+ dataType: "number int53";
12722
+ data: number;
12723
+ driverParam: number;
12724
+ notNull: true;
12725
+ hasDefault: true;
12726
+ isPrimaryKey: false;
12727
+ isAutoincrement: false;
12728
+ hasRuntimeDefault: false;
12729
+ enumValues: undefined;
12730
+ baseColumn: never;
12731
+ identity: undefined;
12732
+ generated: undefined;
12733
+ }, {}>;
12686
12734
  };
12687
12735
  dialect: 'sqlite';
12688
12736
  isAlias: false;
@@ -1266,6 +1266,9 @@ export const tasks = sqliteTable("tasks", {
1266
1266
  closeSuggestedCertainty: real("close_suggested_certainty"),
1267
1267
  closeSuggestedReason: text("close_suggested_reason"),
1268
1268
  closeSuggestedAt: integer("close_suggested_at"),
1269
+ priorityScore: real("priority_score"),
1270
+ agent: text("agent"),
1271
+ revision: integer("revision").notNull().default(1),
1269
1272
  }, (table) => [
1270
1273
  unique().on(table.projectId, table.number),
1271
1274
  index("tasks_by_title_folded").on(table.titleFolded),
@@ -73,10 +73,9 @@ export declare const canonicalTagNamed: (database: CacheDatabase, name: string)
73
73
  export declare const createTag: (context: StoreContext, name: string, kind: TagKind, by: "owner" | "agent", options?: TagCreationOptions) => TagRow;
74
74
  /** The tag of that name, made as a plain tag when no tag or topic holds the name yet. */
75
75
  export declare const ensureTag: (context: StoreContext, name: string) => TagRow;
76
- /** The tags it did not have before, in the order given; a tag the owner adds becomes the owner's. */
77
76
  export declare const addTags: (context: StoreContext, thing: Thing, tags: string[], source?: TaggingSource, author?: Actor) => string[];
78
77
  /** An explicit removal withdraws the effective label, restricted to the requested origin when supplied. */
79
- export declare const removeTags: ({ database }: StoreContext, thing: Thing, tags: string[], source?: "manual" | "auto") => string[];
78
+ export declare const removeTags: (context: StoreContext, thing: Thing, tags: string[], source?: "manual" | "auto") => string[];
80
79
  export interface Label {
81
80
  tag: string;
82
81
  /** `file` when only a note's file states it; the owner's own label otherwise. */
@@ -111,13 +111,23 @@ export const ensureTag = (context, name) => {
111
111
  return canonicalTagNamed(context.database, name) ?? createTag(context, name, "tag", "owner");
112
112
  };
113
113
  /** The tags it did not have before, in the order given; a tag the owner adds becomes the owner's. */
114
+ /** A task's tag change is part of its history, and of its revision. */
115
+ const taskTagsChanged = (context, thing, changes, author) => {
116
+ if (thing.type !== "task" || [...(changes.added ?? []), ...(changes.removed ?? [])].length === 0)
117
+ return;
118
+ const at = context.now();
119
+ context.database.prepare("UPDATE tasks SET updated_at = ? WHERE id = ?").run(at, thing.id);
120
+ context.database
121
+ .prepare("INSERT INTO task_events (task_id, actor_type, actor_id, kind, changes, created_at) VALUES (?, ?, ?, 'tags', ?, ?)")
122
+ .run(thing.id, author?.type ?? null, author?.id ?? null, JSON.stringify({ tags: [changes.removed ?? [], changes.added ?? []] }), at);
123
+ };
114
124
  export const addTags = (context, thing, tags, source = "owner", author) => {
115
125
  const { database, now } = context;
116
126
  const insert = database.prepare("INSERT INTO taggings (tag_id, taggable_type, taggable_id, main, source, author_type, author_id, created_at, updated_at) " +
117
127
  "VALUES (?, ?, ?, 0, ?, ?, ?, ?, ?) ON CONFLICT (tag_id, taggable_type, taggable_id) DO NOTHING");
118
128
  const claim = database.prepare("UPDATE taggings SET source = 'owner', updated_at = ? WHERE tag_id = ? AND taggable_type = ? AND taggable_id = ? AND source <> 'owner'");
119
129
  const at = now();
120
- return tags.filter((name) => {
130
+ const fresh = tags.filter((name) => {
121
131
  const tag = ensureTag(context, name);
122
132
  const held = database
123
133
  .prepare("SELECT source,author_type,author_id FROM taggings WHERE tag_id IN (SELECT ? UNION ALL SELECT source_id FROM tag_redirects WHERE survivor_id=?) AND taggable_type=? AND taggable_id=?")
@@ -129,11 +139,14 @@ export const addTags = (context, thing, tags, source = "owner", author) => {
129
139
  claim.run(at, tag.id, thing.type, thing.id);
130
140
  return added && held.length === 0;
131
141
  });
142
+ taskTagsChanged(context, thing, { added: fresh }, author);
143
+ return fresh;
132
144
  };
133
145
  /** An explicit removal withdraws the effective label, restricted to the requested origin when supplied. */
134
- export const removeTags = ({ database }, thing, tags, source) => {
146
+ export const removeTags = (context, thing, tags, source) => {
147
+ const { database } = context;
135
148
  const remove = database.prepare("DELETE FROM taggings WHERE tag_id=? AND taggable_type=? AND taggable_id=?");
136
- return tags.filter((name) => {
149
+ const removed = tags.filter((name) => {
137
150
  const tag = canonicalTagNamed(database, name);
138
151
  if (!tag)
139
152
  return false;
@@ -171,6 +184,8 @@ export const removeTags = ({ database }, thing, tags, source) => {
171
184
  }
172
185
  return changed;
173
186
  });
187
+ taskTagsChanged(context, thing, { removed });
188
+ return removed;
174
189
  };
175
190
  /** A thing's tags by name, with whether only a file states each. */
176
191
  /** A tagging of something still there: a deleted document keeps its tags but is not counted or listed by them. */
@@ -2,7 +2,7 @@ import { CliError } from "@wirecat/cli-base";
2
2
  import { OPERATORS_BY_TYPE, walkQuery, } from "@wirecat/cli-base/query";
3
3
  import { TASK_TYPES } from "../../domain/knowledge.js";
4
4
  import { taskWordsOf } from "../../search/task-similarity.js";
5
- import { TASK_STATES } from "../../tasks/index.js";
5
+ import { PRIORITY_LABELS, priorityBand, TASK_STATES } from "../../tasks/index.js";
6
6
  import { INBOX_KEY_LENGTH } from "./task-keys.js";
7
7
  export const TASK_SORTS = ["priority", "due", "created", "updated", "key", "state"];
8
8
  const field = (name, type, example, more = {}) => ({
@@ -22,7 +22,11 @@ export const PROJECT_TASK_FIELDS = [
22
22
  field("assignee", "kw", 'assignee:"person:12"', {
23
23
  relation: "who does it: any, none, or a person (person:12 in quotes, or the bare 12)",
24
24
  }),
25
- field("priority", "num", "priority:[0 TO 1]", { relation: "0 urgent … 4 low; NOT priority:* has none" }),
25
+ field("priority", "kw", "priority:high", {
26
+ operators: ["term", "range", "wildcard"],
27
+ relation: "low, medium, high, critical or none; a range of labels or of the 0–1 number: priority:[high TO critical]",
28
+ }),
29
+ field("agent", "kw", "agent:hermes", { relation: "the agent working on it, by name; none or any" }),
26
30
  field("due", "time", "due:[today TO 2026-12-31]", { relation: "the due date; NOT due:* has none" }),
27
31
  field("overdue", "bool", "overdue:true", { relation: "open work whose due date has passed" }),
28
32
  field("closed", "time", "closed:>=14d", { relation: "when it was done or dismissed" }),
@@ -69,6 +73,60 @@ const assigneeWhere = (node) => {
69
73
  throw new CliError("validation_error", `assignee: takes any, none or "person:<id>", not "${node.value}"`);
70
74
  return { sql: `EXISTS (${ASSIGNED} AND a.assignee_id = ?)`, params: [Number(id)] };
71
75
  };
76
+ const bound = (value, side, node) => {
77
+ const word = value.trim().toLowerCase();
78
+ if (word === "*")
79
+ return undefined;
80
+ if (PRIORITY_LABELS.includes(word))
81
+ return priorityBand(word)[side];
82
+ const number = Number(word);
83
+ if (word === "" || !Number.isFinite(number) || number < 0 || number > 1)
84
+ throw new CliError("validation_error", `priority: takes ${PRIORITY_LABELS.join(", ")}, none, or 0 to 1, not "${node.value}"`);
85
+ return number;
86
+ };
87
+ /** A label is its quarter; critical takes 1 itself. */
88
+ const priorityWhere = (node) => {
89
+ const score = "t.priority_score";
90
+ if (node.operator === "wildcard") {
91
+ if (node.value === "*")
92
+ return { sql: `${score} IS NOT NULL`, params: [] };
93
+ throw new CliError("validation_error", "priority: takes a label, none, or a range");
94
+ }
95
+ if (node.operator !== "range") {
96
+ const word = node.value.trim().toLowerCase();
97
+ if (word === "none")
98
+ return { sql: `${score} IS NULL`, params: [] };
99
+ if (!PRIORITY_LABELS.includes(word))
100
+ throw new CliError("validation_error", `priority: takes ${PRIORITY_LABELS.join(", ")} or none; a number goes in a range`);
101
+ const { from, to } = priorityBand(word);
102
+ return { sql: `${score} >= ? AND ${score} ${to === 1 ? "<=" : "<"} ?`, params: [from, to] };
103
+ }
104
+ const lower = bound(node.value, "from", node);
105
+ const upperWord = (node.upper ?? "*").trim().toLowerCase();
106
+ const upper = bound(upperWord, "to", node);
107
+ const labelUpper = PRIORITY_LABELS.includes(upperWord);
108
+ const parts = [`${score} IS NOT NULL`];
109
+ const params = [];
110
+ if (lower !== undefined) {
111
+ parts.push(`${score} ${node.lower_inclusive === false ? ">" : ">="} ?`);
112
+ params.push(lower);
113
+ }
114
+ if (upper !== undefined) {
115
+ parts.push(`${score} ${labelUpper ? (upper === 1 ? "<=" : "<") : node.upper_inclusive === false ? "<" : "<="} ?`);
116
+ params.push(upper);
117
+ }
118
+ return { sql: parts.join(" AND "), params };
119
+ };
120
+ const agentWhere = (node) => {
121
+ const value = node.value.trim().toLowerCase();
122
+ if (value === "none")
123
+ return { sql: "t.agent IS NULL", params: [] };
124
+ if (value === "any" || (node.operator === "wildcard" && value === "*"))
125
+ return { sql: "t.agent IS NOT NULL", params: [] };
126
+ return node.operator === "wildcard"
127
+ ? { sql: "lower(t.agent) GLOB ?", params: [value] }
128
+ : { sql: "lower(t.agent) = ?", params: [value] };
129
+ };
72
130
  const truth = (node, sql) => ({
73
131
  sql: node.value.toLowerCase() === "true" ? sql : `NOT (${sql})`,
74
132
  params: [],
@@ -79,7 +137,8 @@ export const projectTaskColumns = (now) => ({
79
137
  kind: { type: "enum", sql: "t.type" },
80
138
  project: { where: projectWhere },
81
139
  assignee: { where: assigneeWhere },
82
- priority: { type: "num", sql: "t.priority" },
140
+ priority: { where: priorityWhere },
141
+ agent: { where: agentWhere },
83
142
  due: { type: "time", sql: "t.due_at" },
84
143
  closed: { type: "time", sql: "t.closed_at" },
85
144
  overdue: { where: (node) => truth(node, `${OPEN} AND t.due_at IS NOT NULL AND t.due_at < ${Number(now)}`) },
@@ -104,14 +163,14 @@ const STATE_ORDER = `CASE t.status ${TASK_STATES.map((state, at) => `WHEN '${sta
104
163
  export const boardOrder = (sort, descending) => {
105
164
  const direction = descending ? "DESC" : "ASC";
106
165
  const first = {
107
- priority: [`coalesce(t.priority, 5) ${direction}`],
166
+ priority: ["t.priority_score IS NULL", `t.priority_score ${descending ? "ASC" : "DESC"}`],
108
167
  due: ["t.due_at IS NULL", `t.due_at ${direction}`],
109
168
  created: [`t.created_at ${direction}`],
110
169
  updated: [`t.updated_at ${direction}`],
111
170
  key: [`(SELECT key FROM projects WHERE id = t.project_id) ${direction}`, `t.number ${direction}`],
112
171
  state: [`${STATE_ORDER} ${direction}`],
113
172
  }[sort];
114
- return [...first, "coalesce(t.priority, 5)", "t.created_at", "t.id"].join(", ");
173
+ return [...first, "t.priority_score IS NULL", "t.priority_score DESC", "t.created_at", "t.id"].join(", ");
115
174
  };
116
175
  /** The query without its required terms on `name`: a picker counts the choices its own filter hides. */
117
176
  export const withoutField = (query, name) => {
@@ -52,9 +52,10 @@ export const taskCloseOver = (context) => {
52
52
  }),
53
53
  recordCloseCertainty: async (id, certainty) => write(database, () => {
54
54
  const row = liveRow(id);
55
- database
56
- .prepare("UPDATE tasks SET close_certainty = ? WHERE id = ?")
57
- .run(certaintyOf(certainty), Number(row.id));
55
+ const value = certaintyOf(certainty);
56
+ database.prepare("UPDATE tasks SET close_certainty = ? WHERE id = ?").run(value, Number(row.id));
57
+ if ((row.close_certainty ?? null) !== value)
58
+ recordTaskEvent(database, Number(row.id), null, "close-certainty", { close_certainty: [row.close_certainty ?? null, value] }, now());
58
59
  }),
59
60
  };
60
61
  };
@@ -155,7 +155,7 @@ export const taskRelationsStoreOver = (context, record) => {
155
155
  if (!row)
156
156
  throw new CliError("not_found", "the owner project is unavailable");
157
157
  const rows = database
158
- .prepare("SELECT coalesce(t.package_id,t.key) AS id,t.key,substr(t.title,1,300) AS title FROM tasks t WHERE t.project_id=? AND t.status NOT IN ('done','dismissed') AND t.deleted_at IS NULL AND NOT EXISTS (SELECT 1 FROM links l LEFT JOIN tasks b ON b.id=l.to_id LEFT JOIN projects p ON p.id=b.project_id WHERE l.from_type='task' AND l.to_type='task' AND l.kind='blocked-by' AND l.from_id=t.id AND l.confirmed=1 AND l.anchor IS NULL AND (b.id IS NULL OR b.deleted_at IS NOT NULL OR p.deleted_at IS NOT NULL OR b.status!='done')) ORDER BY coalesce(t.priority,5),t.number,t.id LIMIT ?")
158
+ .prepare("SELECT coalesce(t.package_id,t.key) AS id,t.key,substr(t.title,1,300) AS title FROM tasks t WHERE t.project_id=? AND t.status NOT IN ('done','dismissed') AND t.deleted_at IS NULL AND NOT EXISTS (SELECT 1 FROM links l LEFT JOIN tasks b ON b.id=l.to_id LEFT JOIN projects p ON p.id=b.project_id WHERE l.from_type='task' AND l.to_type='task' AND l.kind='blocked-by' AND l.from_id=t.id AND l.confirmed=1 AND l.anchor IS NULL AND (b.id IS NULL OR b.deleted_at IS NOT NULL OR p.deleted_at IS NOT NULL OR b.status!='done')) ORDER BY t.priority_score IS NULL,t.priority_score DESC,t.number,t.id LIMIT ?")
159
159
  .all(Number(row.id), limit + 1);
160
160
  return {
161
161
  items: rows
@@ -197,7 +197,10 @@ export const taskStoreOver = (context) => {
197
197
  const row = required(id);
198
198
  const at = now();
199
199
  write(database, () => {
200
+ const before = database.prepare("SELECT resolution FROM tasks WHERE id = ?").get(row)?.resolution ?? null;
200
201
  database.prepare("UPDATE tasks SET resolution = ?, updated_at = ? WHERE id = ?").run(text, at, row);
202
+ if (before !== text)
203
+ recordTaskEvent(database, row, null, "resolution", { resolution: [before, text] }, at);
201
204
  if (!by)
202
205
  return;
203
206
  database
@@ -214,7 +217,14 @@ export const taskStoreOver = (context) => {
214
217
  judge: async (id, verdict) => {
215
218
  if (verdict !== null && !TASK_VERDICTS.includes(verdict))
216
219
  throw new CliError("validation_error", `a verdict is one of ${TASK_VERDICTS.join(", ")}`);
217
- database.prepare("UPDATE tasks SET verdict = ?, updated_at = ? WHERE id = ?").run(verdict, now(), required(id));
220
+ const row = required(id);
221
+ write(database, () => {
222
+ const at = now();
223
+ const before = database.prepare("SELECT verdict FROM tasks WHERE id = ?").get(row)?.verdict ?? null;
224
+ database.prepare("UPDATE tasks SET verdict = ?, updated_at = ? WHERE id = ?").run(verdict, at, row);
225
+ if (before !== verdict)
226
+ recordTaskEvent(database, row, null, "verdict", { verdict: [before, verdict] }, at);
227
+ });
218
228
  },
219
229
  };
220
230
  };
@@ -1,6 +1,6 @@
1
1
  import type { QueryAst } from "@wirecat/cli-base/query";
2
2
  import { TASK_TYPES, WORK_TASK_TYPES } from "../../domain/knowledge.js";
3
- import { type TaskState, type TaskVerdict } from "../../tasks/index.js";
3
+ import { type PriorityLabel, type TaskState, type TaskVerdict } from "../../tasks/index.js";
4
4
  import type { CacheDatabase } from "../driver.js";
5
5
  import type { Actor } from "./actors.js";
6
6
  import { type Author } from "./memories.js";
@@ -10,15 +10,17 @@ import { type TaskSort } from "./task-board.js";
10
10
  import { type CloseSuggestion } from "./task-close.js";
11
11
  import { type TaskRelationsStore } from "./task-relations.js";
12
12
  export declare const TASK_ROLES: readonly ["assignee", "reviewer", "watcher"];
13
- export declare const TASK_EVENT_KINDS: readonly ["created", "title", "description", "status", "assigned", "unassigned", "due", "priority", "moved", "linked", "unlinked", "category", "close-suggestion"];
13
+ export declare const TASK_EVENT_KINDS: readonly ["created", "title", "description", "status", "assigned", "unassigned", "due", "priority", "moved", "linked", "unlinked", "category", "close-suggestion", "agent", "claimed", "released", "next-check", "resolution", "verdict", "close-certainty", "tags"];
14
14
  export type WorkTaskType = (typeof WORK_TASK_TYPES)[number];
15
15
  export type TaskType = (typeof TASK_TYPES)[number];
16
16
  export type TaskRole = (typeof TASK_ROLES)[number];
17
17
  export interface TaskFields {
18
18
  title?: string;
19
19
  description?: string | null;
20
- /** 0 urgent … 4 low. */
20
+ /** 0 to 1, higher first: `priorityOf` reads a label or the old 0–4. */
21
21
  priority?: number | null;
22
+ /** The agent working on it, by name; `null` clears it. */
23
+ agent?: string | null;
22
24
  /** A task reference (`task:<id>`) or id in the same project; `null` makes it top-level. */
23
25
  parent?: string | null;
24
26
  dueAt?: Date | null;
@@ -50,7 +52,12 @@ export interface TaskDetails {
50
52
  description: string | null;
51
53
  type: TaskType;
52
54
  status: TaskState;
55
+ /** 0 to 1, higher first. */
53
56
  priority: number | null;
57
+ priorityLabel: PriorityLabel | null;
58
+ agent: string | null;
59
+ /** Bumped by every write; give it back to refuse a write made on a stale copy. */
60
+ revision: number;
54
61
  parent: string | null;
55
62
  /** What a task found in a message points at; null for a task made here. */
56
63
  source: string | null;
@@ -71,6 +78,26 @@ export interface TaskDetails {
71
78
  createdAt: string;
72
79
  updatedAt: string;
73
80
  }
81
+ export interface RevisionCheck {
82
+ /** The revision the caller read; a write on any other is refused (`task.stale`). */
83
+ revision?: number;
84
+ }
85
+ export interface TaskMove {
86
+ status?: TaskState;
87
+ before?: string;
88
+ after?: string;
89
+ }
90
+ export interface TaskMoved {
91
+ task: TaskDetails;
92
+ /** The labels before and after, which differ when the move crossed a band edge. */
93
+ label: {
94
+ from: PriorityLabel | null;
95
+ to: PriorityLabel | null;
96
+ changed: boolean;
97
+ };
98
+ /** Other tasks of the column given a new priority to make room, by key. */
99
+ renumbered: string[];
100
+ }
74
101
  export interface BoardOptions {
75
102
  limit: number;
76
103
  offset?: number;
@@ -93,8 +120,9 @@ export interface BoardCounts {
93
120
  name: string | null;
94
121
  count: number;
95
122
  }[];
123
+ /** By label; `none` for the tasks with no priority. */
96
124
  priority: {
97
- value: number | null;
125
+ value: PriorityLabel | "none";
98
126
  count: number;
99
127
  }[];
100
128
  state: {
@@ -114,9 +142,15 @@ export interface TaskEvent {
114
142
  /** Tasks beyond the package's four message kinds, and the fields the package does not know, on any task. */
115
143
  export interface WorkTasks extends TaskRelationsStore {
116
144
  create(input: NewWorkTask, by: Author): Promise<TaskDetails>;
117
- edit(id: string, fields: TaskFields, by: Author): Promise<TaskDetails>;
118
- assign(id: string, assignment: TaskAssignment, by: Author): Promise<TaskDetails>;
119
- unassign(id: string, assignment: TaskAssignment, by: Author): Promise<TaskDetails>;
145
+ edit(id: string, fields: TaskFields, by: Author, check?: RevisionCheck): Promise<TaskDetails>;
146
+ assign(id: string, assignment: TaskAssignment, by: Author, check?: RevisionCheck): Promise<TaskDetails>;
147
+ unassign(id: string, assignment: TaskAssignment, by: Author, check?: RevisionCheck): Promise<TaskDetails>;
148
+ /**
149
+ * Puts a project task just before or after another in a board column, by giving it a priority between its new
150
+ * neighbours; `status` moves it to that column in the same write. When the neighbours leave no room, that
151
+ * priority band of the column is spread out again, and each task given a new number is named.
152
+ */
153
+ move(id: string, move: TaskMove, by: Author, check?: RevisionCheck): Promise<TaskMoved>;
120
154
  /** `person:<id>` of whoever sent the task's source message; `undefined` for the owner, a note or an unknown sender. */
121
155
  sourceSender(id: string): Promise<string | undefined>;
122
156
  details(id: string): Promise<TaskDetails>;
@@ -159,10 +193,13 @@ export interface WorkTasks extends TaskRelationsStore {
159
193
  /** Gives a task back; only its holder or the owner may. */
160
194
  release(id: string, holder: string): Promise<TaskDetails>;
161
195
  }
196
+ type Row = Record<string, unknown>;
162
197
  export declare const recordTaskEvent: (database: CacheDatabase, task: number, actor: Actor | null, kind: TaskEvent["kind"], changes: TaskEvent["changes"], at: number) => void;
198
+ /** What a write made on a stale copy is refused with: the revision the caller read is not the task's now. */
199
+ export declare const checkRevision: (row: Row, expected: number | undefined) => void;
163
200
  type WorkTaskOperations = Omit<WorkTasks, "create" | "assign"> & {
164
201
  create(input: NewWorkTask, by: Author): TaskDetails;
165
- assign(id: string, assignment: TaskAssignment, by: Author): TaskDetails;
202
+ assign(id: string, assignment: TaskAssignment, by: Author, check?: RevisionCheck): TaskDetails;
166
203
  };
167
204
  export declare const workTaskOperationsOver: (context: StoreContext) => WorkTaskOperations;
168
205
  export declare const workTasksOver: (context: StoreContext) => WorkTasks;
@@ -1,6 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { TASK_TYPES, WORK_TASK_TYPES } from "../../domain/knowledge.js";
3
- import { isClosedState, 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
6
  import { CORPORA, drainCorpus } from "./note-index.js";
@@ -28,6 +28,14 @@ export const TASK_EVENT_KINDS = [
28
28
  "unlinked",
29
29
  "category",
30
30
  "close-suggestion",
31
+ "agent",
32
+ "claimed",
33
+ "released",
34
+ "next-check",
35
+ "resolution",
36
+ "verdict",
37
+ "close-certainty",
38
+ "tags",
31
39
  ];
32
40
  const iso = (value) => (value == null ? null : new Date(Number(value)).toISOString());
33
41
  export const recordTaskEvent = (database, task, actor, kind, changes, at) => {
@@ -45,10 +53,11 @@ const textOf = (value, what, max) => {
45
53
  const claimOf = (row, at) => row.claimed_by != null && Number(row.claimed_until) >= at
46
54
  ? { claimedBy: String(row.claimed_by), claimedUntil: iso(row.claimed_until) }
47
55
  : { claimedBy: null, claimedUntil: null };
48
- const priorityOf = (value) => {
49
- if (value !== null && !(Number.isInteger(value) && value >= 0 && value <= 4))
50
- throw new CliError("validation_error", "a priority is 0 (urgent) to 4 (low)");
51
- 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) });
52
61
  };
53
62
  export const workTaskOperationsOver = (context) => {
54
63
  const { database, now } = context;
@@ -104,7 +113,10 @@ export const workTaskOperationsOver = (context) => {
104
113
  description: row.description == null ? null : String(row.description),
105
114
  type: row.type,
106
115
  status: row.status,
107
- 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),
108
120
  parent: row.parent_id == null ? null : reference(Number(row.parent_id)),
109
121
  source: row.source_locator == null ? null : String(row.source_locator),
110
122
  dueAt: iso(row.due_at),
@@ -125,6 +137,7 @@ export const workTaskOperationsOver = (context) => {
125
137
  };
126
138
  };
127
139
  const stemmerFor = stemmerCache();
140
+ const touch = (task, at) => database.prepare("UPDATE tasks SET updated_at = ? WHERE id = ?").run(at, task);
128
141
  const boardWhere = (query, zone, at) => {
129
142
  if (query === undefined)
130
143
  return { sql: BOARD_SCOPE, params: [] };
@@ -143,13 +156,13 @@ export const workTaskOperationsOver = (context) => {
143
156
  const at = now();
144
157
  const set = [];
145
158
  const values = [];
146
- const change = (column, value, event) => {
159
+ const change = (column, value, event, key = column) => {
147
160
  if ((before[column] ?? null) === value)
148
161
  return;
149
162
  set.push(`${column} = ?`);
150
163
  values.push(value);
151
164
  if (event)
152
- recordTaskEvent(database, task, actor, event, { [column]: [before[column] ?? null, value] }, at);
165
+ recordTaskEvent(database, task, actor, event, { [key]: [before[column] ?? null, value] }, at);
153
166
  };
154
167
  if (fields.title !== undefined) {
155
168
  const title = textOf(fields.title, "a task's title", 300);
@@ -158,15 +171,19 @@ export const workTaskOperationsOver = (context) => {
158
171
  }
159
172
  if (fields.description !== undefined)
160
173
  change("description", fields.description === null ? null : textOf(fields.description, "a description", 20000), created ? undefined : "description");
161
- if (fields.priority !== undefined)
162
- 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");
163
181
  if (fields.parent !== undefined)
164
182
  change("parent_id", fields.parent === null ? null : parentOf(task, Number(before.project_id), fields.parent));
165
183
  if (fields.dueAt !== undefined)
166
184
  change("due_at", fields.dueAt === null ? null : fields.dueAt.getTime(), "due");
167
- // Moved on every look, so no event: the history would be mostly check-ins.
168
185
  if (fields.nextCheckAt !== undefined)
169
- 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");
170
187
  if (fields.status !== undefined) {
171
188
  if (!TASK_STATES.includes(fields.status))
172
189
  throw new CliError("validation_error", `a task's status is one of ${TASK_STATES.join(", ")}`);
@@ -211,6 +228,67 @@ export const workTaskOperationsOver = (context) => {
211
228
  return;
212
229
  database.prepare(`UPDATE tasks SET ${set.join(", ")}, updated_at = ? WHERE id = ?`).run(...values, at, task);
213
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
+ };
214
292
  return {
215
293
  ...taskRelationsStoreOver(context, (task, actor, kind, changes, at) => recordTaskEvent(database, task, actor, kind, changes, at)),
216
294
  create: (input, by) => {
@@ -237,16 +315,29 @@ export const workTaskOperationsOver = (context) => {
237
315
  });
238
316
  return details(made);
239
317
  },
240
- edit: async (id, fields, by) => {
318
+ edit: async (id, fields, by, check = {}) => {
241
319
  const task = required(id);
242
- 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
+ });
243
324
  return details(task);
244
325
  },
245
- 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 = {}) => {
246
336
  if (!TASK_ROLES.includes(role))
247
337
  throw new CliError("validation_error", `a role is one of ${TASK_ROLES.join(", ")}`);
248
338
  const task = required(id);
249
339
  write(database, () => {
340
+ checkRevision(rowById(task), check.revision);
250
341
  const person = personOf(to);
251
342
  const held = database
252
343
  .prepare("SELECT 1 FROM task_assignments WHERE task_id = ? AND assignee_type = 'person' AND assignee_id = ? AND role = ?")
@@ -257,6 +348,7 @@ export const workTaskOperationsOver = (context) => {
257
348
  database
258
349
  .prepare("INSERT INTO task_assignments (task_id, assignee_type, assignee_id, role, created_at, updated_at) VALUES (?, 'person', ?, ?, ?, ?)")
259
350
  .run(task, person, role, at, at);
351
+ touch(task, at);
260
352
  const changes = { [role]: [null, `person:${person}`] };
261
353
  recordTaskEvent(database, task, authorActor(context, by).actor, "assigned", changes, at);
262
354
  });
@@ -283,17 +375,20 @@ export const workTaskOperationsOver = (context) => {
283
375
  .get(thing.id);
284
376
  return row ? `person:${String(row.id)}` : undefined;
285
377
  },
286
- unassign: async (id, { to, role }, by) => {
378
+ unassign: async (id, { to, role }, by, check = {}) => {
287
379
  const task = required(id);
288
380
  write(database, () => {
381
+ checkRevision(rowById(task), check.revision);
289
382
  const person = personOf(to);
290
383
  const removed = database
291
384
  .prepare("DELETE FROM task_assignments WHERE task_id = ? AND assignee_type = 'person' AND assignee_id = ? AND role = ?")
292
385
  .run(task, person, role).changes;
293
386
  if (removed === 0)
294
387
  return;
388
+ const at = now();
389
+ touch(task, at);
295
390
  const changes = { [role]: [`person:${person}`, null] };
296
- recordTaskEvent(database, task, authorActor(context, by).actor, "unassigned", changes, now());
391
+ recordTaskEvent(database, task, authorActor(context, by).actor, "unassigned", changes, at);
297
392
  });
298
393
  return details(task);
299
394
  },
@@ -317,17 +412,23 @@ export const workTaskOperationsOver = (context) => {
317
412
  .prepare("SELECT * FROM tasks t WHERE deleted_at IS NULL AND (? IS NULL OR project_id = ?) AND (? IS NULL OR type = ?) " +
318
413
  "AND (? IS NULL OR status = ?) AND (? IS NULL OR EXISTS (SELECT 1 FROM task_assignments a " +
319
414
  "WHERE a.task_id = t.id AND a.assignee_type = 'person' AND a.assignee_id = ?)) " +
320
- `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 ?`)
321
416
  .all(projectId, projectId, type ?? null, type ?? null, status ?? null, status ?? null, person, person, ...(where?.params ?? []), Math.min(Math.max(limit, 1), 500))
322
417
  .map(detailsOf);
323
418
  },
324
419
  claim: async (id, holder, until) => {
325
420
  const task = required(id);
326
421
  const at = now();
327
- const won = write(database, () => database
328
- .prepare("UPDATE tasks SET claimed_by = ?, claimed_until = ?, updated_at = ? " +
329
- "WHERE id = ? AND status NOT IN ('done', 'dismissed') AND (claimed_by IS NULL OR claimed_until < ? OR claimed_by = ?) RETURNING id")
330
- .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
+ });
331
432
  if (!won) {
332
433
  const row = rowById(task);
333
434
  if (isClosedState(String(row.status)))
@@ -346,9 +447,12 @@ export const workTaskOperationsOver = (context) => {
346
447
  throw new CliError("validation_error", `${claimedBy} holds task ${String(row.key)}; only they or the owner release it`, {
347
448
  reason: "task.claimed",
348
449
  });
450
+ const at = now();
349
451
  database
350
452
  .prepare("UPDATE tasks SET claimed_by = NULL, claimed_until = NULL, updated_at = ? WHERE id = ?")
351
- .run(now(), task);
453
+ .run(at, task);
454
+ if (claimedBy !== null)
455
+ recordTaskEvent(database, task, null, "released", { claimed_by: [claimedBy, null] }, at);
352
456
  });
353
457
  return details(task);
354
458
  },
@@ -384,13 +488,20 @@ export const workTaskOperationsOver = (context) => {
384
488
  count: Number(row.n),
385
489
  }))
386
490
  .sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
387
- const priorities = new Map(grouped("priority", "t.priority", "", "t.priority").map((row) => [row.priority, Number(row.n)]));
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
+ }
388
496
  const states = new Map(grouped("state", "t.status", "", "t.status").map((row) => [row.status, Number(row.n)]));
389
497
  return {
390
498
  total: countOf(boardWhere(query, zone, at)),
391
499
  project,
392
500
  assignee,
393
- priority: [0, 1, 2, 3, 4, null].map((value) => ({ value, count: priorities.get(value) ?? 0 })),
501
+ priority: [...[...PRIORITY_LABELS].reverse(), "none"].map((value) => ({
502
+ value,
503
+ count: priorities.get(value) ?? 0,
504
+ })),
394
505
  state: TASK_STATES.map((value) => ({ value, count: states.get(value) ?? 0 })),
395
506
  };
396
507
  },
@@ -431,6 +542,6 @@ export const workTasksOver = (context) => {
431
542
  return {
432
543
  ...operations,
433
544
  create: async (input, by) => operations.create(input, by),
434
- assign: async (id, assignment, by) => operations.assign(id, assignment, by),
545
+ assign: async (id, assignment, by, check) => operations.assign(id, assignment, by, check),
435
546
  };
436
547
  };
@@ -1,3 +1,4 @@
1
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
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";
@@ -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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.512.0",
3
+ "version": "0.514.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",
package/parity.json CHANGED
@@ -3678,18 +3678,14 @@
3678
3678
  }
3679
3679
  },
3680
3680
  "projects": {
3681
- "in": [],
3681
+ "in": ["max", "tg"],
3682
3682
  "planned": {
3683
- "max": "max-cli#886, on the cli-core release with this row",
3684
- "tg": "tg-cli#798, on the cli-core release with this row",
3685
3683
  "wa": "wa backlog: mount projectsCommand once wa is on cli-core 0.504.0"
3686
3684
  }
3687
3685
  },
3688
3686
  "projects evidence": {
3689
- "in": [],
3687
+ "in": ["max", "tg"],
3690
3688
  "planned": {
3691
- "max": "max-cli#886, on the cli-core release with this row",
3692
- "tg": "tg-cli#798, on the cli-core release with this row",
3693
3689
  "wa": "wa backlog: mount projectsCommand once wa is on cli-core 0.504.0"
3694
3690
  },
3695
3691
  "options": {