@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.
- package/CHANGELOG.md +70 -0
- package/dist/calendar/mcp/index.d.ts +46 -10
- package/dist/calendar/mcp/index.js +60 -25
- package/dist/cli/list-query.d.ts +4 -2
- package/dist/cli/list-query.js +3 -3
- package/dist/cli/messenger/tasks-command.js +4 -5
- package/dist/entities/index.d.ts +35 -8
- package/dist/entities/task-results.d.ts +22 -7
- package/dist/entities/task-results.js +4 -1
- package/dist/entities/task.d.ts +18 -1
- package/dist/entities/task.js +12 -1
- package/dist/errors/reasons.js +1 -0
- package/dist/services/task-rules.js +2 -2
- package/dist/services/tasks.d.ts +1 -1
- package/dist/services/tasks.js +7 -5
- package/dist/store/index.d.ts +4 -1
- package/dist/store/index.js +2 -0
- package/dist/store/sqlite/knowledge.js +3 -2
- package/dist/store/sqlite/manifest.js +1 -0
- package/dist/store/sqlite/migrations.generated.js +12 -0
- package/dist/store/sqlite/person-preparation.js +1 -1
- package/dist/store/sqlite/schema.d.ts +48 -0
- package/dist/store/sqlite/schema.js +3 -0
- package/dist/store/sqlite/tags.d.ts +1 -2
- package/dist/store/sqlite/tags.js +18 -3
- package/dist/store/sqlite/task-board.d.ts +16 -0
- package/dist/store/sqlite/task-board.js +192 -0
- package/dist/store/sqlite/task-candidates.js +1 -1
- package/dist/store/sqlite/task-category-settings.js +1 -1
- package/dist/store/sqlite/task-close-store.js +6 -4
- package/dist/store/sqlite/task-pairs.js +4 -3
- package/dist/store/sqlite/task-relations.js +3 -2
- package/dist/store/sqlite/tasks.js +16 -5
- package/dist/store/sqlite/work-tasks.d.ts +96 -9
- package/dist/store/sqlite/work-tasks.js +236 -32
- package/dist/tasks/index.d.ts +2 -1
- package/dist/tasks/index.js +2 -1
- package/dist/tasks/model.d.ts +5 -2
- package/dist/tasks/model.js +5 -2
- package/dist/tasks/priority.d.ts +18 -0
- package/dist/tasks/priority.js +37 -0
- package/dist/tasks/service.js +2 -2
- 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
|
|
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
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 ?
|
|
180
|
-
"
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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;
|