@wirecat/cli-core 0.514.0 → 0.516.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,44 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.516.0 — 01.11.2026
11
+
12
+ ### Fixed
13
+
14
+ - **`syncMail` counts what `max` left unread in `remaining`**, not 0, so a CLI can say how much is left after a run
15
+ cut short by `--limit`.
16
+ - **`tasks update --priority none` clears the priority** instead of failing with "a priority score is 0 to 1":
17
+ Commander stores an option parser's `null` as an empty string. `priorityOption` reads `none` as the word `"none"`
18
+ for a command's own option; a consumer that maps it to `null` (as `--due none`) clears the priority.
19
+
20
+ ### Added
21
+
22
+ - **Store version 42: a task's revision goes up only for a change a person could overwrite** (`minCompatible` stays
23
+ 34): its title, description, status, priority, agent, due date, project or parent, an assignee added or removed, or
24
+ a tag added or removed. A claim taken, renewed or released, a follow-up time, a close suggestion or certainty no
25
+ longer bump it, so a board no longer reports a conflict while an agent simply works. Triggers do it, so writes by
26
+ older builds follow the same rule.
27
+
28
+ ### Changed — may break callers
29
+
30
+ - **`tasks.move` beside a task in another column moves the task into that column**: without `status` it takes the
31
+ neighbour's status. A `status` that differs from the neighbour's is refused with `validation_error`, reason
32
+ `task.column` (`HRB-2 is in in_progress, not todo: pick a task in the todo column`), with `anchor`, `anchorStatus`
33
+ and `requestedStatus` in the details. A neighbour off every project board, or the task itself, is refused with a
34
+ message that says so.
35
+
36
+ ## 0.515.0 — 01.11.2026
37
+
38
+ ### Added
39
+
40
+ - **`storedMailRules(store, account, query?)`** reads one account's mail rules from the store in the shape
41
+ `syncMail` takes as `account.rules`, and `storedMailRule(stored)` reads one; a rule with a condition this build does
42
+ not know is left out. An account the store has not seen has no rules.
43
+ - **`labelName(raw)`, `systemLabel(name)` and `decodeMailboxName(name)`** name Gmail labels as people read them
44
+ (`\Inbox` and `Inbox` are one label, modified UTF-7 is decoded), moved from memo.
45
+ - **`mailProgress(store, key)`** gives each synced folder's window, done and total from the saved resume points,
46
+ without asking the mailbox. It treats only `not_found` as "no account yet" and passes other store errors on.
47
+
10
48
  ## 0.514.0 — 01.11.2026
11
49
 
12
50
  ### Fixed
@@ -8,7 +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
+ import { PRIORITY_LABELS, priorityOption } from "../../tasks/index.js";
12
12
  import { actorOf, holderOf as holderIn } from "../author.js";
13
13
  import { environmentOf } from "../context.js";
14
14
  import { destroysNothing, LOCAL_READ, LOCAL_WRITE, ownerOnlyOption } from "../effects.js";
@@ -119,7 +119,7 @@ const HOLDER_HELP = "who you are for claims: a session id or a chat id; default
119
119
  const planFrom = (options) => {
120
120
  const plan = {};
121
121
  if (options.priority !== undefined)
122
- plan.priority = options.priority;
122
+ plan.priority = options.priority === "none" ? null : options.priority;
123
123
  if (options.parent !== undefined)
124
124
  plan.parent = options.parent === "none" ? null : options.parent;
125
125
  if (options.category !== undefined)
@@ -208,7 +208,7 @@ export const tasksCommand = (messenger) => {
208
208
  .description("update a task's text, schedule, category, assignees, priority or parent; nothing reaches the messenger")
209
209
  .argument("<task>", "the task's id or key, as tasks list shows it")
210
210
  .option("--category <name>", `${categoryHelp}; a change is kept in the task's history`)
211
- .option("--priority <n>", `${PRIORITY_LABELS.join(", ")} or none; 0 (urgent) … 4 (low) still read as before`, priorityOf)
211
+ .option("--priority <n>", `${PRIORITY_LABELS.join(", ")} or none; 0 (urgent) … 4 (low) still read as before`, priorityOption)
212
212
  .option("--parent <id>", "parent task in the same project; none clears it"), "send Alex the API token").action(async function (id) {
213
213
  const context = messengerContext(this, messenger);
214
214
  refuseLocalWrite(context, messenger.app.command, "tasks.update");
@@ -121,6 +121,7 @@ const OTHERS = [
121
121
  validation("task.claimed", "someone else holds the task"),
122
122
  validation("task.closed", "the task is already closed"),
123
123
  ["task.stale", "configuration_error", "the task changed since it was read; read it again"],
124
+ validation("task.column", "the task beside which it moves is in another column than the status asked for"),
124
125
  validation("task.dependency-cycle", "the task would depend on itself"),
125
126
  validation("task.duplicate-candidates", "open tasks may be the same job: attach to one, or add it as new"),
126
127
  validation("task.certainty-required", "an agent closes a task with its certainty, 0 to 1"),
@@ -3,11 +3,14 @@ export { factsOf } from "./facts.js";
3
3
  export { GMAIL_ALL_MAIL, mailFolders } from "./folders.js";
4
4
  export { dropMissing, type Gone, tooManyGone } from "./gone.js";
5
5
  export { type AuthVerdicts, HEADER_FIELDS, headersOf, type MailHeaders, mailHeadersOf } from "./headers.js";
6
+ export { decodeMailboxName, labelName, systemLabel } from "./labels.js";
6
7
  export { type Address, htmlToText, looksLikeHtml, MAX_BODY_CHARS, type ParsedMail, parseMail, } from "./parse.js";
7
8
  export { PRESET_NAMES, PRESETS, type PresetName, presetMatches } from "./presets.js";
8
9
  export { domainsToAge, groupKey, type Probe, type ProbeGroup, probeMail, SUGGEST_RULE } from "./probe.js";
10
+ export { type MailFolderProgress, mailProgress } from "./progress.js";
9
11
  export { type FileStamp, FULL_PASS_MS, MBOX_RESUME, type MboxPoint, mergeRanges, parsePoint, type Ranges, type ResumePoint, rangesOf, resumeKey, rulesKey, within, } from "./resume.js";
10
12
  export { ruleInput, STORE_ACTION } from "./rule-input.js";
13
+ export { storedMailRule, storedMailRules } from "./rule-stored.js";
11
14
  export { mapProposedTags, ruleTagger, settleRuleTags, type TagOutcome } from "./rule-tags.js";
12
15
  export { conditionKeys, decide, describeMatch, ENVELOPE, envelopeBox, envelopeBoxOf, type MailAction, type MailFacts, type MailRule, type MailRuleMatch, matches, sameMatch, tagsOf, } from "./rules.js";
13
16
  export { type DomainAge, FRAUD_SIGNALS, FREE_MAIL, registrableOf, SCORE_RULE, type Scored, type Signal, scoreGroup, WEIGHTS, } from "./score.js";
@@ -3,11 +3,14 @@ export { factsOf } from "./facts.js";
3
3
  export { GMAIL_ALL_MAIL, mailFolders } from "./folders.js";
4
4
  export { dropMissing, tooManyGone } from "./gone.js";
5
5
  export { HEADER_FIELDS, headersOf, mailHeadersOf } from "./headers.js";
6
+ export { decodeMailboxName, labelName, systemLabel } from "./labels.js";
6
7
  export { htmlToText, looksLikeHtml, MAX_BODY_CHARS, parseMail, } from "./parse.js";
7
8
  export { PRESET_NAMES, PRESETS, presetMatches } from "./presets.js";
8
9
  export { domainsToAge, groupKey, probeMail, SUGGEST_RULE } from "./probe.js";
10
+ export { mailProgress } from "./progress.js";
9
11
  export { FULL_PASS_MS, MBOX_RESUME, mergeRanges, parsePoint, rangesOf, resumeKey, rulesKey, within, } from "./resume.js";
10
12
  export { ruleInput, STORE_ACTION } from "./rule-input.js";
13
+ export { storedMailRule, storedMailRules } from "./rule-stored.js";
11
14
  export { mapProposedTags, ruleTagger, settleRuleTags } from "./rule-tags.js";
12
15
  export { conditionKeys, decide, describeMatch, ENVELOPE, envelopeBox, envelopeBoxOf, matches, sameMatch, tagsOf, } from "./rules.js";
13
16
  export { FRAUD_SIGNALS, FREE_MAIL, registrableOf, SCORE_RULE, scoreGroup, WEIGHTS, } from "./score.js";
@@ -0,0 +1,5 @@
1
+ /** IMAP's modified UTF-7 (RFC 3501 §5.1.3): `&` opens base64 of UTF-16BE, `,` stands for `/`, `-` closes. */
2
+ export declare const decodeMailboxName: (name: string) => string;
3
+ export declare const systemLabel: (name: string) => string;
4
+ /** A label as people read it: `\\Important` and `Important` are one label, and names are decoded. */
5
+ export declare const labelName: (raw: string) => string;
@@ -0,0 +1,27 @@
1
+ const SYSTEM_LABELS = {
2
+ inbox: "Inbox",
3
+ sent: "Sent",
4
+ important: "Important",
5
+ starred: "Starred",
6
+ draft: "Drafts",
7
+ drafts: "Drafts",
8
+ spam: "Spam",
9
+ trash: "Trash",
10
+ junk: "Spam",
11
+ };
12
+ /** IMAP's modified UTF-7 (RFC 3501 §5.1.3): `&` opens base64 of UTF-16BE, `,` stands for `/`, `-` closes. */
13
+ export const decodeMailboxName = (name) => name.replace(/&([^-]*)-/g, (_, run) => {
14
+ if (run === "")
15
+ return "&";
16
+ const bytes = Buffer.from(run.replace(/,/g, "/"), "base64");
17
+ let text = "";
18
+ for (let at = 0; at + 1 < bytes.length; at += 2)
19
+ text += String.fromCharCode(bytes.readUInt16BE(at));
20
+ return text;
21
+ });
22
+ export const systemLabel = (name) => SYSTEM_LABELS[name.toLowerCase()] ?? name;
23
+ /** A label as people read it: `\\Important` and `Important` are one label, and names are decoded. */
24
+ export const labelName = (raw) => {
25
+ const system = raw.startsWith("\\") ? raw.slice(1) : raw;
26
+ return SYSTEM_LABELS[system.toLowerCase()] ?? decodeMailboxName(system);
27
+ };
@@ -0,0 +1,10 @@
1
+ import type { AccountKey, MessageStore } from "../store/index.js";
2
+ export interface MailFolderProgress {
3
+ folder: string;
4
+ since: string;
5
+ done: number;
6
+ total: number;
7
+ updatedAt: string;
8
+ }
9
+ /** How far the sync of each stored folder is, from the points the runs saved; the mailbox is not asked. */
10
+ export declare const mailProgress: (store: MessageStore, key: AccountKey) => Promise<MailFolderProgress[]>;
@@ -0,0 +1,29 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { parsePoint, resumeKey } from "./resume.js";
3
+ /** How far the sync of each stored folder is, from the points the runs saved; the mailbox is not asked. */
4
+ export const mailProgress = async (store, key) => {
5
+ let accountId;
6
+ try {
7
+ accountId = (await store.accounts.storedAccount(key)).id;
8
+ }
9
+ catch (error) {
10
+ if (error instanceof CliError && error.code === "not_found")
11
+ return [];
12
+ throw error;
13
+ }
14
+ const folders = [];
15
+ for (const { kind, externalId: folder } of await store.mail.mailboxes(accountId)) {
16
+ if (kind !== null)
17
+ continue;
18
+ const point = parsePoint((await store.sync.syncState(key, resumeKey(folder)))?.value);
19
+ if (point !== undefined)
20
+ folders.push({
21
+ folder,
22
+ since: point.window.since,
23
+ done: point.window.done,
24
+ total: point.window.total,
25
+ updatedAt: point.updatedAt,
26
+ });
27
+ }
28
+ return folders;
29
+ };
@@ -33,6 +33,8 @@ export interface QuickRead {
33
33
  }[];
34
34
  /** Mail of the folder is left for a later round or run. */
35
35
  capped: boolean;
36
+ /** How many of the open mail `max` left for the next run; an unbudgeted run only. */
37
+ left: number;
36
38
  }
37
39
  /**
38
40
  * One round of a folder whose server gives no cheap message id: UID, flags, date and size of what is not done, then
@@ -7,7 +7,7 @@ export const keyOf = (folder, uid) => `${folder}\0${uid}`;
7
7
  * the raw bytes of the newest of it, whose Message-ID gives the id the full listing would have given.
8
8
  */
9
9
  export const readQuick = async (source, since, folder, searched, done, max, plan, located) => {
10
- const answer = { listing: [], read: [], unreadable: [], capped: false };
10
+ const answer = { listing: [], read: [], unreadable: [], capped: false, left: 0 };
11
11
  let items = plan?.quick.get(folder);
12
12
  if (items === undefined) {
13
13
  // Mail whose location is known is listed for its flags only; it is never read again.
@@ -40,6 +40,8 @@ export const readQuick = async (source, since, folder, searched, done, max, plan
40
40
  if (plan !== undefined)
41
41
  plan.remaining += open.length - slice.length;
42
42
  answer.capped = open.length > slice.length;
43
+ if (plan === undefined)
44
+ answer.left = open.length - slice.length;
43
45
  if (slice.length === 0)
44
46
  return answer;
45
47
  if (plan !== undefined) {
@@ -0,0 +1,9 @@
1
+ import type { QueryAst } from "@wirecat/cli-base/query";
2
+ import type { MessageStore, MailRule as StoredRule } from "../store/index.js";
3
+ import type { MailRule } from "./rules.js";
4
+ /** A stored rule as the sync reads it; `undefined` for one this build cannot read, so it is left to the build that can. */
5
+ export declare const storedMailRule: (stored: StoredRule) => MailRule | undefined;
6
+ /** One account's rules from the store, for `account.rules` of the sync; none when the store has not seen the account. */
7
+ export declare const storedMailRules: (store: MessageStore, account: {
8
+ address: string;
9
+ }, query?: QueryAst) => Promise<MailRule[]>;
@@ -0,0 +1,57 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { PRESET_NAMES } from "./presets.js";
3
+ const ACTION_OF = { full: "keep", envelope: "envelope", skip: "skip" };
4
+ const KINDS = {
5
+ from: "string",
6
+ from_contains: "string",
7
+ domain: "string",
8
+ to: "string",
9
+ list_id: "string",
10
+ subject: "string",
11
+ older_than_days: "number",
12
+ larger_than_bytes: "number",
13
+ has_attachments: "boolean",
14
+ label: "string",
15
+ unread: "boolean",
16
+ bulk: "boolean",
17
+ score_at_least: "number",
18
+ preset: "string",
19
+ };
20
+ /** A stored rule as the sync reads it; `undefined` for one this build cannot read, so it is left to the build that can. */
21
+ export const storedMailRule = (stored) => {
22
+ const action = ACTION_OF[String(stored.actions.store)];
23
+ const entries = Object.entries(stored.conditions);
24
+ const known = entries.every(([key, value]) => key in KINDS &&
25
+ typeof value === KINDS[key] &&
26
+ !(key === "preset" && !PRESET_NAMES.includes(value)));
27
+ if (action === undefined || !known || entries.length === 0)
28
+ return undefined;
29
+ const tags = Array.isArray(stored.actions.tags) ? stored.actions.tags.filter((tag) => typeof tag === "string") : [];
30
+ const skipped = stored.actions.tags_skipped;
31
+ const tagsSkipped = skipped !== null && typeof skipped === "object" && !Array.isArray(skipped)
32
+ ? Object.fromEntries(Object.entries(skipped).filter(([, why]) => typeof why === "string"))
33
+ : {};
34
+ return {
35
+ id: stored.id,
36
+ match: Object.fromEntries(entries),
37
+ action,
38
+ ...(tags.length ? { tags: tags } : {}),
39
+ ...(Object.keys(tagsSkipped).length ? { tags_skipped: tagsSkipped } : {}),
40
+ ...(stored.reason === null ? {} : { reason: stored.reason }),
41
+ ...(stored.protected ? { protected: true } : {}),
42
+ ...(stored.source === "owner" ? {} : { source: stored.source }),
43
+ decided_at: new Date(stored.decidedAt).toISOString(),
44
+ };
45
+ };
46
+ /** One account's rules from the store, for `account.rules` of the sync; none when the store has not seen the account. */
47
+ export const storedMailRules = async (store, account, query) => {
48
+ try {
49
+ const { id } = await store.accounts.storedAccount({ provider: "email", account: account.address.toLowerCase() });
50
+ return (await store.mail.rules.list(id, query)).flatMap((stored) => storedMailRule(stored) ?? []);
51
+ }
52
+ catch (error) {
53
+ if (error instanceof CliError && error.code === "not_found")
54
+ return [];
55
+ throw error;
56
+ }
57
+ };
@@ -36,7 +36,7 @@ export interface MailSyncResult {
36
36
  state?: "partial" | "complete";
37
37
  /** Why a budgeted run stopped early: its request budget, its deadline, or a cancel. */
38
38
  stopped?: BudgetStop;
39
- /** Messages of the window this run did not get to read; a budgeted run only. */
39
+ /** Messages of the window this run did not get to read: left by a budget, or by `max`. */
40
40
  remaining?: number;
41
41
  /** Requests a budgeted run spent. */
42
42
  requests?: number;
package/dist/mail/sync.js CHANGED
@@ -121,7 +121,7 @@ export const syncMail = async (source, store, options) => {
121
121
  deleted: sum((round) => round.deleted),
122
122
  complete,
123
123
  state: complete ? "complete" : "partial",
124
- remaining,
124
+ remaining: remaining + (last.remaining ?? 0),
125
125
  requests: budget.used,
126
126
  ...(plan.stopped === null ? {} : { stopped: plan.stopped }),
127
127
  ...(indexed === undefined ? {} : { indexed }),
@@ -183,6 +183,7 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
183
183
  mboxPoint = point;
184
184
  }
185
185
  let capped = false;
186
+ let overMax = 0;
186
187
  const unreadable = [];
187
188
  /** Raw mail read to learn its id, kept for the save so it is not read twice. */
188
189
  const rawOf = new Map();
@@ -258,6 +259,7 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
258
259
  refreshed.set(id, { ...refreshed.get(id), ...flags });
259
260
  if (round.capped)
260
261
  capped = true;
262
+ overMax += round.left;
261
263
  for (const { item, raw } of round.read) {
262
264
  collected.push(item);
263
265
  rawOf.set(item, raw);
@@ -465,8 +467,10 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
465
467
  upgraded.set(item.messageId, boxes);
466
468
  pending.push(item);
467
469
  }
468
- else
470
+ else {
469
471
  full = true;
472
+ overMax++;
473
+ }
470
474
  continue;
471
475
  }
472
476
  if (known.has(item.messageId)) {
@@ -491,8 +495,10 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
491
495
  }
492
496
  if (pending.length < max)
493
497
  pending.push(item);
494
- else
498
+ else {
495
499
  full = true;
500
+ overMax++;
501
+ }
496
502
  }
497
503
  const inMailboxes = (item) => [
498
504
  ...[...(foundFolders.get(item.messageId) ?? [])].map(mailbox),
@@ -794,6 +800,7 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
794
800
  envelopes,
795
801
  deleted,
796
802
  complete,
803
+ remaining: overMax,
797
804
  ...(indexed === undefined ? {} : { indexed }),
798
805
  coverage,
799
806
  ...(attachments.length ? { attachments } : {}),
@@ -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, PRIORITY_LABELS, type PriorityLabel, priorityLabel, priorityOf, TASK_STATES, type TaskState, } from "../tasks/index.js";
8
+ export { CLOSED_STATES, isClosedState, PRIORITY_LABELS, type PriorityLabel, priorityLabel, priorityOf, priorityOption, 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";
@@ -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, PRIORITY_LABELS, priorityLabel, priorityOf, TASK_STATES, } from "../tasks/index.js";
4
+ export { CLOSED_STATES, isClosedState, PRIORITY_LABELS, priorityLabel, priorityOf, priorityOption, 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";
@@ -55,6 +55,7 @@ export const MANIFEST = [
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
57
  { name: "20261031230945_version-41-task-fields", version: 41, minCompatible: 34 },
58
+ { name: "20261101002523_version-42-task-revision", version: 42, minCompatible: 34 },
58
59
  ];
59
60
  export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
60
61
  const migrations = [];
@@ -859,5 +859,16 @@ export const GENERATED = [
859
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
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
861
  ]
862
+ },
863
+ {
864
+ "name": "20261101002523_version-42-task-revision",
865
+ "statements": [
866
+ "-- A claim renewed, a follow-up time or a close suggestion is an agent at work, not a change a person could overwrite;\n-- bumping on those made the board report a conflict while an agent simply worked.\nDROP TRIGGER tasks_revision_au;",
867
+ "CREATE TRIGGER tasks_revision_au AFTER UPDATE OF title, description, status, priority, priority_score, agent, due_at, project_id, parent_id ON tasks\n WHEN new.revision IS old.revision AND (new.title IS NOT old.title OR new.description IS NOT old.description\n OR new.status IS NOT old.status OR new.priority IS NOT old.priority OR new.priority_score IS NOT old.priority_score\n OR new.agent IS NOT old.agent OR new.due_at IS NOT old.due_at OR new.project_id IS NOT old.project_id\n OR new.parent_id IS NOT old.parent_id) BEGIN\n UPDATE tasks SET revision = old.revision + 1 WHERE id = new.id;\nEND;",
868
+ "CREATE TRIGGER tasks_revision_assignment_ai AFTER INSERT ON task_assignments BEGIN\n UPDATE tasks SET revision = revision + 1 WHERE id = new.task_id;\nEND;",
869
+ "CREATE TRIGGER tasks_revision_assignment_ad AFTER DELETE ON task_assignments BEGIN\n UPDATE tasks SET revision = revision + 1 WHERE id = old.task_id;\nEND;",
870
+ "CREATE TRIGGER tasks_revision_tagging_ai AFTER INSERT ON taggings WHEN new.taggable_type = 'task' BEGIN\n UPDATE tasks SET revision = revision + 1 WHERE id = new.taggable_id;\nEND;",
871
+ "CREATE TRIGGER tasks_revision_tagging_ad AFTER DELETE ON taggings WHEN old.taggable_type = 'task' BEGIN\n UPDATE tasks SET revision = revision + 1 WHERE id = old.taggable_id;\nEND;"
872
+ ]
862
873
  }
863
874
  ];
@@ -239,20 +239,27 @@ export const workTaskOperationsOver = (context) => {
239
239
  throw new CliError("validation_error", "give --before or --after, not both");
240
240
  if (!database.prepare(`SELECT 1 FROM tasks t WHERE t.id = ? AND ${BOARD_SCOPE}`).get(task))
241
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
242
  const neighbour = move.before ?? move.after;
245
243
  if (neighbour === undefined) {
246
- if (column.length > 0 || move.status === undefined)
244
+ const status = move.status ?? row.status;
245
+ if (columnOf(task, status).length > 0 || move.status === undefined)
247
246
  throw new CliError("validation_error", "say where it goes: --before <key> or --after <key>");
248
247
  const from = priorityLabel(row.priority_score == null ? null : Number(row.priority_score));
249
248
  edit(task, { status: move.status }, actor);
250
249
  return { label: { from, to: from, changed: false }, renumbered: [] };
251
250
  }
252
251
  const target = required(neighbour);
252
+ const anchor = database.prepare(`SELECT t.key, t.status FROM tasks t WHERE t.id = ? AND ${BOARD_SCOPE}`).get(target);
253
+ if (target === task)
254
+ throw new CliError("validation_error", "a task moves beside another task, not itself");
255
+ if (!anchor)
256
+ throw new CliError("validation_error", `${neighbour} is not on a project's board`);
257
+ // A card dropped beside one in another column joins that column; a status that names a different one is a mistake.
258
+ const status = anchor.status;
259
+ if (move.status !== undefined && move.status !== status)
260
+ throw new CliError("validation_error", `${String(anchor.key)} is in ${status}, not ${move.status}: pick a task in the ${move.status} column`, { reason: "task.column", anchor: String(anchor.key), anchorStatus: status, requestedStatus: move.status });
261
+ const column = columnOf(task, status);
253
262
  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
263
  const index = move.before === undefined ? at + 1 : at;
257
264
  const above = column[index - 1];
258
265
  const below = column[index];
@@ -285,7 +292,7 @@ export const workTaskOperationsOver = (context) => {
285
292
  renumbered.push(one.key);
286
293
  });
287
294
  }
288
- edit(task, { priority: given, ...(move.status === undefined ? {} : { status: move.status }) }, actor);
295
+ edit(task, { priority: given, status }, actor);
289
296
  const to = priorityLabel(given);
290
297
  return { label: { from, to, changed: from !== to }, renumbered };
291
298
  };
@@ -1,4 +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
+ export { checkedScore, LEGACY_PRIORITY_SCORES, legacyPriority, PRIORITY_LABELS, type PriorityLabel, priorityBand, priorityLabel, priorityOf, priorityOption, } from "./priority.js";
3
3
  export { type CloseOptions, createTaskService, type GroupStats, type NewTask, type TaskService, type TaskServiceOptions, } from "./service.js";
4
4
  export { matches, type TaskFilter, type TaskStore } from "./store.js";
@@ -1,4 +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
+ export { checkedScore, LEGACY_PRIORITY_SCORES, legacyPriority, PRIORITY_LABELS, priorityBand, priorityLabel, priorityOf, priorityOption, } from "./priority.js";
3
3
  export { createTaskService, } from "./service.js";
4
4
  export { matches } from "./store.js";
@@ -16,3 +16,5 @@ export declare const checkedScore: (score: number) => number;
16
16
  * `none` clears it.
17
17
  */
18
18
  export declare const priorityOf: (value: string) => number | null;
19
+ /** `priorityOf` for a command-line option: Commander stores a parser's `null` as an empty string, so `none` stays a word. */
20
+ export declare const priorityOption: (value: string) => number | "none";
@@ -35,3 +35,5 @@ export const priorityOf = (value) => {
35
35
  return LEGACY_PRIORITY_SCORES[Number(word)];
36
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
37
  };
38
+ /** `priorityOf` for a command-line option: Commander stores a parser's `null` as an empty string, so `none` stays a word. */
39
+ export const priorityOption = (value) => priorityOf(value) ?? "none";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.514.0",
3
+ "version": "0.516.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",