@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
@@ -0,0 +1,192 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { OPERATORS_BY_TYPE, walkQuery, } from "@wirecat/cli-base/query";
3
+ import { TASK_TYPES } from "../../domain/knowledge.js";
4
+ import { taskWordsOf } from "../../search/task-similarity.js";
5
+ import { PRIORITY_LABELS, priorityBand, TASK_STATES } from "../../tasks/index.js";
6
+ import { INBOX_KEY_LENGTH } from "./task-keys.js";
7
+ export const TASK_SORTS = ["priority", "due", "created", "updated", "key", "state"];
8
+ const field = (name, type, example, more = {}) => ({
9
+ name,
10
+ type,
11
+ operators: OPERATORS_BY_TYPE[type],
12
+ example,
13
+ ...more,
14
+ });
15
+ /** The query language of the owner's project tasks, every project at once; `text` takes the bare words. */
16
+ export const PROJECT_TASK_FIELDS = [
17
+ field("state", "enum", "state:todo", { values: TASK_STATES }),
18
+ field("kind", "enum", "kind:bug", { values: TASK_TYPES }),
19
+ field("project", "kw", "project:HRB", {
20
+ relation: "a project by its key or project:<id>; project:* is every project, and lists project tasks only",
21
+ }),
22
+ field("assignee", "kw", 'assignee:"person:12"', {
23
+ relation: "who does it: any, none, or a person (person:12 in quotes, or the bare 12)",
24
+ }),
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" }),
30
+ field("due", "time", "due:[today TO 2026-12-31]", { relation: "the due date; NOT due:* has none" }),
31
+ field("overdue", "bool", "overdue:true", { relation: "open work whose due date has passed" }),
32
+ field("closed", "time", "closed:>=14d", { relation: "when it was done or dismissed" }),
33
+ field("ready", "bool", "ready:true", { relation: "open work whose blocked-by tasks are all done" }),
34
+ field("text", "txt", "text:invoice", { relation: "words of the title and description; a bare word means this" }),
35
+ ];
36
+ const OPEN = "t.status NOT IN ('done', 'dismissed')";
37
+ const OWNER_PROJECTS = `SELECT id FROM projects WHERE deleted_at IS NULL AND length(key) <> ${INBOX_KEY_LENGTH}`;
38
+ const ASSIGNED = "SELECT 1 FROM task_assignments a WHERE a.task_id = t.id AND a.assignee_type = 'person' AND a.role = 'assignee'";
39
+ /** A `blocked-by` task that is not done holds the task back; so does one deleted or out of reach. */
40
+ export const UNFINISHED_BLOCKER = "SELECT 1 FROM links l LEFT JOIN tasks b ON b.id = l.to_id LEFT JOIN projects bp ON bp.id = b.project_id " +
41
+ "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 " +
42
+ "AND l.anchor IS NULL AND (b.id IS NULL OR b.deleted_at IS NOT NULL OR bp.deleted_at IS NOT NULL OR b.status <> 'done')";
43
+ /** Every word a prefix, all of them required; a quoted phrase as one. `undefined` when nothing in it is a word. */
44
+ const wordsMatch = (node) => {
45
+ const words = taskWordsOf(node.value.replaceAll("*", " "))
46
+ .split(/\s+/)
47
+ .filter((word) => /[\p{L}\p{N}]/u.test(word));
48
+ if (words.length === 0)
49
+ return undefined;
50
+ const quoted = (text) => `"${text.replaceAll('"', '""')}"`;
51
+ return `normalized_text : (${node.operator === "phrase" ? quoted(words.join(" ")) : words.map((word) => `${quoted(word)}*`).join(" AND ")})`;
52
+ };
53
+ const projectWhere = (node) => {
54
+ if (node.operator === "wildcard" && node.value === "*")
55
+ return { sql: "1", params: [] };
56
+ const id = /^project:(\d+)$/.exec(node.value.trim())?.[1];
57
+ if (id !== undefined)
58
+ return { sql: "t.project_id = ?", params: [Number(id)] };
59
+ const key = node.value.trim().toUpperCase();
60
+ return {
61
+ sql: `t.project_id IN (SELECT id FROM projects WHERE key ${node.operator === "wildcard" ? "GLOB" : "="} ?)`,
62
+ params: [key],
63
+ };
64
+ };
65
+ const assigneeWhere = (node) => {
66
+ const value = node.value.trim().toLowerCase();
67
+ if (value === "any" || (node.operator === "wildcard" && value === "*"))
68
+ return { sql: `EXISTS (${ASSIGNED})`, params: [] };
69
+ if (value === "none")
70
+ return { sql: `NOT EXISTS (${ASSIGNED})`, params: [] };
71
+ const id = /^(?:person:)?(\d+)$/.exec(value)?.[1];
72
+ if (id === undefined)
73
+ throw new CliError("validation_error", `assignee: takes any, none or "person:<id>", not "${node.value}"`);
74
+ return { sql: `EXISTS (${ASSIGNED} AND a.assignee_id = ?)`, params: [Number(id)] };
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
+ };
130
+ const truth = (node, sql) => ({
131
+ sql: node.value.toLowerCase() === "true" ? sql : `NOT (${sql})`,
132
+ params: [],
133
+ });
134
+ /** `now` is when overdue is measured; one value per answer, so the list and its counts agree. */
135
+ export const projectTaskColumns = (now) => ({
136
+ state: { type: "enum", sql: "t.status" },
137
+ kind: { type: "enum", sql: "t.type" },
138
+ project: { where: projectWhere },
139
+ assignee: { where: assigneeWhere },
140
+ priority: { where: priorityWhere },
141
+ agent: { where: agentWhere },
142
+ due: { type: "time", sql: "t.due_at" },
143
+ closed: { type: "time", sql: "t.closed_at" },
144
+ overdue: { where: (node) => truth(node, `${OPEN} AND t.due_at IS NOT NULL AND t.due_at < ${Number(now)}`) },
145
+ ready: { where: (node) => truth(node, `${OPEN} AND NOT EXISTS (${UNFINISHED_BLOCKER})`) },
146
+ text: {
147
+ where: (node) => {
148
+ const match = wordsMatch(node);
149
+ const byKey = { sql: "t.key = ?", params: [node.value.trim().toUpperCase()] };
150
+ if (match === undefined)
151
+ return byKey;
152
+ return {
153
+ sql: `(t.id IN (SELECT rowid FROM task_words WHERE task_words MATCH ?) OR ${byKey.sql})`,
154
+ params: [match, ...byKey.params],
155
+ };
156
+ },
157
+ },
158
+ });
159
+ /** Live tasks of the owner's projects; an account's inbox is listed by its account, never here. */
160
+ export const BOARD_SCOPE = `t.deleted_at IS NULL AND t.project_id IN (${OWNER_PROJECTS})`;
161
+ const STATE_ORDER = `CASE t.status ${TASK_STATES.map((state, at) => `WHEN '${state}' THEN ${at}`).join(" ")} END`;
162
+ /** The chosen order first, then priority, age and id, so a page boundary never splits a tie. */
163
+ export const boardOrder = (sort, descending) => {
164
+ const direction = descending ? "DESC" : "ASC";
165
+ const first = {
166
+ priority: ["t.priority_score IS NULL", `t.priority_score ${descending ? "ASC" : "DESC"}`],
167
+ due: ["t.due_at IS NULL", `t.due_at ${direction}`],
168
+ created: [`t.created_at ${direction}`],
169
+ updated: [`t.updated_at ${direction}`],
170
+ key: [`(SELECT key FROM projects WHERE id = t.project_id) ${direction}`, `t.number ${direction}`],
171
+ state: [`${STATE_ORDER} ${direction}`],
172
+ }[sort];
173
+ return [...first, "t.priority_score IS NULL", "t.priority_score DESC", "t.created_at", "t.id"].join(", ");
174
+ };
175
+ /** The query without its required terms on `name`: a picker counts the choices its own filter hides. */
176
+ export const withoutField = (query, name) => {
177
+ if (query === undefined)
178
+ return undefined;
179
+ const strip = (node) => {
180
+ if (walkQuery(node).every((leaf) => leaf.field === name))
181
+ return undefined;
182
+ if (node.kind === "predicate" || node.clauses.some((clause) => clause.occur === "should"))
183
+ return node;
184
+ const clauses = node.clauses.flatMap((clause) => {
185
+ const kept = strip(clause.node);
186
+ return kept === undefined ? [] : [{ ...clause, node: kept }];
187
+ });
188
+ return clauses.length === 0 ? undefined : { ...node, clauses };
189
+ };
190
+ const root = strip(query.root);
191
+ return root === undefined ? undefined : { ...query, root };
192
+ };
@@ -173,7 +173,7 @@ export const taskSimilarityOver = (context) => {
173
173
  drainCorpus(database, CORPORA.task, stemmerFor);
174
174
  const stemmer = stemmerFor(storeStemmers(database) ?? DEFAULT_STEMMERS);
175
175
  const since = now() - RECENTLY_CLOSED_MS;
176
- const live = "t.project_id = ? AND t.deleted_at IS NULL AND t.id <> ? AND (t.status = 'open' OR t.closed_at >= ?)";
176
+ const live = "t.project_id = ? AND t.deleted_at IS NULL AND t.id <> ? AND (t.status NOT IN ('done', 'dismissed') OR t.closed_at >= ?)";
177
177
  const scope = [side.project, side.row ?? -1, since];
178
178
  const found = new Map();
179
179
  const add = (id, by) => found.set(id, (found.get(id) ?? new Set()).add(by));
@@ -11,7 +11,7 @@ export const storeTaskCategories = (database) => savedTaskCategories(database) ?
11
11
  export const unlistedTaskCategories = (database, list) => {
12
12
  const listed = new Set(list.map(({ name }) => name));
13
13
  return database
14
- .prepare("SELECT category, COUNT(*) AS total, SUM(status = 'open') AS open FROM tasks " +
14
+ .prepare("SELECT category, COUNT(*) AS total, SUM(status NOT IN ('done', 'dismissed')) AS open FROM tasks " +
15
15
  "WHERE category IS NOT NULL AND deleted_at IS NULL GROUP BY category ORDER BY category")
16
16
  .all()
17
17
  .map((row) => ({ name: String(row.category), open: Number(row.open), total: Number(row.total) }))
@@ -1,4 +1,5 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
+ import { isClosedState } from "../../tasks/index.js";
2
3
  import { authorActor } from "./memories.js";
3
4
  import { CLEAR_SUGGESTION, certaintyOf, closeSuggestionOf, saveAgentClose, storeAgentClose, } from "./task-close.js";
4
5
  import { DEFAULT_FIRST_PASS_PER_CHAT, savedFirstPassPerChat, saveFirstPassPerChat } from "./task-first-pass.js";
@@ -24,7 +25,7 @@ export const taskCloseOver = (context) => {
24
25
  closeSuggestion: async (id) => closeSuggestionOf(database, liveRow(id)),
25
26
  suggestClose: async (id, suggestion, by) => write(database, () => {
26
27
  const row = liveRow(id);
27
- if (row.status !== "open")
28
+ if (isClosedState(String(row.status)))
28
29
  throw new CliError("validation_error", `task ${String(row.key)} is already ${String(row.status)}`, {
29
30
  reason: "task.closed",
30
31
  });
@@ -51,9 +52,10 @@ export const taskCloseOver = (context) => {
51
52
  }),
52
53
  recordCloseCertainty: async (id, certainty) => write(database, () => {
53
54
  const row = liveRow(id);
54
- database
55
- .prepare("UPDATE tasks SET close_certainty = ? WHERE id = ?")
56
- .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());
57
59
  }),
58
60
  };
59
61
  };
@@ -1,4 +1,5 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
+ import { isClosedState } from "../../tasks/index.js";
2
3
  import { folded } from "./folded.js";
3
4
  import { linkKind } from "./link-kinds.js";
4
5
  import { authorActor } from "./memories.js";
@@ -134,7 +135,7 @@ export const taskPairsOver = (context, similarity) => {
134
135
  };
135
136
  const followUp = async (row, candidates, by) => write(database, () => {
136
137
  for (const one of candidates) {
137
- if (one.status === "open" || one.band === "different")
138
+ if (!isClosedState(one.status) || one.band === "different")
138
139
  continue;
139
140
  const closed = required(one.key);
140
141
  if (mergedInto(closed))
@@ -157,7 +158,7 @@ export const taskPairsOver = (context, similarity) => {
157
158
  const alike = (await similarity.similar({ task: String(row.key) }, { limit: 40 })).filter((one) => one.band !== "different");
158
159
  if (!linked)
159
160
  await followUp(row, alike, { bot: "rule" });
160
- const found = linked ? alike : alike.filter((one) => one.status === "open");
161
+ const found = linked ? alike : alike.filter((one) => !isClosedState(one.status));
161
162
  if (found.length === 0)
162
163
  return 0;
163
164
  return write(database, () => {
@@ -216,7 +217,7 @@ export const taskPairsOver = (context, similarity) => {
216
217
  const a = required(dup);
217
218
  const b = required(into);
218
219
  sameInbox(a, b);
219
- if (a.status !== "open")
220
+ if (isClosedState(String(a.status)))
220
221
  throw new CliError("validation_error", `task ${String(a.key)} is already ${String(a.status)}; a merge closes an open task — reopen it with tasks unmerge if a merge closed it`, { reason: "task.closed" });
221
222
  const onward = mergedInto(b);
222
223
  if (onward)
@@ -1,4 +1,5 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
+ import { isClosedState } from "../../tasks/index.js";
2
3
  import { authorActor } from "./memories.js";
3
4
  import { taskIdOf, taskRowOf } from "./things.js";
4
5
  import { read, write } from "./transaction.js";
@@ -138,7 +139,7 @@ export const taskRelationsStoreOver = (context, record) => {
138
139
  ? "dismissed"
139
140
  : "open",
140
141
  }));
141
- return { ready: liveTask(database, rowId).status === "open" && blockers.length === 0, blockers };
142
+ return { ready: !isClosedState(String(liveTask(database, rowId).status)) && blockers.length === 0, blockers };
142
143
  }),
143
144
  readyTasks: async (project, options = {}) => read(database, () => {
144
145
  const limit = options.limit ?? 50;
@@ -154,7 +155,7 @@ export const taskRelationsStoreOver = (context, record) => {
154
155
  if (!row)
155
156
  throw new CliError("not_found", "the owner project is unavailable");
156
157
  const rows = database
157
- .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='open' 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 ?")
158
159
  .all(Number(row.id), limit + 1);
159
160
  return {
160
161
  items: rows
@@ -1,5 +1,5 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
- import { matches, TASK_KINDS, TASK_ORIGINS, TASK_STATES, TASK_VERDICTS, } from "../../tasks/index.js";
2
+ import { isClosedState, matches, TASK_KINDS, TASK_ORIGINS, TASK_STATES, TASK_VERDICTS, } from "../../tasks/index.js";
3
3
  import { taskCategoriesReader } from "../shared-config.js";
4
4
  import { actorOfOrigin, originOfActor } from "./actors.js";
5
5
  import { folded } from "./folded.js";
@@ -176,11 +176,12 @@ export const taskStoreOver = (context) => {
176
176
  database
177
177
  .prepare("UPDATE tasks SET status = ?, close_reason = ?, close_reason_folded = ?, due_at = ?, closed_at = ?, " +
178
178
  "closed_by_type = ?, closed_by_id = ?, updated_at = ?, " +
179
- "claimed_by = CASE WHEN ? = 'open' THEN claimed_by END, claimed_until = CASE WHEN ? = 'open' THEN claimed_until END, " +
180
- "close_certainty = CASE WHEN ? = 'open' THEN NULL ELSE close_certainty END " +
179
+ "claimed_by = CASE WHEN ? NOT IN ('done', 'dismissed') THEN claimed_by END, " +
180
+ "claimed_until = CASE WHEN ? NOT IN ('done', 'dismissed') THEN claimed_until END, " +
181
+ "close_certainty = CASE WHEN ? NOT IN ('done', 'dismissed') THEN NULL ELSE close_certainty END " +
181
182
  "WHERE id = ?")
182
183
  .run(task.state, task.reason ?? null, folded(task.reason), time(task.dueAt), time(task.closedAt), closedBy?.type ?? null, closedBy?.id ?? null, at, task.state, task.state, task.state, row);
183
- if (task.state !== "open")
184
+ if (isClosedState(task.state))
184
185
  database.prepare(`UPDATE tasks SET ${CLEAR_SUGGESTION} WHERE id = ?`).run(row);
185
186
  if (before.status !== task.state)
186
187
  recordTaskEvent(database, row, closedBy ?? null, "status", { status: [before.status, task.state] }, at);
@@ -196,7 +197,10 @@ export const taskStoreOver = (context) => {
196
197
  const row = required(id);
197
198
  const at = now();
198
199
  write(database, () => {
200
+ const before = database.prepare("SELECT resolution FROM tasks WHERE id = ?").get(row)?.resolution ?? null;
199
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);
200
204
  if (!by)
201
205
  return;
202
206
  database
@@ -213,7 +217,14 @@ export const taskStoreOver = (context) => {
213
217
  judge: async (id, verdict) => {
214
218
  if (verdict !== null && !TASK_VERDICTS.includes(verdict))
215
219
  throw new CliError("validation_error", `a verdict is one of ${TASK_VERDICTS.join(", ")}`);
216
- 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
+ });
217
228
  },
218
229
  };
219
230
  };
@@ -1,22 +1,26 @@
1
+ import type { QueryAst } from "@wirecat/cli-base/query";
1
2
  import { TASK_TYPES, WORK_TASK_TYPES } from "../../domain/knowledge.js";
2
- import { type TaskState, type TaskVerdict } from "../../tasks/index.js";
3
+ import { type PriorityLabel, type TaskState, type TaskVerdict } from "../../tasks/index.js";
3
4
  import type { CacheDatabase } from "../driver.js";
4
5
  import type { Actor } from "./actors.js";
5
6
  import { type Author } from "./memories.js";
6
7
  import type { StoreContext } from "./open.js";
7
- import type { Where } from "./query-filter.js";
8
+ import { type Where } from "./query-filter.js";
9
+ import { type TaskSort } from "./task-board.js";
8
10
  import { type CloseSuggestion } from "./task-close.js";
9
11
  import { type TaskRelationsStore } from "./task-relations.js";
10
12
  export declare const TASK_ROLES: readonly ["assignee", "reviewer", "watcher"];
11
- 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"];
12
14
  export type WorkTaskType = (typeof WORK_TASK_TYPES)[number];
13
15
  export type TaskType = (typeof TASK_TYPES)[number];
14
16
  export type TaskRole = (typeof TASK_ROLES)[number];
15
17
  export interface TaskFields {
16
18
  title?: string;
17
19
  description?: string | null;
18
- /** 0 urgent … 4 low. */
20
+ /** 0 to 1, higher first: `priorityOf` reads a label or the old 0–4. */
19
21
  priority?: number | null;
22
+ /** The agent working on it, by name; `null` clears it. */
23
+ agent?: string | null;
20
24
  /** A task reference (`task:<id>`) or id in the same project; `null` makes it top-level. */
21
25
  parent?: string | null;
22
26
  dueAt?: Date | null;
@@ -48,7 +52,12 @@ export interface TaskDetails {
48
52
  description: string | null;
49
53
  type: TaskType;
50
54
  status: TaskState;
55
+ /** 0 to 1, higher first. */
51
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;
52
61
  parent: string | null;
53
62
  /** What a task found in a message points at; null for a task made here. */
54
63
  source: string | null;
@@ -69,18 +78,79 @@ export interface TaskDetails {
69
78
  createdAt: string;
70
79
  updatedAt: string;
71
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
+ }
101
+ export interface BoardOptions {
102
+ limit: number;
103
+ offset?: number;
104
+ sort?: TaskSort;
105
+ descending?: boolean;
106
+ /** The zone `due:today` and bare dates are read in. */
107
+ zone?: string;
108
+ }
109
+ /** How many tasks match, and how many hold each value: the board's pickers show these beside their choices. */
110
+ export interface BoardCounts {
111
+ total: number;
112
+ project: {
113
+ value: string;
114
+ name: string;
115
+ count: number;
116
+ }[];
117
+ /** `person:<id>`, or `none` for the tasks nobody is assigned. */
118
+ assignee: {
119
+ value: string;
120
+ name: string | null;
121
+ count: number;
122
+ }[];
123
+ /** By label; `none` for the tasks with no priority. */
124
+ priority: {
125
+ value: PriorityLabel | "none";
126
+ count: number;
127
+ }[];
128
+ state: {
129
+ value: TaskState;
130
+ count: number;
131
+ }[];
132
+ }
72
133
  export interface TaskEvent {
73
134
  kind: (typeof TASK_EVENT_KINDS)[number];
74
- actor: Actor | null;
135
+ /** `name`: the person's or the bot's, as the store holds it now. */
136
+ actor: (Actor & {
137
+ name: string | null;
138
+ }) | null;
75
139
  changes: Record<string, [unknown, unknown]> | null;
76
140
  at: string;
77
141
  }
78
142
  /** Tasks beyond the package's four message kinds, and the fields the package does not know, on any task. */
79
143
  export interface WorkTasks extends TaskRelationsStore {
80
144
  create(input: NewWorkTask, by: Author): Promise<TaskDetails>;
81
- edit(id: string, fields: TaskFields, by: Author): Promise<TaskDetails>;
82
- assign(id: string, assignment: TaskAssignment, by: Author): Promise<TaskDetails>;
83
- 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>;
84
154
  /** `person:<id>` of whoever sent the task's source message; `undefined` for the owner, a note or an unknown sender. */
85
155
  sourceSender(id: string): Promise<string | undefined>;
86
156
  details(id: string): Promise<TaskDetails>;
@@ -100,6 +170,20 @@ export interface WorkTasks extends TaskRelationsStore {
100
170
  limit?: number;
101
171
  }): Promise<TaskDetails[]>;
102
172
  events(id: string): Promise<TaskEvent[]>;
173
+ /** The owner's project tasks, every project at once, narrowed by a query over `PROJECT_TASK_FIELDS`. */
174
+ board(query: QueryAst | undefined, options: BoardOptions): Promise<{
175
+ items: TaskDetails[];
176
+ total: number;
177
+ }>;
178
+ /** The owner and everyone who holds a role on a live task, by name: who the board offers to assign. */
179
+ taskPeople(): Promise<{
180
+ person: string;
181
+ name: string | null;
182
+ owner: boolean;
183
+ tasks: number;
184
+ }[]>;
185
+ /** Each count leaves out the query's own terms on that field, so a chosen project does not hide the others. */
186
+ boardCounts(query: QueryAst | undefined, options?: Pick<BoardOptions, "zone">): Promise<BoardCounts>;
103
187
  /**
104
188
  * Takes an open task for `holder` until `until`, unless someone else holds it now — a lapsed claim holds nothing,
105
189
  * and the holder's own claim is extended.
@@ -109,10 +193,13 @@ export interface WorkTasks extends TaskRelationsStore {
109
193
  /** Gives a task back; only its holder or the owner may. */
110
194
  release(id: string, holder: string): Promise<TaskDetails>;
111
195
  }
196
+ type Row = Record<string, unknown>;
112
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;
113
200
  type WorkTaskOperations = Omit<WorkTasks, "create" | "assign"> & {
114
201
  create(input: NewWorkTask, by: Author): TaskDetails;
115
- assign(id: string, assignment: TaskAssignment, by: Author): TaskDetails;
202
+ assign(id: string, assignment: TaskAssignment, by: Author, check?: RevisionCheck): TaskDetails;
116
203
  };
117
204
  export declare const workTaskOperationsOver: (context: StoreContext) => WorkTaskOperations;
118
205
  export declare const workTasksOver: (context: StoreContext) => WorkTasks;